@neoskola/auto-play 0.2.9 → 0.2.11

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.
@@ -33,4 +33,14 @@ class HybridNowPlayingTemplate : HybridNowPlayingTemplateSpec() {
33
33
  template.updateInfo(title, subtitle)
34
34
  }
35
35
  }
36
+
37
+ override fun updateNowPlayingTemplateElapsedTime(
38
+ templateId: String,
39
+ elapsedTime: Double,
40
+ duration: Double
41
+ ): Promise<Unit> {
42
+ return Promise.async {
43
+ // Android Auto manages its own playback UI
44
+ }
45
+ }
36
46
  }
@@ -41,4 +41,21 @@ class HybridNowPlayingTemplate: HybridNowPlayingTemplateSpec {
41
41
  await template.updateInfo(title: title, subtitle: subtitle)
42
42
  }
43
43
  }
44
+
45
+ func updateNowPlayingTemplateElapsedTime(
46
+ templateId: String,
47
+ elapsedTime: Double,
48
+ duration: Double
49
+ ) throws -> Promise<Void> {
50
+ return Promise.async {
51
+ guard
52
+ let template = TemplateStore.getTemplate(templateId: templateId)
53
+ as? NowPlayingTemplate
54
+ else {
55
+ throw AutoPlayError.templateNotFound(templateId)
56
+ }
57
+
58
+ await template.updateElapsedTime(elapsedTime: elapsedTime, duration: duration)
59
+ }
60
+ }
44
61
  }
@@ -51,14 +51,17 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
51
51
  _ templateToPush: CPTemplate,
52
52
  animated: Bool
