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.
Files changed (58) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +348 -0
  3. package/android/build.gradle +82 -0
  4. package/android/src/main/AndroidManifest.xml +31 -0
  5. package/android/src/main/java/com/gliphplayer/DeviceInfoModule.kt +48 -0
  6. package/android/src/main/java/com/gliphplayer/DeviceInfoPackage.kt +16 -0
  7. package/android/src/main/java/com/gliphplayer/GliphPlayerModule.kt +360 -0
  8. package/android/src/main/java/com/gliphplayer/GliphPlayerPackage.kt +48 -0
  9. package/android/src/main/java/com/gliphplayer/GliphPlayerService.kt +797 -0
  10. package/android/src/main/res/xml/automotive_app_desc.xml +4 -0
  11. package/android/src/oldarch/com/gliphplayer/NativeGliphPlayerSpec.kt +56 -0
  12. package/app.plugin.js +1 -0
  13. package/expo-gliph-player.podspec +91 -0
  14. package/ios/GliphAudioPlayer.h +77 -0
  15. package/ios/GliphAudioPlayer.swift +697 -0
  16. package/ios/GliphPlayer-Bridging-Header.h +3 -0
  17. package/ios/GliphPlayerModule.h +12 -0
  18. package/ios/GliphPlayerModule.mm +243 -0
  19. package/ios/expo_gliph_player.h +22 -0
  20. package/lib/commonjs/GliphPlayer.js +310 -0
  21. package/lib/commonjs/GliphPlayer.js.map +1 -0
  22. package/lib/commonjs/hooks.js +233 -0
  23. package/lib/commonjs/hooks.js.map +1 -0
  24. package/lib/commonjs/index.js +166 -0
  25. package/lib/commonjs/index.js.map +1 -0
  26. package/lib/commonjs/package.json +1 -0
  27. package/lib/commonjs/specs/NativeGliphPlayer.js +19 -0
  28. package/lib/commonjs/specs/NativeGliphPlayer.js.map +1 -0
  29. package/lib/commonjs/types.js +175 -0
  30. package/lib/commonjs/types.js.map +1 -0
  31. package/lib/module/GliphPlayer.js +305 -0
  32. package/lib/module/GliphPlayer.js.map +1 -0
  33. package/lib/module/hooks.js +221 -0
  34. package/lib/module/hooks.js.map +1 -0
  35. package/lib/module/index.js +20 -0
  36. package/lib/module/index.js.map +1 -0
  37. package/lib/module/package.json +1 -0
  38. package/lib/module/specs/NativeGliphPlayer.js +20 -0
  39. package/lib/module/specs/NativeGliphPlayer.js.map +1 -0
  40. package/lib/module/types.js +181 -0
  41. package/lib/module/types.js.map +1 -0
  42. package/lib/typescript/src/GliphPlayer.d.ts +113 -0
  43. package/lib/typescript/src/GliphPlayer.d.ts.map +1 -0
  44. package/lib/typescript/src/hooks.d.ts +51 -0
  45. package/lib/typescript/src/hooks.d.ts.map +1 -0
  46. package/lib/typescript/src/index.d.ts +11 -0
  47. package/lib/typescript/src/index.d.ts.map +1 -0
  48. package/lib/typescript/src/specs/NativeGliphPlayer.d.ts +80 -0
  49. package/lib/typescript/src/specs/NativeGliphPlayer.d.ts.map +1 -0
  50. package/lib/typescript/src/types.d.ts +348 -0
  51. package/lib/typescript/src/types.d.ts.map +1 -0
  52. package/package.json +62 -0
  53. package/src/GliphPlayer.ts +356 -0
  54. package/src/hooks.ts +256 -0
  55. package/src/index.ts +61 -0
  56. package/src/specs/NativeGliphPlayer.ts +105 -0
  57. package/src/types.ts +395 -0
  58. 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
+ }
@@ -0,0 +1,3 @@
1
+ #import <React/RCTBridgeModule.h>
2
+ #import <React/RCTEventEmitter.h>
3
+ #import <React/RCTUtils.h>