@mustafaj/capacitor-plugin-playlist 0.9.0 → 0.9.2

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.
package/CHANGELOG.md ADDED
@@ -0,0 +1,9 @@
1
+ # Changelog
2
+
3
+ ## 0.9.0
4
+
5
+ ### Breaking Changes
6
+
7
+ - Low-level `Playlist.removeItem()` and `Playlist.removeItems()` now accept only `id` / `index`.
8
+ - If you were calling the raw Capacitor plugin API with `trackId` / `trackIndex`, update those calls to `id` / `index`.
9
+ - The higher-level `RmxAudioPlayer` wrapper still accepts `trackId` / `trackIndex` and maps them to the canonical plugin contract.
@@ -3,7 +3,7 @@ require 'json'
3
3
  package = JSON.parse(File.read(File.join(__dir__, 'package.json')))
4
4
 
5
5
  Pod::Spec.new do |s|
6
- s.name = 'CapacitorPluginPlaylist'
6
+ s.name = 'MustafajCapacitorPluginPlaylist'
7
7
  s.version = package['version']
8
8
  s.summary = package['description']
9
9
  s.license = package['license']
@@ -13,5 +13,5 @@ Pod::Spec.new do |s|
13
13
  s.source_files = 'ios/Plugin/**/*.{swift,h,m,c,cc,mm,cpp}'
14
14
  s.ios.deployment_target = '15.0'
15
15
  s.dependency 'Capacitor'
16
- s.swift_version = '5.3'
16
+ s.swift_version = '5.9'
17
17
  end
package/Package.swift ADDED
@@ -0,0 +1,27 @@
1
+ // swift-tools-version: 5.9
2
+ import PackageDescription
3
+
4
+ let package = Package(
5
+ name: "CapacitorPluginPlaylist",
6
+ platforms: [.iOS(.v15)],
7
+ products: [
8
+ .library(
9
+ name: "CapacitorPluginPlaylist",
10
+ targets: ["Plugin"]
11
+ )
12
+ ],
13
+ dependencies: [
14
+ .package(url: "https://github.com/ionic-team/capacitor-swift-pm.git", from: "8.0.0")
15
+ ],
16
+ targets: [
17
+ .target(
18
+ name: "Plugin",
19
+ dependencies: [
20
+ .product(name: "Capacitor", package: "capacitor-swift-pm"),
21
+ .product(name: "Cordova", package: "capacitor-swift-pm")
22
+ ],
23
+ path: "ios/Plugin",
24
+ publicHeadersPath: "."
25
+ )
26
+ ]
27
+ )
package/README.md CHANGED
@@ -60,34 +60,16 @@ architect => build => options:
60
60
  ]
61
61
  ```
62
62
 
63
- ##### AndroidManifest.xml:
63
+ ##### Android manifest
64
64
 
65
- ```
66
- <uses-permission android:name="android.permission.WAKE_LOCK" />
67
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
68
- <application
69
- android:name="org.dwbn.plugins.playlist.App"
70
- >
71
- <service android:enabled="true" android:exported="false"
72
- android:name="org.dwbn.plugins.playlist.service.MediaService">
73
- </service>
74
- </application>
75
- ```
65
+ The plugin now ships its own merged Android manifest entries for:
76
66
 
77
- **Note**: Starting with Android 14, you now need to specify the `foregroundServiceType` and request the appropriate permission:
67
+ - `WAKE_LOCK`
68
+ - `FOREGROUND_SERVICE`
69
+ - `FOREGROUND_SERVICE_MEDIA_PLAYBACK`
70
+ - `org.dwbn.plugins.playlist.service.MediaService`
78
71
 
79
- ```
80
- <uses-permission android:name="android.permission.WAKE_LOCK" />
81
- <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
82
- <application
83
- android:name="org.dwbn.plugins.playlist.App"
84
- >
85
- <service android:enabled="true" android:exported="false"
86
- android:foregroundServiceType="mediaPlayback"
87
- android:name="org.dwbn.plugins.playlist.service.MediaService">
88
- </service>
89
- </application>
90
- ```
72
+ You do not need to replace your app's `Application` class to use the plugin.
91
73
 
92
74
  ##### Gradle Configuration (Gradle 9+)
93
75
 
@@ -141,7 +123,6 @@ also see https://guides.codepath.com/android/Displaying-Images-with-the-Glide-Li
141
123
  <key>UIBackgroundModes</key>
142
124
  <array>
143
125
  <string>audio</string>
144
- <string>fetch</string>
145
126
  </array>
146
127
  ```
