import 'dart:async'; import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/audio_service.dart'; import '../services/favorite_events.dart'; import '../services/metadata_events.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 '../models/playlist_summary.dart'; import '../repositories/playlist_repository.dart'; import 'playlist_detail_page.dart'; import '../widgets/song_artwork.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? artworkPath; final String sourceTag; final String metadataState; SongItem({ required this.path, required this.fileName, this.title, this.artist, this.artworkPath, 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 && artist!.isNotEmpty) { return artist!; } return sourceTag.isNotEmpty ? 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; /// 0 = 我的收藏,1 = 歌单列表(左右滑动切换) int _homeTab = 0; final PageController _homePageController = PageController(); StreamSubscription? _metaSub; StreamSubscription? _favSub; List _playlistSummaries = []; // ========== 滚动控制 ========== 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); _metaSub = MetadataEvents().stream.listen(_onMetadataUpdate); _favSub = FavoriteEvents().stream.listen((_) => _onFavoriteChanged()); _initWebDAV(); WidgetsBinding.instance.addPostFrameCallback((_) { _measureMediaLibraryHeight(); }); } @override void dispose() { _metaSub?.cancel(); _favSub?.cancel(); _homePageController.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; }); } } } /// 任何位置收藏/取消收藏后,重新拉一次收藏列表 Future _onFavoriteChanged() async { await _loadFavoritesFromDb(); if (mounted) safeSetState(() {}); } /// 元数据补全后,按 path 更新收藏列表里的对应行 void _onMetadataUpdate(MetadataUpdate update) { final index = _favorites.indexWhere((item) => item.path == update.path); if (index < 0 || !mounted) return; final row = update.row; final title = (row['title'] as String?)?.trim() ?? ''; final artist = (row['artist'] as String?)?.trim() ?? ''; final artwork = (row['artwork_path'] as String?)?.trim() ?? ''; final old = _favorites[index]; safeSetState(() { _favorites[index] = SongItem( path: old.path, fileName: old.fileName, title: title.isNotEmpty ? title : old.title, artist: artist.isNotEmpty ? artist : old.artist, artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath, sourceTag: old.sourceTag, metadataState: (title.isNotEmpty || old.title != null) ? 'success' : 'unknown', ); }); } void _onScroll() { if (mounted) safeSetState(() {}); } Future _initWebDAV() async { safeSetState(() => _isLoading = true); try { await WebDAVService.instance.loadCredentials(); await _loadFavoritesFromDb(); await _loadPlaylistSummaries(); } catch (e) { _favorites = []; } finally { if (mounted) safeSetState(() => _isLoading = false); } } /// 收藏列表直接绑定 favorites 表 + songs 元数据 Future _loadFavoritesFromDb() async { final db = SongDatabase(); final favRows = await db.getFavorites(); final paths = favRows .map((row) => row['song_id'] as String?) .whereType() .toList(); final meta = await db.getSongsByPaths(paths); final items = []; 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; } // 取上级目录名,作为没有标签时的副标题(加括号区分"这是文件夹") final segments = path.split('/').where((s) => s.isNotEmpty).toList(); String folderName = ''; if (segments.length >= 2) { try { folderName = Uri.decodeComponent(segments[segments.length - 2]); } catch (_) { folderName = segments[segments.length - 2]; } } items.add(SongItem( path: path, fileName: displayName, title: hasTitle ? title : null, artist: artist.isNotEmpty ? artist : null, artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty == true ? (row!['artwork_path'] as String).trim() : null, sourceTag: folderName.isNotEmpty ? '($folderName)' : '', 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(); 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, ), ); } } } Future _loadPlaylistSummaries() async { try { final list = await PlaylistRepository().getSummaries(); if (!mounted) return; setState(() => _playlistSummaries = list); } catch (e) { debugPrint('⚠️ [Home] 读取歌单失败: $e'); } } Widget _buildHomeTabIndicator() { Widget seg(int index) { final active = _homeTab == index; return GestureDetector( onTap: () => safeSetState(() => _homeTab = index), behavior: HitTestBehavior.opaque, child: Padding( padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2), child: AnimatedContainer( duration: const Duration(milliseconds: 220), curve: Curves.easeOutCubic, width: active ? 26 : 7, height: 7, decoration: BoxDecoration( color: active ? Colors.white : Colors.white24, borderRadius: BorderRadius.circular(4), ), ), ), ); } return Row( mainAxisSize: MainAxisSize.min, children: [ seg(0), const SizedBox(width: 6), seg(1), ], ); } Widget _buildFavoritesTab() { if (_isLoading) { return const Center( child: CircularProgressIndicator(color: Color(0xFFB8D4D0)), ); } if (_favorites.isEmpty) { return 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, ), ), ], ), ); } return ListView.builder( padding: const EdgeInsets.fromLTRB( 20, 0, 20, UIConstants.miniPlayerBottomSpace), itemCount: _favorites.length, itemBuilder: (context, index) { final song = _favorites[index]; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: ListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, minVerticalPadding: 0, leading: SongArtwork( filePath: song.artworkPath, size: 44, fallbackIcon: Icons.music_note, ), title: Text( song.displayTitle, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w400, color: Colors.white, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( song.displaySubtitle, style: TextStyle(fontSize: 13, 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: () => _playFavorites(index), ), ); }, ); } Widget _buildPlaylistsTab() { if (_isLoading) { return const Center( child: CircularProgressIndicator(color: Color(0xFFB8D4D0)), ); } if (_playlistSummaries.isEmpty) { return Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon(Icons.queue_music, 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, ), ), ], ), ); } return ListView.builder( padding: const EdgeInsets.fromLTRB( 20, 0, 20, UIConstants.miniPlayerBottomSpace), itemCount: _playlistSummaries.length, itemBuilder: (context, index) { final playlist = _playlistSummaries[index]; return Padding( padding: const EdgeInsets.symmetric(vertical: 2), child: ListTile( contentPadding: EdgeInsets.zero, visualDensity: VisualDensity.compact, leading: Container( width: 44, height: 44, decoration: BoxDecoration( color: const Color(0xFF1E2A28), borderRadius: BorderRadius.circular(10), ), child: const Icon( Icons.queue_music, color: Color(0xFFB8D4D0), size: 22, ), ), title: Text( playlist.name, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w400, color: Colors.white, ), maxLines: 1, overflow: TextOverflow.ellipsis, ), subtitle: Text( '${playlist.songCount} 首', style: TextStyle(fontSize: 13, color: Colors.grey[400]), ), onTap: () async { await Navigator.push( context, MaterialPageRoute( builder: (_) => PlaylistDetailPage( playlistId: playlist.id, playlistName: playlist.name, ), ), ); await _loadPlaylistSummaries(); if (mounted) safeSetState(() {}); }, ), ); }, ); } /// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表 Future _playFavorites(int index) async { if (_favorites.isEmpty) return; try { final headers = await WebDAVService.instance.getAuthHeaders(); if (headers.isEmpty) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( const SnackBar( content: Text('请先登录 WebDAV'), backgroundColor: Colors.orange, ), ); } return; } final songs = _favorites .map((item) => Song( id: item.path, title: item.displayTitle, artist: item.displaySubtitle, url: WebDAVService.instance.getFileUrl(item.path), )) .toList(); if (!mounted) return; debugPrint('⭐ [Home] 收藏整队入队:${songs.length} 首,从第 $index 首开始'); await context.read().setQueue(songs, startIndex: index); } 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 在栈底;登录成功后 Setup 会 popUntil 回到这里 await Navigator.push( context, MaterialPageRoute( builder: (_) => const WebDAVSetupPage(), ), ); if (mounted) { await _initWebDAV(); safeSetState(() {}); } } }, 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: 24, 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: const NeverScrollableScrollPhysics(), 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: 20, 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: 12), 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: 13, 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: 13, fontWeight: FontWeight.w400, color: Colors.white70, ), ), ], ), ), ), ), ], ), const SizedBox(height: 16), ], ), ), ), ), SliverPersistentHeader( pinned: true, delegate: _StickyHeaderDelegate( child: Container( height: 48, color: const Color(0xFF0E1211), padding: const EdgeInsets.symmetric(horizontal: 20), child: Row( children: [ Icon( _homeTab == 0 ? Icons.favorite : Icons.queue_music, color: const Color(0xFFB8D4D0), size: 20, ), const SizedBox(width: 8), Text( _homeTab == 0 ? '我的收藏' : '歌单列表', style: const TextStyle( fontSize: 20, fontWeight: FontWeight.w500, color: Color(0xFFB8D4D0), ), ), const Spacer(), _buildHomeTabIndicator(), ], ), ), ), ), SliverFillRemaining( hasScrollBody: true, child: PageView( controller: _homePageController, onPageChanged: (index) => safeSetState(() => _homeTab = index), children: [ _buildFavoritesTab(), _buildPlaylistsTab(), ], ), ), ], ), ), ], ), ); } }