正在播放的自滑动刹车动画动作优化完成,同时调整播放页进入动作
This commit is contained in:
+33
-23
@@ -19,17 +19,11 @@ import 'widgets/global_mini_player.dart';
|
||||
final GlobalKey<NavigatorState> navigatorKey = GlobalKey<NavigatorState>();
|
||||
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<QTPlayerApp> {
|
||||
return '${DateTime.now().difference(start).inMilliseconds}';
|
||||
}
|
||||
|
||||
// ⭐ 路由生成(播放页滑入动画,速度 200ms)
|
||||
// lib/main.dart - _onGenerateRoute 方法
|
||||
// ════════════════════════════════════════════════════════════
|
||||
// ⭐ 路由生成(播放页 & 播放列表页底部滑入)
|
||||
// ════════════════════════════════════════════════════════════
|
||||
Route<dynamic> _onGenerateRoute(RouteSettings settings) {
|
||||
// ⭐ 播放页:底部滑入
|
||||
if (settings.name == '/player') {
|
||||
return PageRouteBuilder(
|
||||
settings: settings,
|
||||
@@ -285,12 +275,9 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
||||
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<QTPlayerApp> {
|
||||
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<QTPlayerApp> {
|
||||
switchInCurve: Curves.easeOut,
|
||||
switchOutCurve: Curves.easeIn,
|
||||
transitionBuilder: (child, animation) {
|
||||
// ⭐ 淡入淡出 + 轻微上滑
|
||||
return FadeTransition(
|
||||
opacity: animation,
|
||||
child: SlideTransition(
|
||||
|
||||
@@ -14,12 +14,17 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
||||
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<PlaylistPage> {
|
||||
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;
|
||||
|
||||
@@ -46,42 +62,59 @@ class _PlaylistPageState extends State<PlaylistPage> {
|
||||
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<PlaylistPage> {
|
||||
|
||||
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<PlaylistPage> {
|
||||
: 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];
|
||||
|
||||
Reference in New Issue
Block a user