mirror of
https://github.com/zarzet/SpotiFLAC-Mobile.git
synced 2026-10-20 06:28:27 +02:00
253 lines
8.0 KiB
Dart
253 lines
8.0 KiB
Dart
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');
|
|
},
|
|
);
|
|
}
|