capacitor-plugin-playlist 0.10.3 → 0.10.9

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.
@@ -52,6 +52,7 @@ final class RmxAudioPlayer: NSObject {
52
52
  private var resetStreamOnPause = false
53
53
  private var updatedNowPlayingInfo: [String : Any]?
54
54
  private let nowPlayingInfoQueue = DispatchQueue(label: "RMXAudioPlayerNowPlayingQueue")
55
+ private let coverArtworkCache = NSCache<NSURL, MPMediaItemArtwork>()
55
56
  private var isReplacingItems = false
56
57
  private var isWaitingToStartPlayback = false
57
58
  private var loop = false
@@ -161,16 +162,17 @@ final class RmxAudioPlayer: NSObject {
161
162
  var removed = 0
162
163
  if items.count > 0 {
163
164
  for item in items {
164
- guard let item = item as? [String: String] else {
165
+ guard let item = item as? [String: Any] else {
165
166
  continue
166
167
  }
167
- if let id = item["trackId"] {
168
+
169
+ if let id = item["id"] as? String {
168
170
  do {
169
171
  try removeItem(id)
170
172
  removed += 1
171
173
  } catch {}
172
174
  }
173
- else if let index = Int(item["trackIndex"]!) {
175
+ else if let index = (item["index"] as? NSNumber)?.intValue {
174
176
  do {
175
177
  try removeItem(index)
176
178
  removed += 1
@@ -305,22 +307,9 @@ final class RmxAudioPlayer: NSObject {
305
307
  // Re-arm the periodic observer if it was removed by a prior releaseResources() call.
306
308
  installPlaybackTimeObserverIfNeeded()
307
309
 
308
- // Ensure audio session is active before playing
309
- // This is critical when resuming after video player has deactivated the session
310
- let audioSession = AVAudioSession.sharedInstance()
311
- if !audioSession.isOtherAudioPlaying {
312
- // Only reactivate if no other audio is playing
313
- do {
314
- try audioSession.setActive(true)
315
- } catch {
316
- print("Warning: Could not activate audio session: \(error.localizedDescription)")
317
- // Try to reactivate with category setup
318
- activateAudioSession()
319
- }
320
- } else {
321
- // If other audio is playing, ensure our category is set correctly
322
- activateAudioSession()
323
- }
310
+ // Always re-activate after native video handoff — `isOtherAudioPlaying` can still be true
311
+ // briefly while AVPlayerViewController tears down, which previously skipped setActive.
312
+ activateAudioSession()
324
313
 
325
314
  if resetStreamOnPause,
326
315
  let currentTrack = avQueuePlayer.currentAudioTrack,
@@ -714,9 +703,7 @@ final class RmxAudioPlayer: NSObject {
714
703
  updatedNowPlayingInfo![MPMediaItemPropertyTitle] = currentItem?.title
715
704
  updatedNowPlayingInfo![MPMediaItemPropertyAlbumTitle] = currentItem?.album
716
705
 
717
- if let mediaItemArtwork = createCoverArtwork(currentItem?.albumArt?.absoluteString) {
718
- updatedNowPlayingInfo![MPMediaItemPropertyArtwork] = mediaItemArtwork
719
- }
706
+ updateNowPlayingArtwork(currentItem?.albumArt?.absoluteString)
720
707
  }
721
708
  updatedNowPlayingInfo![MPMediaItemPropertyPlaybackDuration] = duration ?? 0.0
722
709
  updatedNowPlayingInfo![MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime ?? 0.0
@@ -730,52 +717,76 @@ final class RmxAudioPlayer: NSObject {
730
717
  commandCenter.previousTrackCommand.isEnabled = !avQueuePlayer.isAtBeginning
731
718
  }
732
719
 
733
- func createCoverArtwork(_ coverUriOrNil: String?) -> MPMediaItemArtwork? {
720
+ private func updateNowPlayingArtwork(_ coverUriOrNil: String?) {
734
721
  guard let coverUri = coverUriOrNil else {
735
- return nil
722
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
723
+ return
736
724
  }
737
- var coverImage: UIImage? = nil
738
- if coverUri.hasPrefix("http://") || coverUri.hasPrefix("https://") {
739
- let coverImageUrl = URL(string: coverUri)!
740
725
 
741
- do {
742
- let coverImageData = try Data(contentsOf: coverImageUrl)
743
- coverImage = UIImage(data: coverImageData)
744
- } catch {
745
- print("Error creating the coverImageData");
726
+ if coverUri.hasPrefix("http://") || coverUri.hasPrefix("https://") {
727
+ guard let coverImageUrl = URL(string: coverUri) else {
728
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
729
+ return
746
730
  }
747
- } else {
748
- if FileManager.default.fileExists(atPath: coverUri) {
749
- coverImage = UIImage(contentsOfFile: coverUri)
731
+
732
+ if let cachedArtwork = coverArtworkCache.object(forKey: coverImageUrl as NSURL) {
733
+ updatedNowPlayingInfo?[MPMediaItemPropertyArtwork] = cachedArtwork
734
+ return
750
735
  }
736
+
737
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
738
+
739
+ URLSession.shared.dataTask(with: coverImageUrl) { [weak self] data, response, error in
740
+ guard
741
+ error == nil,
742
+ let httpResponse = response as? HTTPURLResponse,
743
+ (200..<300).contains(httpResponse.statusCode),
744
+ let self = self,
745
+ let data = data,
746
+ let coverImage = UIImage(data: data),
747
+ self.isCoverImageValid(coverImage)
748
+ else {
749
+ return
750
+ }
751
+
752
+ DispatchQueue.main.async {
753
+ let artwork = MPMediaItemArtwork(boundsSize: coverImage.size) { _ in coverImage }
754
+ self.coverArtworkCache.setObject(artwork, forKey: coverImageUrl as NSURL)
755
+
756
+ guard self.avQueuePlayer.currentAudioTrack?.albumArt?.absoluteString == coverUri else {
757
+ return
758
+ }
759
+
760
+ self.nowPlayingInfoQueue.sync {
761
+ self.updatedNowPlayingInfo?[MPMediaItemPropertyArtwork] = artwork
762
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = self.updatedNowPlayingInfo
763
+ }
764
+ }
765
+ }.resume()
766
+ return
751
767
  }
752
768
 
753
- if isCoverImageValid(coverImage) {
754
- return MPMediaItemArtwork.init(boundsSize: coverImage!.size, requestHandler: { (size) -> UIImage in
755
- return coverImage!
756
- })
769
+ if let mediaItemArtwork = createCoverArtwork(coverUri) {
770
+ updatedNowPlayingInfo?[MPMediaItemPropertyArtwork] = mediaItemArtwork
771
+ } else {
772
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
757
773
  }
758
- return nil;
759
774
  }
760
775
 
761
- func downloadImage(url: URL, completion: @escaping ((_ image: UIImage?) -> Void)){
762
- print("Started downloading \"\(url.deletingPathExtension().lastPathComponent)\".")
763
- self.getImageDataFromUrl(url) { (_ data: Data?) in
764
- DispatchQueue.main.async {
765
- print("Finished downloading \"\(url.deletingPathExtension().lastPathComponent)\".")
766
- completion(UIImage(data: data!))
767
- }
776
+ private func createCoverArtwork(_ coverUri: String) -> MPMediaItemArtwork? {
777
+ guard
778
+ FileManager.default.fileExists(atPath: coverUri),
779
+ let coverImage = UIImage(contentsOfFile: coverUri),
780
+ isCoverImageValid(coverImage)
781
+ else {
782
+ return nil
768
783
  }
769
- }
770
784
 
771
- func getImageDataFromUrl(_ url: URL, completion: @escaping ((_ data: Data?) -> Void)) {
772
- URLSession.shared.dataTask(with: url) { (data, response, error) in
773
- completion(data)
774
- }.resume()
785
+ return MPMediaItemArtwork(boundsSize: coverImage.size) { _ in coverImage }
775
786
  }
776
787
 
777
- func isCoverImageValid(_ coverImage: UIImage?) -> Bool {
778
- return coverImage != nil && (coverImage?.ciImage != nil || coverImage?.cgImage != nil)
788
+ private func isCoverImageValid(_ coverImage: UIImage) -> Bool {
789
+ return coverImage.ciImage != nil || coverImage.cgImage != nil
779
790
  }
780
791
 
781
792
  func handleCurrentItemChanged(_ playerItem: AudioTrack?) {
@@ -1218,6 +1229,8 @@ final class RmxAudioPlayer: NSObject {
1218
1229
  // MARK: - Epic 45 video handoff
1219
1230
 
1220
1231
  private var lastKnownHandoffPosition: Float = 0
1232
+ /// Track id at video open — AVQueuePlayer can advance while video owns the session.
1233
+ private var handoffPinnedTrackId: String?
1221
1234
 
1222
1235
  func prepareForVideoHandoff() {
1223
1236
  pauseCommand(false)
@@ -1225,9 +1238,14 @@ final class RmxAudioPlayer: NSObject {
1225
1238
  // true stopped head, not a value that may have ticked during the pause call.
1226
1239
  if let track = avQueuePlayer.currentAudioTrack {
1227
1240
  lastKnownHandoffPosition = getTrackCurrentTime(track)
1241
+ handoffPinnedTrackId = track.trackId
1228
1242
  } else {
1229
1243
  lastKnownHandoffPosition = 0
1244
+ handoffPinnedTrackId = nil
1230
1245
  }
1246
+ // Freeze queue: HLS item failure while paused still triggers .advance otherwise.
1247
+ avQueuePlayer.actionAtItemEnd = .none
1248
+ print("prepareForVideoHandoff: pinned=\(handoffPinnedTrackId ?? "nil") actionAtItemEnd=none")
1231
1249
  do {
1232
1250
  try AVAudioSession.sharedInstance().setActive(false, options: [.notifyOthersOnDeactivation])
1233
1251
  } catch {
@@ -1235,17 +1253,61 @@ final class RmxAudioPlayer: NSObject {
1235
1253
  }
1236
1254
  }
1237
1255
 
1238
- func resumeAfterVideoHandoff(position: Float, prewarm: Bool = false) {
1256
+ /// Completes with `true` when native handled seek (and play when requested) so JS can skip redundant seek/play.
1257
+ func resumeAfterVideoHandoff(
1258
+ position: Float,
1259
+ prewarm: Bool = false,
1260
+ play: Bool = false,
1261
+ completion: @escaping (Bool) -> Void
1262
+ ) {
1239
1263
  lastKnownHandoffPosition = position
1240
1264
  if prewarm {
1265
+ completion(false)
1241
1266
  return
1242
1267
  }
1268
+ avQueuePlayer.actionAtItemEnd = .advance
1269
+ let currentId = avQueuePlayer.currentAudioTrack?.trackId
1270
+ if let pinned = handoffPinnedTrackId, !pinned.isEmpty, currentId != pinned {
1271
+ print("resumeAfterVideoHandoff: restoring pinned=\(pinned) (was \(currentId ?? "nil"))")
1272
+ do {
1273
+ try selectTrack(id: pinned)
1274
+ } catch {
1275
+ print("resumeAfterVideoHandoff: selectTrack failed: \(error.localizedDescription)")
1276
+ }
1277
+ } else {
1278
+ print("resumeAfterVideoHandoff: pinned=\(handoffPinnedTrackId ?? "nil") current=\(currentId ?? "nil")")
1279
+ }
1280
+ handoffPinnedTrackId = nil
1281
+ // Always re-arm session after native video (AVPlayer teardown can briefly look like other audio).
1243
1282
  activateAudioSession()
1244
1283
  // Reset lastTrackId so the timeControlStatus KVO guard does not suppress the PLAYING
1245
1284
  // event on same-track non-index-0 resume. The guard `lastTrackId != trackId || isAtBeginning`
1246
1285
  // (where isAtBeginning = currentIndex() == 0) would silently drop the PLAYING transition
1247
1286
  // for any audio track at playlist index > 0, leaving JS stuck in PAUSED.
1248
1287
  lastTrackId = nil
1288
+
1289
+ let finish: () -> Void = { [weak self] in
1290
+ guard let self = self else {
1291
+ completion(true)
1292
+ return
1293
+ }
1294
+ if play {
1295
+ self.playCommand(false)
1296
+ NSLog("[Playlist] resumeAfterVideoHandoff: seek-then-play at %.3f", position)
1297
+ } else {
1298
+ NSLog("[Playlist] resumeAfterVideoHandoff: seek-only at %.3f", position)
1299
+ }
1300
+ completion(true)
1301
+ }
1302
+
1303
+ if position > 0 {
1304
+ let seekToTime = CMTimeMakeWithSeconds(Float64(position), preferredTimescale: 1000)
1305
+ avQueuePlayer.seek(to: seekToTime, toleranceBefore: .zero, toleranceAfter: .zero) { _ in
1306
+ finish()
1307
+ }
1308
+ } else {
1309
+ finish()
1310
+ }
1249
1311
  }
1250
1312
 
1251
1313
  func getLastKnownPosition() -> Float {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "capacitor-plugin-playlist",
3
- "version": "0.10.3",
3
+ "version": "0.10.9",
4
4
  "description": "Playlist ",
5
5
  "main": "dist/plugin.cjs.js",
6
6
  "module": "dist/esm/index.js",