jellyfin_fakes.dart 12 KB

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