@mustafaj/capacitor-plugin-playlist 0.9.0
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/CapacitorPluginPlaylist.podspec +17 -0
- package/README.md +248 -0
- package/android/.project +34 -0
- package/android/build.gradle +69 -0
- package/android/gradle/wrapper/gradle-wrapper.jar +0 -0
- package/android/gradle/wrapper/gradle-wrapper.properties +7 -0
- package/android/gradle.properties +22 -0
- package/android/gradlew +251 -0
- package/android/gradlew.bat +94 -0
- package/android/proguard-rules.pro +21 -0
- package/android/settings.gradle +2 -0
- package/android/src/androidTest/java/com/getcapacitor/android/ExampleInstrumentedTest.java +26 -0
- package/android/src/main/AndroidManifest.xml +4 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/App.kt +19 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/FakeR.kt +39 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/OnStatusCallback.kt +34 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/OnStatusReportListener.java +7 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistItemOptions.java +52 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/PlaylistPlugin.kt +447 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioErrorType.java +13 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioPlayer.java +487 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxAudioStatusMessage.java +35 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/RmxConstants.java +42 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/TrackRemovalItem.java +12 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/data/AudioTrack.kt +94 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/MediaControlsListener.kt +13 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/Options.kt +77 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/manager/PlaylistManager.kt +308 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/notification/PlaylistNotificationProvider.kt +26 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioApi.kt +114 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/AudioPlaylistHandler.java +146 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/playlist/BaseMediaApi.kt +36 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaImageProvider.kt +83 -0
- package/android/src/main/java/org/dwbn/plugins/playlist/service/MediaService.kt +98 -0
- package/android/src/main/res/.gitkeep +0 -0
- package/android/src/main/res/drawable/ic_closed_caption_white_24dp.xml +9 -0
- package/android/src/main/res/drawable/ic_demo_icon_adaptive.xml +15 -0
- package/android/src/main/res/drawable/ic_launcher_background.xml +48 -0
- package/android/src/main/res/drawable/ic_launcher_foreground.xml +22 -0
- package/android/src/main/res/drawable/ic_notification_icon.png +0 -0
- package/android/src/main/res/layout/bridge_layout_main.xml +15 -0
- package/android/src/main/res/values/colors.xml +3 -0
- package/android/src/main/res/values/strings.xml +3 -0
- package/android/src/main/res/values/styles.xml +3 -0
- package/android/src/test/java/com/getcapacitor/ExampleUnitTest.java +18 -0
- package/dist/docs.json +2071 -0
- package/dist/esm/Constants.d.ts +164 -0
- package/dist/esm/Constants.js +175 -0
- package/dist/esm/Constants.js.map +1 -0
- package/dist/esm/RmxAudioPlayer.d.ts +181 -0
- package/dist/esm/RmxAudioPlayer.js +344 -0
- package/dist/esm/RmxAudioPlayer.js.map +1 -0
- package/dist/esm/definitions.d.ts +78 -0
- package/dist/esm/definitions.js +2 -0
- package/dist/esm/definitions.js.map +1 -0
- package/dist/esm/index.d.ts +5 -0
- package/dist/esm/index.js +6 -0
- package/dist/esm/index.js.map +1 -0
- package/dist/esm/interfaces.d.ts +246 -0
- package/dist/esm/interfaces.js +2 -0
- package/dist/esm/interfaces.js.map +1 -0
- package/dist/esm/plugin.d.ts +3 -0
- package/dist/esm/plugin.js +13 -0
- package/dist/esm/plugin.js.map +1 -0
- package/dist/esm/utils.d.ts +15 -0
- package/dist/esm/utils.js +48 -0
- package/dist/esm/utils.js.map +1 -0
- package/dist/esm/web.d.ts +54 -0
- package/dist/esm/web.js +409 -0
- package/dist/esm/web.js.map +1 -0
- package/dist/plugin.cjs.js +993 -0
- package/dist/plugin.cjs.js.map +1 -0
- package/dist/plugin.js +996 -0
- package/dist/plugin.js.map +1 -0
- package/ios/Plugin/AVBidirectionalQueuePlayer.swift +269 -0
- package/ios/Plugin/AudioTrack.swift +63 -0
- package/ios/Plugin/Constants.swift +39 -0
- package/ios/Plugin/DispatchQueue.swift +47 -0
- package/ios/Plugin/Info.plist +24 -0
- package/ios/Plugin/Plugin.h +10 -0
- package/ios/Plugin/Plugin.m +30 -0
- package/ios/Plugin/Plugin.swift +208 -0
- package/ios/Plugin/RmxAudioPlayer.swift +1150 -0
- package/ios/Plugin.xcodeproj/project.pbxproj +574 -0
- package/ios/Plugin.xcworkspace/contents.xcworkspacedata +10 -0
- package/ios/Plugin.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist +8 -0
- package/ios/PluginTests/Info.plist +22 -0
- package/ios/PluginTests/PluginTests.swift +35 -0
- package/ios/Podfile +16 -0
- package/package.json +89 -0
|
@@ -0,0 +1,1150 @@
|
|
|
1
|
+
// Converted to Swift 5.3 by Swiftify v5.3.19197 - https://swiftify.com/
|
|
2
|
+
//
|
|
3
|
+
// RmxAudioPlayer.swift
|
|
4
|
+
// Music Controls Capacitor Plugin
|
|
5
|
+
//
|
|
6
|
+
// Created by Juan Gonzalez on 12/16/16.
|
|
7
|
+
//
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
import AVFoundation
|
|
11
|
+
import Capacitor
|
|
12
|
+
import MediaPlayer
|
|
13
|
+
import UIKit
|
|
14
|
+
|
|
15
|
+
extension String: Error {}
|
|
16
|
+
|
|
17
|
+
final class RmxAudioPlayer: NSObject {
|
|
18
|
+
|
|
19
|
+
var statusUpdater: StatusUpdater? = nil
|
|
20
|
+
|
|
21
|
+
private var playbackTimeObserver: Any?
|
|
22
|
+
private var wasPlayingInterrupted = false
|
|
23
|
+
private var commandCenterRegistered = false
|
|
24
|
+
private var resetStreamOnPause = false
|
|
25
|
+
private var updatedNowPlayingInfo: [String : Any]?
|
|
26
|
+
private let nowPlayingInfoQueue = DispatchQueue(label: "RMXAudioPlayerNowPlayingQueue")
|
|
27
|
+
private var isReplacingItems = false
|
|
28
|
+
private var isWaitingToStartPlayback = false
|
|
29
|
+
private var loop = false
|
|
30
|
+
|
|
31
|
+
let avQueuePlayer = AVBidirectionalQueuePlayer(items: [])
|
|
32
|
+
|
|
33
|
+
private var lastTrackId: String? = nil
|
|
34
|
+
private var lastRate: Float? = nil
|
|
35
|
+
override init() {
|
|
36
|
+
super.init()
|
|
37
|
+
|
|
38
|
+
activateAudioSession()
|
|
39
|
+
observeLifeCycle()
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
deinit {
|
|
43
|
+
releaseResources()
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
func setOptions(_ options: [String:Any]) {
|
|
47
|
+
print("RmxAudioPlayer.execute=setOptions, \(options)")
|
|
48
|
+
resetStreamOnPause = (options["resetStreamOnPause"] as? NSNumber)?.boolValue ?? false
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
func initialize() {
|
|
52
|
+
print("RmxAudioPlayer.execute=initialize")
|
|
53
|
+
|
|
54
|
+
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)
|
|
58
|
+
|
|
59
|
+
let interval = CMTimeMakeWithSeconds(Float64(1.0), preferredTimescale: Int32(Double(NSEC_PER_SEC)))
|
|
60
|
+
playbackTimeObserver = avQueuePlayer.addPeriodicTimeObserver(forInterval: interval, queue: .main, using: { [weak self] time in
|
|
61
|
+
self?.executePeriodicUpdate(time)
|
|
62
|
+
})
|
|
63
|
+
|
|
64
|
+
onStatus(.rmxstatus_REGISTER, trackId: "INIT", param: nil)
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
func setPlaylistItems(_ items: [AudioTrack], options: [String:Any]) {
|
|
68
|
+
print("RmxAudioPlayer.execute=setPlaylistItems, \(options), \(items.count)")
|
|
69
|
+
|
|
70
|
+
var seekToPosition: Float = 0.0
|
|
71
|
+
let retainPosition = options["retainPosition"] != nil ? (options["retainPosition"] as? Bool) ?? false : false
|
|
72
|
+
let playFromPosition = options["playFromPosition"] != nil ? (options["playFromPosition"] as? Float) ?? 0.0 : 0.0
|
|
73
|
+
|
|
74
|
+
let playFromId = ((options["playFromId"] != nil) ? options["playFromId"] : nil) as? String
|
|
75
|
+
|
|
76
|
+
let startPaused = options["startPaused"] != nil ? (options["startPaused"] as? Bool) ?? false : true
|
|
77
|
+
|
|
78
|
+
if playFromPosition > 0.0 {
|
|
79
|
+
seekToPosition = playFromPosition
|
|
80
|
+
}
|
|
81
|
+
else if retainPosition {
|
|
82
|
+
seekToPosition = getTrackCurrentTime(nil)
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
// Index needs to come from new tracks, so we find it in 'items'
|
|
86
|
+
let result = findTrackIndex(byId: playFromId, items)
|
|
87
|
+
let idx = (result?["index"] as? NSNumber)?.intValue ?? 0
|
|
88
|
+
|
|
89
|
+
setTracks(items, startIndex: idx, startPosition: seekToPosition)
|
|
90
|
+
|
|
91
|
+
// This will wait for the AVPlayerItemStatusReadyToPlay status change, and then trigger playback.
|
|
92
|
+
isWaitingToStartPlayback = !startPaused
|
|
93
|
+
if isWaitingToStartPlayback {
|
|
94
|
+
print("RmxAudioPlayer[setPlaylistItems] will wait for ready event to begin playback")
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
if isWaitingToStartPlayback {
|
|
98
|
+
playCommand(false) // but we will try to preempt it to avoid the button blinking paused.
|
|
99
|
+
}
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
func addItem(_ item: AudioTrack) {
|
|
103
|
+
print("RmxAudioPlayer.execute=addItem, \(item)")
|
|
104
|
+
|
|
105
|
+
let tempArr = [item]
|
|
106
|
+
addTracks(tempArr, startPosition: -1)
|
|
107
|
+
}
|
|
108
|
+
func addAllItems(_ items: [AudioTrack]) {
|
|
109
|
+
addTracks(items, startPosition: -1)
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
func removeItems(_ items: JSArray) -> Int {
|
|
113
|
+
print("RmxAudioPlayer.execute=removeItems, \(items)")
|
|
114
|
+
|
|
115
|
+
var removed = 0
|
|
116
|
+
if items.count > 0 {
|
|
117
|
+
for item in items {
|
|
118
|
+
guard let item = item as? [String: Any] else {
|
|
119
|
+
continue
|
|
120
|
+
}
|
|
121
|
+
|
|
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 {}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
}
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return removed
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
func clearAllItems() {
|
|
142
|
+
print("RmxAudioPlayer.execute=clearAllItems")
|
|
143
|
+
removeAllTracks()
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
func playTrack(index: Int, positionTime: Float?) throws {
|
|
147
|
+
guard (0..<avQueuePlayer.queuedAudioTracks.count).contains(index) else {
|
|
148
|
+
throw "Provided index is out of bounds"
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
if avQueuePlayer.currentIndex() != index {
|
|
152
|
+
avQueuePlayer.setCurrentIndex(index)
|
|
153
|
+
}
|
|
154
|
+
playCommand(false)
|
|
155
|
+
|
|
156
|
+
if positionTime != nil {
|
|
157
|
+
seek(to: positionTime!, isCommand: false)
|
|
158
|
+
}
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
func playTrack(_ trackId: String, positionTime: Float?) throws {
|
|
162
|
+
guard !avQueuePlayer.queuedAudioTracks.isEmpty else {
|
|
163
|
+
throw "The playlist is empty!"
|
|
164
|
+
}
|
|
165
|
+
|
|
166
|
+
if avQueuePlayer.currentAudioTrack?.trackId != trackId {
|
|
167
|
+
let result = findTrack(byId: trackId)
|
|
168
|
+
let idx = result?["index"] as? Int ?? -1
|
|
169
|
+
guard idx >= 0 else {
|
|
170
|
+
throw "Track ID not found"
|
|
171
|
+
}
|
|
172
|
+
avQueuePlayer.setCurrentIndex(idx)
|
|
173
|
+
}
|
|
174
|
+
playCommand(false)
|
|
175
|
+
|
|
176
|
+
if positionTime != nil {
|
|
177
|
+
seek(to: positionTime!, isCommand: false)
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
func setPlaybackRate(_ rate: Float) {
|
|
182
|
+
avQueuePlayer.rate = rate
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// Not supporten in IOS ?https://developer.apple.com/documentation/avfoundation/avplayer/1390127-volume
|
|
186
|
+
func setPlaybackVolume(_ volume: Float) {
|
|
187
|
+
avQueuePlayer.volume = volume
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
func setLoopAll(_ loop: Bool) {
|
|
191
|
+
self.loop = loop
|
|
192
|
+
|
|
193
|
+
print("RmxAudioPlayer.execute=setLoopAll, \(loop)")
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
|
|
197
|
+
// MARK: - Capacitor interface
|
|
198
|
+
|
|
199
|
+
///
|
|
200
|
+
/// Capacitor interface
|
|
201
|
+
///
|
|
202
|
+
/// These are basically just passing through to the core functionality of the queue and this player.
|
|
203
|
+
///
|
|
204
|
+
/// These functions don't really do anything interesting by themselves.
|
|
205
|
+
func selectTrack(index: Int) throws {
|
|
206
|
+
guard index >= 0 && index < avQueuePlayer.queuedAudioTracks.count else {
|
|
207
|
+
throw "Index out of Playlist bounds"
|
|
208
|
+
}
|
|
209
|
+
avQueuePlayer.setCurrentIndex(index)
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
func selectTrack(id: String) throws {
|
|
213
|
+
guard !avQueuePlayer.queuedAudioTracks.isEmpty else {
|
|
214
|
+
throw "Queue is Empty"
|
|
215
|
+
}
|
|
216
|
+
let result = findTrack(byId: id)
|
|
217
|
+
let idx = (result?["index"] as? NSNumber)?.intValue ?? 0
|
|
218
|
+
|
|
219
|
+
if idx >= 0 {
|
|
220
|
+
avQueuePlayer.setCurrentIndex(idx)
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
func removeItem(_ index: Int) throws {
|
|
225
|
+
guard index > -1 && index < avQueuePlayer.queuedAudioTracks.count else {
|
|
226
|
+
throw "Index not found"
|
|
227
|
+
}
|
|
228
|
+
let item = avQueuePlayer.queuedAudioTracks[index]
|
|
229
|
+
removeTrackObservers(item)
|
|
230
|
+
avQueuePlayer.remove(item)
|
|
231
|
+
onStatus(.rmxstatus_ITEM_REMOVED, trackId: item.trackId, param: item.toDict())
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
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
|
|
241
|
+
}
|
|
242
|
+
// AudioTrack* item = [self avQueuePlayer].itemsForPlayer[idx];
|
|
243
|
+
removeTrackObservers(track)
|
|
244
|
+
|
|
245
|
+
if let track = track {
|
|
246
|
+
avQueuePlayer.remove(track)
|
|
247
|
+
}
|
|
248
|
+
onStatus(.rmxstatus_ITEM_REMOVED, trackId: track?.trackId, param: track?.toDict())
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
// MARK: - player actions
|
|
252
|
+
|
|
253
|
+
///
|
|
254
|
+
/// Player actions.
|
|
255
|
+
///
|
|
256
|
+
/// These are the public API for the player and wrap most of the complexity of the queue.
|
|
257
|
+
func playCommand(_ isCommand: Bool) {
|
|
258
|
+
wasPlayingInterrupted = false
|
|
259
|
+
initializeMPCommandCenter()
|
|
260
|
+
|
|
261
|
+
// Ensure audio session is active before playing
|
|
262
|
+
// This is critical when resuming after video player has deactivated the session
|
|
263
|
+
let audioSession = AVAudioSession.sharedInstance()
|
|
264
|
+
if !audioSession.isOtherAudioPlaying {
|
|
265
|
+
// Only reactivate if no other audio is playing
|
|
266
|
+
do {
|
|
267
|
+
try audioSession.setActive(true)
|
|
268
|
+
} catch {
|
|
269
|
+
print("Warning: Could not activate audio session: \(error.localizedDescription)")
|
|
270
|
+
// Try to reactivate with category setup
|
|
271
|
+
activateAudioSession()
|
|
272
|
+
}
|
|
273
|
+
} else {
|
|
274
|
+
// If other audio is playing, ensure our category is set correctly
|
|
275
|
+
activateAudioSession()
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if resetStreamOnPause,
|
|
279
|
+
let currentTrack = avQueuePlayer.currentAudioTrack,
|
|
280
|
+
currentTrack.isStream {
|
|
281
|
+
print( "music-stream-play")
|
|
282
|
+
avQueuePlayer.seek(to: .positiveInfinity, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
283
|
+
currentTrack.seek(to: .positiveInfinity, toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: nil)
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
print( "music-controls-play ")
|
|
287
|
+
|
|
288
|
+
avQueuePlayer.play()
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
func pauseCommand(_ isCommand: Bool) {
|
|
292
|
+
wasPlayingInterrupted = false
|
|
293
|
+
initializeMPCommandCenter()
|
|
294
|
+
avQueuePlayer.pause()
|
|
295
|
+
|
|
296
|
+
// When the track is a stream, we do not want it to hold the buffer at the current location;
|
|
297
|
+
// it does in fact continue buffering afterwards but the buffer on iOS is rather small, so you'll end up
|
|
298
|
+
// reaching a point where you jump forward in time however long you were paused.
|
|
299
|
+
// The correct behavior for streams is to pick up at the current LIVE point in the stream, which we accomplish
|
|
300
|
+
// by seeking to the "end" of the stream.
|
|
301
|
+
if resetStreamOnPause,
|
|
302
|
+
let currentTrack = avQueuePlayer.currentAudioTrack,
|
|
303
|
+
currentTrack.isStream {
|
|
304
|
+
avQueuePlayer.seek(to: .positiveInfinity, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
305
|
+
currentTrack.seek(to: .positiveInfinity, toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: nil)
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
if isCommand {
|
|
309
|
+
let action = "music-controls-pause"
|
|
310
|
+
print("\(action)")
|
|
311
|
+
}
|
|
312
|
+
}
|
|
313
|
+
|
|
314
|
+
func playPrevious(_ isCommand: Bool) {
|
|
315
|
+
wasPlayingInterrupted = false
|
|
316
|
+
initializeMPCommandCenter()
|
|
317
|
+
|
|
318
|
+
avQueuePlayer.playPreviousItem()
|
|
319
|
+
|
|
320
|
+
if isCommand {
|
|
321
|
+
let action = "music-controls-previous"
|
|
322
|
+
print("\(action)")
|
|
323
|
+
|
|
324
|
+
let playerItem = avQueuePlayer.currentAudioTrack
|
|
325
|
+
var param: [String : Any]? = nil
|
|
326
|
+
if let to = playerItem?.toDict() {
|
|
327
|
+
param = [
|
|
328
|
+
"currentIndex": NSNumber(value: avQueuePlayer.currentIndex() ?? 0),
|
|
329
|
+
"currentItem": to
|
|
330
|
+
]
|
|
331
|
+
}
|
|
332
|
+
onStatus(.rmx_STATUS_SKIP_BACK, trackId: playerItem?.trackId, param: param)
|
|
333
|
+
}
|
|
334
|
+
}
|
|
335
|
+
|
|
336
|
+
func playNext(_ isCommand: Bool) {
|
|
337
|
+
wasPlayingInterrupted = false
|
|
338
|
+
initializeMPCommandCenter()
|
|
339
|
+
|
|
340
|
+
avQueuePlayer.advanceToNextItem()
|
|
341
|
+
|
|
342
|
+
if isCommand {
|
|
343
|
+
let action = "music-controls-next"
|
|
344
|
+
print("\(action)")
|
|
345
|
+
|
|
346
|
+
let playerItem = avQueuePlayer.currentAudioTrack
|
|
347
|
+
var param: [String : Any]? = nil
|
|
348
|
+
if let to = playerItem?.toDict() {
|
|
349
|
+
param = [
|
|
350
|
+
"currentIndex": NSNumber(value: avQueuePlayer.currentIndex() ?? 0),
|
|
351
|
+
"currentItem": to
|
|
352
|
+
]
|
|
353
|
+
}
|
|
354
|
+
onStatus(.rmx_STATUS_SKIP_FORWARD, trackId: playerItem?.trackId, param: param)
|
|
355
|
+
}
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
func seek(to positionTime: Float, isCommand: Bool) {
|
|
359
|
+
//Handle seeking with the progress slider on lockscreen or control center
|
|
360
|
+
wasPlayingInterrupted = false
|
|
361
|
+
initializeMPCommandCenter()
|
|
362
|
+
|
|
363
|
+
let seekToTime = CMTimeMakeWithSeconds(Float64(positionTime), preferredTimescale: 1000)
|
|
364
|
+
avQueuePlayer.seek(to: seekToTime, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
365
|
+
|
|
366
|
+
let action = "music-controls-seek-to"
|
|
367
|
+
print(String(format: "%@ %.3f", action, positionTime))
|
|
368
|
+
|
|
369
|
+
// Always fire seek event, not just for commands
|
|
370
|
+
let playerItem = avQueuePlayer.currentAudioTrack
|
|
371
|
+
onStatus(.rmxstatus_SEEK, trackId: playerItem?.trackId, param: [
|
|
372
|
+
"position": NSNumber(value: positionTime)
|
|
373
|
+
])
|
|
374
|
+
}
|
|
375
|
+
|
|
376
|
+
func setVolume(_ volume: Float) {
|
|
377
|
+
avQueuePlayer.volume = volume
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
func addTracks(_ tracks: [AudioTrack], startPosition: Float) {
|
|
381
|
+
for playerItem in tracks {
|
|
382
|
+
addTrackObservers(playerItem)
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
avQueuePlayer.appendItems(tracks)
|
|
386
|
+
|
|
387
|
+
if startPosition > 0 {
|
|
388
|
+
seek(to: startPosition, isCommand: false)
|
|
389
|
+
}
|
|
390
|
+
}
|
|
391
|
+
|
|
392
|
+
func setTracks(_ tracks: [AudioTrack], startIndex: Int, startPosition: Float) {
|
|
393
|
+
avQueuePlayer.removeAllTrackObservers()
|
|
394
|
+
|
|
395
|
+
isReplacingItems = true
|
|
396
|
+
print("RmxAudioPlayer[setTracks] replacing tracks ")
|
|
397
|
+
avQueuePlayer.replaceAllItems(with: tracks)
|
|
398
|
+
|
|
399
|
+
print("RmxAudioPlayer[setTracks] replacing finished ")
|
|
400
|
+
for playerItem in tracks {
|
|
401
|
+
addTrackObservers(playerItem)
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
isReplacingItems = false
|
|
405
|
+
print("RmxAudioPlayer[setTracks] added track observers ")
|
|
406
|
+
if !avQueuePlayer.queuedAudioTracks.isEmpty {
|
|
407
|
+
if startIndex >= 0 {
|
|
408
|
+
avQueuePlayer.setCurrentIndex(startIndex)
|
|
409
|
+
}
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
if startPosition > 0 {
|
|
413
|
+
seek(to: startPosition, isCommand: false)
|
|
414
|
+
}
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
func removeAllTracks() {
|
|
418
|
+
for item in avQueuePlayer.queuedAudioTracks {
|
|
419
|
+
removeTrackObservers(item)
|
|
420
|
+
onStatus(.rmxstatus_ITEM_REMOVED, trackId: item.trackId, param: item.toDict())
|
|
421
|
+
}
|
|
422
|
+
|
|
423
|
+
avQueuePlayer.removeAllItems()
|
|
424
|
+
wasPlayingInterrupted = false
|
|
425
|
+
|
|
426
|
+
// Clear lock screen player info when playlist is cleared
|
|
427
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = nil
|
|
428
|
+
}
|
|
429
|
+
|
|
430
|
+
// MARK: - remote control events
|
|
431
|
+
|
|
432
|
+
///
|
|
433
|
+
/// Events - receive events from the iOS remote controls and command center.
|
|
434
|
+
@objc func play(_ event: MPRemoteCommandEvent?) -> MPRemoteCommandHandlerStatus {
|
|
435
|
+
playCommand(true)
|
|
436
|
+
return .success
|
|
437
|
+
}
|
|
438
|
+
|
|
439
|
+
@objc func pause(_ event: MPRemoteCommandEvent?) -> MPRemoteCommandHandlerStatus {
|
|
440
|
+
pauseCommand(true)
|
|
441
|
+
return .success
|
|
442
|
+
}
|
|
443
|
+
|
|
444
|
+
@objc func togglePlayPauseTrackEvent(_ event: MPRemoteCommandEvent?) -> MPRemoteCommandHandlerStatus {
|
|
445
|
+
if avQueuePlayer.isPlaying {
|
|
446
|
+
pauseCommand(true)
|
|
447
|
+
} else {
|
|
448
|
+
playCommand(true)
|
|
449
|
+
}
|
|
450
|
+
return .success
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
@objc func prevTrackEvent(_ event: MPRemoteCommandEvent?) -> MPRemoteCommandHandlerStatus {
|
|
454
|
+
playPrevious(true)
|
|
455
|
+
return .success
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
@objc func nextTrackEvent(_ event: MPRemoteCommandEvent?) -> MPRemoteCommandHandlerStatus {
|
|
459
|
+
playNext(true)
|
|
460
|
+
return .success
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
@objc func changedThumbSlider(onLockScreen event: MPChangePlaybackPositionCommandEvent?) -> MPRemoteCommandHandlerStatus {
|
|
464
|
+
seek(to: Float(event?.positionTime ?? 0.0), isCommand: true)
|
|
465
|
+
return .success
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
// MARK: - notifications
|
|
469
|
+
|
|
470
|
+
///
|
|
471
|
+
/// Notifications
|
|
472
|
+
///
|
|
473
|
+
/// These handle the events raised by the queue and the player items.
|
|
474
|
+
@objc func itemStalledPlaying(_ notification: Notification?) {
|
|
475
|
+
// This happens when the network is insufficient to continue playback.
|
|
476
|
+
let playerItem = avQueuePlayer.currentAudioTrack
|
|
477
|
+
let trackStatus = getStatusItem(playerItem)
|
|
478
|
+
|
|
479
|
+
onStatus(.rmxstatus_STALLED, trackId: playerItem?.trackId, param: trackStatus)
|
|
480
|
+
}
|
|
481
|
+
|
|
482
|
+
@objc func playerItemDidReachEnd(_ notification: Notification?) {
|
|
483
|
+
if let object = notification?.object {
|
|
484
|
+
print("Player item reached end: \(object)")
|
|
485
|
+
}
|
|
486
|
+
let playerItem = notification?.object as? AudioTrack
|
|
487
|
+
// When an item finishes, immediately scrub it back to the beginning
|
|
488
|
+
// so that the visual indicators show you can "play again" or whatever.
|
|
489
|
+
// Might make sense to have a flag for this behavior.
|
|
490
|
+
playerItem?.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: nil)
|
|
491
|
+
|
|
492
|
+
let trackStatus = getStatusItem(playerItem)
|
|
493
|
+
onStatus(.rmxstatus_COMPLETED, trackId: playerItem?.trackId, param: trackStatus)
|
|
494
|
+
if (avQueuePlayer.isAtEnd) {
|
|
495
|
+
onStatus(.rmxstatus_PLAYLIST_COMPLETED, trackId: "INVALID", param: nil)
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
if loop && avQueuePlayer.isAtEnd {
|
|
499
|
+
print("Last music in playlist play ended, loop back.")
|
|
500
|
+
avQueuePlayer.setCurrentIndex(0)
|
|
501
|
+
avQueuePlayer.play()
|
|
502
|
+
}
|
|
503
|
+
}
|
|
504
|
+
|
|
505
|
+
@objc func handleAudioSessionInterruption(_ interruptionNotification: Notification?) {
|
|
506
|
+
if let interruptionNotification = interruptionNotification {
|
|
507
|
+
print("Audio session interruption received: \(interruptionNotification)")
|
|
508
|
+
}
|
|
509
|
+
guard let userInfo = interruptionNotification?.userInfo,
|
|
510
|
+
let typeValue = userInfo[AVAudioSessionInterruptionTypeKey] as? UInt,
|
|
511
|
+
let interruptionType = AVAudioSession.InterruptionType(rawValue: typeValue) else {
|
|
512
|
+
print("notification.userInfo?[AVAudioSessionInterruptionTypeKey]",
|
|
513
|
+
interruptionNotification?.userInfo?[AVAudioSessionInterruptionTypeKey] as Any)
|
|
514
|
+
return
|
|
515
|
+
}
|
|
516
|
+
|
|
517
|
+
switch interruptionType {
|
|
518
|
+
case AVAudioSession.InterruptionType.began:
|
|
519
|
+
let suspended = (interruptionNotification?.userInfo?[AVAudioSessionInterruptionWasSuspendedKey] as? NSNumber)?.boolValue ?? false
|
|
520
|
+
print("AVAudioSessionInterruptionTypeBegan. Was suspended: \(suspended)")
|
|
521
|
+
if avQueuePlayer.isPlaying {
|
|
522
|
+
wasPlayingInterrupted = true
|
|
523
|
+
}
|
|
524
|
+
|
|
525
|
+
// [[self avQueuePlayer] pause];
|
|
526
|
+
pauseCommand(false)
|
|
527
|
+
case AVAudioSession.InterruptionType.ended:
|
|
528
|
+
print("AVAudioSessionInterruptionTypeEnded")
|
|
529
|
+
guard let optionsValue = userInfo[AVAudioSessionInterruptionOptionKey] as? UInt else { return }
|
|
530
|
+
let options = AVAudioSession.InterruptionOptions(rawValue: optionsValue)
|
|
531
|
+
if options.contains(.shouldResume) {
|
|
532
|
+
if wasPlayingInterrupted {
|
|
533
|
+
avQueuePlayer.play()
|
|
534
|
+
}
|
|
535
|
+
} else {
|
|
536
|
+
// Interruption ended. Playback should not resume.
|
|
537
|
+
}
|
|
538
|
+
wasPlayingInterrupted = false
|
|
539
|
+
default:
|
|
540
|
+
break
|
|
541
|
+
}
|
|
542
|
+
}
|
|
543
|
+
|
|
544
|
+
/*
|
|
545
|
+
* This method only executes while the queue is playing, so we can use the playback position event.
|
|
546
|
+
*/
|
|
547
|
+
func executePeriodicUpdate(_ time: CMTime) {
|
|
548
|
+
guard let playerItem = avQueuePlayer.currentAudioTrack else { return }
|
|
549
|
+
|
|
550
|
+
if !CMTIME_IS_INDEFINITE(playerItem.currentTime()) {
|
|
551
|
+
updateNowPlayingTrackInfo(playerItem, updateTrackData: false)
|
|
552
|
+
if avQueuePlayer.isPlaying {
|
|
553
|
+
let trackStatus = getStatusItem(playerItem)
|
|
554
|
+
onStatus(.rmxstatus_PLAYBACK_POSITION, trackId: playerItem.trackId, param: trackStatus)
|
|
555
|
+
}
|
|
556
|
+
}
|
|
557
|
+
|
|
558
|
+
return
|
|
559
|
+
}
|
|
560
|
+
|
|
561
|
+
override func observeValue(forKeyPath keyPath: String?, of object: Any?, change: [NSKeyValueChangeKey : Any]?, context: UnsafeMutableRawPointer?) {
|
|
562
|
+
guard let change = change else {
|
|
563
|
+
return
|
|
564
|
+
}
|
|
565
|
+
|
|
566
|
+
switch keyPath {
|
|
567
|
+
case "currentItem":
|
|
568
|
+
// only fire on real change!
|
|
569
|
+
let player = object as? AVBidirectionalQueuePlayer
|
|
570
|
+
let playerItem = player?.currentAudioTrack
|
|
571
|
+
if playerItem != nil {
|
|
572
|
+
guard !isReplacingItems && self.lastTrackId != playerItem?.trackId else {
|
|
573
|
+
return
|
|
574
|
+
}
|
|
575
|
+
print("observe change currentItem: lastTrackId \(self.lastTrackId ?? "nil") playerItem: \(playerItem?.trackId ?? "nil")")
|
|
576
|
+
self.lastTrackId = playerItem?.trackId
|
|
577
|
+
handleCurrentItemChanged(playerItem)
|
|
578
|
+
} else {
|
|
579
|
+
self.lastTrackId = nil
|
|
580
|
+
}
|
|
581
|
+
|
|
582
|
+
case "rate":
|
|
583
|
+
guard lastRate != change[.newKey] as? Float else {
|
|
584
|
+
return // todo should call super instead or?
|
|
585
|
+
}
|
|
586
|
+
self.lastRate = change[.newKey] as? Float
|
|
587
|
+
let player = object as? AVBidirectionalQueuePlayer
|
|
588
|
+
|
|
589
|
+
guard let playerItem = player?.currentAudioTrack else { return }
|
|
590
|
+
|
|
591
|
+
let trackStatus = getStatusItem(playerItem)
|
|
592
|
+
print("Playback rate changed: \(String(describing: change[.newKey])), is playing: \(player?.isPlaying ?? false)")
|
|
593
|
+
|
|
594
|
+
if player?.isPlaying ?? false {
|
|
595
|
+
onStatus(.rmxstatus_PLAYING, trackId: playerItem.trackId, param: trackStatus)
|
|
596
|
+
} else {
|
|
597
|
+
onStatus(.rmxstatus_PAUSE, trackId: playerItem.trackId, param: trackStatus)
|
|
598
|
+
}
|
|
599
|
+
case "status":
|
|
600
|
+
DispatchQueue.main.debounce(interval: 0.2, context: self, action: { [self] in
|
|
601
|
+
let playerItem = object as? AudioTrack
|
|
602
|
+
handleTrackStatusEvent(playerItem)
|
|
603
|
+
})
|
|
604
|
+
case "timeControlStatus":
|
|
605
|
+
let player = object as? AVBidirectionalQueuePlayer
|
|
606
|
+
|
|
607
|
+
guard let playerItem = player?.currentAudioTrack else {
|
|
608
|
+
return
|
|
609
|
+
}
|
|
610
|
+
guard lastTrackId != playerItem.trackId || player?.isAtBeginning ?? false else {
|
|
611
|
+
return // todo should call super instead or?
|
|
612
|
+
}
|
|
613
|
+
|
|
614
|
+
let trackStatus = getStatusItem(playerItem)
|
|
615
|
+
print("TCSPlayback rate changed: \(String(describing: change[.newKey])), is playing: \(player?.isPlaying ?? false)")
|
|
616
|
+
|
|
617
|
+
if player?.timeControlStatus == .playing {
|
|
618
|
+
onStatus(.rmxstatus_PLAYING, trackId: playerItem.trackId, param: trackStatus)
|
|
619
|
+
} else if player?.timeControlStatus == .waitingToPlayAtSpecifiedRate {
|
|
620
|
+
onStatus(.rmxstatus_BUFFERING, trackId: playerItem.trackId, param: trackStatus)
|
|
621
|
+
} else {
|
|
622
|
+
onStatus(.rmxstatus_PAUSE, trackId: playerItem.trackId, param: trackStatus)
|
|
623
|
+
}
|
|
624
|
+
case "duration":
|
|
625
|
+
DispatchQueue.main.debounce(interval: 0.5, context: self, action: { [self] in
|
|
626
|
+
let playerItem = object as? AudioTrack
|
|
627
|
+
handleTrackDuration(playerItem)
|
|
628
|
+
})
|
|
629
|
+
case "loadedTimeRanges":
|
|
630
|
+
let playerItem = object as? AudioTrack
|
|
631
|
+
handleTrackBuffering(playerItem)
|
|
632
|
+
default:
|
|
633
|
+
super.observeValue(forKeyPath: keyPath, of: object, change: change, context: context)
|
|
634
|
+
}
|
|
635
|
+
}
|
|
636
|
+
|
|
637
|
+
func updateNowPlayingTrackInfo(_ playerItem: AudioTrack?, updateTrackData: Bool) {
|
|
638
|
+
let currentItem = playerItem ?? avQueuePlayer.currentAudioTrack
|
|
639
|
+
let nowPlayingInfoCenter = MPNowPlayingInfoCenter.default()
|
|
640
|
+
self.nowPlayingInfoQueue.sync {
|
|
641
|
+
if updatedNowPlayingInfo == nil {
|
|
642
|
+
let nowPlayingInfo = nowPlayingInfoCenter.nowPlayingInfo
|
|
643
|
+
updatedNowPlayingInfo = nowPlayingInfo ?? [:]
|
|
644
|
+
}
|
|
645
|
+
}
|
|
646
|
+
|
|
647
|
+
var currentTime: Float? = nil
|
|
648
|
+
if let currentTime1 = currentItem?.currentTime() {
|
|
649
|
+
currentTime = Float(CMTimeGetSeconds(currentTime1))
|
|
650
|
+
}
|
|
651
|
+
var duration: Float? = nil
|
|
652
|
+
if let duration1 = currentItem?.duration {
|
|
653
|
+
duration = Float(CMTimeGetSeconds(duration1))
|
|
654
|
+
}
|
|
655
|
+
if CMTIME_IS_INDEFINITE(currentItem!.duration) {
|
|
656
|
+
duration = 0
|
|
657
|
+
}
|
|
658
|
+
|
|
659
|
+
self.nowPlayingInfoQueue.sync {
|
|
660
|
+
if updateTrackData {
|
|
661
|
+
updatedNowPlayingInfo![MPMediaItemPropertyArtist] = currentItem?.artist
|
|
662
|
+
updatedNowPlayingInfo![MPMediaItemPropertyTitle] = currentItem?.title
|
|
663
|
+
updatedNowPlayingInfo![MPMediaItemPropertyAlbumTitle] = currentItem?.album
|
|
664
|
+
|
|
665
|
+
if let mediaItemArtwork = createCoverArtwork(currentItem?.albumArt?.absoluteString) {
|
|
666
|
+
updatedNowPlayingInfo![MPMediaItemPropertyArtwork] = mediaItemArtwork
|
|
667
|
+
}
|
|
668
|
+
}
|
|
669
|
+
updatedNowPlayingInfo![MPMediaItemPropertyPlaybackDuration] = duration ?? 0.0
|
|
670
|
+
updatedNowPlayingInfo![MPNowPlayingInfoPropertyElapsedPlaybackTime] = currentTime ?? 0.0
|
|
671
|
+
updatedNowPlayingInfo![MPNowPlayingInfoPropertyPlaybackRate] = 1.0
|
|
672
|
+
|
|
673
|
+
MPNowPlayingInfoCenter.default().nowPlayingInfo = updatedNowPlayingInfo
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
677
|
+
commandCenter.nextTrackCommand.isEnabled = !avQueuePlayer.isAtEnd
|
|
678
|
+
commandCenter.previousTrackCommand.isEnabled = !avQueuePlayer.isAtBeginning
|
|
679
|
+
}
|
|
680
|
+
|
|
681
|
+
func createCoverArtwork(_ coverUriOrNil: String?) -> MPMediaItemArtwork? {
|
|
682
|
+
guard let coverUri = coverUriOrNil else {
|
|
683
|
+
return nil
|
|
684
|
+
}
|
|
685
|
+
var coverImage: UIImage? = nil
|
|
686
|
+
if coverUri.hasPrefix("http://") || coverUri.hasPrefix("https://") {
|
|
687
|
+
let coverImageUrl = URL(string: coverUri)!
|
|
688
|
+
|
|
689
|
+
do {
|
|
690
|
+
let coverImageData = try Data(contentsOf: coverImageUrl)
|
|
691
|
+
coverImage = UIImage(data: coverImageData)
|
|
692
|
+
} catch {
|
|
693
|
+
print("Error creating the coverImageData");
|
|
694
|
+
}
|
|
695
|
+
} else {
|
|
696
|
+
if FileManager.default.fileExists(atPath: coverUri) {
|
|
697
|
+
coverImage = UIImage(contentsOfFile: coverUri)
|
|
698
|
+
}
|
|
699
|
+
}
|
|
700
|
+
|
|
701
|
+
if isCoverImageValid(coverImage) {
|
|
702
|
+
return MPMediaItemArtwork.init(boundsSize: coverImage!.size, requestHandler: { (size) -> UIImage in
|
|
703
|
+
return coverImage!
|
|
704
|
+
})
|
|
705
|
+
}
|
|
706
|
+
return nil;
|
|
707
|
+
}
|
|
708
|
+
|
|
709
|
+
func downloadImage(url: URL, completion: @escaping ((_ image: UIImage?) -> Void)){
|
|
710
|
+
print("Started downloading \"\(url.deletingPathExtension().lastPathComponent)\".")
|
|
711
|
+
self.getImageDataFromUrl(url) { (_ data: Data?) in
|
|
712
|
+
DispatchQueue.main.async {
|
|
713
|
+
print("Finished downloading \"\(url.deletingPathExtension().lastPathComponent)\".")
|
|
714
|
+
completion(UIImage(data: data!))
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
}
|
|
718
|
+
|
|
719
|
+
func getImageDataFromUrl(_ url: URL, completion: @escaping ((_ data: Data?) -> Void)) {
|
|
720
|
+
URLSession.shared.dataTask(with: url) { (data, response, error) in
|
|
721
|
+
completion(data)
|
|
722
|
+
}.resume()
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
func isCoverImageValid(_ coverImage: UIImage?) -> Bool {
|
|
726
|
+
return coverImage != nil && (coverImage?.ciImage != nil || coverImage?.cgImage != nil)
|
|
727
|
+
}
|
|
728
|
+
|
|
729
|
+
func handleCurrentItemChanged(_ playerItem: AudioTrack?) {
|
|
730
|
+
if let playerItem = playerItem {
|
|
731
|
+
print("Queue changed current item to: \(playerItem.trackId ?? "nil")")
|
|
732
|
+
// NSLog(@"New music name: %@", ((AVURLAsset*)playerItem.asset).URL.pathComponents.lastObject);
|
|
733
|
+
print("New item ID: \(playerItem.trackId ?? "")")
|
|
734
|
+
print("Queue is at end: \(avQueuePlayer.isAtEnd ? "YES" : "NO")")
|
|
735
|
+
// When an item starts, immediately scrub it back to the beginning
|
|
736
|
+
//playerItem.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero, completionHandler: nil)
|
|
737
|
+
// Update the command center
|
|
738
|
+
updateNowPlayingTrackInfo(playerItem, updateTrackData: true)
|
|
739
|
+
} else if loop {
|
|
740
|
+
return
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
var info: [String: Any] = [:]
|
|
744
|
+
if let to = playerItem != nil ? playerItem?.toDict() : [:] {
|
|
745
|
+
info = [
|
|
746
|
+
"currentItem": to,
|
|
747
|
+
"currentIndex": NSNumber(value: avQueuePlayer.currentIndex() ?? 0),
|
|
748
|
+
"isAtEnd": NSNumber(value: avQueuePlayer.isAtEnd),
|
|
749
|
+
"isAtBeginning": NSNumber(value: avQueuePlayer.isAtBeginning),
|
|
750
|
+
"hasNext": NSNumber(value: !avQueuePlayer.isAtEnd),
|
|
751
|
+
"hasPrevious": NSNumber(value: !avQueuePlayer.isAtBeginning)
|
|
752
|
+
]
|
|
753
|
+
}
|
|
754
|
+
let trackId = playerItem != nil ? playerItem?.trackId : "NONE"
|
|
755
|
+
|
|
756
|
+
print("Update Track changed: \(info)")
|
|
757
|
+
onStatus(.rmxstatus_TRACK_CHANGED, trackId: trackId, param: info)
|
|
758
|
+
|
|
759
|
+
if avQueuePlayer.isAtEnd && avQueuePlayer.currentItem == nil {
|
|
760
|
+
if !loop {
|
|
761
|
+
avQueuePlayer.seek(to: .zero, toleranceBefore: .zero, toleranceAfter: .zero)
|
|
762
|
+
}
|
|
763
|
+
|
|
764
|
+
if !avQueuePlayer.queuedAudioTracks.isEmpty && !isReplacingItems {
|
|
765
|
+
onStatus(.rmxstatus_PLAYLIST_COMPLETED, trackId: "INVALID", param: nil)
|
|
766
|
+
}
|
|
767
|
+
|
|
768
|
+
if loop && !avQueuePlayer.queuedAudioTracks.isEmpty {
|
|
769
|
+
avQueuePlayer.setCurrentIndex(0)
|
|
770
|
+
// not playing here
|
|
771
|
+
} else {
|
|
772
|
+
onStatus(.rmxstatus_STOPPED, trackId: "INVALID", param: nil)
|
|
773
|
+
}
|
|
774
|
+
}
|
|
775
|
+
}
|
|
776
|
+
|
|
777
|
+
func handleTrackStatusEvent(_ playerItem: AudioTrack?) {
|
|
778
|
+
guard let playerItem = playerItem else {
|
|
779
|
+
return
|
|
780
|
+
}
|
|
781
|
+
// NSString* name = ((AVURLAsset*)playerItem.asset).URL.pathComponents.lastObject;
|
|
782
|
+
let name = playerItem.trackId
|
|
783
|
+
let status = playerItem.status
|
|
784
|
+
|
|
785
|
+
// Switch over the status
|
|
786
|
+
switch status {
|
|
787
|
+
case .readyToPlay:
|
|
788
|
+
print("PlayerItem status changed to AVPlayerItemStatusReadyToPlay [\(name ?? "")]")
|
|
789
|
+
let trackStatus = getStatusItem(playerItem)
|
|
790
|
+
onStatus(.rmxstatus_CANPLAY, trackId: playerItem.trackId, param: trackStatus)
|
|
791
|
+
|
|
792
|
+
if isWaitingToStartPlayback {
|
|
793
|
+
isWaitingToStartPlayback = false
|
|
794
|
+
print("RmxAudioPlayer[setPlaylistItems] is beginning playback after waiting for ReadyToPlay event")
|
|
795
|
+
playCommand(false)
|
|
796
|
+
}
|
|
797
|
+
case .failed:
|
|
798
|
+
// Failed. Examine AVPlayerItem.error
|
|
799
|
+
isWaitingToStartPlayback = false
|
|
800
|
+
var errorMsg = ""
|
|
801
|
+
if let error = playerItem.error {
|
|
802
|
+
print("\(error)")
|
|
803
|
+
errorMsg = "Error playing audio track: \((error as NSError).localizedFailureReason ?? "")"
|
|
804
|
+
}
|
|
805
|
+
print("AVPlayerItemStatusFailed: \(errorMsg)")
|
|
806
|
+
let errorParam = createError(withCode: .rmxerr_DECODE, message: errorMsg)
|
|
807
|
+
onStatus(.rmxstatus_ERROR, trackId: playerItem.trackId, param: errorParam)
|
|
808
|
+
case .unknown:
|
|
809
|
+
isWaitingToStartPlayback = false
|
|
810
|
+
print("PlayerItem status changed to AVPlayerItemStatusUnknown [\(name ?? "")]")
|
|
811
|
+
// Not ready
|
|
812
|
+
default:
|
|
813
|
+
break
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
func handleTrackDuration(_ playerItem: AudioTrack?) {
|
|
818
|
+
// This function isn't all that useful really in terms of state management.
|
|
819
|
+
// It doesn't always fire, and it is not needed because the queue's periodic update can also
|
|
820
|
+
// deliver this info.
|
|
821
|
+
//NSString* name = ((AVURLAsset*)playerItem.asset).URL.pathComponents.lastObject;
|
|
822
|
+
|
|
823
|
+
guard let playerItem = playerItem else { return }
|
|
824
|
+
|
|
825
|
+
if !CMTIME_IS_INDEFINITE(playerItem.duration) {
|
|
826
|
+
let duration = CMTimeGetSeconds(playerItem.duration)
|
|
827
|
+
print("The track duration was changed [\(playerItem.trackId ?? "")]: \(duration)")
|
|
828
|
+
|
|
829
|
+
// We will still report the duration though.
|
|
830
|
+
let trackStatus = getStatusItem(playerItem)
|
|
831
|
+
onStatus(.rmxstatus_DURATION, trackId: playerItem.trackId, param: trackStatus)
|
|
832
|
+
} else if let url = (playerItem.asset as? AVURLAsset)?.url {
|
|
833
|
+
print("Item duration is indefinite (unknown): \(url)")
|
|
834
|
+
}
|
|
835
|
+
}
|
|
836
|
+
|
|
837
|
+
func handleTrackBuffering(_ playerItem: AudioTrack?) {
|
|
838
|
+
//NSString* name = ((AVURLAsset*)playerItem.asset).URL.pathComponents.lastObject;
|
|
839
|
+
let name = playerItem?.trackId
|
|
840
|
+
let trackStatus = getStatusItem(playerItem)
|
|
841
|
+
|
|
842
|
+
print(
|
|
843
|
+
String(format: " . . . %.5f -> %.5f (%.1f %%) [%@]",
|
|
844
|
+
(trackStatus?["bufferStart"] as? NSNumber)?.floatValue ?? 0.0,
|
|
845
|
+
(trackStatus?["bufferStart"] as? NSNumber)?.floatValue ?? 0.0 + (trackStatus?["bufferEnd"] as? NSNumber)!.floatValue ,
|
|
846
|
+
(trackStatus?["bufferPercent"] as? NSNumber)?.floatValue ?? 0.0, name ?? ""
|
|
847
|
+
)
|
|
848
|
+
)
|
|
849
|
+
|
|
850
|
+
onStatus(.rmxstatus_BUFFERING, trackId: playerItem?.trackId, param: trackStatus)
|
|
851
|
+
|
|
852
|
+
if (trackStatus?["bufferPercent"] as? NSNumber)?.floatValue ?? 0.0 >= 100.0 {
|
|
853
|
+
onStatus(.rmxstatus_LOADED, trackId: playerItem?.trackId, param: trackStatus)
|
|
854
|
+
}
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
///
|
|
858
|
+
/// Status utilities
|
|
859
|
+
///
|
|
860
|
+
/// These provide the statis objects and data for the player items when they update.
|
|
861
|
+
///
|
|
862
|
+
/// It is largely this data that is actually reported to the consumers.
|
|
863
|
+
|
|
864
|
+
// Not really needed, the dicts do this themselves but, blah.
|
|
865
|
+
func getNumberFor(_ str: String?) -> NSNumber? {
|
|
866
|
+
let f = NumberFormatter()
|
|
867
|
+
f.numberStyle = .decimal
|
|
868
|
+
f.locale = NSLocale.current
|
|
869
|
+
return f.number(from: str ?? "")
|
|
870
|
+
}
|
|
871
|
+
|
|
872
|
+
func getStatusItem(_ playerItem: AudioTrack?) -> [String : Any]? {
|
|
873
|
+
guard let currentItem = playerItem ?? avQueuePlayer.currentAudioTrack else {
|
|
874
|
+
return nil
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
let bufferInfo = getTrackBufferInfo(currentItem)
|
|
878
|
+
var position = getTrackCurrentTime(currentItem)
|
|
879
|
+
let duration = (bufferInfo?["duration"] as? NSNumber)?.floatValue ?? 0.0
|
|
880
|
+
|
|
881
|
+
// Correct this value here, so that playbackPercent is not set to INFINITY
|
|
882
|
+
if position.isNaN || position.isInfinite {
|
|
883
|
+
position = 0.0
|
|
884
|
+
}
|
|
885
|
+
|
|
886
|
+
let playbackPercent = duration > 0 ? (position / duration) * 100.0 : 0.0
|
|
887
|
+
|
|
888
|
+
var status: String
|
|
889
|
+
switch currentItem.status {
|
|
890
|
+
case .readyToPlay:
|
|
891
|
+
status = "ready"
|
|
892
|
+
case .failed:
|
|
893
|
+
status = "error"
|
|
894
|
+
default:
|
|
895
|
+
status = "unknown"
|
|
896
|
+
}
|
|
897
|
+
|
|
898
|
+
if avQueuePlayer.currentItem == currentItem {
|
|
899
|
+
if avQueuePlayer.rate != 0.0 {
|
|
900
|
+
status = "playing"
|
|
901
|
+
|
|
902
|
+
if position <= 0 && (bufferInfo?["bufferPercent"] as? NSNumber)?.floatValue ?? 0.0 == 0.0 {
|
|
903
|
+
status = "loading"
|
|
904
|
+
}
|
|
905
|
+
} else {
|
|
906
|
+
status = "paused"
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
|
|
910
|
+
return [
|
|
911
|
+
"trackId": currentItem.trackId ?? "",
|
|
912
|
+
"isStream": currentItem.isStream ? NSNumber(value: 1) : NSNumber(value: 0),
|
|
913
|
+
"currentIndex": NSNumber(value: avQueuePlayer.currentIndex() ?? 0),
|
|
914
|
+
"status": status,
|
|
915
|
+
"currentPosition": NSNumber(value: position),
|
|
916
|
+
"duration": NSNumber(value: duration),
|
|
917
|
+
"playbackPercent": NSNumber(value: playbackPercent),
|
|
918
|
+
"bufferPercent": NSNumber(value: (bufferInfo?["bufferPercent"] as? NSNumber)?.floatValue ?? 0.0),
|
|
919
|
+
"bufferStart": NSNumber(value: (bufferInfo?["start"] as? NSNumber)?.floatValue ?? 0.0),
|
|
920
|
+
"bufferEnd": NSNumber(value: (bufferInfo?["end"] as? NSNumber)?.floatValue ?? 0.0)
|
|
921
|
+
]
|
|
922
|
+
}
|
|
923
|
+
|
|
924
|
+
func getTrackCurrentTime(_ playerItem: AudioTrack?) -> Float {
|
|
925
|
+
guard let currentItem = playerItem ?? avQueuePlayer.currentAudioTrack else {
|
|
926
|
+
return 0
|
|
927
|
+
}
|
|
928
|
+
|
|
929
|
+
if !CMTIME_IS_INDEFINITE(currentItem.currentTime()) && CMTIME_IS_VALID(currentItem.currentTime()) {
|
|
930
|
+
return Float(CMTimeGetSeconds(currentItem.currentTime()))
|
|
931
|
+
} else {
|
|
932
|
+
return 0
|
|
933
|
+
}
|
|
934
|
+
}
|
|
935
|
+
|
|
936
|
+
func getTrackBufferInfo(_ playerItem: AudioTrack?) -> [String : Any]? {
|
|
937
|
+
guard let playerItem = playerItem, !CMTIME_IS_INDEFINITE(playerItem.duration) else {
|
|
938
|
+
return [
|
|
939
|
+
"start": NSNumber(value: 0.0),
|
|
940
|
+
"end": NSNumber(value: 0.0),
|
|
941
|
+
"bufferPercent": NSNumber(value: 0.0),
|
|
942
|
+
"duration": NSNumber(value: 0.0)
|
|
943
|
+
]
|
|
944
|
+
}
|
|
945
|
+
|
|
946
|
+
let duration = Float(CMTimeGetSeconds(playerItem.duration))
|
|
947
|
+
let timeRanges = playerItem.loadedTimeRanges
|
|
948
|
+
|
|
949
|
+
guard !timeRanges.isEmpty else {
|
|
950
|
+
return [
|
|
951
|
+
"start": NSNumber(value: 0.0),
|
|
952
|
+
"end": NSNumber(value: 0.0),
|
|
953
|
+
"bufferPercent": NSNumber(value: 0.0),
|
|
954
|
+
"duration": NSNumber(value: duration)
|
|
955
|
+
]
|
|
956
|
+
}
|
|
957
|
+
|
|
958
|
+
let timerange = timeRanges[0].timeRangeValue
|
|
959
|
+
let start = Float(CMTimeGetSeconds(timerange.start))
|
|
960
|
+
let rangeEnd = Float(CMTimeGetSeconds(timerange.duration))
|
|
961
|
+
let bufferPercent = (rangeEnd / duration) * 100.0
|
|
962
|
+
|
|
963
|
+
return [
|
|
964
|
+
"start": NSNumber(value: start),
|
|
965
|
+
"end": NSNumber(value: rangeEnd),
|
|
966
|
+
"bufferPercent": NSNumber(value: bufferPercent),
|
|
967
|
+
"duration": NSNumber(value: duration)
|
|
968
|
+
]
|
|
969
|
+
}
|
|
970
|
+
|
|
971
|
+
// MARK: - plugin initialization
|
|
972
|
+
|
|
973
|
+
///
|
|
974
|
+
/// Object initialization. Mostly boring plumbing to initialize the objects and wire everything up.
|
|
975
|
+
func initializeMPCommandCenter() {
|
|
976
|
+
if !commandCenterRegistered {
|
|
977
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
978
|
+
commandCenter.playCommand.isEnabled = true
|
|
979
|
+
commandCenter.playCommand.addTarget(self, action: #selector(play(_:)))
|
|
980
|
+
commandCenter.pauseCommand.isEnabled = true
|
|
981
|
+
commandCenter.pauseCommand.addTarget(self, action: #selector(pause(_:)))
|
|
982
|
+
commandCenter.nextTrackCommand.isEnabled = true
|
|
983
|
+
commandCenter.nextTrackCommand.addTarget(self, action: #selector(nextTrackEvent(_:)))
|
|
984
|
+
commandCenter.previousTrackCommand.isEnabled = true
|
|
985
|
+
commandCenter.previousTrackCommand.addTarget(self, action: #selector(prevTrackEvent(_:)))
|
|
986
|
+
commandCenter.togglePlayPauseCommand.isEnabled = true
|
|
987
|
+
commandCenter.togglePlayPauseCommand.addTarget(self, action: #selector(togglePlayPauseTrackEvent(_:)))
|
|
988
|
+
commandCenter.changePlaybackPositionCommand.isEnabled = true
|
|
989
|
+
commandCenter.changePlaybackPositionCommand.addTarget(self, action: #selector(changedThumbSlider(onLockScreen:)))
|
|
990
|
+
|
|
991
|
+
commandCenterRegistered = true
|
|
992
|
+
}
|
|
993
|
+
}
|
|
994
|
+
|
|
995
|
+
func findTrackIndex(byId trackId: String?, _ tracks: [AudioTrack]) -> [String: Any]? {
|
|
996
|
+
let trackInformation: (Int, AudioTrack)? = tracks
|
|
997
|
+
.enumerated()
|
|
998
|
+
.first(where: { _, track in
|
|
999
|
+
track.trackId == trackId
|
|
1000
|
+
})
|
|
1001
|
+
|
|
1002
|
+
guard
|
|
1003
|
+
let index = trackInformation?.0,
|
|
1004
|
+
let track = trackInformation?.1
|
|
1005
|
+
else {
|
|
1006
|
+
return nil
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
return [
|
|
1010
|
+
"track": track,
|
|
1011
|
+
"index": NSNumber(value: index)
|
|
1012
|
+
]
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
func findTrack(byId trackId: String?) -> [String: Any]? {
|
|
1016
|
+
let trackInformation: (Int, AudioTrack)? = avQueuePlayer.queuedAudioTracks
|
|
1017
|
+
.enumerated()
|
|
1018
|
+
.first(where: { _, track in
|
|
1019
|
+
track.trackId == trackId
|
|
1020
|
+
})
|
|
1021
|
+
|
|
1022
|
+
guard
|
|
1023
|
+
let index = trackInformation?.0,
|
|
1024
|
+
let track = trackInformation?.1
|
|
1025
|
+
else {
|
|
1026
|
+
return nil
|
|
1027
|
+
}
|
|
1028
|
+
|
|
1029
|
+
return [
|
|
1030
|
+
"track": track,
|
|
1031
|
+
"index": NSNumber(value: index)
|
|
1032
|
+
]
|
|
1033
|
+
}
|
|
1034
|
+
|
|
1035
|
+
func addTrackObservers(_ playerItem: AudioTrack?) {
|
|
1036
|
+
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)
|
|
1040
|
+
|
|
1041
|
+
// We don't need this one because we get the currentItem notification from the queue.
|
|
1042
|
+
// But we will wire it up anyway...
|
|
1043
|
+
let listener = NotificationCenter.default
|
|
1044
|
+
listener.addObserver(self, selector: #selector(playerItemDidReachEnd(_:)), name: .AVPlayerItemDidPlayToEndTime, object: playerItem)
|
|
1045
|
+
// Subscribe to the AVPlayerItem's PlaybackStalledNotification notification.
|
|
1046
|
+
listener.addObserver(self, selector: #selector(itemStalledPlaying(_:)), name: .AVPlayerItemPlaybackStalled, object: playerItem)
|
|
1047
|
+
|
|
1048
|
+
onStatus(.rmxstatus_ITEM_ADDED, trackId: playerItem?.trackId, param: playerItem?.toDict())
|
|
1049
|
+
}
|
|
1050
|
+
|
|
1051
|
+
@objc func queueCleared(_ notification: Notification?) {
|
|
1052
|
+
isReplacingItems = false // is this necessary ?
|
|
1053
|
+
print("RmxAudioPlayer, queuePlayerCleared")
|
|
1054
|
+
onStatus(.rmxstatus_PLAYLIST_CLEARED, trackId: "INVALID", param: nil)
|
|
1055
|
+
}
|
|
1056
|
+
|
|
1057
|
+
func removeTrackObservers(_ playerItem: AudioTrack?) {
|
|
1058
|
+
avQueuePlayer.removeTrackObservers(playerItem)
|
|
1059
|
+
}
|
|
1060
|
+
|
|
1061
|
+
func activateAudioSession() {
|
|
1062
|
+
let avSession = AVAudioSession.sharedInstance()
|
|
1063
|
+
|
|
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)
|
|
1069
|
+
|
|
1070
|
+
do {
|
|
1071
|
+
// Always set category first, even if session is already active
|
|
1072
|
+
// This ensures we have the correct category after video player exits
|
|
1073
|
+
try avSession.setCategory(.playAndRecord, options: options)
|
|
1074
|
+
} catch {
|
|
1075
|
+
print("Error setting category! \(error.localizedDescription)")
|
|
1076
|
+
}
|
|
1077
|
+
|
|
1078
|
+
do {
|
|
1079
|
+
// Activate the session (will activate if not active, or update if already active)
|
|
1080
|
+
try AVAudioSession.sharedInstance().setActive(true)
|
|
1081
|
+
} catch {
|
|
1082
|
+
print("Could not activate audio session. \(error.localizedDescription)")
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
/// Register the listener for pause and resume events.
|
|
1087
|
+
func observeLifeCycle() {
|
|
1088
|
+
let listener = NotificationCenter.default
|
|
1089
|
+
|
|
1090
|
+
// We do need these.
|
|
1091
|
+
listener.addObserver(self, selector: #selector(handleAudioSessionInterruption(_:)), name: AVAudioSession.interruptionNotification, object: AVAudioSession.sharedInstance())
|
|
1092
|
+
|
|
1093
|
+
// Listen to when the queue player tells us it emptied the items list
|
|
1094
|
+
listener.addObserver(self, selector: #selector(queueCleared(_:)), name: NSNotification.Name(AVBidirectionalQueueCleared), object: avQueuePlayer)
|
|
1095
|
+
}
|
|
1096
|
+
|
|
1097
|
+
func createError(withCode code: RmxAudioErrorType, message: String?) -> [String : Any]? {
|
|
1098
|
+
[
|
|
1099
|
+
"code": NSNumber(value: code.rawValue),
|
|
1100
|
+
"message": message ?? ""
|
|
1101
|
+
]
|
|
1102
|
+
}
|
|
1103
|
+
|
|
1104
|
+
func onStatus(_ what: RmxAudioStatusMessage, trackId: String?, param: [String:Any]?) {
|
|
1105
|
+
var status: [String : Any] = [:]
|
|
1106
|
+
status["msgType"] = NSNumber(value: what.rawValue)
|
|
1107
|
+
// in the error case contains a dict with "code" and "message", otherwise a NSNumber
|
|
1108
|
+
if let param = param {
|
|
1109
|
+
status["value"] = param
|
|
1110
|
+
}
|
|
1111
|
+
status["trackId"] = trackId ?? ""
|
|
1112
|
+
|
|
1113
|
+
var dict: [String : Any] = [:]
|
|
1114
|
+
dict["action"] = "status"
|
|
1115
|
+
dict["status"] = status
|
|
1116
|
+
|
|
1117
|
+
statusUpdater?.onStatus(dict)
|
|
1118
|
+
}
|
|
1119
|
+
|
|
1120
|
+
/// Cleanup
|
|
1121
|
+
func deregisterMusicControlsEventListener() {
|
|
1122
|
+
let commandCenter = MPRemoteCommandCenter.shared()
|
|
1123
|
+
commandCenter.playCommand.removeTarget(self)
|
|
1124
|
+
commandCenter.pauseCommand.removeTarget(self)
|
|
1125
|
+
commandCenter.nextTrackCommand.removeTarget(self)
|
|
1126
|
+
commandCenter.previousTrackCommand.removeTarget(self)
|
|
1127
|
+
commandCenter.togglePlayPauseCommand.removeTarget(self)
|
|
1128
|
+
commandCenter.changePlaybackPositionCommand.isEnabled = false
|
|
1129
|
+
commandCenter.changePlaybackPositionCommand.removeTarget(self, action: nil)
|
|
1130
|
+
|
|
1131
|
+
commandCenterRegistered = false
|
|
1132
|
+
}
|
|
1133
|
+
|
|
1134
|
+
func onReset() {
|
|
1135
|
+
// Override to cancel any long-running requests when the WebView navigates or refreshes.
|
|
1136
|
+
releaseResources()
|
|
1137
|
+
}
|
|
1138
|
+
|
|
1139
|
+
func releaseResources() {
|
|
1140
|
+
if let playbackTimeObserver = playbackTimeObserver {
|
|
1141
|
+
avQueuePlayer.removeTimeObserver(playbackTimeObserver)
|
|
1142
|
+
}
|
|
1143
|
+
deregisterMusicControlsEventListener()
|
|
1144
|
+
|
|
1145
|
+
removeAllTracks()
|
|
1146
|
+
|
|
1147
|
+
playbackTimeObserver = nil
|
|
1148
|
+
isWaitingToStartPlayback = false
|
|
1149
|
+
}
|
|
1150
|
+
}
|