diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index ab1e141..f51b6aa 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -5,11 +5,12 @@ 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; @@ -17,7 +18,7 @@ class SongItem { final String? title; final String? artist; final String sourceTag; - final String metadataState; // "unknown" | "loading" | "success" | "failed" + final String metadataState; SongItem({ required this.path, @@ -42,7 +43,7 @@ class SongItem { } // ================================================== -// 通用可点击组件(缩放 + 高亮,无涟漪) +// 通用可点击组件 // ================================================== class _ClickableTile extends StatefulWidget { final Widget child; @@ -61,7 +62,6 @@ 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); @@ -72,9 +72,6 @@ class _ClickableTileState extends State<_ClickableTile> _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 @@ -108,14 +105,7 @@ class _ClickableTileState extends State<_ClickableTile> 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: child, ); }, child: widget.child, @@ -125,458 +115,6 @@ class _ClickableTileState extends State<_ClickableTile> } } -// ================================================== -// 主页 -// ================================================== -class HomePage extends StatefulWidget { - const HomePage({super.key}); - - @override - State createState() => _HomePageState(); -} - -class _HomePageState extends State { - List _favorites = []; - bool _isLoading = true; - - @override - void initState() { - super.initState(); - _initWebDAV(); - } - - Future _initWebDAV() async { - setState(() => _isLoading = true); - try { - final hasCred = await WebDAVService.instance.loadCredentials(); - if (hasCred) { - // ✅ 不再自动加载音乐到收藏 - _favorites = []; - } else { - _favorites = []; - } - } catch (e) { - _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 = []); - } - } - - Future _refreshFromWebDAV() async { - final hasCred = await WebDAVService.instance.loadCredentials(); - if (hasCred) { - await _loadMusicList(); - } else { - setState(() => _favorites = []); - } - } - - 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, - ), - ); - } - } - } - - @override - Widget build(BuildContext context) { - // 直接从 WebDAVService 读取实时状态 - 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: 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 { - if (WebDAVService.instance.isConnected) { - // 已连接 → 直接进入文件列表 - await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const WebDAVFileListPage(), - ), - ); - // 返回后刷新界面(可能状态变化) - setState(() {}); - } else { - // 未连接 → 进入设置页 - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const WebDAVSetupPage(), - ), - ); - // 从设置页返回后刷新 - setState(() {}); - 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( - 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), - ], - ), - ), - ), - -// 在 home_page.dart 中,修改 SliverPersistentHeader 的 delegate - SliverPersistentHeader( - pinned: true, - delegate: _StickyHeaderDelegate( - child: SafeArea( - bottom: false, - 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( - isConnected - ? '还没有收藏歌曲\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 委托 // ================================================== @@ -601,3 +139,535 @@ class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate { 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(), + ], + ), + ); + } +} diff --git a/lib/pages/webdav_file_list_page.dart b/lib/pages/webdav_file_list_page.dart index 30015fd..f5da2e1 100644 --- a/lib/pages/webdav_file_list_page.dart +++ b/lib/pages/webdav_file_list_page.dart @@ -1,9 +1,4 @@ -// ============================================================ -// 文件名: webdav_file_list_page.dart -// 功能: WebDAV 文件浏览页面,支持文件夹导航和音乐播放 -// 调用方式: Navigator.push(context, MaterialPageRoute(builder: (_) => WebDAVFileListPage())) -// ============================================================ - +// lib/pages/webdav_file_list_page.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/webdav_service.dart'; @@ -11,7 +6,6 @@ import '../services/playback_service.dart'; import '../services/audio_service.dart'; class WebDAVFileListPage extends StatefulWidget { - /// 当前浏览路径,默认为根目录 '/' final String currentPath; const WebDAVFileListPage({super.key, this.currentPath = '/'}); @@ -26,6 +20,15 @@ class _WebDAVFileListPageState extends State { String _errorMessage = ''; String _currentPath = '/'; + // 安全解码 + String _safeDecode(String input) { + try { + return Uri.decodeComponent(input); + } catch (_) { + return input; // 解码失败时返回原始字符串 + } + } + @override void initState() { super.initState(); @@ -33,9 +36,6 @@ class _WebDAVFileListPageState extends State { _loadDirectory(); } - // ------------------------------------------------------------- - // 加载当前目录内容 - // ------------------------------------------------------------- Future _loadDirectory() async { setState(() { _isLoading = true; @@ -57,9 +57,6 @@ class _WebDAVFileListPageState extends State { } } - // ------------------------------------------------------------- - // 进入子目录 - // ------------------------------------------------------------- void _enterDirectory(WebDAVItem dir) { Navigator.push( context, @@ -69,9 +66,6 @@ class _WebDAVFileListPageState extends State { ); } - // ------------------------------------------------------------- - // 播放音乐文件 - // ------------------------------------------------------------- void _playSong(WebDAVItem file) async { try { final url = WebDAVService.instance.getFileUrl(file.path); @@ -79,13 +73,11 @@ class _WebDAVFileListPageState extends State { final song = Song( id: file.path, - title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''), + title: _safeDecode(file.name).replaceAll(RegExp(r'\.[^.]*$'), ''), artist: '未知艺术家', url: url, ); context.read().playSong(song); - - // ✅ 移除 SnackBar,直接显示 MiniPlayer } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -98,12 +90,13 @@ class _WebDAVFileListPageState extends State { } } - // ------------------------------------------------------------- - // 构建面包屑路径显示(只显示最后两级,避免过长) - // ------------------------------------------------------------- String _getDisplayPath() { - if (_currentPath == '/') return '根目录'; - final parts = _currentPath.split('/').where((s) => s.isNotEmpty).toList(); + if (_currentPath == '/' || _currentPath.isEmpty) return '根目录'; + + final decoded = _safeDecode(_currentPath); + final parts = decoded.split('/').where((s) => s.isNotEmpty).toList(); + + if (parts.isEmpty) return '根目录'; if (parts.length <= 2) return parts.join(' / '); return '... / ${parts.sublist(parts.length - 2).join(' / ')}'; } @@ -132,85 +125,74 @@ class _WebDAVFileListPageState extends State { ), ], ), - body: _buildBody(), - ); - } - - // ------------------------------------------------------------- - // 构建主体内容 - // ------------------------------------------------------------- - Widget _buildBody() { - if (_isLoading) { - return const Center( - child: CircularProgressIndicator( - color: Color(0xFFB8D4D0), - ), - ); - } - - if (_errorMessage.isNotEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.error_outline, size: 48, color: Colors.grey[600]), - const SizedBox(height: 16), - Text( - _errorMessage, - style: TextStyle(color: Colors.grey[400]), - textAlign: TextAlign.center, - ), - const SizedBox(height: 16), - ElevatedButton( - onPressed: _loadDirectory, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFB8D4D0), - foregroundColor: Colors.black87, + body: _isLoading + ? const Center( + child: CircularProgressIndicator( + color: Color(0xFFB8D4D0), ), - child: const Text('重试'), - ), - ], - ), - ); - } - - if (_items.isEmpty) { - return Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.folder_open, size: 48, color: Colors.grey[600]), - const SizedBox(height: 16), - Text( - '此目录为空', - style: TextStyle(color: Colors.grey[400]), - ), - const SizedBox(height: 8), - Text( - '支持格式: MP3, FLAC, M4A, APE, WAV, OPUS', - style: TextStyle(color: Colors.grey[600], fontSize: 12), - ), - ], - ), - ); - } - - return ListView.builder( - padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8), - itemCount: _items.length, - itemBuilder: (context, index) { - final item = _items[index]; - return _buildListItem(item); - }, + ) + : _errorMessage.isNotEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, + size: 48, color: Colors.grey[600]), + const SizedBox(height: 16), + Text( + _errorMessage, + style: TextStyle(color: Colors.grey[400]), + textAlign: TextAlign.center, + ), + const SizedBox(height: 16), + ElevatedButton( + onPressed: _loadDirectory, + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFB8D4D0), + foregroundColor: Colors.black87, + ), + child: const Text('重试'), + ), + ], + ), + ) + : _items.isEmpty + ? Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.folder_open, + size: 48, color: Colors.grey[600]), + const SizedBox(height: 16), + Text( + '此目录为空', + style: TextStyle(color: Colors.grey[400]), + ), + const SizedBox(height: 8), + Text( + '支持格式: MP3, FLAC, M4A, APE, WAV, OPUS', + style: TextStyle( + color: Colors.grey[600], fontSize: 12), + ), + ], + ), + ) + : ListView.builder( + padding: const EdgeInsets.symmetric( + horizontal: 16, vertical: 8), + itemCount: _items.length, + itemBuilder: (context, index) { + final item = _items[index]; + return _buildListItem(item); + }, + ), ); } - // ------------------------------------------------------------- - // 构建单个列表项(区分目录和文件) - // ------------------------------------------------------------- Widget _buildListItem(WebDAVItem item) { + final displayName = _safeDecode(item.name); + if (item.isDirectory) { - // ---------- 目录项 ---------- return ListTile( contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), leading: const Icon( @@ -219,7 +201,7 @@ class _WebDAVFileListPageState extends State { size: 32, ), title: Text( - item.name, + displayName, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w500, @@ -242,7 +224,6 @@ class _WebDAVFileListPageState extends State { onTap: () => _enterDirectory(item), ); } else { - // ---------- 音乐文件项 ---------- final sizeStr = item.size != null ? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB' : ''; @@ -254,7 +235,7 @@ class _WebDAVFileListPageState extends State { size: 28, ), title: Text( - item.name, + displayName, style: const TextStyle( fontSize: 16, fontWeight: FontWeight.w400, diff --git a/lib/services/webdav_service.dart b/lib/services/webdav_service.dart index 79c5b9a..0c2594b 100644 --- a/lib/services/webdav_service.dart +++ b/lib/services/webdav_service.dart @@ -1,16 +1,9 @@ -// ============================================================ -// 文件名: webdav_service.dart -// 功能: WebDAV 协议通信服务,支持文件夹浏览和音乐文件读取 -// ============================================================ - +// lib/services/webdav_service.dart import 'dart:convert'; import 'package:dio/dio.dart'; import 'package:shared_preferences/shared_preferences.dart'; import 'package:xml/xml.dart'; -// ----------------------------------------------------------------- -// WebDAV 服务单例 -// ----------------------------------------------------------------- class WebDAVService { static const String _keyBaseUrl = 'webdav_base_url'; static const String _keyUsername = 'webdav_username'; @@ -25,16 +18,11 @@ class WebDAVService { String? _baseUrl; String? _username; - // ------------------------------------------------------------- - // 公开 Getter - // ------------------------------------------------------------- bool get isConnected => _dio != null; String? get baseUrl => _baseUrl; String? get username => _username; - // ------------------------------------------------------------- - // 凭据管理 - // ------------------------------------------------------------- + // 保存凭据 Future saveCredentials( String baseUrl, String username, String password) async { final prefs = await SharedPreferences.getInstance(); @@ -53,6 +41,7 @@ class WebDAVService { )); } + // 加载已保存的凭据 Future loadCredentials() async { final prefs = await SharedPreferences.getInstance(); final baseUrl = prefs.getString(_keyBaseUrl); @@ -74,6 +63,7 @@ class WebDAVService { return false; } + // 清除凭据 Future clearCredentials() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyBaseUrl); @@ -84,15 +74,22 @@ class WebDAVService { _username = null; } - // ------------------------------------------------------------- - // 核心:列出目录内容(文件夹 + 音乐文件) - // 这是浏览模式的核心方法,支持进入子目录 - // ------------------------------------------------------------- + // 获取音乐文件列表(PROPFIND) Future> listDirectory({String path = '/'}) async { if (_dio == null) throw Exception('WebDAV 未连接'); final requestPath = path.startsWith('/') ? path : '/$path'; + // 路径规范化:去掉首尾斜杠,用于比较 + String _normalize(String p) { + var s = p; + if (s.startsWith('/')) s = s.substring(1); + if (s.endsWith('/')) s = s.substring(0, s.length - 1); + return s; + } + + final normalizedRequest = _normalize(requestPath); + final body = ''' @@ -124,14 +121,13 @@ class WebDAVService { final items = []; final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; - final responseNodes = xml.findAllElements('D:response'); - for (final responseNode in responseNodes) { + for (final responseNode in xml.findAllElements('D:response')) { final hrefNode = responseNode.findElements('D:href').firstOrNull; if (hrefNode == null) continue; String fullPath = Uri.decodeComponent(hrefNode.text.trim()); - // 去掉 baseUrl 前缀 + // 去掉 baseUrl 前缀,得到相对路径 String relativePath = fullPath; if (_baseUrl != null) { try { @@ -149,10 +145,10 @@ class WebDAVService { } } - // 跳过根目录自身 - if (relativePath.isEmpty || - relativePath == '/' || - relativePath == requestPath) { + // 使用规范化路径比较,跳过当前目录自身 + final normalizedRelative = _normalize(relativePath); + if (normalizedRelative.isEmpty || + normalizedRelative == normalizedRequest) { continue; } @@ -160,7 +156,6 @@ class WebDAVService { ? relativePath.substring(0, relativePath.length - 1) : relativePath; - // ✅ 解码文件名 String fileName = cleanPath.split('/').last; fileName = Uri.decodeComponent(fileName); if (fileName.isEmpty) continue; @@ -219,26 +214,28 @@ class WebDAVService { )); } - // 排序 + // 排序:目录在前,文件在后 items.sort((a, b) { if (a.isDirectory && !b.isDirectory) return -1; if (!a.isDirectory && b.isDirectory) return 1; return a.name.toLowerCase().compareTo(b.name.toLowerCase()); }); - // 只保留一条日志,避免刷屏 - print('WebDAV listDirectory: 找到 ${items.length} 项,路径: $requestPath'); return items; } - // ------------------------------------------------------------- - // 兼容旧接口:获取所有音乐文件(递归扫描) - // 用于主页的"我的收藏"列表(后续可改为读取用户收藏) - // ------------------------------------------------------------- + // 获取文件完整 URL + String getFileUrl(String path) { + if (_baseUrl == null) throw Exception('WebDAV 未配置'); + final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/'; + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + return '$base$cleanPath'; + } + + // 递归获取所有音乐文件(用于收藏列表) Future> getMusicFiles({String path = '/'}) async { if (_dio == null) throw Exception('WebDAV 未连接'); - // 递归获取所有文件(先获取当前目录,再递归子目录) final allItems = await _listAllRecursive(path); final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; @@ -255,7 +252,6 @@ class WebDAVService { .toList(); } - // 递归获取所有目录和文件(内部使用) Future> _listAllRecursive(String path) async { final items = await listDirectory(path: path); final result = []; @@ -263,32 +259,16 @@ class WebDAVService { for (final item in items) { result.add(item); if (item.isDirectory) { - // 递归获取子目录内容 try { final subItems = await _listAllRecursive(item.path); result.addAll(subItems); - } catch (_) { - // 忽略无法读取的子目录 - } + } catch (_) {} } } return result; } - - // ------------------------------------------------------------- - // 获取文件的完整下载 URL - // ------------------------------------------------------------- - String getFileUrl(String path) { - if (_baseUrl == null) throw Exception('WebDAV 未配置'); - final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/'; - final cleanPath = path.startsWith('/') ? path.substring(1) : path; - return '$base$cleanPath'; - } } -// ----------------------------------------------------------------- -// 数据模型:WebDAV 目录项(用于浏览模式) -// ----------------------------------------------------------------- class WebDAVItem { final String path; final String name; @@ -305,9 +285,6 @@ class WebDAVItem { }); } -// ----------------------------------------------------------------- -// 数据模型:WebDAV 文件项(用于播放列表) -// ----------------------------------------------------------------- class WebDAVFileItem { final String path; final String name; diff --git a/lib/widgets/magnetic_scroll_controller.dart b/lib/widgets/magnetic_scroll_controller.dart new file mode 100644 index 0000000..7a3355d --- /dev/null +++ b/lib/widgets/magnetic_scroll_controller.dart @@ -0,0 +1,150 @@ +import 'package:flutter/material.dart'; + +/// 磁吸进度状态 +class MagneticSnapState { + /// 吸附进度 0.0 ~ 1.0 + final double progress; + + /// 媒体库透明度 + double get mediaOpacity { + // 0% ~ 70%: 1.0 → 0.45 + // 70% ~ 100%: 0.45 → 0 + if (progress <= 0.7) { + return 1.0 - (progress / 0.7) * 0.55; + } else { + final t = (progress - 0.7) / 0.3; + return 0.45 * (1 - t); + } + } + + /// 收藏标题位移(从 +12px → 0) + double get favoriteTranslateY { + return (1 - progress) * 12; + } + + /// 收藏列表展开程度 0.0 ~ 1.0 + double get listReveal { + if (progress <= 0.7) return 0.0; + return (progress - 0.7) / 0.3; + } + + /// 是否完全吸附 + bool get isSnapped => progress >= 1.0; + + /// 是否在磁吸区(进度 > 70%) + bool get isInMagneticZone => progress > 0.7; + + const MagneticSnapState(this.progress); + + factory MagneticSnapState.initial() => const MagneticSnapState(0.0); + + MagneticSnapState copyWith({double? progress}) { + return MagneticSnapState(progress ?? this.progress); + } +} + +/// 磁吸控制器 +class MagneticScrollController extends ChangeNotifier { + /// 吸附进度 + double _progress = 0.0; + double get progress => _progress; + + /// 是否已吸附 + bool _isSnapped = false; + bool get isSnapped => _isSnapped; + + /// 动画控制器(用于吸附动画) + AnimationController? _animationController; + TickerProvider? _tickerProvider; + + /// 状态 + MagneticSnapState get state => MagneticSnapState(_progress); + + void init(TickerProvider vsync) { + _animationController = AnimationController( + vsync: vsync, + duration: const Duration(milliseconds: 300), + ); + } + + @override + void dispose() { + _animationController?.dispose(); + super.dispose(); + } + + /// 更新滚动进度(在滚动时调用) + void updateProgress(double newProgress) { + if (_isSnapped) return; + _progress = newProgress.clamp(0.0, 1.0); + notifyListeners(); + } + + /// 处理松手事件 + void onDragEnd(double velocity) { + if (_isSnapped) return; + + final threshold = 0.45; // 45% 触发吸附 + final shouldSnap = _progress > threshold || velocity > 800; + + if (shouldSnap) { + snapOpen(); + } else { + snapBack(); + } + } + + /// 吸附展开 + void snapOpen() { + if (_isSnapped) return; + _isSnapped = true; + + _animationController?.reset(); + _animationController + ?.animateTo( + 1.0, + duration: const Duration(milliseconds: 300), + curve: Curves.easeOutBack, + ) + .then((_) { + _progress = 1.0; + notifyListeners(); + }); + + // 实时更新进度 + _animationController?.addListener(() { + _progress = _animationController!.value; + notifyListeners(); + }); + } + + /// 回弹 + void snapBack() { + if (_isSnapped) return; + + _animationController?.reset(); + _animationController + ?.animateTo( + 0.0, + duration: const Duration(milliseconds: 250), + curve: Curves.easeOut, + ) + .then((_) { + _progress = 0.0; + notifyListeners(); + }); + + _animationController?.addListener(() { + _progress = _animationController!.value; + notifyListeners(); + }); + } + + /// 重置状态(退出页面时) + void reset() { + _isSnapped = false; + _progress = 0.0; + _animationController?.reset(); + notifyListeners(); + } +} diff --git a/lib/widgets/magnetic_scroll_physics.dart b/lib/widgets/magnetic_scroll_physics.dart new file mode 100644 index 0000000..a8e67b7 --- /dev/null +++ b/lib/widgets/magnetic_scroll_physics.dart @@ -0,0 +1,74 @@ +// lib/widgets/magnetic_scroll_physics.dart +import 'package:flutter/material.dart'; + +class MagneticScrollPhysics extends ClampingScrollPhysics { + final double snapPoint; + final double magneticZoneStart; + + const MagneticScrollPhysics({ + required this.snapPoint, + this.magneticZoneStart = 0.20, + super.parent, + }); + + @override + MagneticScrollPhysics applyTo(ScrollPhysics? ancestor) { + return MagneticScrollPhysics( + snapPoint: snapPoint, + magneticZoneStart: magneticZoneStart, + parent: buildParent(ancestor), + ); + } + + @override + Simulation? createBallisticSimulation( + ScrollMetrics position, + double velocity, + ) { + final offset = position.pixels; + + if (offset <= 0 || offset >= snapPoint) { + return super.createBallisticSimulation(position, velocity); + } + + final shouldSnap = _shouldSnap(offset, velocity); + + final target = shouldSnap ? snapPoint : 0.0; + + if ((offset - target).abs() < 1.0) { + return null; + } + + return ScrollSpringSimulation( + SpringDescription( + mass: 1.0, + stiffness: 320.0, + damping: 26.0, + ), + offset, + target, + velocity, + tolerance: const Tolerance( + velocity: 0.01, + distance: 0.5, + ), + ); + } + + bool _shouldSnap(double offset, double velocity) { + final zoneStart = snapPoint * magneticZoneStart; + + // 向上滑 → 吸附展开 + if (velocity > 20) { + return true; + } + + // 向下滑 → 回去 + if (velocity < -20) { + return false; + } + + // 松手速度很小,用当前位置决定 + return offset >= zoneStart; + } +}