@neoskola/auto-play 0.3.18 → 0.3.20

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.
@@ -2,7 +2,8 @@ import CarPlay
2
2
  import MediaPlayer
3
3
  import AVFoundation
4
4
 
5
- class NowPlayingTemplate: NSObject, AutoPlayTemplate {
5
+ class NowPlayingTemplate: AutoPlayTemplate {
6
+ var template: CPListTemplate
6
7
  var config: NowPlayingTemplateConfig
7
8
  private var loadedImage: UIImage?
8
9
  private var isSetupComplete = false
@@ -27,33 +28,184 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
27
28
  }
28
29
 
29
30
  func getTemplate() -> CPTemplate {
30
- return CPNowPlayingTemplate.shared
31
+ return template
31
32
  }
32
33
 
33
34
  init(config: NowPlayingTemplateConfig) {
34
35
  self.config = config
35
36
 
36
- super.init()
37
+ // CarPlay-safe custom player screen using CPListTemplate.
38
+ let titleText = Parser.parseText(text: config.title) ?? "Now Playing"
39
+ let subtitleText = config.subtitle.flatMap { Parser.parseText(text: $0) } ?? ""
40
+
41
+ let initialInfoItem = CPListItem(
42
+ text: titleText,
43
+ detailText: subtitleText,
44
+ image: UIImage(systemName: "music.note"),
45
+ accessoryImage: nil,
46
+ accessoryType: .none
47
+ )
48
+ initialInfoItem.isEnabled = false
49
+
50
+ let initialSection = CPListSection(
51
+ items: [initialInfoItem],
52
+ header: nil,
53
+ sectionIndexTitle: nil
54
+ )
55
+
56
+ template = CPListTemplate(
57
+ title: "Now Playing",
58
+ sections: [initialSection],
59
+ assistantCellConfiguration: nil,
60
+ id: config.id
61
+ )
62
+
63
+ DispatchQueue.main.async { [weak self] in
64
+ guard let self = self else { return }
65
+ NowPlayingSessionManager.shared.ensureSessionActive()
66
+ self.setupRemoteCommandCenter()
67
+ self.updatePlayerUI()
68
+ self.updateNowPlayingInfo()
69
+ self.isSetupComplete = true
37
70
 
38
- // Set the config ID on the shared singleton so TemplateStore can find it
39
- initTemplate(template: CPNowPlayingTemplate.shared, id: config.id)
71
+ if let image = config.image {
72
+ self.loadImageAsync(image: image)
73
+ }
74
+ }
75
+ }
40
76
 
41
- // Setup remote commands and now playing info synchronously
42
- // so they're ready before CPNowPlayingTemplate.shared is pushed
43
- NowPlayingSessionManager.shared.ensureSessionActive()
44
- setupRemoteCommandCenter()
45
- updateNowPlayingInfo()
77
+ // MARK: - Player UI
78
+
79
+ private func updatePlayerUI() {
80
+ let titleText = Parser.parseText(text: config.title) ?? "Now Playing"
81
+ let subtitleText = config.subtitle.flatMap { Parser.parseText(text: $0) } ?? ""
82
+ let elapsedText = formatTime(currentElapsedTime)
83
+ let totalText = currentDuration > 0 ? formatTime(currentDuration) : "--:--"
84
+ let stateText = config.isPlaying ? "Playing" : "Paused"
85
+ let stateIcon = config.isPlaying ? "play.circle.fill" : "pause.circle.fill"
86
+
87
+ let infoItem = CPListItem(
88
+ text: titleText,
89
+ detailText: subtitleText,
90
+ image: loadedImage ?? UIImage(systemName: "music.note"),
91
+ accessoryImage: nil,
92
+ accessoryType: .none
93
+ )
94
+ infoItem.isEnabled = false
95
+
96
+ let timingItem = CPListItem(
97
+ text: "\(elapsedText) / \(totalText)",
98
+ detailText: stateText,
99
+ image: UIImage(systemName: stateIcon),
100
+ accessoryImage: nil,
101
+ accessoryType: .none
102
+ )
103
+ timingItem.isEnabled = false
104
+
105
+ let progressPercent: Int
106
+ if currentDuration > 0 {
107
+ let ratio = max(0.0, min(currentElapsedTime / currentDuration, 1.0))
108
+ progressPercent = Int(ratio * 100.0)
109
+ } else {
110
+ progressPercent = 0
111
+ }
112
+ let progressItem = CPListItem(
113
+ text: "Progress \(progressPercent)%",
114
+ detailText: progressBarText(elapsed: currentElapsedTime, duration: currentDuration),
115
+ image: UIImage(systemName: "waveform.path.ecg"),
116
+ accessoryImage: nil,
117
+ accessoryType: .none
118
+ )
119
+ progressItem.isEnabled = false
120
+
121
+ let previousItem = CPListItem(
122
+ text: "Previous Lesson",
123
+ detailText: nil,
124
+ image: UIImage(systemName: "backward.fill"),
125
+ accessoryImage: nil,
126
+ accessoryType: .none
127
+ )
128
+ previousItem.isEnabled = config.onPreviousTrack != nil
129
+ previousItem.handler = { [weak self] _, completion in
130
+ self?.config.onPreviousTrack?()
131
+ completion()
132
+ }
133
+
134
+ let playPauseItem = CPListItem(
135
+ text: config.isPlaying ? "Pause" : "Play",
136
+ detailText: nil,
137
+ image: UIImage(systemName: config.isPlaying ? "pause.fill" : "play.fill"),
138
+ accessoryImage: nil,
139
+ accessoryType: .none
140
+ )
141
+ playPauseItem.handler = { [weak self] _, completion in
142
+ guard let self = self else {
143
+ completion()
144
+ return
145
+ }
46
146
 
47
- // Set playbackState before push so CarPlay sees an active now-playing session
48
- if config.isPlaying {
49
- MPNowPlayingInfoCenter.default().playbackState = .playing
147
+ if self.config.isPlaying {
148
+ self.pauseAudio()
149
+ self.config.onPause?()
150
+ } else {
151
+ self.resumeAudio()
152
+ self.config.onPlay?()
153
+ }
154
+ completion()
155
+ }
156
+
157
+ let nextItem = CPListItem(
158
+ text: "Next Lesson",
159
+ detailText: nil,
160
+ image: UIImage(systemName: "forward.fill"),
161
+ accessoryImage: nil,
162
+ accessoryType: .none
163
+ )
164
+ nextItem.isEnabled = config.onNextTrack != nil
165
+ nextItem.handler = { [weak self] _, completion in
166
+ self?.config.onNextTrack?()
167
+ completion()
50
168
  }
51
169
 
52
- isSetupComplete = true
170
+ let infoSection = CPListSection(
171
+ items: [infoItem],
172
+ header: nil,
173
+ sectionIndexTitle: nil
174
+ )
53
175
 
54
- if let image = config.image {
55
- loadImageAsync(image: image)
176
+ let timelineSection = CPListSection(
177
+ items: [timingItem, progressItem],
178
+ header: nil,
179
+ sectionIndexTitle: nil
180
+ )
181
+
182
+ let controlSection = CPListSection(
183
+ items: [previousItem, playPauseItem, nextItem],
184
+ header: nil,
185
+ sectionIndexTitle: nil
186
+ )
187
+
188
+ template.updateSections([infoSection, timelineSection, controlSection])
189
+ }
190
+
191
+ private func formatTime(_ seconds: Double) -> String {
192
+ guard !seconds.isNaN && !seconds.isInfinite && seconds >= 0 else { return "0:00" }
193
+ let mins = Int(seconds) / 60
194
+ let secs = Int(seconds) % 60
195
+ return String(format: "%d:%02d", mins, secs)
196
+ }
197
+
198
+ private func progressBarText(elapsed: Double, duration: Double) -> String {
199
+ let totalBars = 16
200
+ guard duration > 0, !duration.isNaN, !duration.isInfinite else {
201
+ return "[----------------]"
56
202
  }
203
+
204
+ let clamped = max(0.0, min(elapsed / duration, 1.0))
205
+ let filledCount = Int((clamped * Double(totalBars)).rounded(.towardZero))
206
+ let filled = String(repeating: "#", count: filledCount)
207
+ let empty = String(repeating: "-", count: max(totalBars - filledCount, 0))
208
+ return "[\(filled)\(empty)]"
57
209
  }
58
210
 
59
211
  // MARK: - Native Audio Playback
@@ -73,6 +225,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
73
225
  NowPlayingSessionManager.shared.ensureSessionActive()
74
226
  config.isPlaying = true
75
227
  updateNowPlayingInfo()
228
+ updatePlayerUI()
76
229
  MPNowPlayingInfoCenter.default().playbackState = .playing
77
230
 
78
231
  print("[NowPlayingTemplate] Downloading audio: \(url)")
@@ -131,6 +284,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
131
284
  if !duration.isNaN && !duration.isInfinite && duration > 0 {
132
285
  self.currentDuration = duration
133
286
  self.updateNowPlayingInfo()
287
+ self.updatePlayerUI()
134
288
  print("[NowPlayingTemplate] Duration resolved via KVO: \(duration)s")
135
289
  }
136
290
  }
@@ -200,6 +354,9 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
200
354
  nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
201
355
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
202
356
 
357
+ // Update custom player UI
358
+ updatePlayerUI()
359
+
203
360
  // 95% completion check
204
361
  if !completionFired && currentDuration > 0 && currentTime / currentDuration >= 0.95 {
205
362
  completionFired = true
@@ -217,6 +374,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
217
374
  private func handlePlaybackFinished() {
218
375
  config.isPlaying = false
219
376
  MPNowPlayingInfoCenter.default().playbackState = .stopped
377
+ updatePlayerUI()
220
378
  if !completionFired {
221
379
  completionFired = true
222
380
  config.onComplete?()
@@ -229,6 +387,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
229
387
  player?.pause()
230
388
  config.isPlaying = false
231
389
  updatePlaybackState(isPlaying: false)
390
+ updatePlayerUI()
232
391
  reportProgress()
233
392
  }
234
393
 
@@ -238,6 +397,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
238
397
  player?.play()
239
398
  config.isPlaying = true
240
399
  updatePlaybackState(isPlaying: true)
400
+ updatePlayerUI()
241
401
  }
242
402
 
243
403
  @MainActor
@@ -262,6 +422,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
262
422
  cleanupPlayer()
263
423
  config.isPlaying = false
264
424
  MPNowPlayingInfoCenter.default().playbackState = .stopped
425
+ updatePlayerUI()
265
426
  }
266
427
 
267
428
  private func cleanupPlayer() {
@@ -307,8 +468,6 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
307
468
  nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
308
469
  }
309
470
 
310
- // Keep playback metadata populated even before duration is known.
311
- // This helps CarPlay recognize an active now-playing session.
312
471
  nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
313
472
  nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
314
473
 
@@ -381,6 +540,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
381
540
  self.player?.seek(to: time)
382
541
  self.currentElapsedTime = positionEvent.positionTime
383
542
  self.updateNowPlayingInfo()
543
+ self.updatePlayerUI()
384
544
  return .success
385
545
  }
386
546
  }
