@rntp/player 5.0.0-beta.4 → 5.0.0-beta.5
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.
- package/android/build.gradle +7 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/SleepTimerController.kt +128 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerModule.kt +40 -0
- package/android/src/main/java/com/doublesymmetry/trackplayer/TrackPlayerPlaybackService.kt +99 -87
- package/android/src/main/java/com/doublesymmetry/trackplayer/models/PlayerConfig.kt +12 -1
- package/android/src/test/java/com/doublesymmetry/trackplayer/ExoPlayerIntegrationTest.kt +319 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerIntegrationTest.kt +473 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/SleepTimerStateTest.kt +58 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseNavigationTest.kt +215 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/BrowseTreeTest.kt +166 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/EmitEventTest.kt +68 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/PlayerConfigTest.kt +400 -0
- package/android/src/test/java/com/doublesymmetry/trackplayer/models/TrackPlayerMediaItemTest.kt +380 -0
- package/android/src/test/resources/robolectric.properties +1 -0
- package/ios/TrackPlayer.swift +46 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +46 -32
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +36 -21
- package/ios/player/CacheProxyServer.swift +429 -0
- package/ios/player/DownloadCoordinator.swift +242 -0
- package/ios/player/Preloader.swift +21 -90
- package/ios/player/SleepTimerController.swift +147 -0
- package/ios/tests/AVPlayerEngineIntegrationTests.swift +230 -0
- package/ios/tests/AudioPlayerTests.swift +6 -0
- package/ios/tests/CacheProxyServerTests.swift +403 -0
- package/ios/tests/DownloadCoordinatorTests.swift +197 -0
- package/ios/tests/LocalAudioServer.swift +171 -0
- package/ios/tests/MockPlayerEngine.swift +1 -0
- package/ios/tests/QueueManagerTests.swift +6 -0
- package/ios/tests/SleepTimerIntegrationTests.swift +408 -0
- package/ios/tests/SleepTimerTests.swift +70 -0
- package/lib/commonjs/NativeTrackPlayer.js.map +1 -1
- package/lib/commonjs/audio.js +19 -0
- package/lib/commonjs/audio.js.map +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js +1 -1
- package/lib/commonjs/interfaces/PlayerConfig.js.map +1 -1
- package/lib/module/NativeTrackPlayer.js.map +1 -1
- package/lib/module/audio.js +17 -0
- package/lib/module/audio.js.map +1 -1
- package/lib/module/interfaces/PlayerConfig.js +1 -1
- package/lib/module/interfaces/PlayerConfig.js.map +1 -1
- package/lib/typescript/src/NativeTrackPlayer.d.ts +2 -0
- package/lib/typescript/src/NativeTrackPlayer.d.ts.map +1 -1
- package/lib/typescript/src/audio.d.ts +12 -1
- package/lib/typescript/src/audio.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts +4 -1
- package/lib/typescript/src/interfaces/MediaItem.d.ts.map +1 -1
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts +19 -2
- package/lib/typescript/src/interfaces/PlayerConfig.d.ts.map +1 -1
- package/package.json +4 -1
- package/src/NativeTrackPlayer.ts +4 -0
- package/src/audio.ts +18 -0
- package/src/interfaces/MediaItem.ts +4 -1
- package/src/interfaces/PlayerConfig.ts +22 -3
- 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
|
+
}
|