@rntp/player 5.0.0-beta.4 → 5.0.0-beta.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/android/build.gradle +7 -0
  2. package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
  3. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
  4. package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
  5. package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
  6. package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
  7. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
  8. package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
  9. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
  10. package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
  11. package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
  12. package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
  13. package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
  14. package/android/src/test/resources/robolectric.properties +1 -0
  15. package/ios/TrackPlayer.swift +47 -101
  16. package/ios/TrackPlayerBridge.mm +2 -0
  17. package/ios/player/AVPlayerEngine.swift +47 -35
  18. package/ios/player/AudioCache.swift +34 -0
  19. package/ios/player/AudioPlayer.swift +70 -22
  20. package/ios/player/CacheProxyServer.swift +429 -0
  21. package/ios/player/DownloadCoordinator.swift +242 -0
  22. package/ios/player/Preloader.swift +21 -90
  23. package/ios/player/SleepTimerController.swift +147 -0
  24. package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
  25. package/ios/tests/AudioPlayerTests.swift +6 -0
  26. package/ios/tests/CacheProxyServerTests.swift +403 -0
  27. package/ios/tests/DownloadCoordinatorTests.swift +197 -0
  28. package/ios/tests/LocalAudioServer.swift +171 -0
  29. package/ios/tests/MockPlayerEngine.swift +1 -0
  30. package/ios/tests/QueueManagerTests.swift +6 -0
  31. package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
  32. package/ios/tests/SleepTimerTests.swift +70 -0
  33. package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
  34. package/lib/commonjs/audio.js +19 -0
  35. package/lib/commonjs/audio.js.map +1 -1
  36. package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
  37. package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
  38. package/lib/module/NativeTrackPlayer.js.map +1 -1
  39. package/lib/module/audio.js +17 -0
  40. package/lib/module/audio.js.map +1 -1
  41. package/lib/module/interfaces/PlayerConfig.js +1 -1
  42. package/lib/module/interfaces/PlayerConfig.js.map +1 -1
  43. package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
  44. package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
  45. package/lib/typescript/src/audio.d.ts +12 -1
  46. package/lib/typescript/src/audio.d.ts.map +1 -1
  47. package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
  48. package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
  49. package/lib/typescript/src/interfaces/PlayerConfig.d.ts +21 -2
  50. package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
  51. package/package.json +4 -1
  52. package/src/NativeTrackPlayer.ts +4 -0
  53. package/src/audio.ts +18 -0
  54. package/src/interfaces/MediaItem.ts +4 -1
  55. package/src/interfaces/PlayerConfig.ts +24 -3
  56. package/ios/player/CachingResourceLoader.swift +0 -273