@@ -396,6 +556,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
396
556
  DispatchQueue.main.async {
397
557
  self.loadedImage = uiImage
398
558
  self.updateNowPlayingInfo()
559
+ self.updatePlayerUI()
399
560
  }
400
561
  }.resume()
401
562
  }
@@ -405,6 +566,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
405
566
 
406
567
  @MainActor
407
568
  func invalidate() {
569
+ updatePlayerUI()
408
570
  updateNowPlayingInfo()
409
571
 
410
572
  if loadedImage == nil, let image = config.image {
@@ -451,6 +613,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
451
613
  NowPlayingSessionManager.shared.ensureSessionActive()
452
614
 
453
615
  if !isSetupComplete {
616
+ updatePlayerUI()
454
617
  updateNowPlayingInfo()
455
618
  isSetupComplete = true
456
619
  }
@@ -477,6 +640,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
477
640
  config.title = AutoText(text: title, distance: nil, duration: nil)
478
641
  config.subtitle = AutoText(text: subtitle, distance: nil, duration: nil)
479
642
  updateNowPlayingInfo()
643
+ updatePlayerUI()
480
644
  }
481
645
 
482
646
  @MainActor
@@ -498,5 +662,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
498
662
  nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsedTime
499
663
  nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
500
664
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
665
+
666
+ updatePlayerUI()
501
667
  }
502
668
  }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoskola/auto-play",
3
- "version": "0.3.18",
3
+ "version": "0.3.20",
4
4
  "description": "Android Auto and Apple CarPlay for react-native",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",