53
53
  ) async throws -> Bool {
54
- // CPNowPlayingTemplate.shared must NEVER be pushed via pushTemplate().
55
- // Apple's CarPlay framework throws an uncatchable NSException (Objective-C)
56
- // when attempting to push this singleton. The Now Playing UI appears
57
- // automatically when MPNowPlayingInfoCenter is configured and playbackState
58
- // is set to .playing.
54
+ // CPNowPlayingTemplate.shared can throw an ObjC NSException when pushed.
55
+ // Swift try/catch cannot catch NSExceptions, so we use an ObjC wrapper.
59
56
  if templateToPush is CPNowPlayingTemplate {
60
- print("[AutoPlay] CPNowPlayingTemplate cannot be pushed skipping. Configure MPNowPlayingInfoCenter instead.")
61
- return true
57
+ // Skip if already in the stack
58
+ let alreadyInStack = interfaceController.templates.contains(where: { $0 is CPNowPlayingTemplate })
59
+ if alreadyInStack {
60
+ print("[AutoPlay] CPNowPlayingTemplate already in stack, skipping push")
61
+ return true
62
+ }
63
+
64
+ return await pushNowPlayingTemplateSafely(animated: animated)
62
65
  }
63
66
 
64
67
  return try await interfaceController.pushTemplate(
@@ -67,6 +70,29 @@ class AutoPlayInterfaceController: NSObject, CPInterfaceControllerDelegate {
67
70
  )
68
71
  }
69
72
 
73
+ /// Pushes CPNowPlayingTemplate using ObjC @try/@catch to prevent crash from NSException.
74
+ private func pushNowPlayingTemplateSafely(animated: Bool) async -> Bool {
75
+ return await withCheckedContinuation { continuation in
76
+ var pushError: NSError?
77
+ let success = ObjCExceptionCatcher.tryBlock({
78
+ self.interfaceController.pushTemplate(
79
+ CPNowPlayingTemplate.shared,
80
+ animated: animated
81
+ ) { success, error in
82
+ if let error = error {
83
+ print("[AutoPlay] CPNowPlayingTemplate push completion error: \(error)")
84
+ }
85
+ continuation.resume(returning: success)
86
+ }
87
+ }, error: &pushError)
88
+
89
+ if !success {
90
+ print("[AutoPlay] CPNowPlayingTemplate push threw ObjC exception: \(pushError?.localizedDescription ?? "unknown")")
91
+ continuation.resume(returning: false)
92
+ }
93
+ }
94
+ }
95
+
70
96
  func setRootTemplate(
71
97
  _ rootTemplate: CPTemplate,
72
98
  animated: Bool
@@ -5,6 +5,9 @@ class NowPlayingTemplate: AutoPlayTemplate {
5
5
  var template: CPNowPlayingTemplate
6
6
  var config: NowPlayingTemplateConfig
7
7
  private var loadedImage: UIImage?
8
+ private var isSetupComplete = false
9
+ private var currentElapsedTime: Double = 0
10
+ private var currentDuration: Double = 0
8
11
 
9
12
  var autoDismissMs: Double? {
10
13
  return config.autoDismissMs
@@ -22,12 +25,17 @@ class NowPlayingTemplate: AutoPlayTemplate {
22
25
 
23
26
  // Constructor runs on the JS thread. Dispatch all CarPlay UI setup to main thread.
24
27
  // CPNowPlayingTemplate.shared is Apple's singleton — must be modified on main thread.
25
- // This also ensures MPNowPlayingInfoCenter and MPRemoteCommandCenter are configured
26
- // even if push() is never called (CarPlay shows "Now Playing" bar automatically).
27
28
  DispatchQueue.main.async { [weak self] in
28
29
  guard let self = self else { return }
30
+
31
+ // Activate AVAudioSession FIRST — iOS needs this to recognize the app
32
+ // as a media player before MPNowPlayingInfoCenter metadata is meaningful.
33
+ NowPlayingSessionManager.shared.ensureSessionActive()
34
+
29
35
  self.setupNowPlayingButtons()
30
36
  self.updateNowPlayingInfo()
37
+ self.isSetupComplete = true
38
+
31
39
  if let image = config.image {
32
40
  self.loadImageAsync(image: image)
33
41
  }
@@ -71,6 +79,13 @@ class NowPlayingTemplate: AutoPlayTemplate {
71
79
  nowPlayingInfo[MPMediaItemPropertyArtwork] = artwork
72
80
  }
73
81
 
82
+ // Include duration and elapsed time for the progress bar
83
+ if currentDuration > 0 {
84
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = currentDuration
85
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
86
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
87
+ }
88
+
74
89
  MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
75
90
 
76
91
  setupRemoteCommandCenter()
@@ -108,6 +123,18 @@ class NowPlayingTemplate: AutoPlayTemplate {
108
123
  self?.config.onSkipBackward?()
109
124
  return .success
110
125
  }
126
+
127
+ commandCenter.changePlaybackPositionCommand.isEnabled = true
128
+ commandCenter.changePlaybackPositionCommand.removeTarget(nil)
129
+ commandCenter.changePlaybackPositionCommand.addTarget { [weak self] event in
130
+ guard let self = self,
131
+ let positionEvent = event as? MPChangePlaybackPositionCommandEvent else {
132
+ return .commandFailed
133
+ }
134
+ self.currentElapsedTime = positionEvent.positionTime
135
+ self.updateNowPlayingInfo()
136
+ return .success
137
+ }
111
138
  }
112
139
 
113
140
  private func loadImageAsync(image: Variant_GlyphImage_AssetImage) {
@@ -158,11 +185,35 @@ class NowPlayingTemplate: AutoPlayTemplate {
158
185
  commandCenter.pauseCommand.removeTarget(nil)
159
186
  commandCenter.skipForwardCommand.removeTarget(nil)
160
187
  commandCenter.skipBackwardCommand.removeTarget(nil)
188
+ commandCenter.changePlaybackPositionCommand.removeTarget(nil)
189
+
190
+ // Clear now playing info so CarPlay hides the Now Playing bar
191
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
192
+ MPNowPlayingInfoCenter.default().playbackState = .stopped
161
193
  }
162
194
 
163
195
  @MainActor
164
196
  func updatePlaybackState(isPlaying: Bool) {
165
197
  config.isPlaying = isPlaying
198
+
199
+ // Ensure AVAudioSession is active — required for CarPlay Now Playing bar
200
+ NowPlayingSessionManager.shared.ensureSessionActive()
201
+
202
+ // If constructor's DispatchQueue.main.async hasn't run yet,
203
+ // nowPlayingInfo could be nil. Set it up now to fix the race condition.
204
+ if !isSetupComplete {
205
+ setupNowPlayingButtons()
206
+ updateNowPlayingInfo()
207
+ isSetupComplete = true
208
+ }
209
+
210
+ // Update playback rate in existing nowPlayingInfo
211
+ if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
212
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = isPlaying ? 1.0 : 0.0
213
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentElapsedTime
214
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
215
+ }
216
+
166
217
  MPNowPlayingInfoCenter.default().playbackState = isPlaying ? .playing : .paused
167
218
  }
168
219
 
@@ -172,4 +223,18 @@ class NowPlayingTemplate: AutoPlayTemplate {
172
223
  config.subtitle = AutoText(text: subtitle, distance: nil, duration: nil)
173
224
  updateNowPlayingInfo()
174
225
  }
226
+
227
+ @MainActor
228
+ func updateElapsedTime(elapsedTime: Double, duration: Double) {
229
+ self.currentElapsedTime = elapsedTime
230
+ self.currentDuration = duration
231
+
232
+ // Update only time-related fields without rebuilding everything
233
+ if var nowPlayingInfo = MPNowPlayingInfoCenter.default().nowPlayingInfo {
234
+ nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = duration
235
+ nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = elapsedTime
236
+ nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = config.isPlaying ? 1.0 : 0.0
237
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo
238
+ }
239
+ }
175
240
  }
@@ -0,0 +1,49 @@
1
+ import AVFoundation
2
+
3
+ /// Manages AVAudioSession activation for CarPlay Now Playing integration.
4
+ /// iOS requires an explicit AVAudioSession.setCategory(.playback) + setActive(true)
5
+ /// for MPNowPlayingInfoCenter to be recognized by the system and the Now Playing
6
+ /// bar to appear in CarPlay.
7
+ class NowPlayingSessionManager {
8
+ static let shared = NowPlayingSessionManager()
9
+
10
+ private var isSessionActivated = false
11
+
12
+ private init() {}
13
+
14
+ /// Ensures AVAudioSession is configured for playback and activated.
15
+ /// Safe to call multiple times — only activates once.
16
+ func ensureSessionActive() {
17
+ guard !isSessionActivated else { return }
18
+
19
+ let session = AVAudioSession.sharedInstance()
20
+
21
+ do {
22
+ try session.setCategory(
23
+ .playback,
24
+ mode: .default,
25
+ options: []
26
+ )
27
+ try session.setActive(true)
28
+ isSessionActivated = true
29
+ print("[NowPlayingSessionManager] AVAudioSession activated with .playback category")
30
+ } catch {
31
+ print("[NowPlayingSessionManager] Failed to activate AVAudioSession: \(error)")
32
+ }
33
+ }
34
+
35
+ /// Deactivates the audio session.
36
+ func deactivateSession() {
37
+ guard isSessionActivated else { return }
38
+
39
+ let session = AVAudioSession.sharedInstance()
40
+
41
+ do {
42
+ try session.setActive(false, options: .notifyOthersOnDeactivation)
43
+ isSessionActivated = false
44
+ print("[NowPlayingSessionManager] AVAudioSession deactivated")
45
+ } catch {
46
+ print("[NowPlayingSessionManager] Failed to deactivate AVAudioSession: \(error)")
47
+ }
48
+ }
49
+ }
@@ -0,0 +1,12 @@
1
+ #import <Foundation/Foundation.h>
2
+
3
+ /// Provides Objective-C @try/@catch exception handling for Swift code.
4
+ /// Swift's try/catch cannot catch Objective-C NSExceptions, which causes
5
+ /// crashes when Apple frameworks throw them (e.g., CPInterfaceController.pushTemplate
6
+ /// with CPNowPlayingTemplate). This wrapper makes those exceptions catchable.
7
+ @interface ObjCExceptionCatcher : NSObject
8
+
9
+ + (BOOL)tryBlock:(void(NS_NOESCAPE ^_Nonnull)(void))tryBlock
10
+ error:(NSError * _Nullable * _Nullable)error;
11
+
12
+ @end
@@ -0,0 +1,23 @@
1
+ #import "ObjCExceptionCatcher.h"
2
+
3
+ @implementation ObjCExceptionCatcher
4
+
5
+ + (BOOL)tryBlock:(void(NS_NOESCAPE ^)(void))tryBlock error:(NSError **)error {
6
+ @try {
7
+ tryBlock();
8
+ return YES;
9
+ } @catch (NSException *exception) {
10
+ NSLog(@"[ObjCExceptionCatcher] Caught exception: %@ — %@", exception.name, exception.reason);
11
+ if (error) {
12
+ *error = [NSError errorWithDomain:@"ObjCExceptionCatcher"
13
+ code:0
14
+ userInfo:@{
15
+ NSLocalizedDescriptionKey: exception.reason ?: @"Unknown Objective-C exception",
16
+ @"ExceptionName": exception.name ?: @"Unknown"
17
+ }];
18
+ }
19
+ return NO;
20
+ }
21
+ }
22
+
23
+ @end
@@ -10,5 +10,6 @@ export interface NowPlayingTemplate extends HybridObject<{
10
10
  createNowPlayingTemplate(config: NowPlayingTemplateConfig): void;
11
11
  updateNowPlayingTemplatePlaybackState(templateId: string, isPlaying: boolean): Promise<void>;
12
12
  updateNowPlayingTemplateInfo(templateId: string, title: string, subtitle: string): Promise<void>;
13
+ updateNowPlayingTemplateElapsedTime(templateId: string, elapsedTime: number, duration: number): Promise<void>;
13
14
  }
14
15
  export {};
@@ -22,4 +22,5 @@ export declare class NowPlayingTemplate extends Template<NowPlayingTemplateConfi
22
22
  constructor(config: NowPlayingTemplateConfig);
23
23
  updatePlaybackState(isPlaying: boolean): Promise<void>;
24
24
  updateNowPlayingInfo(title: string, subtitle: string): Promise<void>;
25
+ updateElapsedTime(elapsedTime: number, duration: number): Promise<void>;
25
26
  }
@@ -19,4 +19,7 @@ export class NowPlayingTemplate extends Template {
19
19
  updateNowPlayingInfo(title, subtitle) {
20
20
  return HybridNowPlayingTemplate.updateNowPlayingTemplateInfo(this.id, title, subtitle);
21
21
  }
22
+ updateElapsedTime(elapsedTime, duration) {
23
+ return HybridNowPlayingTemplate.updateNowPlayingTemplateElapsedTime(this.id, elapsedTime, duration);
24
+ }
22
25
  }
@@ -113,5 +113,20 @@ namespace margelo::nitro::swe::iternio::reactnativeautoplay {
113
113
  return __promise;
114
114
  }();
115
115
  }
116
+ std::shared_ptr<Promise<void>> JHybridNowPlayingTemplateSpec::updateNowPlayingTemplateElapsedTime(const std::string& templateId, double elapsedTime, double duration) {
117
+ static const auto method = javaClassStatic()->getMethod<jni::local_ref<JPromise::javaobject>(jni::alias_ref<jni::JString> /* templateId */, double /* elapsedTime */, double /* duration */)>("updateNowPlayingTemplateElapsedTime");
118
+ auto __result = method(_javaPart, jni::make_jstring(templateId), elapsedTime, duration);
119
+ return [&]() {
120
+ auto __promise = Promise<void>::create();
121
+ __result->cthis()->addOnResolvedListener([=](const jni::alias_ref<jni::JObject>& /* unit */) {
122
+ __promise->resolve();
123
+ });
124
+ __result->cthis()->addOnRejectedListener([=](const jni::alias_ref<jni::JThrowable>& __throwable) {
125
+ jni::JniException __jniError(__throwable);
126
+ __promise->reject(std::make_exception_ptr(__jniError));
127
+ });
128
+ return __promise;
129
+ }();
130
+ }
116
131
 
117
132
  } // namespace margelo::nitro::swe::iternio::reactnativeautoplay
@@ -57,6 +57,7 @@ namespace margelo::nitro::swe::iternio::reactnativeautoplay {
57
57
  void createNowPlayingTemplate(const NowPlayingTemplateConfig& config) override;
58
58
  std::shared_ptr<Promise<void>> updateNowPlayingTemplatePlaybackState(const std::string& templateId, bool isPlaying) override;
59
59
  std::shared_ptr<Promise<void>> updateNowPlayingTemplateInfo(const std::string& templateId, const std::string& title, const std::string& subtitle) override;
60
+ std::shared_ptr<Promise<void>> updateNowPlayingTemplateElapsedTime(const std::string& templateId, double elapsedTime, double duration) override;
60
61
 
61
62
  private:
62
63
  friend HybridBase;
@@ -57,6 +57,10 @@ abstract class HybridNowPlayingTemplateSpec: HybridObject() {
57
57
  @DoNotStrip
58
58
  @Keep
59
59
  abstract fun updateNowPlayingTemplateInfo(templateId: String, title: String, subtitle: String): Promise<Unit>
60
+
61
+ @DoNotStrip
62
+ @Keep
63
+ abstract fun updateNowPlayingTemplateElapsedTime(templateId: String, elapsedTime: Double, duration: Double): Promise<Unit>
60
64
 
61
65
  private external fun initHybrid(): HybridData
62
66
 
@@ -106,6 +106,14 @@ namespace margelo::nitro::swe::iternio::reactnativeautoplay {
106
106
  auto __value = std::move(__result.value());
107
107
  return __value;
108
108
  }
109
+ inline std::shared_ptr<Promise<void>> updateNowPlayingTemplateElapsedTime(const std::string& templateId, double elapsedTime, double duration) override {
110
+ auto __result = _swiftPart.updateNowPlayingTemplateElapsedTime(templateId, std::forward<decltype(elapsedTime)>(elapsedTime), std::forward<decltype(duration)>(duration));
111
+ if (__result.hasError()) [[unlikely]] {
112
+ std::rethrow_exception(__result.error());
113
+ }
114
+ auto __value = std::move(__result.value());
115
+ return __value;
116
+ }
109
117
 
110
118
  private:
111
119
  ReactNativeAutoPlay::HybridNowPlayingTemplateSpec_cxx _swiftPart;
@@ -17,6 +17,7 @@ public protocol HybridNowPlayingTemplateSpec_protocol: HybridObject {
17
17
  func createNowPlayingTemplate(config: NowPlayingTemplateConfig) throws -> Void
18
18
  func updateNowPlayingTemplatePlaybackState(templateId: String, isPlaying: Bool) throws -> Promise<Void>
19
19
  func updateNowPlayingTemplateInfo(templateId: String, title: String, subtitle: String) throws -> Promise<Void>
20
+ func updateNowPlayingTemplateElapsedTime(templateId: String, elapsedTime: Double, duration: Double) throws -> Promise<Void>
20
21
  }
21
22
 
22
23
  public extension HybridNowPlayingTemplateSpec_protocol {
@@ -165,4 +165,23 @@ open class HybridNowPlayingTemplateSpec_cxx {
165
165
  return bridge.create_Result_std__shared_ptr_Promise_void___(__exceptionPtr)
166
166
  }
167
167
  }
168
+
169
+ @inline(__always)
170
+ public final func updateNowPlayingTemplateElapsedTime(templateId: std.string, elapsedTime: Double, duration: Double) -> bridge.Result_std__shared_ptr_Promise_void___ {
171
+ do {
172
+ let __result = try self.__implementation.updateNowPlayingTemplateElapsedTime(templateId: String(templateId), elapsedTime: elapsedTime, duration: duration)
173
+ let __resultCpp = { () -> bridge.std__shared_ptr_Promise_void__ in
174
+ let __promise = bridge.create_std__shared_ptr_Promise_void__()
175
+ let __promiseHolder = bridge.wrap_std__shared_ptr_Promise_void__(__promise)
176
+ __result
177
+ .then({ __result in __promiseHolder.resolve() })
178
+ .catch({ __error in __promiseHolder.reject(__error.toCpp()) })
179
+ return __promise
180
+ }()
181
+ return bridge.create_Result_std__shared_ptr_Promise_void___(__resultCpp)
182
+ } catch (let __error) {
183
+ let __exceptionPtr = __error.toCpp()
184
+ return bridge.create_Result_std__shared_ptr_Promise_void___(__exceptionPtr)
185
+ }
186
+ }
168
187
  }
@@ -17,6 +17,7 @@ namespace margelo::nitro::swe::iternio::reactnativeautoplay {
17
17
  prototype.registerHybridMethod("createNowPlayingTemplate", &HybridNowPlayingTemplateSpec::createNowPlayingTemplate);
18
18
  prototype.registerHybridMethod("updateNowPlayingTemplatePlaybackState", &HybridNowPlayingTemplateSpec::updateNowPlayingTemplatePlaybackState);
19
19
  prototype.registerHybridMethod("updateNowPlayingTemplateInfo", &HybridNowPlayingTemplateSpec::updateNowPlayingTemplateInfo);
20
+ prototype.registerHybridMethod("updateNowPlayingTemplateElapsedTime", &HybridNowPlayingTemplateSpec::updateNowPlayingTemplateElapsedTime);
20
21
  });
21
22
  }
