原因:_connect() 成功后用 pushReplacement(WebDAVFileListPage), 把 Setup 页替换成文件列表,所以看起来"登录完不返回"。 - 改为 Navigator.popUntil(isFirst):无论首次配置还是重新登录,都回主页 - 首页"重新登录"入口改为 await push,返回后重新加载 WebDAV 状态并刷新 - 清理两个未使用的 import
981 lines
33 KiB
Dart
981 lines
33 KiB
Dart
import 'package:flutter/material.dart';
|
||
import 'package:provider/provider.dart';
|
||
import '../services/audio_service.dart';
|
||
import '../services/webdav_service.dart';
|
||
import '../widgets/magnetic_scroll_physics.dart';
|
||
import 'webdav_setup_page.dart';
|
||
import 'settings_page.dart';
|
||
import '../database/song_database.dart';
|
||
import '../models/playlist_summary.dart';
|
||
import '../repositories/playlist_repository.dart';
|
||
import 'playlist_detail_page.dart';
|
||
import '../widgets/song_artwork.dart';
|
||
import 'webdav_file_list_page.dart';
|
||
import '../constants/ui_constants.dart';
|
||
import '../base/base_state.dart';
|
||
|
||
// ==================================================
|
||
// 歌曲数据模型
|
||
// ==================================================
|
||
class SongItem {
|
||
final String path;
|
||
final String fileName;
|
||
final String? title;
|
||
final String? artist;
|
||
final String? artworkPath;
|
||
final String sourceTag;
|
||
final String metadataState;
|
||
|
||
SongItem({
|
||
required this.path,
|
||
required this.fileName,
|
||
this.title,
|
||
this.artist,
|
||
this.artworkPath,
|
||
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 && artist!.isNotEmpty) {
|
||
return artist!;
|
||
}
|
||
return sourceTag.isNotEmpty ? 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;
|
||
|
||
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),
|
||
);
|
||
}
|
||
|
||
@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: child,
|
||
);
|
||
},
|
||
child: widget.child,
|
||
),
|
||
),
|
||
);
|
||
}
|
||
}
|
||
|
||
// ==================================================
|
||
// 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;
|
||
}
|
||
}
|
||
|
||
// ==================================================
|
||
// 主页
|
||
// ==================================================
|
||
class HomePage extends StatefulWidget {
|
||
const HomePage({super.key});
|
||
|
||
@override
|
||
State<HomePage> createState() => _HomePageState();
|
||
}
|
||
|
||
class _HomePageState extends BaseState<HomePage> {
|
||
List<SongItem> _favorites = [];
|
||
bool _isLoading = true;
|
||
|
||
/// 0 = 我的收藏,1 = 歌单列表(左右滑动切换)
|
||
int _homeTab = 0;
|
||
List<PlaylistSummary> _playlistSummaries = [];
|
||
|
||
// ========== 滚动控制 ==========
|
||
late final ScrollController _scrollController;
|
||
final GlobalKey _mediaLibraryKey = GlobalKey();
|
||
double _mediaLibraryHeight = 0.0;
|
||
|
||
// 高度测量重试控制
|
||
int _heightMeasureRetryCount = 0;
|
||
static const int _maxHeightMeasureRetries = 10;
|
||
|
||
// ========== 计算属性 ==========
|
||
double get _progress {
|
||
if (_mediaLibraryHeight <= 0) return 0.0;
|
||
final offset =
|
||
_scrollController.hasClients ? _scrollController.offset : 0.0;
|
||
return (offset / _mediaLibraryHeight).clamp(0.0, 1.0);
|
||
}
|
||
|
||
double get _mediaOpacity {
|
||
final p = _progress;
|
||
if (p <= 0.3) {
|
||
return 1.0 - (p / 0.3) * 0.95;
|
||
} else {
|
||
return 0.05 * (1 - (p - 0.3) / 0.7);
|
||
}
|
||
}
|
||
|
||
double get _listReveal {
|
||
if (_progress <= 0.7) return 0.0;
|
||
return (_progress - 0.7) / 0.3;
|
||
}
|
||
|
||
@override
|
||
void initState() {
|
||
super.initState();
|
||
_scrollController = ScrollController()..addListener(_onScroll);
|
||
_initWebDAV();
|
||
|
||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||
_measureMediaLibraryHeight();
|
||
});
|
||
}
|
||
|
||
@override
|
||
void dispose() {
|
||
_scrollController.dispose();
|
||
super.dispose();
|
||
}
|
||
|
||
void _measureMediaLibraryHeight() {
|
||
final renderBox =
|
||
_mediaLibraryKey.currentContext?.findRenderObject() as RenderBox?;
|
||
if (renderBox != null) {
|
||
final height = renderBox.size.height;
|
||
if (height > 0 && height != _mediaLibraryHeight) {
|
||
safeSetState(() {
|
||
_mediaLibraryHeight = height;
|
||
_heightMeasureRetryCount = 0;
|
||
});
|
||
print('✅ 媒体库高度测量成功: $height');
|
||
}
|
||
} else {
|
||
_heightMeasureRetryCount++;
|
||
if (_heightMeasureRetryCount < _maxHeightMeasureRetries) {
|
||
Future.delayed(
|
||
const Duration(milliseconds: 200), _measureMediaLibraryHeight);
|
||
} else {
|
||
print('⚠️ 媒体库高度测量失败,使用默认值 280px');
|
||
safeSetState(() {
|
||
_mediaLibraryHeight = 280.0;
|
||
});
|
||
}
|
||
}
|
||
}
|
||
|
||
void _onScroll() {
|
||
if (mounted) safeSetState(() {});
|
||
}
|
||
|
||
Future<void> _initWebDAV() async {
|
||
safeSetState(() => _isLoading = true);
|
||
try {
|
||
await WebDAVService.instance.loadCredentials();
|
||
await _loadFavoritesFromDb();
|
||
await _loadPlaylistSummaries();
|
||
} catch (e) {
|
||
_favorites = [];
|
||
} finally {
|
||
if (mounted) safeSetState(() => _isLoading = false);
|
||
}
|
||
}
|
||
|
||
/// 收藏列表直接绑定 favorites 表 + songs 元数据
|
||
Future<void> _loadFavoritesFromDb() async {
|
||
final db = SongDatabase();
|
||
final favRows = await db.getFavorites();
|
||
final paths = favRows
|
||
.map((row) => row['song_id'] as String?)
|
||
.whereType<String>()
|
||
.toList();
|
||
final meta = await db.getSongsByPaths(paths);
|
||
|
||
final items = <SongItem>[];
|
||
for (final path in paths) {
|
||
final row = meta[path];
|
||
final title = (row?['title'] as String?)?.trim() ?? '';
|
||
final artist = (row?['artist'] as String?)?.trim() ?? '';
|
||
final hasTitle = title.isNotEmpty;
|
||
|
||
// path 是百分号编码的(播放时必须保持),显示前要解码,
|
||
// 否则没有元数据的收藏会显示成 "13%E6%A5%BC..." 这种不可读串。
|
||
final rawName = path.split('/').last;
|
||
String displayName;
|
||
try {
|
||
displayName = Uri.decodeComponent(rawName);
|
||
} catch (_) {
|
||
displayName = rawName;
|
||
}
|
||
|
||
// 取上级目录名,作为没有标签时的副标题(加括号区分"这是文件夹")
|
||
final segments = path.split('/').where((s) => s.isNotEmpty).toList();
|
||
String folderName = '';
|
||
if (segments.length >= 2) {
|
||
try {
|
||
folderName = Uri.decodeComponent(segments[segments.length - 2]);
|
||
} catch (_) {
|
||
folderName = segments[segments.length - 2];
|
||
}
|
||
}
|
||
|
||
items.add(SongItem(
|
||
path: path,
|
||
fileName: displayName,
|
||
title: hasTitle ? title : null,
|
||
artist: artist.isNotEmpty ? artist : null,
|
||
artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty == true
|
||
? (row!['artwork_path'] as String).trim()
|
||
: null,
|
||
sourceTag: folderName.isNotEmpty ? '($folderName)' : '',
|
||
metadataState: hasTitle ? 'success' : 'unknown',
|
||
));
|
||
}
|
||
_favorites = items;
|
||
}
|
||
|
||
void _playSong(SongItem song) async {
|
||
try {
|
||
// 先校验登录,再开始播放。
|
||
// 以前这里是:playSong() 不 await,紧跟着又 PlaybackService().play() 一次,
|
||
// 同一个 Player 会被 open 两次,media_kit 串行化时第二次会顶掉第一次,
|
||
// 表现就是"媒体加载了但没有声音"。
|
||
final headers = await WebDAVService.instance.getAuthHeaders();
|
||
|
||
if (headers.isEmpty) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('请先登录 WebDAV'),
|
||
backgroundColor: Colors.orange,
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
if (!mounted) return;
|
||
|
||
final url = WebDAVService.instance.getFileUrl(song.path);
|
||
final audioService = context.read<AudioService>();
|
||
await 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,
|
||
),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
Future<void> _loadPlaylistSummaries() async {
|
||
try {
|
||
final list = await PlaylistRepository().getSummaries();
|
||
if (!mounted) return;
|
||
setState(() => _playlistSummaries = list);
|
||
} catch (e) {
|
||
debugPrint('⚠️ [Home] 读取歌单失败: $e');
|
||
}
|
||
}
|
||
|
||
Widget _buildHomeTabIndicator() {
|
||
Widget seg(int index) {
|
||
final active = _homeTab == index;
|
||
return GestureDetector(
|
||
onTap: () => safeSetState(() => _homeTab = index),
|
||
behavior: HitTestBehavior.opaque,
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 10, horizontal: 2),
|
||
child: AnimatedContainer(
|
||
duration: const Duration(milliseconds: 220),
|
||
curve: Curves.easeOutCubic,
|
||
width: active ? 26 : 7,
|
||
height: 7,
|
||
decoration: BoxDecoration(
|
||
color: active ? Colors.white : Colors.white24,
|
||
borderRadius: BorderRadius.circular(4),
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return Row(
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
seg(0),
|
||
const SizedBox(width: 6),
|
||
seg(1),
|
||
],
|
||
);
|
||
}
|
||
|
||
Widget _buildHomeSliver() {
|
||
if (_isLoading) {
|
||
return const SliverFillRemaining(
|
||
child: Center(
|
||
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
|
||
),
|
||
);
|
||
}
|
||
return _homeTab == 0 ? _buildFavoritesSliver() : _buildPlaylistsSliver();
|
||
}
|
||
|
||
Widget _buildFavoritesSliver() {
|
||
if (_favorites.isEmpty) {
|
||
return SliverFillRemaining(
|
||
child: AnimatedOpacity(
|
||
opacity: 1.0 - _listReveal * 0.3,
|
||
duration: const Duration(milliseconds: 100),
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.favorite_border, size: 48, color: Colors.grey[600]),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'还没有收藏歌曲\n在音乐库中点击 ♡ 添加',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
color: Colors.grey[500],
|
||
height: 1.6,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return SliverList(
|
||
delegate: SliverChildBuilderDelegate(
|
||
(context, index) {
|
||
final song = _favorites[index];
|
||
return TweenAnimationBuilder<double>(
|
||
key: ValueKey('fav-$_homeTab-$index'),
|
||
tween: Tween(begin: 0.0, end: 1.0),
|
||
duration: const Duration(milliseconds: 240),
|
||
curve: Curves.easeOutCubic,
|
||
builder: (context, t, child) => Opacity(
|
||
opacity: t,
|
||
child: Transform.translate(
|
||
offset: Offset(0, (1 - t) * 10),
|
||
child: child,
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: ListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
visualDensity: VisualDensity.compact,
|
||
leading: SongArtwork(
|
||
filePath: song.artworkPath,
|
||
size: 44,
|
||
fallbackIcon: Icons.music_note,
|
||
),
|
||
title: Text(
|
||
song.displayTitle,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w400,
|
||
color: Colors.white,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
subtitle: Text(
|
||
song.displaySubtitle,
|
||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
trailing: IconButton(
|
||
icon: const Icon(
|
||
Icons.favorite,
|
||
color: Color(0xFFE05C5C),
|
||
size: 20,
|
||
),
|
||
onPressed: () async {
|
||
await SongDatabase().removeFavorite(song.path);
|
||
await _loadFavoritesFromDb();
|
||
if (mounted) safeSetState(() {});
|
||
},
|
||
),
|
||
onTap: () => _playFavorites(index),
|
||
),
|
||
),
|
||
);
|
||
},
|
||
childCount: _favorites.length,
|
||
),
|
||
);
|
||
}
|
||
|
||
Widget _buildPlaylistsSliver() {
|
||
if (_playlistSummaries.isEmpty) {
|
||
return SliverFillRemaining(
|
||
child: Center(
|
||
child: Column(
|
||
mainAxisAlignment: MainAxisAlignment.center,
|
||
children: [
|
||
Icon(Icons.queue_music, size: 48, color: Colors.grey[600]),
|
||
const SizedBox(height: 16),
|
||
Text(
|
||
'还没有歌单\n在文件列表长按歌曲即可加入',
|
||
textAlign: TextAlign.center,
|
||
style: TextStyle(
|
||
fontSize: 16,
|
||
color: Colors.grey[500],
|
||
height: 1.6,
|
||
),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
);
|
||
}
|
||
|
||
return SliverList(
|
||
delegate: SliverChildBuilderDelegate(
|
||
(context, index) {
|
||
final playlist = _playlistSummaries[index];
|
||
return TweenAnimationBuilder<double>(
|
||
key: ValueKey('pl-$_homeTab-$index'),
|
||
tween: Tween(begin: 0.0, end: 1.0),
|
||
duration: const Duration(milliseconds: 240),
|
||
curve: Curves.easeOutCubic,
|
||
builder: (context, t, child) => Opacity(
|
||
opacity: t,
|
||
child: Transform.translate(
|
||
offset: Offset(0, (1 - t) * 10),
|
||
child: child,
|
||
),
|
||
),
|
||
child: Padding(
|
||
padding: const EdgeInsets.symmetric(vertical: 2),
|
||
child: ListTile(
|
||
contentPadding: EdgeInsets.zero,
|
||
visualDensity: VisualDensity.compact,
|
||
leading: Container(
|
||
width: 44,
|
||
height: 44,
|
||
decoration: BoxDecoration(
|
||
color: const Color(0xFF1E2A28),
|
||
borderRadius: BorderRadius.circular(10),
|
||
),
|
||
child: const Icon(
|
||
Icons.queue_music,
|
||
color: Color(0xFFB8D4D0),
|
||
size: 22,
|
||
),
|
||
),
|
||
title: Text(
|
||
playlist.name,
|
||
style: const TextStyle(
|
||
fontSize: 16,
|
||
fontWeight: FontWeight.w400,
|
||
color: Colors.white,
|
||
),
|
||
maxLines: 1,
|
||
overflow: TextOverflow.ellipsis,
|
||
),
|
||
subtitle: Text(
|
||
'${playlist.songCount} 首',
|
||
style: TextStyle(fontSize: 13, color: Colors.grey[400]),
|
||
),
|
||
onTap: () async {
|
||
await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => PlaylistDetailPage(
|
||
playlistId: playlist.id,
|
||
playlistName: playlist.name,
|
||
),
|
||
),
|
||
);
|
||
await _loadPlaylistSummaries();
|
||
if (mounted) safeSetState(() {});
|
||
},
|
||
),
|
||
),
|
||
);
|
||
},
|
||
childCount: _playlistSummaries.length,
|
||
),
|
||
);
|
||
}
|
||
|
||
/// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表
|
||
Future<void> _playFavorites(int index) async {
|
||
if (_favorites.isEmpty) return;
|
||
try {
|
||
final headers = await WebDAVService.instance.getAuthHeaders();
|
||
if (headers.isEmpty) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
const SnackBar(
|
||
content: Text('请先登录 WebDAV'),
|
||
backgroundColor: Colors.orange,
|
||
),
|
||
);
|
||
}
|
||
return;
|
||
}
|
||
|
||
final songs = _favorites
|
||
.map((item) => Song(
|
||
id: item.path,
|
||
title: item.displayTitle,
|
||
artist: item.displaySubtitle,
|
||
url: WebDAVService.instance.getFileUrl(item.path),
|
||
))
|
||
.toList();
|
||
|
||
if (!mounted) return;
|
||
debugPrint('⭐ [Home] 收藏整队入队:${songs.length} 首,从第 $index 首开始');
|
||
await context.read<AudioService>().setQueue(songs, startIndex: index);
|
||
} catch (e) {
|
||
if (mounted) {
|
||
ScaffoldMessenger.of(context).showSnackBar(
|
||
SnackBar(content: Text('播放失败: $e'), backgroundColor: Colors.red),
|
||
);
|
||
}
|
||
}
|
||
}
|
||
|
||
// ⭐ 退出/重新登录对话框
|
||
void _showReauthDialog() {
|
||
showDialog(
|
||
context: context,
|
||
builder: (context) => AlertDialog(
|
||
backgroundColor: const Color(0xFF1A1F1E),
|
||
title: const Text(
|
||
'重新登录 WebDAV',
|
||
style: TextStyle(color: Colors.white),
|
||
),
|
||
content: const Text(
|
||
'退出当前账号并重新登录?',
|
||
style: TextStyle(color: Colors.grey),
|
||
),
|
||
actions: [
|
||
TextButton(
|
||
onPressed: () => Navigator.pop(context),
|
||
child: const Text('取消'),
|
||
),
|
||
TextButton(
|
||
onPressed: () async {
|
||
Navigator.pop(context);
|
||
await WebDAVService.instance.clearCredentials();
|
||
safeSetState(() {
|
||
_favorites = [];
|
||
_isLoading = false;
|
||
});
|
||
if (mounted) {
|
||
// 用 push 保留 HomePage 在栈底;登录成功后 Setup 会 popUntil 回到这里
|
||
await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => const WebDAVSetupPage(),
|
||
),
|
||
);
|
||
if (mounted) {
|
||
await _initWebDAV();
|
||
safeSetState(() {});
|
||
}
|
||
}
|
||
},
|
||
child: const Text(
|
||
'重新登录',
|
||
style: TextStyle(color: Colors.redAccent),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
|
||
void _resetSnapState() {
|
||
if (_scrollController.hasClients) {
|
||
_scrollController.jumpTo(0.0);
|
||
}
|
||
safeSetState(() {});
|
||
}
|
||
|
||
@override
|
||
Widget build(BuildContext context) {
|
||
final isConnected = WebDAVService.instance.isConnected;
|
||
final username = WebDAVService.instance.username ?? '点击连接';
|
||
|
||
return Scaffold(
|
||
backgroundColor: const Color(0xFF0E1211),
|
||
body: Column(
|
||
children: [
|
||
SafeArea(
|
||
bottom: false,
|
||
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: () {
|
||
Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => const SettingsPage(),
|
||
),
|
||
);
|
||
},
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
Expanded(
|
||
child: GestureDetector(
|
||
onHorizontalDragEnd: (details) {
|
||
final v = details.primaryVelocity ?? 0;
|
||
if (v < -200 && _homeTab == 0) {
|
||
safeSetState(() => _homeTab = 1);
|
||
} else if (v > 200 && _homeTab == 1) {
|
||
safeSetState(() => _homeTab = 0);
|
||
}
|
||
},
|
||
child: CustomScrollView(
|
||
controller: _scrollController,
|
||
physics: _mediaLibraryHeight > 0
|
||
? MagneticScrollPhysics(
|
||
snapPoint: _mediaLibraryHeight,
|
||
magneticZoneStart: 0.20,
|
||
)
|
||
: const ClampingScrollPhysics(),
|
||
slivers: [
|
||
SliverToBoxAdapter(
|
||
child: Opacity(
|
||
opacity: _mediaOpacity,
|
||
child: Container(
|
||
key: _mediaLibraryKey,
|
||
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),
|
||
_ClickableTile(
|
||
onTap: () async {
|
||
if (WebDAVService.instance.isConnected) {
|
||
await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => const WebDAVFileListPage(),
|
||
),
|
||
);
|
||
await _loadFavoritesFromDb();
|
||
_resetSnapState();
|
||
safeSetState(() {});
|
||
} else {
|
||
final result = await Navigator.push(
|
||
context,
|
||
MaterialPageRoute(
|
||
builder: (_) => const WebDAVSetupPage(),
|
||
),
|
||
);
|
||
safeSetState(() {});
|
||
if (result == true) {
|
||
await WebDAVService.instance
|
||
.loadCredentials();
|
||
safeSetState(() {});
|
||
}
|
||
}
|
||
},
|
||
child: Row(
|
||
crossAxisAlignment: CrossAxisAlignment.start,
|
||
children: [
|
||
// 左侧云朵图标(固定 56px)
|
||
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,
|
||
mainAxisSize: MainAxisSize.min,
|
||
children: [
|
||
// 第一行:WebDAV + 状态 + 退出按钮
|
||
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 Spacer(),
|
||
if (isConnected)
|
||
Padding(
|
||
padding:
|
||
const EdgeInsets.only(top: 2),
|
||
child: GestureDetector(
|
||
onTap: _showReauthDialog,
|
||
child: const Icon(
|
||
Icons.logout,
|
||
color: Colors.grey,
|
||
size: 18,
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
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(height: 32),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SliverPersistentHeader(
|
||
pinned: true,
|
||
delegate: _StickyHeaderDelegate(
|
||
child: Container(
|
||
height: 48,
|
||
color: const Color(0xFF0E1211),
|
||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||
child: Row(
|
||
children: [
|
||
Icon(
|
||
_homeTab == 0
|
||
? Icons.favorite
|
||
: Icons.queue_music,
|
||
color: const Color(0xFFB8D4D0),
|
||
size: 20,
|
||
),
|
||
const SizedBox(width: 8),
|
||
Text(
|
||
_homeTab == 0 ? '我的收藏' : '歌单列表',
|
||
style: const TextStyle(
|
||
fontSize: 22,
|
||
fontWeight: FontWeight.w500,
|
||
color: Color(0xFFB8D4D0),
|
||
),
|
||
),
|
||
const Spacer(),
|
||
_buildHomeTabIndicator(),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
),
|
||
SliverPadding(
|
||
padding: const EdgeInsets.only(
|
||
left: 20,
|
||
right: 20,
|
||
bottom: UIConstants.miniPlayerBottomSpace,
|
||
),
|
||
sliver: _buildHomeSliver(),
|
||
),
|
||
],
|
||
),
|
||
),
|
||
),
|
||
],
|
||
),
|
||
);
|
||
}
|
||
}
|