expo-gliph-player 1.0.0
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/LICENSE +21 -0
- package/README.md +348 -0
- package/android/build.gradle +82 -0
- package/android/src/main/AndroidManifest.xml +31 -0
- package/android/src/main/java/com/gliphplayer/DeviceInfoModule.kt +48 -0
- package/android/src/main/java/com/gliphplayer/DeviceInfoPackage.kt +16 -0
- package/android/src/main/java/com/gliphplayer/GliphPlayerModule.kt +360 -0
- package/android/src/main/java/com/gliphplayer/GliphPlayerPackage.kt +48 -0
- package/android/src/main/java/com/gliphplayer/GliphPlayerService.kt +797 -0
- package/android/src/main/res/xml/automotive_app_desc.xml +4 -0
- package/android/src/oldarch/com/gliphplayer/NativeGliphPlayerSpec.kt +56 -0
- package/app.plugin.js +1 -0
- package/expo-gliph-player.podspec +91 -0
- package/ios/GliphAudioPlayer.h +77 -0
- package/ios/GliphAudioPlayer.swift +697 -0
- package/ios/GliphPlayer-Bridging-Header.h +3 -0
- package/ios/GliphPlayerModule.h +12 -0
- package/ios/GliphPlayerModule.mm +243 -0
- package/ios/expo_gliph_player.h +22 -0
- package/lib/commonjs/GliphPlayer.js +310 -0
- package/lib/commonjs/GliphPlayer.js.map +1 -0
- package/lib/commonjs/hooks.js +233 -0
- package/lib/commonjs/hooks.js.map +1 -0
- package/lib/commonjs/index.js +166 -0
- package/lib/commonjs/index.js.map +1 -0
- package/lib/commonjs/package.json +1 -0
- package/lib/commonjs/specs/NativeGliphPlayer.js +19 -0
- package/lib/commonjs/specs/NativeGliphPlayer.js.map +1 -0
- package/lib/commonjs/types.js +175 -0
- package/lib/commonjs/types.js.map +1 -0
- package/lib/module/GliphPlayer.js +305 -0
- package/lib/module/GliphPlayer.js.map +1 -0
- package/lib/module/hooks.js +221 -0
- package/lib/module/hooks.js.map +1 -0
- package/lib/module/index.js +20 -0
- package/lib/module/index.js.map +1 -0
- package/lib/module/package.json +1 -0
- package/lib/module/specs/NativeGliphPlayer.js +20 -0
- package/lib/module/specs/NativeGliphPlayer.js.map +1 -0
- package/lib/module/types.js +181 -0
- package/lib/module/types.js.map +1 -0
- package/lib/typescript/src/GliphPlayer.d.ts +113 -0
- package/lib/typescript/src/GliphPlayer.d.ts.map +1 -0
- package/lib/typescript/src/hooks.d.ts +51 -0
- package/lib/typescript/src/hooks.d.ts.map +1 -0
- package/lib/typescript/src/index.d.ts +11 -0
- package/lib/typescript/src/index.d.ts.map +1 -0
- package/lib/typescript/src/specs/NativeGliphPlayer.d.ts +80 -0
- package/lib/typescript/src/specs/NativeGliphPlayer.d.ts.map +1 -0
- package/lib/typescript/src/types.d.ts +348 -0
- package/lib/typescript/src/types.d.ts.map +1 -0
- package/package.json +62 -0
- package/src/GliphPlayer.ts +356 -0
- package/src/hooks.ts +256 -0
- package/src/index.ts +61 -0
- package/src/specs/NativeGliphPlayer.ts +105 -0
- package/src/types.ts +395 -0
- package/src/withGliphPlayer.js +166 -0
|
@@ -0,0 +1,697 @@
|
|
|
1
|
+
import AVFoundation
|
|
2
|
+
import MediaPlayer
|
|
3
|
+
import UIKit
|
|
4
|
+
|
|
5
|
+
// MARK: - Type aliases
|
|
6
|
+
|
|
7
|
+
public typealias EventEmitter = (String, [String: Any]?) -> Void
|
|
8
|
+
|
|
9
|
+
// MARK: - GliphAudioPlayer
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* GliphAudioPlayer
|
|
13
|
+
*
|
|
14
|
+
* Core Swift class that manages:
|
|
15
|
+
* - AVQueuePlayer for audio playback
|
|
16
|
+
* - MPRemoteCommandCenter for lock screen / headphone controls
|
|
17
|
+
* - MPNowPlayingInfoCenter for Now Playing metadata
|
|
18
|
+
* - Audio session configuration
|
|
19
|
+
* - Queue management
|
|
20
|
+
*/
|
|
21
|
+
@objc public class GliphAudioPlayer: NSObject {
|
|
22
|
+
|
|
23
|
+
// ── Properties ──────────────────────────────────────────────────────────────
|
|
24
|
+
|
|
25
|
+
private var player: AVQueuePlayer?
|
|
26
|
+
private var playerItems: [AVPlayerItem] = []
|
|
27
|
+
private var queue: [[String: Any]] = []
|
|
28
|
+
private var currentIndex: Int = -1
|
|
29
|
+
private var repeatMode: Int = 0 // 0=off, 1=track, 2=queue
|
|
30
|
+
private var isSetup = false
|
|
31
|
+
private var progressTimer: Timer?
|
|
32
|
+
private var progressInterval: TimeInterval = 1.0
|
|
33
|
+
private var options: [String: Any] = [:]
|
|
34
|
+
|
|
35
|
+
private let eventEmitter: EventEmitter
|
|
36
|
+
private var timeObserver: Any?
|
|
37
|
+
private var playerObservations: [NSKeyValueObservation] = []
|
|
38
|
+
|
|
39
|
+
// ── Init ────────────────────────────────────────────────────────────────────
|
|
40
|
+
|
|
41
|
+
@objc public init(eventEmitter: @escaping EventEmitter) {
|
|
42
|
+
self.eventEmitter = eventEmitter
|
|
43
|
+
super.init()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// ── Setup ───────────────────────────────────────────────────────────────────
|
|
47
|
+
|
|
48
|
+
@objc public func setupPlayer(
|
|
49
|
+
options: [String: Any],
|
|
50
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
51
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
52
|
+
) {
|
|
53
|
+
guard !isSetup else { resolve(nil); return }
|
|
54
|
+
|
|
55
|
+
self.options = options
|
|
56
|
+
self.progressInterval = (options["progressUpdateEventInterval"] as? Double) ?? 1.0
|
|
57
|
+
|
|
58
|
+
do {
|
|
59
|
+
let session = AVAudioSession.sharedInstance()
|
|
60
|
+
let category = mapIOSCategory(options["iosCategory"] as? String)
|
|
61
|
+
let mode = mapIOSMode(options["iosCategoryMode"] as? String)
|
|
62
|
+
let opts = mapIOSOptions(options["iosCategoryOptions"] as? [String])
|
|
63
|
+
|
|
64
|
+
try session.setCategory(category, mode: mode, options: opts)
|
|
65
|
+
try session.setActive(true)
|
|
66
|
+
} catch {
|
|
67
|
+
reject("setup_error", "Failed to configure audio session: \(error.localizedDescription)", error)
|
|
68
|
+
return
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
player = AVQueuePlayer()
|
|
72
|
+
player?.allowsExternalPlayback = false
|
|
73
|
+
player?.automaticallyWaitsToMinimizeStalling = (options["waitForBuffer"] as? Bool) ?? true
|
|
74
|
+
|
|
75
|
+
setupRemoteCommands()
|
|
76
|
+
setupNotificationObservers()
|
|
77
|
+
isSetup = true
|
|
78
|
+
resolve(nil)
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
@objc public func destroy() {
|
|
82
|
+
stopProgressTimer()
|
|
83
|
+
removeObservers()
|
|
84
|
+
player?.pause()
|
|
85
|
+
player?.removeAllItems()
|
|
86
|
+
player = nil
|
|
87
|
+
queue.removeAll()
|
|
88
|
+
playerItems.removeAll()
|
|
89
|
+
currentIndex = -1
|
|
90
|
+
isSetup = false
|
|
91
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
92
|
+
UIApplication.shared.endReceivingRemoteControlEvents()
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
@objc public func isReady() -> Bool { return isSetup }
|
|
96
|
+
|
|
97
|
+
// ── Queue management ────────────────────────────────────────────────────────
|
|
98
|
+
|
|
99
|
+
@objc public func addTracks(
|
|
100
|
+
_ tracks: [[String: Any]],
|
|
101
|
+
insertBeforeIndex: Int,
|
|
102
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
103
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
104
|
+
) {
|
|
105
|
+
let insertAt = (insertBeforeIndex < 0 || insertBeforeIndex > queue.count)
|
|
106
|
+
? queue.count
|
|
107
|
+
: insertBeforeIndex
|
|
108
|
+
|
|
109
|
+
for (i, track) in tracks.enumerated() {
|
|
110
|
+
queue.insert(track, at: insertAt + i)
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
rebuildPlayerQueue()
|
|
114
|
+
resolve(insertAt)
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@objc public func removeTracks(
|
|
118
|
+
_ trackIds: [String],
|
|
119
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
120
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
121
|
+
) {
|
|
122
|
+
let idSet = Set(trackIds)
|
|
123
|
+
queue.removeAll { idSet.contains($0["id"] as? String ?? "") }
|
|
124
|
+
rebuildPlayerQueue()
|
|
125
|
+
resolve(nil)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
@objc public func removeUpcomingTracks(
|
|
129
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
130
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
131
|
+
) {
|
|
132
|
+
guard currentIndex >= 0 else { resolve(nil); return }
|
|
133
|
+
queue = Array(queue.prefix(currentIndex + 1))
|
|
134
|
+
rebuildPlayerQueue()
|
|
135
|
+
resolve(nil)
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
@objc public func skipToIndex(
|
|
139
|
+
_ index: Int,
|
|
140
|
+
initialPosition: Double,
|
|
141
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
142
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
143
|
+
) {
|
|
144
|
+
guard index >= 0 && index < queue.count else {
|
|
145
|
+
reject("skip_error", "Index out of bounds", nil); return
|
|
146
|
+
}
|
|
147
|
+
currentIndex = index
|
|
148
|
+
rebuildPlayerQueue()
|
|
149
|
+
if initialPosition >= 0 {
|
|
150
|
+
player?.seek(to: CMTime(seconds: initialPosition, preferredTimescale: 1000))
|
|
151
|
+
}
|
|
152
|
+
player?.play()
|
|
153
|
+
resolve(nil)
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
@objc public func skipToNext(
|
|
157
|
+
initialPosition: Double,
|
|
158
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
159
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
160
|
+
) {
|
|
161
|
+
let next = currentIndex + 1
|
|
162
|
+
guard next < queue.count else {
|
|
163
|
+
reject("skip_error", "No next track", nil); return
|
|
164
|
+
}
|
|
165
|
+
skipToIndex(next, initialPosition: initialPosition, resolve: resolve, reject: reject)
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
@objc public func skipToPrevious(
|
|
169
|
+
initialPosition: Double,
|
|
170
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
171
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
172
|
+
) {
|
|
173
|
+
let prev = currentIndex - 1
|
|
174
|
+
guard prev >= 0 else {
|
|
175
|
+
reject("skip_error", "No previous track", nil); return
|
|
176
|
+
}
|
|
177
|
+
skipToIndex(prev, initialPosition: initialPosition, resolve: resolve, reject: reject)
|
|
178
|
+
}
|
|
179
|
+
|
|
180
|
+
@objc public func moveFrom(
|
|
181
|
+
_ fromIndex: Int,
|
|
182
|
+
toIndex: Int,
|
|
183
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
184
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
185
|
+
) {
|
|
186
|
+
guard fromIndex >= 0 && fromIndex < queue.count,
|
|
187
|
+
toIndex >= 0 && toIndex < queue.count else {
|
|
188
|
+
reject("move_error", "Index out of bounds", nil); return
|
|
189
|
+
}
|
|
190
|
+
let track = queue.remove(at: fromIndex)
|
|
191
|
+
queue.insert(track, at: toIndex)
|
|
192
|
+
rebuildPlayerQueue()
|
|
193
|
+
resolve(nil)
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
// ── Playback control ────────────────────────────────────────────────────────
|
|
197
|
+
|
|
198
|
+
@objc public func play(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
199
|
+
player?.play()
|
|
200
|
+
startProgressTimer()
|
|
201
|
+
resolve(nil)
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
@objc public func pause(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
205
|
+
player?.pause()
|
|
206
|
+
resolve(nil)
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
@objc public func stop(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
210
|
+
player?.pause()
|
|
211
|
+
player?.seek(to: .zero)
|
|
212
|
+
stopProgressTimer()
|
|
213
|
+
resolve(nil)
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
@objc public func reset(resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
217
|
+
player?.pause()
|
|
218
|
+
player?.removeAllItems()
|
|
219
|
+
queue.removeAll()
|
|
220
|
+
playerItems.removeAll()
|
|
221
|
+
currentIndex = -1
|
|
222
|
+
stopProgressTimer()
|
|
223
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
224
|
+
resolve(nil)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
@objc public func seekTo(_ position: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
228
|
+
player?.seek(to: CMTime(seconds: position, preferredTimescale: 1000)) { _ in resolve(nil) }
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
@objc public func seekBy(_ offset: Double, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
232
|
+
guard let player = player else { resolve(nil); return }
|
|
233
|
+
let current = CMTimeGetSeconds(player.currentTime())
|
|
234
|
+
let newPos = max(0, current + offset)
|
|
235
|
+
player.seek(to: CMTime(seconds: newPos, preferredTimescale: 1000)) { _ in resolve(nil) }
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
@objc public func setVolume(_ volume: Float, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
239
|
+
player?.volume = volume
|
|
240
|
+
resolve(nil)
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
@objc public func getVolume() -> Float { return player?.volume ?? 1.0 }
|
|
244
|
+
|
|
245
|
+
@objc public func setRate(_ rate: Float, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
246
|
+
player?.rate = rate
|
|
247
|
+
resolve(nil)
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
@objc public func getRate() -> Float { return player?.rate ?? 1.0 }
|
|
251
|
+
|
|
252
|
+
@objc public func setRepeatMode(_ mode: Int, resolve: @escaping RCTPromiseResolveBlock, reject: @escaping RCTPromiseRejectBlock) {
|
|
253
|
+
repeatMode = mode
|
|
254
|
+
resolve(nil)
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
@objc public func getRepeatMode() -> Int { return repeatMode }
|
|
258
|
+
|
|
259
|
+
// ── Queue getters ───────────────────────────────────────────────────────────
|
|
260
|
+
|
|
261
|
+
@objc public func getQueue() -> [[String: Any]] { return queue }
|
|
262
|
+
|
|
263
|
+
@objc public func getActiveTrackIndex() -> NSNumber? {
|
|
264
|
+
guard currentIndex >= 0 && currentIndex < queue.count else { return nil }
|
|
265
|
+
return NSNumber(value: currentIndex)
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
@objc public func getActiveTrack() -> [String: Any]? {
|
|
269
|
+
guard currentIndex >= 0 && currentIndex < queue.count else { return nil }
|
|
270
|
+
return queue[currentIndex]
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
@objc public func getTrackAtIndex(_ index: Int) -> [String: Any]? {
|
|
274
|
+
guard index >= 0 && index < queue.count else { return nil }
|
|
275
|
+
return queue[index]
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
@objc public func getQueueSize() -> Int { return queue.count }
|
|
279
|
+
|
|
280
|
+
// ── State / progress ────────────────────────────────────────────────────────
|
|
281
|
+
|
|
282
|
+
@objc public func getPlaybackState() -> [String: Any] {
|
|
283
|
+
return ["state": currentStateString()]
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
@objc public func getProgress() -> [String: Any] {
|
|
287
|
+
guard let player = player else {
|
|
288
|
+
return ["position": 0.0, "duration": 0.0, "buffered": 0.0]
|
|
289
|
+
}
|
|
290
|
+
let position = CMTimeGetSeconds(player.currentTime())
|
|
291
|
+
let duration = CMTimeGetSeconds(player.currentItem?.duration ?? .zero)
|
|
292
|
+
let buffered = player.currentItem?.loadedTimeRanges.last.map {
|
|
293
|
+
CMTimeGetSeconds($0.timeRangeValue.start) + CMTimeGetSeconds($0.timeRangeValue.duration)
|
|
294
|
+
} ?? 0.0
|
|
295
|
+
|
|
296
|
+
return [
|
|
297
|
+
"position": position.isNaN ? 0.0 : position,
|
|
298
|
+
"duration": duration.isNaN || duration.isInfinite ? 0.0 : duration,
|
|
299
|
+
"buffered": buffered.isNaN ? 0.0 : buffered,
|
|
300
|
+
]
|
|
301
|
+
}
|
|
302
|
+
|
|
303
|
+
// ── Metadata ────────────────────────────────────────────────────────────────
|
|
304
|
+
|
|
305
|
+
@objc public func updateMetadataForTrack(
|
|
306
|
+
_ index: Int,
|
|
307
|
+
metadata: [String: Any],
|
|
308
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
309
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
310
|
+
) {
|
|
311
|
+
guard index >= 0 && index < queue.count else {
|
|
312
|
+
reject("metadata_error", "Index out of bounds", nil); return
|
|
313
|
+
}
|
|
314
|
+
var track = queue[index]
|
|
315
|
+
metadata.forEach { track[$0.key] = $0.value }
|
|
316
|
+
queue[index] = track
|
|
317
|
+
if index == currentIndex { updateNowPlayingInfo() }
|
|
318
|
+
resolve(nil)
|
|
319
|
+
}
|
|
320
|
+
|
|
321
|
+
@objc public func clearNowPlayingMetadata(
|
|
322
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
323
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
324
|
+
) {
|
|
325
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
326
|
+
resolve(nil)
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
@objc public func updateNowPlayingMetadata(
|
|
330
|
+
_ metadata: [String: Any],
|
|
331
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
332
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
333
|
+
) {
|
|
334
|
+
var info = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [:]
|
|
335
|
+
if let title = metadata["title"] as? String { info[MPMediaItemPropertyTitle] = title }
|
|
336
|
+
if let artist = metadata["artist"] as? String { info[MPMediaItemPropertyArtist] = artist }
|
|
337
|
+
if let album = metadata["album"] as? String { info[MPMediaItemPropertyAlbumTitle] = album }
|
|
338
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
339
|
+
resolve(nil)
|
|
340
|
+
}
|
|
341
|
+
|
|
342
|
+
@objc public func updateOptions(
|
|
343
|
+
_ opts: [String: Any],
|
|
344
|
+
resolve: @escaping RCTPromiseResolveBlock,
|
|
345
|
+
reject: @escaping RCTPromiseRejectBlock
|
|
346
|
+
) {
|
|
347
|
+
options = opts
|
|
348
|
+
setupRemoteCommands()
|
|
349
|
+
resolve(nil)
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
// ── Private helpers ─────────────────────────────────────────────────────────
|
|
353
|
+
|
|
354
|
+
private func rebuildPlayerQueue() {
|
|
355
|
+
guard let player = player else { return }
|
|
356
|
+
player.removeAllItems()
|
|
357
|
+
playerItems.removeAll()
|
|
358
|
+
|
|
359
|
+
let startIndex = max(0, currentIndex)
|
|
360
|
+
for i in startIndex..<queue.count {
|
|
361
|
+
let track = queue[i]
|
|
362
|
+
guard let urlString = track["url"] as? String,
|
|
363
|
+
let url = URL(string: urlString) else { continue }
|
|
364
|
+
let item = AVPlayerItem(url: url)
|
|
365
|
+
playerItems.append(item)
|
|
366
|
+
player.insert(item, after: player.items().last)
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
if currentIndex < 0 && !queue.isEmpty { currentIndex = 0 }
|
|
370
|
+
updateNowPlayingInfo()
|
|
371
|
+
}
|
|
372
|
+
|
|
373
|
+
private func updateNowPlayingInfo() {
|
|
374
|
+
guard currentIndex >= 0 && currentIndex < queue.count else { return }
|
|
375
|
+
let track = queue[currentIndex]
|
|
376
|
+
|
|
377
|
+
var info: [String: Any] = [:]
|
|
378
|
+
info[MPMediaItemPropertyTitle] = track["title"] as? String ?? ""
|
|
379
|
+
info[MPMediaItemPropertyArtist] = track["artist"] as? String ?? ""
|
|
380
|
+
info[MPMediaItemPropertyAlbumTitle] = track["album"] as? String ?? ""
|
|
381
|
+
|
|
382
|
+
if let duration = track["duration"] as? Double, duration > 0 {
|
|
383
|
+
info[MPMediaItemPropertyPlaybackDuration] = duration
|
|
384
|
+
} else if let item = player?.currentItem {
|
|
385
|
+
let d = CMTimeGetSeconds(item.duration)
|
|
386
|
+
if !d.isNaN && !d.isInfinite { info[MPMediaItemPropertyPlaybackDuration] = d }
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
info[MPNowPlayingInfoPropertyElapsedPlaybackTime] = CMTimeGetSeconds(player?.currentTime() ?? .zero)
|
|
390
|
+
info[MPNowPlayingInfoPropertyPlaybackRate] = player?.rate ?? 0.0
|
|
391
|
+
|
|
392
|
+
// Artwork
|
|
393
|
+
if let artworkURL = track["artwork"] as? String, !artworkURL.isEmpty {
|
|
394
|
+
loadArtwork(from: artworkURL) { image in
|
|
395
|
+
if let image = image {
|
|
396
|
+
info[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { _ in image }
|
|
397
|
+
}
|
|
398
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
399
|
+
}
|
|
400
|
+
} else {
|
|
401
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = info
|
|
402
|
+
}
|
|
403
|
+
}
|
|
404
|
+
|
|
405
|
+
private func loadArtwork(from urlString: String, completion: @escaping (UIImage?) -> Void) {
|
|
406
|
+
guard let url = URL(string: urlString) else { completion(nil); return }
|
|
407
|
+
URLSession.shared.dataTask(with: url) { data, _, _ in
|
|
408
|
+
DispatchQueue.main.async {
|
|
409
|
+
completion(data.flatMap { UIImage(data: $0) })
|
|
410
|
+
}
|
|
411
|
+
}.resume()
|
|
412
|
+
}
|
|
413
|
+
|
|
414
|
+
private func currentStateString() -> String {
|
|
415
|
+
guard let player = player else { return "none" }
|
|
416
|
+
switch player.timeControlStatus {
|
|
417
|
+
case .playing: return "playing"
|
|
418
|
+
case .paused:
|
|
419
|
+
return player.currentItem == nil ? "none" : "paused"
|
|
420
|
+
case .waitingToPlayAtSpecifiedRate:
|
|
421
|
+
return "buffering"
|
|
422
|
+
@unknown default:
|
|
423
|
+
return "none"
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
// ── Remote commands ─────────────────────────────────────────────────────────
|
|
428
|
+
|
|
429
|
+
private func setupRemoteCommands() {
|
|
430
|
+
let center = MPRemoteCommandCenter.shared()
|
|
431
|
+
UIApplication.shared.beginReceivingRemoteControlEvents()
|
|
432
|
+
|
|
433
|
+
let capabilities = options["capabilities"] as? [String] ?? [
|
|
434
|
+
"play", "pause", "stop", "skip-to-next", "skip-to-previous", "seek-to"
|
|
435
|
+
]
|
|
436
|
+
|
|
437
|
+
center.playCommand.isEnabled = capabilities.contains("play")
|
|
438
|
+
center.playCommand.removeTarget(nil)
|
|
439
|
+
center.playCommand.addTarget { [weak self] _ in
|
|
440
|
+
self?.player?.play()
|
|
441
|
+
self?.eventEmitter("remote-play", nil)
|
|
442
|
+
return .success
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
center.pauseCommand.isEnabled = capabilities.contains("pause")
|
|
446
|
+
center.pauseCommand.removeTarget(nil)
|
|
447
|
+
center.pauseCommand.addTarget { [weak self] _ in
|
|
448
|
+
self?.player?.pause()
|
|
449
|
+
self?.eventEmitter("remote-pause", nil)
|
|
450
|
+
return .success
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
center.stopCommand.isEnabled = capabilities.contains("stop")
|
|
454
|
+
center.stopCommand.removeTarget(nil)
|
|
455
|
+
center.stopCommand.addTarget { [weak self] _ in
|
|
456
|
+
self?.player?.pause()
|
|
457
|
+
self?.eventEmitter("remote-stop", nil)
|
|
458
|
+
return .success
|
|
459
|
+
}
|
|
460
|
+
|
|
461
|
+
center.nextTrackCommand.isEnabled = capabilities.contains("skip-to-next")
|
|
462
|
+
center.nextTrackCommand.removeTarget(nil)
|
|
463
|
+
center.nextTrackCommand.addTarget { [weak self] _ in
|
|
464
|
+
guard let self = self else { return .commandFailed }
|
|
465
|
+
let next = self.currentIndex + 1
|
|
466
|
+
if next < self.queue.count {
|
|
467
|
+
self.skipToIndex(next, initialPosition: -1, resolve: { _ in }, reject: { _, _, _ in })
|
|
468
|
+
}
|
|
469
|
+
self.eventEmitter("remote-next", nil)
|
|
470
|
+
return .success
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
center.previousTrackCommand.isEnabled = capabilities.contains("skip-to-previous")
|
|
474
|
+
center.previousTrackCommand.removeTarget(nil)
|
|
475
|
+
center.previousTrackCommand.addTarget { [weak self] _ in
|
|
476
|
+
guard let self = self else { return .commandFailed }
|
|
477
|
+
let prev = self.currentIndex - 1
|
|
478
|
+
if prev >= 0 {
|
|
479
|
+
self.skipToIndex(prev, initialPosition: -1, resolve: { _ in }, reject: { _, _, _ in })
|
|
480
|
+
}
|
|
481
|
+
self.eventEmitter("remote-previous", nil)
|
|
482
|
+
return .success
|
|
483
|
+
}
|
|
484
|
+
|
|
485
|
+
center.changePlaybackPositionCommand.isEnabled = capabilities.contains("seek-to")
|
|
486
|
+
center.changePlaybackPositionCommand.removeTarget(nil)
|
|
487
|
+
center.changePlaybackPositionCommand.addTarget { [weak self] event in
|
|
488
|
+
guard let e = event as? MPChangePlaybackPositionCommandEvent else { return .commandFailed }
|
|
489
|
+
self?.player?.seek(to: CMTime(seconds: e.positionTime, preferredTimescale: 1000))
|
|
490
|
+
self?.eventEmitter("remote-seek", ["position": e.positionTime])
|
|
491
|
+
return .success
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
let fwdInterval = options["forwardJumpInterval"] as? Double ?? 15.0
|
|
495
|
+
let bwdInterval = options["backwardJumpInterval"] as? Double ?? 15.0
|
|
496
|
+
|
|
497
|
+
center.skipForwardCommand.isEnabled = capabilities.contains("jump-forward")
|
|
498
|
+
center.skipForwardCommand.preferredIntervals = [NSNumber(value: fwdInterval)]
|
|
499
|
+
center.skipForwardCommand.removeTarget(nil)
|
|
500
|
+
center.skipForwardCommand.addTarget { [weak self] event in
|
|
501
|
+
guard let e = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
|
|
502
|
+
self?.seekBy(e.interval, resolve: { _ in }, reject: { _, _, _ in })
|
|
503
|
+
self?.eventEmitter("remote-jump-forward", ["interval": e.interval])
|
|
504
|
+
return .success
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
center.skipBackwardCommand.isEnabled = capabilities.contains("jump-backward")
|
|
508
|
+
center.skipBackwardCommand.preferredIntervals = [NSNumber(value: bwdInterval)]
|
|
509
|
+
center.skipBackwardCommand.removeTarget(nil)
|
|
510
|
+
center.skipBackwardCommand.addTarget { [weak self] event in
|
|
511
|
+
guard let e = event as? MPSkipIntervalCommandEvent else { return .commandFailed }
|
|
512
|
+
self?.seekBy(-e.interval, resolve: { _ in }, reject: { _, _, _ in })
|
|
513
|
+
self?.eventEmitter("remote-jump-backward", ["interval": e.interval])
|
|
514
|
+
return .success
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
|
|
518
|
+
// ── Observers ───────────────────────────────────────────────────────────────
|
|
519
|
+
|
|
520
|
+
private func setupNotificationObservers() {
|
|
521
|
+
NotificationCenter.default.addObserver(
|
|
522
|
+
self,
|
|
523
|
+
selector: #selector(playerItemDidFinish(_:)),
|
|
524
|
+
name: .AVPlayerItemDidPlayToEndTime,
|
|
525
|
+
object: nil
|
|
526
|
+
)
|
|
527
|
+
NotificationCenter.default.addObserver(
|
|
528
|
+
self,
|
|
529
|
+
selector: #selector(playerItemFailed(_:)),
|
|
530
|
+
name: .AVPlayerItemFailedToPlayToEndTime,
|
|
531
|
+
object: nil
|
|
532
|
+
)
|
|
533
|
+
NotificationCenter.default.addObserver(
|
|
534
|
+
self,
|
|
535
|
+
selector: #selector(audioSessionInterrupted(_:)),
|
|
536
|
+
name: AVAudioSession.interruptionNotification,
|
|
537
|
+
object: nil
|
|
538
|
+
)
|
|
539
|
+
NotificationCenter.default.addObserver(
|
|
540
|
+
self,
|
|
541
|
+
selector: #selector(audioRouteChanged(_:)),
|
|
542
|
+
name: AVAudioSession.routeChangeNotification,
|
|
543
|
+
object: nil
|
|
544
|
+
)
|
|
545
|
+
|
|
546
|
+
// Observe player status
|
|
547
|
+
if let player = player {
|
|
548
|
+
let obs = player.observe(\.timeControlStatus, options: [.new]) { [weak self] p, _ in
|
|
549
|
+
self?.eventEmitter("playback-state", ["state": self?.currentStateString() ?? "none"])
|
|
550
|
+
self?.updateNowPlayingInfo()
|
|
551
|
+
}
|
|
552
|
+
playerObservations.append(obs)
|
|
553
|
+
}
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
private func removeObservers() {
|
|
557
|
+
NotificationCenter.default.removeObserver(self)
|
|
558
|
+
playerObservations.forEach { $0.invalidate() }
|
|
559
|
+
playerObservations.removeAll()
|
|
560
|
+
if let obs = timeObserver {
|
|
561
|
+
player?.removeTimeObserver(obs)
|
|
562
|
+
timeObserver = nil
|
|
563
|
+
}
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
@objc private func playerItemDidFinish(_ notification: Notification) {
|
|
567
|
+
// Only react to notifications for our own player's current item —
|
|
568
|
+
// `object: nil` on the observer means this fires for *any* AVPlayerItem
|
|
569
|
+
// in the whole app (e.g. a video player elsewhere), so we must filter here.
|
|
570
|
+
guard let finishedItem = notification.object as? AVPlayerItem,
|
|
571
|
+
finishedItem === player?.currentItem else { return }
|
|
572
|
+
|
|
573
|
+
let nextIndex = currentIndex + 1
|
|
574
|
+
|
|
575
|
+
if repeatMode == 1 {
|
|
576
|
+
// Repeat current track
|
|
577
|
+
player?.seek(to: .zero)
|
|
578
|
+
player?.play()
|
|
579
|
+
return
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
if nextIndex < queue.count {
|
|
583
|
+
currentIndex = nextIndex
|
|
584
|
+
updateNowPlayingInfo()
|
|
585
|
+
eventEmitter("playback-active-track-changed", [
|
|
586
|
+
"index": nextIndex,
|
|
587
|
+
"track": queue[nextIndex],
|
|
588
|
+
"lastIndex": currentIndex - 1,
|
|
589
|
+
"lastPosition": 0.0,
|
|
590
|
+
])
|
|
591
|
+
} else if repeatMode == 2 {
|
|
592
|
+
// Repeat queue
|
|
593
|
+
currentIndex = 0
|
|
594
|
+
rebuildPlayerQueue()
|
|
595
|
+
player?.play()
|
|
596
|
+
} else {
|
|
597
|
+
eventEmitter("playback-queue-ended", [
|
|
598
|
+
"index": currentIndex,
|
|
599
|
+
"position": CMTimeGetSeconds(player?.currentTime() ?? .zero),
|
|
600
|
+
])
|
|
601
|
+
}
|
|
602
|
+
}
|
|
603
|
+
|
|
604
|
+
@objc private func playerItemFailed(_ notification: Notification) {
|
|
605
|
+
guard let failedItem = notification.object as? AVPlayerItem,
|
|
606
|
+
failedItem === player?.currentItem else { return }
|
|
607
|
+
|
|
608
|
+
let error = notification.userInfo?[AVPlayerItemFailedToPlayToEndTimeErrorKey] as? Error
|
|
609
|
+
eventEmitter("playback-error", [
|
|
610
|
+
"code": "playback_error",
|
|
611
|
+
"message": error?.localizedDescription ?? "Unknown error",
|
|
612
|
+
])
|
|
613
|
+
eventEmitter("playback-state", ["state": "error"])
|
|
614
|
+
}
|
|
615
|
+
|
|
616
|
+
@objc private func audioSessionInterrupted(_ notification: Notification) {
|
|
617
|
+
guard let typeValue = notification.userInfo?[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
618
|
+
let type = AVAudioSession.InterruptionType(rawValue: typeValue) else { return }
|
|
619
|
+
|
|
620
|
+
if type == .began {
|
|
621
|
+
player?.pause()
|
|
622
|
+
eventEmitter("remote-duck", ["paused": true, "permanent": false, "focusLoss": true])
|
|
623
|
+
} else if type == .ended {
|
|
624
|
+
let shouldResume = (notification.userInfo?[AVAudioSessionInterruptionOptionKey] as? UInt)
|
|
625
|
+
.flatMap { AVAudioSession.InterruptionOptions(rawValue: $0) }
|
|
626
|
+
.map { $0.contains(.shouldResume) } ?? false
|
|
627
|
+
if shouldResume { player?.play() }
|
|
628
|
+
eventEmitter("remote-duck", ["paused": false, "permanent": false, "focusLoss": false])
|
|
629
|
+
}
|
|
630
|
+
}
|
|
631
|
+
|
|
632
|
+
@objc private func audioRouteChanged(_ notification: Notification) {
|
|
633
|
+
guard let reasonValue = notification.userInfo?[AVAudioSessionRouteChangeReasonKey] as? UInt,
|
|
634
|
+
let reason = AVAudioSession.RouteChangeReason(rawValue: reasonValue) else { return }
|
|
635
|
+
// Pause on headphone unplug (standard iOS behavior)
|
|
636
|
+
if reason == .oldDeviceUnavailable { player?.pause() }
|
|
637
|
+
}
|
|
638
|
+
|
|
639
|
+
// ── Progress timer ──────────────────────────────────────────────────────────
|
|
640
|
+
|
|
641
|
+
private func startProgressTimer() {
|
|
642
|
+
stopProgressTimer()
|
|
643
|
+
progressTimer = Timer.scheduledTimer(withTimeInterval: progressInterval, repeats: true) { [weak self] _ in
|
|
644
|
+
guard let self = self, let player = self.player, player.timeControlStatus == .playing else { return }
|
|
645
|
+
let progress = self.getProgress()
|
|
646
|
+
var payload = progress
|
|
647
|
+
payload["track"] = self.currentIndex
|
|
648
|
+
self.eventEmitter("playback-progress-updated", payload)
|
|
649
|
+
self.updateNowPlayingInfo()
|
|
650
|
+
}
|
|
651
|
+
}
|
|
652
|
+
|
|
653
|
+
private func stopProgressTimer() {
|
|
654
|
+
progressTimer?.invalidate()
|
|
655
|
+
progressTimer = nil
|
|
656
|
+
}
|
|
657
|
+
|
|
658
|
+
// ── Audio session mapping ───────────────────────────────────────────────────
|
|
659
|
+
|
|
660
|
+
private func mapIOSCategory(_ string: String?) -> AVAudioSession.Category {
|
|
661
|
+
switch string {
|
|
662
|
+
case "playAndRecord": return .playAndRecord
|
|
663
|
+
case "multiRoute": return .multiRoute
|
|
664
|
+
case "ambient": return .ambient
|
|
665
|
+
case "soloAmbient": return .soloAmbient
|
|
666
|
+
case "record": return .record
|
|
667
|
+
default: return .playback
|
|
668
|
+
}
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
private func mapIOSMode(_ string: String?) -> AVAudioSession.Mode {
|
|
672
|
+
switch string {
|
|
673
|
+
case "moviePlayback": return .moviePlayback
|
|
674
|
+
case "spokenAudio": return .spokenAudio
|
|
675
|
+
case "videoChat": return .videoChat
|
|
676
|
+
case "voiceChat": return .voiceChat
|
|
677
|
+
case "voicePrompt": return .voicePrompt
|
|
678
|
+
default: return .default
|
|
679
|
+
}
|
|
680
|
+
}
|
|
681
|
+
|
|
682
|
+
private func mapIOSOptions(_ strings: [String]?) -> AVAudioSession.CategoryOptions {
|
|
683
|
+
var opts: AVAudioSession.CategoryOptions = []
|
|
684
|
+
strings?.forEach { s in
|
|
685
|
+
switch s {
|
|
686
|
+
case "mixWithOthers": opts.insert(.mixWithOthers)
|
|
687
|
+
case "duckOthers": opts.insert(.duckOthers)
|
|
688
|
+
case "allowBluetooth": opts.insert(.allowBluetooth)
|
|
689
|
+
case "allowBluetoothA2DP": opts.insert(.allowBluetoothA2DP)
|
|
690
|
+
case "allowAirPlay": opts.insert(.allowAirPlay)
|
|
691
|
+
case "defaultToSpeaker": opts.insert(.defaultToSpeaker)
|
|
692
|
+
default: break
|
|
693
|
+
}
|
|
694
|
+
}
|
|
695
|
+
return opts
|
|
696
|
+
}
|
|
697
|
+
}
|