@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,171 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import Foundation
7
+ import Network
8
+
9
+ /// A minimal HTTP server for integration tests. Serves a single audio file
10
+ /// with configurable response headers — e.g. with or without `Content-Length`.
11
+ final class LocalAudioServer {
12
+
13
+ struct Options {
14
+ /// When `false`, the `Content-Length` header is omitted from the response.
15
+ var includeContentLength: Bool = true
16
+ /// MIME type for the Content-Type header.
17
+ var contentType: String = "audio/wav"
18
+ /// If true, sends the response body in small chunks with delays to simulate slow network.
19
+ var simulateSlowNetwork: Bool = false
20
+ }
21
+
22
+ private let listener: NWListener
23
+ private let audioData: Data
24
+ private let options: Options
25
+ private var connections: [NWConnection] = []
26
+
27
+ /// The `http://localhost:<port>` URL for the served file.
28
+ var url: URL {
29
+ URL(string: "http://localhost:\(listener.port!.rawValue)/audio.wav")!
30
+ }
31
+
32
+ init(audioData: Data, options: Options = Options()) throws {
33
+ self.audioData = audioData
34
+ self.options = options
35
+
36
+ let params = NWParameters.tcp
37
+ listener = try NWListener(using: params, on: .any)
38
+ }
39
+
40
+ func start() {
41
+ let ready = DispatchSemaphore(value: 0)
42
+ listener.stateUpdateHandler = { state in
43
+ if case .ready = state { ready.signal() }
44
+ }
45
+ listener.newConnectionHandler = { [weak self] conn in
46
+ self?.handleConnection(conn)
47
+ }
48
+ listener.start(queue: .global(qos: .utility))
49
+ ready.wait()
50
+ }
51
+
52
+ func stop() {
53
+ for conn in connections { conn.cancel() }
54
+ connections.removeAll()
55
+ listener.cancel()
56
+ }
57
+
58
+ // MARK: - Private
59
+
60
+ private func handleConnection(_ connection: NWConnection) {
61
+ connections.append(connection)
62
+ connection.start(queue: .global(qos: .utility))
63
+
64
+ // Read the full HTTP request (up to 8 KB is plenty for test requests).
65
+ connection.receive(minimumIncompleteLength: 1, maximumLength: 8192) { [weak self] data, _, _, _ in
66
+ guard let self = self, let data = data else { return }
67
+ let rangeStart = self.parseRangeHeader(data)
68
+ self.sendResponse(on: connection, rangeStart: rangeStart)
69
+ }
70
+ }
71
+
72
+ /// Parses a `Range: bytes=N-` header from the raw HTTP request data.
73
+ /// Returns the byte offset `N`, or `nil` if no Range header is present.
74
+ private func parseRangeHeader(_ requestData: Data) -> Int? {
75
+ guard let requestString = String(data: requestData, encoding: .utf8) else { return nil }
76
+ for line in requestString.components(separatedBy: "\r\n") {
77
+ let lower = line.lowercased()
78
+ guard lower.hasPrefix("range:") else { continue }
79
+ // Expect format: "Range: bytes=N-" or "Range: bytes=N-M"
80
+ let value = line.dropFirst("range:".count).trimmingCharacters(in: .whitespaces)
81
+ guard value.hasPrefix("bytes=") else { return nil }
82
+ let byteRange = value.dropFirst("bytes=".count)
83
+ // Take the part before the dash to get the start offset.
84
+ let startString = byteRange.components(separatedBy: "-").first ?? ""
85
+ return Int(startString)
86
+ }
87
+ return nil
88
+ }
89
+
90
+ private func sendResponse(on connection: NWConnection, rangeStart: Int? = nil) {
91
+ if let start = rangeStart {
92
+ // Partial content response (HTTP 206).
93
+ let slice = audioData.suffix(from: min(start, audioData.count))
94
+ let end = audioData.count - 1
95
+ var header = "HTTP/1.1 206 Partial Content\r\n"
96
+ header += "Content-Type: \(options.contentType)\r\n"
97
+ header += "Content-Range: bytes \(start)-\(end)/\(audioData.count)\r\n"
98
+ header += "Content-Length: \(slice.count)\r\n"
99
+ header += "Connection: close\r\n"
100
+ header += "\r\n"
101
+
102
+ var response = Data(header.utf8)
103
+ response.append(slice)
104
+
105
+ connection.send(content: response, completion: .contentProcessed { _ in
106
+ connection.cancel()
107
+ })
108
+ } else if options.includeContentLength {
109
+ var header = "HTTP/1.1 200 OK\r\n"
110
+ header += "Content-Type: \(options.contentType)\r\n"
111
+ header += "Content-Length: \(audioData.count)\r\n"
112
+ header += "Connection: close\r\n"
113
+ header += "\r\n"
114
+
115
+ if options.simulateSlowNetwork {
116
+ // Send headers first, then body in chunks with delays.
117
+ connection.send(content: Data(header.utf8), completion: .contentProcessed { _ in })
118
+ sendSlowly(audioData, on: connection)
119
+ } else {
120
+ var response = Data(header.utf8)
121
+ response.append(audioData)
122
+ connection.send(content: response, completion: .contentProcessed { _ in
123
+ connection.cancel()
124
+ })
125
+ }
126
+ } else {
127
+ // Use chunked transfer encoding so AVPlayer can determine
128
+ // body boundaries without a Content-Length header.
129
+ var header = "HTTP/1.1 200 OK\r\n"
130
+ header += "Content-Type: \(options.contentType)\r\n"
131
+ header += "Transfer-Encoding: chunked\r\n"
132
+ header += "\r\n"
133
+
134
+ var response = Data(header.utf8)
135
+ // Single chunk: hex size + CRLF + data + CRLF
136
+ response.append(Data(String(audioData.count, radix: 16).utf8))
137
+ response.append(Data("\r\n".utf8))
138
+ response.append(audioData)
139
+ response.append(Data("\r\n".utf8))
140
+ // Final zero-length chunk
141
+ response.append(Data("0\r\n\r\n".utf8))
142
+
143
+ connection.send(content: response, completion: .contentProcessed { _ in
144
+ connection.cancel()
145
+ })
146
+ }
147
+ }
148
+
149
+ /// Sends data in 64KB chunks with 10ms delays between each, then closes.
150
+ private func sendSlowly(_ data: Data, on connection: NWConnection) {
151
+ let chunkSize = 64 * 1024
152
+ var offset = 0
153
+
154
+ func sendNext() {
155
+ guard offset < data.count else {
156
+ connection.cancel()
157
+ return
158
+ }
159
+ let end = min(offset + chunkSize, data.count)
160
+ let chunk = data[offset..<end]
161
+ offset = end
162
+ connection.send(content: chunk, completion: .contentProcessed { _ in
163
+ DispatchQueue.global().asyncAfter(deadline: .now() + .milliseconds(10)) {
164
+ sendNext()
165
+ }
166
+ })
167
+ }
168
+
169
+ sendNext()
170
+ }
171
+ }
@@ -24,6 +24,7 @@ class MockPlayerEngine: PlayerEngine {
24
24
  var onItemPlayedToEnd: (() -> Void)?
25
25
  var onDurationChange: (() -> Void)?
26
26
  var onTimedMetadata: ((_ metadata: StreamMetadata) -> Void)?
27
+ var onAssetMetadata: ((_ metadata: StreamMetadata) -> Void)?
27
28
 
28
29
  // MARK: - Call Tracking
29
30
 
@@ -22,6 +22,12 @@ private struct MockItem: AudioItem {
22
22
  self.sourceUrl = id
23
23
  self.title = id
24
24
  }
25
+
26
+ #if canImport(UIKit)
27
+ func getArtwork(_ handler: @escaping (UIImage?) -> Void) {
28
+ handler(nil)
29
+ }
30
+ #endif
25
31
  }
