Files
QingTing-Player/lib/pages/home_page.dart
T
lxh2875931338 d3ed871626 fix(ui): 收藏列表文件名解码,避免显示成百分号编码串
收藏项没有元数据时回退文件名,但 path 是百分号编码的播放路径,
直接截取会显示 "13%E6%A5%BC%E7%9A%84..." 这种不可读内容。
- 展示前对文件名做 Uri.decodeComponent,解码失败才用原串
- 播放仍使用编码后的原 path,不受影响

验证:收藏 "13楼的大笨象 - 郑融" 在首页正常显示中文歌名。
2026-10-24 21:42:01 +08:00

783 lines
29 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 'settings_page.dart';
import '../database/song_database.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();
await _loadFavoritesFromDb();
} catch (e) {
_favorites = [];
} finally {
if (mounted) safeSetState(() => _isLoading = false);
}
}
/// 收藏列表直接绑定 favorites 表 + songs 元数据
Future<void> _loadFavoritesFromDb() async {
final db = SongDatabase();
final favRows = await db.getFavorites();
final paths = favRows
.map((row) => row['song_id'] as String?)
.whereType<String>()
.toList();
final meta = await db.getSongsByPaths(paths);
final items = <SongItem>[];
for (final path in paths) {
final row = meta[path];
final title = (row?['title'] as String?)?.trim() ?? '';
final artist = (row?['artist'] as String?)?.trim() ?? '';
final hasTitle = title.isNotEmpty;
// path 是百分号编码的(播放时必须保持),显示前要解码,
// 否则没有元数据的收藏会显示成 "13%E6%A5%BC..." 这种不可读串。
final rawName = path.split('/').last;
String displayName;
try {
displayName = Uri.decodeComponent(rawName);
} catch (_) {
displayName = rawName;
}
items.add(SongItem(
path: path,
fileName: displayName,
title: hasTitle ? title : null,
artist: artist.isNotEmpty ? artist : null,
sourceTag: '收藏',
metadataState: hasTitle ? 'success' : 'unknown',
));
}
_favorites = items;
}
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: () {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const SettingsPage(),
),
);
},
),
],
),
),
),
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(),
),
);
await _loadFavoritesFromDb();
_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];
// 收藏项不做滚动渐显:_listReveal 在首屏为 0,
// 会让条目完全透明(看起来"收藏了但不显示")。
return AnimatedOpacity(
opacity: 1.0,
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(0xFFE05C5C),
size: 20,
),
onPressed: () async {
await SongDatabase()
.removeFavorite(song.path);
await _loadFavoritesFromDb();
if (mounted) safeSetState(() {});
},
),
onTap: () => _playSong(song),
),
),
);
},
childCount: _favorites.length,
),
),
),
],
),
),
],
),
);
}
}