@neoskola/auto-play 0.3.4 → 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:
|
|
6
|
+
var template: CPListTemplate
|
|
7
7
|
var config: NowPlayingTemplateConfig
|
|
8
8
|
private var loadedImage: UIImage?
|
|
9
9
|
private var isSetupComplete = false
|
|
@@ -34,19 +34,43 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
34
34
|
init(config: NowPlayingTemplateConfig) {
|
|
35
35
|
self.config = config
|
|
36
36
|
|
|
37
|
-
|
|
38
|
-
|
|
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
|
+
)
|
|
39
69
|
|
|
40
|
-
// Constructor runs on the JS thread. Dispatch all CarPlay UI setup to main thread.
|
|
41
|
-
// CPNowPlayingTemplate.shared is Apple's singleton — must be modified on main thread.
|
|
42
70
|
DispatchQueue.main.async { [weak self] in
|
|
43
71
|
guard let self = self else { return }
|
|
44
|
-
|
|
45
|
-
// Activate AVAudioSession FIRST — iOS needs this to recognize the app
|
|
46
|
-
// as a media player before MPNowPlayingInfoCenter metadata is meaningful.
|
|
47
72
|
NowPlayingSessionManager.shared.ensureSessionActive()
|
|
48
|
-
|
|
49
|
-
self.setupNowPlayingButtons()
|
|
73
|
+
self.setupRemoteCommandCenter()
|
|
50
74
|
self.updateNowPlayingInfo()
|
|
51
75
|
self.isSetupComplete = true
|
|
52
76
|
|
|
@@ -56,6 +80,56 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
56
80
|
}
|
|
57
81
|
}
|
|
58
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
|
+
|
|
59
133
|
// MARK: - Native Audio Playback
|
|
60
134
|
|
|
61
135
|
@MainActor
|
|
@@ -73,12 +147,11 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
73
147
|
NowPlayingSessionManager.shared.ensureSessionActive()
|
|
74
148
|
config.isPlaying = true
|
|
75
149
|
updateNowPlayingInfo()
|
|
150
|
+
updatePlayerUI()
|
|
76
151
|
MPNowPlayingInfoCenter.default().playbackState = .playing
|
|
77
152
|
|
|
78
153
|
print("[NowPlayingTemplate] Downloading audio: \(url)")
|
|
79
154
|
|
|
80
|
-
// Download file first, then play from local — R2/Cloudflare CDN streaming
|
|
81
|
-
// causes FigFilePlayer errors with AVPlayer, local playback is reliable
|
|
82
155
|
downloadTask = URLSession.shared.downloadTask(with: audioURL) { [weak self] tempURL, response, error in
|
|
83
156
|
guard let self = self else { return }
|
|
84
157
|
|
|
@@ -92,7 +165,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
92
165
|
return
|
|
93
166
|
}
|
|
94
167
|
|
|
95
|
-
// Move to a persistent temp location (URLSession deletes the file after this block)
|
|
96
168
|
let localURL = FileManager.default.temporaryDirectory
|
|
97
169
|
.appendingPathComponent("carplay_audio_\(UUID().uuidString).mp3")
|
|
98
170
|
|
|
@@ -121,7 +193,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
121
193
|
let asset = AVURLAsset(url: localURL)
|
|
122
194
|
playerItem = AVPlayerItem(asset: asset)
|
|
123
195
|
|
|
124
|
-
// KVO: detect duration as soon as asset loads (critical for progress bar)
|
|
125
196
|
statusObservation = playerItem?.observe(\.status, options: [.new]) { [weak self] item, _ in
|
|
126
197
|
guard item.status == .readyToPlay else {
|
|
127
198
|
if item.status == .failed {
|
|
@@ -135,6 +206,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
135
206
|
if !duration.isNaN && !duration.isInfinite && duration > 0 {
|
|
136
207
|
self.currentDuration = duration
|
|
137
208
|
self.updateNowPlayingInfo()
|
|
209
|
+
self.updatePlayerUI()
|
|
138
210
|
print("[NowPlayingTemplate] Duration resolved via KVO: \(duration)s")
|
|
139
211
|
}
|
|
140
212
|
}
|
|
@@ -147,7 +219,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
147
219
|
player?.seek(to: time)
|
|
148
220
|
}
|
|
149
221
|
|
|
150
|
-
// Periodic time observer (every 1 second) for MPNowPlayingInfoCenter updates
|
|
151
222
|
let interval = CMTime(seconds: 1.0, preferredTimescale: 600)
|
|
152
223
|
timeObserver = player?.addPeriodicTimeObserver(
|
|
153
224
|
forInterval: interval,
|
|
@@ -156,7 +227,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
156
227
|
self?.handleTimeUpdate(time: time)
|
|
157
228
|
}
|
|
158
229
|
|
|
159
|
-
// Playback finished notification
|
|
160
230
|
didFinishObserver = NotificationCenter.default.addObserver(
|
|
161
231
|
forName: .AVPlayerItemDidPlayToEndTime,
|
|
162
232
|
object: playerItem,
|
|
@@ -165,7 +235,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
165
235
|
self?.handlePlaybackFinished()
|
|
166
236
|
}
|
|
167
237
|
|
|
168
|
-
// Progress report timer (every 30 seconds) — calls JS callback for backend reporting
|
|
169
238
|
progressReportTimer = Timer.scheduledTimer(
|
|
170
239
|
withTimeInterval: 30.0,
|
|
171
240
|
repeats: true
|
|
@@ -190,10 +259,8 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
190
259
|
currentDuration = duration
|
|
191
260
|
}
|
|
192
261
|
|
|
193
|
-
// Use existing info or create fresh if nil (race condition safety)
|
|
194
262
|
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
|
|
195
263
|
|
|
196
|
-
// Ensure title/artist are present if this is a fresh dictionary
|
|
197
264
|
if nowPlayingInfo[MPMediaItemPropertyTitle] == nil {
|
|
198
265
|
let titleText = Parser.parseText(text: config.title) ?? ""
|
|
199
266
|
nowPlayingInfo[MPMediaItemPropertyTitle] = titleText
|
|
@@ -209,6 +276,9 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
209
276
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
|
|
210
277
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
211
278
|
|
|
279
|
+
// Update custom player UI
|
|
280
|
+
updatePlayerUI()
|
|
281
|
+
|
|
212
282
|
// 95% completion check
|
|
213
283
|
if !completionFired && currentDuration > 0 && currentTime / currentDuration >= 0.95 {
|
|
214
284
|
completionFired = true
|
|
@@ -226,7 +296,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
226
296
|
private func handlePlaybackFinished() {
|
|
227
297
|
config.isPlaying = false
|
|
228
298
|
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
229
|
-
|
|
299
|
+
updatePlayerUI()
|
|
230
300
|
if !completionFired {
|
|
231
301
|
completionFired = true
|
|
232
302
|
config.onComplete?()
|
|
@@ -239,6 +309,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
239
309
|
player?.pause()
|
|
240
310
|
config.isPlaying = false
|
|
241
311
|
updatePlaybackState(isPlaying: false)
|
|
312
|
+
updatePlayerUI()
|
|
242
313
|
reportProgress()
|
|
243
314
|
}
|
|
244
315
|
|
|
@@ -248,6 +319,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
248
319
|
player?.play()
|
|
249
320
|
config.isPlaying = true
|
|
250
321
|
updatePlaybackState(isPlaying: true)
|
|
322
|
+
updatePlayerUI()
|
|
251
323
|
}
|
|
252
324
|
|
|
253
325
|
@MainActor
|
|
@@ -272,6 +344,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
272
344
|
cleanupPlayer()
|
|
273
345
|
config.isPlaying = false
|
|
274
346
|
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
347
|
+
updatePlayerUI()
|
|
275
348
|
}
|
|
276
349
|
|
|
277
350
|
private func cleanupPlayer() {
|
|
@@ -298,29 +371,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
298
371
|
}
|
|
299
372
|
}
|
|
300
373
|
|
|
301
|
-
// MARK: -
|
|
302
|
-
|
|
303
|
-
private func setupNowPlayingButtons() {
|
|
304
|
-
var buttons: [CPNowPlayingButton] = []
|
|
305
|
-
|
|
306
|
-
let skipBackButton = CPNowPlayingImageButton(
|
|
307
|
-
image: UIImage(systemName: "gobackward.30")!
|
|
308
|
-
) { [weak self] _ in
|
|
309
|
-
self?.seekBackward(seconds: 30)
|
|
310
|
-
self?.config.onSkipBackward?()
|
|
311
|
-
}
|
|
312
|
-
buttons.append(skipBackButton)
|
|
313
|
-
|
|
314
|
-
let skipForwardButton = CPNowPlayingImageButton(
|
|
315
|
-
image: UIImage(systemName: "goforward.30")!
|
|
316
|
-
) { [weak self] _ in
|
|
317
|
-
self?.seekForward(seconds: 30)
|
|
318
|
-
self?.config.onSkipForward?()
|
|
319
|
-
}
|
|
320
|
-
buttons.append(skipForwardButton)
|
|
321
|
-
|
|
322
|
-
template.updateNowPlayingButtons(buttons)
|
|
323
|
-
}
|
|
374
|
+
// MARK: - Now Playing Info & Remote Commands
|
|
324
375
|
|
|
325
376
|
private func updateNowPlayingInfo() {
|
|
326
377
|
let titleText = Parser.parseText(text: config.title) ?? ""
|
|
@@ -339,7 +390,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
339
390
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
|
340
391
|
}
|
|
341
392
|
|
|
342
|
-
// Include duration and elapsed time for the progress bar
|
|
343
393
|
if currentDuration > 0 {
|
|
344
394
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentDuration
|
|
345
395
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
|
|
@@ -347,8 +397,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
347
397
|
}
|
|
348
398
|
|
|
349
399
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
350
|
-
|
|
351
|
-
setupRemoteCommandCenter()
|
|
352
400
|
}
|
|
353
401
|
|
|
354
402
|
private func setupRemoteCommandCenter() {
|
|
@@ -399,6 +447,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
399
447
|
self.player?.seek(to: time)
|
|
400
448
|
self.currentElapsedTime = positionEvent.positionTime
|
|
401
449
|
self.updateNowPlayingInfo()
|
|
450
|
+
self.updatePlayerUI()
|
|
402
451
|
return .success
|
|
403
452
|
}
|
|
404
453
|
}
|
|
@@ -414,6 +463,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
414
463
|
DispatchQueue.main.async {
|
|
415
464
|
self.loadedImage = uiImage
|
|
416
465
|
self.updateNowPlayingInfo()
|
|
466
|
+
self.updatePlayerUI()
|
|
417
467
|
}
|
|
418
468
|
}.resume()
|
|
419
469
|
}
|
|
@@ -423,7 +473,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
423
473
|
|
|
424
474
|
@MainActor
|
|
425
475
|
func invalidate() {
|
|
426
|
-
|
|
476
|
+
updatePlayerUI()
|
|
427
477
|
updateNowPlayingInfo()
|
|
428
478
|
|
|
429
479
|
if loadedImage == nil, let image = config.image {
|
|
@@ -458,7 +508,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
458
508
|
commandCenter.skipBackwardCommand.removeTarget(nil)
|
|
459
509
|
commandCenter.changePlaybackPositionCommand.removeTarget(nil)
|
|
460
510
|
|
|
461
|
-
// Clear now playing info so CarPlay hides the Now Playing bar
|
|
462
511
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
463
512
|
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
464
513
|
}
|
|
@@ -466,19 +515,14 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
466
515
|
@MainActor
|
|
467
516
|
func updatePlaybackState(isPlaying: Bool) {
|
|
468
517
|
config.isPlaying = isPlaying
|
|
469
|
-
|
|
470
|
-
// Ensure AVAudioSession is active — required for CarPlay Now Playing bar
|
|
471
518
|
NowPlayingSessionManager.shared.ensureSessionActive()
|
|
472
519
|
|
|
473
|
-
// If constructor's DispatchQueue.main.async hasn't run yet,
|
|
474
|
-
// nowPlayingInfo could be nil. Set it up now to fix the race condition.
|
|
475
520
|
if !isSetupComplete {
|
|
476
|
-
|
|
521
|
+
updatePlayerUI()
|
|
477
522
|
updateNowPlayingInfo()
|
|
478
523
|
isSetupComplete = true
|
|
479
524
|
}
|
|
480
525
|
|
|
481
|
-
// Update playback rate — use existing info or create fresh if nil
|
|
482
526
|
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
|
|
483
527
|
|
|
484
528
|
if nowPlayingInfo[MPMediaItemPropertyTitle] == nil {
|
|
@@ -501,6 +545,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
501
545
|
config.title = AutoText(text: title, distance: nil, duration: nil)
|
|
502
546
|
config.subtitle = AutoText(text: subtitle, distance: nil, duration: nil)
|
|
503
547
|
updateNowPlayingInfo()
|
|
548
|
+
updatePlayerUI()
|
|
504
549
|
}
|
|
505
550
|
|
|
506
551
|
@MainActor
|
|
@@ -508,7 +553,6 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
508
553
|
self.currentElapsedTime = elapsedTime
|
|
509
554
|
self.currentDuration = duration
|
|
510
555
|
|
|
511
|
-
// Update time-related fields — use existing info or create fresh if nil
|
|
512
556
|
var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo ?? [String: Any]()
|
|
513
557
|
|
|
514
558
|
if nowPlayingInfo[MPMediaItemPropertyTitle] == nil {
|
|
@@ -523,5 +567,7 @@ class NowPlayingTemplate: AutoPlayTemplate {
|
|
|
523
567
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsedTime
|
|
524
568
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
|
|
525
569
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
570
|
+
|
|
571
|
+
updatePlayerUI()
|
|
526
572
|
}
|
|
527
573
|
}
|