Files
QingTing-Player/lib/pages/home_page.dart

732 lines
27 KiB
Dart
Raw Permalink 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 '../services/playback_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 {
final url = WebDAVService.instance.getFileUrl(song.path);
final audioService = context.read<AudioService>();
audioService.playSong(Song(
id: song.path,
title: song.displayTitle,
artist: song.displaySubtitle,
url: url,
));
final headers = await WebDAVService.instance.getAuthHeaders();
if (headers.isEmpty) {
if (mounted) {
ScaffoldMessenger.of(context).showSnackBar(
const SnackBar(
content: Text('请先登录 WebDAV'),
backgroundColor: Colors.orange,
),
);
}
return;
}
await PlaybackService().play(url, headers: headers);
} 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,
),
),
),
],
),
),
],
),
);
}
}