diff --git a/.gitignore b/.gitignore index 06499a2..5e375f7 100644 --- a/.gitignore +++ b/.gitignore @@ -33,4 +33,5 @@ desktop.ini # ---------- 临时文件 ---------- /tmp/ -*.tmp \ No newline at end of file +*.tmp +.flutter-plugins-dependencies \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt b/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt index adf8e56..28b6fb9 100644 --- a/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt +++ b/android/app/src/main/kotlin/com/example/qt_player/MainActivity.kt @@ -1,5 +1,71 @@ package com.example.qt_player +import android.os.Build +import android.os.Bundle +import android.util.Log +import android.view.WindowManager import io.flutter.embedding.android.FlutterActivity +import io.flutter.embedding.engine.FlutterEngine +import io.flutter.plugin.common.MethodChannel -class MainActivity : FlutterActivity() +class MainActivity : FlutterActivity() { + + private val CHANNEL = "com.qt_player/frame_rate" + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // 在创建时尝试设置(但可能窗口还未就绪,所以下面在 onResume 再次设置) + setHighRefreshRate() + } + + override fun onResume() { + super.onResume() + // 每次回到前台时确保高刷生效 + setHighRefreshRate() + } + + override fun configureFlutterEngine(flutterEngine: FlutterEngine) { + super.configureFlutterEngine(flutterEngine) + + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "setHighRefreshRate" -> { + setHighRefreshRate() + result.success(true) + } + "clearRefreshRate" -> { + clearRefreshRate() + result.success(true) + } + else -> result.notImplemented() + } + } + } + + private fun setHighRefreshRate() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val params = window.attributes + params.preferredRefreshRate = 120f + window.attributes = params + Log.d("QTPlayer", "✅ 设置高刷 120Hz") + } + } catch (e: Exception) { + Log.e("QTPlayer", "设置高刷失败: ${e.message}") + } + } + + private fun clearRefreshRate() { + try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val params = window.attributes + params.preferredRefreshRate = 0f // 0 表示系统自动 + window.attributes = params + Log.d("QTPlayer", "恢复默认刷新率") + } + } catch (e: Exception) { + Log.e("QTPlayer", "恢复默认刷新率失败: ${e.message}") + } + } +} \ No newline at end of file diff --git a/lib/main.dart b/lib/main.dart index c3341fd..2abf198 100644 --- a/lib/main.dart +++ b/lib/main.dart @@ -1,9 +1,16 @@ import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; +import 'package:media_kit/media_kit.dart'; import 'services/audio_service.dart'; +import 'services/playback_service.dart'; import 'pages/home_page.dart'; void main() { + WidgetsFlutterBinding.ensureInitialized(); + // 初始化 media_kit + Player.init(); + // 初始化播放服务 + PlaybackService().init(); runApp( ChangeNotifierProvider( create: (_) => AudioService(), @@ -20,8 +27,8 @@ class QTPlayerApp extends StatelessWidget { return MaterialApp( title: '清听', theme: ThemeData( - brightness: Brightness.dark, // 深色底色,护眼且显质感 - primaryColor: const Color(0xFF7C9A9E), // 清冷的灰绿/青瓷色 + brightness: Brightness.dark, + primaryColor: const Color(0xFF7C9A9E), colorScheme: const ColorScheme.dark( primary: Color(0xFF7C9A9E), secondary: Color(0xFFB8D4D0), diff --git a/lib/pages/home_page.dart b/lib/pages/home_page.dart index 612a461..831feac 100644 --- a/lib/pages/home_page.dart +++ b/lib/pages/home_page.dart @@ -1,23 +1,236 @@ -// lib/pages/home_page.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/audio_service.dart'; +import '../services/webdav_service.dart'; +import '../services/playback_service.dart'; import '../widgets/mini_player_bar.dart'; +import 'webdav_setup_page.dart'; -class HomePage extends StatelessWidget { +// ================================================== +// 歌曲数据模型(含元数据状态) +// ================================================== +class SongItem { + final String path; // WebDAV 完整路径 + final String fileName; // 文件名 + final String? title; // 元数据标题 + final String? artist; // 元数据艺术家 + final String sourceTag; // 来源标签 + final String metadataState; // "unknown" | "loading" | "success" | "failed" + + SongItem({ + required this.path, + required this.fileName, + this.title, + this.artist, + required this.sourceTag, + this.metadataState = 'unknown', + }); + + String get displayTitle => (metadataState == 'success' && title != null) + ? title! + : fileName.replaceAll(RegExp(r'\.[^.]*$'), ''); + + String get displaySubtitle { + if (metadataState == 'success' && artist != null) { + return artist!; + } else { + return sourceTag; + } + } +} + +// ================================================== +// 通用可点击组件(缩放 + 高亮,无涟漪) +// ================================================== +class _ClickableTile extends StatefulWidget { + final Widget child; + final VoidCallback onTap; + + const _ClickableTile({ + required this.child, + required this.onTap, + }); + + @override + State<_ClickableTile> createState() => _ClickableTileState(); +} + +class _ClickableTileState extends State<_ClickableTile> + with SingleTickerProviderStateMixin { + late final AnimationController _controller; + late final Animation _scale; + late final Animation _opacity; + + static const Duration _duration = Duration(milliseconds: 120); + + @override + void initState() { + super.initState(); + _controller = AnimationController(vsync: this, duration: _duration); + _scale = Tween(begin: 1.0, end: 0.95).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + _opacity = Tween(begin: 0.0, end: 0.08).animate( + CurvedAnimation(parent: _controller, curve: Curves.easeOut), + ); + } + + @override + void dispose() { + _controller.dispose(); + super.dispose(); + } + + void _handleTapDown(TapDownDetails details) { + _controller.forward(); + } + + void _handleTapUp(TapUpDetails details) { + _controller.reverse(); + widget.onTap(); + } + + void _handleTapCancel() { + _controller.reverse(); + } + + @override + Widget build(BuildContext context) { + return RepaintBoundary( + child: GestureDetector( + onTapDown: _handleTapDown, + onTapUp: _handleTapUp, + onTapCancel: _handleTapCancel, + child: AnimatedBuilder( + animation: _controller, + builder: (context, child) { + return Transform.scale( + scale: _scale.value, + child: Container( + decoration: BoxDecoration( + color: Colors.white.withOpacity(_opacity.value), + borderRadius: BorderRadius.circular(8), + ), + padding: const EdgeInsets.symmetric(vertical: 4, horizontal: 4), + child: child, + ), + ); + }, + child: widget.child, + ), + ), + ); + } +} + +// ================================================== +// 主页 +// ================================================== +class HomePage extends StatefulWidget { const HomePage({super.key}); - // 收藏数据(从你的 ASCII 图提取) - final List> _favorites = const [ - {'title': '暧昧', 'artist': '王菲', 'format': 'flac', 'tag': 'openlist'}, - {'title': '清平调(独唱版)', 'artist': '王菲', 'format': 'flac', 'tag': 'openlist'}, - { - 'title': 'Rain in the Park', - 'artist': 'Marika Takeuchi', - 'format': 'flac', - 'tag': 'openlist' - }, - ]; + @override + State createState() => _HomePageState(); +} + +class _HomePageState extends State { + List _favorites = []; + bool _isWebDAVConnected = false; + String _webDAVUsername = ''; + bool _isLoading = true; + + @override + void initState() { + super.initState(); + _initWebDAV(); + } + + Future _initWebDAV() async { + setState(() => _isLoading = true); + try { + final hasCred = await WebDAVService.instance.loadCredentials(); + if (hasCred) { + _isWebDAVConnected = true; + // 从 BaseUrl 中提取用户名(简化展示) + final baseUrl = WebDAVService.instance.baseUrl; + _webDAVUsername = + baseUrl?.replaceAll(RegExp(r'^https?://'), '').split('/').first ?? + '已连接'; + await _loadMusicList(); + } else { + _isWebDAVConnected = false; + _favorites = []; + } + } catch (e) { + _isWebDAVConnected = false; + _favorites = []; + } finally { + if (mounted) setState(() => _isLoading = false); + } + } + + Future _loadMusicList() async { + try { + final files = await WebDAVService.instance.getMusicFiles(); + setState(() { + _favorites = files.map((file) { + return SongItem( + path: file.path, + fileName: file.name, + title: file.name.replaceAll(RegExp(r'\.[^.]*$'), ''), + artist: null, + sourceTag: 'webdav', + metadataState: 'unknown', + ); + }).toList(); + }); + } catch (e) { + // 加载失败保持空列表 + setState(() => _favorites = []); + } + } + + // 刷新列表(从 WebDAV 设置页返回时调用) + Future _refreshFromWebDAV() async { + final hasCred = await WebDAVService.instance.loadCredentials(); + setState(() { + _isWebDAVConnected = hasCred; + if (hasCred) { + final baseUrl = WebDAVService.instance.baseUrl; + _webDAVUsername = + baseUrl?.replaceAll(RegExp(r'^https?://'), '').split('/').first ?? + '已连接'; + } + }); + if (hasCred) { + await _loadMusicList(); + } else { + setState(() => _favorites = []); + } + } + + void _playSong(SongItem song) async { + try { + final url = WebDAVService.instance.getFileUrl(song.path); + await PlaybackService().play(url); + // 更新 AudioService 状态 + context.read().playSong(Song( + id: song.path, + title: song.displayTitle, + artist: song.displaySubtitle, + url: url, + )); + } catch (e) { + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('播放失败: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } @override Widget build(BuildContext context) { @@ -26,228 +239,365 @@ class HomePage extends StatelessWidget { return Scaffold( backgroundColor: const Color(0xFF0E1211), - // ---------- 主体:可滚动内容 ---------- - body: Column( + body: Stack( children: [ - Expanded( - child: SingleChildScrollView( - padding: const EdgeInsets.symmetric(horizontal: 20), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const SizedBox(height: 48), - // ---------- 顶部标题栏 ---------- - Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, + 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: 28, - fontWeight: FontWeight.w600, - color: Colors.white, + fontSize: 22, + fontWeight: FontWeight.w500, + color: Color(0xFFB8D4D0), ), ), - IconButton( - icon: const Icon(Icons.menu, color: Colors.white54), - onPressed: () { - // 后续:菜单入口 + const SizedBox(height: 16), + + // ---- WebDAV 入口 ---- + _ClickableTile( + onTap: () async { + final result = await Navigator.push( + context, + MaterialPageRoute( + builder: (_) => const WebDAVSetupPage(), + ), + ); + if (result == true) { + await _refreshFromWebDAV(); + } }, + child: Row( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Padding( + padding: EdgeInsets.only(left: 8.0), + child: Icon( + Icons.cloud_outlined, + color: Color(0xFFB8D4D0), + size: 56, + ), + ), + const SizedBox(width: 16), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + const Text( + 'WebDAV', + style: TextStyle( + fontSize: 18, + fontWeight: FontWeight.w500, + color: Colors.white, + ), + ), + const SizedBox(width: 16), + Text( + _isWebDAVConnected ? '● 已连接' : '● 未连接', + style: TextStyle( + fontSize: 13, + color: _isWebDAVConnected + ? const Color(0xFF4CAF50) + : Colors.grey[500], + fontWeight: FontWeight.w400, + ), + ), + ], + ), + const SizedBox(height: 4), + Text( + _isWebDAVConnected + ? _webDAVUsername + : '点击连接', + style: TextStyle( + fontSize: 14, + color: _isWebDAVConnected + ? Colors.grey[400] + : Colors.grey[600], + ), + ), + ], + ), + ), + ], + ), ), + const SizedBox(height: 24), + + // ---- 三个功能入口 ---- + Row( + children: [ + Expanded( + child: _ClickableTile( + onTap: () {}, + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.music_note, + size: 24, + color: const Color(0xFFB8D4D0)), + const SizedBox(width: 8), + const Text( + '本地音乐', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _ClickableTile( + onTap: () {}, + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.history, + size: 24, + color: const Color(0xFFB8D4D0)), + const SizedBox(width: 8), + const Text( + '最近播放', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + ), + const SizedBox(width: 16), + Expanded( + child: _ClickableTile( + onTap: () {}, + child: Padding( + padding: + const EdgeInsets.symmetric(vertical: 10), + child: Row( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon(Icons.playlist_play, + size: 24, + color: const Color(0xFFB8D4D0)), + const SizedBox(width: 8), + const Text( + '歌单列表', + style: TextStyle( + fontSize: 14, + fontWeight: FontWeight.w400, + color: Colors.white70, + ), + ), + ], + ), + ), + ), + ), + ], + ), + const SizedBox(height: 32), ], ), - const SizedBox(height: 28), + ), + ), - // ---------- 【媒体库】区域 ---------- - const Text( - '媒体库', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Color(0xFFB8D4D0), - ), - ), - const SizedBox(height: 12), - - // ---- WebDAV 连接状态卡片 ---- - Container( - padding: const EdgeInsets.all(16), - decoration: BoxDecoration( - color: const Color(0xFF1A2A2A), - borderRadius: BorderRadius.circular(12), - border: Border.all( - color: const Color(0xFF2A4A4A), width: 0.5), - ), - child: Row( + // ---------- “我的收藏” 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: [ - const Icon(Icons.cloud_outlined, - color: Color(0xFFB8D4D0), size: 24), - const SizedBox(width: 12), - Expanded( - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - const Text( - 'WebDAV', - style: TextStyle( - fontSize: 16, fontWeight: FontWeight.w500), - ), - Text( - 'username@nas', // 后续替换为真实用户名 - style: TextStyle( - fontSize: 13, color: Colors.grey[400]), - ), - ], - ), - ), - Container( - padding: const EdgeInsets.symmetric( - horizontal: 12, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF4CAF50).withOpacity(0.15), - borderRadius: BorderRadius.circular(20), - ), - child: const Text( - '已连接', - style: TextStyle( - fontSize: 12, color: Color(0xFF4CAF50)), + Icon(Icons.favorite, + color: Color(0xFFB8D4D0), size: 20), + SizedBox(width: 8), + Text( + '我的收藏', + style: TextStyle( + fontSize: 22, + fontWeight: FontWeight.w500, + color: Color(0xFFB8D4D0), ), ), ], ), ), - const SizedBox(height: 20), - - // ---- 三个功能入口(横向三列) ---- - Row( - children: [ - _buildEntryCard(Icons.music_note, '本地音乐'), - const SizedBox(width: 12), - _buildEntryCard(Icons.history, '最近播放'), - const SizedBox(width: 12), - _buildEntryCard(Icons.playlist_play, '歌单列表'), - ], - ), - const SizedBox(height: 32), - - // ---------- 【我的收藏】区域 ---------- - Row( - children: [ - const Icon(Icons.favorite, - color: Color(0xFFB8D4D0), size: 20), - const SizedBox(width: 8), - const Text( - '我的收藏', - style: TextStyle( - fontSize: 18, - fontWeight: FontWeight.w500, - color: Color(0xFFB8D4D0), - ), - ), - const Spacer(), - TextButton( - onPressed: () {}, - child: const Text( - '查看全部', - style: TextStyle(fontSize: 13, color: Colors.grey), - ), - ), - ], - ), - const SizedBox(height: 8), - - // ---- 收藏列表 ---- - ListView.separated( - shrinkWrap: true, - physics: const NeverScrollableScrollPhysics(), - itemCount: _favorites.length, - separatorBuilder: (_, __) => const Divider( - color: Colors.white10, - height: 1, - ), - itemBuilder: (context, index) { - final song = _favorites[index]; - return ListTile( - contentPadding: EdgeInsets.zero, - leading: Container( - width: 40, - height: 40, - decoration: BoxDecoration( - color: const Color(0xFF2A3332), - borderRadius: BorderRadius.circular(6), - ), - child: const Icon(Icons.music_note, - color: Colors.white38, size: 20), - ), - title: Text( - '${song['title']} - ${song['artist']}.${song['format']}', - style: const TextStyle( - fontSize: 15, fontWeight: FontWeight.w400), - ), - subtitle: Text( - song['tag']!, - style: - TextStyle(fontSize: 12, color: Colors.grey[500]), - ), - trailing: Container( - padding: const EdgeInsets.symmetric( - horizontal: 10, vertical: 4), - decoration: BoxDecoration( - color: const Color(0xFF2A4A4A).withOpacity(0.4), - borderRadius: BorderRadius.circular(12), - ), - child: Text( - song['tag']!, - style: const TextStyle( - fontSize: 11, color: Color(0xFF7C9A9E)), - ), - ), - onTap: () { - // 后续:播放该歌曲 - }, - ); - }, - ), - const SizedBox(height: 80), // 底部留空,避免被播放栏遮挡 - ], + ), ), - ), + + // ---------- 收藏列表 ---------- + SliverPadding( + padding: EdgeInsets.only( + left: 20, + right: 20, + bottom: showMiniBar ? 80.0 : 20.0, + ), + sliver: _isLoading + ? const SliverFillRemaining( + child: Center( + child: CircularProgressIndicator( + color: Color(0xFFB8D4D0), + ), + ), + ) + : _favorites.isEmpty + ? SliverFillRemaining( + child: Center( + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + Icon( + Icons.music_note, + size: 48, + color: Colors.grey[600], + ), + const SizedBox(height: 16), + Text( + _isWebDAVConnected + ? '还没有收藏歌曲\n去媒体库发现音乐' + : '请先连接 WebDAV', + textAlign: TextAlign.center, + style: TextStyle( + fontSize: 16, + color: Colors.grey[500], + height: 1.6, + ), + ), + ], + ), + ), + ) + : SliverList( + delegate: SliverChildBuilderDelegate( + (context, index) { + final song = _favorites[index]; + return Padding( + padding: + const EdgeInsets.symmetric(vertical: 6), + child: ListTile( + contentPadding: EdgeInsets.zero, + leading: const Icon( + Icons.music_note, + color: Colors.white38, + size: 20, + ), + title: Text( + song.displayTitle, + style: const TextStyle( + fontSize: 17, + fontWeight: FontWeight.w400, + color: Colors.white, + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + subtitle: Text( + song.displaySubtitle, + style: TextStyle( + fontSize: 14, + color: Colors.grey[400], + ), + maxLines: 1, + overflow: TextOverflow.ellipsis, + ), + onTap: () => _playSong(song), + ), + ); + }, + childCount: _favorites.length, + ), + ), + ), + ], ), - // ---------- 底部全局播放控制栏(固定) ---------- - if (showMiniBar) const MiniPlayerBar(), + + // ---------- 底部 MiniPlayer ---------- + if (showMiniBar) + const Positioned( + left: 0, + right: 0, + bottom: 0, + child: MiniPlayerBar(), + ), ], ), ); } +} - // 功能入口卡片(横向三列共用) - Widget _buildEntryCard(IconData icon, String label) { - return Expanded( - child: Container( - padding: const EdgeInsets.symmetric(vertical: 14), - decoration: BoxDecoration( - color: const Color(0xFF1A1F1E), - borderRadius: BorderRadius.circular(12), - border: Border.all(color: Colors.grey[800]!, width: 0.5), - ), - child: InkWell( - borderRadius: BorderRadius.circular(12), - onTap: () { - // 后续跳转 - }, - child: Column( - children: [ - Icon(icon, size: 28, color: const Color(0xFFB8D4D0)), - const SizedBox(height: 6), - Text( - label, - style: - const TextStyle(fontSize: 13, fontWeight: FontWeight.w400), - ), - ], - ), - ), - ), - ); +// ================================================== +// Sticky Header 委托 +// ================================================== +class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate { + final Widget child; + + _StickyHeaderDelegate({required this.child}); + + @override + double get minExtent => 48; + @override + double get maxExtent => 48; + + @override + Widget build( + BuildContext context, double shrinkOffset, bool overlapsContent) { + return child; + } + + @override + bool shouldRebuild(_StickyHeaderDelegate oldDelegate) { + return child != oldDelegate.child; } } diff --git a/lib/pages/webdav_setup_page.dart b/lib/pages/webdav_setup_page.dart new file mode 100644 index 0000000..e4cf4a8 --- /dev/null +++ b/lib/pages/webdav_setup_page.dart @@ -0,0 +1,249 @@ +import 'package:flutter/material.dart'; +import '../services/webdav_service.dart'; + +class WebDAVSetupPage extends StatefulWidget { + const WebDAVSetupPage({super.key}); + + @override + State createState() => _WebDAVSetupPageState(); +} + +class _WebDAVSetupPageState extends State { + final _formKey = GlobalKey(); + final _baseUrlController = TextEditingController(); + final _usernameController = TextEditingController(); + final _passwordController = TextEditingController(); + bool _isLoading = false; + bool _isConnected = false; + String _statusText = '未连接'; + + @override + void initState() { + super.initState(); + _loadSavedCredentials(); + } + + @override + void dispose() { + _baseUrlController.dispose(); + _usernameController.dispose(); + _passwordController.dispose(); + super.dispose(); + } + + Future _loadSavedCredentials() async { + final hasCred = await WebDAVService.instance.loadCredentials(); + setState(() { + _isConnected = hasCred; + _statusText = hasCred ? '已连接' : '未连接'; + if (hasCred) { + _baseUrlController.text = WebDAVService.instance._baseUrl ?? ''; + // 用户名不显示,保持隐私 + } + }); + } + + Future _connect() async { + if (!_formKey.currentState!.validate()) return; + + setState(() => _isLoading = true); + + try { + await WebDAVService.instance.saveCredentials( + _baseUrlController.text.trim(), + _usernameController.text.trim(), + _passwordController.text.trim(), + ); + + // 尝试列出文件以验证连接 + await WebDAVService.instance.getMusicFiles(); + + setState(() { + _isConnected = true; + _statusText = '已连接'; + _isLoading = false; + }); + + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('✅ WebDAV 连接成功'), + backgroundColor: Color(0xFF4CAF50), + ), + ); + Navigator.pop(context, true); + } + } catch (e) { + setState(() { + _isConnected = false; + _statusText = '连接失败'; + _isLoading = false; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + SnackBar( + content: Text('❌ 连接失败: $e'), + backgroundColor: Colors.red, + ), + ); + } + } + } + + Future _disconnect() async { + await WebDAVService.instance.clearCredentials(); + setState(() { + _isConnected = false; + _statusText = '未连接'; + }); + if (mounted) { + ScaffoldMessenger.of(context).showSnackBar( + const SnackBar( + content: Text('已断开连接'), + backgroundColor: Colors.grey, + ), + ); + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + backgroundColor: const Color(0xFF0E1211), + appBar: AppBar( + title: const Text('WebDAV 设置'), + backgroundColor: Colors.transparent, + elevation: 0, + foregroundColor: Colors.white, + ), + body: Padding( + padding: const EdgeInsets.all(24), + child: Form( + key: _formKey, + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + // 状态显示 + Container( + padding: const EdgeInsets.all(16), + decoration: BoxDecoration( + color: const Color(0xFF1A2A2A), + borderRadius: BorderRadius.circular(8), + ), + child: Row( + children: [ + Icon( + _isConnected ? Icons.cloud_done : Icons.cloud_off, + color: + _isConnected ? const Color(0xFF4CAF50) : Colors.grey, + ), + const SizedBox(width: 12), + Text( + '状态: $_statusText', + style: TextStyle( + color: _isConnected + ? const Color(0xFF4CAF50) + : Colors.grey[400], + ), + ), + const Spacer(), + if (_isConnected) + TextButton( + onPressed: _disconnect, + style: + TextButton.styleFrom(foregroundColor: Colors.red), + child: const Text('断开'), + ), + ], + ), + ), + const SizedBox(height: 24), + + // 表单 + TextFormField( + controller: _baseUrlController, + enabled: !_isConnected, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: 'WebDAV 地址', + labelStyle: TextStyle(color: Colors.grey), + hintText: 'https://nas.local/dav/', + hintStyle: TextStyle(color: Colors.grey), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFFB8D4D0)), + ), + ), + validator: (v) => + v == null || v.isEmpty ? '请输入 WebDAV 地址' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _usernameController, + enabled: !_isConnected, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: '用户名', + labelStyle: TextStyle(color: Colors.grey), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFFB8D4D0)), + ), + ), + validator: (v) => v == null || v.isEmpty ? '请输入用户名' : null, + ), + const SizedBox(height: 16), + TextFormField( + controller: _passwordController, + enabled: !_isConnected, + obscureText: true, + style: const TextStyle(color: Colors.white), + decoration: const InputDecoration( + labelText: '密码', + labelStyle: TextStyle(color: Colors.grey), + enabledBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Colors.grey), + ), + focusedBorder: UnderlineInputBorder( + borderSide: BorderSide(color: Color(0xFFB8D4D0)), + ), + ), + validator: (v) => v == null || v.isEmpty ? '请输入密码' : null, + ), + const SizedBox(height: 32), + SizedBox( + width: double.infinity, + child: ElevatedButton( + onPressed: + _isConnected ? null : (_isLoading ? null : _connect), + style: ElevatedButton.styleFrom( + backgroundColor: const Color(0xFFB8D4D0), + foregroundColor: Colors.black87, + padding: const EdgeInsets.symmetric(vertical: 16), + shape: RoundedRectangleBorder( + borderRadius: BorderRadius.circular(8), + ), + ), + child: _isLoading + ? const SizedBox( + height: 20, + width: 20, + child: CircularProgressIndicator( + strokeWidth: 2, + color: Colors.black87, + ), + ) + : Text(_isConnected ? '已连接' : '连接'), + ), + ), + ], + ), + ), + ), + ); + } +} diff --git a/lib/services/playback_service.dart b/lib/services/playback_service.dart new file mode 100644 index 0000000..1a8590b --- /dev/null +++ b/lib/services/playback_service.dart @@ -0,0 +1,28 @@ +import 'package:media_kit/media_kit.dart'; +import 'package:media_kit/media_kit.dart' as media_kit; + +class PlaybackService { + static final PlaybackService _instance = PlaybackService._(); + factory PlaybackService() => _instance; + PlaybackService._(); + + late final Player _player; + bool get isInitialized => _player.state.playing; + + void init() { + _player = Player(); + } + + Future play(String url) async { + await _player.open(Media(url)); + await _player.play(); + } + + void pause() => _player.pause(); + void resume() => _player.play(); + void stop() => _player.stop(); + void dispose() => _player.dispose(); + + // 状态流 + Stream get stateStream => _player.stream; +} diff --git a/lib/services/player_service.dart b/lib/services/player_service.dart new file mode 100644 index 0000000..5363aa9 --- /dev/null +++ b/lib/services/player_service.dart @@ -0,0 +1,21 @@ +import 'package:media_kit/media_kit.dart'; + +class PlayerService { + static final PlayerService _instance = PlayerService._internal(); + factory PlayerService() => _instance; + PlayerService._internal(); + + final Player _player = Player(); + + void play(String url) { + _player.open(Media(url)); + } + + void pause() => _player.pause(); + void resume() => _player.play(); + void stop() => _player.stop(); + + void dispose() => _player.dispose(); + + // 状态监听... +} diff --git a/lib/services/webdav_service.dart b/lib/services/webdav_service.dart new file mode 100644 index 0000000..0058420 --- /dev/null +++ b/lib/services/webdav_service.dart @@ -0,0 +1,103 @@ +import 'package:webdav_client/webdav_client.dart'; +import 'package:shared_preferences/shared_preferences.dart'; + +class WebDAVService { + static const String _keyBaseUrl = 'webdav_base_url'; + static const String _keyUsername = 'webdav_username'; + static const String _keyPassword = 'webdav_password'; + + static WebDAVService? _instance; + static WebDAVService get instance => _instance ??= WebDAVService._(); + + WebDAVService._(); + + WebDAVClient? _client; + String? _baseUrl; + + bool get isConnected => _client != null; + + // 保存凭据 + Future saveCredentials( + String baseUrl, String username, String password) async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setString(_keyBaseUrl, baseUrl); + await prefs.setString(_keyUsername, username); + await prefs.setString(_keyPassword, password); + _baseUrl = baseUrl; + _client = WebDAVClient( + baseUri: Uri.parse(baseUrl), + credentials: '$username:$password', + ); + } + + // 加载已保存的凭据 + Future loadCredentials() async { + final prefs = await SharedPreferences.getInstance(); + final baseUrl = prefs.getString(_keyBaseUrl); + final username = prefs.getString(_keyUsername); + final password = prefs.getString(_keyPassword); + if (baseUrl != null && username != null && password != null) { + _baseUrl = baseUrl; + _client = WebDAVClient( + baseUri: Uri.parse(baseUrl), + credentials: '$username:$password', + ); + return true; + } + return false; + } + + // 清除凭据 + Future clearCredentials() async { + final prefs = await SharedPreferences.getInstance(); + await prefs.remove(_keyBaseUrl); + await prefs.remove(_keyUsername); + await prefs.remove(_keyPassword); + _client = null; + _baseUrl = null; + } + + // 获取音乐文件列表(仅 .mp3 .flac .m4a .ape .wav) + Future> getMusicFiles({String path = '/'}) async { + if (_client == null) throw Exception('WebDAV 未连接'); + + final items = await _client!.listAll(recursive: true); + final musicExtensions = ['.mp3', '.flac', '.m4a', '.ape', '.wav', '.opus']; + + return items + .where((item) => + item.isFile && + musicExtensions.any((ext) => item.path.toLowerCase().endsWith(ext))) + .map((item) => WebDAVFileItem( + path: item.path, + name: item.path.split('/').last, + size: item.size, + modified: item.modified, + )) + .toList(); + } + + // 获取文件的完整下载 URL(用于播放) + String getFileUrl(String path) { + if (_baseUrl == null) throw Exception('WebDAV 未配置'); + // 确保 baseUrl 末尾有 '/' + final base = _baseUrl!.endsWith('/') ? _baseUrl! : '$_baseUrl/'; + // 去掉路径开头的 '/' + final cleanPath = path.startsWith('/') ? path.substring(1) : path; + return '$base$cleanPath'; + } +} + +class WebDAVFileItem { + final String path; + final String name; + final int? size; + final DateTime? modified; + + WebDAVFileItem({ + required this.path, + required this.name, + this.size, + this.modified, + }); +} diff --git a/lib/widgets/mini_player_bar.dart b/lib/widgets/mini_player_bar.dart index 9e1584d..8485a49 100644 --- a/lib/widgets/mini_player_bar.dart +++ b/lib/widgets/mini_player_bar.dart @@ -1,4 +1,3 @@ -// lib/widgets/mini_player_bar.dart import 'package:flutter/material.dart'; import 'package:provider/provider.dart'; import '../services/audio_service.dart'; @@ -15,31 +14,40 @@ class MiniPlayerBar extends StatelessWidget { height: 64, decoration: BoxDecoration( color: const Color(0xFF1A1F1E), - border: - const Border(top: BorderSide(color: Colors.white10, width: 0.5)), boxShadow: [ BoxShadow( - color: Colors.black26, - blurRadius: 8, - offset: const Offset(0, -2)), + 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), + ], + stops: const [0.0, 1.0], + ), ), child: Row( children: [ const SizedBox(width: 12), - // ---- 封面方图 ---- + // 封面占位(56px) Container( - width: 44, - height: 44, + width: 48, + height: 48, decoration: BoxDecoration( color: const Color(0xFF2A3332), - borderRadius: BorderRadius.circular(6), + borderRadius: BorderRadius.circular(4), ), child: - const Icon(Icons.music_note, color: Colors.white38, size: 22), + const Icon(Icons.music_note, color: Colors.white38, size: 24), ), const SizedBox(width: 12), - // ---- 歌曲信息 ---- + // 歌曲信息 Expanded( child: Column( mainAxisAlignment: MainAxisAlignment.center, @@ -48,20 +56,26 @@ class MiniPlayerBar extends StatelessWidget { Text( song.title, style: const TextStyle( - fontSize: 14, fontWeight: FontWeight.w500), + fontSize: 15, + fontWeight: FontWeight.w500, + color: Colors.white, + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), Text( song.artist, - style: TextStyle(fontSize: 12, color: Colors.grey[400]), + style: TextStyle( + fontSize: 13, + color: Colors.grey[400], + ), maxLines: 1, overflow: TextOverflow.ellipsis, ), ], ), ), - // ---- 控制按钮:播放/暂停 + 展开列表 ---- + // 控制按钮 IconButton( icon: Icon( service.isPlaying ? Icons.pause : Icons.play_arrow, @@ -72,9 +86,7 @@ class MiniPlayerBar extends StatelessWidget { IconButton( icon: const Icon(Icons.playlist_play_outlined, color: Colors.white54), - onPressed: () { - // 后续:展开当前播放列表 - }, + onPressed: () {}, ), const SizedBox(width: 4), ], diff --git a/pubspec.lock b/pubspec.lock index 15679ff..ef991b8 100644 --- a/pubspec.lock +++ b/pubspec.lock @@ -65,6 +65,22 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.0.9" + dio: + dependency: "direct main" + description: + name: dio + sha256: "0df44ebba85e503958eb75d07eedd3c86275a58c1d3eda2f2ce8f0a2c3abbb3c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "5.11.0" + dio_web_adapter: + dependency: transitive + description: + name: dio_web_adapter + sha256: "0786d0b7295a373de356fc0af4f6f1d0ab2844ed31b19dfc5e7556b70e24212c" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.2.1" fake_async: dependency: transitive description: @@ -243,6 +259,14 @@ packages: url: "https://pub.flutter-io.cn" source: hosted version: "1.18.0" + mime: + dependency: transitive + description: + name: mime + sha256: "41a20518f0cb1256669420fdba0cd90d21561e560ac240f26ef8322e45bb7ed6" + url: "https://pub.flutter-io.cn" + source: hosted + version: "2.0.0" nested: dependency: transitive description: diff --git a/pubspec.yaml b/pubspec.yaml index 339a7c5..4266cae 100644 --- a/pubspec.yaml +++ b/pubspec.yaml @@ -33,7 +33,7 @@ dependencies: media_kit: ^1.1.10 media_kit_libs_audio: ^1.0.7 provider: ^6.1.2 - + dio: ^5.4.0 # The following adds the Cupertino Icons font to your application. # Use with the CupertinoIcons class for iOS style icons.