147
128
 
@@ -25,7 +25,7 @@ android {
25
25
  proguardFiles getDefaultProguardFile('proguard-android.txt'), 'proguard-rules.pro'
26
26
  }
27
27
  }
28
- lintOptions {
28
+ lint {
29
29
  abortOnError = false
30
30
  }
31
31
  compileOptions {
@@ -1,4 +1,14 @@
1
1
 
2
- <manifest xmlns:android="http://schemas.android.com/apk/res/android"
3
- >
2
+ <manifest xmlns:android="http://schemas.android.com/apk/res/android">
3
+ <uses-permission android:name="android.permission.WAKE_LOCK" />
4
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
5
+ <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
6
+
7
+ <application>
8
+ <service
9
+ android:name="org.dwbn.plugins.playlist.service.MediaService"
10
+ android:enabled="true"
11
+ android:exported="false"
12
+ android:foregroundServiceType="mediaPlayback" />
13
+ </application>
4
14
  </manifest>
@@ -19,7 +19,7 @@ public class PlaylistPlugin : Plugin(), OnStatusReportListener {
19
19
  private var resetStreamOnPause = true
20
20
 
21
21
  override fun load() {
22
- audioPlayerImpl = RmxAudioPlayer(this, (this.context.applicationContext as App))
22
+ audioPlayerImpl = RmxAudioPlayer(this, this.context.applicationContext)
23
23
  }
24
24
 
25
25
  @PluginMethod
@@ -0,0 +1,27 @@
1
+ package org.dwbn.plugins.playlist
2
+
3
+ import android.app.Application
4
+ import android.content.Context
5
+ import org.dwbn.plugins.playlist.manager.PlaylistManager
6
+
7
+ object PlaylistRuntime {
8
+ private var playlistManager: PlaylistManager? = null
9
+
10
+ @JvmStatic
11
+ fun getPlaylistManager(context: Context): PlaylistManager {
12
+ synchronized(this) {
13
+ if (playlistManager == null) {
14
+ playlistManager = PlaylistManager(context.applicationContext as Application)
15
+ }
16
+ return playlistManager!!
17
+ }
18
+ }
19
+
20
+ @JvmStatic
21
+ fun resetPlaylistManager(context: Context): PlaylistManager {
22
+ synchronized(this) {
23
+ playlistManager = PlaylistManager(context.applicationContext as Application)
24
+ return playlistManager!!
25
+ }
26
+ }
27
+ }
@@ -1,5 +1,6 @@
1
1
  package org.dwbn.plugins.playlist;
2
2
 
3
+ import android.content.Context;
3
4
  import android.util.Log;
4
5
 
5
6
  import androidx.annotation.NonNull;
@@ -46,9 +47,9 @@ public class RmxAudioPlayer implements PlaybackStatusListener<AudioTrack>,
46
47
  private boolean resetStreamOnPause = true;
47
48
  private String pendingSelectionTrackId = null;
48
49
  private boolean suppressSelectionPlaybackEvents = false;
49
- private final App app;
50
+ private final Context context;
50
51
 
51
- public RmxAudioPlayer(@NonNull OnStatusReportListener statusListener, App context) {
52
+ public RmxAudioPlayer(@NonNull OnStatusReportListener statusListener, @NonNull Context context) {
52
53
  // AudioPlayerPlugin and RmxAudioPlayer are separate classes in order to increase
53
54
  // the portability of this code.
54
55
  // Because AudioPlayerPlugin itself holds a strong reference to this class,
@@ -56,10 +57,9 @@ public class RmxAudioPlayer implements PlaybackStatusListener<AudioTrack>,
56
57
  // but these two objects will always live together (And the plugin couldn't function
57
58
  // at all if this one gets garbage collected).
58
59
  this.statusListener = statusListener;
59
- this.app = context;
60
+ this.context = context.getApplicationContext();
60
61
 
61
- app.resetPlaylistManager();
62
- getPlaylistManager();
62
+ playlistManager = PlaylistRuntime.resetPlaylistManager(this.context);
63
63
  playlistManager.setId(PLAYLIST_ID);
64
64
  playlistManager.setPlaybackStatusListener(this);
65
65
  playlistManager.setOnErrorListener(this);
@@ -67,7 +67,7 @@ public class RmxAudioPlayer implements PlaybackStatusListener<AudioTrack>,
67
67
  }
68
68
 
69
69
  public PlaylistManager getPlaylistManager() {
70
- playlistManager = app.getPlaylistManager();
70
+ playlistManager = PlaylistRuntime.getPlaylistManager(context);
71
71
  return playlistManager;
72
72
  }
73
73
 
@@ -81,7 +81,7 @@ public class RmxAudioPlayer implements PlaybackStatusListener<AudioTrack>,
81
81
  }
82
82
 
83
83
  public void setOptions(JSONObject val) {
84
- Options options = new Options(app, val);
84
+ Options options = new Options(context, val);
85
85
  getPlaylistManager().setOptions(options);
86
86
  }
87
87
 
@@ -72,6 +72,6 @@ class Options {
72
72
 
73
73
  companion object {
74
74
  // Default icon path
75
- private const val DEFAULT_ICON = "icon"
75
+ private const val DEFAULT_ICON = "ic_notification_icon"
76
76
  }
77
- }
77
+ }
@@ -205,9 +205,24 @@ class PlaylistManager(application: Application) :
205
205
  val progress = currentProgress
206
206
  val seekPosition: Long = if (progress != null) progress.position else 0
207
207
 
208
+ val snapshot = audioTracks.toList()
209
+ val resolvedIndices = LinkedHashSet<Int>()
208
210
  for (item in its) {
209
- val resolvedIndex = resolveItemPosition(item.trackIndex, item.trackId)
211
+ val resolvedIndex =
212
+ if (item.trackIndex >= 0 && item.trackIndex < snapshot.size) {
213
+ item.trackIndex
214
+ } else if (item.trackId.isNotEmpty()) {
215
+ snapshot.indexOfFirst { it.trackId == item.trackId }
216
+ } else {
217
+ -1
218
+ }
210
219
  if (resolvedIndex >= 0) {
220
+ resolvedIndices.add(resolvedIndex)
221
+ }
222
+ }
223
+
224
+ for (resolvedIndex in resolvedIndices.sortedDescending()) {
225
+ if (resolvedIndex in audioTracks.indices) {
211
226
  val foundItem = audioTracks[resolvedIndex]
212
227
  if (foundItem == currentItem) {
213
228
  removingCurrent = true
@@ -66,10 +66,12 @@ class MediaImageProvider(
66
66
  private inner class RemoteViewImageTarget : CustomTarget<Bitmap>() {
67
67
  override fun onResourceReady(resource: Bitmap, transition: Transition<in Bitmap>?) {
68
68
  artworkImage = resource
69
+ onImageUpdatedListener.onImageUpdated()
69
70
  }
70
71
 
71
72
  override fun onLoadCleared(placeholder: android.graphics.drawable.Drawable?) {
72
- // No cleanup needed
73
+ artworkImage = null
74
+ onImageUpdatedListener.onImageUpdated()
73
75
  }
74
76
  }
75
77
 
@@ -80,4 +82,4 @@ class MediaImageProvider(
80
82
  fakeR.getId("drawable", options.icon)
81
83
  )
82
84
  }
83
- }
85
+ }
@@ -8,7 +8,7 @@ import android.os.Build
8
8
  import android.util.Log
9
9
  import com.devbrackets.android.playlistcore.components.playlisthandler.PlaylistHandler
10
10
  import com.devbrackets.android.playlistcore.service.BasePlaylistService
11
- import org.dwbn.plugins.playlist.App
11
+ import org.dwbn.plugins.playlist.PlaylistRuntime
12
12
  import org.dwbn.plugins.playlist.data.AudioTrack
13
13
  import org.dwbn.plugins.playlist.manager.PlaylistManager
14
14
  import org.dwbn.plugins.playlist.playlist.AudioApi
@@ -78,7 +78,7 @@ class MediaService : BasePlaylistService<AudioTrack, PlaylistManager>() {
78
78
  }
79
79
 
80
80
  override val playlistManager: PlaylistManager
81
- get() = (applicationContext as App).playlistManager
81
+ get() = PlaylistRuntime.getPlaylistManager(applicationContext)
82
82
 
83
83
  override fun newPlaylistHandler(): PlaylistHandler<AudioTrack> {
84
84
  val imageProvider = MediaImageProvider(applicationContext, object : OnImageUpdatedListener {
@@ -95,4 +95,4 @@ class MediaService : BasePlaylistService<AudioTrack, PlaylistManager>() {
95
95
  null
96
96
  ).build()
97
97
  }
98
- }
98
+ }
@@ -35,6 +35,7 @@ let AVBidirectionalQueueCleared = "AVBidirectionalQueuePlayer.Cleared"
35
35
 
36
36
  class AVBidirectionalQueuePlayer: AVQueuePlayer {
37
37
  var queuedAudioTracks: [AudioTrack] = []
38
+ var wrapsWhenAtEnd = false
38
39
 
39
40
  var isPlaying: Bool {
40
41
  timeControlStatus == .playing
@@ -126,7 +127,7 @@ class AVBidirectionalQueuePlayer: AVQueuePlayer {
126
127
  open override func advanceToNextItem() {
127
128
  if currentIndex() == nil || currentIndex()! < queuedAudioTracks.count - 1{
128
129
  super.advanceToNextItem();
129
- } else {
130
+ } else if wrapsWhenAtEnd {
130
131
  setCurrentIndex(0)
131
132
  }
132
133
  }
@@ -21,7 +21,6 @@ CAP_PLUGIN(PlaylistPlugin, "Playlist",
21
21
  CAP_PLUGIN_METHOD(seekTo, CAPPluginReturnPromise);
22
22
  CAP_PLUGIN_METHOD(playTrackByIndex, CAPPluginReturnPromise);
23
23
  CAP_PLUGIN_METHOD(playTrackById, CAPPluginReturnPromise);
24
- CAP_PLUGIN_METHOD(playTrackByIndex, CAPPluginReturnPromise);
25
24
  CAP_PLUGIN_METHOD(selectTrackByIndex, CAPPluginReturnPromise);
26
25
  CAP_PLUGIN_METHOD(selectTrackById, CAPPluginReturnPromise);
27
26
  CAP_PLUGIN_METHOD(setPlaybackVolume, CAPPluginReturnPromise);
@@ -27,6 +27,8 @@ final class RmxAudioPlayer: NSObject {
27
27
  private var isReplacingItems = false
28
28
  private var isWaitingToStartPlayback = false
29
29
  private var loop = false
30
+ private var queueObserversRegistered = false
31
+ private var observedTrackItems: Set<ObjectIdentifier> = []
30
32
 
31
33
  let avQueuePlayer = AVBidirectionalQueuePlayer(items: [])
32
34
 
@@ -52,9 +54,7 @@ final class RmxAudioPlayer: NSObject {
52
54
  print("RmxAudioPlayer.execute=initialize")
53
55
 
54
56
  avQueuePlayer.actionAtItemEnd = .advance
55
- avQueuePlayer.addObserver(self, forKeyPath: "currentItem", options: .new, context: nil)
56
- avQueuePlayer.addObserver(self, forKeyPath: "rate", options: .new, context: nil)
57
- avQueuePlayer.addObserver(self, forKeyPath: "timeControlStatus", options: .new, context: nil)
57
+ registerQueueObservers()
58
58
 
59
59
  let interval = CMTimeMakeWithSeconds(Float64(1.0), preferredTimescale: Int32(Double(NSEC_PER_SEC)))
60
60
  playbackTimeObserver = avQueuePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { [weak self] time in
@@ -114,24 +114,26 @@ final class RmxAudioPlayer: NSObject {
114
114
 
115
115
  var removed = 0
116
116
  if items.count > 0 {
117
+ let snapshot = avQueuePlayer.queuedAudioTracks
118
+ var indices = Set<Int>()
117
119
  for item in items {
118
120
  guard let item = item as? [String: Any] else {
119
121
  continue
120
122
  }
121
123
 
122
- if let id = item["id"] as? String {
123
- do {
124
- try removeItem(id)
125
- removed += 1
126
- } catch {}
127
- }
128
- else if let index = (item["index"] as? NSNumber)?.intValue {
129
- do {
130
- try removeItem(index)
131
- removed += 1
132
- } catch {}
124
+ if let index = (item["index"] as? NSNumber)?.intValue, snapshot.indices.contains(index) {
125
+ indices.insert(index)
126
+ } else if let id = item["id"] as? String,
127
+ let index = snapshot.firstIndex(where: { $0.trackId == id }) {
128
+ indices.insert(index)
133
129
  }
130
+ }
134
131
 
132
+ for index in indices.sorted(by: >) {
133
+ do {
134
+ try removeItem(index)
135
+ removed += 1
136
+ } catch {}
135
137
  }
136
138
  }
137
139
 
@@ -189,6 +191,7 @@ final class RmxAudioPlayer: NSObject {
189
191
 
190
192
  func setLoopAll(_ loop: Bool) {
191
193
  self.loop = loop
194
+ avQueuePlayer.wrapsWhenAtEnd = loop
192
195
 
193
196
  print("RmxAudioPlayer.execute=setLoopAll, \(loop)")
194
197
  }
@@ -213,12 +216,14 @@ final class RmxAudioPlayer: NSObject {
213
216
  guard !avQueuePlayer.queuedAudioTracks.isEmpty else {
214
217
  throw "Queue is Empty"
215
218
  }
216
- let result = findTrack(byId: id)
217
- let idx = (result?["index"] as? NSNumber)?.intValue ?? 0
218
-
219
- if idx >= 0 {
220
- avQueuePlayer.setCurrentIndex(idx)
219
+ guard
220
+ let result = findTrack(byId: id),
221
+ let idx = (result["index"] as? NSNumber)?.intValue,
222
+ idx >= 0
223
+ else {
224
+ throw "Track ID not found"
221
225
  }
226
+ avQueuePlayer.setCurrentIndex(idx)
222
227
  }
223
228
 
224
229
  func removeItem(_ index: Int) throws {
@@ -232,20 +237,18 @@ final class RmxAudioPlayer: NSObject {
232
237
  }
233
238
 
234
239
  func removeItem(_ id: String) throws {
235
- let result = findTrack(byId: id)
236
- let idx = (result?["index"] as? NSNumber)?.intValue ?? 0
237
- let track = result?["track"] as? AudioTrack
238
-
239
- guard idx >= 0 else {
240
- throw "Could not find track by id" + id
240
+ guard
241
+ let result = findTrack(byId: id),
242
+ let idx = (result["index"] as? NSNumber)?.intValue,
243
+ let track = result["track"] as? AudioTrack,
244
+ idx >= 0
245
+ else {
246
+ throw "Could not find track by id " + id
241
247
  }
242
- // AudioTrack* item = [self avQueuePlayer].itemsForPlayer[idx];
243
- removeTrackObservers(track)
244
248
 
245
- if let track = track {
246
- avQueuePlayer.remove(track)
247
- }
248
- onStatus(.rmxstatus_ITEM_REMOVED, trackId: track?.trackId, param: track?.toDict())
249
+ removeTrackObservers(track)
250
+ avQueuePlayer.remove(track)
251
+ onStatus(.rmxstatus_ITEM_REMOVED, trackId: track.trackId, param: track.toDict())
249
252
  }
250
253
 
251
254
  // MARK: - player actions
@@ -390,7 +393,9 @@ final class RmxAudioPlayer: NSObject {
390
393
  }
391
394
 
392
395
  func setTracks(_ tracks: [AudioTrack], startIndex: Int, startPosition: Float) {
393
- avQueuePlayer.removeAllTrackObservers()
396
+ for item in avQueuePlayer.queuedAudioTracks {
397
+ removeTrackObservers(item)
398
+ }
394
399
 
395
400
  isReplacingItems = true
396
401
  print("RmxAudioPlayer[setTracks] replacing tracks ")
@@ -662,13 +667,11 @@ final class RmxAudioPlayer: NSObject {
662
667
  updatedNowPlayingInfo![MPMediaItemPropertyTitle] = currentItem?.title
663
668
  updatedNowPlayingInfo![MPMediaItemPropertyAlbumTitle] = currentItem?.album
664
669
 
665
- if let mediaItemArtwork = createCoverArtwork(currentItem?.albumArt?.absoluteString) {
666
- updatedNowPlayingInfo![MPMediaItemPropertyArtwork] = mediaItemArtwork
667
- }
670
+ updateNowPlayingArtwork(currentItem?.albumArt?.absoluteString)
668
671
  }
669
672
  updatedNowPlayingInfo![MPMediaItemPropertyPlaybackDuration] = duration ?? 0.0
670
673
  updatedNowPlayingInfo![MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime ?? 0.0
671
- updatedNowPlayingInfo![MPNowPlayingInfoPropertyPlaybackRate] = 1.0
674
+ updatedNowPlayingInfo![MPNowPlayingInfoPropertyPlaybackRate] = avQueuePlayer.rate != 0.0 ? avQueuePlayer.rate : 0.0
672
675
 
673
676
  MPNowPlayingInfoCenter.default().nowPlayingInfo = updatedNowPlayingInfo
674
677
  }
@@ -678,24 +681,47 @@ final class RmxAudioPlayer: NSObject {
678
681
  commandCenter.previousTrackCommand.isEnabled = !avQueuePlayer.isAtBeginning
679
682
  }
680
683
 
681
- func createCoverArtwork(_ coverUriOrNil: String?) -> MPMediaItemArtwork? {
684
+ func updateNowPlayingArtwork(_ coverUriOrNil: String?) {
682
685
  guard let coverUri = coverUriOrNil else {
683
- return nil
686
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
687
+ return
684
688
  }
685
- var coverImage: UIImage? = nil
686
- if coverUri.hasPrefix("http://") || coverUri.hasPrefix("https://") {
687
- let coverImageUrl = URL(string: coverUri)!
688
689
 
689
- do {
690
- let coverImageData = try Data(contentsOf: coverImageUrl)
691
- coverImage = UIImage(data: coverImageData)
692
- } catch {
693
- print("Error creating the coverImageData");
690
+ if coverUri.hasPrefix("http://") || coverUri.hasPrefix("https://") {
691
+ guard let coverImageUrl = URL(string: coverUri) else {
692
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
693
+ return
694
694
  }
695
- } else {
696
- if FileManager.default.fileExists(atPath: coverUri) {
697
- coverImage = UIImage(contentsOfFile: coverUri)
695
+ downloadImage(url: coverImageUrl) { [weak self] image in
696
+ guard
697
+ let self = self,
698
+ self.isCoverImageValid(image)
699
+ else {
700
+ return
701
+ }
702
+ let artwork = MPMediaItemArtwork(boundsSize: image!.size) { _ in image! }
703
+ self.nowPlayingInfoQueue.sync {
704
+ self.updatedNowPlayingInfo?[MPMediaItemPropertyArtwork] = artwork
705
+ MPNowPlayingInfoCenter.default().nowPlayingInfo = self.updatedNowPlayingInfo
706
+ }
698
707
  }
708
+ return
709
+ }
710
+
711
+ if let mediaItemArtwork = createCoverArtwork(coverUri) {
712
+ updatedNowPlayingInfo?[MPMediaItemPropertyArtwork] = mediaItemArtwork
713
+ } else {
714
+ updatedNowPlayingInfo?.removeValue(forKey: MPMediaItemPropertyArtwork)
715
+ }
716
+ }
717
+
718
+ func createCoverArtwork(_ coverUriOrNil: String?) -> MPMediaItemArtwork? {
719
+ guard let coverUri = coverUriOrNil else {
720
+ return nil
721
+ }
722
+ var coverImage: UIImage? = nil
723
+ if FileManager.default.fileExists(atPath: coverUri) {
724
+ coverImage = UIImage(contentsOfFile: coverUri)
699
725
  }
700
726
 
701
727
  if isCoverImageValid(coverImage) {
@@ -1033,10 +1059,19 @@ final class RmxAudioPlayer: NSObject {
1033
1059
  }
1034
1060
 
1035
1061
  func addTrackObservers(_ playerItem: AudioTrack?) {
1062
+ guard let playerItem = playerItem else {
1063
+ return
1064
+ }
1065
+ let trackId = ObjectIdentifier(playerItem)
1066
+ guard !observedTrackItems.contains(trackId) else {
1067
+ return
1068
+ }
1069
+ observedTrackItems.insert(trackId)
1070
+
1036
1071
  let options: NSKeyValueObservingOptions = [.old, .new]
1037
- playerItem?.addObserver(self, forKeyPath: "status", options: options, context: nil)
1038
- playerItem?.addObserver(self, forKeyPath: "duration", options: options, context: nil)
1039
- playerItem?.addObserver(self, forKeyPath: "loadedTimeRanges", options: options, context: nil)
1072
+ playerItem.addObserver(self, forKeyPath: "status", options: options, context: nil)
1073
+ playerItem.addObserver(self, forKeyPath: "duration", options: options, context: nil)
1074
+ playerItem.addObserver(self, forKeyPath: "loadedTimeRanges", options: options, context: nil)
1040
1075
 
1041
1076
  // We don't need this one because we get the currentItem notification from the queue.
1042
1077
  // But we will wire it up anyway...
@@ -1045,7 +1080,7 @@ final class RmxAudioPlayer: NSObject {
1045
1080
  // Subscribe to the AVPlayerItem's PlaybackStalledNotification notification.
1046
1081
  listener.addObserver(self, selector: #selector(itemStalledPlaying(_:)), name: .AVPlayerItemPlaybackStalled, object: playerItem)
1047
1082
 
1048
- onStatus(.rmxstatus_ITEM_ADDED, trackId: playerItem?.trackId, param: playerItem?.toDict())
1083
+ onStatus(.rmxstatus_ITEM_ADDED, trackId: playerItem.trackId, param: playerItem.toDict())
1049
1084
  }
1050
1085
 
1051
1086
  @objc func queueCleared(_ notification: Notification?) {
@@ -1055,22 +1090,52 @@ final class RmxAudioPlayer: NSObject {
1055
1090
  }
1056
1091
 
1057
1092
  func removeTrackObservers(_ playerItem: AudioTrack?) {
1058
- avQueuePlayer.removeTrackObservers(playerItem)
1093
+ guard let playerItem = playerItem else {
1094
+ return
1095
+ }
1096
+ let trackId = ObjectIdentifier(playerItem)
1097
+ guard observedTrackItems.remove(trackId) != nil else {
1098
+ return
1099
+ }
1100
+
1101
+ playerItem.removeObserver(self, forKeyPath: "status")
1102
+ playerItem.removeObserver(self, forKeyPath: "duration")
1103
+ playerItem.removeObserver(self, forKeyPath: "loadedTimeRanges")
1104
+
1105
+ let listener = NotificationCenter.default
1106
+ listener.removeObserver(self, name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
1107
+ listener.removeObserver(self, name: .AVPlayerItemPlaybackStalled, object: playerItem)
1108
+ }
1109
+
1110
+ func registerQueueObservers() {
1111
+ guard !queueObserversRegistered else {
1112
+ return
1113
+ }
1114
+ avQueuePlayer.addObserver(self, forKeyPath: "currentItem", options: .new, context: nil)
1115
+ avQueuePlayer.addObserver(self, forKeyPath: "rate", options: .new, context: nil)
1116
+ avQueuePlayer.addObserver(self, forKeyPath: "timeControlStatus", options: .new, context: nil)
1117
+ queueObserversRegistered = true
1118
+ }
1119
+
1120
+ func unregisterQueueObservers() {
1121
+ guard queueObserversRegistered else {
1122
+ return
1123
+ }
1124
+ avQueuePlayer.removeObserver(self, forKeyPath: "currentItem")
1125
+ avQueuePlayer.removeObserver(self, forKeyPath: "rate")
1126
+ avQueuePlayer.removeObserver(self, forKeyPath: "timeControlStatus")
1127
+ queueObserversRegistered = false
1059
1128
  }
1060
1129
 
1061
1130
  func activateAudioSession() {
1062
1131
  let avSession = AVAudioSession.sharedInstance()
1063
1132
 
1064
- // If no devices are connected, play audio through the default speaker (rather than the earpiece).
1065
- var options: AVAudioSession.CategoryOptions = .defaultToSpeaker
1066
-
1067
- // If both Bluetooth streaming options are enabled, the low quality stream is preferred; enable A2DP only.
1068
- options.insert(.allowBluetoothA2DP)
1133
+ let options: AVAudioSession.CategoryOptions = [.allowBluetoothA2DP]
1069
1134
 
1070
1135
  do {
1071
1136
  // Always set category first, even if session is already active
1072
1137
  // This ensures we have the correct category after video player exits
1073
- try avSession.setCategory(.playAndRecord, options: options)
1138
+ try avSession.setCategory(.playback, options: options)
1074
1139
  } catch {
1075
1140
  print("Error setting category! \(error.localizedDescription)")
1076
1141
  }
@@ -1140,6 +1205,7 @@ final class RmxAudioPlayer: NSObject {
1140
1205
  if let playbackTimeObserver = playbackTimeObserver {
1141
1206
  avQueuePlayer.removeTimeObserver(playbackTimeObserver)
1142
1207
  }
1208
+ unregisterQueueObservers()
1143
1209
  deregisterMusicControlsEventListener()
1144
1210
 
1145
1211
  removeAllTracks()