604 lines
22 KiB
Dart
604 lines
22 KiB
Dart
// 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';
|
|
import 'webdav_file_list_page.dart';
|
|
|
|
// ==================================================
|
|
// 歌曲数据模型(含元数据状态)
|
|
// ==================================================
|
|
class SongItem {
|
|
final String path;
|
|
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});
|
|
|
|
@override
|
|
State<HomePage> createState() => _HomePageState();
|
|
}
|
|
|
|
class _HomePageState extends State<HomePage> {
|
|
List<SongItem> _favorites = [];
|
|
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) {
|
|
// ✅ 不再自动加载音乐到收藏
|
|
_favorites = [];
|
|
} else {
|
|
_favorites = [];
|
|
}
|
|
} catch (e) {
|
|
_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 = []);
|
|
}
|
|
}
|
|
|
|
Future<void> _refreshFromWebDAV() async {
|
|
final hasCred = await WebDAVService.instance.loadCredentials();
|
|
if (hasCred) {
|
|
await _loadMusicList();
|
|
} else {
|
|
setState(() => _favorites = []);
|
|
}
|
|
}
|
|
|
|
void _playSong(SongItem song) async {
|
|
try {
|
|
final url = WebDAVService.instance.getFileUrl(song.path);
|
|
await PlaybackService().play(url);
|
|
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) {
|
|
// 直接从 WebDAVService 读取实时状态
|
|
final isConnected = WebDAVService.instance.isConnected;
|
|
final username = WebDAVService.instance.username ?? '点击连接';
|
|
final audioService = context.watch<AudioService>();
|
|
final showMiniBar = audioService.currentSong != null;
|
|
|
|
return Scaffold(
|
|
backgroundColor: const Color(0xFF0E1211),
|
|
body: Stack(
|
|
children: [
|
|
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: 22,
|
|
fontWeight: FontWeight.w500,
|
|
color: Color(0xFFB8D4D0),
|
|
),
|
|
),
|
|
const SizedBox(height: 16),
|
|
|
|
// ---- WebDAV 入口 ----
|
|
_ClickableTile(
|
|
onTap: () async {
|
|
if (WebDAVService.instance.isConnected) {
|
|
// 已连接 → 直接进入文件列表
|
|
await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => const WebDAVFileListPage(),
|
|
),
|
|
);
|
|
// 返回后刷新界面(可能状态变化)
|
|
setState(() {});
|
|
} else {
|
|
// 未连接 → 进入设置页
|
|
final result = await Navigator.push(
|
|
context,
|
|
MaterialPageRoute(
|
|
builder: (_) => const WebDAVSetupPage(),
|
|
),
|
|
);
|
|
// 从设置页返回后刷新
|
|
setState(() {});
|
|
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(
|
|
isConnected ? '● 已连接' : '● 未连接',
|
|
style: TextStyle(
|
|
fontSize: 13,
|
|
color: isConnected
|
|
? const Color(0xFF4CAF50)
|
|
: Colors.grey[500],
|
|
fontWeight: FontWeight.w400,
|
|
),
|
|
),
|
|
],
|
|
),
|
|
const SizedBox(height: 4),
|
|
Text(
|
|
isConnected ? username : '点击连接',
|
|
style: TextStyle(
|
|
fontSize: 14,
|
|
color: isConnected
|
|
? 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),
|
|
],
|
|
),
|
|
),
|
|
),
|
|
|
|
// 在 home_page.dart 中,修改 SliverPersistentHeader 的 delegate
|
|
SliverPersistentHeader(
|
|
pinned: true,
|
|
delegate: _StickyHeaderDelegate(
|
|
child: SafeArea(
|
|
bottom: false,
|
|
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(
|
|
isConnected
|
|
? '还没有收藏歌曲\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,
|
|
),
|
|
),
|
|
),
|
|
],
|
|
),
|
|
|
|
// ---- 底部 MiniPlayer ----
|
|
if (showMiniBar)
|
|
const Positioned(
|
|
left: 0,
|
|
right: 0,
|
|
bottom: 0,
|
|
child: MiniPlayerBar(),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|
|
|
|
// ==================================================
|
|
// 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;
|
|
}
|
|
}
|