Files
QingTing-Player/lib/pages/home_page.dart
T
lxh2875931338 9e58d52678 fix(playback): 修复切歌永久假死,并让元数据抓取不再抢播放带宽
问题(后台播一段时间后无法缓冲,点下一首也没反应,只能重启):
1. _isChangingTrack 是没有超时的锁:_playCurrent() 置 true 后,
   只有走完 finally 才会清。mpv 的 open() 在弱网下可能挂很久,
   锁就一直不释放,之后 next()/previous() 全被静默拒绝。
2. 元数据用 dio.download 整文件下载,和 mpv 流式播放抢带宽,
   20Mbps FRP 下播放流只剩一两百 K/s,缓冲长期为 0。
3. 没有订阅 mpv 的 error/buffering,失败无感知、无法自愈。

改动:
- playback_service: open 20s / 控制命令 5s 超时;失败时 stop 回空闲并上抛异常
- audio_service:
  * 去掉「正在切歌就拒绝」的逻辑,改为 latest-wins(epoch 校验,
    旧请求自行放弃,只有最新请求解锁)
  * 播放失败不再吞掉:记录 lastPlaybackError 并 notifyListeners
  * 订阅 stream.error / stream.buffering
  * 缓冲超过 30s 自动重连当前歌曲并 seek 回原位置(2 分钟内最多一次)
- file_provider: 元数据改为 Range 只取头部 2MB,流式写入并在切歌时取消上一请求
- home_page: 去掉重复的 PlaybackService().play()(同一首歌会被 open 两次),
  登录校验提前

验证:dart analyze 无 error;gradlew assembleDebug BUILD SUCCESSFUL。
真机验证待设备重新连接后进行。
2026-10-23 21:04:45 +08:00

