// lib/pages/home_page.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/mini_player_bar.dart'; import '../widgets/magnetic_scroll_physics.dart'; import 'webdav_setup_page.dart'; import 'webdav_file_list_page.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 _scale; static const Duration _duration = Duration(milliseconds: 120); @override void initState() { super.initState(); _controller = AnimationController(vsync: this, duration: _duration); _scale = Tween(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 createState() => _HomePageState(); } class _HomePageState extends State { List _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; // 0% ~ 30%: 1.0 → 0.05 // 30% ~ 100%: 0.05 → 0 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(() {}); } // ========== 初始化 WebDAV ========== Future _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); await PlaybackService().play(url); context.read().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 _resetSnapState() { if (_scrollController.hasClients) { _scrollController.jumpTo(0.0); } setState(() {}); } // ================================================================ // Build // ================================================================ @override Widget build(BuildContext context) { final isConnected = WebDAVService.instance.isConnected; final username = WebDAVService.instance.username ?? '点击连接'; final audioService = context.watch(); final showMiniBar = audioService.currentSong != null; 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), ], ), ), ), ), // ---- "我的收藏" Sticky Header ---- 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: EdgeInsets.only( left: 20, right: 20, bottom: showMiniBar ? 80.0 : 20.0, ), 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, ), ), ), ], ), ), // ---- 底部 MiniPlayer ---- if (showMiniBar) const MiniPlayerBar(), ], ), ); } }