两个边界场景: 1) 新建/重命名/删除歌单、加入/移除歌曲后,主页歌单页不刷新 2) 歌单详情页里歌曲补全元数据后,标题/封面不更新 - 新增 PlaylistEvents(void 事件) - PlaylistRepository 的创建/重命名/删除/批量加入/移除后 publish - 主页订阅后重新拉取歌单摘要 - 歌单详情页订阅 MetadataEvents,按 path 就地更新行
212 lines
6.6 KiB
Dart
212 lines
6.6 KiB
Dart
import 'dart:async';
|
|
// 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 '../services/metadata_events.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 = [];
|
|
StreamSubscription<MetadataUpdate>? _metaSub;
|
|
bool _loading = true;
|
|
|
|
@override
|
|
void initState() {
|
|
super.initState();
|
|
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
|
|
_load();
|
|
}
|
|
|
|
@override
|
|
void dispose() {
|
|
_metaSub?.cancel();
|
|
super.dispose();
|
|
}
|
|
|
|
/// 歌单里的歌补全元数据后,就地更新对应行
|
|
void _onMetadataUpdate(MetadataUpdate update) {
|
|
final index = _songs.indexWhere((item) => item.path == update.path);
|
|
if (index < 0 || !mounted) return;
|
|
final row = update.row;
|
|
final title = (row['title'] as String?)?.trim() ?? '';
|
|
final artist = (row['artist'] as String?)?.trim() ?? '';
|
|
final artwork = (row['artwork_path'] as String?)?.trim() ?? '';
|
|
final old = _songs[index];
|
|
setState(() {
|
|
_songs[index] = _SongRow(
|
|
path: old.path,
|
|
title: title.isNotEmpty ? title : old.title,
|
|
artist: artist.isNotEmpty ? artist : old.artist,
|
|
artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath,
|
|
);
|
|
});
|
|
}
|
|
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),
|
|
),
|
|
],
|
|
),
|
|
);
|
|
}
|
|
}
|