feat(playlist): 歌单集合页 + 歌单详情页 + 首页入口
style(ui): 播放页红心与标题居中对齐、标题单行 - playlists_page:歌单列表(封面/名称/歌曲数)、新建、重命名、删除 - playlist_detail_page:歌单内歌曲、播放全部、单曲播放、移除 - 首页"歌单列表"按钮接入 PlaylistsPage - 播放页:标题行改为 center 对齐、标题限制单行, 收藏按钮压到 36x36,红心与标题不再错位
This commit is contained in:
@@ -5,6 +5,7 @@ import '../services/webdav_service.dart';
|
||||
import '../widgets/magnetic_scroll_physics.dart';
|
||||
import 'webdav_setup_page.dart';
|
||||
import 'settings_page.dart';
|
||||
import 'playlists_page.dart';
|
||||
import '../database/song_database.dart';
|
||||
import '../widgets/song_artwork.dart';
|
||||
import 'webdav_file_list_page.dart';
|
||||
@@ -664,7 +665,15 @@ class _HomePageState extends BaseState<HomePage> {
|
||||
const SizedBox(width: 16),
|
||||
Expanded(
|
||||
child: _ClickableTile(
|
||||
onTap: () {},
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => const PlaylistsPage(),
|
||||
),
|
||||
);
|
||||
if (mounted) safeSetState(() {});
|
||||
},
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
vertical: 10),
|
||||
|
||||
@@ -337,7 +337,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
||||
|
||||
// ---- 标题 / 艺术家(收藏心在标题右侧)----
|
||||
Row(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
crossAxisAlignment: CrossAxisAlignment.center,
|
||||
children: [
|
||||
Expanded(
|
||||
child: Text(
|
||||
@@ -348,7 +348,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
||||
color: Colors.white,
|
||||
height: 1.25,
|
||||
),
|
||||
maxLines: 2,
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
),
|
||||
@@ -356,17 +356,21 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
||||
IconButton(
|
||||
onPressed: () => _toggleFavorite(song),
|
||||
tooltip: '收藏',
|
||||
padding: EdgeInsets.zero,
|
||||
constraints:
|
||||
const BoxConstraints(minWidth: 36, minHeight: 36),
|
||||
iconSize: 24,
|
||||
icon: Icon(
|
||||
_isFavorite ? Icons.favorite : Icons.favorite_border,
|
||||
color: _isFavorite
|
||||
? const Color(0xFFE05C5C)
|
||||
: Colors.white54,
|
||||
size: 26,
|
||||
size: 24,
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
const SizedBox(height: 10),
|
||||
const SizedBox(height: 2),
|
||||
Text(
|
||||
song.artist,
|
||||
style: TextStyle(fontSize: 16, color: Colors.grey[300]),
|
||||
|
||||
@@ -0,0 +1,183 @@
|
||||
// lib/pages/playlist_detail_page.dart
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:provider/provider.dart';
|
||||
|
||||
import '../database/song_database.dart';
|
||||
import '../repositories/playlist_repository.dart';
|
||||
import '../services/audio_service.dart';
|
||||
import '../widgets/song_artwork.dart';
|
||||
|
||||
/// 歌单详情:展示歌单内歌曲,支持播放全部 / 单曲播放 / 移除
|
||||
class PlaylistDetailPage extends StatefulWidget {
|
||||
final String playlistId;
|
||||
final String playlistName;
|
||||
|
||||
const PlaylistDetailPage({
|
||||
super.key,
|
||||
required this.playlistId,
|
||||
required this.playlistName,
|
||||
});
|
||||
|
||||
@override
|
||||
State<PlaylistDetailPage> createState() => _PlaylistDetailPageState();
|
||||
}
|
||||
|
||||
class _SongRow {
|
||||
final String path;
|
||||
final String title;
|
||||
final String artist;
|
||||
final String? artworkPath;
|
||||
|
||||
const _SongRow({
|
||||
required this.path,
|
||||
required this.title,
|
||||
required this.artist,
|
||||
this.artworkPath,
|
||||
});
|
||||
}
|
||||
|
||||
class _PlaylistDetailPageState extends State<PlaylistDetailPage> {
|
||||
final PlaylistRepository _repo = PlaylistRepository();
|
||||
final SongDatabase _db = SongDatabase();
|
||||
|
||||
List<_SongRow> _songs = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final ids = await _repo.getPlaylistSongIds(widget.playlistId);
|
||||
final meta = await _db.getSongsByPaths(ids);
|
||||
final rows = <_SongRow>[];
|
||||
for (final id in ids) {
|
||||
final row = meta[id];
|
||||
final title = (row?['title'] as String?)?.trim() ?? '';
|
||||
final artist = (row?['artist'] as String?)?.trim() ?? '';
|
||||
String fallbackName = id.split('/').last;
|
||||
try {
|
||||
fallbackName = Uri.decodeComponent(fallbackName);
|
||||
} catch (_) {}
|
||||
rows.add(_SongRow(
|
||||
path: id,
|
||||
title: title.isNotEmpty
|
||||
? title
|
||||
: fallbackName.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
||||
artist: artist.isNotEmpty ? artist : '未知艺术家',
|
||||
artworkPath: (row?['artwork_path'] as String?)?.trim().isNotEmpty ==
|
||||
true
|
||||
? (row!['artwork_path'] as String).trim()
|
||||
: null,
|
||||
));
|
||||
}
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_songs = rows;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('读取歌单失败: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _play(int index) async {
|
||||
await context
|
||||
.read<AudioService>()
|
||||
.playPlaylist(widget.playlistId, startIndex: index);
|
||||
}
|
||||
|
||||
Future<void> _remove(_SongRow song) async {
|
||||
await _repo.removeSong(widget.playlistId, song.path);
|
||||
await _load();
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E1211),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: Text(widget.playlistName),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.play_arrow_rounded, color: Color(0xFFB8D4D0)),
|
||||
tooltip: '播放全部',
|
||||
onPressed: _songs.isEmpty ? null : () => _play(0),
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _songs.isEmpty
|
||||
? _buildEmpty()
|
||||
: ListView.builder(
|
||||
padding:
|
||||
const EdgeInsets.symmetric(horizontal: 16, vertical: 8),
|
||||
itemCount: _songs.length,
|
||||
itemBuilder: (context, index) {
|
||||
final song = _songs[index];
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 4),
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: SongArtwork(
|
||||
filePath: song.artworkPath,
|
||||
size: 44,
|
||||
fallbackIcon: Icons.music_note,
|
||||
),
|
||||
title: Text(
|
||||
song.title,
|
||||
style:
|
||||
const TextStyle(color: Colors.white, fontSize: 16),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
song.artist,
|
||||
style: const TextStyle(color: Colors.grey, fontSize: 13),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
trailing: IconButton(
|
||||
icon: const Icon(Icons.close,
|
||||
color: Colors.white24, size: 18),
|
||||
tooltip: '从歌单移除',
|
||||
onPressed: () => _remove(song),
|
||||
),
|
||||
onTap: () => _play(index),
|
||||
);
|
||||
},
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.music_off_outlined, size: 44, color: Colors.grey[700]),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'歌单还是空的',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'在文件列表长按歌曲即可加入',
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,241 @@
|
||||
// lib/pages/playlists_page.dart
|
||||
import 'package:flutter/material.dart';
|
||||
|
||||
import '../models/playlist_summary.dart';
|
||||
import '../repositories/playlist_repository.dart';
|
||||
import '../widgets/song_artwork.dart';
|
||||
import 'playlist_detail_page.dart';
|
||||
|
||||
/// 歌单集合页:展示所有歌单,支持新建 / 重命名 / 删除
|
||||
class PlaylistsPage extends StatefulWidget {
|
||||
const PlaylistsPage({super.key});
|
||||
|
||||
@override
|
||||
State<PlaylistsPage> createState() => _PlaylistsPageState();
|
||||
}
|
||||
|
||||
class _PlaylistsPageState extends State<PlaylistsPage> {
|
||||
final PlaylistRepository _repo = PlaylistRepository();
|
||||
List<PlaylistSummary> _playlists = [];
|
||||
bool _loading = true;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_load();
|
||||
}
|
||||
|
||||
Future<void> _load() async {
|
||||
setState(() => _loading = true);
|
||||
try {
|
||||
final list = await _repo.getSummaries();
|
||||
if (!mounted) return;
|
||||
setState(() {
|
||||
_playlists = list;
|
||||
_loading = false;
|
||||
});
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
setState(() => _loading = false);
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(content: Text('读取歌单失败: $e')),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _create() async {
|
||||
final name = await _promptName('新建歌单');
|
||||
if (name == null || name.isEmpty) return;
|
||||
try {
|
||||
await _repo.createPlaylist(name);
|
||||
await _load();
|
||||
} catch (e) {
|
||||
if (!mounted) return;
|
||||
ScaffoldMessenger.of(context)
|
||||
.showSnackBar(SnackBar(content: Text('新建失败: $e')));
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _rename(PlaylistSummary playlist) async {
|
||||
final name = await _promptName('重命名歌单', initial: playlist.name);
|
||||
if (name == null || name.isEmpty) return;
|
||||
await _repo.updatePlaylistName(playlist.id, name);
|
||||
await _load();
|
||||
}
|
||||
|
||||
Future<void> _delete(PlaylistSummary playlist) async {
|
||||
final ok = await showDialog<bool>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A1F1E),
|
||||
title: const Text('删除歌单', style: TextStyle(color: Colors.white)),
|
||||
content: Text(
|
||||
'删除「${playlist.name}」?歌单内的歌曲不会从网盘删除。',
|
||||
style: const TextStyle(color: Colors.grey),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, false),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, true),
|
||||
child: const Text('删除', style: TextStyle(color: Colors.redAccent)),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
if (ok == true) {
|
||||
await _repo.deletePlaylist(playlist.id);
|
||||
await _load();
|
||||
}
|
||||
}
|
||||
|
||||
Future<String?> _promptName(String title, {String initial = ''}) async {
|
||||
final controller = TextEditingController(text: initial);
|
||||
return showDialog<String>(
|
||||
context: context,
|
||||
builder: (context) => AlertDialog(
|
||||
backgroundColor: const Color(0xFF1A1F1E),
|
||||
title: Text(title, style: const TextStyle(color: Colors.white)),
|
||||
content: TextField(
|
||||
controller: controller,
|
||||
autofocus: true,
|
||||
style: const TextStyle(color: Colors.white),
|
||||
decoration: const InputDecoration(hintText: '歌单名称'),
|
||||
),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context),
|
||||
child: const Text('取消'),
|
||||
),
|
||||
TextButton(
|
||||
onPressed: () => Navigator.pop(context, controller.text.trim()),
|
||||
child: const Text('确定'),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
return Scaffold(
|
||||
backgroundColor: const Color(0xFF0E1211),
|
||||
appBar: AppBar(
|
||||
backgroundColor: Colors.transparent,
|
||||
elevation: 0,
|
||||
title: const Text('歌单'),
|
||||
actions: [
|
||||
IconButton(
|
||||
icon: const Icon(Icons.add, color: Color(0xFFB8D4D0)),
|
||||
tooltip: '新建歌单',
|
||||
onPressed: _create,
|
||||
),
|
||||
],
|
||||
),
|
||||
body: _loading
|
||||
? const Center(child: CircularProgressIndicator())
|
||||
: _playlists.isEmpty
|
||||
? _buildEmpty()
|
||||
: RefreshIndicator(
|
||||
onRefresh: _load,
|
||||
child: ListView.builder(
|
||||
padding: const EdgeInsets.symmetric(
|
||||
horizontal: 16, vertical: 8),
|
||||
itemCount: _playlists.length,
|
||||
itemBuilder: (context, index) {
|
||||
final playlist = _playlists[index];
|
||||
return ListTile(
|
||||
contentPadding:
|
||||
const EdgeInsets.symmetric(horizontal: 4),
|
||||
visualDensity: VisualDensity.compact,
|
||||
leading: SongArtwork(
|
||||
filePath: playlist.coverPath,
|
||||
size: 52,
|
||||
fallbackIcon: Icons.queue_music,
|
||||
),
|
||||
title: Text(
|
||||
playlist.name,
|
||||
style: const TextStyle(
|
||||
color: Colors.white, fontSize: 16),
|
||||
maxLines: 1,
|
||||
overflow: TextOverflow.ellipsis,
|
||||
),
|
||||
subtitle: Text(
|
||||
'${playlist.songCount} 首',
|
||||
style: const TextStyle(
|
||||
color: Colors.grey, fontSize: 12),
|
||||
),
|
||||
trailing: PopupMenuButton<String>(
|
||||
icon: const Icon(Icons.more_vert,
|
||||
color: Colors.white38, size: 20),
|
||||
onSelected: (value) {
|
||||
if (value == 'rename') _rename(playlist);
|
||||
if (value == 'delete') _delete(playlist);
|
||||
},
|
||||
itemBuilder: (context) => const [
|
||||
PopupMenuItem(
|
||||
value: 'rename',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.edit_outlined,
|
||||
color: Color(0xFFB8D4D0), size: 18),
|
||||
SizedBox(width: 10),
|
||||
Text('重命名'),
|
||||
],
|
||||
),
|
||||
),
|
||||
PopupMenuItem(
|
||||
value: 'delete',
|
||||
child: Row(
|
||||
children: [
|
||||
Icon(Icons.delete_outline,
|
||||
color: Colors.redAccent, size: 18),
|
||||
SizedBox(width: 10),
|
||||
Text('删除'),
|
||||
],
|
||||
),
|
||||
),
|
||||
],
|
||||
),
|
||||
onTap: () async {
|
||||
await Navigator.push(
|
||||
context,
|
||||
MaterialPageRoute(
|
||||
builder: (_) => PlaylistDetailPage(
|
||||
playlistId: playlist.id,
|
||||
playlistName: playlist.name,
|
||||
),
|
||||
),
|
||||
);
|
||||
await _load();
|
||||
},
|
||||
);
|
||||
},
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
Widget _buildEmpty() {
|
||||
return Center(
|
||||
child: Column(
|
||||
mainAxisAlignment: MainAxisAlignment.center,
|
||||
children: [
|
||||
Icon(Icons.queue_music, size: 48, color: Colors.grey[700]),
|
||||
const SizedBox(height: 14),
|
||||
Text(
|
||||
'还没有歌单',
|
||||
style: TextStyle(color: Colors.grey[500], fontSize: 15),
|
||||
),
|
||||
const SizedBox(height: 6),
|
||||
Text(
|
||||
'点右上角 + 新建一个',
|
||||
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||
),
|
||||
],
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user