Fixes remote audio source network issues (#35)
This commit is contained in:
@@ -9,7 +9,6 @@
|
||||
import UIKit
|
||||
|
||||
final class AppCoordinator {
|
||||
|
||||
enum Route {
|
||||
case equalizer
|
||||
}
|
||||
@@ -44,8 +43,8 @@ final class AppCoordinator {
|
||||
|
||||
private func routeTo(_ route: AppCoordinator.Route) {
|
||||
switch route {
|
||||
case .equalizer:
|
||||
showEqualizerControls()
|
||||
case .equalizer:
|
||||
showEqualizerControls()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -9,7 +9,6 @@
|
||||
import UIKit
|
||||
|
||||
class EqualizerViewController: UIViewController {
|
||||
|
||||
private lazy var enableTextLabel = UILabel()
|
||||
private lazy var enableButton = UISwitch()
|
||||
|
||||
@@ -22,7 +21,8 @@ class EqualizerViewController: UIViewController {
|
||||
super.init(nibName: nil, bundle: nil)
|
||||
}
|
||||
|
||||
required init?(coder: NSCoder) {
|
||||
@available(*, unavailable)
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
|
||||
@@ -69,7 +69,7 @@ class EqualizerViewController: UIViewController {
|
||||
stackView.topAnchor.constraint(equalTo: view.safeAreaLayoutGuide.topAnchor),
|
||||
stackView.leadingAnchor.constraint(equalTo: view.leadingAnchor),
|
||||
stackView.trailingAnchor.constraint(equalTo: view.trailingAnchor),
|
||||
stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.8)
|
||||
stackView.heightAnchor.constraint(equalTo: view.safeAreaLayoutGuide.heightAnchor, multiplier: 0.8),
|
||||
]
|
||||
)
|
||||
}
|
||||
@@ -86,7 +86,7 @@ class EqualizerViewController: UIViewController {
|
||||
|
||||
private func buildSliders() -> [UIView] {
|
||||
var sliders = [UIView]()
|
||||
for index in 0..<viewModel.numberOfBands() {
|
||||
for index in 0 ..< viewModel.numberOfBands() {
|
||||
guard let item = viewModel.band(at: index) else { continue }
|
||||
let slider = buildSlider(item: item, index: index)
|
||||
sliders.append(slider)
|
||||
|
||||
@@ -16,7 +16,6 @@ struct EQBand {
|
||||
}
|
||||
|
||||
final class EqualzerViewModel {
|
||||
|
||||
private var bands: [EQBand] = []
|
||||
|
||||
private let equalizerService: EqualizerService
|
||||
@@ -31,7 +30,7 @@ final class EqualzerViewModel {
|
||||
bands = equalizerService.bands.map { item in
|
||||
var measurement = item.frequency
|
||||
var frequency = String(Int(measurement))
|
||||
if item.frequency >= 1_000 {
|
||||
if item.frequency >= 1000 {
|
||||
measurement = item.frequency / 1000
|
||||
frequency = "\(String(Int(measurement)))K"
|
||||
}
|
||||
|
||||
@@ -51,7 +51,7 @@ class PlayerViewController: UIViewController {
|
||||
style: .plain,
|
||||
target: self,
|
||||
action: #selector(showEqualizer))
|
||||
|
||||
|
||||
tableView.translatesAutoresizingMaskIntoConstraints = false
|
||||
tableView.delegate = self
|
||||
tableView.dataSource = self
|
||||
@@ -92,12 +92,13 @@ class PlayerViewController: UIViewController {
|
||||
|
||||
@objc private func addNowPlaylistItem() {
|
||||
let controller = UIAlertController(title: "Add new item", message: "", preferredStyle: .alert)
|
||||
controller.addTextField { (textField) in
|
||||
controller.addTextField { textField in
|
||||
textField.placeholder = "Insert url here"
|
||||
}
|
||||
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] action in
|
||||
let saveAction = UIAlertAction(title: "Save", style: .default) { [viewModel] _ in
|
||||
if let textfield = controller.textFields?.first,
|
||||
let text = textfield.text {
|
||||
let text = textfield.text
|
||||
{
|
||||
viewModel.add(urlString: text)
|
||||
}
|
||||
}
|
||||
@@ -105,7 +106,7 @@ class PlayerViewController: UIViewController {
|
||||
|
||||
controller.addAction(saveAction)
|
||||
controller.addAction(cancelAction)
|
||||
self.present(controller, animated: true, completion: nil)
|
||||
present(controller, animated: true, completion: nil)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -149,14 +150,13 @@ extension PlayerViewController: UITableViewDelegate {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
final class PlaylistTableViewCell: UITableViewCell {
|
||||
override init(style: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
override init(style _: UITableViewCell.CellStyle, reuseIdentifier: String?) {
|
||||
super.init(style: .subtitle, reuseIdentifier: reuseIdentifier)
|
||||
}
|
||||
|
||||
@available(*, unavailable)
|
||||
required init?(coder: NSCoder) {
|
||||
required init?(coder _: NSCoder) {
|
||||
fatalError("init(coder:) has not been implemented")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -18,14 +18,15 @@ final class PlayerViewModel {
|
||||
private let playerService: AudioPlayerService
|
||||
private let playlistItemsService: PlaylistItemsService
|
||||
|
||||
private let routeTo: ((AppCoordinator.Route) -> Void)
|
||||
private let routeTo: (AppCoordinator.Route) -> Void
|
||||
private var currentPlayingItemIndex: Int?
|
||||
|
||||
var reloadContent: ((ReloadAction) -> Void)?
|
||||
|
||||
init(playlistItemsService: PlaylistItemsService,
|
||||
playerService: AudioPlayerService,
|
||||
routeTo: @escaping (AppCoordinator.Route) -> Void) {
|
||||
routeTo: @escaping (AppCoordinator.Route) -> Void)
|
||||
{
|
||||
self.playlistItemsService = playlistItemsService
|
||||
self.playerService = playerService
|
||||
self.routeTo = routeTo
|
||||
|
||||
@@ -10,7 +10,7 @@ import AVFoundation
|
||||
|
||||
final class EqualizerService {
|
||||
private let playerService: AudioPlayerService
|
||||
private let _freqs = [32, 64, 128, 250, 500, 1_000, 2_000, 4_000, 8_000, 16_000]
|
||||
private let _freqs = [32, 64, 128, 250, 500, 1000, 2000, 4000, 8000, 16000]
|
||||
private let eqUnit: AVAudioUnitEQ
|
||||
|
||||
var bands: [AVAudioUnitEQFilterParameters] {
|
||||
@@ -23,7 +23,7 @@ final class EqualizerService {
|
||||
self.playerService = playerService
|
||||
|
||||
eqUnit = AVAudioUnitEQ(numberOfBands: _freqs.count)
|
||||
for i in 0..<_freqs.count {
|
||||
for i in 0 ..< _freqs.count {
|
||||
eqUnit.bands[i].bypass = false
|
||||
eqUnit.bands[i].filterType = .parametric
|
||||
eqUnit.bands[i].frequency = Float(_freqs[i])
|
||||
|
||||
@@ -9,10 +9,9 @@
|
||||
import MediaPlayer
|
||||
|
||||
final class NowPlayingCenter {
|
||||
|
||||
private let infoCenter: MPNowPlayingInfoCenter
|
||||
|
||||
init(infoCenter: MPNowPlayingInfoCenter = .default()){
|
||||
init(infoCenter: MPNowPlayingInfoCenter = .default()) {
|
||||
self.infoCenter = infoCenter
|
||||
}
|
||||
|
||||
|
||||
@@ -80,7 +80,7 @@ final class PlaylistItemsService {
|
||||
func provideInitialPlaylistItems() -> [PlaylistItem] {
|
||||
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) }
|
||||
let allItems = allCases.map { PlaylistItem(content: $0, queues: false) }
|
||||
let casesForQueuingItems = casesForQueueing.map { PlaylistItem(content: $0, queues: true) }
|
||||
return allItems + casesForQueuingItems
|
||||
}
|
||||
|
||||
@@ -5,11 +5,11 @@
|
||||
|
||||
import AVFoundation
|
||||
|
||||
extension AVAudioFormat {
|
||||
public extension AVAudioFormat {
|
||||
/// The underlying audio stream description.
|
||||
///
|
||||
/// This exposes the `pointee` value of the `UsafePointer<AudioStreamBasicDescription>`
|
||||
public var basicStreamDescription: AudioStreamBasicDescription {
|
||||
var basicStreamDescription: AudioStreamBasicDescription {
|
||||
return streamDescription.pointee
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,6 +38,7 @@ final class Retrier {
|
||||
|
||||
/// Cancels retrying
|
||||
func cancel() {
|
||||
interval = .seconds(1)
|
||||
timeoutTimer.removeHandler()
|
||||
timeoutTimer.suspend()
|
||||
}
|
||||
|
||||
@@ -9,12 +9,14 @@ import Network
|
||||
enum NetConnectionType: Equatable {
|
||||
case cellular(connected: Bool)
|
||||
case wifi(connected: Bool)
|
||||
case other(connected: Bool)
|
||||
case undetermined
|
||||
|
||||
var isConnected: Bool {
|
||||
switch self {
|
||||
case let .cellular(connected),
|
||||
let .wifi(connected):
|
||||
let .wifi(connected),
|
||||
let .other(connected):
|
||||
return connected
|
||||
default:
|
||||
return false
|
||||
@@ -39,15 +41,13 @@ final class NetStatusService: NetStatusProvider {
|
||||
network.currentPath.toNetConnectionType()
|
||||
}
|
||||
|
||||
private var currentConnectionType: NetConnectionType = .undetermined
|
||||
|
||||
private let network: NWPathMonitor
|
||||
|
||||
private let monitorQueue: DispatchQueue
|
||||
|
||||
init(network: NWPathMonitor) {
|
||||
self.network = network
|
||||
monitorQueue = DispatchQueue(label: "net.path.queue", qos: .background)
|
||||
monitorQueue = DispatchQueue(label: "net.path.queue", qos: .utility)
|
||||
}
|
||||
|
||||
deinit {
|
||||
@@ -59,20 +59,15 @@ final class NetStatusService: NetStatusProvider {
|
||||
/// - parameter connectionChange: A callback block to listen to changes of the network type, this skips duplicates.
|
||||
/// - Note: The callback will be executed on the main thread.
|
||||
func start(connectionChange: @escaping (NetConnectionType) -> Void) {
|
||||
network.pathUpdateHandler = { [weak self] path in
|
||||
guard let self = self else { return }
|
||||
let connecionType = path.toNetConnectionType()
|
||||
if self.currentConnectionType != connecionType {
|
||||
connectionChange(self.connectionType)
|
||||
self.currentConnectionType = self.connectionType
|
||||
}
|
||||
network.pathUpdateHandler = { path in
|
||||
let connectionType = path.toNetConnectionType()
|
||||
connectionChange(connectionType)
|
||||
}
|
||||
startIfNeeded()
|
||||
}
|
||||
|
||||
func stop() {
|
||||
network.cancel()
|
||||
network.pathUpdateHandler = nil
|
||||
}
|
||||
|
||||
func startIfNeeded() {
|
||||
@@ -85,12 +80,17 @@ extension NWPath {
|
||||
func toNetConnectionType() -> NetConnectionType {
|
||||
let isCellular = usesInterfaceType(.cellular)
|
||||
let isWifi = usesInterfaceType(.wifi)
|
||||
let isOther = usesInterfaceType(.loopback)
|
||||
|| usesInterfaceType(.other)
|
||||
|| usesInterfaceType(.wiredEthernet)
|
||||
let isConnected = status == .satisfied
|
||||
|
||||
if isCellular {
|
||||
return .cellular(connected: isConnected)
|
||||
} else if isWifi {
|
||||
return .wifi(connected: isConnected)
|
||||
} else if isOther {
|
||||
return .other(connected: isConnected)
|
||||
}
|
||||
|
||||
return .undetermined
|
||||
|
||||
@@ -109,7 +109,6 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
|
||||
func close() {
|
||||
retrierTimeout.cancel()
|
||||
netStatusService.stop()
|
||||
streamOperationQueue.isSuspended = false
|
||||
streamOperationQueue.cancelAllOperations()
|
||||
if let streamTask = streamRequest {
|
||||
@@ -152,8 +151,8 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
guard let self = self else { return }
|
||||
guard connection.isConnected else { return }
|
||||
if self.waitingForNetwork {
|
||||
self.seek(at: self.supportsSeek ? self.position : 0 )
|
||||
self.waitingForNetwork = false
|
||||
self.seek(at: self.position)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -161,14 +160,13 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private func performOpen(seek seekOffset: Int) {
|
||||
let urlRequest = buildUrlRequest(with: url, seekIfNeeded: seekOffset)
|
||||
|
||||
let request = networkingClient.stream(request: urlRequest)
|
||||
streamRequest = networkingClient.stream(request: urlRequest)
|
||||
.responseStream { [weak self] event in
|
||||
guard let self = self else { return }
|
||||
self.handleResponse(event: event)
|
||||
}
|
||||
.resume()
|
||||
|
||||
streamRequest = request
|
||||
metadataStreamProcessor.delegate = self
|
||||
}
|
||||
|
||||
@@ -231,12 +229,12 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
/// - 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.canProcessMetadata {
|
||||
let extractedAudioData = self.metadataStreamProcessor.processMetadata(data: data)
|
||||
self.delegate?.dataAvailable(source: self, data: extractedAudioData)
|
||||
if metadataStreamProcessor.canProcessMetadata {
|
||||
let extractedAudioData = metadataStreamProcessor.processMetadata(data: data)
|
||||
delegate?.dataAvailable(source: self, data: extractedAudioData)
|
||||
return extractedAudioData.count
|
||||
} else {
|
||||
self.delegate?.dataAvailable(source: self, data: data)
|
||||
delegate?.dataAvailable(source: self, data: data)
|
||||
return data.count
|
||||
}
|
||||
}
|
||||
@@ -270,7 +268,10 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
if length >= 0 { seekOffset = length }
|
||||
delegate?.endOfFileOccurred(source: self)
|
||||
} else if statusCode >= 300 {
|
||||
delegate?.errorOccurred(source: self, error: NetworkError.serverError)
|
||||
delegate?.errorOccurred(
|
||||
source: self,
|
||||
error: NetworkError.serverError
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -287,7 +288,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
urlRequest.addValue("1", forHTTPHeaderField: "Icy-MetaData")
|
||||
urlRequest.addValue("identity", forHTTPHeaderField: "Accept-Encoding")
|
||||
|
||||
if supportsSeek && seekOffset > 0 {
|
||||
if supportsSeek, seekOffset > 0 {
|
||||
urlRequest.addValue("bytes=\(seekOffset)-", forHTTPHeaderField: "Range")
|
||||
}
|
||||
return urlRequest
|
||||
@@ -296,7 +297,7 @@ public class RemoteAudioSource: AudioStreamSource {
|
||||
private func retryOnError() {
|
||||
retrierTimeout.retry { [weak self] in
|
||||
guard let self = self else { return }
|
||||
self.seek(at: self.position)
|
||||
self.seek(at: self.supportsSeek ? self.position : 0)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -470,7 +470,7 @@ open class AudioPlayer {
|
||||
if let first = customAttachedNodes.first {
|
||||
audioEngine.connect(rateNode, to: first, format: nil)
|
||||
}
|
||||
for index in 0..<customAttachedNodes.count - 1 {
|
||||
for index in 0 ..< customAttachedNodes.count - 1 {
|
||||
let current = customAttachedNodes[index]
|
||||
let next = customAttachedNodes[index + 1]
|
||||
let format = current.inputFormat(forBus: 0)
|
||||
|
||||
@@ -195,7 +195,7 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
guard let converter = audioConverter else {
|
||||
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
|
||||
return
|
||||
return
|
||||
}
|
||||
guard AudioConverterSetProperty(converter, kAudioConverterDecompressionMagicCookie, cookieSize, cookie) == noErr else {
|
||||
fileStreamCallback?(.raiseError(.audioSystemError(.fileStreamError(.unknownError))))
|
||||
@@ -265,7 +265,7 @@ final class AudioFileStreamProcessor {
|
||||
var size = UInt32(4)
|
||||
AudioFileStreamGetProperty(fileStream, kAudioFileStreamProperty_FileFormat, &size, &fileFormat)
|
||||
if let stringFileFormat = String(data: Data(fileFormat), encoding: .utf8) {
|
||||
self.currentFileFormat = stringFileFormat
|
||||
currentFileFormat = stringFileFormat
|
||||
}
|
||||
}
|
||||
|
||||
@@ -378,7 +378,7 @@ final class AudioFileStreamProcessor {
|
||||
}
|
||||
|
||||
updateProcessedPackets(inPacketDescriptions: inPacketDescriptions,
|
||||
inNumberPackets: inNumberPackets)
|
||||
inNumberPackets: inNumberPackets)
|
||||
|
||||
var status: OSStatus = noErr
|
||||
packetProcess: while status == noErr {
|
||||
|
||||
@@ -33,7 +33,6 @@ public struct FilterEntry: Equatable {
|
||||
}
|
||||
|
||||
public protocol FrameFiltering {
|
||||
|
||||
/// A Boolean value indicating whether there are filter entries
|
||||
var hasEntries: Bool { get }
|
||||
|
||||
@@ -73,7 +72,6 @@ public protocol FrameFiltering {
|
||||
}
|
||||
|
||||
final class FrameFilterProcessor: NSObject, FrameFiltering {
|
||||
|
||||
public var hasEntries: Bool {
|
||||
lock.lock(); defer { lock.unlock() }
|
||||
return !entries.isEmpty
|
||||
|
||||
@@ -26,7 +26,6 @@ import Foundation
|
||||
/// ```
|
||||
|
||||
final class IcycastHeadersProcessor {
|
||||
|
||||
private var icecastHeaders = Data(capacity: 1024)
|
||||
private var searchComplete = false
|
||||
private var iceHeaderAvailable = false
|
||||
@@ -72,8 +71,9 @@ final class IcycastHeadersProcessor {
|
||||
|
||||
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 {
|
||||
if icecastHeaders[..<icyPrefix.count].elementsEqual(icyPrefix) == false,
|
||||
icecastHeaders[..<httpPrefix.count].elementsEqual(httpPrefix) == false
|
||||
{
|
||||
iceHeaderAvailable = false
|
||||
searchComplete = true
|
||||
}
|
||||
|
||||
@@ -9,9 +9,7 @@
|
||||
import Foundation
|
||||
|
||||
struct IcycastHeaderParser: Parser {
|
||||
|
||||
func parse(input: Data) -> HTTPHeaderParserOutput? {
|
||||
|
||||
guard let icecastValue = String(data: input, encoding: .utf8) else {
|
||||
return nil
|
||||
}
|
||||
|
||||
@@ -20,11 +20,15 @@ struct MetadataParser: Parser {
|
||||
guard let string = String(data: input, encoding: .utf8) else { return .failure(.unableToParse) }
|
||||
// remove added bytes (zeros) and separate the string on every ';' char
|
||||
let pairs = string.trimmingCharacters(in: CharacterSet(charactersIn: "\0")).components(separatedBy: ";")
|
||||
let temp: [String: String] = [:]
|
||||
let metadata = pairs.reduce(into: temp) { result, next in
|
||||
let paired = next.components(separatedBy: "=")
|
||||
if let key = paired.first,
|
||||
let value = paired.last?.replacingOccurrences(of: "'", with: ""), !key.isEmpty
|
||||
let metadata = pairs.reduce(into: [String: String]()) { result, next in
|
||||
let split = next.split(
|
||||
separator: "=",
|
||||
maxSplits: 1,
|
||||
omittingEmptySubsequences: true
|
||||
)
|
||||
.map(String.init)
|
||||
if let key = split.first,
|
||||
let value = split.last?.replacingOccurrences(of: "'", with: ""), !key.isEmpty
|
||||
{
|
||||
result[key] = value
|
||||
}
|
||||
|
||||
@@ -50,6 +50,26 @@ class MetadataParserTests: XCTestCase {
|
||||
}
|
||||
}
|
||||
|
||||
func testParserOutputsCorrectResultWhenEntryContainsEqualSign() throws {
|
||||
let string = "StreamTitle=\'Gramatik - In This Whole World (Original Mix)\';StreamUrl=\'\';track_info=\'k4Smc3RhdHVzoUihQNJiGp6BpHR5cGWhVKJpZKhNWDUxMTYzNISmc3RhdHVzoUOhQNJiGp9cpHR5cGWhVKJpZKhNWDUxMDM3MoSmc3RhdHVzoUOhQNJiGqAqpHR5cGWhVKJpZKhNWDUxMjA5Ng==\';UTC=\'20220226T214447.206\';\0\0\0\0\0\0\0\0\0"
|
||||
let data = string.data(using: .utf8)!
|
||||
|
||||
let parser = MetadataParser()
|
||||
|
||||
let output = parser.parse(input: data)
|
||||
|
||||
switch output {
|
||||
case let .success(values):
|
||||
XCTAssertFalse(values.isEmpty)
|
||||
XCTAssertEqual(values["StreamTitle"], "Gramatik - In This Whole World (Original Mix)")
|
||||
XCTAssertEqual(values["StreamUrl"], "")
|
||||
XCTAssertEqual(values["track_info"], "k4Smc3RhdHVzoUihQNJiGp6BpHR5cGWhVKJpZKhNWDUxMTYzNISmc3RhdHVzoUOhQNJiGp9cpHR5cGWhVKJpZKhNWDUxMDM3MoSmc3RhdHVzoUOhQNJiGqAqpHR5cGWhVKJpZKhNWDUxMjA5Ng==")
|
||||
XCTAssertEqual(values["UTC"], "20220226T214447.206")
|
||||
case .failure:
|
||||
XCTFail()
|
||||
}
|
||||
}
|
||||
|
||||
func testParserOutputsFailureOnEmptyStringData() throws {
|
||||
let data = "".data(using: .utf8)!
|
||||
let parser = MetadataParser()
|
||||
|
||||
+1
-1
@@ -17,7 +17,7 @@ let package = Package(
|
||||
.target(
|
||||
name: "AudioStreaming",
|
||||
path: "AudioStreaming"
|
||||
)
|
||||
),
|
||||
],
|
||||
swiftLanguageVersions: [.v5]
|
||||
)
|
||||
|
||||
Reference in New Issue
Block a user