diff --git a/lib/main.dart b/lib/main.dart index 4cbbf16..92835bb 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,3 +1,4 @@ +// lib/main.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import 'package:media_kit/media_kit.dart'; @@ -49,10 +50,10 @@ void main() async { debugPrint('❌ MediaKit 初始化失败: $e'); } - // ⭐ 创建 Handler(AudioService 稍后初始化) + // ⭐ 创建 Handler _audioHandler = AudioPlayerHandler(); - // ⭐ 注册切歌回调:当歌曲切换时,立即更新通知 + // ⭐ 注册切歌回调 AudioService().setOnSongChanged((song) { debugPrint( '📢 [main] song.artwork is ${song.artwork != null ? 'not null' : 'null'}'); @@ -60,7 +61,7 @@ void main() async { id: song.id, title: song.title, artist: song.artist, - artwork: song.artwork, // ⭐ 传递封面图 + artwork: song.artwork, ); }); @@ -69,7 +70,6 @@ void main() async { providers: [ ChangeNotifierProvider(create: (_) => AudioService()), ChangeNotifierProvider(create: (_) => AppLifecycleService()), - // ⭐ 生命周期管理器 ChangeNotifierProvider( create: (_) => lifecycleManager, ), @@ -108,9 +108,18 @@ class RouteManager extends ChangeNotifier { // ════════════════════════════════════════════════════════════ // 导航观察者(监听路由变化) // ════════════════════════════════════════════════════════════ +// lib/main.dart + class MiniPlayerNavigatorObserver extends NavigatorObserver { void _updateRoute(Route? route) { - final name = route?.settings.name ?? '/'; + if (route == null) return; + + // ⭐ 忽略 PopupRoute(菜单、对话框等) + if (route is PopupRoute) { + return; + } + + final name = route.settings.name ?? '/'; RouteManager().updateRoute(name); } @@ -121,12 +130,21 @@ class MiniPlayerNavigatorObserver extends NavigatorObserver { @override void didPop(Route route, Route? previousRoute) { - _updateRoute(previousRoute); + // ⭐ 返回时,使用上一个路由(previousRoute) + if (previousRoute != null && previousRoute is! PopupRoute) { + final name = previousRoute.settings.name ?? '/'; + RouteManager().updateRoute(name); + } else if (previousRoute == null) { + RouteManager().updateRoute('/'); + } } @override void didReplace({Route? newRoute, Route? oldRoute}) { - _updateRoute(newRoute); + if (newRoute != null && newRoute is! PopupRoute) { + final name = newRoute.settings.name ?? '/'; + RouteManager().updateRoute(name); + } } } @@ -141,49 +159,31 @@ class QTPlayerApp extends StatefulWidget { } class _QTPlayerAppState extends State { - // ⭐ build 计数器(诊断用) static int _buildCount = 0; - bool _isInitializing = false; - - // ⭐ 保存生命周期监听器引用 void Function(AppLifecycleStatus)? _lifecycleListener; - // ============================================================ - // 生命周期 - // ============================================================ @override void initState() { super.initState(); - debugPrint('⏱️ T1.5 initState: ${stopwatch.elapsedMilliseconds}ms'); WidgetsBinding.instance.addPostFrameCallback((_) { debugPrint('⏱️ T2 首帧回调: ${stopwatch.elapsedMilliseconds}ms'); - - // ⭐ 1. 初始化生命周期管理器 lifecycleManager.init(); - - // ⭐ 2. 绑定生命周期到播放器 _bindLifecycleToPlayback(); - - // ⭐ 3. 后台初始化 _startBackgroundInitialization(); }); } @override void dispose() { - // ⭐ 移除生命周期监听器 if (_lifecycleListener != null) { lifecycleManager.removeListenerFromManager(_lifecycleListener!); } super.dispose(); } - // ============================================================ - // 生命周期 → 播放器绑定 - // ============================================================ void _bindLifecycleToPlayback() { if (_lifecycleListener != null) { lifecycleManager.removeListenerFromManager(_lifecycleListener!); @@ -195,15 +195,12 @@ class _QTPlayerAppState extends State { case AppLifecycleStatus.inactive: debugPrint('🎵 App 进入后台,播放继续'); break; - case AppLifecycleStatus.resumed: debugPrint('🎵 App 回到前台'); break; - case AppLifecycleStatus.backgroundIdle: debugPrint('🎵 长后台唤醒,检查播放状态'); break; - default: break; } @@ -212,9 +209,6 @@ class _QTPlayerAppState extends State { lifecycleManager.addListenerToManager(_lifecycleListener!); } - // ============================================================ - // 后台初始化(精细时间切片) - // ============================================================ Future _startBackgroundInitialization() async { if (_isInitializing) return; _isInitializing = true; @@ -222,7 +216,6 @@ class _QTPlayerAppState extends State { final tStart = DateTime.now(); debugPrint('⏱️ T3 后台初始化开始'); - // ----- 1. PlaybackService ----- final t1 = DateTime.now(); try { PlaybackService().init(); @@ -231,7 +224,6 @@ class _QTPlayerAppState extends State { debugPrint('❌ PlaybackService 失败: $e'); } - // ----- 2. WebDAV(只加载凭证) ----- final t2 = DateTime.now(); try { final hasCred = await WebDAVService.instance.loadCredentials(); @@ -240,10 +232,8 @@ class _QTPlayerAppState extends State { debugPrint('❌ WebDAV 凭证加载失败: $e'); } - // ----- 3. ⭐ AudioService(后台播放核心) ----- await _initAudioService(); - // ----- 4. 通知权限 ----- final t3 = DateTime.now(); try { if (await Permission.notification.isDenied) { @@ -260,9 +250,6 @@ class _QTPlayerAppState extends State { debugPrint('⏱️ T4 全部初始化完成: ${tEnd.difference(tStart).inMilliseconds}ms'); } - // ════════════════════════════════════════════════════════════ - // ⭐ AudioService 初始化(后台播放核心) - // ════════════════════════════════════════════════════════════ Future _initAudioService() async { try { await audio_service.AudioService.init( @@ -280,42 +267,61 @@ class _QTPlayerAppState extends State { } } - // ════════════════════════════════════════════════════════════ - // 辅助方法 - // ════════════════════════════════════════════════════════════ String _elapsedMs(DateTime start) { return '${DateTime.now().difference(start).inMilliseconds}'; } - // ════════════════════════════════════════════════════════════ - // Build - // ════════════════════════════════════════════════════════════ + // ⭐ 路由生成(播放页滑入动画,速度 200ms) + // lib/main.dart - _onGenerateRoute 方法 + Route _onGenerateRoute(RouteSettings settings) { + if (settings.name == '/player') { + return PageRouteBuilder( + settings: settings, + pageBuilder: (context, animation, secondaryAnimation) { + return const PlayerPage(); + }, + transitionsBuilder: (context, animation, secondaryAnimation, child) { + // ⭐ 应用贝塞尔曲线 + final curvedAnimation = CurvedAnimation( + parent: animation, + curve: Curves.easeOutCubic, // 平滑减速 + // curve: Curves.easeOutBack, // 轻微回弹,更灵动 + // curve: Curves.fastOutSlowIn, // Material 风格 + ); + 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: 300), + ); + } + + return MaterialPageRoute( + settings: settings, + builder: (context) { + switch (settings.name) { + case '/': + return const HomePage(); + case '/playlist': + return const PlaylistPage(); + default: + return const HomePage(); + } + }, + ); + } + @override Widget build(BuildContext context) { _buildCount++; debugPrint( '🎨 [QTPlayerApp] build #$_buildCount ${stopwatch.elapsedMilliseconds}ms'); - // 监听歌曲变化,更新通知栏 - // main.dart 中 build 方法里的这部分 - final audioService = context.watch(); - final handler = context.read(); - final song = audioService.currentSong; - - WidgetsBinding.instance.addPostFrameCallback((_) { - if (song != null) { - // ⭐ song 不为空时才调用 - handler.updateNotification( - id: song.id, - title: song.title, - artist: song.artist, - ); - } else { - // 清空通知 - handler.mediaItem.add(null); - } - }); - return MaterialApp( title: '清听', theme: ThemeData( @@ -331,11 +337,7 @@ class _QTPlayerAppState extends State { ), navigatorKey: navigatorKey, navigatorObservers: [MiniPlayerNavigatorObserver()], - routes: { - '/': (context) => const HomePage(), - '/player': (context) => const PlayerPage(), - '/playlist': (context) => const PlaylistPage(), - }, + onGenerateRoute: _onGenerateRoute, builder: (context, child) { return Stack( fit: StackFit.expand, @@ -344,12 +346,36 @@ class _QTPlayerAppState extends State { AnimatedBuilder( animation: RouteManager(), builder: (context, _) { - if (!RouteManager().showMiniPlayer) { - return const SizedBox.shrink(); - } - return const Align( - alignment: Alignment.bottomCenter, - child: GlobalMiniPlayer(), + final routeManager = RouteManager(); + final shouldShow = routeManager.currentRoute != '/player' && + routeManager.currentRoute != '/playlist'; + + return AnimatedSwitcher( + duration: const Duration(milliseconds: 300), + switchInCurve: Curves.easeOut, + switchOutCurve: Curves.easeIn, + transitionBuilder: (child, animation) { + // ⭐ 淡入淡出 + 轻微上滑 + return FadeTransition( + opacity: animation, + child: SlideTransition( + position: Tween( + begin: const Offset(0, 0.1), + end: Offset.zero, + ).animate(animation), + child: child, + ), + ); + }, + child: shouldShow + ? const Align( + key: ValueKey('mini_player_visible'), + alignment: Alignment.bottomCenter, + child: GlobalMiniPlayer(), + ) + : const SizedBox.shrink( + key: ValueKey('mini_player_hidden'), + ), ); }, ), diff --git a/lib/services/audio_service.dart b/lib/services/audio_service.dart index 121e784..d4e442b 100644 --- a/lib/services/audio_service.dart +++ b/lib/services/audio_service.dart @@ -3,6 +3,7 @@ import 'dart:async'; import 'dart:io'; import 'dart:typed_data'; import 'package:flutter/material.dart'; +import 'package:media_kit/media_kit.dart'; import 'package:path_provider/path_provider.dart'; import 'playback_service.dart'; import '../metadata/metadata_service.dart'; @@ -60,6 +61,9 @@ class AudioService extends ChangeNotifier { bool _isUserSeeking = false; + // ⭐ 播放代数:每次切歌递增,用于校验异步任务是否过期 + int _playbackGeneration = 0; + // ---- Getter ---- Song? get currentSong => _currentSong; bool get isPlaying => _isPlaying; @@ -144,6 +148,9 @@ class AudioService extends ChangeNotifier { return; } + _playbackGeneration++; + final generation = _playbackGeneration; + final song = _queue[_currentIndex]; _currentSong = song; @@ -158,7 +165,11 @@ class AudioService extends ChangeNotifier { _syncPlayerStateDelayed(); - _loadMetadataForCurrentSong(); + // ⭐ 立即推送基本信息(无 artwork) + _onSongChanged?.call(song); + + // ⭐ 异步加载完整 metadata(含 artwork) + _loadMetadataForCurrentSong(generation); } String _generateSongKey(String url, int fileSize, int modifiedTime) { @@ -166,10 +177,21 @@ class AudioService extends ChangeNotifier { return raw.hashCode.toString(); } - Future _loadMetadataForCurrentSong() async { + // ⭐ 核心:带 generation 校验的 metadata 加载 + // lib/services/audio_service.dart + + Future _loadMetadataForCurrentSong(int generation) async { if (_currentIndex < 0 || _currentIndex >= _queue.length) return; - final song = _queue[_currentIndex]; - if (song.url == null || song.url!.isEmpty) return; + + final currentIndex = _currentIndex; + final song = _queue[currentIndex]; + final songId = song.id; + + if (song.url == null || song.url!.isEmpty) { + // 没有 URL,直接推送 fallback + _onSongChanged?.call(song); + return; + } try { final metadata = await MetadataService().getMetadata( @@ -178,23 +200,41 @@ class AudioService extends ChangeNotifier { fileId: song.id, ); - if (metadata.isNotEmpty) { - final updatedSong = Song( - id: song.id, - title: metadata.title.isNotEmpty ? metadata.title : song.title, - artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, - url: song.url, - artwork: metadata.artwork, // ✅ 已有 - ); - _queue[_currentIndex] = updatedSong; - _currentSong = updatedSong; - notifyListeners(); - - // ⭐ 传递完整的 updatedSong(包含 artwork) - _onSongChanged?.call(updatedSong); + // generation 校验 + if (_playbackGeneration != generation) { + debugPrint('⚠️ [AudioService] metadata stale (generation), ignoring'); + return; } + if (_currentIndex != currentIndex) { + debugPrint( + '⚠️ [AudioService] metadata stale (index changed), ignoring'); + return; + } + if (currentIndex >= _queue.length || _queue[currentIndex].id != songId) { + debugPrint('⚠️ [AudioService] metadata stale (song changed), ignoring'); + return; + } + + // 更新 Song + final updatedSong = Song( + id: song.id, + title: metadata.title.isNotEmpty ? metadata.title : song.title, + artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist, + url: song.url, + artwork: metadata.artwork, + ); + _queue[_currentIndex] = updatedSong; + _currentSong = updatedSong; + notifyListeners(); + + // ⭐ 一次性推送完整数据(含 artwork) + _onSongChanged?.call(updatedSong); + debugPrint( + '✅ [AudioService] metadata updated: ${updatedSong.title} - ${updatedSong.artist} (generation $generation)'); } catch (e) { - debugPrint('⚠️ [AudioService] metadata load failed: $e'); + debugPrint('⚠️ [AudioService] metadata load failed: $e, using fallback'); + // 异常时推送 fallback(无 artwork) + _onSongChanged?.call(song); } } @@ -207,10 +247,8 @@ class AudioService extends ChangeNotifier { debugPrint('🗑️ [AudioService] clearing cache for: ${song.title}'); - // 1. 获取当前索引 final currentIndex = _currentIndex; - // 2. 尝试获取文件信息生成 song_key String songKey; try { final provider = MetadataService().getProviderForUrl(url); @@ -226,17 +264,14 @@ class AudioService extends ChangeNotifier { songKey = url.hashCode.toString(); } - // 3. 清除 SQLite 记录 final db = SongDatabase(); await db.deleteSong(songKey); await db.deleteCache(songKey); debugPrint('🗑️ [AudioService] SQLite records deleted: $songKey'); - // 4. 删除封面图 await ArtworkHelper.deleteArtwork(songKey); debugPrint('🗑️ [AudioService] artwork deleted'); - // 5. 删除 metadata 临时缓存文件 try { final cacheDir = await getTemporaryDirectory(); final cachePath = '${cacheDir.path}/metadata_${url.hashCode}.tmp'; @@ -249,14 +284,11 @@ class AudioService extends ChangeNotifier { // 忽略 } - // 6. 清除内存缓存 await MetadataService().clearCache(song.id); debugPrint('🗑️ [AudioService] memory cache cleared'); - // 7. 停止并重新播放 if (currentIndex >= 0 && currentIndex < _queue.length) { stopPlay(); - // 确保重新播放同一首歌 _playCurrent(); debugPrint('🔄 [AudioService] song reloaded'); } diff --git a/lib/widgets/global_mini_player.dart b/lib/widgets/global_mini_player.dart index 86fad59..8f99c56 100644 --- a/lib/widgets/global_mini_player.dart +++ b/lib/widgets/global_mini_player.dart @@ -9,16 +9,19 @@ class GlobalMiniPlayer extends StatelessWidget { @override Widget build(BuildContext context) { - // ⭐ 使用 Selector 监听完整的 Song 对象(包括 artwork) final song = context.select((s) => s.currentSong); final isPlaying = context.select((s) => s.isPlaying); - final bottomPadding = MediaQuery.of(context).padding.bottom; + // 如果没有歌曲,不显示 + if (song == null) { + return const SizedBox.shrink(); + } + return Stack( clipBehavior: Clip.none, children: [ - // ⭐ 背景高度从 56 改为 60 + // 背景条 Positioned( left: 0, right: 0, @@ -28,6 +31,7 @@ class GlobalMiniPlayer extends StatelessWidget { color: const Color(0xFF1A1F1E), ), ), + // 主内容 Positioned( left: 0, right: 0, @@ -37,110 +41,101 @@ class GlobalMiniPlayer extends StatelessWidget { bottom: Radius.circular(16), ), child: Container( - height: 60, // ⭐ 从 56 改为 60 + height: 60, color: const Color(0xFF1A1F1E), - child: Material( - color: Colors.transparent, - child: InkWell( - onTap: () { - if (song != null) { + child: ClipRRect( + // ⭐ 修复涟漪圆角问题 + borderRadius: BorderRadius.circular(12), + child: Material( + color: Colors.transparent, + child: InkWell( + onTap: () { navigatorKey.currentState?.pushNamed('/player'); - } - }, - highlightColor: Colors.white.withOpacity(0.05), - splashColor: Colors.white.withOpacity(0.1), - child: Row( - children: [ - const SizedBox(width: 12), - // ⭐ 封面图容器尺寸从 40 改为 44(适配 60px 高度) - Container( - width: 44, - height: 44, - decoration: BoxDecoration( - borderRadius: BorderRadius.circular(4), - color: song != null - ? const Color(0xFF2A3332) - : Colors.grey[800], - // ⭐ 如果 song.artwork 存在,显示封面图 - image: song?.artwork != null - ? DecorationImage( - image: MemoryImage(song!.artwork!), - fit: BoxFit.cover, + }, + highlightColor: Colors.white.withOpacity(0.05), + splashColor: Colors.white.withOpacity(0.1), + borderRadius: BorderRadius.circular(12), + child: Row( + children: [ + const SizedBox(width: 12), + // 封面图 + Container( + width: 44, + height: 44, + decoration: BoxDecoration( + borderRadius: BorderRadius.circular(4), + color: const Color(0xFF2A3332), + image: song.artwork != null + ? DecorationImage( + image: MemoryImage(song.artwork!), + fit: BoxFit.cover, + ) + : null, + ), + child: song.artwork == null + ? const Icon( + Icons.music_note, + color: Colors.white38, + size: 20, ) : null, ), - child: song?.artwork == null - ? Icon( - song != null - ? Icons.music_note - : Icons.music_off, - color: song != null - ? Colors.white38 - : Colors.grey[600], - size: 20, - ) - : null, - ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - song != null ? song.title : '清听', - style: const TextStyle( - fontSize: 14, - fontWeight: FontWeight.w500, - color: Colors.white, - decoration: TextDecoration.none, + const SizedBox(width: 12), + // 标题 + 艺术家 + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + song.title, + style: const TextStyle( + fontSize: 14, + fontWeight: FontWeight.w500, + color: Colors.white, + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - song != null ? song.artist : '未播放', - style: TextStyle( - fontSize: 12, - color: song != null - ? Colors.grey[400] - : Colors.grey[600], - decoration: TextDecoration.none, + Text( + song.artist, + style: TextStyle( + fontSize: 12, + color: Colors.grey[400], + decoration: TextDecoration.none, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), - ), - IconButton( - icon: Icon( - song != null && isPlaying - ? Icons.pause - : Icons.play_arrow, - color: song != null ? Colors.white : Colors.grey[600], - size: 24, - ), - onPressed: () { - if (song != null) { + // 播放/暂停按钮 + IconButton( + icon: Icon( + isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + size: 24, + ), + onPressed: () { context.read().togglePlay(); - } - }, - ), - IconButton( - icon: const Icon( - Icons.playlist_play_outlined, - color: Colors.grey, - size: 24, + }, ), - onPressed: () { - if (song != null) { + // 播放列表按钮 + IconButton( + icon: const Icon( + Icons.playlist_play_outlined, + color: Colors.grey, + size: 24, + ), + onPressed: () { navigatorKey.currentState?.pushNamed('/playlist'); - } - }, - ), - const SizedBox(width: 4), - ], + }, + ), + const SizedBox(width: 4), + ], + ), ), ), ),