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 _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 BaseState { 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; 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 _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.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(); setState(() { _favorites = []; _isLoading = false; }); if (mounted) { Navigator.pushReplacement( context, MaterialPageRoute( builder: (_) => const WebDAVSetupPage(), ), ); } }, child: const Text( '重新登录', style: TextStyle(color: Colors.redAccent), ), ), ], ), ); } 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, ), ), // ⭐ 退出按钮(已连接时显示) if (isConnected) ...[ const Spacer(), IconButton( icon: const Icon( Icons.logout, color: Colors.grey, size: 18, ), tooltip: '重新登录', onPressed: _showReauthDialog, padding: EdgeInsets.zero, constraints: const BoxConstraints( minWidth: 32, minHeight: 32, ), ), const SizedBox(width: 4), ], ], ), 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, ), ), ), ], ), ), ], ), ); } }