mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 14:37:33 +02:00
refactor(player): separate metadata state and lyrics rendering
This commit is contained in:
1 parent
a363f5a459
commit
c22577eb18
5 files changed
+2203
-1922
No files matched your search
@@ -0,0 +1,176 @@
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:spotiflac_android/services/playback_metadata.dart';
|
||||
import 'package:spotiflac_android/utils/int_utils.dart';
|
||||
import 'package:spotiflac_android/utils/logger.dart';
|
||||
import 'package:spotiflac_android/utils/lyrics_parser.dart';
|
||||
|
||||
final _log = AppLogger('PlayerMetadata');
|
||||
|
||||
/// Owns file metadata and lyrics for the full player. Requests are scoped to
|
||||
/// the current queue item so a late probe cannot replace another track's data.
|
||||
class PlayerMetadataController extends ChangeNotifier {
|
||||
PlayerMetadataController({PlaybackMetadataReader? readMetadata})
|
||||
: _readMetadata = readMetadata ?? readPlaybackFileMetadataWithRetry;
|
||||
|
||||
final PlaybackMetadataReader _readMetadata;
|
||||
(String, String, String?)? _requestKey;
|
||||
String? _loadedPath;
|
||||
Map<String, dynamic> _fallback = const {};
|
||||
int _generation = 0;
|
||||
bool _disposed = false;
|
||||
|
||||
String? get source => _requestKey?.$2;
|
||||
Map<String, dynamic>? _metadata;
|
||||
ParsedLyrics _lyrics = ParsedLyrics.empty;
|
||||
bool _loading = false;
|
||||
Map<String, dynamic>? get metadata => _metadata;
|
||||
ParsedLyrics get lyrics => _lyrics;
|
||||
bool get loading => _loading;
|
||||
|
||||
Future<void> load(
|
||||
MediaItem? item, {
|
||||
bool inspectUnresolvedContentUri = false,
|
||||
}) async {
|
||||
if (_disposed) return;
|
||||
final source = item?.extras?['source']?.toString() ?? '';
|
||||
if (item == null || source.isEmpty) {
|
||||
if (_requestKey == null) return;
|
||||
_generation++;
|
||||
_requestKey = null;
|
||||
_loadedPath = null;
|
||||
_replaceMetadata(const {});
|
||||
return;
|
||||
}
|
||||
final resolvedSource = item.extras?['resolvedSource']?.toString().trim();
|
||||
final key = (item.id, source, resolvedSource);
|
||||
final sameItem = key == _requestKey;
|
||||
_fallback = Map.unmodifiable(playbackAudioMetadataFromMediaItem(item));
|
||||
if (!sameItem) {
|
||||
_generation++;
|
||||
_requestKey = key;
|
||||
_loadedPath = null;
|
||||
}
|
||||
if (const {'http', 'https'}.contains(Uri.tryParse(source)?.scheme)) {
|
||||
_loadedPath = source;
|
||||
_replaceMetadata(_fallback);
|
||||
return;
|
||||
}
|
||||
final path = _metadataPath(source, resolvedSource);
|
||||
final unresolved = path == source && source.startsWith('content://');
|
||||
if (sameItem) {
|
||||
if (metadata == null && _fallback.isNotEmpty) {
|
||||
_metadata = _fallback;
|
||||
notifyListeners();
|
||||
}
|
||||
if (loading || _loadedPath == path) return;
|
||||
} else {
|
||||
_replaceMetadata(_fallback);
|
||||
}
|
||||
// Opening the player must not copy an unresolved SAF document. Lyrics
|
||||
// requests may inspect it until playback publishes its resolved source.
|
||||
if (unresolved && !inspectUnresolvedContentUri) return;
|
||||
_loading = true;
|
||||
notifyListeners();
|
||||
final generation = _generation;
|
||||
try {
|
||||
final probed = await _readMetadata(path);
|
||||
if (_disposed || generation != _generation) return;
|
||||
_loadedPath = path;
|
||||
_replaceMetadata(mergePlaybackFileMetadata(_fallback, probed));
|
||||
} catch (error) {
|
||||
if (_disposed || generation != _generation) return;
|
||||
_log.w('Failed to read metadata: $error');
|
||||
_replaceMetadata(_fallback);
|
||||
}
|
||||
}
|
||||
|
||||
void _replaceMetadata(Map<String, dynamic> value) {
|
||||
_metadata = value.isEmpty ? null : Map.unmodifiable(value);
|
||||
_lyrics = LyricsParser.parse((value['lyrics'] ?? '').toString());
|
||||
_loading = false;
|
||||
notifyListeners();
|
||||
}
|
||||
|
||||
String _metadataPath(String source, String? resolved) =>
|
||||
!source.startsWith('network://') && resolved?.isNotEmpty == true
|
||||
? resolved!
|
||||
: source;
|
||||
|
||||
Map<String, dynamic> detailsSnapshot(MediaItem? item) {
|
||||
if (item == null) return const {};
|
||||
final source = item.extras?['source']?.toString() ?? '';
|
||||
final resolved = item.extras?['resolvedSource']?.toString().trim();
|
||||
final sameItem = _requestKey == (item.id, source, resolved);
|
||||
return {
|
||||
'title': item.title,
|
||||
'artist': item.artist ?? '',
|
||||
'album': item.album ?? '',
|
||||
...playbackAudioMetadataFromMediaItem(item),
|
||||
if (sameItem) ...?metadata,
|
||||
};
|
||||
}
|
||||
|
||||
/// A details sheet owns this snapshot, independently of later track changes.
|
||||
/// Probing an unresolved document here does not alter the lyrics lifecycle.
|
||||
Future<Map<String, dynamic>> readDetails(MediaItem? item) async {
|
||||
final fallback = detailsSnapshot(item);
|
||||
final source = item?.extras?['source']?.toString() ?? '';
|
||||
final resolved = item?.extras?['resolvedSource']?.toString().trim();
|
||||
final path = _metadataPath(source, resolved);
|
||||
if (_disposed ||
|
||||
path.isEmpty ||
|
||||
const {'http', 'https'}.contains(Uri.tryParse(source)?.scheme) ||
|
||||
(_requestKey == (item?.id, source, resolved) && _loadedPath == path)) {
|
||||
return fallback;
|
||||
}
|
||||
try {
|
||||
return mergePlaybackFileMetadata(fallback, await _readMetadata(path));
|
||||
} catch (error) {
|
||||
_log.w('Failed to read details metadata: $error');
|
||||
return fallback;
|
||||
}
|
||||
}
|
||||
|
||||
({String? writers, String? provider}) get credits {
|
||||
String? field(String key) {
|
||||
final value = metadata?[key];
|
||||
return value is String && value.trim().isNotEmpty ? value.trim() : null;
|
||||
}
|
||||
|
||||
return (
|
||||
writers: lyrics.writers ?? field('lyricist') ?? field('composer'),
|
||||
provider: lyrics.provider ?? field('lyrics_provider'),
|
||||
);
|
||||
}
|
||||
|
||||
String? get qualityLabel {
|
||||
final meta = metadata;
|
||||
if (meta == null) return null;
|
||||
final parts = <String>[];
|
||||
final format = (meta['format'] ?? meta['audio_codec'] ?? '')
|
||||
.toString()
|
||||
.trim()
|
||||
.toUpperCase();
|
||||
if (format.isNotEmpty) parts.add(format);
|
||||
final bitDepth = readPositiveInt(meta['bit_depth']) ?? 0;
|
||||
if (bitDepth > 0) parts.add('$bitDepth-bit');
|
||||
final sampleRate = readPositiveInt(meta['sample_rate']) ?? 0;
|
||||
if (sampleRate > 0) {
|
||||
final khz = sampleRate / 1000;
|
||||
parts.add(
|
||||
'${khz.toStringAsFixed(khz == khz.roundToDouble() ? 0 : 1)} kHz',
|
||||
);
|
||||
}
|
||||
final bitrate = readPositiveInt(meta['bitrate']) ?? 0;
|
||||
if (bitDepth == 0 && bitrate > 0) parts.add('$bitrate kbps');
|
||||
return parts.isEmpty ? null : parts.join(' · ');
|
||||
}
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
_disposed = true;
|
||||
_generation++;
|
||||
super.dispose();
|
||||
}
|
||||
}
|
||||
@@ -168,6 +168,32 @@ class MusicPlayerController {
|
||||
await playAll(media, initialIndex: initialIndex.clamp(0, media.length - 1));
|
||||
}
|
||||
|
||||
Future<({LocalLibraryItem item, List<LocalLibraryItem> tracks})?>
|
||||
localAlbumFor(String mediaId) async {
|
||||
final database = _runtime.dependencies.libraryDatabase;
|
||||
final row = await database.getById(mediaId);
|
||||
if (row == null) return null;
|
||||
final item = LocalLibraryItem.fromJson(row);
|
||||
final rows = await database.getQueueLocalAlbumTracksByKey(item.albumKey);
|
||||
final tracks = rows.map(LocalLibraryItem.fromJson).toList(growable: false);
|
||||
return tracks.isEmpty ? null : (item: item, tracks: tracks);
|
||||
}
|
||||
|
||||
Future<bool> shuffleLibrary() async {
|
||||
final rows = await _runtime.dependencies.libraryDatabase.getAll();
|
||||
final media = rows
|
||||
.map(LocalLibraryItem.fromJson)
|
||||
.where((item) => item.filePath.trim().isNotEmpty)
|
||||
.map(playableFromLocal)
|
||||
.toList();
|
||||
if (media.isEmpty) return false;
|
||||
final handler = await _runtime.initialize();
|
||||
media.shuffle();
|
||||
await handler.setShuffleMode(AudioServiceShuffleMode.all);
|
||||
await handler.setQueueAndPlay(media);
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<void> play() async => _handler?.play();
|
||||
Future<void> pause() async => _handler?.pause();
|
||||
Future<void> stop() async => _handler?.stop();
|
||||
|
||||
+62
-1922
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,252 @@
|
||||
import 'dart:async';
|
||||
|
||||
import 'package:audio_service/audio_service.dart';
|
||||
import 'package:flutter_test/flutter_test.dart';
|
||||
import 'package:spotiflac_android/controllers/player_metadata_controller.dart';
|
||||
|
||||
MediaItem _item(
|
||||
String id, {
|
||||
String? source,
|
||||
String? resolved,
|
||||
Map<String, dynamic> metadata = const {},
|
||||
}) => MediaItem(
|
||||
id: id,
|
||||
title: id,
|
||||
artist: 'Artist',
|
||||
album: 'Album',
|
||||
extras: {
|
||||
'source': source ?? '/music/$id.flac',
|
||||
'resolvedSource': ?resolved,
|
||||
...metadata,
|
||||
},
|
||||
);
|
||||
|
||||
void main() {
|
||||
test('late result cannot overwrite a track switched away and back', () async {
|
||||
final reads = <Completer<Map<String, dynamic>>>[];
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (_) {
|
||||
final read = Completer<Map<String, dynamic>>();
|
||||
reads.add(read);
|
||||
return read.future;
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final first = controller.load(_item('a'));
|
||||
final second = controller.load(_item('b'));
|
||||
final current = controller.load(_item('a'));
|
||||
|
||||
reads[2].complete({'lyrics': '[00:01.00]Current A'});
|
||||
await current;
|
||||
reads[0].complete({'lyrics': '[00:01.00]Old A'});
|
||||
reads[1].completeError(StateError('Old B failed'));
|
||||
await Future.wait([first, second]);
|
||||
|
||||
expect(controller.lyrics.lines.single.text, 'Current A');
|
||||
expect(controller.loading, isFalse);
|
||||
});
|
||||
|
||||
test('resolved source supersedes an outstanding content URI read', () async {
|
||||
final old = Completer<Map<String, dynamic>>();
|
||||
final paths = <String>[];
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (path) {
|
||||
paths.add(path);
|
||||
return path.startsWith('content://')
|
||||
? old.future
|
||||
: Future.value({'lyrics': '[00:01.00]Resolved'});
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final source = 'content://library/song.flac';
|
||||
final pending = controller.load(
|
||||
_item('a', source: source),
|
||||
inspectUnresolvedContentUri: true,
|
||||
);
|
||||
await controller.load(
|
||||
_item('a', source: source, resolved: '/tmp/song.flac'),
|
||||
);
|
||||
old.complete({'lyrics': '[00:01.00]Unresolved'});
|
||||
await pending;
|
||||
|
||||
expect(paths, [source, '/tmp/song.flac']);
|
||||
expect(controller.lyrics.lines.single.text, 'Resolved');
|
||||
});
|
||||
|
||||
test(
|
||||
'disposal prevents outstanding reads from notifying or mutating',
|
||||
() async {
|
||||
final read = Completer<Map<String, dynamic>>();
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (_) => read.future,
|
||||
);
|
||||
var notifications = 0;
|
||||
controller.addListener(() => notifications++);
|
||||
final pending = controller.load(_item('a'));
|
||||
final beforeDispose = notifications;
|
||||
controller.dispose();
|
||||
read.complete({'lyrics': '[00:01.00]Late'});
|
||||
await pending;
|
||||
await controller.load(_item('b'));
|
||||
|
||||
expect(notifications, beforeDispose);
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
},
|
||||
);
|
||||
|
||||
test(
|
||||
'unresolved SAF metadata stays on demand and reads are deduplicated',
|
||||
() async {
|
||||
final read = Completer<Map<String, dynamic>>();
|
||||
final paths = <String>[];
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (path) {
|
||||
paths.add(path);
|
||||
return read.future;
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final item = _item('a', source: 'content://library/a.flac');
|
||||
await controller.load(item);
|
||||
expect(paths, isEmpty);
|
||||
expect(controller.loading, isFalse);
|
||||
final pending = controller.load(item, inspectUnresolvedContentUri: true);
|
||||
await controller.load(item, inspectUnresolvedContentUri: true);
|
||||
expect(paths, [item.extras!['source']]);
|
||||
read.complete({'lyrics': '[00:01.00]Embedded'});
|
||||
await pending;
|
||||
await controller.load(item, inspectUnresolvedContentUri: true);
|
||||
expect(paths, hasLength(1));
|
||||
},
|
||||
);
|
||||
|
||||
test('HTTP quality fallback avoids file probes and old lyrics', () async {
|
||||
final paths = <String>[];
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (path) async {
|
||||
paths.add(path);
|
||||
return {'lyrics': '[00:01.00]Local'};
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.load(_item('local'));
|
||||
final remote = _item(
|
||||
'remote',
|
||||
source: 'https://audio.test/song.mp3',
|
||||
resolved: 'https://cdn.audio.test/song.mp3',
|
||||
metadata: {'format': 'mp3', 'bitrate': 320, 'sample_rate': 44100},
|
||||
);
|
||||
expect((await controller.readDetails(remote))['title'], 'remote');
|
||||
await controller.load(remote);
|
||||
expect((await controller.readDetails(remote))['format'], 'mp3');
|
||||
|
||||
expect(paths, ['/music/local.flac']);
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
expect(controller.qualityLabel, 'MP3 · 44.1 kHz · 320 kbps');
|
||||
});
|
||||
|
||||
test(
|
||||
'network metadata uses logical source despite playback HTTP URL',
|
||||
() async {
|
||||
final paths = <String>[];
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (path) async {
|
||||
paths.add(path);
|
||||
return {'lyrics': '[00:01.00]Network'};
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
await controller.load(
|
||||
_item(
|
||||
'network',
|
||||
source: 'network://library/song.flac',
|
||||
resolved: 'http://127.0.0.1/playback',
|
||||
),
|
||||
);
|
||||
expect(paths, ['network://library/song.flac']);
|
||||
expect(controller.lyrics.lines.single.text, 'Network');
|
||||
},
|
||||
);
|
||||
|
||||
test('failed probes retain quality metadata and may retry', () async {
|
||||
var reads = 0;
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (_) async {
|
||||
if (++reads == 1) throw StateError('Temporarily unavailable');
|
||||
return {
|
||||
'lyrics': '[00:01.00]Recovered',
|
||||
'format': '',
|
||||
'sample_rate': 0,
|
||||
'lyricist': 'Writer',
|
||||
'lyrics_provider': 'Provider',
|
||||
};
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final item = _item(
|
||||
'a',
|
||||
metadata: {'format': 'FLAC', 'sample_rate': 48000, 'bit_depth': 24},
|
||||
);
|
||||
await controller.load(item);
|
||||
expect(controller.loading, isFalse);
|
||||
expect(controller.qualityLabel, 'FLAC · 24-bit · 48 kHz');
|
||||
await controller.load(item);
|
||||
expect(controller.qualityLabel, 'FLAC · 24-bit · 48 kHz');
|
||||
expect(controller.lyrics.lines.single.text, 'Recovered');
|
||||
expect(controller.credits, (writers: 'Writer', provider: 'Provider'));
|
||||
expect(
|
||||
() => controller.metadata!['format'] = 'MP3',
|
||||
throwsUnsupportedError,
|
||||
);
|
||||
});
|
||||
|
||||
test('details snapshot probes SAF without changing lyrics state', () async {
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (_) async => {
|
||||
'title': 'Tagged',
|
||||
'lyrics': '[00:01.00]Lyrics',
|
||||
},
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final item = _item('a', source: 'content://library/a.flac');
|
||||
await controller.load(item);
|
||||
final details = await controller.readDetails(item);
|
||||
expect(details['title'], 'Tagged');
|
||||
expect(details['artist'], 'Artist');
|
||||
expect(controller.lyrics.isEmpty, isTrue);
|
||||
expect(controller.loading, isFalse);
|
||||
await controller.load(null);
|
||||
expect(controller.source, isNull);
|
||||
expect(controller.metadata, isNull);
|
||||
});
|
||||
|
||||
test(
|
||||
'failed details retain their snapshot after the track changes',
|
||||
() async {
|
||||
final read = Completer<Map<String, dynamic>>();
|
||||
final controller = PlayerMetadataController(
|
||||
readMetadata: (path) => path.startsWith('content://')
|
||||
? read.future
|
||||
: Future.value({'lyrics': '[00:01.00]Current'}),
|
||||
);
|
||||
addTearDown(controller.dispose);
|
||||
final item = _item(
|
||||
'a',
|
||||
source: 'content://library/a.flac',
|
||||
metadata: {'format': 'FLAC'},
|
||||
);
|
||||
await controller.load(item);
|
||||
final pending = controller.readDetails(item);
|
||||
await controller.load(_item('b'));
|
||||
read.completeError(StateError('File unavailable'));
|
||||
|
||||
expect(await pending, {
|
||||
'title': 'a',
|
||||
'artist': 'Artist',
|
||||
'album': 'Album',
|
||||
'format': 'FLAC',
|
||||
});
|
||||
expect(controller.lyrics.lines.single.text, 'Current');
|
||||
},
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user