Compare commits
36 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 566dc86f3f | |||
| d8aa58525c | |||
| 8197db0016 | |||
| c2aee1669b | |||
| 334be32bf9 | |||
| a2da46f85b | |||
| aca69debd1 | |||
| e032d34ff7 | |||
| 280d3464c1 | |||
| f0811c4fc8 | |||
| 6c9ef18d4e | |||
| db8aa646da | |||
| c84f4d9d24 | |||
| 22e46114a6 | |||
| 38bdd32526 | |||
| 28fa4463e0 | |||
| abd8c91b46 | |||
| 86d6e3a05a | |||
| 474a390b29 | |||
| f8cd25bd68 | |||
| 767978e70a | |||
| 85b45f6dfa | |||
| 0e6cadba1b | |||
| ed58739be0 | |||
| 10455ed4be | |||
| 6f48f3a526 | |||
| ed03fcdd0e | |||
| 6e3b50d6f9 | |||
| 21b245c114 | |||
| 8599d66bec | |||
| c6bd74a68c | |||
| bb3e518d08 | |||
| 1097743d57 | |||
| 2efe273f9e | |||
| 2ddd11d255 | |||
| 33575385e3 |
@@ -0,0 +1,24 @@
|
||||
name: "AudioStreaming CI"
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- main
|
||||
- hotfix
|
||||
pull_request:
|
||||
branches:
|
||||
- '*'
|
||||
|
||||
jobs:
|
||||
iOS:
|
||||
name: Test iOS
|
||||
runs-on: macOS-latest
|
||||
env:
|
||||
DEVELOPER_DIR: /Applications/Xcode_12.app/Contents/Developer
|
||||
strategy:
|
||||
matrix:
|
||||
destination: ["OS=14.0,name=iPhone 11 Pro"] #, "OS=12.4,name=iPhone XS", "OS=11.4,name=iPhone X", "OS=10.3.1,name=iPhone SE"]
|
||||
steps:
|
||||
- uses: actions/checkout@v2
|
||||
- name: iOS - ${{ matrix.destination }}
|
||||
run: set -o pipefail && env NSUnbufferedIO=YES xcodebuild -project "AudioStreaming.xcodeproj" -scheme "AudioStreaming" -destination "${{ matrix.destination }}" clean test | xcpretty
|
||||
@@ -55,7 +55,7 @@ class PlayerViewController: UIViewController {
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
tableView.register(UITableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
|
||||
tableView.register(PlaylistTableViewCell.self, forCellReuseIdentifier: "PlaylistCell")
|
||||
|
||||
let controlsController = controlsProvider()
|
||||
playerControlsController = controlsController
|
||||
@@ -120,6 +120,7 @@ extension PlayerViewController: UITableViewDataSource {
|
||||
return cell
|
||||
}
|
||||
cell.textLabel?.text = item.name
|
||||
cell.detailTextLabel?.text = item.queues ? "Queue item" : nil
|
||||
update(status: item.status, of: cell)
|
||||
return cell
|
||||
}
|
||||
@@ -147,3 +148,15 @@ extension PlayerViewController: UITableViewDelegate {
|
||||
viewModel.playItem(at: indexPath)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class PlaylistTableViewCell: UITableViewCell {
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -47,7 +47,7 @@ final class PlayerViewModel {
|
||||
print("malformed url error")
|
||||
return
|
||||
}
|
||||
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, status: .stopped))
|
||||
playlistItemsService.add(item: PlaylistItem(url: url, name: urlString, status: .stopped, queues: false))
|
||||
reloadContent?(.all)
|
||||
}
|
||||
|
||||
@@ -57,13 +57,20 @@ final class PlayerViewModel {
|
||||
|
||||
func playItem(at indexPath: IndexPath) {
|
||||
guard let item = item(at: indexPath) else { return }
|
||||
if let index = currentPlayingItemIndex {
|
||||
playlistItemsService.setStatus(for: index, status: .stopped)
|
||||
reloadContent?(.item(IndexPath(row: index, section: 0)))
|
||||
currentPlayingItemIndex = nil
|
||||
if item.queues {
|
||||
playerService.queue(url: item.url)
|
||||
if currentPlayingItemIndex == nil {
|
||||
currentPlayingItemIndex = indexPath.row
|
||||
}
|
||||
} else {
|
||||
if let index = currentPlayingItemIndex {
|
||||
playlistItemsService.setStatus(for: index, status: .stopped)
|
||||
reloadContent?(.item(IndexPath(row: index, section: 0)))
|
||||
currentPlayingItemIndex = nil
|
||||
}
|
||||
playerService.play(url: item.url)
|
||||
currentPlayingItemIndex = indexPath.row
|
||||
}
|
||||
playerService.play(url: item.url)
|
||||
currentPlayingItemIndex = indexPath.row
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -12,10 +12,12 @@ enum AudioContent: Int, CaseIterable {
|
||||
case offradio
|
||||
case enlefko
|
||||
case pepper966
|
||||
case kosmos
|
||||
case radiox
|
||||
case khruangbin
|
||||
case piano
|
||||
case local
|
||||
case podcast
|
||||
|
||||
var title: String {
|
||||
switch self {
|
||||
@@ -25,6 +27,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return "Enlefko (stream)"
|
||||
case .pepper966:
|
||||
return "Pepper 96.6 (stream)"
|
||||
case .kosmos:
|
||||
return "Kosmos 93.6 (stream)"
|
||||
case .radiox:
|
||||
return "Radio X (stream)"
|
||||
case .khruangbin:
|
||||
@@ -33,6 +37,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return "Piano (mp3)"
|
||||
case .local:
|
||||
return "Local file (mp3)"
|
||||
case .podcast:
|
||||
return "Swift by Sundell. Ep. 50 (mp3)"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -44,6 +50,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
return URL(string: "https://s3.yesstreaming.net:17062/stream")!
|
||||
case .pepper966:
|
||||
return URL(string: "https://ample-09.radiojar.com/pepper.m4a?1593699983=&rj-tok=AAABcw_1KyMAIViq2XpI098ZSQ&rj-ttl=5")!
|
||||
case .kosmos:
|
||||
return URL(string: "https://radiostreaming.ert.gr/ert-kosmos")!
|
||||
case .radiox:
|
||||
return URL(string: "https://media-ssl.musicradio.com/RadioXLondon")!
|
||||
case .khruangbin:
|
||||
@@ -53,6 +61,8 @@ enum AudioContent: Int, CaseIterable {
|
||||
case .local:
|
||||
let path = Bundle.main.path(forResource: "bensound-jazzyfrenchy", ofType: "mp3")!
|
||||
return URL(fileURLWithPath: path)
|
||||
case .podcast:
|
||||
return URL(string: "https://hwcdn.libsyn.com/p/f/6/e/f6e7cb785cf0f71f/SwiftBySundell50.mp3?c_id=45232967&cs_id=45232967&expiration=1605613140&hwt=f9ff0b2f758c3286cd75322e14ef7a23")!
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -56,6 +56,11 @@ final class AudioPlayerService {
|
||||
player.play(url: url)
|
||||
}
|
||||
|
||||
func queue(url: URL) {
|
||||
activateAudioSession()
|
||||
player.queue(url: url)
|
||||
}
|
||||
|
||||
func stop() {
|
||||
player.stop()
|
||||
deactivateAudioSession()
|
||||
|
||||
@@ -19,17 +19,20 @@ struct PlaylistItem: Equatable {
|
||||
let url: URL
|
||||
let name: String
|
||||
let status: Status
|
||||
let queues: Bool
|
||||
|
||||
init(content: AudioContent) {
|
||||
init(content: AudioContent, queues: Bool) {
|
||||
name = content.title
|
||||
url = content.streamUrl
|
||||
status = .stopped
|
||||
self.queues = queues
|
||||
}
|
||||
|
||||
init(url: URL, name: String, status: Status) {
|
||||
init(url: URL, name: String, status: Status, queues: Bool) {
|
||||
self.url = url
|
||||
self.name = name
|
||||
self.status = status
|
||||
self.queues = queues
|
||||
}
|
||||
}
|
||||
|
||||
@@ -70,10 +73,14 @@ final class PlaylistItemsService {
|
||||
guard let item = item(at: index) else {
|
||||
return
|
||||
}
|
||||
items[index] = PlaylistItem(url: item.url, name: item.name, status: status)
|
||||
items[index] = PlaylistItem(url: item.url, name: item.name, status: status, queues: item.queues)
|
||||
}
|
||||
}
|
||||
|
||||
func provideInitialPlaylistItems() -> [PlaylistItem] {
|
||||
AudioContent.allCases.map(PlaylistItem.init(content:))
|
||||
let allCases = AudioContent.allCases
|
||||
let casesForQueueing: [AudioContent] = [.piano, .local, .khruangbin]
|
||||
let allItems = allCases.map { PlaylistItem.init(content: $0 , queues: false) }
|
||||
let casesForQueuingItems = casesForQueueing.map { PlaylistItem.init(content: $0 , queues: true) }
|
||||
return allItems + casesForQueuingItems
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
Pod::Spec.new do |s|
|
||||
s.name = 'AudioStreaming'
|
||||
s.version = '0.1.0'
|
||||
s.version = '0.5.1'
|
||||
s.license = 'MIT'
|
||||
s.summary = 'An AudioPlayer/Streaming library for iOS written in Swift using AVAudioEngine.'
|
||||
s.homepage = 'https://github.com/dimitris-c/AudioStreaming'
|
||||
@@ -13,9 +13,7 @@ Pod::Spec.new do |s|
|
||||
|
||||
s.source_files = 'AudioStreaming/**/*.swift'
|
||||
|
||||
s.frameworks = 'AVFoundation', ' CoreAudio', 'AudioToolbox', 'Network'
|
||||
|
||||
s.pod_target_xcconfig = {
|
||||
'SWIFT_INSTALL_OBJC_HEADER' => 'NO'
|
||||
}
|
||||
end
|
||||
end
|
||||
|
||||
@@ -53,6 +53,8 @@
|
||||
B5AEDBB824744153007D8101 /* AudioStreaming.framework in Frameworks */ = {isa = PBXBuildFile; fileRef = B5AEDBAE24744153007D8101 /* AudioStreaming.framework */; };
|
||||
B5AEDBBF24744153007D8101 /* AudioStreaming.h in Headers */ = {isa = PBXBuildFile; fileRef = B5AEDBB124744153007D8101 /* AudioStreaming.h */; settings = {ATTRIBUTES = (Public, ); }; };
|
||||
B5B3B7CC248647ED00656828 /* AudioPlayerState.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */; };
|
||||
B5D4A40925D9321400E1450C /* IcycastHeaderParser.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */; };
|
||||
B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */; };
|
||||
B5D82E65255DD562009EDAA4 /* NetStatusService.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5D82E64255DD562009EDAA4 /* NetStatusService.swift */; };
|
||||
B5DB66E2255C2EAB00B8DF53 /* AudioEntryProvider.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5DB66E1255C2EAB00B8DF53 /* AudioEntryProvider.swift */; };
|
||||
B5E1DE2524B70B4200955BFB /* AudioPlayerConfiguration.swift in Sources */ = {isa = PBXBuildFile; fileRef = B5E1DE2424B70B4200955BFB /* AudioPlayerConfiguration.swift */; };
|
||||
@@ -143,6 +145,8 @@
|
||||
B5AEDBB724744153007D8101 /* AudioStreamingTests.xctest */ = {isa = PBXFileReference; explicitFileType = wrapper.cfbundle; includeInIndex = 0; path = AudioStreamingTests.xctest; sourceTree = BUILT_PRODUCTS_DIR; };
|
||||
B5AEDBBE24744153007D8101 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist.xml; path = Info.plist; sourceTree = "<group>"; };
|
||||
B5B3B7CB248647ED00656828 /* AudioPlayerState.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerState.swift; sourceTree = "<group>"; };
|
||||
B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcycastHeaderParser.swift; sourceTree = "<group>"; };
|
||||
B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = IcycastHeadersProcessor.swift; sourceTree = "<group>"; };
|
||||
B5D82E64255DD562009EDAA4 /* NetStatusService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = NetStatusService.swift; sourceTree = "<group>"; };
|
||||
B5DB66DA255C079C00B8DF53 /* AVFoundation.framework */ = {isa = PBXFileReference; lastKnownFileType = wrapper.framework; name = AVFoundation.framework; path = System/Library/Frameworks/AVFoundation.framework; sourceTree = SDKROOT; };
|
||||
B5DB66E1255C2EAB00B8DF53 /* AudioEntryProvider.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioEntryProvider.swift; sourceTree = "<group>"; };
|
||||
@@ -205,6 +209,7 @@
|
||||
B55CEAB32485107C0001C498 /* Parser.swift */,
|
||||
B55A736B247FCB420050C53D /* HTTPHeaderParser.swift */,
|
||||
B55CE96D248058B60001C498 /* MetadataParser.swift */,
|
||||
B5D4A40825D9321400E1450C /* IcycastHeaderParser.swift */,
|
||||
);
|
||||
path = Parsers;
|
||||
sourceTree = "<group>";
|
||||
@@ -257,6 +262,7 @@
|
||||
B5667A8F2499018D00D93F85 /* AudioFileStreamProcessor.swift */,
|
||||
B5667B3D249BC43000D93F85 /* AudioPlayerRenderProcessor.swift */,
|
||||
B55CE97024810DE20001C498 /* MetadataStreamProcessor.swift */,
|
||||
B5D4A40B25D9445600E1450C /* IcycastHeadersProcessor.swift */,
|
||||
);
|
||||
path = Processors;
|
||||
sourceTree = "<group>";
|
||||
@@ -594,6 +600,7 @@
|
||||
B51B9F9A24DBE5BF00BDEAA2 /* AVAudioFormat+Convenience.swift in Sources */,
|
||||
B51FE0C624890CCB00F2A4D2 /* PlayerQueueEntries.swift in Sources */,
|
||||
B5EF9557247E9439003E8FF8 /* AudioStreamSource.swift in Sources */,
|
||||
B5D4A40925D9321400E1450C /* IcycastHeaderParser.swift in Sources */,
|
||||
B59DF1A32493E90C0043C498 /* AudioFileStream+Helpers.swift in Sources */,
|
||||
B54D876D2490E4A000C361A0 /* UnitDescriptions.swift in Sources */,
|
||||
B514657F248E3884005C03F7 /* DispatchTimerSource.swift in Sources */,
|
||||
@@ -604,6 +611,7 @@
|
||||
B5EF955B247EBCB3003E8FF8 /* AudioFileType.swift in Sources */,
|
||||
B592E1252545FF9A008866FB /* BiMap.swift in Sources */,
|
||||
B5DB66E2255C2EAB00B8DF53 /* AudioEntryProvider.swift in Sources */,
|
||||
B5D4A41025D948EF00E1450C /* IcycastHeadersProcessor.swift in Sources */,
|
||||
B5667A902499018D00D93F85 /* AudioFileStreamProcessor.swift in Sources */,
|
||||
B59D0B6F255C904900D6CCE5 /* FileAudioSource.swift in Sources */,
|
||||
B5EF9555247E9393003E8FF8 /* AudioEntry.swift in Sources */,
|
||||
@@ -786,6 +794,7 @@
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
@@ -798,7 +807,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MARKETING_VERSION = 0.5.1;
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
@@ -816,6 +825,7 @@
|
||||
buildSettings = {
|
||||
CLANG_ENABLE_MODULES = YES;
|
||||
CODE_SIGN_STYLE = Automatic;
|
||||
CURRENT_PROJECT_VERSION = 2;
|
||||
DEFINES_MODULE = YES;
|
||||
DYLIB_COMPATIBILITY_VERSION = 1;
|
||||
DYLIB_CURRENT_VERSION = 1;
|
||||
@@ -828,7 +838,7 @@
|
||||
"@executable_path/Frameworks",
|
||||
"@loader_path/Frameworks",
|
||||
);
|
||||
MARKETING_VERSION = 0.1.0;
|
||||
MARKETING_VERSION = 0.5.1;
|
||||
OTHER_LDFLAGS = "-ObjC";
|
||||
PRODUCT_BUNDLE_IDENTIFIER = com.decimal.AudioStreaming;
|
||||
PRODUCT_NAME = "$(TARGET_NAME:c99extidentifier)";
|
||||
|
||||
@@ -46,10 +46,10 @@ final class AudioEntryProvider: AudioEntryProviding {
|
||||
FileAudioSource(url: url, underlyingQueue: underlyingQueue)
|
||||
}
|
||||
|
||||
func source(for url: URL, headers _: [String: String]) -> CoreAudioStreamSource {
|
||||
func source(for url: URL, headers: [String: String]) -> CoreAudioStreamSource {
|
||||
guard !url.isFileURL else {
|
||||
return provideFileAudioSource(url: url)
|
||||
}
|
||||
return provideAudioSource(url: url, headers: [:])
|
||||
return provideAudioSource(url: url, headers: headers)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,278 +0,0 @@
|
||||
//
|
||||
// Created by Dimitrios Chatzieleftheriou on 27/05/2020.
|
||||
// Copyright © 2020 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import AudioToolbox
|
||||
import Foundation
|
||||
|
||||
typealias CoreAudioURLBlock = (URL) -> Void
|
||||
|
||||
public class RemoteAudioSource: NSObject, AudioStreamSource {
|
||||
var inputStream: InputStream?
|
||||
|
||||
weak var delegate: AudioStreamSourceDelegate?
|
||||
|
||||
var position: Int {
|
||||
return seekOffset + relativePosition
|
||||
}
|
||||
|
||||
var length: Int {
|
||||
guard let parsedHeader = parsedHeaderOutput else { return 0 }
|
||||
return parsedHeader.fileLength
|
||||
}
|
||||
|
||||
private let url: URL
|
||||
private let networking: NetworkingClient
|
||||
internal let metadataStreamProccessor: MetadataStreamSource
|
||||
private var streamRequest: NetworkDataStream?
|
||||
|
||||
private var additionalRequestHeaders: [String: String]
|
||||
private var httpStatusCode: Int
|
||||
|
||||
private var httpResponse: HTTPURLResponse?
|
||||
private var parsedHeaderOutput: HTTPHeaderParserOutput?
|
||||
private var relativePosition: Int
|
||||
private var seekOffset: Int
|
||||
|
||||
internal var dispatchQueue: DispatchQueue?
|
||||
|
||||
init(networking: NetworkingClient,
|
||||
metadataStreamSource: MetadataStreamSource,
|
||||
url: URL,
|
||||
httpHeaders: [String: String])
|
||||
{
|
||||
self.networking = networking
|
||||
metadataStreamProccessor = metadataStreamSource
|
||||
self.url = url
|
||||
additionalRequestHeaders = httpHeaders
|
||||
httpStatusCode = 0
|
||||
relativePosition = 0
|
||||
seekOffset = 0
|
||||
}
|
||||
|
||||
convenience init(networking: NetworkingClient, url: URL, httpHeaders: [String: String]) {
|
||||
let metadataParser = MetadataParser()
|
||||
let metadataProccessor = MetadataStreamProccessor(parser: metadataParser.eraseToAnyParser())
|
||||
self.init(networking: networking,
|
||||
metadataStreamSource: metadataProccessor,
|
||||
url: url,
|
||||
httpHeaders: httpHeaders)
|
||||
}
|
||||
|
||||
convenience init(networking: NetworkingClient, url: URL) {
|
||||
self.init(networking: networking,
|
||||
url: url,
|
||||
httpHeaders: [:])
|
||||
}
|
||||
|
||||
func setup(for queue: DispatchQueue) {
|
||||
dispatchQueue = queue
|
||||
|
||||
guard let stream = inputStream else {
|
||||
return
|
||||
}
|
||||
|
||||
stream.delegate = self
|
||||
stream.set(onQueue: queue)
|
||||
|
||||
return
|
||||
}
|
||||
|
||||
func removeFromQueue() {
|
||||
guard let stream = inputStream else { return }
|
||||
stream.delegate = nil
|
||||
stream.unsetFromQueue()
|
||||
}
|
||||
|
||||
func close() {
|
||||
if inputStream != nil {
|
||||
if dispatchQueue != nil {
|
||||
removeFromQueue()
|
||||
}
|
||||
inputStream?.close()
|
||||
inputStream = nil
|
||||
}
|
||||
}
|
||||
|
||||
func seek(at offset: Int) {
|
||||
guard let queue = dispatchQueue else {
|
||||
return
|
||||
}
|
||||
dispatchPrecondition(condition: .onQueue(queue))
|
||||
|
||||
close()
|
||||
|
||||
relativePosition = 0
|
||||
seekOffset = offset
|
||||
|
||||
if let supportsSeek = parsedHeaderOutput?.supportsSeek,
|
||||
!supportsSeek, offset != relativePosition
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
performOpen(seek: seekOffset)
|
||||
}
|
||||
|
||||
func audioFileHint() -> AudioFileTypeID {
|
||||
return audioFileType(fileExtension: url.pathExtension)
|
||||
}
|
||||
|
||||
func read(into buffer: UnsafeMutablePointer<UInt8>, size: Int) -> Int {
|
||||
return performRead(into: buffer, size: size)
|
||||
}
|
||||
|
||||
// MARK: Private
|
||||
|
||||
private func performRead(into buffer: UnsafeMutablePointer<UInt8>, size: Int) -> Int {
|
||||
guard size != 0 else { return 0 }
|
||||
guard let stream = inputStream else { return 0 }
|
||||
|
||||
var read: Int = 0
|
||||
// Metadata parsing
|
||||
// if metadataStreamProccessor.canProccessMetadata {
|
||||
// read = metadataStreamProccessor.proccessFromRead(into: buffer, size: size, using: stream) { [weak self] position in
|
||||
// self?.relativePosition += position
|
||||
// }
|
||||
// } else {
|
||||
read = stream.read(buffer, maxLength: size)
|
||||
// }
|
||||
|
||||
guard read > 0 else { return read }
|
||||
relativePosition += read
|
||||
|
||||
return read
|
||||
}
|
||||
|
||||
private func performOpen(seek seekOffset: Int) {
|
||||
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
|
||||
|
||||
let streamRequest = networking.stream(request: urlRequest)
|
||||
.responseStream(on: .global(qos: .default)) { [weak self] event in
|
||||
switch event {
|
||||
case let .stream(result):
|
||||
switch result {
|
||||
case let .success(response):
|
||||
self?.httpResponse = response.response
|
||||
default:
|
||||
break
|
||||
}
|
||||
case let .complete(completion):
|
||||
print(completion)
|
||||
}
|
||||
}
|
||||
self.streamRequest = streamRequest
|
||||
inputStream = streamRequest.asInputStream()
|
||||
|
||||
guard let inputStream = inputStream else {
|
||||
errorOccured()
|
||||
return
|
||||
}
|
||||
|
||||
inputStream.setProperty(StreamNetworkServiceTypeValue.background, forKey: .networkServiceType)
|
||||
|
||||
if let scheme = url.scheme, scheme == "https" {
|
||||
inputStream.setProperty(StreamSocketSecurityLevel.negotiatedSSL, forKey: .socketSecurityLevelKey)
|
||||
let sslSettings: [String: Any] = [kCFStreamSSLValidatesCertificateChain as String: false]
|
||||
inputStream.setProperty(sslSettings, forKey: kCFStreamPropertySSLSettings as Stream.PropertyKey)
|
||||
}
|
||||
|
||||
performSoftSetup()
|
||||
httpStatusCode = 0
|
||||
|
||||
inputStream.open()
|
||||
}
|
||||
|
||||
private func parseHeader(response: HTTPURLResponse?) -> Bool {
|
||||
guard let response = response else { return false }
|
||||
httpStatusCode = response.statusCode
|
||||
// parse the header response
|
||||
let parser = HTTPHeaderParser()
|
||||
parsedHeaderOutput = parser.parse(input: response)
|
||||
// check to see if we have metadata to proccess
|
||||
if let metadataStep = parsedHeaderOutput?.metadataStep {
|
||||
metadataStreamProccessor.metadataAvailable(step: metadataStep)
|
||||
}
|
||||
// check for error
|
||||
if httpStatusCode == 416 { // range not satisfied error
|
||||
if length >= 0 { seekOffset = length }
|
||||
endOfFileOccurred()
|
||||
return false
|
||||
} else if httpStatusCode >= 300 {
|
||||
errorOccured()
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
private func performSoftSetup() {
|
||||
guard let queue = dispatchQueue, let inputStream = inputStream else { return }
|
||||
inputStream.delegate = self
|
||||
inputStream.set(onQueue: queue)
|
||||
}
|
||||
|
||||
private func buildUrlRequest(with _: URL, seekIfNeeded seekOffset: Int) -> URLRequest {
|
||||
var urlRequest = URLRequest(url: url)
|
||||
urlRequest.timeoutInterval = 30
|
||||
urlRequest.networkServiceType = .avStreaming
|
||||
|
||||
for header in additionalRequestHeaders {
|
||||
urlRequest.addValue(header.value, forHTTPHeaderField: header.key)
|
||||
}
|
||||
urlRequest.addValue("*/*", forHTTPHeaderField: "Accept")
|
||||
urlRequest.addValue("1", forHTTPHeaderField: "Icy-Metadata")
|
||||
|
||||
if let supportsSeek = parsedHeaderOutput?.supportsSeek, supportsSeek, seekOffset > 0 {
|
||||
urlRequest.addValue("bytes=\(seekOffset)", forHTTPHeaderField: "Range")
|
||||
}
|
||||
|
||||
return urlRequest
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: StreamEventsSource
|
||||
|
||||
extension RemoteAudioSource: StreamEventsSource {
|
||||
func openCompleted() {
|
||||
print("open completed")
|
||||
}
|
||||
|
||||
func dataAvailable() {
|
||||
guard inputStream != nil else { return }
|
||||
if httpStatusCode == 0 {
|
||||
guard parseHeader(response: httpResponse) else { return }
|
||||
if hasBytesAvailable {
|
||||
delegate?.dataAvailable(source: self)
|
||||
}
|
||||
} else {
|
||||
delegate?.dataAvailable(source: self)
|
||||
}
|
||||
}
|
||||
|
||||
func endOfFileOccurred() {
|
||||
delegate?.endOfFileOccured(source: self)
|
||||
}
|
||||
|
||||
func errorOccured() {
|
||||
delegate?.errorOccured(source: self)
|
||||
}
|
||||
}
|
||||
|
||||
// MARK: StreamDelegate
|
||||
|
||||
extension RemoteAudioSource: StreamDelegate {
|
||||
public func stream(_: Stream, handle eventCode: Stream.Event) {
|
||||
switch eventCode {
|
||||
case .openCompleted:
|
||||
openCompleted()
|
||||
case .hasBytesAvailable:
|
||||
dataAvailable()
|
||||
case .endEncountered:
|
||||
endOfFileOccurred()
|
||||
case .errorOccurred:
|
||||
errorOccured()
|
||||
default: break
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -41,6 +41,10 @@ final class FileAudioSource: NSObject, CoreAudioStreamSource {
|
||||
length = 0
|
||||
}
|
||||
|
||||
deinit {
|
||||
buffer.deallocate()
|
||||
}
|
||||
|
||||
func close() {
|
||||
guard let inputStream = inputStream else {
|
||||
return
|
||||
|
||||
@@ -29,9 +29,13 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private var parsedHeaderOutput: HTTPHeaderParserOutput?
|
||||
private var relativePosition: Int
|
||||
private var seekOffset: Int
|
||||
private var supportsSeek: Bool
|
||||
|
||||
internal var metadataStreamProcessor: MetadataStreamSource
|
||||
|
||||
private var shouldTryParsingIcycastHeaders: Bool = false
|
||||
private let icycastHeadersProcessor: IcycastHeadersProcessor
|
||||
|
||||
internal var audioFileHint: AudioFileTypeID {
|
||||
guard let output = parsedHeaderOutput, output.typeId != 0 else {
|
||||
return audioFileType(fileExtension: url.pathExtension)
|
||||
@@ -47,6 +51,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
|
||||
init(networking: NetworkingClient,
|
||||
metadataStreamSource: MetadataStreamSource,
|
||||
icycastHeadersProcessor: IcycastHeadersProcessor,
|
||||
netStatusProvider: NetStatusProvider,
|
||||
retrier: Retrier,
|
||||
url: URL,
|
||||
@@ -59,7 +64,9 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
additionalRequestHeaders = httpHeaders
|
||||
relativePosition = 0
|
||||
seekOffset = 0
|
||||
supportsSeek = false
|
||||
netStatusService = netStatusProvider
|
||||
self.icycastHeadersProcessor = icycastHeadersProcessor
|
||||
self.underlyingQueue = underlyingQueue
|
||||
streamOperationQueue = OperationQueue()
|
||||
streamOperationQueue.underlyingQueue = underlyingQueue
|
||||
@@ -78,9 +85,11 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
let metadataParser = MetadataParser()
|
||||
let metadataProcessor = MetadataStreamProcessor(parser: metadataParser.eraseToAnyParser())
|
||||
let netStatusProvider = NetStatusService(network: NWPathMonitor())
|
||||
let icyheaderProcessor = IcycastHeadersProcessor()
|
||||
let retrierTimout = Retrier(interval: .seconds(1), maxInterval: 5, underlyingQueue: nil)
|
||||
self.init(networking: networking,
|
||||
metadataStreamSource: metadataProcessor,
|
||||
icycastHeadersProcessor: icyheaderProcessor,
|
||||
netStatusProvider: netStatusProvider,
|
||||
retrier: retrierTimout,
|
||||
url: url,
|
||||
@@ -116,14 +125,14 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
relativePosition = 0
|
||||
seekOffset = offset
|
||||
|
||||
if let supportsSeek = parsedHeaderOutput?.supportsSeek,
|
||||
!supportsSeek, offset != relativePosition
|
||||
{
|
||||
if !supportsSeek, offset != relativePosition {
|
||||
return
|
||||
}
|
||||
|
||||
retrierTimeout.cancel()
|
||||
metadataStreamProcessor.reset()
|
||||
icycastHeadersProcessor.reset()
|
||||
shouldTryParsingIcycastHeaders = false
|
||||
|
||||
performOpen(seek: offset)
|
||||
}
|
||||
@@ -189,16 +198,26 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private func handleStreamEvent(event: NetworkDataStream.StreamResult) {
|
||||
switch event {
|
||||
case let .success(value):
|
||||
addStreamOperation { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if let data = value.data {
|
||||
if self.metadataStreamProcessor.canProccessMetadata {
|
||||
let extractedAudioData = self.metadataStreamProcessor.proccessMetadata(data: data)
|
||||
self.delegate?.dataAvailable(source: self, data: extractedAudioData)
|
||||
} else {
|
||||
self.delegate?.dataAvailable(source: self, data: data)
|
||||
if let audioData = value.data {
|
||||
addStreamOperation { [weak self] in
|
||||
guard let self = self else { return }
|
||||
if self.shouldTryParsingIcycastHeaders {
|
||||
let (header, extractedAudio) = self.icycastHeadersProcessor.proccess(data: audioData)
|
||||
if let header = header {
|
||||
self.shouldTryParsingIcycastHeaders = false
|
||||
let parser = IcycastHeaderParser()
|
||||
self.parsedHeaderOutput = parser.parse(input: header)
|
||||
if let metadataStep = self.parsedHeaderOutput?.metadataStep {
|
||||
self.metadataStreamProcessor.metadataAvailable(step: metadataStep)
|
||||
}
|
||||
|
||||
let audioCount = self.processAudio(data: extractedAudio)
|
||||
self.relativePosition += audioCount
|
||||
return
|
||||
}
|
||||
}
|
||||
self.relativePosition += data.count
|
||||
let audioCount = self.processAudio(data: audioData)
|
||||
self.relativePosition += audioCount
|
||||
}
|
||||
}
|
||||
case .failure:
|
||||
@@ -211,20 +230,49 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
}
|
||||
}
|
||||
|
||||
/// Processing audio data, extracting metadata if needed.
|
||||
/// - Parameter data: The audio to be processed
|
||||
/// - Returns: An `Int` value representing the amount of audio data bytes.
|
||||
private func processAudio(data: Data) -> Int {
|
||||
if self.metadataStreamProcessor.canProccessMetadata {
|
||||
let extractedAudioData = self.metadataStreamProcessor.proccessMetadata(data: data)
|
||||
self.delegate?.dataAvailable(source: self, data: extractedAudioData)
|
||||
return extractedAudioData.count
|
||||
} else {
|
||||
self.delegate?.dataAvailable(source: self, data: data)
|
||||
return data.count
|
||||
}
|
||||
}
|
||||
|
||||
private func parseResponseHeader(response: HTTPURLResponse?) {
|
||||
guard let response = response else { return }
|
||||
let httpStatusCode = response.statusCode
|
||||
let parser = HTTPHeaderParser()
|
||||
parsedHeaderOutput = parser.parse(input: response)
|
||||
|
||||
if parsedHeaderOutput == nil {
|
||||
shouldTryParsingIcycastHeaders = true
|
||||
checkHTTP(statusCode: httpStatusCode)
|
||||
return
|
||||
}
|
||||
|
||||
if let acceptRanges = parser.value(forHTTPHeaderField: HeaderField.acceptRanges, in: response) {
|
||||
supportsSeek = acceptRanges != "none"
|
||||
}
|
||||
|
||||
// check to see if we have metadata to proccess
|
||||
if let metadataStep = parsedHeaderOutput?.metadataStep {
|
||||
metadataStreamProcessor.metadataAvailable(step: metadataStep)
|
||||
}
|
||||
checkHTTP(statusCode: httpStatusCode)
|
||||
}
|
||||
|
||||
private func checkHTTP(statusCode: Int) {
|
||||
// check for error
|
||||
if httpStatusCode == 416 { // range not satisfied error
|
||||
if statusCode == 416 { // range not satisfied error
|
||||
if length >= 0 { seekOffset = length }
|
||||
delegate?.endOfFileOccured(source: self)
|
||||
} else if httpStatusCode >= 300 {
|
||||
} else if statusCode >= 300 {
|
||||
delegate?.errorOccured(source: self, error: NetworkError.serverError)
|
||||
}
|
||||
}
|
||||
@@ -242,7 +290,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
|
||||
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
||||
|
||||
if let supportsSeek = parsedHeaderOutput?.supportsSeek, supportsSeek, seekOffset > 0 {
|
||||
if supportsSeek && seekOffset > 0 {
|
||||
urlRequest.addValue("bytes=\(seekOffset)-", forHTTPHeaderField: "Range")
|
||||
}
|
||||
return urlRequest
|
||||
|
||||
@@ -6,7 +6,7 @@
|
||||
import AVFoundation
|
||||
import CoreAudio
|
||||
|
||||
public final class AudioPlayer {
|
||||
open class AudioPlayer {
|
||||
public weak var delegate: AudioPlayerDelegate?
|
||||
|
||||
public var muted: Bool {
|
||||
@@ -70,7 +70,14 @@ public final class AudioPlayer {
|
||||
playerContext.entriesLock.lock()
|
||||
let playingEntry = playerContext.audioPlayingEntry
|
||||
playerContext.entriesLock.unlock()
|
||||
guard let entry = playingEntry, !entry.seekRequest.requested else { return 0 }
|
||||
guard let entry = playingEntry else { return 0 }
|
||||
entry.seekRequest.lock.lock()
|
||||
let seekRequested = entry.seekRequest.requested
|
||||
let seekTime = entry.seekRequest.time
|
||||
entry.seekRequest.lock.unlock()
|
||||
if seekRequested {
|
||||
return seekTime
|
||||
}
|
||||
return entry.progress
|
||||
}
|
||||
|
||||
@@ -88,28 +95,33 @@ public final class AudioPlayer {
|
||||
private var stateBeforePaused: InternalState = .initial
|
||||
|
||||
/// The underlying `AVAudioEngine` object
|
||||
let audioEngine = AVAudioEngine()
|
||||
private let audioEngine = AVAudioEngine()
|
||||
/// An `AVAudioUnit` object that represents the audio player
|
||||
private(set) var player = AVAudioUnit()
|
||||
/// An `AVAudioUnitTimePitch` that controls the playback rate of the audio engine
|
||||
let rateNode = AVAudioUnitTimePitch()
|
||||
private let rateNode = AVAudioUnitTimePitch()
|
||||
|
||||
/// A Boolean value that indicates whether the audio engine is running.
|
||||
/// `true` if the engine is running, otherwise, `false`
|
||||
var isEngineRunning: Bool { audioEngine.isRunning }
|
||||
public var isEngineRunning: Bool { audioEngine.isRunning }
|
||||
|
||||
/// The `AVAudioMixerNode` as created by the underlying audio engine
|
||||
public var mainMixerNode: AVAudioMixerNode {
|
||||
audioEngine.mainMixerNode
|
||||
}
|
||||
|
||||
/// An object representing the context of the audio render.
|
||||
/// Holds the audio buffer and in/out lists as required by the audio rendering
|
||||
let rendererContext: AudioRendererContext
|
||||
private let rendererContext: AudioRendererContext
|
||||
/// An object representing the context of the player.
|
||||
/// Holds the player's state, current playing and reading entries.
|
||||
let playerContext: AudioPlayerContext
|
||||
private let playerContext: AudioPlayerContext
|
||||
|
||||
let fileStreamProcessor: AudioFileStreamProcessor
|
||||
let playerRenderProcessor: AudioPlayerRenderProcessor
|
||||
private let fileStreamProcessor: AudioFileStreamProcessor
|
||||
private let playerRenderProcessor: AudioPlayerRenderProcessor
|
||||
|
||||
private let audioReadSource: DispatchTimerSource
|
||||
private let underlyingQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated, attributes: .concurrent)
|
||||
private let serializationQueue: DispatchQueue
|
||||
private let sourceQueue: DispatchQueue
|
||||
|
||||
private let entryProvider: AudioEntryProviding
|
||||
@@ -123,7 +135,8 @@ public final class AudioPlayer {
|
||||
playerContext = AudioPlayerContext()
|
||||
entriesQueue = PlayerQueueEntries()
|
||||
|
||||
sourceQueue = DispatchQueue(label: "source.queue", qos: .userInitiated, target: underlyingQueue)
|
||||
serializationQueue = DispatchQueue(label: "streaming.core.queue", qos: .userInitiated)
|
||||
sourceQueue = DispatchQueue(label: "source.queue", qos: .userInitiated)
|
||||
audioReadSource = DispatchTimerSource(interval: .milliseconds(200), queue: sourceQueue)
|
||||
|
||||
entryProvider = AudioEntryProvider(networkingClient: NetworkingClient(),
|
||||
@@ -144,7 +157,6 @@ public final class AudioPlayer {
|
||||
}
|
||||
|
||||
deinit {
|
||||
// todo more stuff to release...
|
||||
playerContext.audioPlayingEntry?.close()
|
||||
clearQueue()
|
||||
stopReadProccessFromSource()
|
||||
@@ -167,18 +179,21 @@ public final class AudioPlayer {
|
||||
public func play(url: URL, headers: [String: String]) {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
audioEntry.delegate = self
|
||||
clearQueue()
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
playerContext.setInternalState(to: .pendingNext)
|
||||
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
serializationQueue.sync {
|
||||
clearQueue()
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
playerContext.setInternalState(to: .pendingNext)
|
||||
do {
|
||||
try self.startEngineIfNeeded()
|
||||
} catch {
|
||||
self.raiseUnxpected(error: .audioSystemError(.engineFailure))
|
||||
}
|
||||
}
|
||||
|
||||
sourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.processSource()
|
||||
self.startReadProcessFromSourceIfNeeded()
|
||||
}
|
||||
@@ -186,19 +201,47 @@ public final class AudioPlayer {
|
||||
|
||||
/// Queues the specified URL
|
||||
///
|
||||
/// - Parameter url: A `URL` specifying the audio context to be played.
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
public func queue(url: URL) {
|
||||
queue(url: url, headers: [:])
|
||||
}
|
||||
|
||||
/// Queues the specified URLs
|
||||
///
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
public func queue(urls: [URL]) {
|
||||
queue(urls: urls, headers: [:])
|
||||
}
|
||||
|
||||
/// Queues the specified URL
|
||||
///
|
||||
/// - Parameter url: A `URL` specifying the audio context to be played.
|
||||
/// - Parameter url: A `URL` specifying the audio content to be played.
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func queue(url: URL, headers: [String: String]) {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
audioEntry.delegate = self
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
serializationQueue.sync {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
audioEntry.delegate = self
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
}
|
||||
|
||||
/// Queues the specified URLs
|
||||
///
|
||||
/// - Parameter url: A array of `URL`s specifying the audio content to be played.
|
||||
/// - parameter headers: A `Dictionary` specifying any additional headers to be pass to the network request.
|
||||
public func queue(urls: [URL], headers: [String: String]) {
|
||||
serializationQueue.sync {
|
||||
for url in urls {
|
||||
let audioEntry = entryProvider.provideAudioEntry(url: url, headers: headers)
|
||||
audioEntry.delegate = self
|
||||
entriesQueue.enqueue(item: audioEntry, type: .upcoming)
|
||||
}
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
@@ -208,8 +251,11 @@ public final class AudioPlayer {
|
||||
public func stop() {
|
||||
guard playerContext.internalState != .stopped else { return }
|
||||
|
||||
stopEngine(reason: .userAction)
|
||||
stopReadProccessFromSource()
|
||||
serializationQueue.sync {
|
||||
stopEngine(reason: .userAction)
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
sourceQueue.async { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.playerContext.audioReadingEntry?.delegate = nil
|
||||
@@ -226,7 +272,6 @@ public final class AudioPlayer {
|
||||
|
||||
self.processSource()
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
}
|
||||
|
||||
/// Pauses the audio playback
|
||||
@@ -234,8 +279,9 @@ public final class AudioPlayer {
|
||||
if playerContext.internalState != .paused, playerContext.internalState.contains(.running) {
|
||||
stateBeforePaused = playerContext.internalState
|
||||
playerContext.setInternalState(to: .paused)
|
||||
|
||||
pauseEngine()
|
||||
serializationQueue.sync {
|
||||
pauseEngine()
|
||||
}
|
||||
stopReadProccessFromSource()
|
||||
playerContext.audioPlayingEntry?.suspend()
|
||||
sourceQueue.async { [weak self] in
|
||||
@@ -248,23 +294,25 @@ public final class AudioPlayer {
|
||||
public func resume() {
|
||||
guard playerContext.internalState == .paused else { return }
|
||||
playerContext.setInternalState(to: stateBeforePaused)
|
||||
// check if seek time requested and reset buffers
|
||||
do {
|
||||
try startEngine()
|
||||
} catch {
|
||||
Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
|
||||
}
|
||||
|
||||
if let playingEntry = playerContext.audioReadingEntry {
|
||||
if playingEntry.seekRequest.requested {
|
||||
rendererContext.resetBuffers()
|
||||
serializationQueue.sync {
|
||||
do {
|
||||
try startEngine()
|
||||
} catch {
|
||||
Logger.debug("resuming audio engine failed: %@", category: .generic, args: error.localizedDescription)
|
||||
}
|
||||
playingEntry.resume()
|
||||
if let playingEntry = playerContext.audioReadingEntry {
|
||||
if playingEntry.seekRequest.requested {
|
||||
rendererContext.resetBuffers()
|
||||
}
|
||||
playingEntry.resume()
|
||||
}
|
||||
startPlayer(resetBuffers: false)
|
||||
}
|
||||
startPlayer(resetBuffers: false)
|
||||
startReadProcessFromSourceIfNeeded()
|
||||
}
|
||||
|
||||
/// Seeks the audio to the specified time.
|
||||
/// - Parameter time: A `Double` value specifing the time of the requested seek in seconds
|
||||
public func seek(to time: Double) {
|
||||
guard let playingEntry = playerContext.audioPlayingEntry else {
|
||||
return
|
||||
@@ -287,10 +335,16 @@ public final class AudioPlayer {
|
||||
}
|
||||
}
|
||||
|
||||
/// Attaches the given `AVAudioNode` to the engine
|
||||
/// - Note: The node will be added after the default rate node
|
||||
/// - Parameter node: An instance of `AVAudioNode`
|
||||
public func attach(node: AVAudioNode) {
|
||||
attach(nodes: [node])
|
||||
}
|
||||
|
||||
/// Attaches the given `AVAudioNode`s to the engine
|
||||
/// - Note: The nodes will be added after the default rate node
|
||||
/// - Parameter node: An array of `AVAudioNode` instances
|
||||
public func attach(nodes: [AVAudioNode]) {
|
||||
nodes.forEach { node in
|
||||
customAttachedNodes.append(node)
|
||||
@@ -300,6 +354,8 @@ public final class AudioPlayer {
|
||||
reattachCustomNodes()
|
||||
}
|
||||
|
||||
/// Detaches the given `AVAudioNode` from the engine
|
||||
/// - Parameter node: An instance of `AVAudioNode`
|
||||
public func detach(node: AVAudioNode) {
|
||||
guard customAttachedNodes.contains(node) else {
|
||||
return
|
||||
@@ -309,6 +365,8 @@ public final class AudioPlayer {
|
||||
reattachCustomNodes()
|
||||
}
|
||||
|
||||
/// Detaches the given `AVAudioNode`s from the engine
|
||||
/// - Parameter node: An array of `AVAudioNode` instances
|
||||
public func detachCustomAttachedNodes() {
|
||||
customAttachedNodes.forEach { node in
|
||||
audioEngine.detach(node)
|
||||
@@ -375,9 +433,11 @@ public final class AudioPlayer {
|
||||
|
||||
playerRenderProcessor.audioFinishedPlaying = { [weak self] entry in
|
||||
guard let self = self else { return }
|
||||
self.sourceQueue.async {
|
||||
self.serializationQueue.sync {
|
||||
let nextEntry = self.entriesQueue.dequeue(type: .buffering)
|
||||
self.processFinishPlaying(entry: entry, with: nextEntry)
|
||||
}
|
||||
self.sourceQueue.async {
|
||||
self.processSource()
|
||||
}
|
||||
}
|
||||
@@ -455,10 +515,6 @@ public final class AudioPlayer {
|
||||
///
|
||||
/// - parameter reason: A value of `AudioPlayerStopReason` indicating the reason the engine stopped.
|
||||
private func stopEngine(reason: AudioPlayerStopReason) {
|
||||
guard isEngineRunning && player.auAudioUnit.isRunning else {
|
||||
Logger.debug("already already stopped 🛑", category: .generic)
|
||||
return
|
||||
}
|
||||
audioEngine.stop()
|
||||
player.auAudioUnit.stopHardware()
|
||||
rendererContext.resetBuffers()
|
||||
@@ -491,11 +547,8 @@ public final class AudioPlayer {
|
||||
if resetBuffers {
|
||||
rendererContext.resetBuffers()
|
||||
}
|
||||
if !isEngineRunning && !player.auAudioUnit.isRunning {
|
||||
Logger.debug("trying to start the player when audio engine and player are already running", category: .generic)
|
||||
return
|
||||
}
|
||||
do {
|
||||
try startEngineIfNeeded()
|
||||
try player.auAudioUnit.allocateRenderResources()
|
||||
try player.auAudioUnit.startHardware()
|
||||
} catch {
|
||||
@@ -542,7 +595,7 @@ public final class AudioPlayer {
|
||||
let entry = entriesQueue.dequeue(type: .upcoming)
|
||||
let shouldStartPlaying = playerContext.audioPlayingEntry == nil
|
||||
playerContext.setInternalState(to: .waitingForData)
|
||||
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: true)
|
||||
setCurrentReading(entry: entry, startPlaying: shouldStartPlaying, shouldClearQueue: false)
|
||||
} else if playerContext.audioPlayingEntry == nil {
|
||||
if playerContext.internalState != .stopped {
|
||||
stopReadProccessFromSource()
|
||||
@@ -574,7 +627,8 @@ public final class AudioPlayer {
|
||||
}
|
||||
|
||||
private func proccessSeekTime() {
|
||||
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry, "reading and playing entry must be the same")
|
||||
assert(playerContext.audioReadingEntry === playerContext.audioPlayingEntry,
|
||||
"reading and playing entry must be the same")
|
||||
fileStreamProcessor.processSeek()
|
||||
}
|
||||
|
||||
@@ -662,7 +716,9 @@ public final class AudioPlayer {
|
||||
playerContext.audioPlayingEntry = nil
|
||||
playerContext.entriesLock.unlock()
|
||||
}
|
||||
processSource()
|
||||
sourceQueue.async { [weak self] in
|
||||
self?.processSource()
|
||||
}
|
||||
checkRenderWaitingAndNotifyIfNeeded()
|
||||
}
|
||||
|
||||
|
||||
@@ -191,7 +191,11 @@ final class AudioFileStreamProcessor {
|
||||
guard AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_MagicCookieData, &cookieSize, &cookie) == noErr else {
|
||||
return
|
||||
}
|
||||
guard AudioFileStreamSetProperty(fileStream, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
|
||||
guard let converter = audioConverter else {
|
||||
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
|
||||
return
|
||||
}
|
||||
guard AudioConverterSetProperty(converter, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
|
||||
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
|
||||
return
|
||||
}
|
||||
@@ -382,41 +386,43 @@ final class AudioFileStreamProcessor {
|
||||
var start = bufferContext.frameStartIndex
|
||||
var end = bufferContext.end
|
||||
|
||||
var framesLeftInBuffer = max(bufferContext.totalFrameCount &- used, 0)
|
||||
var framesLeftInBuffer = bufferContext.totalFrameCount - used
|
||||
rendererContext.lock.unlock()
|
||||
|
||||
if framesLeftInBuffer == 0 {
|
||||
rendererContext.lock.lock()
|
||||
let bufferContext = rendererContext.bufferContext
|
||||
used = bufferContext.frameUsedCount
|
||||
start = bufferContext.frameStartIndex
|
||||
end = bufferContext.end
|
||||
framesLeftInBuffer = max(bufferContext.totalFrameCount &- used, 0)
|
||||
rendererContext.lock.unlock()
|
||||
if framesLeftInBuffer > 0 {
|
||||
break packetProccess
|
||||
}
|
||||
if playerContext.disposedRequested
|
||||
|| playerContext.internalState == .disposed
|
||||
|| playerContext.internalState == .pendingNext
|
||||
|| playerContext.internalState == .stopped
|
||||
{
|
||||
return
|
||||
}
|
||||
|
||||
if let playingEntry = playerContext.audioPlayingEntry,
|
||||
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
|
||||
{
|
||||
fileStreamCallback?(.proccessSource)
|
||||
if rendererContext.waiting.value {
|
||||
rendererContext.packetsSemaphore.signal()
|
||||
while true {
|
||||
rendererContext.lock.lock()
|
||||
let bufferContext = rendererContext.bufferContext
|
||||
used = bufferContext.frameUsedCount
|
||||
start = bufferContext.frameStartIndex
|
||||
end = (bufferContext.frameStartIndex + bufferContext.frameUsedCount) % bufferContext.totalFrameCount
|
||||
framesLeftInBuffer = bufferContext.totalFrameCount - used
|
||||
rendererContext.lock.unlock()
|
||||
if framesLeftInBuffer > 0 {
|
||||
break
|
||||
}
|
||||
if playerContext.disposedRequested
|
||||
|| playerContext.internalState == .disposed
|
||||
|| playerContext.internalState == .pendingNext
|
||||
|| playerContext.internalState == .stopped
|
||||
{
|
||||
return
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
rendererContext.waiting.write { $0 = true }
|
||||
rendererContext.packetsSemaphore.wait()
|
||||
rendererContext.waiting.write { $0 = false }
|
||||
if let playingEntry = playerContext.audioPlayingEntry,
|
||||
playingEntry.seekRequest.requested, playingEntry.calculatedBitrate() > 0
|
||||
{
|
||||
fileStreamCallback?(.proccessSource)
|
||||
if rendererContext.waiting.value {
|
||||
rendererContext.packetsSemaphore.signal()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
rendererContext.waiting.write { $0 = true }
|
||||
rendererContext.packetsSemaphore.wait()
|
||||
rendererContext.waiting.write { $0 = false }
|
||||
}
|
||||
}
|
||||
|
||||
let localBufferList = AudioBufferList.allocate(maximumBuffers: 1)
|
||||
@@ -444,7 +450,7 @@ final class AudioFileStreamProcessor {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
return
|
||||
} else if status != 0 {
|
||||
/// raise undexpected error... codec error
|
||||
fileStreamCallback?(.raiseError(.codecError))
|
||||
return
|
||||
}
|
||||
|
||||
@@ -473,7 +479,7 @@ final class AudioFileStreamProcessor {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProccess
|
||||
} else if status != 0 {
|
||||
/// raise undexpected error... codec error
|
||||
fileStreamCallback?(.raiseError(.codecError))
|
||||
return
|
||||
}
|
||||
} else {
|
||||
@@ -500,7 +506,7 @@ final class AudioFileStreamProcessor {
|
||||
fillUsedFrames(framesCount: framesAdded)
|
||||
continue packetProccess
|
||||
} else if status != 0 {
|
||||
/// raise undexpected error... codec error
|
||||
fileStreamCallback?(.raiseError(.codecError))
|
||||
return
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,91 @@
|
||||
//
|
||||
// IcycastHeadersProcessor.swift
|
||||
// AudioStreaming
|
||||
//
|
||||
// Created by Dimitrios C on 14/02/2021.
|
||||
// Copyright © 2021 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
/// ICY is built on HTTP some old servers might still send headers in the stream.
|
||||
/// From a server point of view, this should be considered deprecated and should not be used as it might break HTML5 compatibility.
|
||||
/// Although there are some servers still using this, this class will extract those headers from the stream
|
||||
///
|
||||
/// The format of the headers is as follows:
|
||||
/// ```
|
||||
/// =================================================================
|
||||
/// [ ICY 200 OK ]
|
||||
/// [ icy-mentaint: the number of bytes between 2 metadata chunks ]
|
||||
/// [ icy-br: send the bitrate in kilobits per second ]
|
||||
/// [ icy-genre: sends the genre ]
|
||||
/// [ icy-name: sends the stream's name ]
|
||||
/// [ icy-url: is the URL of the radio station ]
|
||||
/// [ icy-pub: can be 1 or 0 to tell if it is listed or not ]
|
||||
/// =================================================================
|
||||
/// ```
|
||||
|
||||
final class IcycastHeadersProcessor {
|
||||
|
||||
private var icecastHeaders = Data(capacity: 1024)
|
||||
private var searchComplete = false
|
||||
private var iceHeaderAvailable = false
|
||||
|
||||
func reset() {
|
||||
icecastHeaders = Data(capacity: 1024)
|
||||
searchComplete = false
|
||||
iceHeaderAvailable = false
|
||||
}
|
||||
|
||||
@inline(__always)
|
||||
func proccess(data: Data) -> (Data?, Data) {
|
||||
let stopProccessingCheckOne: [UInt8] = Array("\n\n".utf8)
|
||||
let stopProccessingCheckTwo: [UInt8] = Array("\r\n\r\n".utf8)
|
||||
let icyPrefix: [UInt8] = Array("ICY ".utf8)
|
||||
let httpPrefix: [UInt8] = Array("HTTP".utf8)
|
||||
return data.withUnsafeBytes { buffer -> (Data?, Data) in
|
||||
var bytesRead = 0
|
||||
let bytes = buffer.baseAddress!.assumingMemoryBound(to: UInt8.self)
|
||||
// Read through the bytes and stop when our search is complete
|
||||
// Since we don't know the amount of bytes to be proccessed
|
||||
// we add each character up until we found on of the checks as defined above.
|
||||
while bytesRead < buffer.count, !searchComplete {
|
||||
let pointer = bytes + bytesRead
|
||||
icecastHeaders.append(pointer, count: 1)
|
||||
|
||||
if icecastHeaders.count >= stopProccessingCheckOne.count {
|
||||
if icecastHeaders.suffix(stopProccessingCheckOne.count) == stopProccessingCheckOne {
|
||||
iceHeaderAvailable = true
|
||||
searchComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if icecastHeaders.count >= stopProccessingCheckTwo.count {
|
||||
if icecastHeaders.suffix(stopProccessingCheckTwo.count) == stopProccessingCheckTwo {
|
||||
iceHeaderAvailable = true
|
||||
searchComplete = true
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
if icecastHeaders.count >= icyPrefix.count {
|
||||
// in case the first 4 chars are not "ICY " nor "HTTP" then we stop the flow
|
||||
if icecastHeaders[..<icyPrefix.count].elementsEqual(icyPrefix) == false &&
|
||||
icecastHeaders[..<httpPrefix.count].elementsEqual(httpPrefix) == false {
|
||||
iceHeaderAvailable = false
|
||||
searchComplete = true
|
||||
}
|
||||
}
|
||||
|
||||
bytesRead += 1
|
||||
}
|
||||
if !iceHeaderAvailable {
|
||||
return (nil, data)
|
||||
}
|
||||
let extractedAudio = data[icecastHeaders.count...]
|
||||
iceHeaderAvailable = false
|
||||
return (icecastHeaders, extractedAudio)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -18,21 +18,28 @@ enum IcyHeaderField {
|
||||
}
|
||||
|
||||
struct HTTPHeaderParserOutput {
|
||||
let supportsSeek: Bool
|
||||
let fileLength: Int
|
||||
let typeId: AudioFileTypeID
|
||||
// Metadata Support
|
||||
let metadataStep: Int
|
||||
}
|
||||
|
||||
struct HTTPHeaderParser: Parser {
|
||||
protocol HTTPHeaderParsing: Parser {
|
||||
/// Returns the value for the given field of the headers in the given `HTTPURLResponse`
|
||||
///
|
||||
/// - Parameters:
|
||||
/// - field: The header field to be searched
|
||||
/// - response: The `HTTPURLResponse` for the header
|
||||
/// - Returns: A `String` if the field exists in the headers otherwise `nil`
|
||||
func value(forHTTPHeaderField field: String, in response: HTTPURLResponse) -> String?
|
||||
}
|
||||
|
||||
struct HTTPHeaderParser: HTTPHeaderParsing {
|
||||
typealias Input = HTTPURLResponse
|
||||
typealias Output = HTTPHeaderParserOutput?
|
||||
|
||||
func parse(input: HTTPURLResponse) -> HTTPHeaderParserOutput? {
|
||||
guard let headers = input.allHeaderFields as? [String: String], !headers.isEmpty else { return nil }
|
||||
|
||||
let supportsSeek = headers[HeaderField.acceptRanges] != "none"
|
||||
guard let headers = input.allHeaderFields as? [String: String], headers.count > 2 else { return nil }
|
||||
|
||||
var typeId: UInt32 = 0
|
||||
if let contentType = input.mimeType {
|
||||
@@ -41,13 +48,12 @@ struct HTTPHeaderParser: Parser {
|
||||
|
||||
var fileLength: Int = 0
|
||||
if input.statusCode == 200 {
|
||||
if let contentLength = headers[HeaderField.contentLength],
|
||||
let length = Int(contentLength)
|
||||
{
|
||||
let contentLength = value(forHTTPHeaderField: HeaderField.contentLength, in: input)
|
||||
if let contentLength = contentLength, let length = Int(contentLength) {
|
||||
fileLength = length
|
||||
}
|
||||
} else if input.statusCode == 206 {
|
||||
if let contentLength = headers[HeaderField.contentRange] {
|
||||
if let contentLength = value(forHTTPHeaderField: HeaderField.contentRange, in: input) {
|
||||
let components = contentLength.components(separatedBy: "/")
|
||||
if components.count == 2 {
|
||||
if let last = components.last, let length = Int(last) {
|
||||
@@ -58,15 +64,38 @@ struct HTTPHeaderParser: Parser {
|
||||
}
|
||||
|
||||
var metadataStep = 0
|
||||
if let icyMetaint = headers[IcyHeaderField.icyMentaint],
|
||||
if let icyMetaint = value(forHTTPHeaderField: IcyHeaderField.icyMentaint, in: input),
|
||||
let intValue = Int(icyMetaint)
|
||||
{
|
||||
metadataStep = intValue
|
||||
}
|
||||
|
||||
return HTTPHeaderParserOutput(supportsSeek: supportsSeek,
|
||||
fileLength: fileLength,
|
||||
return HTTPHeaderParserOutput(fileLength: fileLength,
|
||||
typeId: typeId,
|
||||
metadataStep: metadataStep)
|
||||
}
|
||||
}
|
||||
|
||||
extension Parser where Self: HTTPHeaderParsing {
|
||||
func value(forHTTPHeaderField field: String, in response: HTTPURLResponse) -> String? {
|
||||
if #available(iOS 13.0, *) {
|
||||
return response.value(forHTTPHeaderField: field)
|
||||
} else {
|
||||
if let fields = response.allHeaderFields as? [String: String] {
|
||||
return valueForCaseInsensitiveKey(field, fields: fields)
|
||||
} else {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private func valueForCaseInsensitiveKey(_ key: String, fields: [String: String]) -> String? {
|
||||
let keyToBeFound = key.lowercased()
|
||||
for (key, value) in fields {
|
||||
if key.lowercased() == keyToBeFound {
|
||||
return value
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,34 @@
|
||||
//
|
||||
// IcycastHeaderParser.swift
|
||||
// AudioStreaming
|
||||
//
|
||||
// Created by Dimitrios C on 14/02/2021.
|
||||
// Copyright © 2021 Decimal. All rights reserved.
|
||||
//
|
||||
|
||||
import Foundation
|
||||
|
||||
struct IcycastHeaderParser: Parser {
|
||||
|
||||
func parse(input: Data) -> HTTPHeaderParserOutput? {
|
||||
|
||||
guard let icecastValue = String(data: input, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
let headers = icecastValue.components(separatedBy: CharacterSet(charactersIn: "\r\n"))
|
||||
var result = [String: String]()
|
||||
for header in headers where !header.isEmpty {
|
||||
let values = header.split(separator: ":", maxSplits: 1, omittingEmptySubsequences: true)
|
||||
if let key = values.first, let value = values.last {
|
||||
result[String(key)] = String(value)
|
||||
}
|
||||
}
|
||||
let metadataStep = Int(result[IcyHeaderField.icyMentaint] ?? "") ?? 0
|
||||
let contentType = result[HeaderField.contentType.lowercased()] ?? "audio/mpeg"
|
||||
let typeId = audioFileType(mimeType: contentType)
|
||||
|
||||
return HTTPHeaderParserOutput(fileLength: 0,
|
||||
typeId: typeId,
|
||||
metadataStep: metadataStep)
|
||||
}
|
||||
}
|
||||
@@ -32,8 +32,7 @@ class HTTPHeaderParserTests: XCTestCase {
|
||||
|
||||
// When
|
||||
let headers: [String: String] =
|
||||
[HeaderField.acceptRanges: "range",
|
||||
HeaderField.contentLength: "1000",
|
||||
[HeaderField.contentLength: "1000",
|
||||
HeaderField.contentType: "audio/mp3",
|
||||
IcyHeaderField.icyMentaint: "16000"]
|
||||
let httpURLResponse = HTTPURLResponse(url: URL(string: "www.google.com")!,
|
||||
@@ -46,23 +45,21 @@ class HTTPHeaderParserTests: XCTestCase {
|
||||
// Then
|
||||
XCTAssertNotNil(output)
|
||||
XCTAssertEqual(output!.fileLength, 1000)
|
||||
XCTAssertEqual(output!.supportsSeek, true)
|
||||
XCTAssertEqual(output!.typeId, kAudioFileMP3Type)
|
||||
XCTAssertEqual(output!.metadataStep, 16000)
|
||||
}
|
||||
|
||||
func testReturnCorrectValuesOnRequestThatSupportsSeekRanges() throws {
|
||||
func testReturnCorectValuesOnCaseInsensitiveHeaderFiels() throws {
|
||||
// Given
|
||||
let parser = HTTPHeaderParser()
|
||||
|
||||
// When
|
||||
let headers: [String: String] =
|
||||
[HeaderField.acceptRanges: "range",
|
||||
HeaderField.contentLength: "1000",
|
||||
HeaderField.contentType: "audio/mp3",
|
||||
HeaderField.contentRange: "100/1000"]
|
||||
[HeaderField.contentLength.lowercased(): "1000",
|
||||
HeaderField.contentType.lowercased(): "audio/mp3",
|
||||
IcyHeaderField.icyMentaint.lowercased(): "16000"]
|
||||
let httpURLResponse = HTTPURLResponse(url: URL(string: "www.google.com")!,
|
||||
statusCode: 206,
|
||||
statusCode: 200,
|
||||
httpVersion: "",
|
||||
headerFields: headers)
|
||||
|
||||
@@ -71,7 +68,7 @@ class HTTPHeaderParserTests: XCTestCase {
|
||||
// Then
|
||||
XCTAssertNotNil(output)
|
||||
XCTAssertEqual(output!.fileLength, 1000)
|
||||
XCTAssertEqual(output!.supportsSeek, true)
|
||||
XCTAssertEqual(output!.typeId, kAudioFileMP3Type)
|
||||
XCTAssertEqual(output!.metadataStep, 16000)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,168 @@
|
||||

|
||||
|
||||
# AudioStreaming
|
||||
An AudioPlayer/Streaming library for iOS written in Swift, allows playback of online audio streaming, local file as well as gapless queueing.
|
||||
|
||||
Under the hood `AudioStreaming` uses `AVAudioEngine` and `CoreAudio` for playback and provides an easy way of applying real-time [audio enhancements](https://developer.apple.com/documentation/avfaudio/audio_engine/audio_units).
|
||||
|
||||
#### Supported audio
|
||||
- Online streaming (Shoutcast/ICY streams) with metadata parsing
|
||||
- AIFF, AIFC, WAVE, CAF, NeXT, ADTS, MPEG Audio Layer 3, AAC audio formats
|
||||
- M4A (_Optimized files only_)
|
||||
|
||||
Known limitations:
|
||||
- As described above non-optimised M4A files are not supported this is a limitation of [AudioFileStream Services](https://developer.apple.com/documentation/audiotoolbox/audio_file_stream_services?language=swift)
|
||||
|
||||
|
||||
# Requirements
|
||||
- iOS 12.0+
|
||||
- Swift 5.x
|
||||
|
||||
# Using AudioStreaming
|
||||
|
||||
### Playing an audio source over HTTP
|
||||
Note: You need to keep a reference to the `AudioPlayer` object
|
||||
```
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(string: "https://your-remote-url/to/audio-file.mp3")!)
|
||||
```
|
||||
|
||||
### Playing a local file
|
||||
```
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
```
|
||||
### Queueing audio files
|
||||
```
|
||||
let player = AudioPlayer()
|
||||
// when you want to queue a single url
|
||||
player.queue(url: URL(string: "https://your-remote-url/to/audio-file.mp3")!)
|
||||
|
||||
// or if you want to queue a list of urls use
|
||||
player.queue(urls: [
|
||||
URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!,
|
||||
URL(fileURLWithPath: "your-local-path/to/audio-file-2.mp3")!
|
||||
])
|
||||
```
|
||||
|
||||
### Adjusting playback properties
|
||||
```
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
// adjust the playback rate
|
||||
player.rate = 2.0
|
||||
|
||||
// adjusting the volume
|
||||
player.volume = 0.5
|
||||
|
||||
// mute/unmute the audio
|
||||
player.mute = true
|
||||
|
||||
// pause the playback
|
||||
player.pause()
|
||||
|
||||
// resume the playback
|
||||
player.resume()
|
||||
|
||||
// stop the playback
|
||||
player.stop()
|
||||
|
||||
// seeking to to a time (in seconds)
|
||||
player.seek(to: 10)
|
||||
```
|
||||
|
||||
### Audio playback properties
|
||||
```
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
|
||||
// To get the audio file duration
|
||||
let duration = player.duration
|
||||
|
||||
// To get the progress of the player
|
||||
let progress = player.progress
|
||||
|
||||
// To get the state of the player, for possible values view the `AudioPlayerState` enum
|
||||
let state = player.state
|
||||
|
||||
// To get the stop reason of the player, for possible values view the `AudioPlayerStopReason` enum
|
||||
let state = player.stopReason
|
||||
```
|
||||
|
||||
### AudioPlayer Delegate
|
||||
You can inspect various callbacks by using the `delegate` property of the `AudioPlayer` to get informed about the player state, errors etc.
|
||||
View the [AudioPlayerDelegate](AudioStreaming/Streaming/AudioPlayer/AudioPlayerDelegate.swift) for more details
|
||||
|
||||
```
|
||||
let player = AudioPlayer()
|
||||
player.play(url: URL(fileURLWithPath: "your-local-path/to/audio-file.mp3")!)
|
||||
|
||||
player.delegate = self // an object conforming to AudioPlayerDelegate
|
||||
|
||||
// observing the audio player state, provides the new and previous state of the player.
|
||||
func audioPlayerStateChanged(player: AudioPlayer, with newState: AudioPlayerState, previous: AudioPlayerState) {}
|
||||
```
|
||||
|
||||
### Adding custom audio nodes to AudioPlayer
|
||||
`AudioStreaming` provides an easy way to attach/remove `AVAudioNode`(s).
|
||||
This provides a powerful way of adjusting the playback audio with various enchncements
|
||||
|
||||
```
|
||||
let reverbNode = AVAudioUnitReverb()
|
||||
reverbNode.wetDryMix = 50
|
||||
|
||||
let player = AudioPlayer()
|
||||
// attach a single node
|
||||
player.attach(node: reverbNode)
|
||||
|
||||
// detach a single node
|
||||
player.detach(node: reverbNode)
|
||||
|
||||
// detach all custom added nodes
|
||||
player.detachCustomAttachedNodes()
|
||||
```
|
||||
|
||||
The example project shows an example of adding a custom `AVAudioUnitEQ` node for adding equaliser to the `AudioPlayer`
|
||||
|
||||
# Installation
|
||||
|
||||
### Cocoapods
|
||||
|
||||
[Cocoapods](https://cocoapods.org/) is a dependency manager for Cocoa projects. You can install it with the following command:
|
||||
```
|
||||
$ gem install cocoapods
|
||||
```
|
||||
|
||||
To intergrate AudioStreaming with [Cocoapods](https://cocoapods.org/) to your Xcode project add the following to your `Podfile`:
|
||||
```
|
||||
pod 'AudioStreaming'
|
||||
```
|
||||
|
||||
### Swift Package Manager
|
||||
|
||||
On Xcode 11.0+ you can add a new dependency by going to **File / Swift Packages / Add Package Dependency...**
|
||||
and enter package repository URL https://github.com/dimitris-c/AudioStreaming.git, then follow the instructions.
|
||||
|
||||
### Carthage
|
||||
|
||||
[Carthage](https://github.com/Carthage/Carthage) is a decentralized dependency manager that builds your dependencies and provides you with frameworks.
|
||||
|
||||
You can install Carthage with Homebrew using the following command:
|
||||
```
|
||||
$ brew update
|
||||
$ brew install carthage
|
||||
```
|
||||
|
||||
To integrate AudioStreaming into your Xcode project using Carthage, add the following to your `Cartfile`:
|
||||
```
|
||||
github "dimitris-c/AudioStreaming"
|
||||
```
|
||||
Visit [installation instructions](https://github.com/Carthage/Carthage#adding-frameworks-to-an-application) on Carthage to install the framework
|
||||
|
||||
# Licence
|
||||
|
||||
AudioStreaming is available under the MIT license. See the LICENSE file for more info.
|
||||
|
||||
# Attributions
|
||||
This librabry takes inspiration on the already battled-tested streaming library, [StreamingKit](https://github.com/tumtumtum/StreamingKit).
|
||||
Big 🙏 to Thong Nguyen (@tumtumtum) and Matt Gallagher (@mattgallagher) for [AudioStreamer](https://github.com/mattgallagher/AudioStreamer)
|
||||
Reference in New Issue
Block a user