Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
35 commits
Select commit Hold shift + click to select a range
8e0b643
- update audioItem protocol
parth-codamusic Aug 3, 2023
a6fb1d4
- remove private access of updateNowPlayingPlaybackValues
parth-codamusic Aug 8, 2023
c857479
- update audioItem protocol
parth-codamusic Aug 8, 2023
b069910
- add func recreatePlayer
parth-codamusic Apr 25, 2025
66ab91b
- add item move event
parth-codamusic Apr 25, 2025
d459d93
- add event for queue update
parth-codamusic Apr 28, 2025
3cddd63
Update QueueManager.swift
parth0072 Apr 28, 2025
bbecf46
- fix thread sync issue
parth-codamusic May 14, 2025
2d16af5
- add item function with seek position
parth-codamusic May 22, 2025
249ec19
- update logs
parth-codamusic May 25, 2025
8bcdc8a
- fix asset loading on main thread issue
parth-codamusic May 27, 2025
fa49103
- add preload bool
parth-codamusic May 27, 2025
e58f79a
- remove unnecessary preload data for streaming
parth-codamusic May 29, 2025
3f7968b
- handle fail for only playable
parth-codamusic May 29, 2025
f990bd3
- remove time range
parth-codamusic Jul 14, 2025
aff88a9
- fix compile issue
parth-codamusic Jul 14, 2025
fa9944b
- fix crash in startObserving
parth-codamusic Jul 23, 2025
bbf6953
- add offline mode in queue
parth-codamusic Aug 7, 2025
0002e5c
- preload next song
parth-codamusic Oct 9, 2025
a0400b2
- add clear avplayer queue function
parth-codamusic Oct 16, 2025
93f52f2
- fix wrong song play issue
parth-codamusic Oct 28, 2025
3ed92ad
- fix thread issue
parth-codamusic Oct 29, 2025
e165e6c
- update next preloaded song
bansi-coda Nov 18, 2025
5db0754
- update next preloaded song on queue reorder
bansi-coda Dec 8, 2025
86f36c4
- update next preloaded song
bansi-coda Nov 18, 2025
bf5c01c
Merge remote-tracking branch 'refs/remotes/origin/main'
bansi-coda Dec 9, 2025
e1b0552
- allow public access
bansi-coda Dec 9, 2025
2385722
- fix reply issue
parth-codamusic Jan 2, 2026
a08652f
- fix replay auto issue
parth-codamusic Feb 9, 2026
1a21f34
- add current path change observer
parth-codamusic Feb 18, 2026
2258035
- make avplayer public ref
parth-codamusic Feb 24, 2026
642f323
- add retry logic if song fail in between
parth-codamusic Apr 10, 2026
804840c
- fix IOS-1772
parth-codamusic Apr 10, 2026
70da7b7
Add stall auto-resume via wasPlayingBeforeStall flag
parth-codamusic Apr 17, 2026
4777b7f
Merge pull request #3 from parth0072/fix/auto-skip-and-stall-resume
parth0072 Apr 17, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
238 changes: 164 additions & 74 deletions SwiftAudioEx/Classes/AVPlayerWrapper/AVPlayerWrapper.swift
Original file line number Diff line number Diff line change
Expand Up @@ -20,10 +20,10 @@ public enum PlaybackEndedReason: String {
case failed
}

