Compare commits
34
Commits
672a545c38
..
test
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3a60cabca5 | ||
|
|
e10be527cb | ||
|
|
653c1f5563 | ||
|
|
708ab91ccb | ||
|
|
a927e3dd44 | ||
|
|
b6e2340aae | ||
|
|
eea8e46a90 | ||
|
|
fc9a630dd0 | ||
|
|
dbbf215ba8 | ||
|
|
7d701c5dfc | ||
|
|
83ab47d8fc | ||
|
|
6ca31c37ba | ||
|
|
632713d133 | ||
|
|
9a932e92d4 | ||
|
|
5a1910bee4 | ||
|
|
6cb96e95dc | ||
|
|
432e3b1260 | ||
|
|
7bf8d18f18 | ||
|
|
f793223311 | ||
|
|
e89bd6381a | ||
|
|
e3f72a69a9 | ||
|
|
1c57d59d73 | ||
|
|
6351ef1395 | ||
|
|
abe960cf58 | ||
|
|
7cd1e13a82 | ||
|
|
70227afea5 | ||
|
|
e010a9c0d3 | ||
|
|
1604f296c8 | ||
|
|
888ea3fd54 | ||
|
|
30e68780f4 | ||
|
|
5c4b0c2728 | ||
|
|
b439d1cb8c | ||
|
|
fdca721250 | ||
|
|
ca4546871c |
@@ -1,5 +1,5 @@
|
|||||||
// lib/constants/ui_constants.dart
|
// lib/constants/ui_constants.dart
|
||||||
class UIConstants {
|
class UIConstants {
|
||||||
static const double miniPlayerHeight = 60;
|
static const double miniPlayerHeight = 60;
|
||||||
static const double miniPlayerBottomSpace = 80; // 60 + 20 安全余量
|
static const double miniPlayerBottomSpace = 100; // 迷你播放器 60 + 手势区/安全余量
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -92,14 +92,27 @@ class PlaybackController {
|
|||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 单曲播放(fallback)
|
// 单曲播放(fallback)
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
|
/// 播放路径/文件名是百分号编码的,展示前必须解码
|
||||||
|
String _displayName(String pathOrName) {
|
||||||
|
final raw = pathOrName.split('/').last;
|
||||||
|
String decoded;
|
||||||
|
try {
|
||||||
|
decoded = Uri.decodeComponent(raw);
|
||||||
|
} catch (_) {
|
||||||
|
decoded = raw;
|
||||||
|
}
|
||||||
|
return decoded.replaceAll(RegExp(r'\.[^.]*$'), '');
|
||||||
|
}
|
||||||
Future<void> _playSingleSong(String songPath) async {
|
Future<void> _playSingleSong(String songPath) async {
|
||||||
debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath');
|
debugPrint('🎯 [PlaybackController] _playSingleSong: $songPath');
|
||||||
final url = WebDAVService.instance.getFileUrl(songPath);
|
final url = WebDAVService.instance.getFileUrl(songPath);
|
||||||
final dbSong = await _db.getSongByPath(songPath);
|
final dbSong = await _db.getSongByPath(songPath);
|
||||||
final song = Song(
|
final song = Song(
|
||||||
id: songPath,
|
id: songPath,
|
||||||
title: dbSong?['title'] as String? ??
|
artworkPath: dbSong?['artwork_path'] as String?,
|
||||||
songPath.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: (dbSong?['title'] as String?)?.trim().isNotEmpty == true
|
||||||
|
? dbSong!['title'] as String
|
||||||
|
: _displayName(songPath),
|
||||||
artist: dbSong?['artist'] as String? ?? '未知艺术家',
|
artist: dbSong?['artist'] as String? ?? '未知艺术家',
|
||||||
url: url,
|
url: url,
|
||||||
);
|
);
|
||||||
@@ -117,15 +130,17 @@ class PlaybackController {
|
|||||||
if (dbSong != null) {
|
if (dbSong != null) {
|
||||||
queue.add(Song(
|
queue.add(Song(
|
||||||
id: item.path,
|
id: item.path,
|
||||||
title: dbSong['title'] as String? ??
|
artworkPath: dbSong['artwork_path'] as String?,
|
||||||
item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: (dbSong['title'] as String?)?.trim().isNotEmpty == true
|
||||||
|
? dbSong['title'] as String
|
||||||
|
: _displayName(item.path),
|
||||||
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
||||||
url: url,
|
url: url,
|
||||||
));
|
));
|
||||||
} else {
|
} else {
|
||||||
queue.add(Song(
|
queue.add(Song(
|
||||||
id: item.path,
|
id: item.path,
|
||||||
title: item.name.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
title: _displayName(item.path),
|
||||||
artist: '未知艺术家',
|
artist: '未知艺术家',
|
||||||
url: url,
|
url: url,
|
||||||
));
|
));
|
||||||
|
|||||||
@@ -313,6 +313,26 @@ class SongDatabase {
|
|||||||
return await db.query('playlists', orderBy: 'created_at DESC');
|
return await db.query('playlists', orderBy: 'created_at DESC');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单摘要:歌曲数 + 封面(取歌单内第一首有封面的歌)。
|
||||||
|
/// 列表页只需要这些,不用把每首歌都拉出来。
|
||||||
|
Future<List<Map<String, dynamic>>> getPlaylistSummaries() async {
|
||||||
|
final db = await database;
|
||||||
|
return await db.rawQuery('''
|
||||||
|
SELECT p.id, p.name, p.updated_at,
|
||||||
|
(SELECT COUNT(*) FROM playlist_songs ps
|
||||||
|
WHERE ps.playlist_id = p.id) AS song_count,
|
||||||
|
(SELECT s.artwork_path FROM playlist_songs ps2
|
||||||
|
JOIN songs s ON s.remote_path = ps2.song_id
|
||||||
|
WHERE ps2.playlist_id = p.id
|
||||||
|
AND s.artwork_path IS NOT NULL
|
||||||
|
AND s.artwork_path != ''
|
||||||
|
ORDER BY ps2.order_index
|
||||||
|
LIMIT 1) AS cover_path
|
||||||
|
FROM playlists p
|
||||||
|
ORDER BY p.updated_at DESC
|
||||||
|
''');
|
||||||
|
}
|
||||||
|
|
||||||
Future<Map<String, dynamic>?> getPlaylist(String id) async {
|
Future<Map<String, dynamic>?> getPlaylist(String id) async {
|
||||||
_logQuery('playlists', 'get', args: {'id': id});
|
_logQuery('playlists', 'get', args: {'id': id});
|
||||||
final db = await database;
|
final db = await database;
|
||||||
|
|||||||
+2
-24
@@ -7,6 +7,7 @@ import 'package:permission_handler/permission_handler.dart';
|
|||||||
|
|
||||||
import 'package:flutter/services.dart';
|
import 'package:flutter/services.dart';
|
||||||
import 'services/audio_service.dart';
|
import 'services/audio_service.dart';
|
||||||
|
import 'services/route_manager.dart';
|
||||||
import 'services/playback_service.dart';
|
import 'services/playback_service.dart';
|
||||||
import 'services/app_lifecycle_service.dart';
|
import 'services/app_lifecycle_service.dart';
|
||||||
import 'services/webdav_service.dart';
|
import 'services/webdav_service.dart';
|
||||||
@@ -107,28 +108,6 @@ void main() async {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
// 路由管理器(控制 MiniPlayer 显示/隐藏)
|
|
||||||
// ════════════════════════════════════════════════════════════
|
|
||||||
class RouteManager extends ChangeNotifier {
|
|
||||||
static final RouteManager _instance = RouteManager._internal();
|
|
||||||
factory RouteManager() => _instance;
|
|
||||||
RouteManager._internal();
|
|
||||||
|
|
||||||
String _currentRoute = '/';
|
|
||||||
String get currentRoute => _currentRoute;
|
|
||||||
|
|
||||||
bool get showMiniPlayer =>
|
|
||||||
_currentRoute != '/player' && _currentRoute != '/playlist';
|
|
||||||
|
|
||||||
void updateRoute(String route) {
|
|
||||||
if (_currentRoute != route) {
|
|
||||||
_currentRoute = route;
|
|
||||||
debugPrint('📌 路由更新: $route');
|
|
||||||
notifyListeners();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
// 导航观察者(监听路由变化)
|
// 导航观察者(监听路由变化)
|
||||||
@@ -399,8 +378,7 @@ class _QTPlayerAppState extends State<QTPlayerApp> {
|
|||||||
animation: RouteManager(),
|
animation: RouteManager(),
|
||||||
builder: (context, _) {
|
builder: (context, _) {
|
||||||
final routeManager = RouteManager();
|
final routeManager = RouteManager();
|
||||||
final shouldShow = routeManager.currentRoute != '/player' &&
|
final shouldShow = routeManager.showMiniPlayer;
|
||||||
routeManager.currentRoute != '/playlist';
|
|
||||||
|
|
||||||
return AnimatedSwitcher(
|
return AnimatedSwitcher(
|
||||||
duration: const Duration(milliseconds: 300),
|
duration: const Duration(milliseconds: 300),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ import 'metadata_model.dart';
|
|||||||
import 'metadata_reader.dart';
|
import 'metadata_reader.dart';
|
||||||
import 'metadata_normalizer.dart';
|
import 'metadata_normalizer.dart';
|
||||||
import 'metadata_stability.dart';
|
import 'metadata_stability.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
import 'metadata_cache.dart';
|
import 'metadata_cache.dart';
|
||||||
import 'file_provider.dart';
|
import 'file_provider.dart';
|
||||||
import '../utils/artwork_helper.dart';
|
import '../utils/artwork_helper.dart';
|
||||||
@@ -118,6 +119,9 @@ class MetadataService {
|
|||||||
source: 'database',
|
source: 'database',
|
||||||
);
|
);
|
||||||
await _memoryCache.put(fileId, metadata);
|
await _memoryCache.put(fileId, metadata);
|
||||||
|
// 命中也推一次:列表页可能是元数据写入前加载的,
|
||||||
|
// 靠这次事件把那一行补上(幂等,订阅方按 path 覆盖)。
|
||||||
|
MetadataEvents().publish(fileId, dbSong);
|
||||||
return metadata;
|
return metadata;
|
||||||
}
|
}
|
||||||
debugPrint('🔁 [MetadataService] 记录未验证'
|
debugPrint('🔁 [MetadataService] 记录未验证'
|
||||||
@@ -189,6 +193,9 @@ class MetadataService {
|
|||||||
await _db.updateSong(songKey, row);
|
await _db.updateSong(songKey, row);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 通知界面局部刷新(收藏/文件列表/歌单等)
|
||||||
|
MetadataEvents().publish(fileId, row);
|
||||||
|
|
||||||
final merged = FinalMetadata(
|
final merged = FinalMetadata(
|
||||||
title: row['title'] as String? ?? '',
|
title: row['title'] as String? ?? '',
|
||||||
artist: row['artist'] as String? ?? '',
|
artist: row['artist'] as String? ?? '',
|
||||||
|
|||||||
@@ -0,0 +1,33 @@
|
|||||||
|
// lib/models/playlist_summary.dart
|
||||||
|
|
||||||
|
/// 歌单列表用的轻量摘要(不加载歌单内全部歌曲)
|
||||||
|
class PlaylistSummary {
|
||||||
|
final String id;
|
||||||
|
final String name;
|
||||||
|
final int songCount;
|
||||||
|
|
||||||
|
/// 封面:取歌单里第一首有封面的歌
|
||||||
|
final String? coverPath;
|
||||||
|
final int updatedAt;
|
||||||
|
|
||||||
|
const PlaylistSummary({
|
||||||
|
required this.id,
|
||||||
|
required this.name,
|
||||||
|
this.songCount = 0,
|
||||||
|
this.coverPath,
|
||||||
|
this.updatedAt = 0,
|
||||||
|
});
|
||||||
|
|
||||||
|
factory PlaylistSummary.fromRow(Map<String, dynamic> row) {
|
||||||
|
final cover = (row['cover_path'] as String?)?.trim() ?? '';
|
||||||
|
return PlaylistSummary(
|
||||||
|
id: row['id'] as String? ?? '',
|
||||||
|
name: (row['name'] as String?)?.trim() ?? '',
|
||||||
|
songCount: (row['song_count'] as num?)?.toInt() ?? 0,
|
||||||
|
coverPath: cover.isEmpty ? null : cover,
|
||||||
|
updatedAt: (row['updated_at'] as num?)?.toInt() ?? 0,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
bool get isEmpty => songCount == 0;
|
||||||
|
}
|
||||||
+348
-151
@@ -1,11 +1,19 @@
|
|||||||
|
import 'dart:async';
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/favorite_events.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
|
import '../services/playlist_events.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import '../widgets/magnetic_scroll_physics.dart';
|
import '../widgets/magnetic_scroll_physics.dart';
|
||||||
import 'webdav_setup_page.dart';
|
import 'webdav_setup_page.dart';
|
||||||
import 'settings_page.dart';
|
import 'settings_page.dart';
|
||||||
import '../database/song_database.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 'webdav_file_list_page.dart';
|
||||||
import '../constants/ui_constants.dart';
|
import '../constants/ui_constants.dart';
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
@@ -18,6 +26,7 @@ class SongItem {
|
|||||||
final String fileName;
|
final String fileName;
|
||||||
final String? title;
|
final String? title;
|
||||||
final String? artist;
|
final String? artist;
|
||||||
|
final String? artworkPath;
|
||||||
final String sourceTag;
|
final String sourceTag;
|
||||||
final String metadataState;
|
final String metadataState;
|
||||||
|
|
||||||
@@ -26,6 +35,7 @@ class SongItem {
|
|||||||
required this.fileName,
|
required this.fileName,
|
||||||
this.title,
|
this.title,
|
||||||
this.artist,
|
this.artist,
|
||||||
|
this.artworkPath,
|
||||||
required this.sourceTag,
|
required this.sourceTag,
|
||||||
this.metadataState = 'unknown',
|
this.metadataState = 'unknown',
|
||||||
});
|
});
|
||||||
@@ -154,6 +164,14 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
List<SongItem> _favorites = [];
|
List<SongItem> _favorites = [];
|
||||||
bool _isLoading = true;
|
bool _isLoading = true;
|
||||||
|
|
||||||
|
/// 0 = 我的收藏,1 = 歌单列表(左右滑动切换)
|
||||||
|
int _homeTab = 0;
|
||||||
|
final PageController _homePageController = PageController();
|
||||||
|
StreamSubscription<MetadataUpdate>? _metaSub;
|
||||||
|
StreamSubscription<String>? _favSub;
|
||||||
|
StreamSubscription<void>? _plSub;
|
||||||
|
List<PlaylistSummary> _playlistSummaries = [];
|
||||||
|
|
||||||
// ========== 滚动控制 ==========
|
// ========== 滚动控制 ==========
|
||||||
late final ScrollController _scrollController;
|
late final ScrollController _scrollController;
|
||||||
final GlobalKey _mediaLibraryKey = GlobalKey();
|
final GlobalKey _mediaLibraryKey = GlobalKey();
|
||||||
@@ -189,6 +207,9 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
_scrollController = ScrollController()..addListener(_onScroll);
|
_scrollController = ScrollController()..addListener(_onScroll);
|
||||||
|
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
|
||||||
|
_favSub = FavoriteEvents().stream.listen((_) => _onFavoriteChanged());
|
||||||
|
_plSub = PlaylistEvents().stream.listen((_) => _onPlaylistChanged());
|
||||||
_initWebDAV();
|
_initWebDAV();
|
||||||
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
@@ -198,6 +219,10 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_metaSub?.cancel();
|
||||||
|
_favSub?.cancel();
|
||||||
|
_plSub?.cancel();
|
||||||
|
_homePageController.dispose();
|
||||||
_scrollController.dispose();
|
_scrollController.dispose();
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
@@ -228,6 +253,43 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单数据变化后刷新歌单页
|
||||||
|
Future<void> _onPlaylistChanged() async {
|
||||||
|
await _loadPlaylistSummaries();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 任何位置收藏/取消收藏后,重新拉一次收藏列表
|
||||||
|
Future<void> _onFavoriteChanged() async {
|
||||||
|
await _loadFavoritesFromDb();
|
||||||
|
if (mounted) safeSetState(() {});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元数据补全后,按 path 更新收藏列表里的对应行
|
||||||
|
void _onMetadataUpdate(MetadataUpdate update) {
|
||||||
|
final index = _favorites.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 = _favorites[index];
|
||||||
|
safeSetState(() {
|
||||||
|
_favorites[index] = SongItem(
|
||||||
|
path: old.path,
|
||||||
|
fileName: old.fileName,
|
||||||
|
title: title.isNotEmpty ? title : old.title,
|
||||||
|
artist: artist.isNotEmpty ? artist : old.artist,
|
||||||
|
artworkPath: artwork.isNotEmpty ? artwork : old.artworkPath,
|
||||||
|
sourceTag: old.sourceTag,
|
||||||
|
metadataState: (title.isNotEmpty || old.title != null)
|
||||||
|
? 'success'
|
||||||
|
: 'unknown',
|
||||||
|
);
|
||||||
|
});
|
||||||
|
}
|
||||||
void _onScroll() {
|
void _onScroll() {
|
||||||
if (mounted) safeSetState(() {});
|
if (mounted) safeSetState(() {});
|
||||||
}
|
}
|
||||||
@@ -237,6 +299,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
try {
|
try {
|
||||||
await WebDAVService.instance.loadCredentials();
|
await WebDAVService.instance.loadCredentials();
|
||||||
await _loadFavoritesFromDb();
|
await _loadFavoritesFromDb();
|
||||||
|
await _loadPlaylistSummaries();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
_favorites = [];
|
_favorites = [];
|
||||||
} finally {
|
} finally {
|
||||||
@@ -287,6 +350,9 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
fileName: displayName,
|
fileName: displayName,
|
||||||
title: hasTitle ? title : null,
|
title: hasTitle ? title : null,
|
||||||
artist: artist.isNotEmpty ? artist : 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)' : '',
|
sourceTag: folderName.isNotEmpty ? '($folderName)' : '',
|
||||||
metadataState: hasTitle ? 'success' : 'unknown',
|
metadataState: hasTitle ? 'success' : 'unknown',
|
||||||
));
|
));
|
||||||
@@ -322,6 +388,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
id: song.path,
|
id: song.path,
|
||||||
title: song.displayTitle,
|
title: song.displayTitle,
|
||||||
artist: song.displaySubtitle,
|
artist: song.displaySubtitle,
|
||||||
|
artworkPath: song.artworkPath,
|
||||||
url: url,
|
url: url,
|
||||||
));
|
));
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
@@ -336,6 +403,250 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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 _buildFavoritesTab() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_favorites.isEmpty) {
|
||||||
|
return 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 ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
20, 0, 20, UIConstants.miniPlayerBottomSpace),
|
||||||
|
itemCount: _favorites.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final song = _favorites[index];
|
||||||
|
return Padding(
|
||||||
|
padding: const EdgeInsets.symmetric(vertical: 2),
|
||||||
|
child: ListTile(
|
||||||
|
contentPadding: EdgeInsets.zero,
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
minVerticalPadding: 0,
|
||||||
|
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),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildPlaylistsTab() {
|
||||||
|
if (_isLoading) {
|
||||||
|
return const Center(
|
||||||
|
child: CircularProgressIndicator(color: Color(0xFFB8D4D0)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
if (_playlistSummaries.isEmpty) {
|
||||||
|
return 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 ListView.builder(
|
||||||
|
padding: const EdgeInsets.fromLTRB(
|
||||||
|
20, 0, 20, UIConstants.miniPlayerBottomSpace),
|
||||||
|
itemCount: _playlistSummaries.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = _playlistSummaries[index];
|
||||||
|
return 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(() {});
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 收藏列表整队入队:点哪首就从哪首开始,队列是整个收藏列表
|
||||||
|
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,
|
||||||
|
artworkPath: item.artworkPath,
|
||||||
|
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() {
|
void _showReauthDialog() {
|
||||||
showDialog(
|
showDialog(
|
||||||
@@ -364,13 +675,17 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
_isLoading = false;
|
_isLoading = false;
|
||||||
});
|
});
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
// ⭐ 修复:用 push 保留 HomePage 在栈底
|
// 用 push 保留 HomePage 在栈底;登录成功后 Setup 会 popUntil 回到这里
|
||||||
Navigator.push(
|
await Navigator.push(
|
||||||
context,
|
context,
|
||||||
MaterialPageRoute(
|
MaterialPageRoute(
|
||||||
builder: (_) => const WebDAVSetupPage(),
|
builder: (_) => const WebDAVSetupPage(),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
|
if (mounted) {
|
||||||
|
await _initWebDAV();
|
||||||
|
safeSetState(() {});
|
||||||
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
child: const Text(
|
child: const Text(
|
||||||
@@ -409,7 +724,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'清听',
|
'清听',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 28,
|
fontSize: 24,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
),
|
),
|
||||||
@@ -432,12 +747,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
Expanded(
|
Expanded(
|
||||||
child: CustomScrollView(
|
child: CustomScrollView(
|
||||||
controller: _scrollController,
|
controller: _scrollController,
|
||||||
physics: _mediaLibraryHeight > 0
|
physics: const NeverScrollableScrollPhysics(),
|
||||||
? MagneticScrollPhysics(
|
|
||||||
snapPoint: _mediaLibraryHeight,
|
|
||||||
magneticZoneStart: 0.20,
|
|
||||||
)
|
|
||||||
: const ClampingScrollPhysics(),
|
|
||||||
slivers: [
|
slivers: [
|
||||||
SliverToBoxAdapter(
|
SliverToBoxAdapter(
|
||||||
child: Opacity(
|
child: Opacity(
|
||||||
@@ -451,7 +761,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'媒体库',
|
'媒体库',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
),
|
),
|
||||||
@@ -559,7 +869,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 24),
|
const SizedBox(height: 12),
|
||||||
Row(
|
Row(
|
||||||
children: [
|
children: [
|
||||||
Expanded(
|
Expanded(
|
||||||
@@ -579,7 +889,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'本地音乐',
|
'本地音乐',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
fontWeight: FontWeight.w400,
|
fontWeight: FontWeight.w400,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -607,35 +917,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
const Text(
|
const Text(
|
||||||
'最近播放',
|
'最近播放',
|
||||||
style: TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 14,
|
fontSize: 13,
|
||||||
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,
|
fontWeight: FontWeight.w400,
|
||||||
color: Colors.white70,
|
color: Colors.white70,
|
||||||
),
|
),
|
||||||
@@ -647,7 +929,7 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 32),
|
const SizedBox(height: 16),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
@@ -660,127 +942,42 @@ class _HomePageState extends BaseState<HomePage> {
|
|||||||
height: 48,
|
height: 48,
|
||||||
color: const Color(0xFF0E1211),
|
color: const Color(0xFF0E1211),
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 20),
|
padding: const EdgeInsets.symmetric(horizontal: 20),
|
||||||
child: const Row(
|
child: Row(
|
||||||
children: [
|
children: [
|
||||||
Icon(Icons.favorite,
|
Icon(
|
||||||
color: Color(0xFFB8D4D0), size: 20),
|
_homeTab == 0
|
||||||
SizedBox(width: 8),
|
? Icons.favorite
|
||||||
|
: Icons.queue_music,
|
||||||
|
color: const Color(0xFFB8D4D0),
|
||||||
|
size: 20,
|
||||||
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
Text(
|
Text(
|
||||||
'我的收藏',
|
_homeTab == 0 ? '我的收藏' : '歌单列表',
|
||||||
style: TextStyle(
|
style: const TextStyle(
|
||||||
fontSize: 22,
|
fontSize: 20,
|
||||||
fontWeight: FontWeight.w500,
|
fontWeight: FontWeight.w500,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
|
const Spacer(),
|
||||||
|
_buildHomeTabIndicator(),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
SliverPadding(
|
SliverFillRemaining(
|
||||||
padding: const EdgeInsets.only(
|
hasScrollBody: true,
|
||||||
left: 20,
|
child: PageView(
|
||||||
right: 20,
|
controller: _homePageController,
|
||||||
bottom: UIConstants.miniPlayerBottomSpace,
|
onPageChanged: (index) =>
|
||||||
|
safeSetState(() => _homeTab = index),
|
||||||
|
children: [
|
||||||
|
_buildFavoritesTab(),
|
||||||
|
_buildPlaylistsTab(),
|
||||||
|
],
|
||||||
),
|
),
|
||||||
sliver: _isLoading
|
|
||||||
? const SliverFillRemaining(
|
|
||||||
child: Center(
|
|
||||||
child: CircularProgressIndicator(
|
|
||||||
color: Color(0xFFB8D4D0),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: _favorites.isEmpty
|
|
||||||
? 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,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
: SliverList(
|
|
||||||
delegate: SliverChildBuilderDelegate(
|
|
||||||
(context, index) {
|
|
||||||
final song = _favorites[index];
|
|
||||||
|
|
||||||
// 收藏项不做滚动渐显:_listReveal 在首屏为 0,
|
|
||||||
// 会让条目完全透明(看起来"收藏了但不显示")。
|
|
||||||
return AnimatedOpacity(
|
|
||||||
opacity: 1.0,
|
|
||||||
duration: const Duration(milliseconds: 150),
|
|
||||||
child: 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,
|
|
||||||
),
|
|
||||||
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: () => _playSong(song),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
},
|
|
||||||
childCount: _favorites.length,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
|
|||||||
+531
-200
@@ -1,8 +1,12 @@
|
|||||||
|
import 'dart:io';
|
||||||
// lib/pages/player_page.dart
|
// lib/pages/player_page.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
import '../services/audio_service.dart';
|
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/favorite_events.dart';
|
||||||
|
|
||||||
class PlayerPage extends StatefulWidget {
|
class PlayerPage extends StatefulWidget {
|
||||||
final VoidCallback? onClose;
|
final VoidCallback? onClose;
|
||||||
@@ -21,6 +25,23 @@ class PlayerPage extends StatefulWidget {
|
|||||||
class _PlayerPageState extends BaseState<PlayerPage> {
|
class _PlayerPageState extends BaseState<PlayerPage> {
|
||||||
double? _dragProgress;
|
double? _dragProgress;
|
||||||
|
|
||||||
|
bool _isFavorite = false;
|
||||||
|
String _album = '';
|
||||||
|
String _folder = '';
|
||||||
|
String _dbTitle = '';
|
||||||
|
String _dbArtist = '';
|
||||||
|
String? _metaSongId;
|
||||||
|
|
||||||
|
/// 0 = 文件信息,1 = 播放主页,2 = 歌词
|
||||||
|
final PageController _pageController = PageController(initialPage: 1);
|
||||||
|
int _pageIndex = 1;
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_pageController.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
String _formatDuration(Duration d) {
|
String _formatDuration(Duration d) {
|
||||||
if (d.inMilliseconds < 0) return '00:00';
|
if (d.inMilliseconds < 0) return '00:00';
|
||||||
final minutes = d.inMinutes;
|
final minutes = d.inMinutes;
|
||||||
@@ -47,14 +68,12 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
@override
|
@override
|
||||||
void initState() {
|
void initState() {
|
||||||
super.initState();
|
super.initState();
|
||||||
// ⭐ 页面加载时主动拉取状态(多次重试)
|
|
||||||
_syncStateWithRetry();
|
_syncStateWithRetry();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void didChangeDependencies() {
|
void didChangeDependencies() {
|
||||||
super.didChangeDependencies();
|
super.didChangeDependencies();
|
||||||
// ⭐ 依赖变化时(如从后台返回)重新同步
|
|
||||||
WidgetsBinding.instance.addPostFrameCallback((_) {
|
WidgetsBinding.instance.addPostFrameCallback((_) {
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
context.read<AudioService>().syncPlayerStateNow();
|
context.read<AudioService>().syncPlayerStateNow();
|
||||||
@@ -64,11 +83,8 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
|
|
||||||
void _syncStateWithRetry() {
|
void _syncStateWithRetry() {
|
||||||
final service = context.read<AudioService>();
|
final service = context.read<AudioService>();
|
||||||
|
|
||||||
// 立即同步
|
|
||||||
service.syncPlayerStateNow();
|
service.syncPlayerStateNow();
|
||||||
|
|
||||||
// 延迟重试
|
|
||||||
const delays = [200, 500, 800, 1200];
|
const delays = [200, 500, 800, 1200];
|
||||||
for (final delay in delays) {
|
for (final delay in delays) {
|
||||||
Future.delayed(Duration(milliseconds: delay), () {
|
Future.delayed(Duration(milliseconds: delay), () {
|
||||||
@@ -79,6 +95,64 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 元数据(专辑、收藏状态)只在切歌时查一次
|
||||||
|
Future<void> _loadMeta(Song song) async {
|
||||||
|
if (_metaSongId == song.id) return;
|
||||||
|
_metaSongId = song.id;
|
||||||
|
|
||||||
|
final db = SongDatabase();
|
||||||
|
String album = '';
|
||||||
|
String dbTitle = '';
|
||||||
|
String dbArtist = '';
|
||||||
|
bool favorite = false;
|
||||||
|
try {
|
||||||
|
final row = await db.getSongByPath(song.id);
|
||||||
|
album = (row?['album'] as String?)?.trim() ?? '';
|
||||||
|
dbTitle = (row?['title'] as String?)?.trim() ?? '';
|
||||||
|
dbArtist = (row?['artist'] as String?)?.trim() ?? '';
|
||||||
|
favorite = await db.isFavorite(song.id);
|
||||||
|
} catch (_) {
|
||||||
|
// 元数据只是装饰,失败忽略
|
||||||
|
}
|
||||||
|
|
||||||
|
// 上级目录名,作为没有专辑信息时的补充
|
||||||
|
String folder = '';
|
||||||
|
final segments =
|
||||||
|
song.id.split('/').where((s) => s.isNotEmpty).toList();
|
||||||
|
if (segments.length >= 2) {
|
||||||
|
try {
|
||||||
|
folder = Uri.decodeComponent(segments[segments.length - 2]);
|
||||||
|
} catch (_) {
|
||||||
|
folder = segments[segments.length - 2];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() {
|
||||||
|
_album = album;
|
||||||
|
_folder = folder;
|
||||||
|
_dbTitle = dbTitle;
|
||||||
|
_dbArtist = dbArtist;
|
||||||
|
_isFavorite = favorite;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _toggleFavorite(Song song) async {
|
||||||
|
try {
|
||||||
|
await SongDatabase().toggleFavorite(song.id);
|
||||||
|
FavoriteEvents().publish(song.id);
|
||||||
|
final favorite = await SongDatabase().isFavorite(song.id);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _isFavorite = favorite);
|
||||||
|
} catch (e) {
|
||||||
|
if (mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('收藏失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
Widget build(BuildContext context) {
|
Widget build(BuildContext context) {
|
||||||
final service = context.watch<AudioService>();
|
final service = context.watch<AudioService>();
|
||||||
@@ -110,6 +184,7 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_loadMeta(song);
|
||||||
final isPlaying = service.isPlaying;
|
final isPlaying = service.isPlaying;
|
||||||
|
|
||||||
return Scaffold(
|
return Scaffold(
|
||||||
@@ -118,25 +193,17 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
backgroundColor: Colors.transparent,
|
backgroundColor: Colors.transparent,
|
||||||
elevation: 0,
|
elevation: 0,
|
||||||
leading: IconButton(
|
leading: IconButton(
|
||||||
icon: const Icon(Icons.arrow_back_ios_new, color: Colors.white),
|
icon: const Icon(Icons.keyboard_arrow_down, color: Colors.white70),
|
||||||
onPressed: _closePage,
|
onPressed: _closePage,
|
||||||
),
|
),
|
||||||
title: const Text(
|
|
||||||
'正在播放',
|
|
||||||
style: TextStyle(
|
|
||||||
color: Colors.white,
|
|
||||||
fontSize: 16,
|
|
||||||
fontWeight: FontWeight.w400,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
centerTitle: true,
|
centerTitle: true,
|
||||||
|
title: _buildPageIndicator(_isCompact),
|
||||||
actions: [
|
actions: [
|
||||||
PopupMenuButton<String>(
|
PopupMenuButton<String>(
|
||||||
icon: const Icon(Icons.more_vert, color: Colors.white54),
|
icon: const Icon(Icons.more_vert, color: Colors.white54),
|
||||||
onSelected: (value) async {
|
onSelected: (value) async {
|
||||||
if (value == 'clear_cache') {
|
if (value == 'clear_cache') {
|
||||||
final service = context.read<AudioService>();
|
await context.read<AudioService>().clearCurrentSongCache();
|
||||||
await service.clearCurrentSongCache();
|
|
||||||
if (mounted) {
|
if (mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
const SnackBar(
|
const SnackBar(
|
||||||
@@ -145,9 +212,33 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
} else if (value == 'mode') {
|
||||||
|
context.read<AudioService>().togglePlayMode();
|
||||||
|
} else if (value == 'playlist') {
|
||||||
|
_openPlaylist();
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
itemBuilder: (context) => [
|
itemBuilder: (context) => [
|
||||||
|
PopupMenuItem(
|
||||||
|
value: 'mode',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(service.playModeIcon, color: const Color(0xFFB8D4D0)),
|
||||||
|
const SizedBox(width: 12),
|
||||||
|
const Text('切换播放模式'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const PopupMenuItem(
|
||||||
|
value: 'playlist',
|
||||||
|
child: Row(
|
||||||
|
children: [
|
||||||
|
Icon(Icons.queue_music, color: Color(0xFFB8D4D0)),
|
||||||
|
SizedBox(width: 12),
|
||||||
|
Text('播放队列'),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
),
|
||||||
const PopupMenuItem(
|
const PopupMenuItem(
|
||||||
value: 'clear_cache',
|
value: 'clear_cache',
|
||||||
child: Row(
|
child: Row(
|
||||||
@@ -162,75 +253,318 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
),
|
),
|
||||||
],
|
],
|
||||||
),
|
),
|
||||||
body: Padding(
|
body: SafeArea(
|
||||||
padding: const EdgeInsets.symmetric(horizontal: 24),
|
top: false,
|
||||||
child: Column(
|
child: PageView(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
controller: _pageController,
|
||||||
|
onPageChanged: (index) => setState(() => _pageIndex = index),
|
||||||
children: [
|
children: [
|
||||||
Container(
|
_buildInfoPage(song),
|
||||||
width: 240,
|
_buildMainPage(service, song, isPlaying, compact: _isCompact),
|
||||||
height: 240,
|
if (!_isCompact) _buildLyricsPage(song),
|
||||||
decoration: BoxDecoration(
|
],
|
||||||
shape: BoxShape.circle,
|
),
|
||||||
color: const Color(0xFF2A3332),
|
),
|
||||||
boxShadow: [
|
);
|
||||||
BoxShadow(
|
}
|
||||||
color: const Color(0xFF7C9A9E).withOpacity(0.15),
|
|
||||||
blurRadius: 60,
|
/// 小窗/分屏:宽高比明显小于全屏(1368x3192 → 2.33;1368x2577 → 1.88)
|
||||||
spreadRadius: 10,
|
bool get _isCompact {
|
||||||
),
|
final size = MediaQuery.of(context).size;
|
||||||
],
|
if (size.width <= 0) return false;
|
||||||
image: song.artwork != null
|
return size.height / size.width < 2.05;
|
||||||
? DecorationImage(
|
}
|
||||||
image: MemoryImage(song.artwork!),
|
|
||||||
fit: BoxFit.cover,
|
/// 顶部指示器:当前页对应"长条",另外的页是小点。
|
||||||
)
|
/// 全屏三页(信息/主页/歌词),小窗两页(信息/主页)。
|
||||||
: null,
|
Widget _buildPageIndicator(bool compact) {
|
||||||
),
|
Widget segment(int index) {
|
||||||
child: song.artwork == null
|
final active = _pageIndex == index;
|
||||||
? const Icon(Icons.music_note,
|
return GestureDetector(
|
||||||
size: 64, color: Colors.white24)
|
onTap: () => _pageController.animateToPage(
|
||||||
: null,
|
index,
|
||||||
|
duration: const Duration(milliseconds: 280),
|
||||||
|
curve: Curves.easeOutCubic,
|
||||||
|
),
|
||||||
|
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 ? 30 : 7,
|
||||||
|
height: 7,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: active ? Colors.white : Colors.white24,
|
||||||
|
borderRadius: BorderRadius.circular(4),
|
||||||
),
|
),
|
||||||
const SizedBox(height: 48),
|
),
|
||||||
Column(
|
),
|
||||||
crossAxisAlignment: CrossAxisAlignment.center,
|
);
|
||||||
children: [
|
}
|
||||||
Text(
|
|
||||||
|
final indices = compact ? const [0, 1] : const [0, 1, 2];
|
||||||
|
return Row(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
for (var i = 0; i < indices.length; i++) ...[
|
||||||
|
if (i > 0) const SizedBox(width: 6),
|
||||||
|
segment(indices[i]),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildMainPage(AudioService service, Song song, bool isPlaying,
|
||||||
|
{required bool compact}) {
|
||||||
|
final screen = MediaQuery.of(context).size;
|
||||||
|
// 小窗:封面改为固定方形(约 30% 可用高度),给控件留出空间
|
||||||
|
final coverSide = compact
|
||||||
|
? (screen.height * 0.38).clamp(160.0, 380.0)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
final artworkPath = song.artworkPath;
|
||||||
|
final hasArtworkFile = artworkPath != null &&
|
||||||
|
artworkPath.isNotEmpty &&
|
||||||
|
File(artworkPath).existsSync();
|
||||||
|
|
||||||
|
final cover = ClipRRect(
|
||||||
|
borderRadius: BorderRadius.circular(compact ? 18 : 24),
|
||||||
|
child: song.artwork != null
|
||||||
|
? Image.memory(
|
||||||
|
song.artwork!,
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: hasArtworkFile
|
||||||
|
? Image.file(
|
||||||
|
File(artworkPath),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
gaplessPlayback: true,
|
||||||
|
)
|
||||||
|
: Container(
|
||||||
|
color: const Color(0xFF2A3332),
|
||||||
|
child: Icon(
|
||||||
|
Icons.music_note,
|
||||||
|
size: compact ? 48 : 72,
|
||||||
|
color: Colors.white24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
final padded = Padding(
|
||||||
|
// 顶部留白:与 AppBar/指示器拉开距离,避免封面贴着顶部
|
||||||
|
padding: EdgeInsets.fromLTRB(24, compact ? 8 : 24, 24, 0),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
// ---- 封面 ----
|
||||||
|
if (coverSide != null)
|
||||||
|
Center(
|
||||||
|
child: SizedBox(
|
||||||
|
width: coverSide,
|
||||||
|
height: coverSide,
|
||||||
|
child: cover,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
AspectRatio(
|
||||||
|
aspectRatio: 1,
|
||||||
|
child: cover,
|
||||||
|
),
|
||||||
|
SizedBox(height: compact ? 12 : 28),
|
||||||
|
|
||||||
|
// ---- 标题 / 艺术家(收藏心在标题右侧)----
|
||||||
|
Row(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Expanded(
|
||||||
|
child: Text(
|
||||||
song.title,
|
song.title,
|
||||||
style: const TextStyle(
|
style: TextStyle(
|
||||||
fontSize: 20,
|
fontSize: compact ? 20 : 24,
|
||||||
fontWeight: FontWeight.w600,
|
fontWeight: FontWeight.w600,
|
||||||
color: Colors.white,
|
color: Colors.white,
|
||||||
|
height: 1.25,
|
||||||
),
|
),
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 2,
|
|
||||||
overflow: TextOverflow.ellipsis,
|
|
||||||
),
|
|
||||||
const SizedBox(height: 8),
|
|
||||||
Text(
|
|
||||||
song.artist,
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 15,
|
|
||||||
color: Colors.grey[400],
|
|
||||||
),
|
|
||||||
textAlign: TextAlign.center,
|
|
||||||
maxLines: 1,
|
maxLines: 1,
|
||||||
overflow: TextOverflow.ellipsis,
|
overflow: TextOverflow.ellipsis,
|
||||||
),
|
),
|
||||||
],
|
),
|
||||||
|
const SizedBox(width: 8),
|
||||||
|
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: 24,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
const SizedBox(height: 2),
|
||||||
|
Text(
|
||||||
|
song.artist,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: compact ? 14 : 16,
|
||||||
|
color: Colors.grey[300],
|
||||||
),
|
),
|
||||||
const SizedBox(height: 40),
|
maxLines: 1,
|
||||||
_buildProgressSection(service),
|
overflow: TextOverflow.ellipsis,
|
||||||
const SizedBox(height: 32),
|
),
|
||||||
_buildControlButtons(service, isPlaying),
|
|
||||||
const SizedBox(height: 16),
|
// ---- 信息区(专辑 / 目录)----
|
||||||
|
if (_album.isNotEmpty || _folder.isNotEmpty) ...[
|
||||||
|
const SizedBox(height: 10),
|
||||||
|
if (_album.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
_album,
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[500],
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
maxLines: 2,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
if (_folder.isNotEmpty)
|
||||||
|
Text(
|
||||||
|
'($_folder)',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 12,
|
||||||
|
color: Colors.grey[600],
|
||||||
|
height: 1.5,
|
||||||
|
),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
],
|
],
|
||||||
|
|
||||||
|
const Spacer(),
|
||||||
|
|
||||||
|
// ---- 进度 ----
|
||||||
|
_buildProgressSection(service),
|
||||||
|
SizedBox(height: compact ? 10 : 18),
|
||||||
|
|
||||||
|
// ---- 控制 ----
|
||||||
|
_buildControlButtons(service, song, isPlaying),
|
||||||
|
// 底部多留 50px:控制行整体上移
|
||||||
|
SizedBox(height: compact ? 20 : 45),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!compact) return padded;
|
||||||
|
|
||||||
|
// 小窗/横屏:内容超高时可滚动;内容不足时撑满,避免底部大片空白
|
||||||
|
return LayoutBuilder(
|
||||||
|
builder: (context, constraints) => SingleChildScrollView(
|
||||||
|
child: ConstrainedBox(
|
||||||
|
constraints: BoxConstraints(minHeight: constraints.maxHeight),
|
||||||
|
child: IntrinsicHeight(child: padded),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 左页:文件信息
|
||||||
|
Widget _buildInfoPage(Song song) {
|
||||||
|
final rawName = song.id.split('/').last;
|
||||||
|
String fileName = rawName;
|
||||||
|
try {
|
||||||
|
fileName = Uri.decodeComponent(rawName);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final dot = fileName.lastIndexOf('.');
|
||||||
|
final format =
|
||||||
|
dot > 0 ? fileName.substring(dot + 1).toUpperCase() : '未知';
|
||||||
|
|
||||||
|
String decodedPath = song.id;
|
||||||
|
try {
|
||||||
|
decodedPath = Uri.decodeComponent(song.id);
|
||||||
|
} catch (_) {}
|
||||||
|
|
||||||
|
final effectiveArtist = _dbArtist.isNotEmpty
|
||||||
|
? _dbArtist
|
||||||
|
: (song.artist.isEmpty ? '未知' : song.artist);
|
||||||
|
final effectiveTitle = _dbTitle.isNotEmpty ? _dbTitle : song.title;
|
||||||
|
|
||||||
|
final rows = <MapEntry<String, String>>[
|
||||||
|
MapEntry('标题', effectiveTitle),
|
||||||
|
MapEntry('歌手', effectiveArtist),
|
||||||
|
MapEntry('专辑', _album.isEmpty ? '未知' : _album),
|
||||||
|
MapEntry('文件夹', _folder.isEmpty ? '根目录' : _folder),
|
||||||
|
MapEntry('文件名', fileName),
|
||||||
|
MapEntry('格式', format),
|
||||||
|
MapEntry('路径', decodedPath),
|
||||||
|
];
|
||||||
|
|
||||||
|
return SingleChildScrollView(
|
||||||
|
padding: const EdgeInsets.symmetric(horizontal: 24, vertical: 8),
|
||||||
|
child: Column(
|
||||||
|
crossAxisAlignment: CrossAxisAlignment.start,
|
||||||
|
children: [
|
||||||
|
const Text(
|
||||||
|
'文件信息',
|
||||||
|
style: TextStyle(
|
||||||
|
fontSize: 18,
|
||||||
|
fontWeight: FontWeight.w600,
|
||||||
|
color: Colors.white,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 20),
|
||||||
|
for (final row in rows) ...[
|
||||||
|
Text(
|
||||||
|
row.key,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 4),
|
||||||
|
Text(
|
||||||
|
row.value,
|
||||||
|
style: const TextStyle(
|
||||||
|
fontSize: 14,
|
||||||
|
color: Colors.white,
|
||||||
|
height: 1.4,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 18),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 右页:歌词(占位)
|
||||||
|
Widget _buildLyricsPage(Song song) {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.lyrics_outlined, size: 44, color: Colors.grey[700]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'暂无歌词',
|
||||||
|
style: TextStyle(fontSize: 14, color: Colors.grey[600]),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
song.title,
|
||||||
|
style: TextStyle(fontSize: 12, color: Colors.grey[700]),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
Widget _buildProgressSection(AudioService service) {
|
Widget _buildProgressSection(AudioService service) {
|
||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: service.positionNotifier,
|
valueListenable: service.positionNotifier,
|
||||||
@@ -238,7 +572,6 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
return ValueListenableBuilder(
|
return ValueListenableBuilder(
|
||||||
valueListenable: service.durationNotifier,
|
valueListenable: service.durationNotifier,
|
||||||
builder: (context, duration, _) {
|
builder: (context, duration, _) {
|
||||||
// ⭐ 如果 duration 为 0,显示加载指示器
|
|
||||||
if (duration.inMilliseconds == 0) {
|
if (duration.inMilliseconds == 0) {
|
||||||
return const Padding(
|
return const Padding(
|
||||||
padding: EdgeInsets.symmetric(vertical: 8),
|
padding: EdgeInsets.symmetric(vertical: 8),
|
||||||
@@ -249,115 +582,118 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
final progress = duration.inMilliseconds > 0
|
final progress = position.inMilliseconds / duration.inMilliseconds;
|
||||||
? position.inMilliseconds / duration.inMilliseconds
|
final bufferProgress = (service
|
||||||
: 0.0;
|
.bufferedNotifier.value.inMilliseconds /
|
||||||
|
duration.inMilliseconds)
|
||||||
final bufferProgress = duration.inMilliseconds > 0
|
.clamp(0.0, 1.0);
|
||||||
? (service.bufferedNotifier.value.inMilliseconds /
|
|
||||||
duration.inMilliseconds)
|
|
||||||
.clamp(0.0, 1.0)
|
|
||||||
: 0.0;
|
|
||||||
|
|
||||||
final displayProgress = _dragProgress ?? progress;
|
final displayProgress = _dragProgress ?? progress;
|
||||||
|
|
||||||
return Column(
|
return Row(
|
||||||
children: [
|
children: [
|
||||||
SizedBox(
|
Text(
|
||||||
height: 20,
|
_formatDuration(
|
||||||
child: LayoutBuilder(
|
_dragProgress != null
|
||||||
builder: (context, constraints) {
|
? Duration(
|
||||||
final width = constraints.maxWidth;
|
milliseconds:
|
||||||
final bufferWidth =
|
(_dragProgress! * duration.inMilliseconds)
|
||||||
width * bufferProgress.clamp(0.0, 1.0);
|
.round(),
|
||||||
final progressWidth =
|
)
|
||||||
width * displayProgress.clamp(0.0, 1.0);
|
: position,
|
||||||
|
),
|
||||||
return Stack(
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
alignment: Alignment.centerLeft,
|
),
|
||||||
children: [
|
const SizedBox(width: 10),
|
||||||
Container(
|
Expanded(
|
||||||
width: width,
|
child: SizedBox(
|
||||||
height: 4,
|
height: 24,
|
||||||
decoration: BoxDecoration(
|
child: LayoutBuilder(
|
||||||
color: Colors.grey[800],
|
builder: (context, constraints) {
|
||||||
borderRadius: BorderRadius.circular(2),
|
final width = constraints.maxWidth;
|
||||||
),
|
return Stack(
|
||||||
),
|
alignment: Alignment.centerLeft,
|
||||||
Container(
|
children: [
|
||||||
width: bufferWidth,
|
// 背景轨道
|
||||||
height: 4,
|
Positioned(
|
||||||
decoration: BoxDecoration(
|
left: 0,
|
||||||
color: const Color(0xFF4A7A7A),
|
right: 0,
|
||||||
borderRadius: BorderRadius.circular(2),
|
top: 10.5,
|
||||||
),
|
child: Container(
|
||||||
),
|
height: 3,
|
||||||
Container(
|
decoration: BoxDecoration(
|
||||||
width: progressWidth,
|
color: Colors.grey[800],
|
||||||
height: 4,
|
borderRadius: BorderRadius.circular(2),
|
||||||
decoration: BoxDecoration(
|
|
||||||
color: const Color(0xFFB8D4D0),
|
|
||||||
borderRadius: BorderRadius.circular(2),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Positioned.fill(
|
|
||||||
child: SliderTheme(
|
|
||||||
data: SliderTheme.of(context).copyWith(
|
|
||||||
trackHeight: 0,
|
|
||||||
activeTrackColor: Colors.transparent,
|
|
||||||
inactiveTrackColor: Colors.transparent,
|
|
||||||
thumbColor: Colors.transparent,
|
|
||||||
overlayColor: Colors.transparent,
|
|
||||||
thumbShape: const RoundSliderThumbShape(
|
|
||||||
enabledThumbRadius: 0,
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
child: Slider(
|
),
|
||||||
value: displayProgress.clamp(0.0, 1.0),
|
// 已缓冲
|
||||||
min: 0.0,
|
Positioned(
|
||||||
max: 1.0,
|
left: 0,
|
||||||
onChanged: (value) {
|
top: 10.5,
|
||||||
setState(() {
|
child: Container(
|
||||||
_dragProgress = value;
|
width: width * bufferProgress.clamp(0.0, 1.0),
|
||||||
});
|
height: 3,
|
||||||
},
|
decoration: BoxDecoration(
|
||||||
onChangeEnd: (value) {
|
color: const Color(0xFF4A7A7A),
|
||||||
final newPosition = Duration(
|
borderRadius: BorderRadius.circular(2),
|
||||||
milliseconds:
|
),
|
||||||
(value * duration.inMilliseconds)
|
|
||||||
.round(),
|
|
||||||
);
|
|
||||||
service.seekTo(newPosition);
|
|
||||||
setState(() {
|
|
||||||
_dragProgress = null;
|
|
||||||
});
|
|
||||||
},
|
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
),
|
// 已播放
|
||||||
],
|
Positioned(
|
||||||
);
|
left: 0,
|
||||||
},
|
top: 10.5,
|
||||||
|
child: Container(
|
||||||
|
width:
|
||||||
|
width * displayProgress.clamp(0.0, 1.0),
|
||||||
|
height: 3,
|
||||||
|
decoration: BoxDecoration(
|
||||||
|
color: const Color(0xFFB8D4D0),
|
||||||
|
borderRadius: BorderRadius.circular(2),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
Positioned.fill(
|
||||||
|
child: SliderTheme(
|
||||||
|
data: SliderTheme.of(context).copyWith(
|
||||||
|
trackHeight: 0,
|
||||||
|
activeTrackColor: Colors.transparent,
|
||||||
|
inactiveTrackColor: Colors.transparent,
|
||||||
|
thumbColor: Colors.transparent,
|
||||||
|
overlayColor: Colors.transparent,
|
||||||
|
thumbShape: const RoundSliderThumbShape(
|
||||||
|
enabledThumbRadius: 0,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
child: Slider(
|
||||||
|
value: displayProgress.clamp(0.0, 1.0),
|
||||||
|
min: 0.0,
|
||||||
|
max: 1.0,
|
||||||
|
onChanged: (value) {
|
||||||
|
setState(() => _dragProgress = value);
|
||||||
|
},
|
||||||
|
onChangeEnd: (value) {
|
||||||
|
final newPosition = Duration(
|
||||||
|
milliseconds:
|
||||||
|
(value * duration.inMilliseconds)
|
||||||
|
.round(),
|
||||||
|
);
|
||||||
|
service.seekTo(newPosition);
|
||||||
|
setState(() => _dragProgress = null);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
Row(
|
const SizedBox(width: 10),
|
||||||
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
Text(
|
||||||
children: [
|
_formatDuration(duration),
|
||||||
Text(
|
style: TextStyle(fontSize: 12, color: Colors.grey[500]),
|
||||||
_formatDuration(position),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
Text(
|
|
||||||
_formatDuration(duration),
|
|
||||||
style: TextStyle(
|
|
||||||
fontSize: 12,
|
|
||||||
color: Colors.grey[500],
|
|
||||||
),
|
|
||||||
),
|
|
||||||
],
|
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
@@ -367,61 +703,56 @@ class _PlayerPageState extends BaseState<PlayerPage> {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
Widget _buildControlButtons(AudioService service, bool isPlaying) {
|
Widget _buildControlButtons(
|
||||||
|
AudioService service, Song song, bool isPlaying) {
|
||||||
return Row(
|
return Row(
|
||||||
mainAxisAlignment: MainAxisAlignment.center,
|
mainAxisAlignment: MainAxisAlignment.spaceBetween,
|
||||||
children: [
|
children: [
|
||||||
IconButton(
|
|
||||||
onPressed: service.togglePlayMode,
|
|
||||||
icon: Icon(
|
|
||||||
service.playModeIcon,
|
|
||||||
color: const Color(0xFFB8D4D0),
|
|
||||||
size: 22,
|
|
||||||
),
|
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
|
||||||
const SizedBox(width: 4),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: service.hasQueue ? service.previous : null,
|
onPressed: service.hasQueue ? service.previous : null,
|
||||||
icon: const Icon(Icons.skip_previous, size: 28),
|
icon: const Icon(Icons.skip_previous_rounded, size: 34),
|
||||||
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
|
color: service.hasQueue ? Colors.white70 : Colors.grey[700],
|
||||||
padding: const EdgeInsets.all(12),
|
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
|
||||||
Container(
|
Container(
|
||||||
width: 56,
|
width: 68,
|
||||||
height: 56,
|
height: 68,
|
||||||
decoration: BoxDecoration(
|
decoration: const BoxDecoration(
|
||||||
color: const Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
shape: BoxShape.circle,
|
shape: BoxShape.circle,
|
||||||
),
|
),
|
||||||
child: IconButton(
|
child: IconButton(
|
||||||
icon: Icon(
|
icon: Icon(
|
||||||
isPlaying ? Icons.pause : Icons.play_arrow,
|
isPlaying ? Icons.pause_rounded : Icons.play_arrow_rounded,
|
||||||
color: Colors.black87,
|
color: Colors.black87,
|
||||||
size: 28,
|
size: 36,
|
||||||
),
|
),
|
||||||
padding: EdgeInsets.zero,
|
padding: EdgeInsets.zero,
|
||||||
onPressed: service.togglePlay,
|
onPressed: service.togglePlay,
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
const SizedBox(width: 20),
|
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: service.hasQueue ? service.next : null,
|
onPressed: service.hasQueue ? service.next : null,
|
||||||
icon: const Icon(Icons.skip_next, size: 28),
|
icon: const Icon(Icons.skip_next_rounded, size: 34),
|
||||||
color: service.hasQueue ? Colors.white60 : Colors.grey[600],
|
color: service.hasQueue ? Colors.white70 : Colors.grey[700],
|
||||||
padding: const EdgeInsets.all(12),
|
),
|
||||||
|
IconButton(
|
||||||
|
onPressed: service.togglePlayMode,
|
||||||
|
icon: Icon(
|
||||||
|
service.playModeIcon,
|
||||||
|
color: const Color(0xFFB8D4D0),
|
||||||
|
size: 26,
|
||||||
|
),
|
||||||
|
tooltip: '播放模式',
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
onPressed: _openPlaylist,
|
onPressed: _openPlaylist,
|
||||||
icon: const Icon(
|
icon: const Icon(
|
||||||
Icons.playlist_play_outlined,
|
Icons.queue_music_rounded,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
size: 26,
|
size: 26,
|
||||||
),
|
),
|
||||||
padding: const EdgeInsets.all(8),
|
tooltip: '播放队列',
|
||||||
),
|
),
|
||||||
const SizedBox(width: 4),
|
|
||||||
],
|
],
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,211 @@
|
|||||||
|
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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:async';
|
||||||
// lib/pages/webdav_file_list_page.dart
|
// lib/pages/webdav_file_list_page.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
@@ -6,7 +7,11 @@ import '../base/base_state.dart';
|
|||||||
import '../controllers/playback_controller.dart';
|
import '../controllers/playback_controller.dart';
|
||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
import '../services/favorite_events.dart';
|
||||||
|
import '../services/metadata_events.dart';
|
||||||
import '../widgets/song_artwork.dart';
|
import '../widgets/song_artwork.dart';
|
||||||
|
import '../widgets/add_to_playlist_sheet.dart';
|
||||||
|
import 'webdav_search_page.dart';
|
||||||
|
|
||||||
class WebDAVFileListPage extends StatefulWidget {
|
class WebDAVFileListPage extends StatefulWidget {
|
||||||
final String currentPath;
|
final String currentPath;
|
||||||
@@ -26,6 +31,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
|
/// path -> 元数据行(songs 表),用于把文件名替换成标签里的标题/艺术家
|
||||||
Map<String, Map<String, dynamic>> _metaByPath = {};
|
Map<String, Map<String, dynamic>> _metaByPath = {};
|
||||||
String? _lastSyncedSongPath;
|
String? _lastSyncedSongPath;
|
||||||
|
StreamSubscription<MetadataUpdate>? _metaSub;
|
||||||
|
|
||||||
/// 已收藏的歌曲路径
|
/// 已收藏的歌曲路径
|
||||||
Set<String> _favoritePaths = {};
|
Set<String> _favoritePaths = {};
|
||||||
@@ -44,15 +50,25 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
super.initState();
|
super.initState();
|
||||||
_currentPath = widget.currentPath;
|
_currentPath = widget.currentPath;
|
||||||
AudioService().addListener(_onPlaybackChanged);
|
AudioService().addListener(_onPlaybackChanged);
|
||||||
|
_metaSub = MetadataEvents().stream.listen(_onMetadataUpdate);
|
||||||
_loadDirectory();
|
_loadDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@override
|
@override
|
||||||
void dispose() {
|
void dispose() {
|
||||||
|
_metaSub?.cancel();
|
||||||
AudioService().removeListener(_onPlaybackChanged);
|
AudioService().removeListener(_onPlaybackChanged);
|
||||||
super.dispose();
|
super.dispose();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 元数据补全后,按 path 更新列表行的标题/艺术家/封面
|
||||||
|
void _onMetadataUpdate(MetadataUpdate update) {
|
||||||
|
if (!_items.any((item) => item.path == update.path)) return;
|
||||||
|
if (!mounted) return;
|
||||||
|
safeSetState(() {
|
||||||
|
_metaByPath = {..._metaByPath, update.path: update.row};
|
||||||
|
});
|
||||||
|
}
|
||||||
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
|
/// 播放中的歌一旦补全了元数据,立刻刷新列表里对应的那一行
|
||||||
void _onPlaybackChanged() {
|
void _onPlaybackChanged() {
|
||||||
final path = AudioService().currentSong?.id;
|
final path = AudioService().currentSong?.id;
|
||||||
@@ -77,6 +93,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
final wasFavorite = _favoritePaths.contains(path);
|
final wasFavorite = _favoritePaths.contains(path);
|
||||||
try {
|
try {
|
||||||
await SongDatabase().toggleFavorite(path);
|
await SongDatabase().toggleFavorite(path);
|
||||||
|
FavoriteEvents().publish(path);
|
||||||
if (!mounted) return;
|
if (!mounted) return;
|
||||||
safeSetState(() {
|
safeSetState(() {
|
||||||
final next = {..._favoritePaths};
|
final next = {..._favoritePaths};
|
||||||
@@ -264,9 +281,17 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
),
|
),
|
||||||
actions: [
|
actions: [
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.playlist_play),
|
icon: const Icon(Icons.search),
|
||||||
onPressed: _playAll,
|
onPressed: () => Navigator.push(
|
||||||
tooltip: '全部播放',
|
context,
|
||||||
|
MaterialPageRoute(
|
||||||
|
builder: (_) => WebDAVSearchPage(
|
||||||
|
directoryPath: _currentPath,
|
||||||
|
items: _items,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
tooltip: '搜索本文件夹',
|
||||||
),
|
),
|
||||||
IconButton(
|
IconButton(
|
||||||
icon: const Icon(Icons.refresh),
|
icon: const Icon(Icons.refresh),
|
||||||
@@ -351,7 +376,8 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
|
|
||||||
if (item.isDirectory) {
|
if (item.isDirectory) {
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
leading: const Icon(
|
leading: const Icon(
|
||||||
Icons.folder_outlined,
|
Icons.folder_outlined,
|
||||||
color: Color(0xFFB8D4D0),
|
color: Color(0xFFB8D4D0),
|
||||||
@@ -397,7 +423,8 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
].join(' · ');
|
].join(' · ');
|
||||||
|
|
||||||
return ListTile(
|
return ListTile(
|
||||||
contentPadding: const EdgeInsets.symmetric(horizontal: 4, vertical: 2),
|
contentPadding: const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
leading: SongArtwork(
|
leading: SongArtwork(
|
||||||
filePath: meta?['artwork_path'] as String?,
|
filePath: meta?['artwork_path'] as String?,
|
||||||
size: 44,
|
size: 44,
|
||||||
@@ -433,6 +460,7 @@ class _WebDAVFileListPageState extends BaseState<WebDAVFileListPage> {
|
|||||||
onPressed: () => _toggleFavorite(item.path),
|
onPressed: () => _toggleFavorite(item.path),
|
||||||
),
|
),
|
||||||
onTap: () => _playSong(item),
|
onTap: () => _playSong(item),
|
||||||
|
onLongPress: () => showAddToPlaylistSheet(context, songPath: item.path),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,215 @@
|
|||||||
|
// lib/pages/webdav_search_page.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../controllers/playback_controller.dart';
|
||||||
|
import '../database/song_database.dart';
|
||||||
|
import '../services/webdav_service.dart';
|
||||||
|
import '../widgets/song_artwork.dart';
|
||||||
|
|
||||||
|
/// 在当前 WebDAV 文件夹内搜索歌曲
|
||||||
|
class WebDAVSearchPage extends StatefulWidget {
|
||||||
|
final String directoryPath;
|
||||||
|
final List<WebDAVItem> items;
|
||||||
|
|
||||||
|
const WebDAVSearchPage({
|
||||||
|
super.key,
|
||||||
|
required this.directoryPath,
|
||||||
|
required this.items,
|
||||||
|
});
|
||||||
|
|
||||||
|
@override
|
||||||
|
State<WebDAVSearchPage> createState() => _WebDAVSearchPageState();
|
||||||
|
}
|
||||||
|
|
||||||
|
class _WebDAVSearchPageState extends State<WebDAVSearchPage> {
|
||||||
|
final TextEditingController _controller = TextEditingController();
|
||||||
|
final FocusNode _focusNode = FocusNode();
|
||||||
|
|
||||||
|
/// path -> songs 表行
|
||||||
|
Map<String, Map<String, dynamic>> _meta = {};
|
||||||
|
String _query = '';
|
||||||
|
|
||||||
|
List<WebDAVItem> get _files =>
|
||||||
|
widget.items.where((item) => !item.isDirectory).toList();
|
||||||
|
|
||||||
|
@override
|
||||||
|
void initState() {
|
||||||
|
super.initState();
|
||||||
|
_loadMeta();
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
void dispose() {
|
||||||
|
_controller.dispose();
|
||||||
|
_focusNode.dispose();
|
||||||
|
super.dispose();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _loadMeta() async {
|
||||||
|
try {
|
||||||
|
final paths = _files.map((item) => item.path).toList();
|
||||||
|
final meta = await SongDatabase().getSongsByPaths(paths);
|
||||||
|
if (!mounted) return;
|
||||||
|
setState(() => _meta = meta);
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [Search] 读取元数据失败: $e');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
String _decode(String input) {
|
||||||
|
try {
|
||||||
|
return Uri.decodeComponent(input);
|
||||||
|
} catch (_) {
|
||||||
|
return input;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
List<WebDAVItem> get _results {
|
||||||
|
final keyword = _query.trim().toLowerCase();
|
||||||
|
if (keyword.isEmpty) return const [];
|
||||||
|
return _files.where((item) {
|
||||||
|
final name = _decode(item.name).toLowerCase();
|
||||||
|
final meta = _meta[item.path];
|
||||||
|
final title = (meta?['title'] as String? ?? '').toLowerCase();
|
||||||
|
final artist = (meta?['artist'] as String? ?? '').toLowerCase();
|
||||||
|
return name.contains(keyword) ||
|
||||||
|
title.contains(keyword) ||
|
||||||
|
artist.contains(keyword);
|
||||||
|
}).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _play(WebDAVItem item) async {
|
||||||
|
try {
|
||||||
|
await PlaybackController().playFromWebDAV(
|
||||||
|
directoryPath: widget.directoryPath,
|
||||||
|
clickedSongPath: item.path,
|
||||||
|
allItems: widget.items,
|
||||||
|
);
|
||||||
|
if (mounted) Navigator.pop(context);
|
||||||
|
} catch (e) {
|
||||||
|
if (!mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('播放失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@override
|
||||||
|
Widget build(BuildContext context) {
|
||||||
|
final results = _results;
|
||||||
|
|
||||||
|
return Scaffold(
|
||||||
|
backgroundColor: const Color(0xFF0E1211),
|
||||||
|
appBar: AppBar(
|
||||||
|
backgroundColor: Colors.transparent,
|
||||||
|
elevation: 0,
|
||||||
|
foregroundColor: Colors.white,
|
||||||
|
titleSpacing: 0,
|
||||||
|
title: TextField(
|
||||||
|
controller: _controller,
|
||||||
|
focusNode: _focusNode,
|
||||||
|
autofocus: true,
|
||||||
|
style: const TextStyle(color: Colors.white, fontSize: 16),
|
||||||
|
cursorColor: const Color(0xFFB8D4D0),
|
||||||
|
decoration: const InputDecoration(
|
||||||
|
hintText: '搜索本文件夹的歌曲',
|
||||||
|
hintStyle: TextStyle(color: Colors.white38, fontSize: 15),
|
||||||
|
border: InputBorder.none,
|
||||||
|
),
|
||||||
|
onChanged: (value) => setState(() => _query = value),
|
||||||
|
),
|
||||||
|
actions: [
|
||||||
|
if (_query.isNotEmpty)
|
||||||
|
IconButton(
|
||||||
|
icon: const Icon(Icons.close, color: Colors.white54),
|
||||||
|
tooltip: '清除',
|
||||||
|
onPressed: () {
|
||||||
|
_controller.clear();
|
||||||
|
setState(() => _query = '');
|
||||||
|
},
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
body: _query.trim().isEmpty
|
||||||
|
? _buildHint()
|
||||||
|
: results.isEmpty
|
||||||
|
? _buildEmpty()
|
||||||
|
: ListView.builder(
|
||||||
|
padding: const EdgeInsets.symmetric(
|
||||||
|
horizontal: 16, vertical: 8),
|
||||||
|
itemCount: results.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final item = results[index];
|
||||||
|
final meta = _meta[item.path];
|
||||||
|
final metaTitle =
|
||||||
|
(meta?['title'] as String?)?.trim() ?? '';
|
||||||
|
final metaArtist =
|
||||||
|
(meta?['artist'] as String?)?.trim() ?? '';
|
||||||
|
final artwork =
|
||||||
|
(meta?['artwork_path'] as String?)?.trim() ?? '';
|
||||||
|
final name = _decode(item.name);
|
||||||
|
|
||||||
|
return ListTile(
|
||||||
|
contentPadding:
|
||||||
|
const EdgeInsets.symmetric(horizontal: 4),
|
||||||
|
visualDensity: VisualDensity.compact,
|
||||||
|
leading: SongArtwork(
|
||||||
|
filePath: artwork.isNotEmpty ? artwork : null,
|
||||||
|
size: 44,
|
||||||
|
fallbackIcon: Icons.audiotrack,
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
metaTitle.isNotEmpty ? metaTitle : name,
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.white, fontSize: 16),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
metaArtist.isNotEmpty
|
||||||
|
? metaArtist
|
||||||
|
: (item.size != null
|
||||||
|
? '${(item.size! / 1024 / 1024).toStringAsFixed(1)} MB'
|
||||||
|
: ''),
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey, fontSize: 12),
|
||||||
|
maxLines: 1,
|
||||||
|
overflow: TextOverflow.ellipsis,
|
||||||
|
),
|
||||||
|
onTap: () => _play(item),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildHint() {
|
||||||
|
return Center(
|
||||||
|
child: Column(
|
||||||
|
mainAxisAlignment: MainAxisAlignment.center,
|
||||||
|
children: [
|
||||||
|
Icon(Icons.search, size: 48, color: Colors.grey[700]),
|
||||||
|
const SizedBox(height: 14),
|
||||||
|
Text(
|
||||||
|
'输入歌名、歌手或文件名',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 14),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 6),
|
||||||
|
Text(
|
||||||
|
'只在当前文件夹内搜索',
|
||||||
|
style: TextStyle(color: Colors.grey[700], fontSize: 12),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
Widget _buildEmpty() {
|
||||||
|
return Center(
|
||||||
|
child: Text(
|
||||||
|
'没有找到「${_query.trim()}」',
|
||||||
|
style: TextStyle(color: Colors.grey[500], fontSize: 14),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import '../services/webdav_service.dart';
|
import '../services/webdav_service.dart';
|
||||||
import 'webdav_file_list_page.dart';
|
|
||||||
import '../base/base_state.dart';
|
import '../base/base_state.dart';
|
||||||
|
|
||||||
class WebDAVSetupPage extends StatefulWidget {
|
class WebDAVSetupPage extends StatefulWidget {
|
||||||
@@ -72,14 +71,10 @@ class _WebDAVSetupPageState extends BaseState<WebDAVSetupPage> {
|
|||||||
backgroundColor: Color(0xFF4CAF50),
|
backgroundColor: Color(0xFF4CAF50),
|
||||||
),
|
),
|
||||||
);
|
);
|
||||||
// ⭐ 用 pushReplacement 替换当前页面(WebDAVSetupPage)
|
// 登录成功后直接回到主页:
|
||||||
// 此时栈底是 HomePage,替换后栈变为 [HomePage, WebDAVFileListPage]
|
// - 首次配置:用户配完先进主页,再自己点 WebDAV 进入文件列表
|
||||||
Navigator.pushReplacement(
|
// - 重新登录:从主页 push 进来,直接回主页,不会停在文件列表
|
||||||
context,
|
Navigator.of(context).popUntil((route) => route.isFirst);
|
||||||
MaterialPageRoute(
|
|
||||||
builder: (_) => const WebDAVFileListPage(),
|
|
||||||
),
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
safeSetState(() {
|
safeSetState(() {
|
||||||
|
|||||||
@@ -2,6 +2,8 @@
|
|||||||
import 'dart:math';
|
import 'dart:math';
|
||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
|
import '../services/playlist_events.dart';
|
||||||
import '../services/audio_service.dart';
|
import '../services/audio_service.dart';
|
||||||
|
|
||||||
class PlaylistRepository {
|
class PlaylistRepository {
|
||||||
@@ -34,14 +36,36 @@ class PlaylistRepository {
|
|||||||
Future<Playlist> createPlaylist(String name) async {
|
Future<Playlist> createPlaylist(String name) async {
|
||||||
final playlist = Playlist(id: _generateId(), name: name);
|
final playlist = Playlist(id: _generateId(), name: name);
|
||||||
await _db.insertPlaylist(playlist.toJson());
|
await _db.insertPlaylist(playlist.toJson());
|
||||||
|
PlaylistEvents().publish();
|
||||||
return playlist;
|
return playlist;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 歌单列表摘要(歌曲数 + 封面),给主页/列表页用
|
||||||
|
Future<List<PlaylistSummary>> getSummaries() async {
|
||||||
|
final rows = await _db.getPlaylistSummaries();
|
||||||
|
return rows.map(PlaylistSummary.fromRow).toList();
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 批量加入歌曲(自动跳过歌单里已有的,避免打乱顺序)
|
||||||
|
Future<int> addSongs(String playlistId, List<String> songIds) async {
|
||||||
|
if (songIds.isEmpty) return 0;
|
||||||
|
final existing = (await getPlaylistSongIds(playlistId)).toSet();
|
||||||
|
var added = 0;
|
||||||
|
for (final id in songIds) {
|
||||||
|
if (existing.contains(id)) continue;
|
||||||
|
await _db.addSongToPlaylist(playlistId, id);
|
||||||
|
existing.add(id);
|
||||||
|
added++;
|
||||||
|
}
|
||||||
|
if (added > 0) PlaylistEvents().publish();
|
||||||
|
return added;
|
||||||
|
}
|
||||||
Future<void> updatePlaylistName(String id, String newName) async {
|
Future<void> updatePlaylistName(String id, String newName) async {
|
||||||
await _db.updatePlaylist(id, {
|
await _db.updatePlaylist(id, {
|
||||||
'name': newName,
|
'name': newName,
|
||||||
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
'updated_at': DateTime.now().millisecondsSinceEpoch,
|
||||||
});
|
});
|
||||||
|
PlaylistEvents().publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ⭐ 新增:更新播放模式
|
// ⭐ 新增:更新播放模式
|
||||||
@@ -58,6 +82,7 @@ class PlaylistRepository {
|
|||||||
|
|
||||||
Future<void> deletePlaylist(String id) async {
|
Future<void> deletePlaylist(String id) async {
|
||||||
await _db.deletePlaylist(id);
|
await _db.deletePlaylist(id);
|
||||||
|
PlaylistEvents().publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
// ════════════════════════════════════════════════════════════
|
// ════════════════════════════════════════════════════════════
|
||||||
@@ -75,6 +100,7 @@ class PlaylistRepository {
|
|||||||
|
|
||||||
Future<void> removeSong(String playlistId, String songId) async {
|
Future<void> removeSong(String playlistId, String songId) async {
|
||||||
await _db.removeSongFromPlaylist(playlistId, songId);
|
await _db.removeSongFromPlaylist(playlistId, songId);
|
||||||
|
PlaylistEvents().publish();
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> reorderSongs(
|
Future<void> reorderSongs(
|
||||||
|
|||||||
+192
-17
@@ -13,6 +13,7 @@ import '../metadata/metadata_service.dart';
|
|||||||
import '../database/song_database.dart';
|
import '../database/song_database.dart';
|
||||||
import '../utils/artwork_helper.dart';
|
import '../utils/artwork_helper.dart';
|
||||||
import '../utils/song_key.dart';
|
import '../utils/song_key.dart';
|
||||||
|
import '../utils/position_guard.dart';
|
||||||
import '../repositories/playlist_repository.dart';
|
import '../repositories/playlist_repository.dart';
|
||||||
import '../models/playlist.dart';
|
import '../models/playlist.dart';
|
||||||
import 'webdav_service.dart';
|
import 'webdav_service.dart';
|
||||||
@@ -31,12 +32,17 @@ class Song {
|
|||||||
final String? url;
|
final String? url;
|
||||||
final Uint8List? artwork;
|
final Uint8List? artwork;
|
||||||
|
|
||||||
|
/// 本地封面文件路径(songs.artwork_path)。
|
||||||
|
/// 命中过元数据的歌直接用它显示封面,不必等元数据流程回来。
|
||||||
|
final String? artworkPath;
|
||||||
|
|
||||||
Song({
|
Song({
|
||||||
required this.id,
|
required this.id,
|
||||||
required this.title,
|
required this.title,
|
||||||
required this.artist,
|
required this.artist,
|
||||||
this.url,
|
this.url,
|
||||||
this.artwork,
|
this.artwork,
|
||||||
|
this.artworkPath,
|
||||||
});
|
});
|
||||||
|
|
||||||
Map<String, dynamic> toJson() => {
|
Map<String, dynamic> toJson() => {
|
||||||
@@ -103,6 +109,36 @@ class AudioService extends ChangeNotifier {
|
|||||||
Duration _lastObservedPosition = Duration.zero;
|
Duration _lastObservedPosition = Duration.zero;
|
||||||
DateTime _lastProgressAt = DateTime.now();
|
DateTime _lastProgressAt = DateTime.now();
|
||||||
|
|
||||||
|
/// 最后已知的有效播放位置。
|
||||||
|
///
|
||||||
|
/// 打开失败/重连瞬间 mpv 会短暂把 position 报成 0,
|
||||||
|
/// 恢复时若读实时位置就会"从头开始"。所有恢复路径都用这个值续播。
|
||||||
|
Duration _lastKnownPosition = Duration.zero;
|
||||||
|
|
||||||
|
/// 正在执行自动恢复(此时允许位置回退,因为是我们自己重开的)
|
||||||
|
bool _isRecovering = false;
|
||||||
|
|
||||||
|
/// 当前这首歌已经连续自动恢复了几次(成功后清零)。
|
||||||
|
/// 打开一首本身拿不到数据的歌时,看门狗会无限重试,
|
||||||
|
/// 必须熔断,否则日志和请求都会爆炸。
|
||||||
|
int _recoveryAttempts = 0;
|
||||||
|
static const int _maxRecoveryAttempts = 3;
|
||||||
|
|
||||||
|
/// 播放请求去重(同一次点击可能从多个入口触发)
|
||||||
|
DateTime? _lastPlayRequestAt;
|
||||||
|
int _lastPlayRequestIndex = -1;
|
||||||
|
|
||||||
|
/// 恢复退避:拥塞网络里连续失败后拉长间隔,避免和整栋楼抢带宽。
|
||||||
|
DateTime? _nextRecoveryAt;
|
||||||
|
Timer? _autoRetryTimer;
|
||||||
|
|
||||||
|
static Duration _backoffFor(int attempts) {
|
||||||
|
if (attempts <= 2) return Duration.zero;
|
||||||
|
if (attempts == 3) return const Duration(seconds: 30);
|
||||||
|
if (attempts == 4) return const Duration(seconds: 60);
|
||||||
|
return const Duration(seconds: 120);
|
||||||
|
}
|
||||||
|
|
||||||
/// 当前歌曲开始加载的时间,用于"起播看门狗":
|
/// 当前歌曲开始加载的时间,用于"起播看门狗":
|
||||||
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
|
/// 有些情况下 mpv 的 playing 已经是 true,但 position 永远是 0、
|
||||||
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
/// 也不发 buffering / error(实测公网链路可静默卡 75s+)。
|
||||||
@@ -264,21 +300,29 @@ class AudioService extends ChangeNotifier {
|
|||||||
final songs = <Song>[];
|
final songs = <Song>[];
|
||||||
for (final id in songIds) {
|
for (final id in songIds) {
|
||||||
final dbSong = await _db.getSongByPath(id);
|
final dbSong = await _db.getSongByPath(id);
|
||||||
if (dbSong != null) {
|
final path = (dbSong?['remote_path'] as String?) ?? id;
|
||||||
songs.add(Song(
|
|
||||||
id: dbSong['remote_path'] as String,
|
// 没有标签时回退文件名,必须解码:id 是百分号编码的播放路径,
|
||||||
title: dbSong['title'] as String? ?? '',
|
// 直接展示会变成 "%E6%9C%AA..." 这种不可读串。
|
||||||
artist: dbSong['artist'] as String? ?? '未知艺术家',
|
final rawName = path.split('/').last;
|
||||||
url: dbSong['remote_path'] as String,
|
String displayName;
|
||||||
));
|
try {
|
||||||
} else {
|
displayName = Uri.decodeComponent(rawName);
|
||||||
songs.add(Song(
|
} catch (_) {
|
||||||
id: id,
|
displayName = rawName;
|
||||||
title: id.split('/').last.replaceAll(RegExp(r'\.[^.]*$'), ''),
|
|
||||||
artist: '未知艺术家',
|
|
||||||
url: id,
|
|
||||||
));
|
|
||||||
}
|
}
|
||||||
|
displayName = displayName.replaceAll(RegExp(r'\.[^.]*$'), '');
|
||||||
|
|
||||||
|
final dbTitle = (dbSong?['title'] as String?)?.trim() ?? '';
|
||||||
|
final dbArtist = (dbSong?['artist'] as String?)?.trim() ?? '';
|
||||||
|
|
||||||
|
songs.add(Song(
|
||||||
|
id: path,
|
||||||
|
artworkPath: dbSong?['artwork_path'] as String?,
|
||||||
|
title: dbTitle.isNotEmpty ? dbTitle : displayName,
|
||||||
|
artist: dbArtist.isNotEmpty ? dbArtist : '未知艺术家',
|
||||||
|
url: WebDAVService.instance.getFileUrl(path),
|
||||||
|
));
|
||||||
}
|
}
|
||||||
|
|
||||||
if (songs.isNotEmpty) {
|
if (songs.isNotEmpty) {
|
||||||
@@ -305,6 +349,18 @@ class AudioService extends ChangeNotifier {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 去重:同一次点击可能从多个入口触发(列表 onTap + 控制器 + 通知),
|
||||||
|
// 反复 open 会互相顶掉,表现为"点了要等好几秒才出声"。
|
||||||
|
final now = DateTime.now();
|
||||||
|
if (_lastPlayRequestIndex == _currentIndex &&
|
||||||
|
_lastPlayRequestAt != null &&
|
||||||
|
now.difference(_lastPlayRequestAt!) < const Duration(milliseconds: 800)) {
|
||||||
|
debugPrint('⏭️ [AudioService] 忽略重复播放请求(index=$_currentIndex)');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_lastPlayRequestAt = now;
|
||||||
|
_lastPlayRequestIndex = _currentIndex;
|
||||||
|
|
||||||
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
|
// 「最新请求胜出」:不再因为 _isChangingTrack 为 true 就直接丢弃请求。
|
||||||
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
|
// 旧请求会通过 epoch 校验自行放弃后续步骤,避免和新的切歌互相覆盖。
|
||||||
final epoch = ++_switchEpoch;
|
final epoch = ++_switchEpoch;
|
||||||
@@ -312,6 +368,11 @@ class AudioService extends ChangeNotifier {
|
|||||||
_isChangingTrack = true;
|
_isChangingTrack = true;
|
||||||
_handlingCompletion = false;
|
_handlingCompletion = false;
|
||||||
_hasStartedCurrentPlayback = false;
|
_hasStartedCurrentPlayback = false;
|
||||||
|
// 切歌:位置基准归零,避免上一首的最后位置被当成这一首的"最后已知位置"
|
||||||
|
_lastKnownPosition = Duration.zero;
|
||||||
|
_recoveryAttempts = 0;
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
|
||||||
try {
|
try {
|
||||||
_playbackGeneration++;
|
_playbackGeneration++;
|
||||||
@@ -556,10 +617,42 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (_openRetryGeneration == _playbackGeneration) return;
|
if (_openRetryGeneration == _playbackGeneration) return;
|
||||||
|
|
||||||
_openRetryGeneration = _playbackGeneration;
|
_openRetryGeneration = _playbackGeneration;
|
||||||
debugPrint('🔁 [AudioService] 打开失败,1.5s 后自动重试');
|
|
||||||
Timer(const Duration(milliseconds: 1500), () {
|
// 缓冲余量决定重试节奏:
|
||||||
|
// 余量充足 -> 慢一点重试,期间 mpv 还有东西可播,用户不会察觉;
|
||||||
|
// 余量见底 -> 尽快重连,尽量减少静音时间。
|
||||||
|
final bufferAhead = bufferedNotifier.value - positionNotifier.value;
|
||||||
|
final hasBuffer = bufferAhead >= const Duration(seconds: 12);
|
||||||
|
final delay = hasBuffer
|
||||||
|
? const Duration(seconds: 4)
|
||||||
|
: const Duration(milliseconds: 1200);
|
||||||
|
final baseline = _lastKnownPosition;
|
||||||
|
|
||||||
|
debugPrint('🔁 [AudioService] 读取出错,缓冲余量 ${bufferAhead.inSeconds}s,'
|
||||||
|
'${delay.inMilliseconds}ms 后重试');
|
||||||
|
|
||||||
|
Timer(delay, () {
|
||||||
if (_currentSong == null) return;
|
if (_currentSong == null) return;
|
||||||
if (_isChangingTrack) return;
|
if (_isChangingTrack) return;
|
||||||
|
// mpv 可能自己接上了:位置在推进就不打断它
|
||||||
|
if (positionNotifier.value >
|
||||||
|
baseline + const Duration(seconds: 1)) {
|
||||||
|
debugPrint('♻️ [AudioService] 播放已自行恢复,取消重连');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recoverFromStall(force: true);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 冷却结束后的自动重试(只试一次,成功与否交给下一轮判断)
|
||||||
|
void _scheduleAutoRetry(Duration delay) {
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
_autoRetryTimer = Timer(delay, () {
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
if (_currentSong == null) return;
|
||||||
|
if (_isChangingTrack) return;
|
||||||
|
if (positionNotifier.value > const Duration(seconds: 1)) return;
|
||||||
|
debugPrint('⏰ [AudioService] 冷却结束,再试一次');
|
||||||
_recoverFromStall(force: true);
|
_recoverFromStall(force: true);
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
@@ -575,6 +668,30 @@ class AudioService extends ChangeNotifier {
|
|||||||
final url = song.url;
|
final url = song.url;
|
||||||
if (url == null || url.isEmpty) return;
|
if (url == null || url.isEmpty) return;
|
||||||
|
|
||||||
|
// 熔断 + 退避:拥塞网络里连续失败,说明现在这条链路抢不到带宽。
|
||||||
|
// 拉长间隔,冷却结束后自动再试一次;超过上限就停住等用户手动点。
|
||||||
|
if (_nextRecoveryAt != null && DateTime.now().isBefore(_nextRecoveryAt!)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (_recoveryAttempts >= _maxRecoveryAttempts) {
|
||||||
|
final delay = _backoffFor(_recoveryAttempts);
|
||||||
|
_nextRecoveryAt = DateTime.now().add(delay);
|
||||||
|
debugPrint('⏳ [AudioService] 已连续恢复 $_recoveryAttempts 次,'
|
||||||
|
'${delay.inSeconds}s 后再试:${song.title}');
|
||||||
|
if (_recoveryAttempts >= _maxRecoveryAttempts + 3) {
|
||||||
|
// 退避到极限:停住,交给用户手动重试(点播放会清零计数)
|
||||||
|
debugPrint('⛔ [AudioService] 放弃自动恢复:${song.title}');
|
||||||
|
_lastPlaybackError = '网络拥塞,无法播放:${song.title}';
|
||||||
|
notifyListeners();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_scheduleAutoRetry(delay);
|
||||||
|
_recoveryAttempts++;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_recoveryAttempts++;
|
||||||
|
|
||||||
final now = DateTime.now();
|
final now = DateTime.now();
|
||||||
if (!force &&
|
if (!force &&
|
||||||
_lastStallRecoveryAt != null &&
|
_lastStallRecoveryAt != null &&
|
||||||
@@ -584,23 +701,36 @@ class AudioService extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
_lastStallRecoveryAt = now;
|
_lastStallRecoveryAt = now;
|
||||||
|
|
||||||
final resumeAt = positionNotifier.value;
|
// 恢复前记下代次:await 期间用户可能已经切歌,
|
||||||
|
// 不能把旧歌重新打开(否则就是"点了新歌却切回旧歌")。
|
||||||
|
final recoveryGeneration = _playbackGeneration;
|
||||||
|
final resumeAt = _lastKnownPosition > Duration.zero
|
||||||
|
? _lastKnownPosition
|
||||||
|
: positionNotifier.value;
|
||||||
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
debugPrint('🩺 [AudioService] 尝试重连:${song.title}(从 ${resumeAt.inSeconds}s 继续)');
|
||||||
|
_isRecovering = true;
|
||||||
try {
|
try {
|
||||||
_trackStartAt = DateTime.now();
|
_trackStartAt = DateTime.now();
|
||||||
_playReadyAt = null;
|
_playReadyAt = null;
|
||||||
_firstAudioReported = false;
|
_firstAudioReported = false;
|
||||||
await _playWithHeaders(url);
|
await _playWithHeaders(url);
|
||||||
|
if (_playbackGeneration != recoveryGeneration) {
|
||||||
|
debugPrint('⏭️ [AudioService] 恢复期间已切歌,放弃本次恢复');
|
||||||
|
return;
|
||||||
|
}
|
||||||
_playReadyAt = DateTime.now();
|
_playReadyAt = DateTime.now();
|
||||||
if (resumeAt > Duration.zero) {
|
if (resumeAt > Duration.zero) {
|
||||||
await PlaybackService().seek(resumeAt);
|
await PlaybackService().seek(resumeAt);
|
||||||
positionNotifier.value = resumeAt;
|
positionNotifier.value = resumeAt;
|
||||||
|
_lastKnownPosition = resumeAt;
|
||||||
}
|
}
|
||||||
debugPrint('✅ [AudioService] 自动恢复完成');
|
debugPrint('✅ [AudioService] 自动恢复完成');
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
debugPrint('❌ [AudioService] 自动恢复失败: $e');
|
||||||
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
_lastPlaybackError = '缓冲卡住,自动恢复失败';
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
|
} finally {
|
||||||
|
_isRecovering = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -610,9 +740,18 @@ class AudioService extends ChangeNotifier {
|
|||||||
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
|
/// 这里做 3s 去抖:切换瞬间往往会连续触发多次网络接口变化。
|
||||||
void _onNetworkChanged() {
|
void _onNetworkChanged() {
|
||||||
_networkDebounce?.cancel();
|
_networkDebounce?.cancel();
|
||||||
|
// 记录去抖开始时的歌:2 秒内用户可能已经切歌,
|
||||||
|
// 那时再去重连"上一首"就是异常切换(日志里出现过 78s 重连旧歌)。
|
||||||
|
final expectedSongId = _currentSong?.id;
|
||||||
|
final expectedGeneration = _playbackGeneration;
|
||||||
_networkDebounce = Timer(const Duration(seconds: 2), () {
|
_networkDebounce = Timer(const Duration(seconds: 2), () {
|
||||||
if (_currentSong == null) return;
|
if (_currentSong == null) return;
|
||||||
if (_isChangingTrack) return;
|
if (_isChangingTrack) return;
|
||||||
|
if (_currentSong?.id != expectedSongId ||
|
||||||
|
_playbackGeneration != expectedGeneration) {
|
||||||
|
debugPrint('🌐 [AudioService] 网络变化期间已切歌,忽略本次重连');
|
||||||
|
return;
|
||||||
|
}
|
||||||
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
|
debugPrint('🌐 [AudioService] 网络已切换,主动重连当前歌曲');
|
||||||
_recoverFromStall(force: true);
|
_recoverFromStall(force: true);
|
||||||
});
|
});
|
||||||
@@ -728,6 +867,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
|
artist: metadata.artist.isNotEmpty ? metadata.artist : song.artist,
|
||||||
url: song.url,
|
url: song.url,
|
||||||
artwork: metadata.artwork,
|
artwork: metadata.artwork,
|
||||||
|
// 保留封面文件路径:元数据流程回来前,播放页靠它立即显示封面
|
||||||
|
artworkPath: song.artworkPath,
|
||||||
);
|
);
|
||||||
_queue[_currentIndex] = updatedSong;
|
_queue[_currentIndex] = updatedSong;
|
||||||
_currentSong = updatedSong;
|
_currentSong = updatedSong;
|
||||||
@@ -872,6 +1013,10 @@ class AudioService extends ChangeNotifier {
|
|||||||
if (_currentSong == null) return;
|
if (_currentSong == null) return;
|
||||||
if (_isPlaying) return;
|
if (_isPlaying) return;
|
||||||
|
|
||||||
|
// 用户手动点播放:重置退避计数,允许立即重试
|
||||||
|
_recoveryAttempts = 0;
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
|
||||||
if (_restoredPendingOpen) {
|
if (_restoredPendingOpen) {
|
||||||
_restoredPendingOpen = false;
|
_restoredPendingOpen = false;
|
||||||
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
|
debugPrint('▶️ [AudioService] 冷启动后首次播放,按保存进度打开');
|
||||||
@@ -972,6 +1117,34 @@ class AudioService extends ChangeNotifier {
|
|||||||
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
debugPrint('🎯 [AudioService] positionStream ignored: user seeking');
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 位置回退保护:打开失败/重连时 mpv 会短暂报 0。
|
||||||
|
// 非用户操作、非切歌、非恢复中时,不允许从 77s 掉回 0s,
|
||||||
|
// 否则界面进度会归零,恢复时也会"从头播"。
|
||||||
|
if (shouldIgnorePositionRegression(
|
||||||
|
lastKnown: _lastKnownPosition,
|
||||||
|
incoming: position,
|
||||||
|
isChangingTrack: _isChangingTrack,
|
||||||
|
isRecovering: _isRecovering,
|
||||||
|
)) {
|
||||||
|
debugPrint('🛡️ [AudioService] 忽略位置回退: '
|
||||||
|
'${_lastKnownPosition.inSeconds}s -> ${position.inSeconds}s');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (position > _lastKnownPosition ||
|
||||||
|
_isChangingTrack ||
|
||||||
|
_isRecovering) {
|
||||||
|
_lastKnownPosition = position;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 真的出声了(>1s),说明恢复成功,重置熔断计数
|
||||||
|
if (position > const Duration(seconds: 1)) {
|
||||||
|
_recoveryAttempts = 0;
|
||||||
|
_nextRecoveryAt = null;
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
}
|
||||||
|
|
||||||
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
|
// 首次拿到 >0 的播放位置:这是"真正出声"的时刻,
|
||||||
// 与 play() 发起时间相减即可算出起播延迟。
|
// 与 play() 发起时间相减即可算出起播延迟。
|
||||||
if (!_firstAudioReported && position > Duration.zero) {
|
if (!_firstAudioReported && position > Duration.zero) {
|
||||||
@@ -1243,6 +1416,8 @@ class AudioService extends ChangeNotifier {
|
|||||||
_progressTimer = null;
|
_progressTimer = null;
|
||||||
_networkDebounce?.cancel();
|
_networkDebounce?.cancel();
|
||||||
_networkDebounce = null;
|
_networkDebounce = null;
|
||||||
|
_autoRetryTimer?.cancel();
|
||||||
|
_autoRetryTimer = null;
|
||||||
NetworkWatcher().stop();
|
NetworkWatcher().stop();
|
||||||
savePlaybackState();
|
savePlaybackState();
|
||||||
_stopListening();
|
_stopListening();
|
||||||
|
|||||||
@@ -0,0 +1,22 @@
|
|||||||
|
// lib/services/favorite_events.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 收藏状态变化事件。
|
||||||
|
///
|
||||||
|
/// 文件列表、播放页都能收藏/取消收藏,主页的收藏列表需要跟着变,
|
||||||
|
/// 否则要退出重进才看得到。
|
||||||
|
class FavoriteEvents {
|
||||||
|
static final FavoriteEvents _instance = FavoriteEvents._internal();
|
||||||
|
factory FavoriteEvents() => _instance;
|
||||||
|
FavoriteEvents._internal();
|
||||||
|
|
||||||
|
final StreamController<String> _controller =
|
||||||
|
StreamController<String>.broadcast();
|
||||||
|
|
||||||
|
Stream<String> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void publish(String songPath) {
|
||||||
|
if (songPath.isEmpty) return;
|
||||||
|
_controller.add(songPath);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,30 @@
|
|||||||
|
// lib/services/metadata_events.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 一条元数据更新(path + songs 表的最新行)
|
||||||
|
class MetadataUpdate {
|
||||||
|
final String path;
|
||||||
|
final Map<String, dynamic> row;
|
||||||
|
|
||||||
|
const MetadataUpdate(this.path, this.row);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// 元数据更新事件总线。
|
||||||
|
///
|
||||||
|
/// 之前各列表页靠"返回时重查"来刷新,歌曲补全标签后要退出重进才看得到。
|
||||||
|
/// 现在 MetadataService 写库成功后 publish,订阅的页面按 path 局部更新。
|
||||||
|
class MetadataEvents {
|
||||||
|
static final MetadataEvents _instance = MetadataEvents._internal();
|
||||||
|
factory MetadataEvents() => _instance;
|
||||||
|
MetadataEvents._internal();
|
||||||
|
|
||||||
|
final StreamController<MetadataUpdate> _controller =
|
||||||
|
StreamController<MetadataUpdate>.broadcast();
|
||||||
|
|
||||||
|
Stream<MetadataUpdate> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void publish(String path, Map<String, dynamic> row) {
|
||||||
|
if (path.isEmpty) return;
|
||||||
|
_controller.add(MetadataUpdate(path, row));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -41,6 +41,20 @@ class PlaybackService {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
_player = Player();
|
_player = Player();
|
||||||
|
|
||||||
|
// 拥塞网络(公司共享出口)下加大缓冲:多存一点余量,
|
||||||
|
// 减少"播到一半断流 -> 重连"的次数。代价是起播稍慢。
|
||||||
|
// 通过 platform 动态调用,避免依赖 media_kit 的内部类型。
|
||||||
|
try {
|
||||||
|
final platform = _player!.platform as dynamic;
|
||||||
|
platform.setProperty('cache-secs', '60');
|
||||||
|
platform.setProperty('demuxer-max-bytes', '64MiB');
|
||||||
|
platform.setProperty('demuxer-readahead-secs', '30');
|
||||||
|
debugPrint('✅ [PlaybackService] mpv 缓冲参数已设置(cache 60s / 64MiB)');
|
||||||
|
} catch (e) {
|
||||||
|
debugPrint('⚠️ [PlaybackService] 设置 mpv 缓冲参数失败: $e');
|
||||||
|
}
|
||||||
|
|
||||||
_initialized = true;
|
_initialized = true;
|
||||||
debugPrint(
|
debugPrint(
|
||||||
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
|
'✅ [PlaybackService] Player 已创建, serviceId=$_serviceId, player=${_player.hashCode}');
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
// lib/services/playlist_events.dart
|
||||||
|
import 'dart:async';
|
||||||
|
|
||||||
|
/// 歌单数据变更事件(新建/重命名/删除/加歌/删歌/排序)。
|
||||||
|
///
|
||||||
|
/// 主页的歌单页、歌单详情页都靠它实时刷新,
|
||||||
|
/// 不用等页面重建。
|
||||||
|
class PlaylistEvents {
|
||||||
|
static final PlaylistEvents _instance = PlaylistEvents._internal();
|
||||||
|
factory PlaylistEvents() => _instance;
|
||||||
|
PlaylistEvents._internal();
|
||||||
|
|
||||||
|
final StreamController<void> _controller =
|
||||||
|
StreamController<void>.broadcast();
|
||||||
|
|
||||||
|
Stream<void> get stream => _controller.stream;
|
||||||
|
|
||||||
|
void publish() {
|
||||||
|
_controller.add(null);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// lib/services/route_manager.dart
|
||||||
|
import 'package:flutter/foundation.dart';
|
||||||
|
|
||||||
|
/// 路由管理器(控制 MiniPlayer 显示/隐藏)
|
||||||
|
class RouteManager extends ChangeNotifier {
|
||||||
|
static final RouteManager _instance = RouteManager._internal();
|
||||||
|
factory RouteManager() => _instance;
|
||||||
|
RouteManager._internal();
|
||||||
|
|
||||||
|
String _currentRoute = '/';
|
||||||
|
String get currentRoute => _currentRoute;
|
||||||
|
|
||||||
|
/// 弹层/对话框打开时临时隐藏迷你播放器,避免盖住内容
|
||||||
|
bool _temporarilyHidden = false;
|
||||||
|
|
||||||
|
bool get showMiniPlayer =>
|
||||||
|
!_temporarilyHidden &&
|
||||||
|
_currentRoute != '/player' &&
|
||||||
|
_currentRoute != '/playlist';
|
||||||
|
|
||||||
|
void setTemporarilyHidden(bool hidden) {
|
||||||
|
if (_temporarilyHidden == hidden) return;
|
||||||
|
_temporarilyHidden = hidden;
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
|
||||||
|
void updateRoute(String route) {
|
||||||
|
if (_currentRoute != route) {
|
||||||
|
_currentRoute = route;
|
||||||
|
debugPrint('📌 路由更新: $route');
|
||||||
|
notifyListeners();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,20 @@
|
|||||||
|
// lib/utils/position_guard.dart
|
||||||
|
|
||||||
|
/// 位置回退保护。
|
||||||
|
///
|
||||||
|
/// 打开失败 / 重连瞬间,mpv 会短暂把 position 报成 0 或几秒。
|
||||||
|
/// 如果直接采用,界面进度会归零,恢复时也会"从头播"。
|
||||||
|
///
|
||||||
|
/// 规则:只有在"已经播过一段时间"且"新位置几乎归零"时才算异常回退;
|
||||||
|
/// 用户 seek、切歌、我们自己的恢复流程都允许回退,不拦截。
|
||||||
|
bool shouldIgnorePositionRegression({
|
||||||
|
required Duration lastKnown,
|
||||||
|
required Duration incoming,
|
||||||
|
required bool isChangingTrack,
|
||||||
|
required bool isRecovering,
|
||||||
|
}) {
|
||||||
|
if (isChangingTrack || isRecovering) return false;
|
||||||
|
if (lastKnown <= const Duration(seconds: 10)) return false;
|
||||||
|
if (incoming >= const Duration(seconds: 5)) return false;
|
||||||
|
return (lastKnown - incoming) > const Duration(seconds: 5);
|
||||||
|
}
|
||||||
@@ -0,0 +1,173 @@
|
|||||||
|
// lib/widgets/add_to_playlist_sheet.dart
|
||||||
|
import 'package:flutter/material.dart';
|
||||||
|
|
||||||
|
import '../models/playlist_summary.dart';
|
||||||
|
import '../repositories/playlist_repository.dart';
|
||||||
|
import '../services/route_manager.dart';
|
||||||
|
|
||||||
|
/// 把一首歌加入歌单的底部弹层。
|
||||||
|
///
|
||||||
|
/// 视觉先保持系统样式,等设计定稿后只换这个文件即可,
|
||||||
|
/// 调用方(文件列表长按)不用改。
|
||||||
|
Future<void> showAddToPlaylistSheet(
|
||||||
|
BuildContext context, {
|
||||||
|
required String songPath,
|
||||||
|
}) async {
|
||||||
|
final repo = PlaylistRepository();
|
||||||
|
List<PlaylistSummary> playlists;
|
||||||
|
try {
|
||||||
|
playlists = await repo.getSummaries();
|
||||||
|
} catch (e) {
|
||||||
|
if (context.mounted) {
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('读取歌单失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!context.mounted) return;
|
||||||
|
|
||||||
|
RouteManager().setTemporarilyHidden(true);
|
||||||
|
try {
|
||||||
|
await showModalBottomSheet<void>(
|
||||||
|
context: context,
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
builder: (sheetContext) {
|
||||||
|
return SafeArea(
|
||||||
|
minimum: const EdgeInsets.only(bottom: 12),
|
||||||
|
child: Column(
|
||||||
|
mainAxisSize: MainAxisSize.min,
|
||||||
|
children: [
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.fromLTRB(20, 18, 20, 8),
|
||||||
|
child: Align(
|
||||||
|
alignment: Alignment.centerLeft,
|
||||||
|
child: Text(
|
||||||
|
'加入歌单',
|
||||||
|
style: TextStyle(
|
||||||
|
color: Colors.white,
|
||||||
|
fontSize: 16,
|
||||||
|
fontWeight: FontWeight.w500,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
),
|
||||||
|
ListTile(
|
||||||
|
leading: const Icon(Icons.add, color: Color(0xFFB8D4D0)),
|
||||||
|
title: const Text(
|
||||||
|
'新建歌单',
|
||||||
|
style: TextStyle(color: Color(0xFFB8D4D0)),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
await _createPlaylistAndAdd(context, repo, songPath);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
const Divider(height: 1, color: Colors.white12),
|
||||||
|
if (playlists.isEmpty)
|
||||||
|
const Padding(
|
||||||
|
padding: EdgeInsets.symmetric(vertical: 24),
|
||||||
|
child: Text(
|
||||||
|
'还没有歌单,先新建一个',
|
||||||
|
style: TextStyle(color: Colors.grey, fontSize: 13),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
else
|
||||||
|
ConstrainedBox(
|
||||||
|
constraints: const BoxConstraints(maxHeight: 320),
|
||||||
|
child: ListView.builder(
|
||||||
|
shrinkWrap: true,
|
||||||
|
itemCount: playlists.length,
|
||||||
|
itemBuilder: (context, index) {
|
||||||
|
final playlist = playlists[index];
|
||||||
|
return ListTile(
|
||||||
|
leading: const Icon(
|
||||||
|
Icons.queue_music,
|
||||||
|
color: Color(0xFFB8D4D0),
|
||||||
|
),
|
||||||
|
title: Text(
|
||||||
|
playlist.name,
|
||||||
|
style: const TextStyle(color: Colors.white),
|
||||||
|
),
|
||||||
|
subtitle: Text(
|
||||||
|
'${playlist.songCount} 首',
|
||||||
|
style: const TextStyle(
|
||||||
|
color: Colors.grey, fontSize: 12),
|
||||||
|
),
|
||||||
|
onTap: () async {
|
||||||
|
final added = await repo.addSongs(
|
||||||
|
playlist.id,
|
||||||
|
[songPath],
|
||||||
|
);
|
||||||
|
if (!sheetContext.mounted) return;
|
||||||
|
Navigator.pop(sheetContext);
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(
|
||||||
|
content: Text(added > 0
|
||||||
|
? '已加入「${playlist.name}」'
|
||||||
|
: '这首歌已经在「${playlist.name}」里了'),
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
const SizedBox(height: 16),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
RouteManager().setTemporarilyHidden(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<void> _createPlaylistAndAdd(
|
||||||
|
BuildContext context,
|
||||||
|
PlaylistRepository repo,
|
||||||
|
String songPath,
|
||||||
|
) async {
|
||||||
|
final controller = TextEditingController();
|
||||||
|
final name = await showDialog<String>(
|
||||||
|
context: context,
|
||||||
|
builder: (dialogContext) => AlertDialog(
|
||||||
|
backgroundColor: const Color(0xFF1A1F1E),
|
||||||
|
title: const Text('新建歌单', style: 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(dialogContext),
|
||||||
|
child: const Text('取消'),
|
||||||
|
),
|
||||||
|
TextButton(
|
||||||
|
onPressed: () =>
|
||||||
|
Navigator.pop(dialogContext, controller.text.trim()),
|
||||||
|
child: const Text('创建'),
|
||||||
|
),
|
||||||
|
],
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
if (name == null || name.isEmpty) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
final playlist = await repo.createPlaylist(name);
|
||||||
|
await repo.addSongs(playlist.id, [songPath]);
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('已新建「$name」并加入这首歌')),
|
||||||
|
);
|
||||||
|
} catch (e) {
|
||||||
|
if (!context.mounted) return;
|
||||||
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
|
SnackBar(content: Text('新建歌单失败: $e')),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'dart:io';
|
||||||
// lib/widgets/global_mini_player.dart
|
// lib/widgets/global_mini_player.dart
|
||||||
import 'package:flutter/material.dart';
|
import 'package:flutter/material.dart';
|
||||||
import 'package:provider/provider.dart';
|
import 'package:provider/provider.dart';
|
||||||
@@ -66,9 +67,22 @@ class GlobalMiniPlayer extends StatelessWidget {
|
|||||||
image: MemoryImage(song.artwork!),
|
image: MemoryImage(song.artwork!),
|
||||||
fit: BoxFit.cover,
|
fit: BoxFit.cover,
|
||||||
)
|
)
|
||||||
: null,
|
: (song.artworkPath != null &&
|
||||||
|
song.artworkPath!.isNotEmpty &&
|
||||||
|
File(song.artworkPath!)
|
||||||
|
.existsSync())
|
||||||
|
? DecorationImage(
|
||||||
|
image: FileImage(
|
||||||
|
File(song.artworkPath!)),
|
||||||
|
fit: BoxFit.cover,
|
||||||
|
)
|
||||||
|
: null,
|
||||||
),
|
),
|
||||||
child: song.artwork == null
|
child: (song.artwork == null &&
|
||||||
|
(song.artworkPath == null ||
|
||||||
|
song.artworkPath!.isEmpty ||
|
||||||
|
!File(song.artworkPath!)
|
||||||
|
.existsSync()))
|
||||||
? const Icon(
|
? const Icon(
|
||||||
Icons.music_note,
|
Icons.music_note,
|
||||||
color: Colors.white38,
|
color: Colors.white38,
|
||||||
|
|||||||
@@ -0,0 +1,51 @@
|
|||||||
|
// test/playlist_summary_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/models/playlist_summary.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('PlaylistSummary.fromRow', () {
|
||||||
|
test('完整行解析', () {
|
||||||
|
final s = PlaylistSummary.fromRow({
|
||||||
|
'id': 'abc',
|
||||||
|
'name': '随手',
|
||||||
|
'song_count': 12,
|
||||||
|
'cover_path': '/data/artworks/x.jpg',
|
||||||
|
'updated_at': 1700000000,
|
||||||
|
});
|
||||||
|
expect(s.id, 'abc');
|
||||||
|
expect(s.name, '随手');
|
||||||
|
expect(s.songCount, 12);
|
||||||
|
expect(s.coverPath, '/data/artworks/x.jpg');
|
||||||
|
expect(s.isEmpty, isFalse);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('没有封面时 coverPath 为 null', () {
|
||||||
|
final s = PlaylistSummary.fromRow({
|
||||||
|
'id': 'abc',
|
||||||
|
'name': '车用',
|
||||||
|
'song_count': 3,
|
||||||
|
'cover_path': '',
|
||||||
|
});
|
||||||
|
expect(s.coverPath, isNull);
|
||||||
|
expect(s.songCount, 3);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('空歌单 isEmpty', () {
|
||||||
|
final s = PlaylistSummary.fromRow({
|
||||||
|
'id': 'x',
|
||||||
|
'name': '空',
|
||||||
|
'song_count': 0,
|
||||||
|
});
|
||||||
|
expect(s.isEmpty, isTrue);
|
||||||
|
expect(s.coverPath, isNull);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('字段缺失时不崩', () {
|
||||||
|
final s = PlaylistSummary.fromRow({});
|
||||||
|
expect(s.id, '');
|
||||||
|
expect(s.name, '');
|
||||||
|
expect(s.songCount, 0);
|
||||||
|
expect(s.isEmpty, isTrue);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,79 @@
|
|||||||
|
// test/position_guard_test.dart
|
||||||
|
import 'package:flutter_test/flutter_test.dart';
|
||||||
|
import 'package:qingting_player/utils/position_guard.dart';
|
||||||
|
|
||||||
|
void main() {
|
||||||
|
group('shouldIgnorePositionRegression', () {
|
||||||
|
test('正常播放推进:不拦截', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: const Duration(seconds: 78),
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('77s 突然归零:拦截(这就是"从头重播"的元凶)', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isTrue,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('用户切歌:允许回退', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 200),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: true,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('自动恢复中:允许回退(我们自己重开的)', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 200),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: true,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('刚开始播放(最后位置 <10s):不拦截,避免误伤起播', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 8),
|
||||||
|
incoming: Duration.zero,
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
test('小幅回退(77s -> 73s):不拦截', () {
|
||||||
|
expect(
|
||||||
|
shouldIgnorePositionRegression(
|
||||||
|
lastKnown: const Duration(seconds: 77),
|
||||||
|
incoming: const Duration(seconds: 73),
|
||||||
|
isChangingTrack: false,
|
||||||
|
isRecovering: false,
|
||||||
|
),
|
||||||
|
isFalse,
|
||||||
|
);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user