jellyfin_fakes.dart 12 KB

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