@@ -0,0 +1,473 @@
1
+ package com.doublesymmetry.trackplayer
2
+
3
+ import androidx.media3.common.MediaItem
4
+ import androidx.media3.common.MediaMetadata
5
+ import androidx.media3.exoplayer.ExoPlayer
6
+ import org.junit.After
7
+ import org.junit.Before
8
+ import org.junit.Test
9
+ import org.junit.Assert.*
10
+ import org.junit.runner.RunWith
11
+ import org.robolectric.RobolectricTestRunner
12
+ import org.robolectric.RuntimeEnvironment
13
+ import org.robolectric.annotation.Config
14
+
15
+ // ---------------------------------------------------------------------------
16
+ // SleepTimerIntegrationTest
17
+ //
18
+ // Tests the full sleep-timer logic against a real ExoPlayer instance driven
19
+ // by Robolectric, using the production SleepTimerController directly.
20
+ //
21
+ // Three bugs caught during manual testing are explicitly guarded:
22
+ // 1. Timer not firing (RunLoop issue) — tick/pause assertions.
23
+ // 2. Volume not restored after fade — testVolumeRestoredAfterFadeCompletes.
24
+ // 3. Pause overridden during media-item transition —
25
+ // testMediaItemTimerPausesAfterTargetIndex.
26
+ // ---------------------------------------------------------------------------
27
+
28
+ @RunWith(RobolectricTestRunner::class)
29
+ @Config(sdk = [33])
30
+ class SleepTimerIntegrationTest {
31
+
32
+ private lateinit var player: ExoPlayer
33
+ private lateinit var controller: SleepTimerController
34
+ private val triggeredTypes = mutableListOf<String>()
35
+
36
+ @Before
37
+ fun setUp() {
38
+ val context = RuntimeEnvironment.getApplication()
39
+ player = ExoPlayer.Builder(context).build()
40
+ player.playWhenReady = true
41
+ controller = SleepTimerController(player)
42
+ controller.onTriggered = { triggeredTypes.add(it) }
43
+ }
44
+
45
+ @After
46
+ fun tearDown() {
47
+ player.release()
48
+ }
49
+
50
+ // ---- helpers -----------------------------------------------------------
51
+
52
+ private fun buildMediaItem(id: String, url: String = "https://example.com/$id.mp3"): MediaItem =
53
+ MediaItem.Builder()
54
+ .setMediaId(id)
55
+ .setUri(url)
56
+ .setMediaMetadata(MediaMetadata.Builder().setTitle(id).build())
57
+ .build()
58
+
59
+ private fun buildQueue(count: Int): List<MediaItem> =
60
+ (1..count).map { buildMediaItem("track-$it") }
61
+
62
+ // ========================================================================
63
+ // Time-Based: Basic Countdown
64
+ // ========================================================================
65
+
66
+ @Test
67
+ fun `timer pauses player when countdown reaches zero`() {
68
+ controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 0.0)
69
+
70
+ controller.tick() // 2 remaining
71
+ assertTrue("should not be paused before countdown ends", player.playWhenReady)
72
+
73
+ controller.tick() // 1 remaining
74
+ assertTrue("should not be paused before countdown ends", player.playWhenReady)
75
+
76
+ controller.tick() // 0 remaining — fires
77
+ assertFalse("player must be paused when timer reaches 0", player.playWhenReady)
78
+ assertEquals("time", triggeredTypes.lastOrNull())
79
+ }
80
+
81
+ @Test
82
+ fun `getState returns null after timer fires`() {
83
+ controller.sleepAfterTime(seconds = 2.0, fadeOutSeconds = 0.0)
84
+ controller.tick()
85
+ controller.tick()
86
+
87
+ assertNull("getState must return null after firing", controller.getState())
88
+ }
89
+
90
+ // ========================================================================
91
+ // Time-Based: Zero-Second Immediate Pause
92
+ // ========================================================================
93
+
94
+ @Test
95
+ fun `zero second timer pauses immediately without ticks`() {
96
+ controller.sleepAfterTime(seconds = 0.0, fadeOutSeconds = 0.0)
97
+
98
+ assertFalse("0-second timer must pause immediately", player.playWhenReady)
99
+ assertEquals("time", triggeredTypes.lastOrNull())
100
+ assertNull(controller.getState())
101
+ }
102
+
103
+ // ========================================================================
104
+ // Time-Based: Fade-Out
105
+ // ========================================================================
106
+
107
+ @Test
108
+ fun `fade out reduces volume linearly then pauses`() {
109
+ player.volume = 1.0f
110
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 5.0)
111
+ val preFadeVolume = 1.0f
112
+
113
+ val volumes = mutableListOf(player.volume)
114
+ repeat(5) {
115
+ controller.tick()
116
+ volumes.add(player.volume)
117
+ }
118
+
119
+ // Volume should have decreased monotonically during the fade
120
+ for (i in 1 until volumes.size - 1) {
121
+ assertTrue(
122
+ "volume at step $i (${volumes[i]}) should be less than step ${i-1} (${volumes[i-1]})",
123
+ volumes[i] < volumes[i - 1]
124
+ )
125
+ }
126
+
127
+ // After the timer fires, volume is restored (bug #2)
128
+ assertEquals("volume must be restored after timer fires",
129
+ preFadeVolume, player.volume, 0.01f)
130
+ assertFalse("player must be paused", player.playWhenReady)
131
+ }
132
+
133
+ @Test
134
+ fun `volume is restored after fade completes`() {
135
+ // Explicitly guards bug #2: volume not restoring after fade.
136
+ player.volume = 0.8f
137
+ controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 3.0)
138
+
139
+ repeat(3) { controller.tick() }
140
+
141
+ assertEquals(
142
+ "pre-fade volume must be restored after timer fires so next playback is not muted",
143
+ 0.8f, player.volume, 0.01f
144
+ )
145
+ }
146
+
147
+ @Test
148
+ fun `each tick during fade decreases volume`() {
149
+ player.volume = 1.0f
150
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 5.0)
151
+
152
+ var previousVolume = player.volume
153
+ repeat(4) {
154
+ controller.tick()
155
+ assertTrue("volume should decrease each tick", player.volume < previousVolume)
156
+ previousVolume = player.volume
157
+ }
158
+ }
159
+
160
+ @Test
161
+ fun `fade uses pre-fade volume as ceiling when less than 1`() {
162
+ player.volume = 0.6f
163
+ controller.sleepAfterTime(seconds = 4.0, fadeOutSeconds = 4.0)
164
+
165
+ controller.tick() // 3 remaining, progress = 3/4 → expected = 0.6 * 0.75 = 0.45
166
+ assertEquals("fade must scale from pre-fade volume, not 1.0",
167
+ 0.6f * 0.75f, player.volume, 0.01f)
168
+ }
169
+
170
+ @Test
171
+ fun `no fade when fadeOutSeconds is zero`() {
172
+ player.volume = 1.0f
173
+ controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 0.0)
174
+
175
+ controller.tick()
176
+ controller.tick()
177
+
178
+ assertEquals("volume must not change when fadeOutSeconds is 0",
179
+ 1.0f, player.volume, 0.001f)
180
+
181
+ controller.tick() // fires
182
+ assertFalse("must pause when countdown ends", player.playWhenReady)
183
+ }
184
+
185
+ // ========================================================================
186
+ // Time-Based: Cancel Mid-Fade
187
+ // ========================================================================
188
+
189
+ @Test
190
+ fun `cancel mid-fade restores volume`() {
191
+ player.volume = 1.0f
192
+ controller.sleepAfterTime(seconds = 10.0, fadeOutSeconds = 10.0)
193
+
194
+ controller.tick()
195
+ controller.tick()
196
+ val volumeDuringFade = player.volume
197
+ assertTrue("volume should have decreased before cancel", volumeDuringFade < 1.0f)
198
+
199
+ controller.cancel()
200
+
201
+ assertEquals("cancelling mid-fade must restore pre-fade volume",
202
+ 1.0f, player.volume, 0.01f)
203
+ assertNull(controller.getState())
204
+ assertTrue("cancel must not pause", player.playWhenReady)
205
+ }
206
+
207
+ @Test
208
+ fun `cancel without fade does not pause`() {
209
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0)
210
+ controller.tick()
211
+ controller.cancel()
212
+
213
+ assertTrue("cancel must not pause the player", player.playWhenReady)
214
+ assertNull(controller.getState())
215
+ }
216
+
217
+ // ========================================================================
218
+ // Time-Based: FadeOutSeconds > Seconds Clamping
219
+ // ========================================================================
220
+
221
+ @Test
222
+ fun `fadeOutSeconds is clamped to timer duration`() {
223
+ controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 10.0)
224
+
225
+ assertEquals("fadeOutSeconds must be clamped to timer duration",
226
+ 3.0, controller.sleepTimerFadeOutSeconds, 0.001)
227
+ }
228
+
229
+ @Test
230
+ fun `clamped fade still produces monotonic volume decrease`() {
231
+ player.volume = 1.0f
232
+ controller.sleepAfterTime(seconds = 3.0, fadeOutSeconds = 10.0)
233
+
234
+ controller.tick() // 2 remaining
235
+ val v1 = player.volume
236
+ controller.tick() // 1 remaining
237
+ val v2 = player.volume
238
+ controller.tick() // 0 remaining — fires
239
+
240
+ assertTrue("volume must start decreasing after clamp", v1 < 1.0f)
241
+ assertTrue("volume must keep decreasing", v2 < v1)
242
+ assertFalse("timer must still fire after clamped fade", player.playWhenReady)
243
+ }
244
+
245
+ // ========================================================================
246
+ // Time-Based: getState State
247
+ // ========================================================================
248
+
249
+ @Test
250
+ fun `getState returns correct time state`() {
251
+ controller.sleepAfterTime(seconds = 30.0, fadeOutSeconds = 10.0)
252
+
253
+ val state = controller.getState()
254
+ assertNotNull(state)
255
+ assertEquals("time", state!!["type"])
256
+ assertEquals(30.0, state["remainingSeconds"] as Double, 0.001)
257
+ assertEquals(10.0, state["fadeOutSeconds"] as Double, 0.001)
258
+ }
259
+
260
+ @Test
261
+ fun `remaining seconds decrease after tick`() {
262
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0)
263
+ controller.tick()
264
+
265
+ assertEquals(4.0, controller.sleepTimerRemainingSeconds, 0.001)
266
+ }
267
+
268
+ @Test
269
+ fun `getState returns null after cancel`() {
270
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0)
271
+ controller.cancel()
272
+
273
+ assertNull(controller.getState())
274
+ }
275
+
276
+ // ========================================================================
277
+ // MediaItem-Based: Forward Transition
278
+ // ========================================================================
279
+
280
+ @Test
281
+ fun `mediaItem timer pauses after target index`() {
282
+ // Guards bug #3: pause being overridden during media-item transitions.
283
+ player.setMediaItems(buildQueue(3))
284
+ // Target is index 1; we are currently at index 1 (just finished it)
285
+ controller.sleepAfterMediaItemAtIndex(index = 1)
286
+ // Manually set previousIndex to simulate we are sitting at index 1
287
+ // The controller reads player.currentMediaItemIndex in sleepAfterMediaItemAtIndex,
288
+ // so we need the player to be at index 1. Since we can't easily seek in Robolectric
289
+ // without media loaded, we use handleItemTransition to simulate the transition flow.
290
+ // First simulate arriving at index 1 (so previousIndex becomes 1)
291
+ controller.handleItemTransition(1)
292
+
293
+ val fired = controller.handleItemTransition(2)
294
+
295
+ assertTrue("sleep timer should fire when transitioning past target index", fired)
296
+ assertEquals("mediaItem", triggeredTypes.lastOrNull())
297
+ assertNull("timer must be cleared after firing", controller.getState())
298
+ }
299
+
300
+ @Test
301
+ fun `mediaItem timer does not fire when arriving at target`() {
302
+ player.setMediaItems(buildQueue(3))
303
+ // Set up: target=1, previousIndex=0 (starts at 0 by default from player)
304
+ controller.sleepAfterMediaItemAtIndex(index = 1)
305
+
306
+ // Transition from 0 → 1 (arriving at target, not leaving it)
307
+ val fired = controller.handleItemTransition(1)
308
+
309
+ assertFalse("arriving at target index must not fire the timer", fired)
310
+ assertTrue("player must not be paused", player.playWhenReady)
311
+ }
312
+
313
+ @Test
314
+ fun `mediaItem timer does not fire before reaching target`() {
315
+ player.setMediaItems(buildQueue(4))
316
+ // player starts at index 0, so sleepTimerPreviousIndex = 0, target = 2
317
+ controller.sleepAfterMediaItemAtIndex(index = 2)
318
+
319
+ // 0 → 1 (pre-target)
320
+ val firedAt1 = controller.handleItemTransition(1)
321
+ assertFalse("must not fire when previous (0) ≠ target (2)", firedAt1)
322
+ assertTrue(player.playWhenReady)
323
+
324
+ // 1 → 2 (arriving at target)
325
+ val firedAt2 = controller.handleItemTransition(2)
326
+ assertFalse("must not fire when arriving at target", firedAt2)
327
+ assertTrue(player.playWhenReady)
328
+
329
+ // 2 → 3 (leaving target — fires)
330
+ val firedAt3 = controller.handleItemTransition(3)
331
+ assertTrue("must fire when leaving target index", firedAt3)
332
+ }
333
+
334
+ @Test
335
+ fun `mediaItem timer fires on departure regardless of direction`() {
336
+ // Production code fires when previousIndex == targetIndex && newIndex != targetIndex.
337
+ // A skip-backward that departs the target index also fires.
338
+ player.setMediaItems(buildQueue(3))
339
+ // player starts at index 0; set target=2, then simulate arriving at 2 first
340
+ controller.sleepAfterMediaItemAtIndex(index = 2)
341
+ controller.handleItemTransition(2) // arrive at target → previousIndex = 2
342
+
343
+ val fired = controller.handleItemTransition(1) // skip backward from target
344
+ assertTrue("departing target (backward skip) fires the timer per production logic", fired)
345
+ }
346
+
347
+ // ========================================================================
348
+ // MediaItem-Based: Cancel
349
+ // ========================================================================
350
+
351
+ @Test
352
+ fun `cancelling mediaItem timer prevents subsequent pause`() {
353
+ player.setMediaItems(buildQueue(2))
354
+ controller.sleepAfterMediaItemAtIndex(index = 0)
355
+ controller.cancel()
356
+
357
+ val fired = controller.handleItemTransition(1)
358
+
359
+ assertFalse("cancelled timer must not fire on transition", fired)
360
+ assertTrue("player must not be paused", player.playWhenReady)
361
+ }
362
+
363
+ // ========================================================================
364
+ // MediaItem-Based: getState State
365
+ // ========================================================================
366
+
367
+ @Test
368
+ fun `getState returns correct mediaItem state`() {
369
+ player.setMediaItems(buildQueue(4))
370
+ controller.sleepAfterMediaItemAtIndex(index = 3)
371
+
372
+ val state = controller.getState()
373
+ assertNotNull(state)
374
+ assertEquals("mediaItem", state!!["type"])
375
+ assertEquals(3, state["index"])
376
+ }
377
+
378
+ @Test
379
+ fun `getState returns null after mediaItem timer fires`() {
380
+ player.setMediaItems(buildQueue(2))
381
+ controller.sleepAfterMediaItemAtIndex(index = 0)
382
+ controller.handleItemTransition(1) // fires
383
+
384
+ assertNull(controller.getState())
385
+ }
386
+
387
+ // ========================================================================
388
+ // Interaction: Last-One-Wins
389
+ // ========================================================================
390
+
391
+ @Test
392
+ fun `setting time timer cancels existing mediaItem timer`() {
393
+ controller.sleepAfterMediaItemAtIndex(index = 1)
394
+ assertEquals("mediaItem", controller.getState()!!["type"])
395
+
396
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 0.0)
397
+
398
+ assertEquals("setting time timer must replace active mediaItem timer",
399
+ "time", controller.getState()!!["type"])
400
+ }
401
+
402
+ @Test
403
+ fun `setting mediaItem timer cancels existing time timer`() {
404
+ controller.sleepAfterTime(seconds = 10.0, fadeOutSeconds = 0.0)
405
+ assertEquals("time", controller.getState()!!["type"])
406
+
407
+ controller.sleepAfterMediaItemAtIndex(index = 2)
408
+
409
+ assertEquals("setting mediaItem timer must replace active time timer",
410
+ "mediaItem", controller.getState()!!["type"])
411
+ }
412
+
413
+ @Test
414
+ fun `replacing fading time timer with new timer restores volume`() {
415
+ player.volume = 1.0f
416
+ controller.sleepAfterTime(seconds = 5.0, fadeOutSeconds = 5.0)
417
+ controller.tick()
418
+ controller.tick()
419
+ assertTrue("volume must have decreased during fade", player.volume < 1.0f)
420
+
421
+ // Replace with a new timer
422
+ controller.sleepAfterTime(seconds = 10.0, fadeOutSeconds = 0.0)
423
+
424
+ assertEquals(
425
+ "replacing a fading timer must restore volume before starting the new one",
426
+ 1.0f, player.volume, 0.01f
427
+ )
428
+ }
429
+
430
+ // ========================================================================
431
+ // State Invariants
432
+ // ========================================================================
433
+
434
+ @Test
435
+ fun `getState returns null initially`() {
436
+ assertNull(controller.getState())
437
+ }
438
+
439
+ @Test
440
+ fun `extra ticks after timer fires are no-ops`() {
441
+ controller.sleepAfterTime(seconds = 1.0, fadeOutSeconds = 0.0)
442
+ controller.tick() // fires — timer is now cancelled
443
+
444
+ // Extra ticks after firing are no-ops (timer type is null)
445
+ controller.tick()
446
+ controller.tick()
447
+
448
+ // Verify the controller recorded exactly one triggered event, not multiple.
449
+ assertEquals(1, triggeredTypes.size)
450
+ assertEquals("time", triggeredTypes[0])
451
+ // And sleepTimer state is fully cleared
452
+ assertNull(controller.getState())
453
+ assertEquals(0.0, controller.sleepTimerRemainingSeconds, 0.001)
454
+ }
455
+
456
+ @Test
457
+ fun `volume defaults to 1 on a fresh ExoPlayer`() {
458
+ assertEquals("ExoPlayer default volume should be 1.0",
459
+ 1.0f, player.volume, 0.001f)
460
+ }
461
+
462
+ @Test
463
+ fun `volume changes are reflected on ExoPlayer`() {
464
+ player.volume = 0.5f
465
+ assertEquals(0.5f, player.volume, 0.001f)
466
+
467
+ player.volume = 0.0f
468
+ assertEquals(0.0f, player.volume, 0.001f)
469
+
470
+ player.volume = 1.0f
471
+ assertEquals(1.0f, player.volume, 0.001f)
472
+ }
473
+ }
@@ -0,0 +1,58 @@
1
+ package com.doublesymmetry.trackplayer
2
+
3
+ import org.junit.Test
4
+ import org.junit.Assert.*
5
+ import org.junit.runner.RunWith
6
+ import org.robolectric.RobolectricTestRunner
7
+ import org.robolectric.RuntimeEnvironment
8
+ import org.json.JSONObject
9
+
10
+ @RunWith(RobolectricTestRunner::class)
11
+ class SleepTimerStateTest {
12
+
13
+ @Test
14
+ fun `time mode persists correctly to SharedPreferences`() {
15
+ val context = RuntimeEnvironment.getApplication()
16
+ val prefs = context.getSharedPreferences("TrackPlayerPrefs", 0)
17
+
18
+ val json = JSONObject().apply {
19
+ put("type", "time")
20
+ put("remainingSeconds", 120.0)
21
+ put("fadeOutSeconds", 30.0)
22
+ }
23
+ prefs.edit().putString("sleep_timer_state", json.toString()).apply()
24
+
25
+ val parsed = JSONObject(prefs.getString("sleep_timer_state", null)!!)
26
+ assertEquals("time", parsed.getString("type"))
27
+ assertEquals(120.0, parsed.getDouble("remainingSeconds"), 0.01)
28
+ assertEquals(30.0, parsed.getDouble("fadeOutSeconds"), 0.01)
29
+ }
30
+
31
+ @Test
32
+ fun `mediaItem mode persists correctly`() {
33
+ val context = RuntimeEnvironment.getApplication()
34
+ val prefs = context.getSharedPreferences("TrackPlayerPrefs", 0)
35
+
36
+ val json = JSONObject().apply {
37
+ put("type", "mediaItem")
38
+ put("index", 5)
39
+ }
40
+ prefs.edit().putString("sleep_timer_state", json.toString()).apply()
41
+
42
+ val parsed = JSONObject(prefs.getString("sleep_timer_state", null)!!)
43
+ assertEquals("mediaItem", parsed.getString("type"))
44
+ assertEquals(5, parsed.getInt("index"))
45
+ }
46
+
47
+ @Test
48
+ fun `cancel removes state from SharedPreferences`() {
49
+ val context = RuntimeEnvironment.getApplication()
50
+ val prefs = context.getSharedPreferences("TrackPlayerPrefs", 0)
51
+
52
+ prefs.edit().putString("sleep_timer_state", """{"type":"time"}""").apply()
53
+ assertNotNull(prefs.getString("sleep_timer_state", null))
54
+
55
+ prefs.edit().remove("sleep_timer_state").apply()
56
+ assertNull(prefs.getString("sleep_timer_state", null))
57
+ }
58
+ }