Files
QingTing-Player/lib/pages/player_page.dart
T
lxh2875931338 9a932e92d4 style(ui): 小窗封面居中并加大,控件上移 8px
- 封面尺寸 30% -> 38%(上限 380dp),并在窗口中水平居中
- 控件行底部留白 12 -> 20(整体上移 8px)
2026-11-01 12:11:16 +08:00

746 lines
24 KiB
Dart
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
// lib/pages/player_page.dart
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../base/base_state.dart';
import '../database/song_database.dart';
import '../services/audio_service.dart';
class PlayerPage extends StatefulWidget {
final VoidCallback? onClose;
final VoidCallback? onOpenPlaylist;
const PlayerPage({
super.key,
this.onClose,
this.onOpenPlaylist,
});
@override
State<PlayerPage> createState() => _PlayerPageState();
}
class _PlayerPageState extends BaseState<PlayerPage> {
double? _dragProgress;
bool _isFavorite = false;
String _album = '';
String _folder = '';
String _dbTitle = '';
String _dbArtist = '';
String? _metaSongId;
/// 0 = 文件信息,1 = 播放主页,2 = 歌词
final PageController _pageController = PageController(initialPage: 1);
int _pageIndex = 1;
@override
void dispose() {
_pageController.dispose();
super.dispose();
}
String _formatDuration(Duration d) {
if (d.inMilliseconds < 0) return '00:00';
final minutes = d.inMinutes;
final seconds = d.inSeconds % 60;
return '${minutes.toString().padLeft(2, '0')}:${seconds.toString().padLeft(2, '0')}';
}
void _closePage() {
if (widget.onClose != null) {
widget.onClose!();
} else {
Navigator.pop(context);
}
}
void _openPlaylist() {
if (widget.onOpenPlaylist != null) {
widget.onOpenPlaylist!();
} else {
Navigator.pushNamed(context, '/playlist');
}
}
@override
void initState() {
super.initState();
_syncStateWithRetry();
}
@override
void didChangeDependencies() {
super.didChangeDependencies();
WidgetsBinding.instance.addPostFrameCallback((_) {
if (mounted) {
context.read<AudioService>().syncPlayerStateNow();
}
});
}
void _syncStateWithRetry() {
final service = context.read<AudioService>();
service.syncPlayerStateNow();
const delays = [200, 500, 800, 1200];
for (final delay in delays) {
Future.delayed(Duration(milliseconds: delay), () {
if (mounted) {
service.syncPlayerStateNow();
}
});
}
}
/// 元数据(专辑、收藏状态)只在切歌时查一次
Future<void> _loadMeta(Song song) async {
if (_metaSongId == song.id) return;
_metaSongId = song.id;
final db = SongDatabase();
String album = '';
String dbTitle = '';
String dbArtist = '';
bool favorite = false;
try {
final row = await db.getSongByPath(song.id);
album = (row?['album'] as String?)?.trim() ?? '';
dbTitle = (row?['title'] as String?)?.trim() ?? '';
dbArtist = (row?['artist'] 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;
_dbTitle = dbTitle;
_dbArtist = dbArtist;
_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
Widget build(BuildContext context) {
final service = context.watch<AudioService>();
final song = service.currentSong;
if (song == null) {
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
body: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
const Text(
'没有正在播放的歌曲',
style: TextStyle(color: Colors.grey),
),
const SizedBox(height: 16),
ElevatedButton(
onPressed: _closePage,
style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFB8D4D0),
foregroundColor: Colors.black87,
),
child: const Text('返回'),
),
],
),
),
);
}
_loadMeta(song);
final isPlaying = service.isPlaying;
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
appBar: AppBar(
backgroundColor: Colors.transparent,
elevation: 0,
leading: IconButton(
icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70),
onPressed: _closePage,
),
centerTitle: true,
title: _buildPageIndicator(_isCompact),
actions: [
PopupMenuButton<String>(
icon: const Icon(Icons.more_vert, color: Colors.white54),
onSelected: (value) async {
if (value == 'clear_cache') {
await context.read<AudioService>().clearCurrentSongCache();
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('缓存已清除,重新加载中...'),
backgroundColor: Colors.orange,
),
);
}
} else if (value == 'mode') {
context.read<AudioService>().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(
children: [
Icon(Icons.cleaning_services, color: Colors.redAccent),
SizedBox(width: 12),
Text('清除缓存'),
],
),
),
],
),
],
),
body: SafeArea(
top: false,
child: PageView(
controller: _pageController,
onPageChanged: (index) => setState(() => _pageIndex = index),
children: [
_buildInfoPage(song),
_buildMainPage(service, song, isPlaying, compact: _isCompact),
if (!_isCompact) _buildLyricsPage(song),
],
),
),
);
}
/// 小窗/分屏:宽高比明显小于全屏(1368x3192 → 2.33;1368x2577 → 1.88)
bool get _isCompact {
final size = MediaQuery.of(context).size;
if (size.width <= 0) return false;
return size.height / size.width < 2.05;
}
/// 顶部指示器:当前页对应"长条",另外的页是小点。
/// 全屏三页(信息/主页/歌词),小窗两页(信息/主页)。
Widget _buildPageIndicator(bool compact) {
Widget segment(int index) {
final active = _pageIndex == index;
return GestureDetector(
onTap: () => _pageController.animateToPage(
index,
duration: const Duration(milliseconds: 280),
curve: Curves.easeOutCubic,
),
behavior: HitTestBehavior.opaque,
child: Padding(
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
child: AnimatedContainer(
duration: const Duration(milliseconds: 220),
curve: Curves.easeOutCubic,
width: active ? 30 : 7,
height: 7,
decoration: BoxDecoration(
color: active ? Colors.white : Colors.white24,
borderRadius: BorderRadius.circular(4),
),
),
),
);
}
final indices = compact ? const [0, 1] : const [0, 1, 2];
return Row(
mainAxisSize: MainAxisSize.min,
children: [
for (var i = 0; i < indices.length; i++) ...[
if (i > 0) const SizedBox(width: 6),
segment(indices[i]),
],
],
);
}
Widget _buildMainPage(AudioService service, Song song, bool isPlaying,
{required bool compact}) {
final screen = MediaQuery.of(context).size;
// 小窗:封面改为固定方形(约 30% 可用高度),给控件留出空间
final coverSide = compact
? (screen.height * 0.38).clamp(160.0, 380.0)
: null;
final cover = ClipRRect(
borderRadius: BorderRadius.circular(compact ? 18 : 24),
child: song.artwork != null
? Image.memory(
song.artwork!,
fit: BoxFit.cover,
gaplessPlayback: true,
)
: Container(
color: const Color(0xFF2A3332),
child: Icon(
Icons.music_note,
size: compact ? 48 : 72,
color: Colors.white24,
),
),
);
final padded = Padding(
// 顶部留白:与 AppBar/指示器拉开距离,避免封面贴着顶部
padding: EdgeInsets.fromLTRB(24, compact ? 8 : 24, 24, 0),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// ---- 封面 ----
if (coverSide != null)
Center(
child: SizedBox(
width: coverSide,
height: coverSide,
child: cover,
),
)
else
AspectRatio(
aspectRatio: 1,
child: cover,
),
SizedBox(height: compact ? 12 : 28),
// ---- 标题 / 艺术家(收藏心在标题右侧)----
Row(
crossAxisAlignment: CrossAxisAlignment.center,
children: [
Expanded(
child: Text(
song.title,
style: TextStyle(
fontSize: compact ? 20 : 24,
fontWeight: FontWeight.w600,
color: Colors.white,
height: 1.25,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
),
const SizedBox(width: 8),
IconButton(
onPressed: () => _toggleFavorite(song),
tooltip: '收藏',
padding: EdgeInsets.zero,
constraints:
const BoxConstraints(minWidth: 36, minHeight: 36),
iconSize: 24,
icon: Icon(
_isFavorite ? Icons.favorite : Icons.favorite_border,
color: _isFavorite
? const Color(0xFFE05C5C)
: Colors.white54,
size: 24,
),
),
],
),
const SizedBox(height: 2),
Text(
song.artist,
style: TextStyle(
fontSize: compact ? 14 : 16,
color: Colors.grey[300],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
// ---- 信息区(专辑 / 目录)----
if (_album.isNotEmpty || _folder.isNotEmpty) ...[
const SizedBox(height: 10),
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,
),
],
const Spacer(),
// ---- 进度 ----
_buildProgressSection(service),
SizedBox(height: compact ? 10 : 18),
// ---- 控制 ----
_buildControlButtons(service, song, isPlaying),
// 底部多留 50px:控制行整体上移
SizedBox(height: compact ? 20 : 45),
],
),
);
if (!compact) return padded;
// 小窗/横屏:内容超高时可滚动;内容不足时撑满,避免底部大片空白
return LayoutBuilder(
builder: (context, constraints) => SingleChildScrollView(
child: ConstrainedBox(
constraints: BoxConstraints(minHeight: constraints.maxHeight),
child: IntrinsicHeight(child: padded),
),
),
);
}
/// 左页:文件信息
Widget _buildInfoPage(Song song) {
final rawName = song.id.split('/').last;
String fileName = rawName;
try {
fileName = Uri.decodeComponent(rawName);
} catch (_) {}
final dot = fileName.lastIndexOf('.');
final format =
dot > 0 ? fileName.substring(dot + 1).toUpperCase() : '未知';
String decodedPath = song.id;
try {
decodedPath = Uri.decodeComponent(song.id);
} catch (_) {}
final effectiveArtist = _dbArtist.isNotEmpty
? _dbArtist
: (song.artist.isEmpty ? '未知' : song.artist);
final effectiveTitle = _dbTitle.isNotEmpty ? _dbTitle : song.title;
final rows = <MapEntry<String, String>>[
MapEntry('标题', effectiveTitle),
MapEntry('歌手', effectiveArtist),
MapEntry('专辑', _album.isEmpty ? '未知' : _album),
MapEntry('文件夹', _folder.isEmpty ? '根目录' : _folder),
MapEntry('文件名', fileName),
MapEntry('格式', format),
MapEntry('路径', decodedPath),
];
return SingleChildScrollView(
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'文件信息',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
const SizedBox(height: 20),
for (final row in rows) ...[
Text(
row.key,
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
),
const SizedBox(height: 4),
Text(
row.value,
style: const TextStyle(
fontSize: 14,
color: Colors.white,
height: 1.4,
),
),
const SizedBox(height: 18),
],
],
),
);
}
/// 右页:歌词(占位)
Widget _buildLyricsPage(Song song) {
return Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(Icons.lyrics_outlined, size: 44, color: Colors.grey[700]),
const SizedBox(height: 14),
Text(
'暂无歌词',
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
),
const SizedBox(height: 6),
Text(
song.title,
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
],
),
);
}
Widget _buildProgressSection(AudioService service) {
return ValueListenableBuilder(
valueListenable: service.positionNotifier,
builder: (context, position, _) {
return ValueListenableBuilder(
valueListenable: service.durationNotifier,
builder: (context, duration, _) {
if (duration.inMilliseconds == 0) {
return const Padding(
padding: EdgeInsets.symmetric(vertical: 8),
child: LinearProgressIndicator(
color: Color(0xFFB8D4D0),
backgroundColor: Color(0xFF2A3332),
),
);
}
final progress = position.inMilliseconds / duration.inMilliseconds;
final bufferProgress = (service
.bufferedNotifier.value.inMilliseconds /
duration.inMilliseconds)
.clamp(0.0, 1.0);
final displayProgress = _dragProgress ?? progress;
return Row(
children: [
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: LayoutBuilder(
builder: (context, constraints) {
final width = constraints.maxWidth;
return Stack(
alignment: Alignment.centerLeft,
children: [
// 背景轨道
Positioned(
left: 0,
right: 0,
top: 10.5,
child: Container(
height: 3,
decoration: BoxDecoration(
color: Colors.grey[800],
borderRadius: BorderRadius.circular(2),
),
),
),
// 已缓冲
Positioned(
left: 0,
top: 10.5,
child: Container(
width: width * bufferProgress.clamp(0.0, 1.0),
height: 3,
decoration: BoxDecoration(
color: const Color(0xFF4A7A7A),
borderRadius: BorderRadius.circular(2),
),
),
),
// 已播放
Positioned(
left: 0,
top: 10.5,
child: Container(
width:
width * displayProgress.clamp(0.0, 1.0),
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,
),
),
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);
},
),
),
),
],
);
},
),
),
),
const SizedBox(width: 10),
Text(
_formatDuration(duration),
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
),
],
);
},
);
},
);
}
Widget _buildControlButtons(
AudioService service, Song song, bool isPlaying) {
return Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
IconButton(
onPressed: service.hasQueue ? service.previous : null,
icon: const Icon(Icons.skip_previous_rounded, size: 34),
color: service.hasQueue ? Colors.white70 : Colors.grey[700],
),
Container(
width: 68,
height: 68,
decoration: const BoxDecoration(
color: Color(0xFFB8D4D0),
shape: BoxShape.circle,
),
child: IconButton(
icon: Icon(
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
color: Colors.black87,
size: 36,
),
padding: EdgeInsets.zero,
onPressed: service.togglePlay,
),
),
IconButton(
onPressed: service.hasQueue ? service.next : null,
icon: const Icon(Icons.skip_next_rounded, size: 34),
color: service.hasQueue ? Colors.white70 : Colors.grey[700],
),
IconButton(
onPressed: service.togglePlayMode,
icon: Icon(
service.playModeIcon,
color: const Color(0xFFB8D4D0),
size: 26,
),
tooltip: '播放模式',
),
IconButton(
onPressed: _openPlaylist,
icon: const Icon(
Icons.queue_music_rounded,
color: Color(0xFFB8D4D0),
size: 26,
),
tooltip: '播放队列',
),
],
);
}
}