@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.
- 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 +47 -101
- package/ios/TrackPlayerBridge.mm +2 -0
- package/ios/player/AVPlayerEngine.swift +47 -35
- package/ios/player/AudioCache.swift +34 -0
- package/ios/player/AudioPlayer.swift +70 -22
- 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 +21 -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 +24 -3
- 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,
|
|
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":[]}
|