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

665 lines
25 KiB
Dart

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';
// ==================================================
// 歌曲数据模型
// ==================================================
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 State<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) {
setState(() {
_mediaLibraryHeight = height;
_heightMeasureRetryCount = 0;
});
print('✅ 媒体库高度测量成功: $height');
}
} else {
_heightMeasureRetryCount++;
if (_heightMeasureRetryCount < _maxHeightMeasureRetries) {
Future.delayed(
const Duration(milliseconds: 200), _measureMediaLibraryHeight);
} else {
print('⚠️ 媒体库高度测量失败,使用默认值 280px');
setState(() {
_mediaLibraryHeight = 280.0;
});
}
}
}
void _onScroll() {
if (mounted) setState(() {});
}
Future<void> _initWebDAV() async {
setState(() => _isLoading = true);
try {
await WebDAVService.instance.loadCredentials();
_favorites = [];
} catch (e) {
_favorites = [];
} finally {
if (mounted) setState(() => _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 _resetSnapState() {
if (_scrollController.hasClients) {
_scrollController.jumpTo(0.0);
}
setState(() {});
}
@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();
setState(() {});
} else {
final result = await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const WebDAVSetupPage(),
),
);
setState(() {});
if (result == true) {
await WebDAVService.instance
.loadCredentials();
setState(() {});
}
}
},
child: Row(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
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,
children: [
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 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: () {
setState(() {
_favorites.removeAt(index);
});
},
),
onTap: () => _playSong(song),
),
),
);
},
childCount: _favorites.length,
),
),
),
],
),
),
],
),
);
}
}