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 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 = >>[]; final controller = PlayerMetadataController( readMetadata: (_) { final read = Completer>(); 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>(); final paths = []; 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>(); 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>(); final paths = []; 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 = []; 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 = []; 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>(); 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'); }, ); }