import 'dart:typed_data'; import 'package:cardash_launcher/data/models/app_entry.dart'; import 'package:cardash_launcher/data/models/jellyfin_library.dart'; import 'package:cardash_launcher/data/models/jellyfin_models.dart'; import 'package:cardash_launcher/data/models/jellyfin_playback.dart'; import 'package:cardash_launcher/data/models/media_playback.dart'; import 'package:cardash_launcher/data/models/system_status.dart'; import 'package:cardash_launcher/data/models/update_info.dart'; import 'package:cardash_launcher/data/repositories/settings_repository.dart'; import 'package:cardash_launcher/data/services/jellyfin_library_service.dart'; import 'package:cardash_launcher/data/services/jellyfin_playback_platform.dart'; import 'package:cardash_launcher/data/services/jellyfin_service.dart'; import 'package:cardash_launcher/data/services/native_bridge.dart'; import 'package:cardash_launcher/data/services/secure_store.dart'; import 'package:cardash_launcher/features/settings/jellyfin_controller.dart'; import 'package:shared_preferences/shared_preferences.dart'; final testJellyfinConnection = JellyfinConnection( baseUrl: Uri.parse('https://jellyfin.example.com/'), accessToken: 'test-token', userId: 'user-1', deviceId: 'device-1', userName: 'wasp', ); JellyfinAlbum album(String id, {String? imageTag, int? year}) { return JellyfinAlbum( id: id, name: 'Album $id', artist: 'Artist $id', imageTag: imageTag, year: year, ); } JellyfinTrack track( String id, { String albumId = 'album-1', String albumName = 'Album 1', String artist = 'Artist 1', int index = 1, Duration duration = const Duration(minutes: 3), String? mediaSourceId, DateTime? lastPlayed, }) { return JellyfinTrack( id: id, name: 'Track $id', albumId: albumId, album: albumName, artist: artist, indexNumber: index, parentIndexNumber: 1, duration: duration, mediaSourceId: mediaSourceId ?? 'source-$id', container: 'mp3', codec: 'mp3', lastPlayed: lastPlayed, ); } class MemorySecureStore implements SecureStore { final values = {}; @override Future read(String key) async => values[key]; @override Future write(String key, String value) async { values[key] = value; } @override Future delete(String key) async { values.remove(key); } } class FakeJellyfinGateway implements JellyfinGateway { @override Future serverInfo(Uri baseUrl) async { return const JellyfinServerInfo(name: 'Test server', version: '12.2.0'); } @override Future startQuickConnect( Uri baseUrl, { required String deviceId, }) async { return const JellyfinQuickConnect(code: '123456', secret: 'secret'); } @override Future isQuickConnectApproved(Uri baseUrl, String secret) async { return false; } @override Future authenticateQuickConnect( Uri baseUrl, String secret, { required String deviceId, }) async { return const JellyfinSession( accessToken: 'test-token', userId: 'user-1', userName: 'wasp', ); } @override Future currentUser(Uri baseUrl, String token) async { return const JellyfinUser(id: 'user-1', name: 'wasp'); } @override void close() {} } class FakeSystemPlatform implements LauncherPlatform { @override Future> listApps() async => const []; @override Future launchApp({ required String packageName, required String activityName, }) async {} @override Future isDefaultHome() async => true; @override Future requestDefaultHome() async => true; @override Future getStatus() async => const SystemStatus(batteryLevel: 80, charging: false, wifi: true); @override Future getVolume() async => const VolumeInfo(index: 5, max: 15); @override Future setVolume(int index) async {} @override Future isMusicPlaying() async => false; @override Future sendMediaKey(String action) async => true; @override Future getMediaState() async => const MediaPlayback(); @override Future openNotificationAccessSettings() async => true; @override Future setKeepScreenOn(bool enabled) async {} @override Future openUrl(String url) async => true; @override Future downloadAndInstallUpdate(String url) async { return const UpdateInstallResult(started: false, permissionRequired: false); } } class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway { List played = const []; List added = const []; List random = const []; List favorites = const []; Map> tracks = const {}; List libraryIds = const ['library-1']; List genres = const []; Uint8List? artwork; Object? error; Object? randomError; Object? favoriteError; bool albumFavorite = false; int playedCalls = 0; int addedCalls = 0; int randomCalls = 0; int genreCalls = 0; int trackCalls = 0; int artworkCalls = 0; int favoriteCalls = 0; int favoriteStateCalls = 0; int setFavoriteCalls = 0; List? lastRandomGenres; String? lastAlbumId; bool? lastFavoriteValue; void resetCounters() { playedCalls = 0; addedCalls = 0; randomCalls = 0; genreCalls = 0; trackCalls = 0; artworkCalls = 0; favoriteCalls = 0; favoriteStateCalls = 0; setFavoriteCalls = 0; } @override Future> playedTracks( JellyfinConnection connection, { int limit = 50, }) async { playedCalls++; if (error != null) throw error!; return played.take(limit).toList(growable: false); } @override Future> latestAddedAlbums( JellyfinConnection connection, { int limit = 5, }) async { addedCalls++; if (error != null) throw error!; return added.take(limit).toList(growable: false); } @override Future> randomAlbums( JellyfinConnection connection, { required List genres, int limit = 5, }) async { randomCalls++; lastRandomGenres = List.from(genres); if (randomError != null) throw randomError!; return random.take(limit).toList(growable: false); } @override Future> favoriteAlbums( JellyfinConnection connection, ) async { favoriteCalls++; if (favoriteError != null) throw favoriteError!; return favorites; } @override Future isAlbumFavorite( JellyfinConnection connection, String albumId, ) async { favoriteStateCalls++; if (favoriteError != null) throw favoriteError!; return albumFavorite || favorites.any((album) => album.id == albumId); } @override Future setAlbumFavorite( JellyfinConnection connection, String albumId, { required bool favorite, }) async { setFavoriteCalls++; lastAlbumId = albumId; lastFavoriteValue = favorite; if (favoriteError != null) throw favoriteError!; albumFavorite = favorite; if (!favorite) { favorites = favorites .where((album) => album.id != albumId) .toList(growable: false); } } @override Future> albumTracks( JellyfinConnection connection, String albumId, ) async { trackCalls++; lastAlbumId = albumId; if (error != null) throw error!; return tracks[albumId] ?? const []; } @override Future> musicLibraryIds(JellyfinConnection connection) async { return libraryIds; } @override Future> genreNames( JellyfinConnection connection, String libraryId, ) async { genreCalls++; if (error != null) throw error!; return genres; } @override Future albumArtwork( JellyfinConnection connection, String albumId, { String? tag, int maxHeight = 400, }) async { artworkCalls++; return artwork; } } Future configuredJellyfinController({ FakeJellyfinGateway? gateway, MemorySecureStore? store, }) async { SharedPreferences.setMockInitialValues({ 'jellyfin.enabled': true, 'jellyfin.serverUrl': 'https://jellyfin.example.com', 'jellyfin.userId': 'user-1', 'jellyfin.userName': 'wasp', 'jellyfin.serverName': 'Test server', }); final prefs = await SharedPreferences.getInstance(); final settings = SettingsRepository(prefs); final secure = store ?? MemorySecureStore(); secure.values[JellyfinController.tokenKey] = 'test-token'; final controller = JellyfinController( settings, gateway ?? FakeJellyfinGateway(), secure, ); await controller.initialize(); return controller; } JellyfinLibraryService testLibraryService( JellyfinController controller, FakeJellyfinLibraryGateway gateway, ) { return JellyfinLibraryService( connectionProvider: () => controller.connection ?? testJellyfinConnection, gateway: gateway, ); } class FakeJellyfinPlaybackPlatform implements JellyfinPlaybackPlatform { JellyfinPlaybackQueue? queue; JellyfinPlaybackSnapshot state = JellyfinPlaybackSnapshot.empty; final List commands = []; int setQueueCalls = 0; bool setQueueResult = true; bool permissionGranted = false; bool zeroDuration = false; @override Future setQueue(JellyfinPlaybackQueue queue) async { commands.add('setQueue'); setQueueCalls++; if (!setQueueResult) return false; this.queue = queue; state = snapshotAt(0, playing: true); return true; } JellyfinPlaybackSnapshot snapshotAt( int index, { bool playing = false, int positionMs = 0, }) { final current = queue; if (current == null || current.items.isEmpty) { return JellyfinPlaybackSnapshot.empty; } final safeIndex = index.clamp(0, current.items.length - 1); final item = current.items[safeIndex]; return JellyfinPlaybackSnapshot( available: true, playing: playing, itemId: item.trackId, albumId: current.albumId, title: item.title, artist: item.artist, album: item.album, durationMs: zeroDuration ? 0 : item.durationMs, positionMs: positionMs, index: safeIndex, hasPrevious: safeIndex > 0, hasNext: safeIndex < current.items.length - 1, ); } @override Future play() async { commands.add('play'); state = state.copyWith(playing: true); return true; } @override Future pause() async { commands.add('pause'); state = state.copyWith(playing: false); return true; } @override Future next() async { commands.add('next'); final current = queue; if (current == null || !state.hasNext) return false; state = snapshotAt(state.index + 1, playing: state.playing, positionMs: 0); return true; } @override Future previous() async { commands.add('previous'); if (!state.hasPrevious) return false; state = snapshotAt(state.index - 1, playing: state.playing, positionMs: 0); return true; } @override Future seekToIndex(int index) async { commands.add('seekToIndex:$index'); final current = queue; if (current == null || index < 0 || index >= current.items.length) { return false; } state = snapshotAt(index, playing: state.playing, positionMs: 0); return true; } @override Future seek(Duration position) async { commands.add('seek:${position.inMilliseconds}'); state = state.copyWith(positionMs: position.inMilliseconds); return true; } @override Future stop() async { commands.add('stop'); state = JellyfinPlaybackSnapshot.empty; queue = null; return true; } @override Future getState() async { commands.add('getState'); return state; } @override Future notificationPermissionGranted() async => permissionGranted; @override Future requestNotificationPermission() async { permissionGranted = true; return true; } @override void dispose() {} }