@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:
|
|
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
|
|
31
|
+
return template
|
|
31
32
|
}
|
|
32
33
|
|
|
33
34
|
init(config: NowPlayingTemplateConfig) {
|
|
34
35
|
self.config = config
|
|
35
36
|
|
|
36
|
-
|
|
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
|
-
|
|
39
|
-
|
|
71
|
+
if let image = config.image {
|
|
72
|
+
self.loadImageAsync(image: image)
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
}
|
|
40
76
|
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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
|
-
|
|
48
|
-
|
|
49
|
-
|
|
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
|
-
|
|
170
|
+
let infoSection = CPListSection(
|
|
171
|
+
items: [infoItem],
|
|
172
|
+
header: nil,
|
|
173
|
+
sectionIndexTitle: nil
|
|
174
|
+
)
|
|
53
175
|
|
|
54
|
-
|
|
55
|
-
|
|
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
|
}
|