jellyfin_fakes.dart 13 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516
  1. import 'dart:typed_data';
  2. import 'package:cardash_launcher/data/models/app_entry.dart';
  3. import 'package:cardash_launcher/data/models/bluetooth_device_info.dart';
  4. import 'package:cardash_launcher/data/models/bluetooth_power_state.dart';
  5. import 'package:cardash_launcher/data/models/gps_speed.dart';
  6. import 'package:cardash_launcher/data/models/jellyfin_library.dart';
  7. import 'package:cardash_launcher/data/models/jellyfin_models.dart';
  8. import 'package:cardash_launcher/data/models/jellyfin_playback.dart';
  9. import 'package:cardash_launcher/data/models/location_reading.dart';
  10. import 'package:cardash_launcher/data/models/media_playback.dart';
  11. import 'package:cardash_launcher/data/models/obd_state.dart';
  12. import 'package:cardash_launcher/data/models/system_status.dart';
  13. import 'package:cardash_launcher/data/models/update_info.dart';
  14. import 'package:cardash_launcher/data/repositories/settings_repository.dart';
  15. import 'package:cardash_launcher/data/services/jellyfin_library_service.dart';
  16. import 'package:cardash_launcher/data/services/jellyfin_playback_platform.dart';
  17. import 'package:cardash_launcher/data/services/jellyfin_service.dart';
  18. import 'package:cardash_launcher/data/services/native_bridge.dart';
  19. import 'package:cardash_launcher/data/services/secure_store.dart';
  20. import 'package:cardash_launcher/data/services/telemetry_bridge.dart';
  21. import 'package:cardash_launcher/features/settings/jellyfin_controller.dart';
  22. import 'package:shared_preferences/shared_preferences.dart';
  23. final testJellyfinConnection = JellyfinConnection(
  24. baseUrl: Uri.parse('https://jellyfin.example.com/'),
  25. accessToken: 'test-token',
  26. userId: 'user-1',
  27. deviceId: 'device-1',
  28. userName: 'wasp',
  29. );
  30. JellyfinAlbum album(String id, {String? imageTag, int? year}) {
  31. return JellyfinAlbum(
  32. id: id,
  33. name: 'Album $id',
  34. artist: 'Artist $id',
  35. imageTag: imageTag,
  36. year: year,
  37. );
  38. }
  39. JellyfinTrack track(
  40. String id, {
  41. String albumId = 'album-1',
  42. String albumName = 'Album 1',
  43. String artist = 'Artist 1',
  44. int index = 1,
  45. Duration duration = const Duration(minutes: 3),
  46. String? mediaSourceId,
  47. DateTime? lastPlayed,
  48. }) {
  49. return JellyfinTrack(
  50. id: id,
  51. name: 'Track $id',
  52. albumId: albumId,
  53. album: albumName,
  54. artist: artist,
  55. indexNumber: index,
  56. parentIndexNumber: 1,
  57. duration: duration,
  58. mediaSourceId: mediaSourceId ?? 'source-$id',
  59. container: 'mp3',
  60. codec: 'mp3',
  61. lastPlayed: lastPlayed,
  62. );
  63. }
  64. class MemorySecureStore implements SecureStore {
  65. final values = <String, String>{};
  66. @override
  67. Future<String?> read(String key) async => values[key];
  68. @override
  69. Future<void> write(String key, String value) async {
  70. values[key] = value;
  71. }
  72. @override
  73. Future<void> delete(String key) async {
  74. values.remove(key);
  75. }
  76. }
  77. class FakeJellyfinGateway implements JellyfinGateway {
  78. @override
  79. Future<JellyfinServerInfo> serverInfo(Uri baseUrl) async {
  80. return const JellyfinServerInfo(name: 'Test server', version: '12.2.0');
  81. }
  82. @override
  83. Future<JellyfinQuickConnect> startQuickConnect(
  84. Uri baseUrl, {
  85. required String deviceId,
  86. }) async {
  87. return const JellyfinQuickConnect(code: '123456', secret: 'secret');
  88. }
  89. @override
  90. Future<bool> isQuickConnectApproved(Uri baseUrl, String secret) async {
  91. return false;
  92. }
  93. @override
  94. Future<JellyfinSession> authenticateQuickConnect(
  95. Uri baseUrl,
  96. String secret, {
  97. required String deviceId,
  98. }) async {
  99. return const JellyfinSession(
  100. accessToken: 'test-token',
  101. userId: 'user-1',
  102. userName: 'wasp',
  103. );
  104. }
  105. @override
  106. Future<JellyfinUser> currentUser(Uri baseUrl, String token) async {
  107. return const JellyfinUser(id: 'user-1', name: 'wasp');
  108. }
  109. @override
  110. void close() {}
  111. }
  112. class FakeSystemPlatform implements LauncherPlatform {
  113. @override
  114. Future<List<AppEntry>> listApps() async => const [];
  115. @override
  116. Future<void> launchApp({
  117. required String packageName,
  118. required String activityName,
  119. }) async {}
  120. @override
  121. Future<bool> isDefaultHome() async => true;
  122. @override
  123. Future<bool> requestDefaultHome() async => true;
  124. @override
  125. Future<SystemStatus> getStatus() async =>
  126. const SystemStatus(batteryLevel: 80, charging: false, wifi: true);
  127. @override
  128. Future<VolumeInfo> getVolume() async => const VolumeInfo(index: 5, max: 15);
  129. @override
  130. Future<void> setVolume(int index) async {}
  131. @override
  132. Future<bool> isMusicPlaying() async => false;
  133. @override
  134. Future<bool> sendMediaKey(String action) async => true;
  135. @override
  136. Future<MediaPlayback> getMediaState() async => const MediaPlayback();
  137. @override
  138. Future<bool> openNotificationAccessSettings() async => true;
  139. @override
  140. Future<void> setKeepScreenOn(bool enabled) async {}
  141. @override
  142. Future<bool> openUrl(String url) async => true;
  143. @override
  144. Future<UpdateInstallResult> downloadAndInstallUpdate(String url) async {
  145. return const UpdateInstallResult(started: false, permissionRequired: false);
  146. }
  147. }
  148. class FakeTelemetryPlatform implements TelemetryPlatform {
  149. @override
  150. Future<List<BluetoothDeviceInfo>> listBondedDevices() async => const [];
  151. @override
  152. Future<BluetoothPowerState> bluetoothState() async => BluetoothPowerState.off;
  153. @override
  154. Future<bool> openBluetoothSettings() async => true;
  155. @override
  156. Future<bool> ensureBluetoothPermission() async => true;
  157. @override
  158. Future<bool> ensureLocationPermission() async => true;
  159. @override
  160. Future<LocationReading> getLocation() async =>
  161. const LocationReading(latitude: 0, longitude: 0, place: '');
  162. @override
  163. Future<void> connectObd(String address) async {}
  164. @override
  165. Future<void> disconnectObd() async {}
  166. @override
  167. Future<ObdState> getObdState() async => const ObdState();
  168. @override
  169. Future<bool> startGpsSpeed() async => false;
  170. @override
  171. Future<void> stopGpsSpeed() async {}
  172. @override
  173. Future<GpsSpeed> getGpsSpeed() async => const GpsSpeed();
  174. }
  175. class FakeJellyfinLibraryGateway implements JellyfinLibraryGateway {
  176. List<JellyfinTrack> played = const [];
  177. List<JellyfinAlbum> added = const [];
  178. List<JellyfinAlbum> random = const [];
  179. List<JellyfinAlbum> favorites = const [];
  180. Map<String, List<JellyfinTrack>> tracks = const {};
  181. List<String> libraryIds = const ['library-1'];
  182. List<String> genres = const [];
  183. Uint8List? artwork;
  184. Object? error;
  185. Object? randomError;
  186. Object? favoriteError;
  187. bool albumFavorite = false;
  188. int playedCalls = 0;
  189. int addedCalls = 0;
  190. int randomCalls = 0;
  191. int genreCalls = 0;
  192. int trackCalls = 0;
  193. int artworkCalls = 0;
  194. int favoriteCalls = 0;
  195. int favoriteStateCalls = 0;
  196. int setFavoriteCalls = 0;
  197. List<String>? lastRandomGenres;
  198. String? lastAlbumId;
  199. bool? lastFavoriteValue;
  200. void resetCounters() {
  201. playedCalls = 0;
  202. addedCalls = 0;
  203. randomCalls = 0;
  204. genreCalls = 0;
  205. trackCalls = 0;
  206. artworkCalls = 0;
  207. favoriteCalls = 0;
  208. favoriteStateCalls = 0;
  209. setFavoriteCalls = 0;
  210. }
  211. @override
  212. Future<List<JellyfinTrack>> playedTracks(
  213. JellyfinConnection connection, {
  214. int limit = 50,
  215. }) async {
  216. playedCalls++;
  217. if (error != null) throw error!;
  218. return played.take(limit).toList(growable: false);
  219. }
  220. @override
  221. Future<List<JellyfinAlbum>> latestAddedAlbums(
  222. JellyfinConnection connection, {
  223. int limit = 5,
  224. }) async {
  225. addedCalls++;
  226. if (error != null) throw error!;
  227. return added.take(limit).toList(growable: false);
  228. }
  229. @override
  230. Future<List<JellyfinAlbum>> randomAlbums(
  231. JellyfinConnection connection, {
  232. required List<String> genres,
  233. int limit = 5,
  234. }) async {
  235. randomCalls++;
  236. lastRandomGenres = List<String>.from(genres);
  237. if (randomError != null) throw randomError!;
  238. return random.take(limit).toList(growable: false);
  239. }
  240. @override
  241. Future<List<JellyfinAlbum>> favoriteAlbums(
  242. JellyfinConnection connection,
  243. ) async {
  244. favoriteCalls++;
  245. if (favoriteError != null) throw favoriteError!;
  246. return favorites;
  247. }
  248. @override
  249. Future<bool> isAlbumFavorite(
  250. JellyfinConnection connection,
  251. String albumId,
  252. ) async {
  253. favoriteStateCalls++;
  254. if (favoriteError != null) throw favoriteError!;
  255. return albumFavorite || favorites.any((album) => album.id == albumId);
  256. }
  257. @override
  258. Future<void> setAlbumFavorite(
  259. JellyfinConnection connection,
  260. String albumId, {
  261. required bool favorite,
  262. }) async {
  263. setFavoriteCalls++;
  264. lastAlbumId = albumId;
  265. lastFavoriteValue = favorite;
  266. if (favoriteError != null) throw favoriteError!;
  267. albumFavorite = favorite;
  268. if (!favorite) {
  269. favorites = favorites
  270. .where((album) => album.id != albumId)
  271. .toList(growable: false);
  272. }
  273. }
  274. @override
  275. Future<List<JellyfinTrack>> albumTracks(
  276. JellyfinConnection connection,
  277. String albumId,
  278. ) async {
  279. trackCalls++;
  280. lastAlbumId = albumId;
  281. if (error != null) throw error!;
  282. return tracks[albumId] ?? const [];
  283. }
  284. @override
  285. Future<List<String>> musicLibraryIds(JellyfinConnection connection) async {
  286. return libraryIds;
  287. }
  288. @override
  289. Future<List<String>> genreNames(
  290. JellyfinConnection connection,
  291. String libraryId,
  292. ) async {
  293. genreCalls++;
  294. if (error != null) throw error!;
  295. return genres;
  296. }
  297. @override
  298. Future<Uint8List?> albumArtwork(
  299. JellyfinConnection connection,
  300. String albumId, {
  301. String? tag,
  302. int maxHeight = 400,
  303. }) async {
  304. artworkCalls++;
  305. return artwork;
  306. }
  307. }
  308. Future<JellyfinController> configuredJellyfinController({
  309. FakeJellyfinGateway? gateway,
  310. MemorySecureStore? store,
  311. }) async {
  312. SharedPreferences.setMockInitialValues({
  313. 'jellyfin.enabled': true,
  314. 'jellyfin.serverUrl': 'https://jellyfin.example.com',
  315. 'jellyfin.userId': 'user-1',
  316. 'jellyfin.userName': 'wasp',
  317. 'jellyfin.serverName': 'Test server',
  318. });
  319. final prefs = await SharedPreferences.getInstance();
  320. final settings = SettingsRepository(prefs);
  321. final secure = store ?? MemorySecureStore();
  322. secure.values[JellyfinController.tokenKey] = 'test-token';
  323. final controller = JellyfinController(
  324. settings,
  325. gateway ?? FakeJellyfinGateway(),
  326. secure,
  327. );
  328. await controller.initialize();
  329. return controller;
  330. }
  331. JellyfinLibraryService testLibraryService(
  332. JellyfinController controller,
  333. FakeJellyfinLibraryGateway gateway,
  334. ) {
  335. return JellyfinLibraryService(
  336. connectionProvider: () => controller.connection ?? testJellyfinConnection,
  337. gateway: gateway,
  338. );
  339. }
  340. class FakeJellyfinPlaybackPlatform implements JellyfinPlaybackPlatform {
  341. JellyfinPlaybackQueue? queue;
  342. JellyfinPlaybackSnapshot state = JellyfinPlaybackSnapshot.empty;
  343. final List<String> commands = [];
  344. int setQueueCalls = 0;
  345. bool setQueueResult = true;
  346. bool permissionGranted = false;
  347. bool zeroDuration = false;
  348. @override
  349. Future<bool> setQueue(JellyfinPlaybackQueue queue) async {
  350. commands.add('setQueue');
  351. setQueueCalls++;
  352. if (!setQueueResult) return false;
  353. this.queue = queue;
  354. state = snapshotAt(0, playing: true);
  355. return true;
  356. }
  357. JellyfinPlaybackSnapshot snapshotAt(
  358. int index, {
  359. bool playing = false,
  360. int positionMs = 0,
  361. }) {
  362. final current = queue;
  363. if (current == null || current.items.isEmpty) {
  364. return JellyfinPlaybackSnapshot.empty;
  365. }
  366. final safeIndex = index.clamp(0, current.items.length - 1);
  367. final item = current.items[safeIndex];
  368. return JellyfinPlaybackSnapshot(
  369. available: true,
  370. playing: playing,
  371. itemId: item.trackId,
  372. albumId: current.albumId,
  373. title: item.title,
  374. artist: item.artist,
  375. album: item.album,
  376. durationMs: zeroDuration ? 0 : item.durationMs,
  377. positionMs: positionMs,
  378. index: safeIndex,
  379. hasPrevious: safeIndex > 0,
  380. hasNext: safeIndex < current.items.length - 1,
  381. );
  382. }
  383. @override
  384. Future<bool> play() async {
  385. commands.add('play');
  386. state = state.copyWith(playing: true);
  387. return true;
  388. }
  389. @override
  390. Future<bool> pause() async {
  391. commands.add('pause');
  392. state = state.copyWith(playing: false);
  393. return true;
  394. }
  395. @override
  396. Future<bool> next() async {
  397. commands.add('next');
  398. final current = queue;
  399. if (current == null || !state.hasNext) return false;
  400. state = snapshotAt(state.index + 1, playing: state.playing, positionMs: 0);
  401. return true;
  402. }
  403. @override
  404. Future<bool> previous() async {
  405. commands.add('previous');
  406. if (!state.hasPrevious) return false;
  407. state = snapshotAt(state.index - 1, playing: state.playing, positionMs: 0);
  408. return true;
  409. }
  410. @override
  411. Future<bool> seekToIndex(int index) async {
  412. commands.add('seekToIndex:$index');
  413. final current = queue;
  414. if (current == null || index < 0 || index >= current.items.length) {
  415. return false;
  416. }
  417. state = snapshotAt(index, playing: state.playing, positionMs: 0);
  418. return true;
  419. }
  420. @override
  421. Future<bool> seek(Duration position) async {
  422. commands.add('seek:${position.inMilliseconds}');
  423. state = state.copyWith(positionMs: position.inMilliseconds);
  424. return true;
  425. }
  426. @override
  427. Future<bool> stop() async {
  428. commands.add('stop');
  429. state = JellyfinPlaybackSnapshot.empty;
  430. queue = null;
  431. return true;
  432. }
  433. @override
  434. Future<JellyfinPlaybackSnapshot> getState() async {
  435. commands.add('getState');
  436. return state;
  437. }
  438. @override
  439. Future<bool> notificationPermissionGranted() async => permissionGranted;
  440. @override
  441. Future<bool> requestNotificationPermission() async {
  442. permissionGranted = true;
  443. return true;
  444. }
  445. @override
  446. void dispose() {}
  447. }