目前调通了120全局,准备开始介入播放层,在修bug

This commit is contained in:
2026-08-15 23:06:55 +08:00
parent 15683d0bab
commit 902788c736
11 changed files with 1096 additions and 235 deletions
+2 -1
View File
@@ -33,4 +33,5 @@ desktop.ini
# ---------- 临时文件 ---------- # ---------- 临时文件 ----------
/tmp/ /tmp/
*.tmp *.tmp
.flutter-plugins-dependencies
@@ -1,5 +1,71 @@
package com.example.qt_player 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.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}")
}
}
}
+9 -2
View File
@@ -1,9 +1,16 @@
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:provider/provider.dart'; import 'package:provider/provider.dart';
import 'package:media_kit/media_kit.dart';
import 'services/audio_service.dart'; import 'services/audio_service.dart';
import 'services/playback_service.dart';
import 'pages/home_page.dart'; import 'pages/home_page.dart';
void main() { void main() {
WidgetsFlutterBinding.ensureInitialized();
// 初始化 media_kit
Player.init();
// 初始化播放服务
PlaybackService().init();
runApp( runApp(
ChangeNotifierProvider( ChangeNotifierProvider(
create: (_) => AudioService(), create: (_) => AudioService(),
@@ -20,8 +27,8 @@ class QTPlayerApp extends StatelessWidget {
return MaterialApp( return MaterialApp(
title: '清听', title: '清听',
theme: ThemeData( theme: ThemeData(
brightness: Brightness.dark, // 深色底色,护眼且显质感 brightness: Brightness.dark,
primaryColor: const Color(0xFF7C9A9E), // 清冷的灰绿/青瓷色 primaryColor: const Color(0xFF7C9A9E),
colorScheme: const ColorScheme.dark( colorScheme: const ColorScheme.dark(
primary: Color(0xFF7C9A9E), primary: Color(0xFF7C9A9E),
secondary: Color(0xFFB8D4D0), secondary: Color(0xFFB8D4D0),
+562 -212
View File
@@ -1,23 +1,236 @@
// 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';
import '../services/webdav_service.dart';
import '../services/playback_service.dart';
import '../widgets/mini_player_bar.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<double> _scale;
late final Animation<double> _opacity;
static const Duration _duration = Duration(milliseconds: 120);
@override
void initState() {
super.initState();
_controller = AnimationController(vsync: this, duration: _duration);
_scale = Tween<double>(begin: 1.0, end: 0.95).animate(
CurvedAnimation(parent: _controller, curve: Curves.easeOut),
);
_opacity = Tween<double>(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}); const HomePage({super.key});
// 收藏数据(从你的 ASCII 图提取) @override
final List<Map<String, String>> _favorites = const [ State<HomePage> createState() => _HomePageState();
{'title': '暧昧', 'artist': '王菲', 'format': 'flac', 'tag': 'openlist'}, }
{'title': '清平调(独唱版)', 'artist': '王菲', 'format': 'flac', 'tag': 'openlist'},
{ class _HomePageState extends State<HomePage> {
'title': 'Rain in the Park', List<SongItem> _favorites = [];
'artist': 'Marika Takeuchi', bool _isWebDAVConnected = false;
'format': 'flac', String _webDAVUsername = '';
'tag': 'openlist' bool _isLoading = true;
},
]; @override
void initState() {
super.initState();
_initWebDAV();
}
Future<void> _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<void> _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<void> _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<AudioService>().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 @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -26,228 +239,365 @@ class HomePage extends StatelessWidget {
return Scaffold( return Scaffold(
backgroundColor: const Color(0xFF0E1211), backgroundColor: const Color(0xFF0E1211),
// ---------- 主体:可滚动内容 ---------- body: Stack(
body: Column(
children: [ children: [
Expanded( CustomScrollView(
child: SingleChildScrollView( slivers: [
padding: const EdgeInsets.symmetric(horizontal: 20), // ---------- 顶部安全区域 + 标题 ----------
child: Column( SliverToBoxAdapter(
crossAxisAlignment: CrossAxisAlignment.start, child: SafeArea(
children: [ child: Padding(
const SizedBox(height: 48), padding: const EdgeInsets.symmetric(
// ---------- 顶部标题栏 ---------- horizontal: 20, vertical: 12),
Row( child: Row(
mainAxisAlignment: MainAxisAlignment.spaceBetween, 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: [ children: [
const Text( const Text(
'清听', '媒体库',
style: TextStyle( style: TextStyle(
fontSize: 28, fontSize: 22,
fontWeight: FontWeight.w600, fontWeight: FontWeight.w500,
color: Colors.white, color: Color(0xFFB8D4D0),
), ),
), ),
IconButton( const SizedBox(height: 16),
icon: const Icon(Icons.menu, color: Colors.white54),
onPressed: () { // ---- 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), ),
),
// ---------- 【媒体库】区域 ---------- // ---------- “我的收藏” Sticky Header ----------
const Text( SliverPersistentHeader(
'媒体库', pinned: true,
style: TextStyle( delegate: _StickyHeaderDelegate(
fontSize: 18, child: Container(
fontWeight: FontWeight.w500, height: 48,
color: Color(0xFFB8D4D0), color: const Color(0xFF0E1211),
), padding: const EdgeInsets.symmetric(horizontal: 20),
), child: const Row(
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(
children: [ children: [
const Icon(Icons.cloud_outlined, Icon(Icons.favorite,
color: Color(0xFFB8D4D0), size: 24), color: Color(0xFFB8D4D0), size: 20),
const SizedBox(width: 12), SizedBox(width: 8),
Expanded( Text(
child: Column( '我的收藏',
crossAxisAlignment: CrossAxisAlignment.start, style: TextStyle(
children: [ fontSize: 22,
const Text( fontWeight: FontWeight.w500,
'WebDAV', color: Color(0xFFB8D4D0),
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)),
), ),
), ),
], ],
), ),
), ),
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) { // Sticky Header 委托
return Expanded( // ==================================================
child: Container( class _StickyHeaderDelegate extends SliverPersistentHeaderDelegate {
padding: const EdgeInsets.symmetric(vertical: 14), final Widget child;
decoration: BoxDecoration(
color: const Color(0xFF1A1F1E), _StickyHeaderDelegate({required this.child});
borderRadius: BorderRadius.circular(12),
border: Border.all(color: Colors.grey[800]!, width: 0.5), @override
), double get minExtent => 48;
child: InkWell( @override
borderRadius: BorderRadius.circular(12), double get maxExtent => 48;
onTap: () {
// 后续跳转 @override
}, Widget build(
child: Column( BuildContext context, double shrinkOffset, bool overlapsContent) {
children: [ return child;
Icon(icon, size: 28, color: const Color(0xFFB8D4D0)), }
const SizedBox(height: 6),
Text( @override
label, bool shouldRebuild(_StickyHeaderDelegate oldDelegate) {
style: return child != oldDelegate.child;
const TextStyle(fontSize: 13, fontWeight: FontWeight.w400),
),
],
),
),
),
);
} }
} }
+249
View File
@@ -0,0 +1,249 @@
import 'package:flutter/material.dart';
import '../services/webdav_service.dart';
class WebDAVSetupPage extends StatefulWidget {
const WebDAVSetupPage({super.key});
@override
State<WebDAVSetupPage> createState() => _WebDAVSetupPageState();
}
class _WebDAVSetupPageState extends State<WebDAVSetupPage> {
final _formKey = GlobalKey<FormState>();
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<void> _loadSavedCredentials() async {
final hasCred = await WebDAVService.instance.loadCredentials();
setState(() {
_isConnected = hasCred;
_statusText = hasCred ? '已连接' : '未连接';
if (hasCred) {
_baseUrlController.text = WebDAVService.instance._baseUrl ?? '';
// 用户名不显示,保持隐私
}
});
}
Future<void> _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<void> _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 ? '已连接' : '连接'),
),
),
],
),
),
),
);
}
}
+28
View File
@@ -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<void> 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<PlayerState> get stateStream => _player.stream;
}
+21
View File
@@ -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();
// 状态监听...
}
+103
View File
@@ -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<void> 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<bool> 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<void> 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<List<WebDAVFileItem>> 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,
});
}
+30 -18
View File
@@ -1,4 +1,3 @@
// lib/widgets/mini_player_bar.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';
@@ -15,31 +14,40 @@ class MiniPlayerBar extends StatelessWidget {
height: 64, height: 64,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF1A1F1E), color: const Color(0xFF1A1F1E),
border:
const Border(top: BorderSide(color: Colors.white10, width: 0.5)),
boxShadow: [ boxShadow: [
BoxShadow( BoxShadow(
color: Colors.black26, color: Colors.black.withOpacity(0.4),
blurRadius: 8, blurRadius: 12,
offset: const Offset(0, -2)), 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( child: Row(
children: [ children: [
const SizedBox(width: 12), const SizedBox(width: 12),
// ---- 封面方图 ---- // 封面占位(56px
Container( Container(
width: 44, width: 48,
height: 44, height: 48,
decoration: BoxDecoration( decoration: BoxDecoration(
color: const Color(0xFF2A3332), color: const Color(0xFF2A3332),
borderRadius: BorderRadius.circular(6), borderRadius: BorderRadius.circular(4),
), ),
child: 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), const SizedBox(width: 12),
// ---- 歌曲信息 ---- // 歌曲信息
Expanded( Expanded(
child: Column( child: Column(
mainAxisAlignment: MainAxisAlignment.center, mainAxisAlignment: MainAxisAlignment.center,
@@ -48,20 +56,26 @@ class MiniPlayerBar extends StatelessWidget {
Text( Text(
song.title, song.title,
style: const TextStyle( style: const TextStyle(
fontSize: 14, fontWeight: FontWeight.w500), fontSize: 15,
fontWeight: FontWeight.w500,
color: Colors.white,
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
Text( Text(
song.artist, song.artist,
style: TextStyle(fontSize: 12, color: Colors.grey[400]), style: TextStyle(
fontSize: 13,
color: Colors.grey[400],
),
maxLines: 1, maxLines: 1,
overflow: TextOverflow.ellipsis, overflow: TextOverflow.ellipsis,
), ),
], ],
), ),
), ),
// ---- 控制按钮:播放/暂停 + 展开列表 ---- // 控制按钮
IconButton( IconButton(
icon: Icon( icon: Icon(
service.isPlaying ? Icons.pause : Icons.play_arrow, service.isPlaying ? Icons.pause : Icons.play_arrow,
@@ -72,9 +86,7 @@ class MiniPlayerBar extends StatelessWidget {
IconButton( IconButton(
icon: icon:
const Icon(Icons.playlist_play_outlined, color: Colors.white54), const Icon(Icons.playlist_play_outlined, color: Colors.white54),
onPressed: () { onPressed: () {},
// 后续:展开当前播放列表
},
), ),
const SizedBox(width: 4), const SizedBox(width: 4),
], ],
+24
View File
@@ -65,6 +65,22 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.0.9" 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: fake_async:
dependency: transitive dependency: transitive
description: description:
@@ -243,6 +259,14 @@ packages:
url: "https://pub.flutter-io.cn" url: "https://pub.flutter-io.cn"
source: hosted source: hosted
version: "1.18.0" 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: nested:
dependency: transitive dependency: transitive
description: description:
+1 -1
View File
@@ -33,7 +33,7 @@ dependencies:
media_kit: ^1.1.10 media_kit: ^1.1.10
media_kit_libs_audio: ^1.0.7 media_kit_libs_audio: ^1.0.7
provider: ^6.1.2 provider: ^6.1.2
dio: ^5.4.0
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.