@neoskola/auto-play 0.3.3 → 0.3.5

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -3,7 +3,7 @@ import MediaPlayer
3
3
  import AVFoundation
4
4
 
5
5
  class NowPlayingTemplate: AutoPlayTemplate {
6
- var template: CPNowPlayingTemplate
6
+ var template: CPListTemplate
7
7
  var config: NowPlayingTemplateConfig
8
8
  private var loadedImage: UIImage?
9
9
  private var isSetupComplete = false
@@ -19,6 +19,9 @@ class NowPlayingTemplate: AutoPlayTemplate {
19
19
  private var didFinishObserver: NSObjectProtocol?
20
20
  private var statusObservation: NSKeyValueObservation?
21
21
  private var completionFired = false
22
+ private var downloadTask: URLSessionDownloadTask?
23
+ private var localAudioFileURL: URL?
24
+ private var pendingStartFrom: Double = 0
22
25
 
23
26
  var autoDismissMs: Double? {
24
27
  return config.autoDismissMs
@@ -31,19 +34,43 @@ class NowPlayingTemplate: AutoPlayTemplate {
31
34
  init(config: NowPlayingTemplateConfig) {
32
35
  self.config = config
33
36
 
34
- template = CPNowPlayingTemplate.shared
35
- initTemplate(template: template, id: config.id)
37
+ // Custom player screen using CPListTemplate instead of CPNowPlayingTemplate.shared
38
+ let titleText = Parser.parseText(text: config.title) ?? "Now Playing"
39
+ let subtitleText = config.subtitle.flatMap { Parser.parseText(text: $0) } ?? ""
40
+
41
+ let infoItem = CPListItem(
42
+ text: titleText,
43
+ detailText: subtitleText,
44
+ image: UIImage(systemName: "music.note"),
45
+ accessoryImage: nil,
46
+ accessoryType: .none
47
+ )
48
+
49
+ let statusItem = CPListItem(
50
+ text: "Yükleniyor...",
51
+ detailText: nil,
52
+ image: UIImage(systemName: "arrow.down.circle"),
53
+ accessoryImage: nil,
54
+ accessoryType: .none
55
+ )
56
+
57
+ let section = CPListSection(
58
+ items: [infoItem, statusItem],
59
+ header: nil,
60
+ sectionIndexTitle: nil
61
+ )
62
+
63
+ template = CPListTemplate(
64
+ title: "Now Playing",
65
+ sections: [section],
66
+ assistantCellConfiguration: nil,
67
+ id: config.id
68
+ )
36
69
 
37
- // Constructor runs on the JS thread. Dispatch all CarPlay UI setup to main thread.
38
- // CPNowPlayingTemplate.shared is Apple's singleton — must be modified on main thread.
39
70
  DispatchQueue.main.async { [weak self] in
40
71
  guard let self = self else { return }
41
-
42
- // Activate AVAudioSession FIRST — iOS needs this to recognize the app
43
- // as a media player before MPNowPlayingInfoCenter metadata is meaningful.
44
72
  NowPlayingSessionManager.shared.ensureSessionActive()
45
-
46
- self.setupNowPlayingButtons()
73
+ self.setupRemoteCommandCenter()
47
74
  self.updateNowPlayingInfo()
48
75
  self.isSetupComplete = true
49
76
 
@@ -53,6 +80,56 @@ class NowPlayingTemplate: AutoPlayTemplate {
53
80
  }
54
81
  }
55
82
 
83
+ // MARK: - Player UI
84
+
85
+ private func updatePlayerUI() {
86
+ let titleText = Parser.parseText(text: config.title) ?? "Now Playing"
87
+ let subtitleText = config.subtitle.flatMap { Parser.parseText(text: $0) } ?? ""
88
+
89
+ let infoItem = CPListItem(
90
+ text: titleText,
91
+ detailText: subtitleText,
92
+ image: loadedImage ?? UIImage(systemName: "music.note"),
93
+ accessoryImage: nil,
94
+ accessoryType: .none
95
+ )
96
+
97
+ let statusText: String
98
+ let statusIcon: String
99
+ if config.isPlaying {
100
+ let elapsed = formatTime(currentElapsedTime)
101
+ let total = currentDuration > 0 ? formatTime(currentDuration) : "--:--"
102
+ statusText = "Playing \(elapsed) / \(total)"
103
+ statusIcon = "play.circle.fill"
104
+ } else {
105
+ statusText = "Paused"
106
+ statusIcon = "pause.circle.fill"
107
+ }
108
+
109
+ let statusItem = CPListItem(
110
+ text: statusText,
111
+ detailText: nil,
112
+ image: UIImage(systemName: statusIcon),
113
+ accessoryImage: nil,
114
+ accessoryType: .none
115
+ )
116
+
117
+ let section = CPListSection(
118
+ items: [infoItem, statusItem],
119
+ header: nil,
120
+ sectionIndexTitle: nil
121
+ )
122
+
123
+ template.updateSections([section])
124
+ }
125
+
126
+ private func formatTime(_ seconds: Double) -> String {
127
+ guard !seconds.isNaN && !seconds.isInfinite && seconds >= 0 else { return "0:00" }
128
+ let mins = Int(seconds) / 60
129
+ let secs = Int(seconds) % 60
130
+ return String(format: "%d:%02d", mins, secs)
131
+ }
132
+
56
133
  // MARK: - Native Audio Playback
57
134
 
58
135
  @MainActor
@@ -60,6 +137,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
60
137
  cleanupPlayer()
61
138
  completionFired = false
62
139
  lastReportedSecond = Int(startFrom)
140
+ pendingStartFrom = startFrom
63
141
 
64
142
  guard let audioURL = URL(string: url) else {
65
143
  print("[NowPlayingTemplate] Invalid audio URL: \(url)")
@@ -69,13 +147,52 @@ class NowPlayingTemplate: AutoPlayTemplate {
69
147
  NowPlayingSessionManager.shared.ensureSessionActive()
70
148
  config.isPlaying = true
71
149
  updateNowPlayingInfo()
150
+ updatePlayerUI()
72
151
  MPNowPlayingInfoCenter.default().playbackState = .playing
73
152
 
74
- // Direct AVPlayer streaming — URL now has correct headers from backend
75
- let asset = AVURLAsset(url: audioURL)
153
+ print("[NowPlayingTemplate] Downloading audio: \(url)")
154
+
155
+ downloadTask = URLSession.shared.downloadTask(with: audioURL) { [weak self] tempURL, response, error in
156
+ guard let self = self else { return }
157
+
158
+ if let error = error {
159
+ print("[NowPlayingTemplate] Download failed: \(error.localizedDescription)")
160
+ return
161
+ }
162
+
163
+ guard let tempURL = tempURL else {
164
+ print("[NowPlayingTemplate] Download returned no file")
165
+ return
166
+ }
167
+
168
+ let localURL = FileManager.default.temporaryDirectory
169
+ .appendingPathComponent("carplay_audio_\(UUID().uuidString).mp3")
170
+
171
+ do {
172
+ if let oldFile = self.localAudioFileURL {
173
+ try? FileManager.default.removeItem(at: oldFile)
174
+ }
175
+ try FileManager.default.moveItem(at: tempURL, to: localURL)
176
+ self.localAudioFileURL = localURL
177
+ print("[NowPlayingTemplate] Audio downloaded to: \(localURL.lastPathComponent)")
178
+ } catch {
179
+ print("[NowPlayingTemplate] Failed to move downloaded file: \(error)")
180
+ return
181
+ }
182
+
183
+ DispatchQueue.main.async { [weak self] in
184
+ self?.startPlaybackFromLocalFile(localURL: localURL, startFrom: self?.pendingStartFrom ?? 0)
185
+ }
186
+ }
187
+ downloadTask?.resume()
188
+
189
+ return true
190
+ }
191
+
192
+ private func startPlaybackFromLocalFile(localURL: URL, startFrom: Double) {
193
+ let asset = AVURLAsset(url: localURL)
76
194
  playerItem = AVPlayerItem(asset: asset)
77
195
 
78
- // KVO: detect duration as soon as asset loads (critical for progress bar)
79
196
  statusObservation = playerItem?.observe(\.status, options: [.new]) { [weak self] item, _ in
80
197
  guard item.status == .readyToPlay else {
81
198
  if item.status == .failed {
@@ -89,6 +206,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
89
206
  if !duration.isNaN && !duration.isInfinite && duration > 0 {
90
207
  self.currentDuration = duration
91
208
  self.updateNowPlayingInfo()
209
+ self.updatePlayerUI()
92
210
  print("[NowPlayingTemplate] Duration resolved via KVO: \(duration)s")
93
211
  }
94
212
  }
@@ -96,15 +214,11 @@ class NowPlayingTemplate: AutoPlayTemplate {
96
214
 
97
215
  player = AVPlayer(playerItem: playerItem)
98
216
 
99
- // Seek to start position
100
217
  if startFrom > 0 {
101
218
  let time = CMTime(seconds: startFrom, preferredTimescale: 600)
102
219
  player?.seek(to: time)
103
220
  }
104
221
 
105
- player?.play()
106
-
107
- // Periodic time observer (every 1 second) for MPNowPlayingInfoCenter updates
108
222
  let interval = CMTime(seconds: 1.0, preferredTimescale: 600)
109
223
  timeObserver = player?.addPeriodicTimeObserver(
110
224
  forInterval: interval,
@@ -113,15 +227,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
113
227
  self?.handleTimeUpdate(time: time)
114
228
  }
115
229
 
116
- // Progress report timer (every 30 seconds) — calls JS callback for backend reporting
117
- progressReportTimer = Timer.scheduledTimer(
118
- withTimeInterval: 30.0,
119
- repeats: true
120
- ) { [weak self] _ in
121
- self?.reportProgress()
122
- }
123
-
124
- // Playback finished notification
125
230
  didFinishObserver = NotificationCenter.default.addObserver(
126
231
  forName: .AVPlayerItemDidPlayToEndTime,
127
232
  object: playerItem,
@@ -130,8 +235,17 @@ class NowPlayingTemplate: AutoPlayTemplate {
130
235
  self?.handlePlaybackFinished()
131
236
  }
132
237
 
133
- print("[NowPlayingTemplate] Streaming playback started: \(url)")
134
- return true
238
+ progressReportTimer = Timer.scheduledTimer(
239
+ withTimeInterval: 30.0,
240
+ repeats: true
241
+ ) { [weak self] _ in
242
+ self?.reportProgress()
243
+ }
244
+
245
+ player?.play()
246
+ MPNowPlayingInfoCenter.default().playbackState = .playing
247
+
248
+ print("[NowPlayingTemplate] Playback started from local file")
135
249
  }
136
250
 
137
251
  private func handleTimeUpdate(time: CMTime) {
@@ -145,10 +259,8 @@ class NowPlayingTemplate: AutoPlayTemplate {
145
259
  currentDuration = duration
146
260
  }
147
261
 
148
- // Use existing info or create fresh if nil (race condition safety)
149
262
  var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
150
263
 
151
- // Ensure title/artist are present if this is a fresh dictionary
152
264
  if nowPlayingInfo[MPMediaItemPropertyTitle] == nil {
153
265
  let titleText = Parser.parseText(text: config.title) ?? ""
154
266
  nowPlayingInfo[MPMediaItemPropertyTitle] = titleText
@@ -164,6 +276,9 @@ class NowPlayingTemplate: AutoPlayTemplate {
164
276
  nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
165
277
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
166
278
 
279
+ // Update custom player UI
280
+ updatePlayerUI()
281
+
167
282
  // 95% completion check
168
283
  if !completionFired && currentDuration > 0 && currentTime / currentDuration >= 0.95 {
169
284
  completionFired = true
@@ -181,7 +296,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
181
296
  private func handlePlaybackFinished() {
182
297
  config.isPlaying = false
183
298
  MPNowPlayingInfoCenter.default().playbackState = .stopped
184
- // Fire completion if not already fired
299
+ updatePlayerUI()
185
300
  if !completionFired {
186
301
  completionFired = true
187
302
  config.onComplete?()
@@ -194,6 +309,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
194
309
  player?.pause()
195
310
  config.isPlaying = false
196
311
  updatePlaybackState(isPlaying: false)
312
+ updatePlayerUI()
197
313
  reportProgress()
198
314
  }
199
315
 
@@ -203,6 +319,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
203
319
  player?.play()
204
320
  config.isPlaying = true
205
321
  updatePlaybackState(isPlaying: true)
322
+ updatePlayerUI()
206
323
  }
207
324
 
208
325
  @MainActor
@@ -227,9 +344,12 @@ class NowPlayingTemplate: AutoPlayTemplate {
227
344
  cleanupPlayer()
228
345
  config.isPlaying = false
229
346
  MPNowPlayingInfoCenter.default().playbackState = .stopped
347
+ updatePlayerUI()
230
348
  }
231
349
 
232
350
  private func cleanupPlayer() {
351
+ downloadTask?.cancel()
352
+ downloadTask = nil
233
353
  statusObservation?.invalidate()
234
354
  statusObservation = nil
235
355
  if let timeObserver = timeObserver {
@@ -245,32 +365,14 @@ class NowPlayingTemplate: AutoPlayTemplate {
245
365
  player?.pause()
246
366
  player = nil
247
367
  playerItem = nil
248
- }
249
-
250
- // MARK: - CarPlay UI
251
-
252
- private func setupNowPlayingButtons() {
253
- var buttons: [CPNowPlayingButton] = []
254
-
255
- let skipBackButton = CPNowPlayingImageButton(
256
- image: UIImage(systemName: "gobackward.30")!
257
- ) { [weak self] _ in
258
- self?.seekBackward(seconds: 30)
259
- self?.config.onSkipBackward?()
260
- }
261
- buttons.append(skipBackButton)
262
-
263
- let skipForwardButton = CPNowPlayingImageButton(
264
- image: UIImage(systemName: "goforward.30")!
265
- ) { [weak self] _ in
266
- self?.seekForward(seconds: 30)
267
- self?.config.onSkipForward?()
368
+ if let localFile = localAudioFileURL {
369
+ try? FileManager.default.removeItem(at: localFile)
370
+ localAudioFileURL = nil
268
371
  }
269
- buttons.append(skipForwardButton)
270
-
271
- template.updateNowPlayingButtons(buttons)
272
372
  }
273
373
 
374
+ // MARK: - Now Playing Info & Remote Commands
375
+
274
376
  private func updateNowPlayingInfo() {
275
377
  let titleText = Parser.parseText(text: config.title) ?? ""
276
378
  let subtitleText = config.subtitle.map { Parser.parseText(text: $0) } ?? nil
@@ -288,7 +390,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
288
390
  nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
289
391
  }
290
392
 
291
- // Include duration and elapsed time for the progress bar
292
393
  if currentDuration > 0 {
293
394
  nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentDuration
294
395
  nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
@@ -296,8 +397,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
296
397
  }
297
398
 
298
399
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
299
-
300
- setupRemoteCommandCenter()
301
400
  }
302
401
 
303
402
  private func setupRemoteCommandCenter() {
@@ -348,6 +447,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
348
447
  self.player?.seek(to: time)
349
448
  self.currentElapsedTime = positionEvent.positionTime
350
449
  self.updateNowPlayingInfo()
450
+ self.updatePlayerUI()
351
451
  return .success
352
452
  }
353
453
  }
@@ -363,6 +463,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
363
463
  DispatchQueue.main.async {
364
464
  self.loadedImage = uiImage
365
465
  self.updateNowPlayingInfo()
466
+ self.updatePlayerUI()
366
467
  }
367
468
  }.resume()
368
469
  }
@@ -372,7 +473,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
372
473
 
373
474
  @MainActor
374
475
  func invalidate() {
375
- setupNowPlayingButtons()
476
+ updatePlayerUI()
376
477
  updateNowPlayingInfo()
377
478
 
378
479
  if loadedImage == nil, let image = config.image {
@@ -407,7 +508,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
407
508
  commandCenter.skipBackwardCommand.removeTarget(nil)
408
509
  commandCenter.changePlaybackPositionCommand.removeTarget(nil)
409
510
 
410
- // Clear now playing info so CarPlay hides the Now Playing bar
411
511
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
412
512
  MPNowPlayingInfoCenter.default().playbackState = .stopped
413
513
  }
@@ -415,19 +515,14 @@ class NowPlayingTemplate: AutoPlayTemplate {
415
515
  @MainActor
416
516
  func updatePlaybackState(isPlaying: Bool) {
417
517
  config.isPlaying = isPlaying
418
-
419
- // Ensure AVAudioSession is active — required for CarPlay Now Playing bar
420
518
  NowPlayingSessionManager.shared.ensureSessionActive()
421
519
 
422
- // If constructor's DispatchQueue.main.async hasn't run yet,
423
- // nowPlayingInfo could be nil. Set it up now to fix the race condition.
424
520
  if !isSetupComplete {
425
- setupNowPlayingButtons()
521
+ updatePlayerUI()
426
522
  updateNowPlayingInfo()
427
523
  isSetupComplete = true
428
524
  }
429
525
 
430
- // Update playback rate — use existing info or create fresh if nil
431
526
  var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
432
527
 
433
528
  if nowPlayingInfo[MPMediaItemPropertyTitle] == nil {
@@ -450,6 +545,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
450
545
  config.title = AutoText(text: title, distance: nil, duration: nil)
451
546
  config.subtitle = AutoText(text: subtitle, distance: nil, duration: nil)
452
547
  updateNowPlayingInfo()
548
+ updatePlayerUI()
453
549
  }
454
550
 
455
551
  @MainActor
@@ -457,7 +553,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
457
553
  self.currentElapsedTime = elapsedTime
458
554
  self.currentDuration = duration
459
555
 
460
- // Update time-related fields — use existing info or create fresh if nil
461
556
  var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
462
557
 
463
558
  if nowPlayingInfo[MPMediaItemPropertyTitle] == nil {
@@ -472,5 +567,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
472
567
  nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsedTime
473
568
  nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
474
569
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
570
+
571
+ updatePlayerUI()
475
572
  }
476
573
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoskola/auto-play",
3
- "version": "0.3.3",
3
+ "version": "0.3.5",
4
4
  "description": "Android Auto and Apple CarPlay for react-native",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",