import 'dart:typed_data'; import 'package:cardash_launcher/data/services/jellyfin_library_service.dart'; import 'package:cardash_launcher/features/jellyfin/jellyfin_playback_controller.dart'; import 'package:cardash_launcher/features/settings/jellyfin_controller.dart'; import 'package:flutter_test/flutter_test.dart'; import 'jellyfin_fakes.dart'; class _Fixture { _Fixture({ required this.jellyfin, required this.gateway, required this.library, required this.platform, required this.playback, }); final JellyfinController jellyfin; final FakeJellyfinLibraryGateway gateway; final JellyfinLibraryService library; final FakeJellyfinPlaybackPlatform platform; final JellyfinPlaybackController playback; } Future<_Fixture> _fixture() async { final jellyfin = await configuredJellyfinController(); final gateway = FakeJellyfinLibraryGateway() ..tracks = { 'album-1': [ track('t1', albumId: 'album-1', albumName: 'Album 1', index: 1), track('t2', albumId: 'album-1', albumName: 'Album 1', index: 2), track('t3', albumId: 'album-1', albumName: 'Album 1', index: 3), ], 'album-2': [ track('u1', albumId: 'album-2', albumName: 'Album 2', index: 1), track('u2', albumId: 'album-2', albumName: 'Album 2', index: 2), ], } ..artwork = Uint8List.fromList([1, 2, 3]); final library = testLibraryService(jellyfin, gateway); final platform = FakeJellyfinPlaybackPlatform(); final playback = JellyfinPlaybackController( platform, library, jellyfin, pollInterval: const Duration(hours: 1), ); return _Fixture( jellyfin: jellyfin, gateway: gateway, library: library, platform: platform, playback: playback, ); } void main() { test('starts an album and exposes the current track', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); final error = await fixture.playback.playAlbum(album('album-1')); expect(error, isNull); expect(fixture.platform.setQueueCalls, 1); expect(fixture.playback.isActive, isTrue); expect(fixture.playback.playing, isTrue); expect(fixture.playback.currentItem?.trackId, 't1'); expect(fixture.playback.mediaPlayback.title, 'Track t1'); expect(fixture.playback.queueItems.length, 3); expect(fixture.platform.queue?.artwork, [1, 2, 3]); expect( fixture.platform.queue?.authorization, contains('Token="test-token"'), ); }); test('the same album is not rebuilt but a different album is', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); await fixture.playback.playAlbum(album('album-1')); expect(fixture.platform.setQueueCalls, 1); await fixture.playback.playAlbum(album('album-2')); expect(fixture.platform.setQueueCalls, 2); expect(fixture.playback.currentItem?.trackId, 'u1'); }); test('toggle pauses and resumes without losing the position', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); await fixture.playback.toggle(); expect(fixture.playback.playing, isFalse); expect(fixture.platform.commands, contains('pause')); await fixture.playback.toggle(); expect(fixture.playback.playing, isTrue); expect(fixture.platform.commands, contains('play')); }); test('next and previous move through the queue', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); await fixture.playback.next(); expect(fixture.playback.currentItem?.trackId, 't2'); expect(fixture.playback.canGoPrevious, isTrue); await fixture.playback.previous(); expect(fixture.playback.currentItem?.trackId, 't1'); }); test('selecting a track seeks to its queue index', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); await fixture.playback.playTrackAt(2); expect(fixture.platform.commands, contains('seekToIndex:2')); expect(fixture.playback.currentItem?.trackId, 't3'); expect(fixture.playback.playing, isTrue); }); test('tapping play after the end restarts at track one', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); fixture.platform.state = fixture.platform.state.copyWith( playing: false, positionMs: fixture.platform.state.durationMs, ); await fixture.playback.restore(); await fixture.playback.toggle(); expect(fixture.platform.commands, contains('seekToIndex:0')); expect(fixture.playback.playing, isTrue); expect(fixture.playback.snapshot.positionMs, 0); }); test('stop clears the queue and snapshot', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); await fixture.playback.stop(); expect(fixture.playback.isActive, isFalse); expect(fixture.playback.queueItems, isEmpty); expect(fixture.playback.snapshot.available, isFalse); }); test('notification permission delegates to the platform', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); expect(fixture.platform.permissionGranted, isFalse); expect(await fixture.playback.notificationPermissionGranted(), isFalse); expect(await fixture.playback.requestNotificationPermission(), isTrue); expect(fixture.platform.permissionGranted, isTrue); }); test('media progress falls back to the queued track duration', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); await fixture.playback.playAlbum(album('album-1')); fixture.platform.zeroDuration = true; fixture.platform.state = fixture.platform.snapshotAt( 0, playing: true, positionMs: 30000, ); await fixture.playback.toggle(); expect(fixture.playback.snapshot.durationMs, 0); final media = fixture.playback.mediaPlayback; expect(media.durationMs, fixture.playback.currentItem!.durationMs); expect(media.positionMs, 30000); expect(media.progress, greaterThan(0)); }); test('a failed queue start surfaces an error', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); fixture.platform.setQueueResult = false; final error = await fixture.playback.playAlbum(album('album-1')); expect(error, isNotNull); expect(fixture.playback.error, isNotNull); expect(fixture.playback.isActive, isFalse); }); test('an album with no playable tracks reports an error', () async { final fixture = await _fixture(); addTearDown(fixture.playback.dispose); addTearDown(fixture.jellyfin.dispose); final error = await fixture.playback.playAlbum(album('missing')); expect(error, contains('No playable tracks')); expect(fixture.playback.error, contains('No playable tracks')); }); }