26
32
 
27
33
  private func items(_ ids: String...) -> [AudioItem] {
@@ -0,0 +1,408 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import XCTest
7
+
8
+ @testable import PlayerCore
9
+
10
+ // MARK: - Sleep Timer Integration Tests
11
+ //
12
+ // These tests exercise the complete sleep timer flow using the real production
13
+ // SleepTimerController with AudioPlayer + MockPlayerEngine, giving us observable
14
+ // side-effects without requiring the React Native runtime.
15
+ //
16
+ // The three bugs caught during manual testing are guarded by explicit test cases:
17
+ // 1. Timer not firing (RunLoop) — covered by tick-count / pause assertions.
18
+ // 2. Volume not restoring after fade — testVolumeRestoredAfterFadeCompletes.
19
+ // 3. Pause overridden during media-item transition — testMediaItemTimerPausesAfterTargetIndex.
20
+
21
+ // MARK: - MockItem helper
22
+
23
+ private struct TestItem: AudioItem {
24
+ var sourceUrl: String
25
+ var sourceType: SourceType = .stream
26
+ var headers: [String: String]? = nil
27
+ var isLive: Bool = false
28
+ var title: String?
29
+ var artist: String? = nil
30
+ var albumTitle: String? = nil
31
+
32
+ init(_ id: String) {
33
+ self.sourceUrl = "https://example.com/\(id).mp3"
34
+ self.title = id
35
+ }
36
+
37
+ #if canImport(UIKit)
38
+ func getArtwork(_ handler: @escaping (UIImage?) -> Void) {
39
+ handler(nil)
40
+ }
41
+ #endif
42
+ }
43
+
44
+ private func trackItem(_ id: String) -> TestItem { TestItem(id) }
45
+
46
+ // MARK: - SleepTimerIntegrationTests
47
+
48
+ final class SleepTimerIntegrationTests: XCTestCase {
49
+
50
+ private var engine: MockPlayerEngine!
51
+ private var player: AudioPlayer!
52
+ private var controller: SleepTimerController!
53
+ private var triggeredTypes: [String]!
54
+
55
+ override func setUp() {
56
+ super.setUp()
57
+ engine = MockPlayerEngine()
58
+ player = AudioPlayer(engine: engine)
59
+ controller = SleepTimerController(player: player)
60
+ triggeredTypes = []
61
+ controller.onTriggered = { [weak self] type in
62
+ self?.triggeredTypes.append(type)
63
+ }
64
+ }
65
+
66
+ // MARK: - Time-Based: Basic Countdown
67
+
68
+ func testTimerPausesAfterCountdownReachesZero() {
69
+ controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 0)
70
+
71
+ controller.tick() // 2 remaining
72
+ XCTAssertEqual(engine.pauseCallCount, 0, "should not pause before countdown ends")
73
+
74
+ controller.tick() // 1 remaining
75
+ XCTAssertEqual(engine.pauseCallCount, 0)
76
+
77
+ controller.tick() // 0 remaining — fires
78
+ XCTAssertEqual(engine.pauseCallCount, 1, "pause must be called when timer reaches 0")
79
+ XCTAssertEqual(triggeredTypes, ["time"])
80
+ }
81
+
82
+ func testTimerStateIsNilAfterFiring() {
83
+ controller.sleepAfterTime(seconds: 2, fadeOutSeconds: 0)
84
+ controller.tick()
85
+ controller.tick()
86
+
87
+ XCTAssertNil(controller.getState(), "getState must return nil after the timer fires")
88
+ }
89
+
90
+ // MARK: - Time-Based: Zero-Second Immediate Pause
91
+
92
+ func testTimerWithZeroSecondsPausesImmediately() {
93
+ controller.sleepAfterTime(seconds: 0, fadeOutSeconds: 0)
94
+
95
+ XCTAssertEqual(engine.pauseCallCount, 1, "0-second timer must pause immediately without any ticks")
96
+ XCTAssertEqual(triggeredTypes, ["time"])
97
+ XCTAssertNil(controller.getState())
98
+ }
99
+
100
+ // MARK: - Time-Based: Fade-Out
101
+
102
+ func testFadeOutReducesVolumeEachTick() {
103
+ player.volume = 1.0
104
+ // 5 seconds total, 5-second fade — entire duration fades
105
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 5)
106
+
107
+ var volumes: [Float] = [player.volume] // 1.0 before any tick
108
+
109
+ for _ in 0..<4 {
110
+ controller.tick()
111
+ volumes.append(player.volume)
112
+ }
113
+
114
+ // Verify each tick reduces volume
115
+ for i in 1..<volumes.count {
116
+ XCTAssertLessThan(volumes[i], volumes[i - 1],
117
+ "volume at tick \(i) (\(volumes[i])) should be lower than tick \(i-1) (\(volumes[i-1]))")
118
+ }
119
+ }
120
+
121
+ func testFadeOutVolumeReachesZeroOnFinalTick() {
122
+ player.volume = 1.0
123
+ controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 3)
124
+
125
+ controller.tick() // 2 remaining — volume = 2/3
126
+ controller.tick() // 1 remaining — volume = 1/3
127
+ controller.tick() // 0 remaining — volume = 0, then pause, then restore
128
+
129
+ // After the timer fires, cancelInternal(restoreVolume: true) restores volume
130
+ XCTAssertEqual(engine.volume, 1.0, accuracy: 0.0001,
131
+ "volume must be restored to pre-fade value after timer fires")
132
+ XCTAssertEqual(engine.pauseCallCount, 1)
133
+ }
134
+
135
+ func testVolumeRestoredAfterFadeCompletes() {
136
+ // This guards bug #2: volume not restored after fade completes.
137
+ let initialVolume: Float = 0.8
138
+ player.volume = initialVolume
139
+ controller.sleepAfterTime(seconds: 4, fadeOutSeconds: 4)
140
+
141
+ // Tick through all 4 seconds
142
+ for _ in 0..<4 { controller.tick() }
143
+
144
+ XCTAssertEqual(engine.volume, initialVolume, accuracy: 0.0001,
145
+ "pre-fade volume must be restored after timer fires so next playback is not muted")
146
+ }
147
+
148
+ func testNonFullVolumeFadeUsesPreFadeVolumeAsCeiling() {
149
+ player.volume = 0.6
150
+ controller.sleepAfterTime(seconds: 4, fadeOutSeconds: 4)
151
+
152
+ controller.tick() // 3 remaining, progress = 3/4 → expected volume = 0.6 * 0.75 = 0.45
153
+ XCTAssertEqual(engine.volume, 0.6 * 0.75, accuracy: 0.0001,
154
+ "fade must scale from pre-fade volume, not from 1.0")
155
+ }
156
+
157
+ // MARK: - Time-Based: Cancel Mid-Fade
158
+
159
+ func testCancelMidFadeRestoresVolume() {
160
+ let initialVolume: Float = 1.0
161
+ player.volume = initialVolume
162
+ controller.sleepAfterTime(seconds: 10, fadeOutSeconds: 10)
163
+
164
+ controller.tick() // volume decreasing
165
+ controller.tick() // volume decreasing
166
+ let volumeDuringFade = player.volume
167
+ XCTAssertLessThan(volumeDuringFade, initialVolume,
168
+ "volume should have decreased before cancel")
169
+
170
+ controller.cancel()
171
+
172
+ XCTAssertEqual(engine.volume, initialVolume, accuracy: 0.0001,
173
+ "cancelling mid-fade must restore pre-fade volume")
174
+ XCTAssertNil(controller.getState())
175
+ XCTAssertEqual(engine.pauseCallCount, 0, "cancel must not pause")
176
+ }
177
+
178
+ func testCancelWithNoFadeDoesNotChangePauseCount() {
179
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
180
+ controller.tick()
181
+
182
+ controller.cancel()
183
+
184
+ XCTAssertEqual(engine.pauseCallCount, 0)
185
+ XCTAssertNil(controller.getState())
186
+ }
187
+
188
+ // MARK: - Time-Based: FadeOutSeconds > Seconds Clamping
189
+
190
+ func testFadeOutSecondsClampedToTimerDuration() {
191
+ // fadeOutSeconds > seconds: production clamps to seconds
192
+ controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 10)
193
+
194
+ XCTAssertEqual(controller.sleepTimerFadeOutSeconds, 3,
195
+ "fadeOutSeconds must be clamped to timer duration when it exceeds it")
196
+ }
197
+
198
+ func testClampedFadeOutStillProducesMonotonicDecrease() {
199
+ player.volume = 1.0
200
+ // Requesting 10s fade on 3s timer → effectively 3s fade
201
+ controller.sleepAfterTime(seconds: 3, fadeOutSeconds: 10)
202
+
203
+ controller.tick() // 2 remaining
204
+ let v1 = player.volume
205
+ controller.tick() // 1 remaining
206
+ let v2 = player.volume
207
+ controller.tick() // 0 remaining — fires
208
+
209
+ XCTAssertLessThan(v1, 1.0, "fade should have started on first tick")
210
+ XCTAssertLessThan(v2, v1, "volume should keep decreasing")
211
+ XCTAssertEqual(engine.pauseCallCount, 1, "timer must still fire after clamped fade")
212
+ }
213
+
214
+ // MARK: - Time-Based: getState State
215
+
216
+ func testGetStateReturnsTimeState() {
217
+ controller.sleepAfterTime(seconds: 30, fadeOutSeconds: 10)
218
+
219
+ let state = controller.getState()
220
+ XCTAssertNotNil(state)
221
+ XCTAssertEqual(state?["type"] as? String, "time")
222
+ XCTAssertEqual(state?["remainingSeconds"] as? Double, 30)
223
+ XCTAssertEqual(state?["fadeOutSeconds"] as? Double, 10)
224
+ }
225
+
226
+ func testGetStateDecreasesAfterTick() {
227
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
228
+ controller.tick()
229
+
230
+ let state = controller.getState()
231
+ XCTAssertEqual(state?["remainingSeconds"] as? Double, 4)
232
+ }
233
+
234
+ func testGetStateReturnsNilAfterCancel() {
235
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
236
+ controller.cancel()
237
+
238
+ XCTAssertNil(controller.getState())
239
+ }
240
+
241
+ // MARK: - MediaItem-Based: Forward Transition
242
+
243
+ func testMediaItemTimerPausesAfterTargetIndex() {
244
+ // This guards bug #3: pause being overridden during media-item transitions.
245
+ // Target is index 1; previous is 1; when we transition to index 2 it should fire.
246
+ player.add(items: [trackItem("a"), trackItem("b"), trackItem("c")])
247
+ controller.sleepAfterMediaItemAtIndex(index: 1)
248
+
249
+ // Simulate the player being at index 1 (set previousIndex via handleItemTransition)
250
+ // The controller captures currentIndex on init — engine starts at index 0 after add.
251
+ // We need to bring previousIndex to 1 first.
252
+ _ = controller.handleItemTransition(to: 1) // 0→1: arrives at target, no fire
253
+ let fired = controller.handleItemTransition(to: 2) // 1→2: departs target, fires
254
+
255
+ XCTAssertTrue(fired, "sleep timer should fire when transitioning past target index")
256
+ XCTAssertEqual(triggeredTypes, ["mediaItem"])
257
+ XCTAssertNil(controller.getState(), "timer must be cleared after firing")
258
+ }
259
+
260
+ func testMediaItemTimerDoesNotPauseOnSameIndex() {
261
+ player.add(items: [trackItem("a"), trackItem("b")])
262
+ controller.sleepAfterMediaItemAtIndex(index: 1)
263
+
264
+ // Transition from current (0) → 1 (arriving at target, not leaving it)
265
+ let fired = controller.handleItemTransition(to: 1)
266
+
267
+ XCTAssertFalse(fired, "arriving at target index must not fire the timer")
268
+ XCTAssertEqual(triggeredTypes, [])
269
+ }
270
+
271
+ func testMediaItemTimerDoesNotPauseOnBackwardSkip() {
272
+ // The production code fires whenever previousIndex == targetIndex && newIndex != targetIndex.
273
+ // A skip-backward from target DOES fire — so this test verifies the current documented
274
+ // behaviour: the timer fires on any forward OR backward departure from the target index.
275
+ player.add(items: [trackItem("a"), trackItem("b"), trackItem("c")])
276
+ controller.sleepAfterMediaItemAtIndex(index: 2)
277
+
278
+ // Bring previousIndex to 2
279
+ _ = controller.handleItemTransition(to: 1) // 0→1
280
+ _ = controller.handleItemTransition(to: 2) // 1→2: arrives at target
281
+
282
+ // Transition to a LOWER index (skip backward from 2 to 1)
283
+ let fired = controller.handleItemTransition(to: 1)
284
+ XCTAssertTrue(fired, "departing the target index (even backward) fires the timer per production logic")
285
+ XCTAssertEqual(triggeredTypes, ["mediaItem"])
286
+ }
287
+
288
+ func testMediaItemTimerDoesNotPauseBeforeReachingTarget() {
289
+ player.add(items: [trackItem("a"), trackItem("b"), trackItem("c"), trackItem("d")])
290
+ // Target is 2; controller captures currentIndex = 0 on sleepAfterMediaItemAtIndex
291
+ controller.sleepAfterMediaItemAtIndex(index: 2)
292
+
293
+ // Transition 0 → 1 (pre-target)
294
+ let firedAt1 = controller.handleItemTransition(to: 1)
295
+ XCTAssertFalse(firedAt1, "must not fire when previous (0) ≠ target (2)")
296
+ XCTAssertEqual(triggeredTypes, [])
297
+
298
+ // Transition 1 → 2 (arriving at target)
299
+ let firedAt2 = controller.handleItemTransition(to: 2)
300
+ XCTAssertFalse(firedAt2, "must not fire when arriving at target")
301
+ XCTAssertEqual(triggeredTypes, [])
302
+
303
+ // Transition 2 → 3 (leaving target — fires)
304
+ let firedAt3 = controller.handleItemTransition(to: 3)
305
+ XCTAssertTrue(firedAt3, "must fire when leaving target index")
306
+ XCTAssertEqual(triggeredTypes, ["mediaItem"])
307
+ }
308
+
309
+ // MARK: - MediaItem-Based: Cancel
310
+
311
+ func testCancelMediaItemTimerPreventsSubsequentPause() {
312
+ player.add(items: [trackItem("a"), trackItem("b")])
313
+ controller.sleepAfterMediaItemAtIndex(index: 0)
314
+ controller.cancel()
315
+
316
+ let fired = controller.handleItemTransition(to: 1)
317
+
318
+ XCTAssertFalse(fired, "cancelled timer must not fire on transition")
319
+ XCTAssertEqual(triggeredTypes, [])
320
+ }
321
+
322
+ // MARK: - MediaItem-Based: getState State
323
+
324
+ func testGetStateReturnsMediaItemState() {
325
+ controller.sleepAfterMediaItemAtIndex(index: 3)
326
+
327
+ let state = controller.getState()
328
+ XCTAssertNotNil(state)
329
+ XCTAssertEqual(state?["type"] as? String, "mediaItem")
330
+ XCTAssertEqual(state?["index"] as? Int, 3)
331
+ }
332
+
333
+ func testGetStateReturnsNilAfterMediaItemFires() {
334
+ player.add(items: [trackItem("a"), trackItem("b")])
335
+ controller.sleepAfterMediaItemAtIndex(index: 0)
336
+ // previousIndex = 0 (currentIndex at setup), transition to 1 fires
337
+ _ = controller.handleItemTransition(to: 1)
338
+
339
+ XCTAssertNil(controller.getState())
340
+ }
341
+
342
+ // MARK: - Interaction: Last-One-Wins
343
+
344
+ func testSettingTimeTimerCancelsMediaItemTimer() {
345
+ controller.sleepAfterMediaItemAtIndex(index: 1)
346
+ XCTAssertEqual(controller.getState()?["type"] as? String, "mediaItem")
347
+
348
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
349
+
350
+ XCTAssertEqual(controller.getState()?["type"] as? String, "time",
351
+ "setting a time timer must replace the active mediaItem timer")
352
+ }
353
+
354
+ func testSettingMediaItemTimerCancelsTimeTimer() {
355
+ controller.sleepAfterTime(seconds: 10, fadeOutSeconds: 0)
356
+ XCTAssertEqual(controller.getState()?["type"] as? String, "time")
357
+
358
+ controller.sleepAfterMediaItemAtIndex(index: 2)
359
+
360
+ XCTAssertEqual(controller.getState()?["type"] as? String, "mediaItem",
361
+ "setting a mediaItem timer must replace the active time timer")
362
+ }
363
+
364
+ func testReplacingTimeTimerWithTimeTimerRestoresVolume() {
365
+ // First timer with fade is replaced mid-fade; volume must be restored.
366
+ player.volume = 1.0
367
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 5)
368
+ controller.tick() // volume drops
369
+ controller.tick() // volume drops more
370
+ let volumeDuringFade = player.volume
371
+ XCTAssertLessThan(volumeDuringFade, 1.0)
372
+
373
+ // Replace with a new timer
374
+ controller.sleepAfterTime(seconds: 10, fadeOutSeconds: 0)
375
+
376
+ XCTAssertEqual(engine.volume, 1.0, accuracy: 0.0001,
377
+ "replacing a fading time timer must restore volume before starting the new one")
378
+ }
379
+
380
+ func testReplacingMediaItemTimerWithTimeTimerRestoresVolume() {
381
+ player.volume = 1.0
382
+ controller.sleepAfterMediaItemAtIndex(index: 1)
383
+
384
+ // Replace with a time timer
385
+ controller.sleepAfterTime(seconds: 5, fadeOutSeconds: 0)
386
+
387
+ // No volume was changed by mediaItem timer, but cancellation should be clean
388
+ XCTAssertEqual(engine.volume, 1.0, accuracy: 0.0001)
389
+ XCTAssertEqual(controller.getState()?["type"] as? String, "time")
390
+ }
391
+
392
+ // MARK: - State Invariants
393
+
394
+ func testTimerTypeIsNilInitially() {
395
+ XCTAssertNil(controller.getState())
396
+ }
397
+
398
+ func testMultipleTicksBeyondZeroDoNotCallPauseMoreThanOnce() {
399
+ controller.sleepAfterTime(seconds: 1, fadeOutSeconds: 0)
400
+ controller.tick() // fires at 0
401
+
402
+ // Timer is cancelled after firing; further ticks are no-ops
403
+ controller.tick()
404
+ controller.tick()
405
+
406
+ XCTAssertEqual(engine.pauseCallCount, 1, "pause must be called exactly once")
407
+ }
408
+ }
@@ -0,0 +1,70 @@
1
+ //
2
+ // Copyright (c) Double Symmetry GmbH
3
+ // Commercial use requires a license. See https://rntp.dev/pricing
4
+ //
5
+
6
+ import XCTest
7
+
8
+ @testable import PlayerCore
9
+
10
+ // MARK: - Sleep Timer AudioPlayer Volume Clamping Tests
11
+ //
12
+ // The sleep timer sets and restores volume through AudioPlayer.volume, which
13
+ // clamps values to [0, 1]. These tests verify that clamping so the fade can
14
+ // never accidentally leave the player in an invalid state.
15
+ //
16
+ // The fade formula and state-machine logic are covered by SleepTimerIntegrationTests,
17
+ // which test the real SleepTimerController with AudioPlayer + MockPlayerEngine.
18
+
19
+ final class SleepTimerTests: XCTestCase {
20
+
21
+ // MARK: - AudioPlayer Volume Clamping
22
+
23
+ private var engine: MockPlayerEngine!
24
+ private var player: AudioPlayer!
25
+
26
+ override func setUp() {
27
+ super.setUp()
28
+ engine = MockPlayerEngine()
29
+ player = AudioPlayer(engine: engine)
30
+ }
31
+
32
+ func testPlayerVolumeClampedAboveOne() {
33
+ player.volume = 2.0
34
+ XCTAssertEqual(engine.volume, 1.0,
35
+ "sleep timer setting volume above 1.0 must be clamped")
36
+ }
37
+
38
+ func testPlayerVolumeClampedBelowZero() {
39
+ player.volume = -0.5
40
+ XCTAssertEqual(engine.volume, 0.0,
41
+ "sleep timer setting volume below 0 must be clamped")
42
+ }
43
+
44
+ func testPlayerVolumeAtZeroIsExactlyZero() {
45
+ player.volume = 0.0
46
+ XCTAssertEqual(engine.volume, 0.0)
47
+ }
48
+
49
+ func testPlayerVolumeAtOneIsExactlyOne() {
50
+ player.volume = 1.0
51
+ XCTAssertEqual(engine.volume, 1.0)
52
+ }
53
+
54
+ func testPlayerVolumeSetToFadedValue() {
55
+ // Simulate what the sleep timer does: set a mid-fade volume (0.8 * 0.25 = 0.2)
56
+ let fadedVolume: Float = 0.8 * Float(5.0 / 20.0)
57
+ player.volume = fadedVolume
58
+ XCTAssertEqual(engine.volume, fadedVolume, accuracy: 0.0001)
59
+ }
60
+
61
+ func testPlayerVolumeRestoredAfterFade() {
62
+ // Simulate: pre-fade volume captured at 0.8, fade runs to 0, then restore.
63
+ let preFadeVolume: Float = 0.8
64
+ player.volume = 0.0 // volume after fade completes
65
+ XCTAssertEqual(engine.volume, 0.0)
66
+
67
+ player.volume = preFadeVolume // sleep timer restore on cancel/completion
68
+ XCTAssertEqual(engine.volume, preFadeVolume, accuracy: 0.0001)
69
+ }
70
+ }
@@ -1 +1 @@
1
- {"version":3,"names":["_reactNative","require","_default","exports","default","TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeTrackPlayer.ts"],"mappings":";;;;;;AAMA,IAAAA,YAAA,GAAAC,OAAA;AANA;AACA;AACA;AACA;AAHA,IAAAC,QAAA,GAAAC,OAAA,CAAAC,OAAA,GA4FeC,gCAAmB,CAACC,YAAY,CAAO,aAAa,CAAC","ignoreList":[]}
1
+ {"version":3,"names":["_reactNative","require","_default","exports","default","TurboModuleRegistry","getEnforcing"],"sourceRoot":"../../src","sources":["NativeTrackPlayer.ts"],"mappings":";;;;;;AAMA,IAAAA,YAAA,GAAAC,OAAA;AANA;AACA;AACA;AACA;AAHA,IAAAC,QAAA,GAAAC,OAAA,CAAAC,OAAA,GAgGeC,gCAAmB,CAACC,YAAY,CAAO,aAAa,CAAC","ignoreList":[]}