Files
QingTing-Player/lib/pages/player_page.dart
T
lxh2875931338 6cb96e95dc feat(ui): 小窗/分屏自适应播放页
按可用宽高比判断紧凑模式(全屏 3192/1368≈2.33,小窗 2577/1368≈1.88):
- 封面由撑满宽度改为固定方形(可用高度的 30%,上限 340dp)
- 标题 24→20、艺术家 16→14,各段间距收紧
- 歌词页不显示(PageView 两页:信息 / 主页),指示器同步改两段
- 紧凑模式下整页可滚动,避免控件被挤出屏幕

不依赖"记住分辨率":直接读当前 MediaQuery,窗口尺寸变化时自动切换。
2026-11-01 12:06:12 +08:00

738 lines
23 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.30).clamp(140.0, 340.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)
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,
),
],
if (!compact) const Spacer(),
if (compact) const SizedBox(height: 12),
// ---- 进度 ----
_buildProgressSection(service),
SizedBox(height: compact ? 10 : 18),
// ---- 控制 ----
_buildControlButtons(service, song, isPlaying),
// 底部多留 50px:控制行整体上移
SizedBox(height: compact ? 12 : 45),
],
),
);
// 小窗/横屏:内容超出时允许滚动,避免控件被挤出屏幕
return compact
? SingleChildScrollView(child: padded)
: 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: '播放队列',
),
],
);
}
}