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 'webdav_setup_page.dart'; // ================================================== // 歌曲数据模型(含元数据状态) // ================================================== class SongItem { final String path; // WebDAV 完整路径 final String fileName; // 文件名 final String? title; // 元数据标题 final String? artist; // 元数据艺术家 final String sourceTag; // 来源标签 final String metadataState; // "unknown" | "loading" | "success" | "failed" 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; late final Animation _opacity; 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), ); _opacity = Tween(begin: 0.0, end: 0.08).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: Container( decoration: BoxDecoration( color: Colors.white.withOpacity(_opacity.value), borderRadius: BorderRadius.circular(8), ), padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), child: child, ), ); }, child: widget.child, ), ), ); } } // ================================================== // 主页 // ================================================== class HomePage extends StatefulWidget { const HomePage({super.key}); @override State createState() => _HomePageState(); } class _HomePageState extends State { List _favorites = []; bool _isWebDAVConnected = false; String _webDAVUsername = ''; bool _isLoading = true; @override void initState() { super.initState(); _initWebDAV(); } Future _initWebDAV() async { setState(() => _isLoading = true); try { final hasCred = await WebDAVService.instance.loadCredentials(); if (hasCred) { _isWebDAVConnected = true; // 从 BaseUrl 中提取用户名(简化展示) final baseUrl = WebDAVService.instance.baseUrl; _webDAVUsername = baseUrl?.replaceAll(RegExp(r'^https?://'), '').split('/').first ?? '已连接'; await _loadMusicList(); } else { _isWebDAVConnected = false; _favorites = []; } } catch (e) { _isWebDAVConnected = false; _favorites = []; } finally { if (mounted) setState(() => _isLoading = false); } } Future _loadMusicList() async { try { final files = await WebDAVService.instance.getMusicFiles(); setState(() { _favorites = files.map((file) { return SongItem( path: file.path, fileName: file.name, title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''), artist: null, sourceTag: 'webdav', metadataState: 'unknown', ); }).toList(); }); } catch (e) { // 加载失败保持空列表 setState(() => _favorites = []); } } // 刷新列表(从 WebDAV 设置页返回时调用) Future _refreshFromWebDAV() async { final hasCred = await WebDAVService.instance.loadCredentials(); setState(() { _isWebDAVConnected = hasCred; if (hasCred) { final baseUrl = WebDAVService.instance.baseUrl; _webDAVUsername = baseUrl?.replaceAll(RegExp(r'^https?://'), '').split('/').first ?? '已连接'; } }); if (hasCred) { await _loadMusicList(); } else { setState(() => _favorites = []); } } void _playSong(SongItem song) async { try { final url = WebDAVService.instance.getFileUrl(song.path); await PlaybackService().play(url); // 更新 AudioService 状态 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, ), ); } } } @override Widget build(BuildContext context) { final audioService = context.watch(); final showMiniBar = audioService.currentSong != null; return Scaffold( backgroundColor: const Color(0xFF0E1211), body: Stack( children: [ CustomScrollView( slivers: [ // ---------- 顶部安全区域 + 标题 ---------- SliverToBoxAdapter( child: SafeArea( 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: () {}, ), ], ), ), ), ), // ---------- 媒体库区块 ---------- SliverToBoxAdapter( child: Padding( 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), // ---- WebDAV 入口 ---- _ClickableTile( onTap: () async { final result = await Navigator.push( context, MaterialPageRoute( builder: (_) => const WebDAVSetupPage(), ), ); if (result == true) { await _refreshFromWebDAV(); } }, 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( _isWebDAVConnected ? '● 已连接' : '● 未连接', style: TextStyle( fontSize: 13, color: _isWebDAVConnected ? const Color(0xFF4CAF50) : Colors.grey[500], fontWeight: FontWeight.w400, ), ), ], ), const SizedBox(height: 4), Text( _isWebDAVConnected ? _webDAVUsername : '点击连接', style: TextStyle( fontSize: 14, color: _isWebDAVConnected ? 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: Center( child: Column( mainAxisAlignment: MainAxisAlignment.center, children: [ Icon( Icons.music_note, size: 48, color: Colors.grey[600], ), const SizedBox(height: 16), Text( _isWebDAVConnected ? '还没有收藏歌曲\n去媒体库发现音乐' : '请先连接 WebDAV', textAlign: TextAlign.center, style: TextStyle( fontSize: 16, color: Colors.grey[500], height: 1.6, ), ), ], ), ), ) : SliverList( delegate: SliverChildBuilderDelegate( (context, index) { final song = _favorites[index]; return 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, ), onTap: () => _playSong(song), ), ); }, childCount: _favorites.length, ), ), ), ], ), // ---------- 底部 MiniPlayer ---------- if (showMiniBar) const Positioned( left: 0, right: 0, bottom: 0, child: MiniPlayerBar(), ), ], ), ); } } // ================================================== // 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; } }