From 457a9bf1e5517e73b8de07636c089aed16e24b0a Mon Sep 17 00:00:00 2001 From: lxh2875931338 Date: Sun, 16 Aug 2026 21:58:18 +0800 Subject: [PATCH] =?UTF-8?q?=E7=95=8C=E9=9D=A2=E9=83=A8=E5=88=86=E9=98=B6?= =?UTF-8?q?=E6=AE=B5=E6=80=A7=E8=BE=BE=E6=88=90=E6=95=88=E6=9E=9C?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- lib/main.dart | 47 +++- lib/pages/home_page.dart | 130 +++++------ lib/pages/webdav_file_list_page.dart | 320 +++++++++++++++++---------- lib/pages/webdav_setup_page.dart | 17 +- lib/services/webdav_service.dart | 205 ++++++++++++++--- lib/widgets/mini_player_bar.dart | 142 ++++++------ 6 files changed, 562 insertions(+), 299 deletions(-) diff --git a/lib/main.dart b/lib/main.dart index 390a38b..0a561ec 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -8,11 +8,50 @@ import 'pages/home_page.dart'; void main() { WidgetsFlutterBinding.ensureInitialized(); - // ✅ 关键:必须在调用任何 media_kit API 之前执行 - MediaKit.ensureInitialized(); + // 捕获初始化阶段的所有异常,避免引擎崩溃 + try { + // 必须先初始化 media_kit + MediaKit.ensureInitialized(); + // 然后初始化播放服务 + PlaybackService().init(); + } catch (e, stack) { + print('❌ 初始化失败: $e'); + print(stack); + // 显示错误页面,而不是白屏 + runApp( + MaterialApp( + home: Scaffold( + backgroundColor: const Color(0xFF0E1211), + body: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.error_outline, size: 64, color: Colors.red[300]), + const SizedBox(height: 16), + Text( + '应用初始化失败', + style: TextStyle(color: Colors.white, fontSize: 20), + ), + const SizedBox(height: 8), + Text( + '$e', + style: TextStyle(color: Colors.grey[400], fontSize: 14), + textAlign: TextAlign.center, + ), + ], + ), + ), + ), + ), + ); + return; + } - // 现在可以安全地初始化 PlaybackService - PlaybackService().init(); + // 运行时错误捕获 + FlutterError.onError = (details) { + print('❌ Flutter Error: ${details.exception}'); + print('Stack: ${details.stack}'); + }; runApp( ChangeNotifierProvider( diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 831feac..ab1e141 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,3 +1,4 @@ +// lib/pages/home_page.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/audio_service.dart'; @@ -5,16 +6,17 @@ import '../services/webdav_service.dart'; import '../services/playback_service.dart'; import '../widgets/mini_player_bar.dart'; import 'webdav_setup_page.dart'; +import 'webdav_file_list_page.dart'; // ================================================== // 歌曲数据模型(含元数据状态) // ================================================== class SongItem { - final String path; // WebDAV 完整路径 - final String fileName; // 文件名 - final String? title; // 元数据标题 - final String? artist; // 元数据艺术家 - final String sourceTag; // 来源标签 + final String path; + final String fileName; + final String? title; + final String? artist; + final String sourceTag; final String metadataState; // "unknown" | "loading" | "success" | "failed" SongItem({ @@ -135,8 +137,6 @@ class HomePage extends StatefulWidget { class _HomePageState extends State { List _favorites = []; - bool _isWebDAVConnected = false; - String _webDAVUsername = ''; bool _isLoading = true; @override @@ -150,19 +150,12 @@ class _HomePageState extends State { 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(); + // ✅ 不再自动加载音乐到收藏 + _favorites = []; } else { - _isWebDAVConnected = false; _favorites = []; } } catch (e) { - _isWebDAVConnected = false; _favorites = []; } finally { if (mounted) setState(() => _isLoading = false); @@ -185,23 +178,12 @@ class _HomePageState extends State { }).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 { @@ -213,7 +195,6 @@ class _HomePageState extends State { try { final url = WebDAVService.instance.getFileUrl(song.path); await PlaybackService().play(url); - // 更新 AudioService 状态 context.read().playSong(Song( id: song.path, title: song.displayTitle, @@ -234,6 +215,9 @@ class _HomePageState extends State { @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; @@ -243,7 +227,7 @@ class _HomePageState extends State { children: [ CustomScrollView( slivers: [ - // ---------- 顶部安全区域 + 标题 ---------- + // ---- 顶部标题 ---- SliverToBoxAdapter( child: SafeArea( child: Padding( @@ -270,7 +254,7 @@ class _HomePageState extends State { ), ), - // ---------- 媒体库区块 ---------- + // ---- 媒体库 ---- SliverToBoxAdapter( child: Padding( padding: const EdgeInsets.symmetric(horizontal: 20), @@ -290,14 +274,29 @@ class _HomePageState extends State { // ---- WebDAV 入口 ---- _ClickableTile( onTap: () async { - final result = await Navigator.push( - context, - MaterialPageRoute( - builder: (_) => const WebDAVSetupPage(), - ), - ); - if (result == true) { - await _refreshFromWebDAV(); + 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( @@ -328,10 +327,10 @@ class _HomePageState extends State { ), const SizedBox(width: 16), Text( - _isWebDAVConnected ? '● 已连接' : '● 未连接', + isConnected ? '● 已连接' : '● 未连接', style: TextStyle( fontSize: 13, - color: _isWebDAVConnected + color: isConnected ? const Color(0xFF4CAF50) : Colors.grey[500], fontWeight: FontWeight.w400, @@ -341,12 +340,10 @@ class _HomePageState extends State { ), const SizedBox(height: 4), Text( - _isWebDAVConnected - ? _webDAVUsername - : '点击连接', + isConnected ? username : '点击连接', style: TextStyle( fontSize: 14, - color: _isWebDAVConnected + color: isConnected ? Colors.grey[400] : Colors.grey[600], ), @@ -450,34 +447,37 @@ class _HomePageState extends State { ), ), - // ---------- “我的收藏” Sticky Header ---------- +// 在 home_page.dart 中,修改 SliverPersistentHeader 的 delegate 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), + 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, @@ -505,7 +505,7 @@ class _HomePageState extends State { ), const SizedBox(height: 16), Text( - _isWebDAVConnected + isConnected ? '还没有收藏歌曲\n去媒体库发现音乐' : '请先连接 WebDAV', textAlign: TextAlign.center, @@ -563,7 +563,7 @@ class _HomePageState extends State { ], ), - // ---------- 底部 MiniPlayer ---------- + // ---- 底部 MiniPlayer ---- if (showMiniBar) const Positioned( left: 0, diff --git a/lib/pages/webdav_file_list_page.dart b/lib/pages/webdav_file_list_page.dart index ef11779..30015fd 100644 --- a/lib/pages/webdav_file_list_page.dart +++ b/lib/pages/webdav_file_list_page.dart @@ -1,46 +1,52 @@ +// ============================================================ +// 文件名: webdav_file_list_page.dart +// 功能: WebDAV 文件浏览页面,支持文件夹导航和音乐播放 +// 调用方式: Navigator.push(context, MaterialPageRoute(builder: (_) => WebDAVFileListPage())) +// ============================================================ + import 'package:flutter/material.dart'; +import 'package:provider/provider.dart'; import '../services/webdav_service.dart'; import '../services/playback_service.dart'; import '../services/audio_service.dart'; -import 'package:provider/provider.dart'; class WebDAVFileListPage extends StatefulWidget { - const WebDAVFileListPage({super.key, this.currentPath = '/'}); - + /// 当前浏览路径,默认为根目录 '/' final String currentPath; + const WebDAVFileListPage({super.key, this.currentPath = '/'}); + @override State createState() => _WebDAVFileListPageState(); } class _WebDAVFileListPageState extends State { - List _files = []; - List _directories = []; + List _items = []; bool _isLoading = true; String _errorMessage = ''; + String _currentPath = '/'; @override void initState() { super.initState(); - _loadFiles(); + _currentPath = widget.currentPath; + _loadDirectory(); } - Future _loadFiles() async { + // ------------------------------------------------------------- + // 加载当前目录内容 + // ------------------------------------------------------------- + Future _loadDirectory() async { setState(() { _isLoading = true; _errorMessage = ''; }); try { - final allItems = - await WebDAVService.instance.getMusicFiles(path: widget.currentPath); - - // 分离目录和文件(这里 getMusicFiles 只返回音乐文件,但如果有目录逻辑需要扩展) - // 由于 getMusicFiles 只返回音乐文件,我们需要获取目录列表 - // 这里先简化:只显示音乐文件 + final items = + await WebDAVService.instance.listDirectory(path: _currentPath); setState(() { - _files = allItems; - _directories = []; + _items = items; _isLoading = false; }); } catch (e) { @@ -51,12 +57,26 @@ class _WebDAVFileListPageState extends State { } } - void _playSong(WebDAVFileItem file) async { + // ------------------------------------------------------------- + // 进入子目录 + // ------------------------------------------------------------- + void _enterDirectory(WebDAVItem dir) { + Navigator.push( + context, + MaterialPageRoute( + builder: (_) => WebDAVFileListPage(currentPath: dir.path), + ), + ); + } + + // ------------------------------------------------------------- + // 播放音乐文件 + // ------------------------------------------------------------- + void _playSong(WebDAVItem file) async { try { final url = WebDAVService.instance.getFileUrl(file.path); await PlaybackService().play(url); - // 更新 AudioService 状态 final song = Song( id: file.path, title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''), @@ -65,15 +85,7 @@ class _WebDAVFileListPageState extends State { ); context.read().playSong(song); - if (mounted) { - ScaffoldMessenger.of(context).showSnackBar( - SnackBar( - content: Text('正在播放: ${file.name}'), - backgroundColor: const Color(0xFF4CAF50), - duration: const Duration(seconds: 1), - ), - ); - } + // ✅ 移除 SnackBar,直接显示 MiniPlayer } catch (e) { if (mounted) { ScaffoldMessenger.of(context).showSnackBar( @@ -86,12 +98,25 @@ class _WebDAVFileListPageState extends State { } } + // ------------------------------------------------------------- + // 构建面包屑路径显示(只显示最后两级,避免过长) + // ------------------------------------------------------------- + String _getDisplayPath() { + if (_currentPath == '/') return '根目录'; + final parts = _currentPath.split('/').where((s) => s.isNotEmpty).toList(); + if (parts.length <= 2) return parts.join(' / '); + return '... / ${parts.sublist(parts.length - 2).join(' / ')}'; + } + @override Widget build(BuildContext context) { return Scaffold( backgroundColor: const Color(0xFF0E1211), appBar: AppBar( - title: const Text('音乐库'), + title: Text( + _getDisplayPath(), + style: const TextStyle(fontSize: 16), + ), backgroundColor: Colors.transparent, elevation: 0, foregroundColor: Colors.white, @@ -102,104 +127,155 @@ class _WebDAVFileListPageState extends State { actions: [ IconButton( icon: const Icon(Icons.refresh), - onPressed: _loadFiles, + onPressed: _loadDirectory, + tooltip: '刷新', ), ], ), - body: _isLoading - ? const Center( - child: CircularProgressIndicator( - color: Color(0xFFB8D4D0), - ), - ) - : _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: _loadFiles, - style: ElevatedButton.styleFrom( - backgroundColor: const Color(0xFFB8D4D0), - foregroundColor: Colors.black87, - ), - child: const Text('重试'), - ), - ], - ), - ) - : _files.isEmpty - ? Center( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - Icon(Icons.music_note, - 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: _files.length, - itemBuilder: (context, index) { - final file = _files[index]; - return ListTile( - contentPadding: const EdgeInsets.symmetric( - horizontal: 4, vertical: 2), - leading: const Icon( - Icons.audiotrack, - color: Color(0xFFB8D4D0), - size: 28, - ), - title: Text( - file.name, - style: const TextStyle( - fontSize: 16, - fontWeight: FontWeight.w400, - color: Colors.white, - ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - subtitle: Text( - file.size != null - ? '${(file.size! / 1024 / 1024).toStringAsFixed(1)} MB' - : '', - style: TextStyle( - fontSize: 12, - color: Colors.grey[500], - ), - ), - trailing: IconButton( - icon: const Icon(Icons.play_arrow, - color: Color(0xFFB8D4D0)), - onPressed: () => _playSong(file), - ), - onTap: () => _playSong(file), - ); - }, - ), + 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, + ), + 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); + }, + ); + } + + // ------------------------------------------------------------- + // 构建单个列表项(区分目录和文件) + // ------------------------------------------------------------- + Widget _buildListItem(WebDAVItem item) { + if (item.isDirectory) { + // ---------- 目录项 ---------- + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + leading: const Icon( + Icons.folder_outlined, + color: Color(0xFFB8D4D0), + size: 32, + ), + title: Text( + item.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + '文件夹', + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + trailing: const Icon( + Icons.chevron_right, + color: Colors.grey, + ), + onTap: () => _enterDirectory(item), + ); + } else { + // ---------- 音乐文件项 ---------- + final sizeStr = item.size != null + ? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB' + : ''; + return ListTile( + contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2), + leading: const Icon( + Icons.audiotrack, + color: Color(0xFFB8D4D0), + size: 28, + ), + title: Text( + item.name, + style: const TextStyle( + fontSize: 16, + fontWeight: FontWeight.w400, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + sizeStr, + style: TextStyle( + fontSize: 12, + color: Colors.grey[500], + ), + ), + trailing: IconButton( + icon: const Icon(Icons.play_arrow, color: Color(0xFFB8D4D0)), + onPressed: () => _playSong(item), + ), + onTap: () => _playSong(item), + ); + } + } } diff --git a/lib/pages/webdav_setup_page.dart b/lib/pages/webdav_setup_page.dart index 018eaf6..bfeadb7 100644 --- a/lib/pages/webdav_setup_page.dart +++ b/lib/pages/webdav_setup_page.dart @@ -1,5 +1,6 @@ import 'package:flutter/material.dart'; import '../services/webdav_service.dart'; +import 'webdav_file_list_page.dart'; class WebDAVSetupPage extends StatefulWidget { const WebDAVSetupPage({super.key}); @@ -38,7 +39,6 @@ class _WebDAVSetupPageState extends State { _statusText = hasCred ? '已连接' : '未连接'; if (hasCred) { _baseUrlController.text = WebDAVService.instance.baseUrl ?? ''; - // 用户名不显示,保持隐私 } }); } @@ -55,7 +55,7 @@ class _WebDAVSetupPageState extends State { _passwordController.text.trim(), ); - // 尝试列出文件以验证连接 + // 验证连接 await WebDAVService.instance.getMusicFiles(); setState(() { @@ -71,7 +71,13 @@ class _WebDAVSetupPageState extends State { backgroundColor: Color(0xFF4CAF50), ), ); - Navigator.pop(context, true); + // ✅ 登录成功 → 直接跳转到文件列表页 + Navigator.pushReplacement( + context, + MaterialPageRoute( + builder: (_) => const WebDAVFileListPage(), + ), + ); } } catch (e) { setState(() { @@ -103,6 +109,7 @@ class _WebDAVSetupPageState extends State { backgroundColor: Colors.grey, ), ); + Navigator.pop(context); } } @@ -115,6 +122,10 @@ class _WebDAVSetupPageState extends State { backgroundColor: Colors.transparent, elevation: 0, foregroundColor: Colors.white, + leading: IconButton( + icon: const Icon(Icons.arrow_back_ios_new), + onPressed: () => Navigator.pop(context), + ), ), body: Padding( padding: const EdgeInsets.all(24), diff --git a/lib/services/webdav_service.dart b/lib/services/webdav_service.dart index 575624a..79c5b9a 100644 --- a/lib/services/webdav_service.dart +++ b/lib/services/webdav_service.dart @@ -1,8 +1,16 @@ +// ============================================================ +// 文件名: webdav_service.dart +// 功能: WebDAV 协议通信服务,支持文件夹浏览和音乐文件读取 +// ============================================================ + 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'; @@ -17,11 +25,16 @@ 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(); @@ -40,7 +53,6 @@ class WebDAVService { )); } - // 加载已保存的凭据 Future loadCredentials() async { final prefs = await SharedPreferences.getInstance(); final baseUrl = prefs.getString(_keyBaseUrl); @@ -62,7 +74,6 @@ class WebDAVService { return false; } - // 清除凭据 Future clearCredentials() async { final prefs = await SharedPreferences.getInstance(); await prefs.remove(_keyBaseUrl); @@ -73,11 +84,15 @@ class WebDAVService { _username = null; } - // 获取音乐文件列表(通过 PROPFIND) - Future> getMusicFiles({String path = '/'}) async { + // ------------------------------------------------------------- + // 核心:列出目录内容(文件夹 + 音乐文件) + // 这是浏览模式的核心方法,支持进入子目录 + // ------------------------------------------------------------- + Future> listDirectory({String path = '/'}) async { if (_dio == null) throw Exception('WebDAV 未连接'); - // PROPFIND 请求体(Depth: 1 表示获取子项) + final requestPath = path.startsWith('/') ? path : '/$path'; + final body = ''' @@ -87,10 +102,10 @@ class WebDAVService { - '''; + '''; final response = await _dio!.request( - path, + requestPath, options: Options( method: 'PROPFIND', headers: { @@ -106,67 +121,163 @@ class WebDAVService { } final xml = XmlDocument.parse(response.data as String); + final items = []; final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; - final items = []; - - // 查找所有响应项 - for (final responseNode in xml.findAllElements('response')) { - final hrefNode = responseNode.findElements('href').firstOrNull; + final responseNodes = xml.findAllElements('D:response'); + for (final responseNode in responseNodes) { + 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) { - final baseUri = Uri.parse(_baseUrl!); - final fullUri = Uri.parse(fullPath); - relativePath = fullUri.path; - if (baseUri.path.isNotEmpty && relativePath.startsWith(baseUri.path)) { - relativePath = relativePath.substring(baseUri.path.length); + try { + final baseUri = Uri.parse(_baseUrl!); + final fullUri = Uri.parse(fullPath); + String fullPathOnly = fullUri.path; + if (baseUri.path.isNotEmpty && + fullPathOnly.startsWith(baseUri.path)) { + relativePath = fullPathOnly.substring(baseUri.path.length); + } else { + relativePath = fullPathOnly; + } + } catch (_) { + relativePath = fullPath; } } - if (relativePath.isEmpty || relativePath == '/') continue; - // 检查是否是文件(非集合) - final resTypeNode = responseNode.findElements('resourcetype').firstOrNull; - final isCollection = - resTypeNode?.findElements('collection').isNotEmpty ?? false; - if (isCollection) continue; - - // 检查扩展名 - final fileName = relativePath.split('/').last; - if (!musicExtensions.any((ext) => fileName.toLowerCase().endsWith(ext))) + // 跳过根目录自身 + if (relativePath.isEmpty || + relativePath == '/' || + relativePath == requestPath) { continue; + } + + final cleanPath = relativePath.endsWith('/') + ? relativePath.substring(0, relativePath.length - 1) + : relativePath; + + // ✅ 解码文件名 + String fileName = cleanPath.split('/').last; + fileName = Uri.decodeComponent(fileName); + if (fileName.isEmpty) continue; + + // 判断是否为目录 + bool isDirectory = false; + final resTypeNode = + responseNode.findElements('D:resourcetype').firstOrNull; + if (resTypeNode != null) { + final collection = resTypeNode.findElements('D:collection').firstOrNull; + if (collection != null) { + isDirectory = true; + } + } + if (!isDirectory && fullPath.endsWith('/')) { + isDirectory = true; + } + + if (isDirectory) { + items.add(WebDAVItem( + path: cleanPath, + name: fileName, + isDirectory: true, + size: null, + modified: null, + )); + continue; + } + + // 只保留音乐文件 + if (!musicExtensions.any((ext) => fileName.toLowerCase().endsWith(ext))) { + continue; + } - // 获取文件大小和修改时间 int? size; DateTime? modified; final sizeNode = - responseNode.findElements('getcontentlength').firstOrNull; - if (sizeNode != null) { + responseNode.findElements('D:getcontentlength').firstOrNull; + if (sizeNode != null && sizeNode.text.isNotEmpty) { size = int.tryParse(sizeNode.text); } final modifiedNode = - responseNode.findElements('getlastmodified').firstOrNull; + responseNode.findElements('D:getlastmodified').firstOrNull; if (modifiedNode != null) { try { modified = DateTime.parse(modifiedNode.text); } catch (_) {} } - items.add(WebDAVFileItem( - path: relativePath, + items.add(WebDAVItem( + path: cleanPath, name: fileName, + isDirectory: false, size: size, modified: modified, )); } + // 排序 + 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 + // ------------------------------------------------------------- + // 兼容旧接口:获取所有音乐文件(递归扫描) + // 用于主页的"我的收藏"列表(后续可改为读取用户收藏) + // ------------------------------------------------------------- + Future> getMusicFiles({String path = '/'}) async { + if (_dio == null) throw Exception('WebDAV 未连接'); + + // 递归获取所有文件(先获取当前目录,再递归子目录) + final allItems = await _listAllRecursive(path); + final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; + + return allItems + .where((item) => + !item.isDirectory && + musicExtensions.any((ext) => item.name.toLowerCase().endsWith(ext))) + .map((item) => WebDAVFileItem( + path: item.path, + name: item.name, + size: item.size, + modified: item.modified, + )) + .toList(); + } + + // 递归获取所有目录和文件(内部使用) + Future> _listAllRecursive(String path) async { + final items = await listDirectory(path: path); + final result = []; + + for (final item in items) { + result.add(item); + if (item.isDirectory) { + // 递归获取子目录内容 + try { + final subItems = await _listAllRecursive(item.path); + result.addAll(subItems); + } catch (_) { + // 忽略无法读取的子目录 + } + } + } + return result; + } + + // ------------------------------------------------------------- + // 获取文件的完整下载 URL + // ------------------------------------------------------------- String getFileUrl(String path) { if (_baseUrl == null) throw Exception('WebDAV 未配置'); final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/'; @@ -175,6 +286,28 @@ class WebDAVService { } } +// ----------------------------------------------------------------- +// 数据模型:WebDAV 目录项(用于浏览模式) +// ----------------------------------------------------------------- +class WebDAVItem { + final String path; + final String name; + final bool isDirectory; + final int? size; + final DateTime? modified; + + WebDAVItem({ + required this.path, + required this.name, + required this.isDirectory, + this.size, + this.modified, + }); +} + +// ----------------------------------------------------------------- +// 数据模型:WebDAV 文件项(用于播放列表) +// ----------------------------------------------------------------- class WebDAVFileItem { final String path; final String name; diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart index c4b4f5b..3468541 100644 --- a/lib/widgets/mini_player_bar.dart +++ b/lib/widgets/mini_player_bar.dart @@ -12,82 +12,86 @@ class MiniPlayerBar extends StatelessWidget { if (song == null) return const SizedBox.shrink(); - return Container( - height: 64, - decoration: BoxDecoration( - color: const Color(0xFF1A1F1E), - boxShadow: [ - BoxShadow( - color: Colors.black.withOpacity(0.4), - blurRadius: 12, - offset: const Offset(0, -4), - ), - ], - gradient: LinearGradient( - begin: Alignment.topCenter, - end: Alignment.bottomCenter, - colors: [ - Colors.transparent, - Colors.black.withOpacity(0.1), + return SafeArea( + top: false, + bottom: true, + child: Container( + height: 64, + decoration: BoxDecoration( + color: const Color(0xFF1A1F1E), // 不透明背景 + boxShadow: [ + BoxShadow( + color: Colors.black.withOpacity(0.4), + blurRadius: 12, + offset: const Offset(0, -4), + ), ], - stops: const [0.0, 1.0], + gradient: LinearGradient( + begin: Alignment.topCenter, + end: Alignment.bottomCenter, + colors: [ + Colors.transparent, + Colors.black.withOpacity(0.1), + ], + stops: const [0.0, 1.0], + ), ), - ), - child: Row( - children: [ - const SizedBox(width: 12), - Container( - width: 48, - height: 48, - decoration: BoxDecoration( - color: const Color(0xFF2A3332), - borderRadius: BorderRadius.circular(4), + child: Row( + children: [ + const SizedBox(width: 12), + Container( + width: 48, + height: 48, + decoration: BoxDecoration( + color: const Color(0xFF2A3332), + borderRadius: BorderRadius.circular(4), + ), + child: + const Icon(Icons.music_note, color: Colors.white38, size: 24), ), - child: - const Icon(Icons.music_note, color: Colors.white38, size: 24), - ), - const SizedBox(width: 12), - Expanded( - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - Text( - song.title, - style: const TextStyle( - fontSize: 15, - fontWeight: FontWeight.w500, - color: Colors.white, + const SizedBox(width: 12), + Expanded( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + song.title, + style: const TextStyle( + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - Text( - song.artist, - style: TextStyle( - fontSize: 13, - color: Colors.grey[400], + Text( + song.artist, + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, ), - maxLines: 1, - overflow: TextOverflow.ellipsis, - ), - ], + ], + ), ), - ), - IconButton( - icon: Icon( - service.isPlaying ? Icons.pause : Icons.play_arrow, - color: Colors.white, + IconButton( + icon: Icon( + service.isPlaying ? Icons.pause : Icons.play_arrow, + color: Colors.white, + ), + onPressed: () => context.read().togglePlay(), ), - onPressed: () => context.read().togglePlay(), - ), - IconButton( - icon: - const Icon(Icons.playlist_play_outlined, color: Colors.white54), - onPressed: () {}, - ), - const SizedBox(width: 4), - ], + IconButton( + icon: const Icon(Icons.playlist_play_outlined, + color: Colors.white54), + onPressed: () {}, + ), + const SizedBox(width: 4), + ], + ), ), ); }