22
23
 
@@ -54,6 +54,7 @@ namespace margelo::nitro::swe::iternio::reactnativeautoplay {
54
54
  virtual void createNowPlayingTemplate(const NowPlayingTemplateConfig& config) = 0;
55
55
  virtual std::shared_ptr<Promise<void>> updateNowPlayingTemplatePlaybackState(const std::string& templateId, bool isPlaying) = 0;
56
56
  virtual std::shared_ptr<Promise<void>> updateNowPlayingTemplateInfo(const std::string& templateId, const std::string& title, const std::string& subtitle) = 0;
57
+ virtual std::shared_ptr<Promise<void>> updateNowPlayingTemplateElapsedTime(const std::string& templateId, double elapsedTime, double duration) = 0;
57
58
 
58
59
  protected:
59
60
  // Hybrid Setup
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@neoskola/auto-play",
3
- "version": "0.2.9",
3
+ "version": "0.2.11",
4
4
  "description": "Android Auto and Apple CarPlay for react-native",
5
5
  "main": "lib/index",
6
6
  "module": "lib/index",
@@ -8,4 +8,5 @@ export interface NowPlayingTemplate extends HybridObject<{ android: 'kotlin'; io
8
8
  createNowPlayingTemplate(config: NowPlayingTemplateConfig): void;
9
9
  updateNowPlayingTemplatePlaybackState(templateId: string, isPlaying: boolean): Promise<void>;
10
10
  updateNowPlayingTemplateInfo(templateId: string, title: string, subtitle: string): Promise<void>;
11
+ updateNowPlayingTemplateElapsedTime(templateId: string, elapsedTime: number, duration: number): Promise<void>;
11
12
  }
@@ -59,4 +59,8 @@ export class NowPlayingTemplate extends Template<
59
59
  public updateNowPlayingInfo(title: string, subtitle: string) {
60
60
  return HybridNowPlayingTemplate.updateNowPlayingTemplateInfo(this.id, title, subtitle);
61
61
  }
62
+
63
+ public updateElapsedTime(elapsedTime: number, duration: number) {
64
+ return HybridNowPlayingTemplate.updateNowPlayingTemplateElapsedTime(this.id, elapsedTime, duration);
65
+ }
62
66
  }