目前调通了120全局,准备开始介入播放层,在修bug
This commit is contained in:
@@ -34,3 +34,4 @@ 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
@@ -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),
|
||||||
|
|||||||
+533
-183
@@ -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,18 +239,17 @@ 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: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
@@ -50,204 +262,342 @@ class HomePage extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.menu, color: Colors.white54),
|
icon: const Icon(Icons.menu, color: Colors.white54),
|
||||||
onPressed: () {
|
onPressed: () {},
|
||||||
// 后续:菜单入口
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 28),
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
// ---------- 【媒体库】区域 ----------
|
// ---------- 媒体库区块 ----------
|
||||||
const Text(
|
SliverToBoxAdapter(
|
||||||
'媒体库',
|
child: Padding(
|
||||||
style: TextStyle(
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
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(
|
|
||||||
children: [
|
|
||||||
const Icon(Icons.cloud_outlined,
|
|
||||||
color: Color(0xFFB8D4D0), size: 24),
|
|
||||||
const SizedBox(width: 12),
|
|
||||||
Expanded(
|
|
||||||
child: Column(
|
child: Column(
|
||||||
crossAxisAlignment: CrossAxisAlignment.start,
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
children: [
|
children: [
|
||||||
const Text(
|
const Text(
|
||||||
'WebDAV',
|
'媒体库',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 16, fontWeight: FontWeight.w500),
|
fontSize: 22,
|
||||||
),
|
|
||||||
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,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const Spacer(),
|
const SizedBox(height: 16),
|
||||||
TextButton(
|
|
||||||
onPressed: () {},
|
// ---- WebDAV 入口 ----
|
||||||
child: const Text(
|
_ClickableTile(
|
||||||
'查看全部',
|
onTap: () async {
|
||||||
style: TextStyle(fontSize: 13, color: Colors.grey),
|
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: 8),
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
_isWebDAVConnected
|
||||||
|
? _webDAVUsername
|
||||||
|
: '点击连接',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: _isWebDAVConnected
|
||||||
|
? Colors.grey[400]
|
||||||
|
: Colors.grey[600],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 24),
|
||||||
|
|
||||||
// ---- 收藏列表 ----
|
// ---- 三个功能入口 ----
|
||||||
ListView.separated(
|
Row(
|
||||||
shrinkWrap: true,
|
children: [
|
||||||
physics: const NeverScrollableScrollPhysics(),
|
Expanded(
|
||||||
itemCount: _favorites.length,
|
child: _ClickableTile(
|
||||||
separatorBuilder: (_, __) => const Divider(
|
onTap: () {},
|
||||||
color: Colors.white10,
|
child: Padding(
|
||||||
height: 1,
|
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,
|
||||||
),
|
),
|
||||||
itemBuilder: (context, index) {
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _ClickableTile(
|
||||||
|
onTap: () {},
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.history,
|
||||||
|
size: 24,
|
||||||
|
color: const Color(0xFFB8D4D0)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'最近播放',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(width: 16),
|
||||||
|
Expanded(
|
||||||
|
child: _ClickableTile(
|
||||||
|
onTap: () {},
|
||||||
|
child: Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 10),
|
||||||
|
child: Row(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.playlist_play,
|
||||||
|
size: 24,
|
||||||
|
color: const Color(0xFFB8D4D0)),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
const Text(
|
||||||
|
'歌单列表',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.white70,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 32),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---------- “我的收藏” Sticky Header ----------
|
||||||
|
SliverPersistentHeader(
|
||||||
|
pinned: true,
|
||||||
|
delegate: _StickyHeaderDelegate(
|
||||||
|
child: Container(
|
||||||
|
height: 48,
|
||||||
|
color: const Color(0xFF0E1211),
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
|
child: const Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.favorite,
|
||||||
|
color: Color(0xFFB8D4D0), size: 20),
|
||||||
|
SizedBox(width: 8),
|
||||||
|
Text(
|
||||||
|
'我的收藏',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 22,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
color: Color(0xFFB8D4D0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
|
||||||
|
// ---------- 收藏列表 ----------
|
||||||
|
SliverPadding(
|
||||||
|
padding: EdgeInsets.only(
|
||||||
|
left: 20,
|
||||||
|
right: 20,
|
||||||
|
bottom: showMiniBar ? 80.0 : 20.0,
|
||||||
|
),
|
||||||
|
sliver: _isLoading
|
||||||
|
? const SliverFillRemaining(
|
||||||
|
child: Center(
|
||||||
|
child: CircularProgressIndicator(
|
||||||
|
color: Color(0xFFB8D4D0),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
: _favorites.isEmpty
|
||||||
|
? SliverFillRemaining(
|
||||||
|
child: 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];
|
final song = _favorites[index];
|
||||||
return ListTile(
|
return Padding(
|
||||||
|
padding:
|
||||||
|
const EdgeInsets.symmetric(vertical: 6),
|
||||||
|
child: ListTile(
|
||||||
contentPadding: EdgeInsets.zero,
|
contentPadding: EdgeInsets.zero,
|
||||||
leading: Container(
|
leading: const Icon(
|
||||||
width: 40,
|
Icons.music_note,
|
||||||
height: 40,
|
color: Colors.white38,
|
||||||
decoration: BoxDecoration(
|
size: 20,
|
||||||
color: const Color(0xFF2A3332),
|
|
||||||
borderRadius: BorderRadius.circular(6),
|
|
||||||
),
|
|
||||||
child: const Icon(Icons.music_note,
|
|
||||||
color: Colors.white38, size: 20),
|
|
||||||
),
|
),
|
||||||
title: Text(
|
title: Text(
|
||||||
'${song['title']} - ${song['artist']}.${song['format']}',
|
song.displayTitle,
|
||||||
style: const TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 15, fontWeight: FontWeight.w400),
|
fontSize: 17,
|
||||||
|
fontWeight: FontWeight.w400,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
subtitle: Text(
|
subtitle: Text(
|
||||||
song['tag']!,
|
song.displaySubtitle,
|
||||||
style:
|
style: TextStyle(
|
||||||
TextStyle(fontSize: 12, color: Colors.grey[500]),
|
fontSize: 14,
|
||||||
|
color: Colors.grey[400],
|
||||||
),
|
),
|
||||||
trailing: Container(
|
maxLines: 1,
|
||||||
padding: const EdgeInsets.symmetric(
|
overflow: TextOverflow.ellipsis,
|
||||||
horizontal: 10, vertical: 4),
|
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFF2A4A4A).withOpacity(0.4),
|
|
||||||
borderRadius: BorderRadius.circular(12),
|
|
||||||
),
|
),
|
||||||
child: Text(
|
onTap: () => _playSong(song),
|
||||||
song['tag']!,
|
|
||||||
style: const TextStyle(
|
|
||||||
fontSize: 11, color: Color(0xFF7C9A9E)),
|
|
||||||
),
|
),
|
||||||
),
|
|
||||||
onTap: () {
|
|
||||||
// 后续:播放该歌曲
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
childCount: _favorites.length,
|
||||||
|
),
|
||||||
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 80), // 底部留空,避免被播放栏遮挡
|
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|
||||||
|
// ---------- 底部 MiniPlayer ----------
|
||||||
|
if (showMiniBar)
|
||||||
|
const Positioned(
|
||||||
|
left: 0,
|
||||||
|
right: 0,
|
||||||
|
bottom: 0,
|
||||||
|
child: MiniPlayerBar(),
|
||||||
),
|
),
|
||||||
),
|
|
||||||
// ---------- 底部全局播放控制栏(固定) ----------
|
|
||||||
if (showMiniBar) const 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),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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 ? '已连接' : '连接'),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
|
||||||
|
// 状态监听...
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
Reference in New Issue
Block a user