class AVPlayerWrapper: AVPlayerWrapperProtocol {
public class AVPlayerWrapper: AVPlayerWrapperProtocol {
// MARK: - Properties

fileprivate var avPlayer = AVPlayer()
var avPlayer = AVQueuePlayer()
private let playerObserver = AVPlayerObserver()
internal let playerTimeObserver: AVPlayerTimeObserver
private let playerItemNotificationObserver = AVPlayerItemNotificationObserver()
Expand All @@ -37,7 +37,11 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
label: "AVPlayerWrapper.stateQueue",
attributes: .concurrent
)


private var nextAsset: AVAsset?
private var nextPreloadUrl: URL?
private var wasPlayingBeforeStall = false

public init() {
playerTimeObserver = AVPlayerTimeObserver(periodicObserverTimeInterval: timeEventFrequency.getTime())

Expand All @@ -56,20 +60,17 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
var _state: AVPlayerWrapperState = AVPlayerWrapperState.idle
var state: AVPlayerWrapperState {
get {
var state: AVPlayerWrapperState!
stateQueue.sync {
state = _state
}

return state
return stateQueue.sync { _state }
}
set {
stateQueue.async(flags: .barrier) { [weak self] in
guard let self = self else { return }
let currentState = self._state
if (currentState != newValue) {
if currentState != newValue {
self._state = newValue
self.delegate?.AVWrapper(didChangeState: newValue)
DispatchQueue.main.async {
self.delegate?.AVWrapper(didChangeState: newValue)
}
}
}
}
Expand Down Expand Up @@ -174,7 +175,15 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
func pause() {
playWhenReady = false
}

func recreatePlayer() {
recreateAVPlayer()
}

func clearAvPlayerQueue() {
avPlayer.removeAllItems()
}

func togglePlaying() {
switch avPlayer.timeControlStatus {
case .playing, .waitingToPlayAtSpecifiedRate:
Expand Down Expand Up @@ -228,73 +237,146 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
}

func load() {
if (state == .failed) {
if state == .failed {
recreateAVPlayer()
}
if let asset {
asset.cancelLoading()
self.asset = nil
}
self.stopObservingAVPlayerItem()
nextAsset?.cancelLoading()
guard let url = url else { return }
state = .loading
print("---- requesting new item \(url.absoluteString.suffix(10)) ")
print("---- \(avPlayer.items().compactMap { ($0.asset as? AVURLAsset)?.url.absoluteString.suffix(10) })")
if let item = avPlayer.items().first { ($0.asset as? AVURLAsset)?.url == url }, avPlayer.items().count == 2 {
print("---- load existing item")
self.item = item
self.avPlayer.advanceToNextItem()
self.applyAVPlayerRate()
self.asset = item.asset
self.startObservingAVPlayer(item: item)
self.applyAVPlayerRate()
self.prefetchNextTracks()
return;
}
let pendingAsset = AVURLAsset(url: url, options: urlOptions)
asset = pendingAsset
let keys: [String] = [
"playable",
"availableChapterLocales",
"availableMetadataFormats",
"commonMetadata",
"duration"
]

pendingAsset.loadValuesAsynchronously(forKeys: keys) { [weak self] in
guard let self = self else { return }
DispatchQueue.main.async {
var error: NSError?
let playableStatus = pendingAsset.statusOfValue(forKey: "playable", error: &error)
switch playableStatus {
case .cancelled, .loading, .unknown:
return
case .failed:
self.playbackFailed(error: AudioPlayerError.PlaybackError.failedToLoadKeyValue)
return
default: break
}
self.handleLoadedAsset(pendingAsset)
}
}
}

private func handleLoadedAsset(_ pendingAsset: AVURLAsset) {
guard pendingAsset == asset else { return }

if !pendingAsset.isPlayable {
playbackFailed(error: AudioPlayerError.PlaybackError.itemWasUnplayable)
return
}

let keysToLoad: [String] = [
"playable",
"availableChapterLocales",
"availableMetadataFormats",
"commonMetadata",
"duration"
]

let item = AVPlayerItem(asset: pendingAsset, automaticallyLoadedAssetKeys: keysToLoad)
self.item = item
item.preferredForwardBufferDuration = self.bufferDuration
print("---- avplayer removeallitems ----")
self.avPlayer.removeAllItems()
self.avPlayer.insert(item, after: nil)
self.prefetchNextTracks()
self.startObservingAVPlayer(item: item)
self.applyAVPlayerRate()
if !pendingAsset.availableChapterLocales.isEmpty {
for locale in pendingAsset.availableChapterLocales {
let chapters = pendingAsset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil)
self.delegate?.AVWrapper(didReceiveMetadata: chapters)
}
} else {
clearCurrentItem()
for format in pendingAsset.availableMetadataFormats {
let timeRange = CMTimeRange(start: .zero, end: pendingAsset.duration)
let group = AVTimedMetadataGroup(items: pendingAsset.metadata(forFormat: format), timeRange: timeRange)
self.delegate?.AVWrapper(didReceiveMetadata: [group])
}
}
if let url = url {
let keys = ["playable"]
let pendingAsset = AVURLAsset(url: url, options: urlOptions)
asset = pendingAsset
state = .loading
pendingAsset.loadValuesAsynchronously(forKeys: keys, completionHandler: { [weak self] in

if let initialTime = self.timeToSeekToAfterLoading {
self.timeToSeekToAfterLoading = nil
self.seek(to: initialTime)
}
}

func prefetchNextTracks() {
QueuedAudioPlayer.nextAudioItem.first?.getSourceUrl({ [weak self] urlString in
guard let self = self else { return }
guard let url = URL(string: urlString) else { return }
nextAsset = AVURLAsset(url: url)
guard let asset = nextAsset else { return }
let keys = [
"playable",
"availableChapterLocales",
"availableMetadataFormats",
"commonMetadata",
"duration"
]
asset.loadValuesAsynchronously(forKeys: keys) { [weak self] in
guard let self = self else { return }
var error: NSError?
let status = asset.statusOfValue(forKey: "playable", error: &error)
guard status == .loaded else { return }

let item = AVPlayerItem(asset: asset)
item.preferredForwardBufferDuration = 30
item.canUseNetworkResourcesForLiveStreamingWhilePaused = true

DispatchQueue.main.async {
if (pendingAsset != self.asset) { return; }

for key in keys {
var error: NSError?
let keyStatus = pendingAsset.statusOfValue(forKey: key, error: &error)
switch keyStatus {
case .failed:
self.playbackFailed(error: AudioPlayerError.PlaybackError.failedToLoadKeyValue)
return
case .cancelled, .loading, .unknown:
return
case .loaded:
break
default: break
}
}

if (!pendingAsset.isPlayable) {
self.playbackFailed(error: AudioPlayerError.PlaybackError.itemWasUnplayable)
return;
}

let item = AVPlayerItem(
asset: pendingAsset,
automaticallyLoadedAssetKeys: keys
)
self.item = item;
item.preferredForwardBufferDuration = self.bufferDuration
self.avPlayer.replaceCurrentItem(with: item)
self.startObservingAVPlayer(item: item)
self.applyAVPlayerRate()
if pendingAsset.availableChapterLocales.count > 0 {
for locale in pendingAsset.availableChapterLocales {
let chapters = pendingAsset.chapterMetadataGroups(withTitleLocale: locale, containingItemsWithCommonKeys: nil)
self.delegate?.AVWrapper(didReceiveMetadata: chapters)
}
} else {
for format in pendingAsset.availableMetadataFormats {
let timeRange = CMTimeRange(start: CMTime(seconds: 0, preferredTimescale: 1000), end: pendingAsset.duration)
let group = AVTimedMetadataGroup(items: pendingAsset.metadata(forFormat: format), timeRange: timeRange)
self.delegate?.AVWrapper(didReceiveMetadata: [group])
}
}

if let initialTime = self.timeToSeekToAfterLoading {
self.timeToSeekToAfterLoading = nil
self.seek(to: initialTime)
if self.avPlayer.items().count > 1 {
let nextItems = self.avPlayer.items().dropFirst()
nextItems.forEach { self.avPlayer.remove($0) }
}
self.avPlayer.insert(item, after: nil)
print("---- preload next item \((self.avPlayer.items().compactMap { ($0.asset as? AVURLAsset)?.url.absoluteString.suffix(10) }).joined(separator: " || ") ) ")
}
})
}
}
})
}

func preloadNextTracks(_ url: URL) {
guard !avPlayer.items().contains(where: { ($0.asset as? AVURLAsset)?.url == url} ) else { return }
self.nextPreloadUrl = url
}

func clearPreloadedTracks() {
avPlayer.removeAllItems()
}

func load(from url: URL, playWhenReady: Bool, options: [String: Any]? = nil) {
self.playWhenReady = playWhenReady
self.url = url
Expand Down Expand Up @@ -377,15 +459,15 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
playerItemNotificationObserver.stopObservingCurrentItem()
}

private func recreateAVPlayer() {
func recreateAVPlayer() {
playbackError = nil
playerTimeObserver.unregisterForBoundaryTimeEvents()
playerTimeObserver.unregisterForPeriodicEvents()
playerObserver.stopObserving()
stopObservingAVPlayerItem()
clearCurrentItem()

avPlayer = AVPlayer();
avPlayer = AVQueuePlayer();
setupAVPlayer()

delegate?.AVWrapperDidRecreateAVPlayer()
Expand All @@ -394,7 +476,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
private func setupAVPlayer() {
// disabled since we're not making use of video playback
avPlayer.allowsExternalPlayback = false;

AudioPlayer.avPlayerRef = avPlayer
playerObserver.player = avPlayer
playerObserver.startObserving()

Expand All @@ -403,6 +485,7 @@ class AVPlayerWrapper: AVPlayerWrapperProtocol {
playerTimeObserver.registerForPeriodicTimeEvents()

applyAVPlayerRate()
avPlayer.actionAtItemEnd = .none
}

private func applyAVPlayerRate() {
Expand All @@ -421,11 +504,13 @@ extension AVPlayerWrapper: AVPlayerObserverDelegate {
if self.asset == nil && state != .stopped {
self.state = .idle
} else if (state != .failed && state != .stopped) {
// Playback may have become paused externally for example due to a bluetooth device disconnecting:
// Playback may have become paused externally for example due to a bluetooth device disconnecting,
// or a buffer stall when automaticallyWaitsToMinimizeStalling = false:
if (self.playWhenReady) {
// Only if we are not on the boundaries of the track, otherwise itemDidPlayToEndTime will handle it instead.
if (self.currentTime > 0 && self.currentTime < self.duration) {
self.playWhenReady = false;
wasPlayingBeforeStall = true
self.playWhenReady = false
}
} else {
self.state = .paused
Expand All @@ -436,6 +521,7 @@ extension AVPlayerWrapper: AVPlayerObserverDelegate {
self.state = .buffering
}
case .playing:
wasPlayingBeforeStall = false
self.state = .playing
@unknown default:
break
Expand Down Expand Up @@ -489,8 +575,12 @@ extension AVPlayerWrapper: AVPlayerItemObserverDelegate {
// MARK: - AVPlayerItemObserverDelegate

func item(didUpdatePlaybackLikelyToKeepUp playbackLikelyToKeepUp: Bool) {
if (playbackLikelyToKeepUp && state != .playing) {
if playbackLikelyToKeepUp && state != .playing {
state = .ready
if wasPlayingBeforeStall {
wasPlayingBeforeStall = false
playWhenReady = true
}
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -46,11 +46,15 @@ protocol AVPlayerWrapperProtocol: AnyObject {
func play()

func pause()

func recreatePlayer()

func clearAvPlayerQueue()

func togglePlaying()

func stop()

func seek(to seconds: TimeInterval)

func seek(by offset: TimeInterval)
Expand All @@ -64,4 +68,7 @@ protocol AVPlayerWrapperProtocol: AnyObject {
func unload()

func reload(startFromCurrentTime: Bool)

func preloadNextTracks(_ url: URL)

}
Loading