目前调通了120全局,准备开始介入播放层,在修bug
This commit is contained in:
+562
-212
@@ -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<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});
|
||||
|
||||
// 收藏数据(从你的 ASCII 图提取)
|
||||
final List<Map<String, String>> _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<HomePage> createState() => _HomePageState();
|
||||
}
|
||||
|
||||
class _HomePageState extends State<HomePage> {
|
||||
List<SongItem> _favorites = [];
|
||||
bool _isWebDAVConnected = false;
|
||||
String _webDAVUsername = '';
|
||||
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
|
||||
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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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 ? '已连接' : '连接'),
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user