734 lines
28 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.
import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/audio_service.dart';
import '../services/webdav_service.dart';
import '../widgets/magnetic_scroll_physics.dart';
import 'webdav_setup_page.dart';
import 'webdav_file_list_page.dart';
import '../constants/ui_constants.dart';
import '../base/base_state.dart';
// ==================================================
// 歌曲数据模型
// ==================================================
class SongItem {
final String path;
final String fileName;
final String? title;
final String? artist;
final String sourceTag;
final String metadataState;
SongItem({
required this.path,
required this.fileName,
this.title,
this.artist,
required this.sourceTag,
this.metadataState = 'unknown',
});
String get displayTitle => (metadataState == 'success' && title != null)
? title!
: fileName.replaceAll(RegExp(r'\.[^.]*$'), '');
String get displaySubtitle {
if (metadataState == 'success' && artist != null) {
return artist!;
} else {
return sourceTag;
}
}
}
// ==================================================
// 通用可点击组件
// ==================================================
class _ClickableTile extends StatefulWidget {
final Widget child;
final VoidCallback onTap;
const _ClickableTile({
required this.child,
required this.onTap,
});
@override
State<_ClickableTile> createState() => _ClickableTileState();
}
class _ClickableTileState extends State<_ClickableTile>
with SingleTickerProviderStateMixin {
late final AnimationController _controller;
late final Animation<double> _scale;
static const Duration _duration = Duration(milliseconds: 120);
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: _duration);
_scale = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
}
@override
void dispose() {
_controller.dispose();
super.dispose();
}
void _handleTapDown(TapDownDetails details) {
_controller.forward();
}
void _handleTapUp(TapUpDetails details) {
_controller.reverse();
widget.onTap();
}
void _handleTapCancel() {
_controller.reverse();
}
@override
Widget build(BuildContext context) {
return RepaintBoundary(
child: GestureDetector(
onTapDown: _handleTapDown,
onTapUp: _handleTapUp,
onTapCancel: _handleTapCancel,
child: AnimatedBuilder(
animation: _controller,
builder: (context, child) {
return Transform.scale(
scale: _scale.value,
child: child,
);
},
child: widget.child,
),
),
);
}
}
// ==================================================
// Sticky Header 委托
// ==================================================
class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
final Widget child;
_StickyHeaderDelegate({required this.child});
@override
double get minExtent => 48;
@override
double get maxExtent => 48;
@override
Widget build(
BuildContext context, double shrinkOffset, bool overlapsContent) {
return child;
}
@override
bool shouldRebuild(_StickyHeaderDelegate oldDelegate) {
return child != oldDelegate.child;
}
}
// ==================================================
// 主页
// ==================================================
class HomePage extends StatefulWidget {
const HomePage({super.key});
@override
State<HomePage> createState() => _HomePageState();
}
class _HomePageState extends BaseState<HomePage> {
List<SongItem> _favorites = [];
bool _isLoading = true;
// ========== 滚动控制 ==========
late final ScrollController _scrollController;
final GlobalKey _mediaLibraryKey = GlobalKey();
double _mediaLibraryHeight = 0.0;
// 高度测量重试控制
int _heightMeasureRetryCount = 0;
static const int _maxHeightMeasureRetries = 10;
// ========== 计算属性 ==========
double get _progress {
if (_mediaLibraryHeight <= 0) return 0.0;
final offset =
_scrollController.hasClients ? _scrollController.offset : 0.0;
return (offset / _mediaLibraryHeight).clamp(0.0, 1.0);
}
double get _mediaOpacity {
final p = _progress;
if (p <= 0.3) {
return 1.0 - (p / 0.3) * 0.95;
} else {
return 0.05 * (1 - (p - 0.3) / 0.7);
}
}
double get _listReveal {
if (_progress <= 0.7) return 0.0;
return (_progress - 0.7) / 0.3;
}
@override
void initState() {
super.initState();
_scrollController = ScrollController()..addListener(_onScroll);
_initWebDAV();
WidgetsBinding.instance.addPostFrameCallback((_) {
_measureMediaLibraryHeight();
});
}
@override
void dispose() {
_scrollController.dispose();
super.dispose();
}
void _measureMediaLibraryHeight() {
final renderBox =
_mediaLibraryKey.currentContext?.findRenderObject() as RenderBox?;
if (renderBox != null) {
final height = renderBox.size.height;
if (height > 0 && height != _mediaLibraryHeight) {
safeSetState(() {
_mediaLibraryHeight = height;
_heightMeasureRetryCount = 0;
});
print('✅ 媒体库高度测量成功: $height');
}
} else {
_heightMeasureRetryCount++;
if (_heightMeasureRetryCount < _maxHeightMeasureRetries) {
Future.delayed(
const Duration(milliseconds: 200), _measureMediaLibraryHeight);
} else {
print('⚠️ 媒体库高度测量失败,使用默认值 280px');
safeSetState(() {
_mediaLibraryHeight = 280.0;
});
}
}
}
void _onScroll() {
if (mounted) safeSetState(() {});
}
Future<void> _initWebDAV() async {
safeSetState(() => _isLoading = true);
try {
await WebDAVService.instance.loadCredentials();
_favorites = [];
} catch (e) {
_favorites = [];
} finally {
if (mounted) safeSetState(() => _isLoading = false);
}
}
void _playSong(SongItem song) async {
try {
// 先校验登录,再开始播放。
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
// 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
// 表现就是"媒体加载了但没有声音"。
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先登录 WebDAV'),
backgroundColor: Colors.orange,
),
);
}
return;
}
if (!mounted) return;
final url = WebDAVService.instance.getFileUrl(song.path);
final audioService = context.read<AudioService>();
await audioService.playSong(Song(
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
url: url,
));
} catch (e) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('播放失败: $e'),
backgroundColor: Colors.red,
),
);
}
}
}
// ⭐ 退出/重新登录对话框
void _showReauthDialog() {
showDialog(
context: context,
builder: (context) => AlertDialog(
backgroundColor: const Color(0xFF1A1F1E),
title: const Text(
'重新登录 WebDAV',
style: TextStyle(color: Colors.white),
),
content: const Text(
'退出当前账号并重新登录?',
style: TextStyle(color: Colors.grey),
),
actions: [
TextButton(
onPressed: () => Navigator.pop(context),
child: const Text('取消'),
),
TextButton(
onPressed: () async {
Navigator.pop(context);
await WebDAVService.instance.clearCredentials();
safeSetState(() {
_favorites = [];
_isLoading = false;
});
if (mounted) {
// ⭐ 修复:用 push 保留 HomePage 在栈底
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const WebDAVSetupPage(),
),
);
}
},
child: const Text(
'重新登录',
style: TextStyle(color: Colors.redAccent),
),
),
],
),
);
}
void _resetSnapState() {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
safeSetState(() {});
}
@override
Widget build(BuildContext context) {
final isConnected = WebDAVService.instance.isConnected;
final username = WebDAVService.instance.username ?? '点击连接';
return Scaffold(
backgroundColor: const Color(0xFF0E1211),
body: Column(
children: [
SafeArea(
bottom: false,
child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20, vertical: 12),
child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween,
children: [
const Text(
'清听',
style: TextStyle(
fontSize: 28,
fontWeight: FontWeight.w600,
color: Colors.white,
),
),
IconButton(
icon: const Icon(Icons.menu, color: Colors.white54),
onPressed: () {},
),
],
),
),
),
Expanded(
child: CustomScrollView(
controller: _scrollController,
physics: _mediaLibraryHeight > 0
? MagneticScrollPhysics(
snapPoint: _mediaLibraryHeight,
magneticZoneStart: 0.20,
)
: const ClampingScrollPhysics(),
slivers: [
SliverToBoxAdapter(
child: Opacity(
opacity: _mediaOpacity,
child: Container(
key: _mediaLibraryKey,
padding: const EdgeInsets.symmetric(horizontal: 20),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'媒体库',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w500,
color: Color(0xFFB8D4D0),
),
),
const SizedBox(height: 16),
_ClickableTile(
onTap: () async {
if (WebDAVService.instance.isConnected) {
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const WebDAVFileListPage(),
),
);
_resetSnapState();
safeSetState(() {});
} else {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const WebDAVSetupPage(),
),
);
safeSetState(() {});
if (result == true) {
await WebDAVService.instance
.loadCredentials();
safeSetState(() {});
}
}
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
// 左侧云朵图标(固定 56px)
const Padding(
padding: EdgeInsets.only(left: 8.0),
child: Icon(
Icons.cloud_outlined,
color: Color(0xFFB8D4D0),
size: 56,
),
),
const SizedBox(width: 16),
// 右侧所有内容
Expanded(
child: Column(
crossAxisAlignment:
CrossAxisAlignment.start,
mainAxisSize: MainAxisSize.min,
children: [
// 第一行:WebDAV + 状态 + 退出按钮
Row(
children: [
const Text(
'WebDAV',
style: TextStyle(
fontSize: 18,
fontWeight: FontWeight.w500,
color: Colors.white,
),
),
const SizedBox(width: 16),
Text(
isConnected ? '● 已连接' : '● 未连接',
style: TextStyle(
fontSize: 13,
color: isConnected
? const Color(0xFF4CAF50)
: Colors.grey[500],
fontWeight: FontWeight.w400,
),
),
const Spacer(),
if (isConnected)
Padding(
padding:
const EdgeInsets.only(top: 2),
child: GestureDetector(
onTap: _showReauthDialog,
child: const Icon(
Icons.logout,
color: Colors.grey,
size: 18,
),
),
),
],
),
const SizedBox(height: 4),
// 第二行:用户名
Text(
isConnected ? username : '点击连接',
style: TextStyle(
fontSize: 14,
color: isConnected
? Colors.grey[400]
: Colors.grey[600],
),
),
],
),
),
],
),
),
const SizedBox(height: 24),
Row(
children: [
Expanded(
child: _ClickableTile(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.music_note,
size: 24,
color: const Color(0xFFB8D4D0)),
const SizedBox(width: 8),
const Text(
'本地音乐',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Colors.white70,
),
),
],
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: _ClickableTile(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.history,
size: 24,
color: const Color(0xFFB8D4D0)),
const SizedBox(width: 8),
const Text(
'最近播放',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Colors.white70,
),
),
],
),
),
),
),
const SizedBox(width: 16),
Expanded(
child: _ClickableTile(
onTap: () {},
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 10),
child: Row(
mainAxisAlignment:
MainAxisAlignment.center,
children: [
Icon(Icons.playlist_play,
size: 24,
color: const Color(0xFFB8D4D0)),
const SizedBox(width: 8),
const Text(
'歌单列表',
style: TextStyle(
fontSize: 14,
fontWeight: FontWeight.w400,
color: Colors.white70,
),
),
],
),
),
),
),
],
),
const SizedBox(height: 32),
],
),
),
),
),
SliverPersistentHeader(
pinned: true,
delegate: _StickyHeaderDelegate(
child: Container(
height: 48,
color: const Color(0xFF0E1211),
padding: const EdgeInsets.symmetric(horizontal: 20),
child: const Row(
children: [
Icon(Icons.favorite,
color: Color(0xFFB8D4D0), size: 20),
SizedBox(width: 8),
Text(
'我的收藏',
style: TextStyle(
fontSize: 22,
fontWeight: FontWeight.w500,
color: Color(0xFFB8D4D0),
),
),
],
),
),
),
),
SliverPadding(
padding: const EdgeInsets.only(
left: 20,
right: 20,
bottom: UIConstants.miniPlayerBottomSpace,
),
sliver: _isLoading
? const SliverFillRemaining(
child: Center(
child: CircularProgressIndicator(
color: Color(0xFFB8D4D0),
),
),
)
: _favorites.isEmpty
? SliverFillRemaining(
child: AnimatedOpacity(
opacity: 1.0 - _listReveal * 0.3,
duration: const Duration(milliseconds: 100),
child: Center(
child: Column(
mainAxisAlignment: MainAxisAlignment.center,
children: [
Icon(
Icons.favorite_border,
size: 48,
color: Colors.grey[600],
),
const SizedBox(height: 16),
Text(
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
textAlign: TextAlign.center,
style: TextStyle(
fontSize: 16,
color: Colors.grey[500],
height: 1.6,
),
),
],
),
),
),
)
: SliverList(
delegate: SliverChildBuilderDelegate(
(context, index) {
final song = _favorites[index];
final itemProgress =
(_listReveal * 2 - index / 5)
.clamp(0.0, 1.0);
return AnimatedOpacity(
opacity: itemProgress,
duration: const Duration(milliseconds: 150),
child: Padding(
padding: const EdgeInsets.symmetric(
vertical: 6),
child: ListTile(
contentPadding: EdgeInsets.zero,
leading: const Icon(
Icons.music_note,
color: Colors.white38,
size: 20,
),
title: Text(
song.displayTitle,
style: const TextStyle(
fontSize: 17,
fontWeight: FontWeight.w400,
color: Colors.white,
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
subtitle: Text(
song.displaySubtitle,
style: TextStyle(
fontSize: 14,
color: Colors.grey[400],
),
maxLines: 1,
overflow: TextOverflow.ellipsis,
),
trailing: IconButton(
icon: const Icon(
Icons.favorite,
color: Color(0xFFB8D4D0),
size: 20,
),
onPressed: () {
safeSetState(() {
_favorites.removeAt(index);
});
},
),
onTap: () => _playSong(song),
),
),
);
},
childCount: _favorites.length,
),
),
),
],
),
),
],
),
);
}
}