界面部分阶段性达成效果

This commit is contained in:
2026-08-16 21:58:18 +08:00
parent 1dd669287c
commit 457a9bf1e5
6 changed files with 562 additions and 299 deletions
+42 -3
View File
@@ -8,11 +8,50 @@ import 'pages/home_page.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized(); WidgetsFlutterBinding.ensureInitialized();
// ✅ 关键:必须在调用任何 media_kit API 之前执行 // 捕获初始化阶段的所有异常,避免引擎崩溃
try {
// 必须先初始化 media_kit
MediaKit.ensureInitialized(); MediaKit.ensureInitialized();
// 然后初始化播放服务
// 现在可以安全地初始化 PlaybackService
PlaybackService().init(); 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;
}
// 运行时错误捕获
FlutterError.onError = (details) {
print('❌ Flutter Error: ${details.exception}');
print('Stack: ${details.stack}');
};
runApp( runApp(
ChangeNotifierProvider( ChangeNotifierProvider(
+40 -40
View File
@@ -1,3 +1,4 @@
// lib/pages/home_page.dart
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
@@ -5,16 +6,17 @@ import '../services/webdav_service.dart';
import '../services/playback_service.dart'; import '../services/playback_service.dart';
import '../widgets/mini_player_bar.dart'; import '../widgets/mini_player_bar.dart';
import 'webdav_setup_page.dart'; import 'webdav_setup_page.dart';
import 'webdav_file_list_page.dart';
// ================================================== // ==================================================
// 歌曲数据模型(含元数据状态) // 歌曲数据模型(含元数据状态)
// ================================================== // ==================================================
class SongItem { class SongItem {
final String path; // WebDAV 完整路径 final String path;
final String fileName; // 文件名 final String fileName;
final String? title; // 元数据标题 final String? title;
final String? artist; // 元数据艺术家 final String? artist;
final String sourceTag; // 来源标签 final String sourceTag;
final String metadataState; // "unknown" | "loading" | "success" | "failed" final String metadataState; // "unknown" | "loading" | "success" | "failed"
SongItem({ SongItem({
@@ -135,8 +137,6 @@ class HomePage extends StatefulWidget {
class _HomePageState extends State<HomePage> { class _HomePageState extends State<HomePage> {
List<SongItem> _favorites = []; List<SongItem> _favorites = [];
bool _isWebDAVConnected = false;
String _webDAVUsername = '';
bool _isLoading = true; bool _isLoading = true;
@override @override
@@ -150,19 +150,12 @@ class _HomePageState extends State<HomePage> {
try { try {
final hasCred = await WebDAVService.instance.loadCredentials(); final hasCred = await WebDAVService.instance.loadCredentials();
if (hasCred) { if (hasCred) {
_isWebDAVConnected = true; // ✅ 不再自动加载音乐到收藏
// 从 BaseUrl 中提取用户名(简化展示) _favorites = [];
final baseUrl = WebDAVService.instance.baseUrl;
_webDAVUsername =
baseUrl?.replaceAll(RegExp(r'^https?://'), '').split('/').first ??
'已连接';
await _loadMusicList();
} else { } else {
_isWebDAVConnected = false;
_favorites = []; _favorites = [];
} }
} catch (e) { } catch (e) {
_isWebDAVConnected = false;
_favorites = []; _favorites = [];
} finally { } finally {
if (mounted) setState(() => _isLoading = false); if (mounted) setState(() => _isLoading = false);
@@ -185,23 +178,12 @@ class _HomePageState extends State<HomePage> {
}).toList(); }).toList();
}); });
} catch (e) { } catch (e) {
// 加载失败保持空列表
setState(() => _favorites = []); setState(() => _favorites = []);
} }
} }
// 刷新列表(从 WebDAV 设置页返回时调用)
Future<void> _refreshFromWebDAV() async { Future<void> _refreshFromWebDAV() async {
final hasCred = await WebDAVService.instance.loadCredentials(); 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) { if (hasCred) {
await _loadMusicList(); await _loadMusicList();
} else { } else {
@@ -213,7 +195,6 @@ class _HomePageState extends State<HomePage> {
try { try {
final url = WebDAVService.instance.getFileUrl(song.path); final url = WebDAVService.instance.getFileUrl(song.path);
await PlaybackService().play(url); await PlaybackService().play(url);
// 更新 AudioService 状态
context.read<AudioService>().playSong(Song( context.read<AudioService>().playSong(Song(
id: song.path, id: song.path,
title: song.displayTitle, title: song.displayTitle,
@@ -234,6 +215,9 @@ class _HomePageState extends State<HomePage> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
// 直接从 WebDAVService 读取实时状态
final isConnected = WebDAVService.instance.isConnected;
final username = WebDAVService.instance.username ?? '点击连接';
final audioService = context.watch<AudioService>(); final audioService = context.watch<AudioService>();
final showMiniBar = audioService.currentSong != null; final showMiniBar = audioService.currentSong != null;
@@ -243,7 +227,7 @@ class _HomePageState extends State<HomePage> {
children: [ children: [
CustomScrollView( CustomScrollView(
slivers: [ slivers: [
// ---------- 顶部安全区域 + 标题 ---------- // ---- 顶部标题 ----
SliverToBoxAdapter( SliverToBoxAdapter(
child: SafeArea( child: SafeArea(
child: Padding( child: Padding(
@@ -270,7 +254,7 @@ class _HomePageState extends State<HomePage> {
), ),
), ),
// ---------- 媒体库区块 ---------- // ---- 媒体库 ----
SliverToBoxAdapter( SliverToBoxAdapter(
child: Padding( child: Padding(
padding: const EdgeInsets.symmetric(horizontal: 20), padding: const EdgeInsets.symmetric(horizontal: 20),
@@ -290,15 +274,30 @@ class _HomePageState extends State<HomePage> {
// ---- WebDAV 入口 ---- // ---- WebDAV 入口 ----
_ClickableTile( _ClickableTile(
onTap: () async { onTap: () async {
if (WebDAVService.instance.isConnected) {
// 已连接 → 直接进入文件列表
await Navigator.push(
context,
MaterialPageRoute(
builder: (_) => const WebDAVFileListPage(),
),
);
// 返回后刷新界面(可能状态变化)
setState(() {});
} else {
// 未连接 → 进入设置页
final result = await Navigator.push( final result = await Navigator.push(
context, context,
MaterialPageRoute( MaterialPageRoute(
builder: (_) => const WebDAVSetupPage(), builder: (_) => const WebDAVSetupPage(),
), ),
); );
// 从设置页返回后刷新
setState(() {});
if (result == true) { if (result == true) {
await _refreshFromWebDAV(); await _refreshFromWebDAV();
} }
}
}, },
child: Row( child: Row(
crossAxisAlignment: CrossAxisAlignment.start, crossAxisAlignment: CrossAxisAlignment.start,
@@ -328,10 +327,10 @@ class _HomePageState extends State<HomePage> {
), ),
const SizedBox(width: 16), const SizedBox(width: 16),
Text( Text(
_isWebDAVConnected ? '● 已连接' : '● 未连接', isConnected ? '● 已连接' : '● 未连接',
style: TextStyle( style: TextStyle(
fontSize: 13, fontSize: 13,
color: _isWebDAVConnected color: isConnected
? const Color(0xFF4CAF50) ? const Color(0xFF4CAF50)
: Colors.grey[500], : Colors.grey[500],
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -341,12 +340,10 @@ class _HomePageState extends State<HomePage> {
), ),
const SizedBox(height: 4), const SizedBox(height: 4),
Text( Text(
_isWebDAVConnected isConnected ? username : '点击连接',
? _webDAVUsername
: '点击连接',
style: TextStyle( style: TextStyle(
fontSize: 14, fontSize: 14,
color: _isWebDAVConnected color: isConnected
? Colors.grey[400] ? Colors.grey[400]
: Colors.grey[600], : Colors.grey[600],
), ),
@@ -450,10 +447,12 @@ class _HomePageState extends State<HomePage> {
), ),
), ),
// ---------- “我的收藏” Sticky Header ---------- // 在 home_page.dart 中,修改 SliverPersistentHeader 的 delegate
SliverPersistentHeader( SliverPersistentHeader(
pinned: true, pinned: true,
delegate: _StickyHeaderDelegate( delegate: _StickyHeaderDelegate(
child: SafeArea(
bottom: false,
child: Container( child: Container(
height: 48, height: 48,
color: const Color(0xFF0E1211), color: const Color(0xFF0E1211),
@@ -476,8 +475,9 @@ class _HomePageState extends State<HomePage> {
), ),
), ),
), ),
),
// ---------- 收藏列表 ---------- // ---- 收藏列表 ----
SliverPadding( SliverPadding(
padding: EdgeInsets.only( padding: EdgeInsets.only(
left: 20, left: 20,
@@ -505,7 +505,7 @@ class _HomePageState extends State<HomePage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_isWebDAVConnected isConnected
? '还没有收藏歌曲\n去媒体库发现音乐' ? '还没有收藏歌曲\n去媒体库发现音乐'
: '请先连接 WebDAV', : '请先连接 WebDAV',
textAlign: TextAlign.center, textAlign: TextAlign.center,
@@ -563,7 +563,7 @@ class _HomePageState extends State<HomePage> {
], ],
), ),
// ---------- 底部 MiniPlayer ---------- // ---- 底部 MiniPlayer ----
if (showMiniBar) if (showMiniBar)
const Positioned( const Positioned(
left: 0, left: 0,
+139 -63
View File
@@ -1,46 +1,52 @@
// ============================================================
// 文件名: webdav_file_list_page.dart
// 功能: WebDAV 文件浏览页面,支持文件夹导航和音乐播放
// 调用方式: Navigator.push(context, MaterialPageRoute(builder: (_) => WebDAVFileListPage()))
// ============================================================
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
import '../services/playback_service.dart'; import '../services/playback_service.dart';
import '../services/audio_service.dart'; import '../services/audio_service.dart';
import 'package:provider/provider.dart';
class WebDAVFileListPage extends StatefulWidget { class WebDAVFileListPage extends StatefulWidget {
const WebDAVFileListPage({super.key, this.currentPath = '/'}); /// 当前浏览路径,默认为根目录 '/'
final String currentPath; final String currentPath;
const WebDAVFileListPage({super.key, this.currentPath = '/'});
@override @override
State<WebDAVFileListPage> createState() => _WebDAVFileListPageState(); State<WebDAVFileListPage> createState() => _WebDAVFileListPageState();
} }
class _WebDAVFileListPageState extends State<WebDAVFileListPage> { class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
List<WebDAVFileItem> _files = []; List<WebDAVItem> _items = [];
List<WebDAVFileItem> _directories = [];
bool _isLoading = true; bool _isLoading = true;
String _errorMessage = ''; String _errorMessage = '';
String _currentPath = '/';
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_loadFiles(); _currentPath = widget.currentPath;
_loadDirectory();
} }
Future<void> _loadFiles() async { // -------------------------------------------------------------
// 加载当前目录内容
// -------------------------------------------------------------
Future<void> _loadDirectory() async {
setState(() { setState(() {
_isLoading = true; _isLoading = true;
_errorMessage = ''; _errorMessage = '';
}); });
try { try {
final allItems = final items =
await WebDAVService.instance.getMusicFiles(path: widget.currentPath); await WebDAVService.instance.listDirectory(path: _currentPath);
// 分离目录和文件(这里 getMusicFiles 只返回音乐文件,但如果有目录逻辑需要扩展)
// 由于 getMusicFiles 只返回音乐文件,我们需要获取目录列表
// 这里先简化:只显示音乐文件
setState(() { setState(() {
_files = allItems; _items = items;
_directories = [];
_isLoading = false; _isLoading = false;
}); });
} catch (e) { } catch (e) {
@@ -51,12 +57,26 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
} }
} }
void _playSong(WebDAVFileItem file) async { // -------------------------------------------------------------
// 进入子目录
// -------------------------------------------------------------
void _enterDirectory(WebDAVItem dir) {
Navigator.push(
context,
MaterialPageRoute(
builder: (_) => WebDAVFileListPage(currentPath: dir.path),
),
);
}
// -------------------------------------------------------------
// 播放音乐文件
// -------------------------------------------------------------
void _playSong(WebDAVItem file) async {
try { try {
final url = WebDAVService.instance.getFileUrl(file.path); final url = WebDAVService.instance.getFileUrl(file.path);
await PlaybackService().play(url); await PlaybackService().play(url);
// 更新 AudioService 状态
final song = Song( final song = Song(
id: file.path, id: file.path,
title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''), title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
@@ -65,15 +85,7 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
); );
context.read<AudioService>().playSong(song); context.read<AudioService>().playSong(song);
if (mounted) { // ✅ 移除 SnackBar,直接显示 MiniPlayer
ScaffoldMessenger.of(context).showSnackBar(
SnackBar(
content: Text('正在播放: ${file.name}'),
backgroundColor: const Color(0xFF4CAF50),
duration: const Duration(seconds: 1),
),
);
}
} catch (e) { } catch (e) {
if (mounted) { if (mounted) {
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
@@ -86,12 +98,25 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
} }
} }
// -------------------------------------------------------------
// 构建面包屑路径显示(只显示最后两级,避免过长)
// -------------------------------------------------------------
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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0E1211), backgroundColor: const Color(0xFF0E1211),
appBar: AppBar( appBar: AppBar(
title: const Text('音乐库'), title: Text(
_getDisplayPath(),
style: const TextStyle(fontSize: 16),
),
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
foregroundColor: Colors.white, foregroundColor: Colors.white,
@@ -102,23 +127,33 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
actions: [ actions: [
IconButton( IconButton(
icon: const Icon(Icons.refresh), icon: const Icon(Icons.refresh),
onPressed: _loadFiles, onPressed: _loadDirectory,
tooltip: '刷新',
), ),
], ],
), ),
body: _isLoading body: _buildBody(),
? const Center( );
}
// -------------------------------------------------------------
// 构建主体内容
// -------------------------------------------------------------
Widget _buildBody() {
if (_isLoading) {
return const Center(
child: CircularProgressIndicator( child: CircularProgressIndicator(
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
), ),
) );
: _errorMessage.isNotEmpty }
? Center(
if (_errorMessage.isNotEmpty) {
return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.error_outline, Icon(Icons.error_outline, size: 48, color: Colors.grey[600]),
size: 48, color: Colors.grey[600]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
_errorMessage, _errorMessage,
@@ -127,7 +162,7 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
), ),
const SizedBox(height: 16), const SizedBox(height: 16),
ElevatedButton( ElevatedButton(
onPressed: _loadFiles, onPressed: _loadDirectory,
style: ElevatedButton.styleFrom( style: ElevatedButton.styleFrom(
backgroundColor: const Color(0xFFB8D4D0), backgroundColor: const Color(0xFFB8D4D0),
foregroundColor: Colors.black87, foregroundColor: Colors.black87,
@@ -136,44 +171,90 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
), ),
], ],
), ),
) );
: _files.isEmpty }
? Center(
if (_items.isEmpty) {
return Center(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
children: [ children: [
Icon(Icons.music_note, Icon(Icons.folder_open, size: 48, color: Colors.grey[600]),
size: 48, color: Colors.grey[600]),
const SizedBox(height: 16), const SizedBox(height: 16),
Text( Text(
'没有找到音乐文件', '此目录为空',
style: TextStyle(color: Colors.grey[400]), style: TextStyle(color: Colors.grey[400]),
), ),
const SizedBox(height: 8), const SizedBox(height: 8),
Text( Text(
'支持格式: MP3, FLAC, M4A, APE, WAV, OPUS', '支持格式: MP3, FLAC, M4A, APE, WAV, OPUS',
style: TextStyle( style: TextStyle(color: Colors.grey[600], fontSize: 12),
color: Colors.grey[600], fontSize: 12),
), ),
], ],
), ),
) );
: ListView.builder( }
padding: const EdgeInsets.symmetric(
horizontal: 16, vertical: 8), return ListView.builder(
itemCount: _files.length, padding: const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
itemCount: _items.length,
itemBuilder: (context, index) { itemBuilder: (context, index) {
final file = _files[index]; final item = _items[index];
return _buildListItem(item);
},
);
}
// -------------------------------------------------------------
// 构建单个列表项(区分目录和文件)
// -------------------------------------------------------------
Widget _buildListItem(WebDAVItem item) {
if (item.isDirectory) {
// ---------- 目录项 ----------
return ListTile( return ListTile(
contentPadding: const EdgeInsets.symmetric( contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
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( leading: const Icon(
Icons.audiotrack, Icons.audiotrack,
color: Color(0xFFB8D4D0), color: Color(0xFFB8D4D0),
size: 28, size: 28,
), ),
title: Text( title: Text(
file.name, item.name,
style: const TextStyle( style: const TextStyle(
fontSize: 16, fontSize: 16,
fontWeight: FontWeight.w400, fontWeight: FontWeight.w400,
@@ -183,23 +264,18 @@ class _WebDAVFileListPageState extends State<WebDAVFileListPage> {
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
subtitle: Text( subtitle: Text(
file.size != null sizeStr,
? '${(file.size! / 1024 / 1024).toStringAsFixed(1)} MB'
: '',
style: TextStyle( style: TextStyle(
fontSize: 12, fontSize: 12,
color: Colors.grey[500], color: Colors.grey[500],
), ),
), ),
trailing: IconButton( trailing: IconButton(
icon: const Icon(Icons.play_arrow, icon: const Icon(Icons.play_arrow, color: Color(0xFFB8D4D0)),
color: Color(0xFFB8D4D0)), onPressed: () => _playSong(item),
onPressed: () => _playSong(file),
),
onTap: () => _playSong(file),
);
},
), ),
onTap: () => _playSong(item),
); );
} }
} }
}
+14 -3
View File
@@ -1,5 +1,6 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import '../services/webdav_service.dart'; import '../services/webdav_service.dart';
import 'webdav_file_list_page.dart';
class WebDAVSetupPage extends StatefulWidget { class WebDAVSetupPage extends StatefulWidget {
const WebDAVSetupPage({super.key}); const WebDAVSetupPage({super.key});
@@ -38,7 +39,6 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
_statusText = hasCred ? '已连接' : '未连接'; _statusText = hasCred ? '已连接' : '未连接';
if (hasCred) { if (hasCred) {
_baseUrlController.text = WebDAVService.instance.baseUrl ?? ''; _baseUrlController.text = WebDAVService.instance.baseUrl ?? '';
// 用户名不显示,保持隐私
} }
}); });
} }
@@ -55,7 +55,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
_passwordController.text.trim(), _passwordController.text.trim(),
); );
// 尝试列出文件以验证连接 // 验证连接
await WebDAVService.instance.getMusicFiles(); await WebDAVService.instance.getMusicFiles();
setState(() { setState(() {
@@ -71,7 +71,13 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
backgroundColor: Color(0xFF4CAF50), backgroundColor: Color(0xFF4CAF50),
), ),
); );
Navigator.pop(context, true); // ✅ 登录成功 → 直接跳转到文件列表页
Navigator.pushReplacement(
context,
MaterialPageRoute(
builder: (_) => const WebDAVFileListPage(),
),
);
} }
} catch (e) { } catch (e) {
setState(() { setState(() {
@@ -103,6 +109,7 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
backgroundColor: Colors.grey, backgroundColor: Colors.grey,
), ),
); );
Navigator.pop(context);
} }
} }
@@ -115,6 +122,10 @@ class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
backgroundColor: Colors.transparent, backgroundColor: Colors.transparent,
elevation: 0, elevation: 0,
foregroundColor: Colors.white, foregroundColor: Colors.white,
leading: IconButton(
icon: const Icon(Icons.arrow_back_ios_new),
onPressed: () => Navigator.pop(context),
),
), ),
body: Padding( body: Padding(
padding: const EdgeInsets.all(24), padding: const EdgeInsets.all(24),
+166 -33
View File
@@ -1,8 +1,16 @@
// ============================================================
// 文件名: webdav_service.dart
// 功能: WebDAV 协议通信服务,支持文件夹浏览和音乐文件读取
// ============================================================
import 'dart:convert'; import 'dart:convert';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:shared_preferences/shared_preferences.dart'; import 'package:shared_preferences/shared_preferences.dart';
import 'package:xml/xml.dart'; import 'package:xml/xml.dart';
// -----------------------------------------------------------------
// WebDAV 服务单例
// -----------------------------------------------------------------
class WebDAVService { class WebDAVService {
static const String _keyBaseUrl = 'webdav_base_url'; static const String _keyBaseUrl = 'webdav_base_url';
static const String _keyUsername = 'webdav_username'; static const String _keyUsername = 'webdav_username';
@@ -17,11 +25,16 @@ class WebDAVService {
String? _baseUrl; String? _baseUrl;
String? _username; String? _username;
// -------------------------------------------------------------
// 公开 Getter
// -------------------------------------------------------------
bool get isConnected => _dio != null; bool get isConnected => _dio != null;
String? get baseUrl => _baseUrl; String? get baseUrl => _baseUrl;
String? get username => _username; String? get username => _username;
// 保存凭据 // -------------------------------------------------------------
// 凭据管理
// -------------------------------------------------------------
Future<void> saveCredentials( Future<void> saveCredentials(
String baseUrl, String username, String password) async { String baseUrl, String username, String password) async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
@@ -40,7 +53,6 @@ class WebDAVService {
)); ));
} }
// 加载已保存的凭据
Future<bool> loadCredentials() async { Future<bool> loadCredentials() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
final baseUrl = prefs.getString(_keyBaseUrl); final baseUrl = prefs.getString(_keyBaseUrl);
@@ -62,7 +74,6 @@ class WebDAVService {
return false; return false;
} }
// 清除凭据
Future<void> clearCredentials() async { Future<void> clearCredentials() async {
final prefs = await SharedPreferences.getInstance(); final prefs = await SharedPreferences.getInstance();
await prefs.remove(_keyBaseUrl); await prefs.remove(_keyBaseUrl);
@@ -73,11 +84,15 @@ class WebDAVService {
_username = null; _username = null;
} }
// 获取音乐文件列表(通过 PROPFIND) // -------------------------------------------------------------
Future<List<WebDAVFileItem>> getMusicFiles({String path = '/'}) async { // 核心:列出目录内容(文件夹 + 音乐文件)
// 这是浏览模式的核心方法,支持进入子目录
// -------------------------------------------------------------
Future<List<WebDAVItem>> listDirectory({String path = '/'}) async {
if (_dio == null) throw Exception('WebDAV 未连接'); if (_dio == null) throw Exception('WebDAV 未连接');
// PROPFIND 请求体(Depth: 1 表示获取子项) final requestPath = path.startsWith('/') ? path : '/$path';
final body = ''' final body = '''
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<propfind xmlns="DAV:"> <propfind xmlns="DAV:">
@@ -90,7 +105,7 @@ class WebDAVService {
'''; ''';
final response = await _dio!.request( final response = await _dio!.request(
path, requestPath,
options: Options( options: Options(
method: 'PROPFIND', method: 'PROPFIND',
headers: { headers: {
@@ -106,67 +121,163 @@ class WebDAVService {
} }
final xml = XmlDocument.parse(response.data as String); final xml = XmlDocument.parse(response.data as String);
final items = <WebDAVItem>[];
final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus'];
final items = <WebDAVFileItem>[]; final responseNodes = xml.findAllElements('D:response');
for (final responseNode in responseNodes) {
// 查找所有响应项 final hrefNode = responseNode.findElements('D:href').firstOrNull;
for (final responseNode in xml.findAllElements('response')) {
final hrefNode = responseNode.findElements('href').firstOrNull;
if (hrefNode == null) continue; if (hrefNode == null) continue;
String fullPath = Uri.decodeComponent(hrefNode.text.trim()); String fullPath = Uri.decodeComponent(hrefNode.text.trim());
// 去掉 baseUrl 前缀,得到相对路径
// 去掉 baseUrl 前缀
String relativePath = fullPath; String relativePath = fullPath;
if (_baseUrl != null) { if (_baseUrl != null) {
try {
final baseUri = Uri.parse(_baseUrl!); final baseUri = Uri.parse(_baseUrl!);
final fullUri = Uri.parse(fullPath); final fullUri = Uri.parse(fullPath);
relativePath = fullUri.path; String fullPathOnly = fullUri.path;
if (baseUri.path.isNotEmpty && relativePath.startsWith(baseUri.path)) { if (baseUri.path.isNotEmpty &&
relativePath = relativePath.substring(baseUri.path.length); 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; if (relativePath.isEmpty ||
final isCollection = relativePath == '/' ||
resTypeNode?.findElements('collection').isNotEmpty ?? false; relativePath == requestPath) {
if (isCollection) continue;
// 检查扩展名
final fileName = relativePath.split('/').last;
if (!musicExtensions.any((ext) => fileName.toLowerCase().endsWith(ext)))
continue; 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; int? size;
DateTime? modified; DateTime? modified;
final sizeNode = final sizeNode =
responseNode.findElements('getcontentlength').firstOrNull; responseNode.findElements('D:getcontentlength').firstOrNull;
if (sizeNode != null) { if (sizeNode != null && sizeNode.text.isNotEmpty) {
size = int.tryParse(sizeNode.text); size = int.tryParse(sizeNode.text);
} }
final modifiedNode = final modifiedNode =
responseNode.findElements('getlastmodified').firstOrNull; responseNode.findElements('D:getlastmodified').firstOrNull;
if (modifiedNode != null) { if (modifiedNode != null) {
try { try {
modified = DateTime.parse(modifiedNode.text); modified = DateTime.parse(modifiedNode.text);
} catch (_) {} } catch (_) {}
} }
items.add(WebDAVFileItem( items.add(WebDAVItem(
path: relativePath, path: cleanPath,
name: fileName, name: fileName,
isDirectory: false,
size: size, size: size,
modified: modified, 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; return items;
} }
// 获取文件的完整 URL // -------------------------------------------------------------
// 兼容旧接口:获取所有音乐文件(递归扫描)
// 用于主页的"我的收藏"列表(后续可改为读取用户收藏)
// -------------------------------------------------------------
Future<List<WebDAVFileItem>> 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<List<WebDAVItem>> _listAllRecursive(String path) async {
final items = await listDirectory(path: path);
final result = <WebDAVItem>[];
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) { String getFileUrl(String path) {
if (_baseUrl == null) throw Exception('WebDAV 未配置'); if (_baseUrl == null) throw Exception('WebDAV 未配置');
final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/'; 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 { class WebDAVFileItem {
final String path; final String path;
final String name; final String name;
+8 -4
View File
@@ -12,10 +12,13 @@ class MiniPlayerBar extends StatelessWidget {
if (song == null) return const SizedBox.shrink(); if (song == null) return const SizedBox.shrink();
return Container( return SafeArea(
top: false,
bottom: true,
child: Container(
height: 64, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1F1E), color: const Color(0xFF1A1F1E), // 不透明背景
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black.withOpacity(0.4), color: Colors.black.withOpacity(0.4),
@@ -82,13 +85,14 @@ class MiniPlayerBar extends StatelessWidget {
onPressed: () => context.read<AudioService>().togglePlay(), onPressed: () => context.read<AudioService>().togglePlay(),
), ),
IconButton( IconButton(
icon: icon: const Icon(Icons.playlist_play_outlined,
const Icon(Icons.playlist_play_outlined, color: Colors.white54), color: Colors.white54),
onPressed: () {}, onPressed: () {},
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
), ),
),
); );
} }
} }