feat(ui): 播放页改为大封面 + 左对齐信息 + 底部控制骨架

按设计稿重排(背景色保持 #0E1211 不变):
- 接近全宽的圆角封面(1:1,24 圆角)
- 标题/艺术家左对齐,下面补专辑名与所在目录
- 进度条改为"时间 + 滑块 + 时间"同一行,滑块加可见圆点
- 控制行:上一首 / 播放(68 圆) / 下一首 / 收藏 / 播放队列
- 收藏按钮接 favorites 表,状态随当前歌刷新
- 更多菜单:切换播放模式、播放队列、清除缓存
This commit is contained in:
2026-10-31 22:32:10 +08:00
parent 888ea3fd54
commit 1604f296c8
+274 -197
View File
@@ -1,8 +1,10 @@
// lib/pages/player_page.dart // lib/pages/player_page.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../base/base_state.dart'; import '../base/base_state.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
class PlayerPage extends StatefulWidget { class PlayerPage extends StatefulWidget {
final VoidCallback? onClose; final VoidCallback? onClose;
@@ -21,6 +23,11 @@ class PlayerPage extends StatefulWidget {
class _PlayerPageState extends BaseState<PlayerPage> { class _PlayerPageState extends BaseState<PlayerPage> {
double? _dragProgress; double? _dragProgress;
bool _isFavorite = false;
String _album = '';
String _folder = '';
String? _metaSongId;
String _formatDuration(Duration d) { String _formatDuration(Duration d) {
if (d.inMilliseconds < 0) return '00:00'; if (d.inMilliseconds < 0) return '00:00';
final minutes = d.inMinutes; final minutes = d.inMinutes;
@@ -47,14 +54,12 @@ class _PlayerPageState extends BaseState<PlayerPage> {
@override @override
void initState() { void initState() {
super.initState(); super.initState();
// ⭐ 页面加载时主动拉取状态(多次重试)
_syncStateWithRetry(); _syncStateWithRetry();
} }
@override @override
void didChangeDependencies() { void didChangeDependencies() {
super.didChangeDependencies(); super.didChangeDependencies();
// ⭐ 依赖变化时(如从后台返回)重新同步
WidgetsBinding.instance.addPostFrameCallback((_) { WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) { if (mounted) {
context.read<AudioService>().syncPlayerStateNow(); context.read<AudioService>().syncPlayerStateNow();
@@ -64,11 +69,8 @@ class _PlayerPageState extends BaseState<PlayerPage> {
void _syncStateWithRetry() { void _syncStateWithRetry() {
final service = context.read<AudioService>(); final service = context.read<AudioService>();
// 立即同步
service.syncPlayerStateNow(); service.syncPlayerStateNow();
// 延迟重试
const delays = [200, 500, 800, 1200]; const delays = [200, 500, 800, 1200];
for (final delay in delays) { for (final delay in delays) {
Future.delayed(Duration(milliseconds: delay), () { Future.delayed(Duration(milliseconds: delay), () {
@@ -79,6 +81,57 @@ class _PlayerPageState extends BaseState<PlayerPage> {
} }
} }
/// 元数据(专辑、收藏状态)只在切歌时查一次
Future<void> _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<void> _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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
final service = context.watch<AudioService>(); final service = context.watch<AudioService>();
@@ -110,6 +163,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
); );
} }
_loadMeta(song);
final isPlaying = service.isPlaying; final isPlaying = service.isPlaying;
return Scaffold( return Scaffold(
@@ -118,25 +172,15 @@ class _PlayerPageState extends BaseState<PlayerPage> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
leading: IconButton( 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, onPressed: _closePage,
), ),
title: const Text(
'正在播放',
style: TextStyle(
color: Colors.white,
fontSize: 16,
fontWeight: FontWeight.w400,
),
),
centerTitle: true,
actions: [ actions: [
PopupMenuButton<String>( PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white54), icon: const Icon(Icons.more_vert, color: Colors.white54),
onSelected: (value) async { onSelected: (value) async {
if (value == 'clear_cache') { if (value == 'clear_cache') {
final service = context.read<AudioService>(); await context.read<AudioService>().clearCurrentSongCache();
await service.clearCurrentSongCache();
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(
@@ -145,9 +189,33 @@ class _PlayerPageState extends BaseState<PlayerPage> {
), ),
); );
} }
} else if (value == 'mode') {
context.read<AudioService>().togglePlayMode();
} else if (value == 'playlist') {
_openPlaylist();
} }
}, },
itemBuilder: (context) => [ 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( const PopupMenuItem(
value: 'clear_cache', value: 'clear_cache',
child: Row( child: Row(
@@ -162,70 +230,93 @@ class _PlayerPageState extends BaseState<PlayerPage> {
), ),
], ],
), ),
body: Padding( body: SafeArea(
padding: const EdgeInsets.symmetric(horizontal: 24), child: Padding(
child: Column( padding: const EdgeInsets.symmetric(horizontal: 24),
mainAxisAlignment: MainAxisAlignment.center, child: Column(
children: [ crossAxisAlignment: CrossAxisAlignment.start,
Container( children: [
width: 240, // ---- 封面 ----
height: 240, AspectRatio(
decoration: BoxDecoration( aspectRatio: 1,
shape: BoxShape.circle, child: ClipRRect(
color: const Color(0xFF2A3332), borderRadius: BorderRadius.circular(24),
boxShadow: [ child: song.artwork != null
BoxShadow( ? Image.memory(
color: const Color(0xFF7C9A9E).withOpacity(0.15), song.artwork!,
blurRadius: 60, fit: BoxFit.cover,
spreadRadius: 10, gaplessPlayback: true,
), )
], : Container(
image: song.artwork != null color: const Color(0xFF2A3332),
? DecorationImage( child: const Icon(
image: MemoryImage(song.artwork!), Icons.music_note,
fit: BoxFit.cover, size: 72,
) color: Colors.white24,
: null, ),
),
),
), ),
child: song.artwork == null const SizedBox(height: 28),
? const Icon(Icons.music_note,
size: 64, color: Colors.white24) // ---- 标题 / 艺术家 ----
: null, Text(
), song.title,
const SizedBox(height: 48), style: const TextStyle(
Column( fontSize: 24,
crossAxisAlignment: CrossAxisAlignment.center, fontWeight: FontWeight.w600,
children: [ color: Colors.white,
Text( height: 1.25,
song.title,
style: const TextStyle(
fontSize: 20,
fontWeight: FontWeight.w600,
color: Colors.white,
),
textAlign: TextAlign.center,
maxLines: 2,
overflow: TextOverflow.ellipsis,
), ),
const SizedBox(height: 8), maxLines: 2,
Text( overflow: TextOverflow.ellipsis,
song.artist, ),
style: TextStyle( const SizedBox(height: 6),
fontSize: 15, Text(
color: Colors.grey[400], 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), const Spacer(),
_buildProgressSection(service),
const SizedBox(height: 32), // ---- 进度 ----
_buildControlButtons(service, isPlaying), _buildProgressSection(service),
const SizedBox(height: 16), const SizedBox(height: 18),
],
// ---- 控制 ----
_buildControlButtons(service, song, isPlaying),
const SizedBox(height: 20),
],
),
), ),
), ),
); );
@@ -238,7 +329,6 @@ class _PlayerPageState extends BaseState<PlayerPage> {
return ValueListenableBuilder( return ValueListenableBuilder(
valueListenable: service.durationNotifier, valueListenable: service.durationNotifier,
builder: (context, duration, _) { builder: (context, duration, _) {
// ⭐ 如果 duration 为 0,显示加载指示器
if (duration.inMilliseconds == 0) { if (duration.inMilliseconds == 0) {
return const Padding( return const Padding(
padding: EdgeInsets.symmetric(vertical: 8), padding: EdgeInsets.symmetric(vertical: 8),
@@ -249,115 +339,105 @@ class _PlayerPageState extends BaseState<PlayerPage> {
); );
} }
final progress = duration.inMilliseconds > 0 final progress = position.inMilliseconds / duration.inMilliseconds;
? position.inMilliseconds / duration.inMilliseconds final bufferProgress = (service
: 0.0; .bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds)
final bufferProgress = duration.inMilliseconds > 0 .clamp(0.0, 1.0);
? (service.bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds)
.clamp(0.0, 1.0)
: 0.0;
final displayProgress = _dragProgress ?? progress; final displayProgress = _dragProgress ?? progress;
return Column( return Row(
children: [ children: [
SizedBox( Text(
height: 20, _formatDuration(
child: LayoutBuilder( _dragProgress != null
builder: (context, constraints) { ? Duration(
final width = constraints.maxWidth; milliseconds:
final bufferWidth = (_dragProgress! * duration.inMilliseconds)
width * bufferProgress.clamp(0.0, 1.0); .round(),
final progressWidth = )
width * displayProgress.clamp(0.0, 1.0); : position,
),
return Stack( style: TextStyle(fontSize: 12, color: Colors.grey[500]),
alignment: Alignment.centerLeft, ),
children: [ const SizedBox(width: 10),
Container( Expanded(
width: width, child: SizedBox(
height: 4, height: 24,
decoration: BoxDecoration( child: Stack(
color: Colors.grey[800], alignment: Alignment.centerLeft,
borderRadius: BorderRadius.circular(2), children: [
), Container(
height: 3,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(2),
), ),
Container( ),
width: bufferWidth, FractionallySizedBox(
height: 4, alignment: Alignment.centerLeft,
widthFactor: bufferProgress.clamp(0.0, 1.0),
child: Container(
height: 3,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF4A7A7A), color: const Color(0xFF4A7A7A),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
Container( ),
width: progressWidth, FractionallySizedBox(
height: 4, alignment: Alignment.centerLeft,
widthFactor: displayProgress.clamp(0.0, 1.0),
child: Container(
height: 3,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFFB8D4D0), color: const Color(0xFFB8D4D0),
borderRadius: BorderRadius.circular(2), borderRadius: BorderRadius.circular(2),
), ),
), ),
Positioned.fill( ),
child: SliderTheme( Positioned.fill(
data: SliderTheme.of(context).copyWith( child: SliderTheme(
trackHeight: 0, data: SliderTheme.of(context).copyWith(
activeTrackColor: Colors.transparent, trackHeight: 0,
inactiveTrackColor: Colors.transparent, activeTrackColor: Colors.transparent,
thumbColor: Colors.transparent, inactiveTrackColor: Colors.transparent,
overlayColor: Colors.transparent, thumbColor: const Color(0xFFB8D4D0),
thumbShape: const RoundSliderThumbShape( overlayColor: const Color(0x33B8D4D0),
enabledThumbRadius: 0, thumbShape: const RoundSliderThumbShape(
), enabledThumbRadius: 6,
), ),
child: Slider( overlayShape: const RoundSliderOverlayShape(
value: displayProgress.clamp(0.0, 1.0), overlayRadius: 14,
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;
});
},
), ),
), ),
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( const SizedBox(width: 10),
mainAxisAlignment: MainAxisAlignment.spaceBetween, Text(
children: [ _formatDuration(duration),
Text( style: TextStyle(fontSize: 12, color: Colors.grey[500]),
_formatDuration(position),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
Text(
_formatDuration(duration),
style: TextStyle(
fontSize: 12,
color: Colors.grey[500],
),
),
],
), ),
], ],
); );
@@ -367,61 +447,58 @@ class _PlayerPageState extends BaseState<PlayerPage> {
); );
} }
Widget _buildControlButtons(AudioService service, bool isPlaying) { Widget _buildControlButtons(
AudioService service, Song song, bool isPlaying) {
return Row( return Row(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [ 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( IconButton(
onPressed: service.hasQueue ? service.previous : null, onPressed: service.hasQueue ? service.previous : null,
icon: const Icon(Icons.skip_previous, size: 28), icon: const Icon(Icons.skip_previous_rounded, size: 34),
color: service.hasQueue ? Colors.white60 : Colors.grey[600], color: service.hasQueue ? Colors.white70 : Colors.grey[700],
padding: const EdgeInsets.all(12),
), ),
const SizedBox(width: 20),
Container( Container(
width: 56, width: 68,
height: 56, height: 68,
decoration: BoxDecoration( decoration: const BoxDecoration(
color: const Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
shape: BoxShape.circle, shape: BoxShape.circle,
), ),
child: IconButton( child: IconButton(
icon: Icon( icon: Icon(
isPlaying ? Icons.pause : Icons.play_arrow, isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: Colors.black87, color: Colors.black87,
size: 28, size: 36,
), ),
padding: EdgeInsets.zero, padding: EdgeInsets.zero,
onPressed: service.togglePlay, onPressed: service.togglePlay,
), ),
), ),
const SizedBox(width: 20),
IconButton( IconButton(
onPressed: service.hasQueue ? service.next : null, onPressed: service.hasQueue ? service.next : null,
icon: const Icon(Icons.skip_next, size: 28), icon: const Icon(Icons.skip_next_rounded, size: 34),
color: service.hasQueue ? Colors.white60 : Colors.grey[600], color: service.hasQueue ? Colors.white70 : Colors.grey[700],
padding: const EdgeInsets.all(12), ),
IconButton(
onPressed: () => _toggleFavorite(song),
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? const Color(0xFFE05C5C)
: Colors.white54,
size: 26,
),
tooltip: '收藏',
), ),
IconButton( IconButton(
onPressed: _openPlaylist, onPressed: _openPlaylist,
icon: const Icon( icon: const Icon(
Icons.playlist_play_outlined, Icons.queue_music_rounded,
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
size: 26, size: 26,
), ),
padding: const EdgeInsets.all(8), tooltip: '播放队列',
), ),
const SizedBox(width: 4),
], ],
); );
} }