diff --git a/lib/pages/player_page.dart b/lib/pages/player_page.dart index 0d051b7..6c09ad1 100644 --- a/lib/pages/player_page.dart +++ b/lib/pages/player_page.dart @@ -1,8 +1,10 @@ // lib/pages/player_page.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; -import '../services/audio_service.dart'; + import '../base/base_state.dart'; +import '../database/song_database.dart'; +import '../services/audio_service.dart'; class PlayerPage extends StatefulWidget { final VoidCallback? onClose; @@ -21,6 +23,11 @@ class PlayerPage extends StatefulWidget { class _PlayerPageState extends BaseState { double? _dragProgress; + bool _isFavorite = false; + String _album = ''; + String _folder = ''; + String? _metaSongId; + String _formatDuration(Duration d) { if (d.inMilliseconds < 0) return '00:00'; final minutes = d.inMinutes; @@ -47,14 +54,12 @@ class _PlayerPageState extends BaseState { @override void initState() { super.initState(); - // ⭐ 页面加载时主动拉取状态(多次重试) _syncStateWithRetry(); } @override void didChangeDependencies() { super.didChangeDependencies(); - // ⭐ 依赖变化时(如从后台返回)重新同步 WidgetsBinding.instance.addPostFrameCallback((_) { if (mounted) { context.read().syncPlayerStateNow(); @@ -64,11 +69,8 @@ class _PlayerPageState extends BaseState { void _syncStateWithRetry() { final service = context.read(); - - // 立即同步 service.syncPlayerStateNow(); - // 延迟重试 const delays = [200, 500, 800, 1200]; for (final delay in delays) { Future.delayed(Duration(milliseconds: delay), () { @@ -79,6 +81,57 @@ class _PlayerPageState extends BaseState { } } + /// 元数据(专辑、收藏状态)只在切歌时查一次 + Future _loadMeta(Song song) async { + if (_metaSongId == song.id) return; + _metaSongId = song.id; + + final db = SongDatabase(); + String album = ''; + bool favorite = false; + try { + final row = await db.getSongByPath(song.id); + album = (row?['album'] as String?)?.trim() ?? ''; + favorite = await db.isFavorite(song.id); + } catch (_) { + // 元数据只是装饰,失败忽略 + } + + // 上级目录名,作为没有专辑信息时的补充 + String folder = ''; + final segments = + song.id.split('/').where((s) => s.isNotEmpty).toList(); + if (segments.length >= 2) { + try { + folder = Uri.decodeComponent(segments[segments.length - 2]); + } catch (_) { + folder = segments[segments.length - 2]; + } + } + + if (!mounted) return; + setState(() { + _album = album; + _folder = folder; + _isFavorite = favorite; + }); + } + + Future _toggleFavorite(Song song) async { + try { + await SongDatabase().toggleFavorite(song.id); + final favorite = await SongDatabase().isFavorite(song.id); + if (!mounted) return; + setState(() => _isFavorite = favorite); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar(content: Text('收藏失败: $e')), + ); + } + } + } + @override Widget build(BuildContext context) { final service = context.watch(); @@ -110,6 +163,7 @@ class _PlayerPageState extends BaseState { ); } + _loadMeta(song); final isPlaying = service.isPlaying; return Scaffold( @@ -118,25 +172,15 @@ class _PlayerPageState extends BaseState { backgroundColor: Colors.transparent, elevation: 0, leading: IconButton( - icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white), + icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70), onPressed: _closePage, ), - title: const Text( - '正在播放', - style: TextStyle( - color: Colors.white, - fontSize: 16, - fontWeight: FontWeight.w400, - ), - ), - centerTitle: true, actions: [ PopupMenuButton( icon: const Icon(Icons.more_vert, color: Colors.white54), onSelected: (value) async { if (value == 'clear_cache') { - final service = context.read(); - await service.clearCurrentSongCache(); + await context.read().clearCurrentSongCache(); if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( @@ -145,9 +189,33 @@ class _PlayerPageState extends BaseState { ), ); } + } else if (value == 'mode') { + context.read().togglePlayMode(); + } else if (value == 'playlist') { + _openPlaylist(); } }, itemBuilder: (context) => [ + PopupMenuItem( + value: 'mode', + child: Row( + children: [ + Icon(service.playModeIcon, color: const Color(0xFFB8D4D0)), + const SizedBox(width: 12), + const Text('切换播放模式'), + ], + ), + ), + const PopupMenuItem( + value: 'playlist', + child: Row( + children: [ + Icon(Icons.queue_music, color: Color(0xFFB8D4D0)), + SizedBox(width: 12), + Text('播放队列'), + ], + ), + ), const PopupMenuItem( value: 'clear_cache', child: Row( @@ -162,70 +230,93 @@ class _PlayerPageState extends BaseState { ), ], ), - body: Padding( - padding: const EdgeInsets.symmetric(horizontal: 24), - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Container( - width: 240, - height: 240, - decoration: BoxDecoration( - shape: BoxShape.circle, - color: const Color(0xFF2A3332), - boxShadow: [ - BoxShadow( - color: const Color(0xFF7C9A9E).withOpacity(0.15), - blurRadius: 60, - spreadRadius: 10, - ), - ], - image: song.artwork != null - ? DecorationImage( - image: MemoryImage(song.artwork!), - fit: BoxFit.cover, - ) - : null, + body: SafeArea( + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 24), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // ---- 封面 ---- + AspectRatio( + aspectRatio: 1, + child: ClipRRect( + borderRadius: BorderRadius.circular(24), + child: song.artwork != null + ? Image.memory( + song.artwork!, + fit: BoxFit.cover, + gaplessPlayback: true, + ) + : Container( + color: const Color(0xFF2A3332), + child: const Icon( + Icons.music_note, + size: 72, + color: Colors.white24, + ), + ), + ), ), - child: song.artwork == null - ? const Icon(Icons.music_note, - size: 64, color: Colors.white24) - : null, - ), - const SizedBox(height: 48), - Column( - crossAxisAlignment: CrossAxisAlignment.center, - children: [ - Text( - song.title, - style: const TextStyle( - fontSize: 20, - fontWeight: FontWeight.w600, - color: Colors.white, - ), - textAlign: TextAlign.center, - maxLines: 2, - overflow: TextOverflow.ellipsis, + const SizedBox(height: 28), + + // ---- 标题 / 艺术家 ---- + Text( + song.title, + style: const TextStyle( + fontSize: 24, + fontWeight: FontWeight.w600, + color: Colors.white, + height: 1.25, ), - const SizedBox(height: 8), - Text( - song.artist, - style: TextStyle( - fontSize: 15, - color: Colors.grey[400], + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + const SizedBox(height: 6), + Text( + song.artist, + style: TextStyle(fontSize: 14, color: Colors.grey[400]), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + + // ---- 信息区(专辑 / 目录)---- + if (_album.isNotEmpty || _folder.isNotEmpty) ...[ + const SizedBox(height: 14), + if (_album.isNotEmpty) + Text( + _album, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + height: 1.5, + ), + maxLines: 2, + overflow: TextOverflow.ellipsis, + ), + if (_folder.isNotEmpty) + Text( + '($_folder)', + style: TextStyle( + fontSize: 12, + color: Colors.grey[600], + height: 1.5, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - textAlign: TextAlign.center, - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), ], - ), - const SizedBox(height: 40), - _buildProgressSection(service), - const SizedBox(height: 32), - _buildControlButtons(service, isPlaying), - const SizedBox(height: 16), - ], + + const Spacer(), + + // ---- 进度 ---- + _buildProgressSection(service), + const SizedBox(height: 18), + + // ---- 控制 ---- + _buildControlButtons(service, song, isPlaying), + const SizedBox(height: 20), + ], + ), ), ), ); @@ -238,7 +329,6 @@ class _PlayerPageState extends BaseState { return ValueListenableBuilder( valueListenable: service.durationNotifier, builder: (context, duration, _) { - // ⭐ 如果 duration 为 0,显示加载指示器 if (duration.inMilliseconds == 0) { return const Padding( padding: EdgeInsets.symmetric(vertical: 8), @@ -249,115 +339,105 @@ class _PlayerPageState extends BaseState { ); } - final progress = duration.inMilliseconds > 0 - ? position.inMilliseconds / duration.inMilliseconds - : 0.0; - - final bufferProgress = duration.inMilliseconds > 0 - ? (service.bufferedNotifier.value.inMilliseconds / - duration.inMilliseconds) - .clamp(0.0, 1.0) - : 0.0; - + final progress = position.inMilliseconds / duration.inMilliseconds; + final bufferProgress = (service + .bufferedNotifier.value.inMilliseconds / + duration.inMilliseconds) + .clamp(0.0, 1.0); final displayProgress = _dragProgress ?? progress; - return Column( + return Row( children: [ - SizedBox( - height: 20, - child: LayoutBuilder( - builder: (context, constraints) { - final width = constraints.maxWidth; - final bufferWidth = - width * bufferProgress.clamp(0.0, 1.0); - final progressWidth = - width * displayProgress.clamp(0.0, 1.0); - - return Stack( - alignment: Alignment.centerLeft, - children: [ - Container( - width: width, - height: 4, - decoration: BoxDecoration( - color: Colors.grey[800], - borderRadius: BorderRadius.circular(2), - ), + Text( + _formatDuration( + _dragProgress != null + ? Duration( + milliseconds: + (_dragProgress! * duration.inMilliseconds) + .round(), + ) + : position, + ), + style: TextStyle(fontSize: 12, color: Colors.grey[500]), + ), + const SizedBox(width: 10), + Expanded( + child: SizedBox( + height: 24, + child: Stack( + alignment: Alignment.centerLeft, + children: [ + Container( + height: 3, + decoration: BoxDecoration( + color: Colors.grey[800], + borderRadius: BorderRadius.circular(2), ), - Container( - width: bufferWidth, - height: 4, + ), + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: bufferProgress.clamp(0.0, 1.0), + child: Container( + height: 3, decoration: BoxDecoration( color: const Color(0xFF4A7A7A), borderRadius: BorderRadius.circular(2), ), ), - Container( - width: progressWidth, - height: 4, + ), + FractionallySizedBox( + alignment: Alignment.centerLeft, + widthFactor: displayProgress.clamp(0.0, 1.0), + child: Container( + height: 3, decoration: BoxDecoration( color: const Color(0xFFB8D4D0), borderRadius: BorderRadius.circular(2), ), ), - Positioned.fill( - child: SliderTheme( - data: SliderTheme.of(context).copyWith( - trackHeight: 0, - activeTrackColor: Colors.transparent, - inactiveTrackColor: Colors.transparent, - thumbColor: Colors.transparent, - overlayColor: Colors.transparent, - thumbShape: const RoundSliderThumbShape( - enabledThumbRadius: 0, - ), + ), + Positioned.fill( + child: SliderTheme( + data: SliderTheme.of(context).copyWith( + trackHeight: 0, + activeTrackColor: Colors.transparent, + inactiveTrackColor: Colors.transparent, + thumbColor: const Color(0xFFB8D4D0), + overlayColor: const Color(0x33B8D4D0), + thumbShape: const RoundSliderThumbShape( + enabledThumbRadius: 6, ), - child: Slider( - value: displayProgress.clamp(0.0, 1.0), - min: 0.0, - max: 1.0, - onChanged: (value) { - setState(() { - _dragProgress = value; - }); - }, - onChangeEnd: (value) { - final newPosition = Duration( - milliseconds: - (value * duration.inMilliseconds) - .round(), - ); - service.seekTo(newPosition); - setState(() { - _dragProgress = null; - }); - }, + overlayShape: const RoundSliderOverlayShape( + overlayRadius: 14, ), ), + child: Slider( + value: displayProgress.clamp(0.0, 1.0), + min: 0.0, + max: 1.0, + onChanged: (value) { + setState(() => _dragProgress = value); + }, + onChangeEnd: (value) { + final newPosition = Duration( + milliseconds: + (value * duration.inMilliseconds) + .round(), + ); + service.seekTo(newPosition); + setState(() => _dragProgress = null); + }, + ), ), - ], - ); - }, + ), + ], + ), ), ), - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Text( - _formatDuration(position), - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), - ), - Text( - _formatDuration(duration), - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), - ), - ], + const SizedBox(width: 10), + Text( + _formatDuration(duration), + style: TextStyle(fontSize: 12, color: Colors.grey[500]), ), ], ); @@ -367,61 +447,58 @@ class _PlayerPageState extends BaseState { ); } - Widget _buildControlButtons(AudioService service, bool isPlaying) { + Widget _buildControlButtons( + AudioService service, Song song, bool isPlaying) { return Row( - mainAxisAlignment: MainAxisAlignment.center, + mainAxisAlignment: MainAxisAlignment.spaceBetween, children: [ - IconButton( - onPressed: service.togglePlayMode, - icon: Icon( - service.playModeIcon, - color: const Color(0xFFB8D4D0), - size: 22, - ), - padding: const EdgeInsets.all(12), - ), - const SizedBox(width: 4), IconButton( onPressed: service.hasQueue ? service.previous : null, - icon: const Icon(Icons.skip_previous, size: 28), - color: service.hasQueue ? Colors.white60 : Colors.grey[600], - padding: const EdgeInsets.all(12), + icon: const Icon(Icons.skip_previous_rounded, size: 34), + color: service.hasQueue ? Colors.white70 : Colors.grey[700], ), - const SizedBox(width: 20), Container( - width: 56, - height: 56, - decoration: BoxDecoration( - color: const Color(0xFFB8D4D0), + width: 68, + height: 68, + decoration: const BoxDecoration( + color: Color(0xFFB8D4D0), shape: BoxShape.circle, ), child: IconButton( icon: Icon( - isPlaying ? Icons.pause : Icons.play_arrow, + isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded, color: Colors.black87, - size: 28, + size: 36, ), padding: EdgeInsets.zero, onPressed: service.togglePlay, ), ), - const SizedBox(width: 20), IconButton( onPressed: service.hasQueue ? service.next : null, - icon: const Icon(Icons.skip_next, size: 28), - color: service.hasQueue ? Colors.white60 : Colors.grey[600], - padding: const EdgeInsets.all(12), + icon: const Icon(Icons.skip_next_rounded, size: 34), + color: service.hasQueue ? Colors.white70 : Colors.grey[700], + ), + IconButton( + onPressed: () => _toggleFavorite(song), + icon: Icon( + _isFavorite ? Icons.favorite : Icons.favorite_border, + color: _isFavorite + ? const Color(0xFFE05C5C) + : Colors.white54, + size: 26, + ), + tooltip: '收藏', ), IconButton( onPressed: _openPlaylist, icon: const Icon( - Icons.playlist_play_outlined, + Icons.queue_music_rounded, color: Color(0xFFB8D4D0), size: 26, ), - padding: const EdgeInsets.all(8), + tooltip: '播放队列', ), - const SizedBox(width: 4), ], ); }