Skip to content

Commit

Permalink
Move to plugin engine
Browse files Browse the repository at this point in the history
  • Loading branch information
kuamanet committed Aug 30, 2022
1 parent acbf954 commit 248e28a
Show file tree
Hide file tree
Showing 7 changed files with 133 additions and 135 deletions.
2 changes: 1 addition & 1 deletion Example/kMusicSwift.xcodeproj/project.pbxproj
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@
6B3424974F17D2F574222AAF /* LICENSE */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = LICENSE; path = ../LICENSE; sourceTree = "<group>"; };
8E2B18C928B92281005061EE /* nature.mp3 */ = {isa = PBXFileReference; lastKnownFileType = audio.mp3; path = nature.mp3; sourceTree = "<group>"; };
9395B49A8870444AB6F1F7D5 /* Pods-kMusicSwift_Example.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-kMusicSwift_Example.release.xcconfig"; path = "Target Support Files/Pods-kMusicSwift_Example/Pods-kMusicSwift_Example.release.xcconfig"; sourceTree = "<group>"; };
94DD4CCDCDF86874A7626EBD /* kMusicSwift.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = kMusicSwift.podspec; path = ../kMusicSwift.podspec; sourceTree = "<group>"; };
94DD4CCDCDF86874A7626EBD /* kMusicSwift.podspec */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text; name = kMusicSwift.podspec; path = ../kMusicSwift.podspec; sourceTree = "<group>"; xcLanguageSpecificationIdentifier = xcode.lang.ruby; };
AFA213F132C4B1F76E1B15D8 /* Pods-kMusicSwift_Tests.release.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-kMusicSwift_Tests.release.xcconfig"; path = "Target Support Files/Pods-kMusicSwift_Tests/Pods-kMusicSwift_Tests.release.xcconfig"; sourceTree = "<group>"; };
BB7C6F9BFC0756DD3F6EDB7D /* Pods_kMusicSwift_Example.framework */ = {isa = PBXFileReference; explicitFileType = wrapper.framework; includeInIndex = 0; path = Pods_kMusicSwift_Example.framework; sourceTree = BUILT_PRODUCTS_DIR; };
C6B32C999EF9A006B6B3807F /* Pods-kMusicSwift_Example.debug.xcconfig */ = {isa = PBXFileReference; includeInIndex = 1; lastKnownFileType = text.xcconfig; name = "Pods-kMusicSwift_Example.debug.xcconfig"; path = "Target Support Files/Pods-kMusicSwift_Example/Pods-kMusicSwift_Example.debug.xcconfig"; sourceTree = "<group>"; };
Expand Down
13 changes: 3 additions & 10 deletions Example/kMusicSwift/ViewController.swift
Original file line number Diff line number Diff line change
Expand Up @@ -43,17 +43,10 @@ class ViewController: UIViewController {
.assign(to: \.value, on: volumeSlider)
.store(in: &cancellables)

do {
try ["nature.mp3", "AudioSource.mp3"].forEach { track in

let builder = try TrackResourceBuilder()
.fromAsset(track)
let resource = try builder.build()
jap.addTrack(resource)
}
jap.addTrack(TrackResource(uri: "https://ribgame.com/remote.mp3", isRemote: true))

} catch {
handleError(error: error)
["nature.mp3", "AudioSource.mp3"].forEach { track in
jap.addTrack(TrackResource(uri: track))
}
}

Expand Down
13 changes: 0 additions & 13 deletions kMusicSwift/Classes/Error/CouldNotFindAssetError.swift

This file was deleted.

This file was deleted.

11 changes: 0 additions & 11 deletions kMusicSwift/Classes/Error/CouldNotStartEngineError.swift

This file was deleted.

129 changes: 77 additions & 52 deletions kMusicSwift/Classes/JustAudioPlayer.swift
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import AVFoundation
import Combine
import Darwin
import Foundation
import SwiftAudioPlayer

// Icy Metadata (?)
// Volume ✅
Expand Down Expand Up @@ -32,26 +33,35 @@ public enum LoopMode {

@available(iOS 15.0, *)
public class JustAudioPlayer {
public var isPlaying: Bool {
playerNode.isPlaying
}
// whether we're currently playing a song
@Published public private(set) var isPlaying: Bool = false

// the current loop mode
@Published public private(set) var loopMode: LoopMode = .off

// player node volume value
@Published public private(set) var volume: Float?

private let engine: AVAudioEngine = .init()
private let playerNode: AVAudioPlayerNode = .init()

// To be forwarded to the http client, in case we load a track from the internet
// TODO: to be forwarded to the http client, in case we load a track from the internet
var httpHeaders: [String: String] = [:]

/// Tracks which track is being reproduced
private var queueIndex: Int?

/// Full list of tracks that will be played
private var queue: [TrackResource] = []

/// Track that is currently being processed
private var currentTrack: TrackResource? {
if let index = queueIndex {
return queue[index]
}

return nil
}

private var playingStatusSubscription: UInt?

public init() {}

/// To be modified in order to handle multiple tracks at once
Expand All @@ -64,27 +74,60 @@ public class JustAudioPlayer {
/// Starts to play the current queue of the player
/// If the player is already playing, calling this method will result in a no-op
public func play() throws {
if isPlaying {
return
}

if let track = getNextTrack() {
try setupEngine(withProcessingFormat: track.processingFormat)
playingStatusSubscription = SAPlayer.Updates.PlayingStatus
.subscribe { [weak self] playingStatus in

// Edge case:
// When playing a remote song, we often receive this sequence of statuses:
//
// buffering
// ended
// playing
//
// or worse:
//
// ended
// buffering
// playing
// To avoid going to the next song in these situations, we need to know if the current track is really playing

// TODO: remove when stable
print(playingStatus)

let convertedTrackStatus: TrackResourcePlayingStatus = TrackResourcePlayingStatus.fromSAPlayingStatus(playingStatus)

self?.currentTrack?.setPlayingStatus(convertedTrackStatus)

let currentTrackPlayingStatus = self?.currentTrack?.playingStatus ?? .idle

if currentTrackPlayingStatus == .ended {
if let track = self?.tryMoveToNextTrack() {
self?.play(track: track)
}
}

// propagate status to subscribers
self?.isPlaying = currentTrackPlayingStatus == .playing
}

playNext(track: track)
if let track = tryMoveToNextTrack() {
play(track: track)
}
}

// Pause but remain ready to play
public func pause() {
playerNode.pause()
engine.pause()
SAPlayer.shared.pause()
}

public func stop() {
SAPlayer.shared.stopStreamingRemoteAudio()
SAPlayer.shared.playerNode?.stop()
SAPlayer.shared.engine?.stop()
queue.removeAll()
playerNode.stop()
engine.stop()
if let subscription = playingStatusSubscription {
SAPlayer.Updates.PlayingStatus.unsubscribe(subscription)
}
}

// Jump to the 10 second position
Expand Down Expand Up @@ -116,7 +159,7 @@ public class JustAudioPlayer {
throw VolumeValueNotValid(value: volume)
}
self.volume = volume
playerNode.volume = volume
SAPlayer.shared.playerNode?.volume = volume
}

// Set the loop mode
Expand Down Expand Up @@ -157,59 +200,41 @@ public class JustAudioPlayer {

// MARK: - Queue

func getNextTrack() -> AVAudioFile? {
// side effect
/// Tries to move the queue index to the next track.
/// If we're on the last track of the queue or the queue is empty, the queueIndex will not change
func tryMoveToNextTrack() -> TrackResource? {
let currentIndex = queueIndex ?? 0

// do not change the index, and return the current track
if loopMode == LoopMode.one {
return queue[currentIndex].audioFile
return queue[currentIndex]
}

let nextIndex = queueIndex != nil ? currentIndex + 1 : currentIndex

if queue.indices.contains(nextIndex) {
queueIndex = nextIndex
return queue[nextIndex].audioFile
return queue[nextIndex]
}

// we're at the end of the queue
if loopMode == .all {
queueIndex = 0
return queue[0].audioFile
return queue[0]
}

return nil
}

func playNext(track audioFile: AVAudioFile) {
playerNode.scheduleFile(audioFile, at: nil) {
// check and see if we have any other tracks to play
if let track = self.getNextTrack() {
self.playNext(track: track)
func play(track trackResource: TrackResource) {
if let url = trackResource.audioUrl {
if trackResource.isRemote {
SAPlayer.shared.startRemoteAudio(withRemoteUrl: url)
} else {
SAPlayer.shared.startSavedAudio(withSavedUrl: url)
}
}

playerNode.play()
}

// MARK: - Engine

// TODO: evaluate possible extrapolation to own engine class
// TODO: manage consecutive calls to this method: we should either have a state to decide what to do or expect to be in a "clean" state
private func setupEngine(withProcessingFormat processingFormat: AVAudioFormat) throws {
// Setup the engine:

// 1. attach the player
engine.attach(playerNode)

// 2. connect the player to the mixer (we need at least 1 access, even in read mode, to the mainMixerNode, otherwise the start will throw)
engine.connect(playerNode, to: engine.mainMixerNode, format: processingFormat)

engine.prepare()

do {
try engine.start()
} catch {
throw CouldNotStartEngineError(message: "Could not start the engine, check the cause for the full error", cause: error)
SAPlayer.shared.play()
}
}
}
87 changes: 52 additions & 35 deletions kMusicSwift/Classes/TrackResource.swift
Original file line number Diff line number Diff line change
Expand Up @@ -6,50 +6,67 @@
//

import AVFAudio
import SwiftAudioPlayer

// URLs
// Assets
// Files
// TO BE SUPPORTED Schemes: https | file: | asset:
public struct TrackResource {
var audioFile: AVAudioFile
public enum TrackResourcePlayingStatus {
case playing
case paused
case buffering
case ended
case idle

init(audioFile: AVAudioFile) {
self.audioFile = audioFile
static func fromSAPlayingStatus(_ playingStatus: SAPlayingStatus) -> TrackResourcePlayingStatus {
switch playingStatus {
case .playing:
return .playing
case .paused:
return .paused
case .buffering:
return .buffering
case .ended:
return .ended
}
}
}

/// Builds a TrackResource
public class TrackResourceBuilder {
private var inputFileUrl: URL?

public init(inputFileUrl: URL? = nil) {
self.inputFileUrl = inputFileUrl
}

/// Expects an asset name with extension. Ex "track.mp3"
@discardableResult
public func fromAsset(_ asset: String) throws -> TrackResourceBuilder {
guard let inputFileUrl = Bundle.main.url(forResource: asset, withExtension: "") else {
throw CouldNotFindAssetError(message: "input file url: \(inputFileUrl?.description ?? "nil"), asset: \(asset)", cause: nil)
}
// URLs
// Assets
// Files
// TO BE SUPPORTED Schemes: https | file: | asset:
public class TrackResource {
public private(set) var audioUrl: URL?
public private(set) var isRemote: Bool

self.inputFileUrl = inputFileUrl
public private(set) var playingStatus: TrackResourcePlayingStatus = .idle

return self
public init(uri: String, isRemote: Bool = false) {
audioUrl = isRemote ? URL(string: uri) : Bundle.main.url(forResource: uri, withExtension: "")
self.isRemote = isRemote
}

public func build() throws -> TrackResource {
guard let inputFileUrl = inputFileUrl else {
// TODO: this should become a "resource not provided to builder error" when all resource types are handled
fatalError("Provide an asset via the fromAsset method")
}

// Load the track inside a AVAudioFile
guard let inputFile = try? AVAudioFile(forReading: inputFileUrl) else {
throw CouldNotLoadUrlIntoTrackResourceError(message: "input file url: \(inputFileUrl.description)", cause: nil)
/// Enforces the correct flow of the status of a track
public func setPlayingStatus(_ nextStatus: TrackResourcePlayingStatus) {
switch playingStatus {
case .playing:
if nextStatus != .playing, nextStatus != .idle {
playingStatus = nextStatus
}
case .paused:
if nextStatus != .paused {
playingStatus = nextStatus
}
case .buffering:
if nextStatus != .ended {
playingStatus = nextStatus
}
case .ended:
if nextStatus != .idle {
playingStatus = nextStatus
}
case .idle:
if nextStatus != .ended, nextStatus != .paused {
playingStatus = nextStatus
}
}

return TrackResource(audioFile: inputFile)
}
}

0 comments on commit 248e28a

Please sign in to comment.