@neoskola/auto-play 0.3.17 → 0.3.19
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.
|
@@ -7,6 +7,11 @@
|
|
|
7
7
|
|
|
8
8
|
import CarPlay
|
|
9
9
|
|
|
10
|
+
private struct NowPlayingPushResult {
|
|
11
|
+
let success: Bool
|
|
12
|
+
let errorDescription: String?
|
|
13
|
+
}
|
|
14
|
+
|
|
10
15
|
@MainActor
|
|
11
16
|
class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
|
|
12
17
|
let interfaceController: CPInterfaceController
|
|
@@ -61,11 +66,28 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
|
|
|
61
66
|
return true
|
|
62
67
|
}
|
|
63
68
|
|
|
64
|
-
let
|
|
65
|
-
if
|
|
66
|
-
|
|
69
|
+
let firstAttempt = await pushNowPlayingTemplateSafely(animated: animated)
|
|
70
|
+
if firstAttempt.success || interfaceController.topTemplate is CPNowPlayingTemplate {
|
|
71
|
+
return true
|
|
67
72
|
}
|
|
68
|
-
|
|
73
|
+
|
|
74
|
+
// CarPlay can reject pushes while handling list selection transitions.
|
|
75
|
+
// Retry once after a short delay to avoid transient failures.
|
|
76
|
+
try? await Task.sleep(nanoseconds: 250_000_000)
|
|
77
|
+
let secondAttempt = await pushNowPlayingTemplateSafely(animated: animated)
|
|
78
|
+
if secondAttempt.success || interfaceController.topTemplate is CPNowPlayingTemplate {
|
|
79
|
+
return true
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
let reason = secondAttempt.errorDescription
|
|
83
|
+
?? firstAttempt.errorDescription
|
|
84
|
+
?? "unknown reason"
|
|
85
|
+
let topTemplateDescription = interfaceController.topTemplate.map {
|
|
86
|
+
String(describing: type(of: $0))
|
|
87
|
+
} ?? "nil"
|
|
88
|
+
throw AutoPlayError.pushFailed(
|
|
89
|
+
"CPNowPlayingTemplate push failed (\(reason)). top=\(topTemplateDescription) stackCount=\(interfaceController.templates.count)"
|
|
90
|
+
)
|
|
69
91
|
}
|
|
70
92
|
|
|
71
93
|
return try await interfaceController.pushTemplate(
|
|
@@ -76,7 +98,7 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
|
|
|
76
98
|
|
|
77
99
|
/// Pushes CPNowPlayingTemplate using ObjC @try/@catch to prevent crash from NSException.
|
|
78
100
|
/// Uses a completion handler to confirm the push actually succeeded.
|
|
79
|
-
private func pushNowPlayingTemplateSafely(animated: Bool) async ->
|
|
101
|
+
private func pushNowPlayingTemplateSafely(animated: Bool) async -> NowPlayingPushResult {
|
|
80
102
|
return await withCheckedContinuation { continuation in
|
|
81
103
|
do {
|
|
82
104
|
try ObjCExceptionCatcher.push(
|
|
@@ -88,12 +110,22 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
|
|
|
88
110
|
print("[AutoPlay] CPNowPlayingTemplate push completion error: \(error.localizedDescription)")
|
|
89
111
|
}
|
|
90
112
|
print("[AutoPlay] CPNowPlayingTemplate push completion: success=\(success)")
|
|
91
|
-
continuation.resume(
|
|
113
|
+
continuation.resume(
|
|
114
|
+
returning: NowPlayingPushResult(
|
|
115
|
+
success: success,
|
|
116
|
+
errorDescription: error?.localizedDescription
|
|
117
|
+
)
|
|
118
|
+
)
|
|
92
119
|
}
|
|
93
120
|
)
|
|
94
121
|
} catch {
|
|
95
122
|
print("[AutoPlay] CPNowPlayingTemplate push threw ObjC exception: \(error.localizedDescription)")
|
|
96
|
-
continuation.resume(
|
|
123
|
+
continuation.resume(
|
|
124
|
+
returning: NowPlayingPushResult(
|
|
125
|
+
success: false,
|
|
126
|
+
errorDescription: error.localizedDescription
|
|
127
|
+
)
|
|
128
|
+
)
|
|
97
129
|
}
|
|
98
130
|
}
|
|
99
131
|
}
|
|
@@ -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,106 @@ 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
|
+
// 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
|
+
)
|
|
69
|
+
|
|
70
|
+
DispatchQueue.main.async { [weak self] in
|
|
71
|
+
guard let self = self else { return }
|
|
72
|
+
NowPlayingSessionManager.shared.ensureSessionActive()
|
|
73
|
+
self.setupRemoteCommandCenter()
|
|
74
|
+
self.updateNowPlayingInfo()
|
|
75
|
+
self.isSetupComplete = true
|
|
37
76
|
|
|
38
|
-
|
|
39
|
-
|
|
77
|
+
if let image = config.image {
|
|
78
|
+
self.loadImageAsync(image: image)
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
}
|
|
40
82
|
|
|
41
|
-
|
|
42
|
-
// so they're ready before CPNowPlayingTemplate.shared is pushed
|
|
43
|
-
NowPlayingSessionManager.shared.ensureSessionActive()
|
|
44
|
-
setupRemoteCommandCenter()
|
|
45
|
-
updateNowPlayingInfo()
|
|
83
|
+
// MARK: - Player UI
|
|
46
84
|
|
|
47
|
-
|
|
48
|
-
|
|
49
|
-
|
|
50
|
-
}
|
|
85
|
+
private func updatePlayerUI() {
|
|
86
|
+
let titleText = Parser.parseText(text: config.title) ?? "Now Playing"
|
|
87
|
+
let subtitleText = config.subtitle.flatMap { Parser.parseText(text: $0) } ?? ""
|
|
51
88
|
|
|
52
|
-
|
|
89
|
+
let infoItem = CPListItem(
|
|
90
|
+
text: titleText,
|
|
91
|
+
detailText: subtitleText,
|
|
92
|
+
image: loadedImage ?? UIImage(systemName: "music.note"),
|
|
93
|
+
accessoryImage: nil,
|
|
94
|
+
accessoryType: .none
|
|
95
|
+
)
|
|
53
96
|
|
|
54
|
-
|
|
55
|
-
|
|
56
|
-
|
|
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)
|
|
57
131
|
}
|
|
58
132
|
|
|
59
133
|
// MARK: - Native Audio Playback
|
|
@@ -73,6 +147,7 @@ class NowPlayingTemplate: NSObject, 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)")
|
|
@@ -131,6 +206,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
131
206
|
if !duration.isNaN && !duration.isInfinite && duration > 0 {
|
|
132
207
|
self.currentDuration = duration
|
|
133
208
|
self.updateNowPlayingInfo()
|
|
209
|
+
self.updatePlayerUI()
|
|
134
210
|
print("[NowPlayingTemplate] Duration resolved via KVO: \(duration)s")
|
|
135
211
|
}
|
|
136
212
|
}
|
|
@@ -200,6 +276,9 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
200
276
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
|
|
201
277
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
202
278
|
|
|
279
|
+
// Update custom player UI
|
|
280
|
+
updatePlayerUI()
|
|
281
|
+
|
|
203
282
|
// 95% completion check
|
|
204
283
|
if !completionFired && currentDuration > 0 && currentTime / currentDuration >= 0.95 {
|
|
205
284
|
completionFired = true
|
|
@@ -217,6 +296,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
217
296
|
private func handlePlaybackFinished() {
|
|
218
297
|
config.isPlaying = false
|
|
219
298
|
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
299
|
+
updatePlayerUI()
|
|
220
300
|
if !completionFired {
|
|
221
301
|
completionFired = true
|
|
222
302
|
config.onComplete?()
|
|
@@ -229,6 +309,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
229
309
|
player?.pause()
|
|
230
310
|
config.isPlaying = false
|
|
231
311
|
updatePlaybackState(isPlaying: false)
|
|
312
|
+
updatePlayerUI()
|
|
232
313
|
reportProgress()
|
|
233
314
|
}
|
|
234
315
|
|
|
@@ -238,6 +319,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
238
319
|
player?.play()
|
|
239
320
|
config.isPlaying = true
|
|
240
321
|
updatePlaybackState(isPlaying: true)
|
|
322
|
+
updatePlayerUI()
|
|
241
323
|
}
|
|
242
324
|
|
|
243
325
|
@MainActor
|
|
@@ -262,6 +344,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
262
344
|
cleanupPlayer()
|
|
263
345
|
config.isPlaying = false
|
|
264
346
|
MPNowPlayingInfoCenter.default().playbackState = .stopped
|
|
347
|
+
updatePlayerUI()
|
|
265
348
|
}
|
|
266
349
|
|
|
267
350
|
private func cleanupPlayer() {
|
|
@@ -307,10 +390,11 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
307
390
|
nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
|
|
308
391
|
}
|
|
309
392
|
|
|
393
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
|
|
394
|
+
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
|
|
395
|
+
|
|
310
396
|
if currentDuration > 0 {
|
|
311
397
|
nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentDuration
|
|
312
|
-
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
|
|
313
|
-
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
|
|
314
398
|
}
|
|
315
399
|
|
|
316
400
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
@@ -378,6 +462,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
378
462
|
self.player?.seek(to: time)
|
|
379
463
|
self.currentElapsedTime = positionEvent.positionTime
|
|
380
464
|
self.updateNowPlayingInfo()
|
|
465
|
+
self.updatePlayerUI()
|
|
381
466
|
return .success
|
|
382
467
|
}
|
|
383
468
|
}
|
|
@@ -393,6 +478,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
393
478
|
DispatchQueue.main.async {
|
|
394
479
|
self.loadedImage = uiImage
|
|
395
480
|
self.updateNowPlayingInfo()
|
|
481
|
+
self.updatePlayerUI()
|
|
396
482
|
}
|
|
397
483
|
}.resume()
|
|
398
484
|
}
|
|
@@ -402,6 +488,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
402
488
|
|
|
403
489
|
@MainActor
|
|
404
490
|
func invalidate() {
|
|
491
|
+
updatePlayerUI()
|
|
405
492
|
updateNowPlayingInfo()
|
|
406
493
|
|
|
407
494
|
if loadedImage == nil, let image = config.image {
|
|
@@ -448,6 +535,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
448
535
|
NowPlayingSessionManager.shared.ensureSessionActive()
|
|
449
536
|
|
|
450
537
|
if !isSetupComplete {
|
|
538
|
+
updatePlayerUI()
|
|
451
539
|
updateNowPlayingInfo()
|
|
452
540
|
isSetupComplete = true
|
|
453
541
|
}
|
|
@@ -474,6 +562,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
474
562
|
config.title = AutoText(text: title, distance: nil, duration: nil)
|
|
475
563
|
config.subtitle = AutoText(text: subtitle, distance: nil, duration: nil)
|
|
476
564
|
updateNowPlayingInfo()
|
|
565
|
+
updatePlayerUI()
|
|
477
566
|
}
|
|
478
567
|
|
|
479
568
|
@MainActor
|
|
@@ -495,5 +584,7 @@ class NowPlayingTemplate: NSObject, AutoPlayTemplate {
|
|
|
495
584
|
nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsedTime
|
|
496
585
|
nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
|
|
497
586
|
MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
|
|
587
|
+
|
|
588
|
+
updatePlayerUI()
|
|
498
589
|
}
|
|
499
590
|
}
|