This commit is contained in:
dimitris-c
2024-05-15 13:23:46 +03:00
parent 7d91e318d1
commit 1b7558a4f1
11 changed files with 303 additions and 176 deletions
@@ -22,6 +22,7 @@
9816A8B22BC8330C00AD1299 /* bensound-jazzyfrenchy.m4a in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */; };
9816A8B32BC8330C00AD1299 /* hipjazz.wav in Resources */ = {isa = PBXBuildFile; fileRef = 9816A8AF2BC832DC00AD1299 /* hipjazz.wav */; };
9816A8BB2BC87BC200AD1299 /* AudioPlayerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */; };
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */ = {isa = PBXBuildFile; fileRef = 984DE9542BDAE59C004B427A /* Notifier.swift */; };
98BFB41A2BC97AF800E812C0 /* DisplayLink.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB4192BC97AF800E812C0 /* DisplayLink.swift */; };
98BFB41D2BCD7BB800E812C0 /* EqualizerView.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */; };
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */ = {isa = PBXBuildFile; fileRef = 98BFB41E2BCD814000E812C0 /* EqualizerService.swift */; };
@@ -61,6 +62,7 @@
9816A8AE2BC832DB00AD1299 /* bensound-jazzyfrenchy.m4a */ = {isa = PBXFileReference; lastKnownFileType = file; path = "bensound-jazzyfrenchy.m4a"; sourceTree = "<group>"; };
9816A8AF2BC832DC00AD1299 /* hipjazz.wav */ = {isa = PBXFileReference; lastKnownFileType = audio.wav; path = hipjazz.wav; sourceTree = "<group>"; };
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = AudioPlayerService.swift; sourceTree = "<group>"; };
984DE9542BDAE59C004B427A /* Notifier.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = Notifier.swift; sourceTree = "<group>"; };
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = DisplayLink.swift; sourceTree = "<group>"; };
98BFB41B2BCAAD8A00E812C0 /* Info.plist */ = {isa = PBXFileReference; lastKnownFileType = text.plist; path = Info.plist; sourceTree = "<group>"; };
98BFB41C2BCD7BB800E812C0 /* EqualizerView.swift */ = {isa = PBXFileReference; lastKnownFileType = sourcecode.swift; path = EqualizerView.swift; sourceTree = "<group>"; };
@@ -103,10 +105,9 @@
9806E8162BC5D12500757370 /* AudioPlayer */ = {
isa = PBXGroup;
children = (
984DE9532BDAE57F004B427A /* Dependencies */,
98BFB41B2BCAAD8A00E812C0 /* Info.plist */,
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
984DE9522BDAE571004B427A /* Helpers */,
9816A8A82BC7F4DE00AD1299 /* Common */,
9806E8282BC68F7300757370 /* Content */,
9806E8272BC68F6600757370 /* Navigation */,
@@ -173,6 +174,24 @@
path = Resources;
sourceTree = "<group>";
};
984DE9522BDAE571004B427A /* Helpers */ = {
isa = PBXGroup;
children = (
98BFB4192BC97AF800E812C0 /* DisplayLink.swift */,
984DE9542BDAE59C004B427A /* Notifier.swift */,
);
path = Helpers;
sourceTree = "<group>";
};
984DE9532BDAE57F004B427A /* Dependencies */ = {
isa = PBXGroup;
children = (
98BFB41E2BCD814000E812C0 /* EqualizerService.swift */,
9816A8BA2BC87BC200AD1299 /* AudioPlayerService.swift */,
);
path = Dependencies;
sourceTree = "<group>";
};
98E3921B2BD845D200B586E9 /* AudioQueue */ = {
isa = PBXGroup;
children = (
@@ -275,6 +294,7 @@
98E392202BD8461D00B586E9 /* AudioQueueModel.swift in Sources */,
9816A8AC2BC820DF00AD1299 /* AudioContent.swift in Sources */,
9806E8262BC5D2A900757370 /* Sidebar.swift in Sources */,
984DE9552BDAE59C004B427A /* Notifier.swift in Sources */,
9806E82A2BC68F8700757370 /* AudioPlayerView.swift in Sources */,
9806E8312BC6927D00757370 /* AudioPlayerModel.swift in Sources */,
98BFB41F2BCD814000E812C0 /* EqualizerService.swift in Sources */,
@@ -23,43 +23,18 @@ enum ScrubState: Equatable {
public class AudioPlayerModel {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
@ObservationIgnored
private var displayLink: DisplayLink?
var audioTracks: [AudioPlaylist] = []
var isLiveAudioStreaming: Bool {
totalTime == 0
}
var liveAudioMetadata: String?
var isPlaying: Bool = false
var isMuted: Bool = false
var playbackRate: Double = 0.0
var currentTime: Double = 0
var totalTime: Double?
var scrubState: ScrubState = .idle
var formattedCurrentTime: String?
var formattedTotalTime: String?
var currentTrack: AudioTrack?
init(audioTracksProvider: () -> [AudioPlaylist] = audioTracksProvider, audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
self.audioTracks = audioTracksProvider()
self.audioPlayerService.delegate = self
}
deinit {
audioPlayerService.stop()
displayLink?.deactivate()
displayLink = nil
}
func addNewAudioTrack(url: URL) {
@@ -76,136 +51,11 @@ public class AudioPlayerModel {
}
}
func mute() {
isMuted.toggle()
audioPlayerService.toggleMute()
}
func playPause() {
if audioPlayerService.state == .playing {
audioPlayerService.pause()
} else {
audioPlayerService.resume()
}
}
func update(rate: Float) {
let rate = round(rate / 0.2) * 0.2
audioPlayerService.update(rate: rate)
}
func stop() {
isPlaying = false
audioPlayerService.stop()
currentTrack?.status = .idle
currentTrack = nil
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack?.status = .idle
audioPlayerService.play(url: track.url)
currentTrack = track
}
}
func onTick() {
let duration = audioPlayerService.duration
let progress = audioPlayerService.progress
if duration > 0 {
let elapsed = Int(progress)
let remaining = Int(duration - progress)
totalTime = duration
switch scrubState {
case .idle:
currentTime = progress
case .started:
break
case .ended(let seekTime):
currentTime = seekTime
if audioPlayerService.duration > 0 {
audioPlayerService.seek(at: seekTime)
}
scrubState = .idle
}
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
formattedTotalTime = timeFrom(seconds: remaining)
} else {
let elapsed = Int(progress)
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
if formattedTotalTime != nil {
formattedTotalTime = nil
}
}
}
func resetLabels() {
currentTime = 0
totalTime = 0
formattedCurrentTime = nil
formattedTotalTime = nil
}
private func timeFrom(seconds: Int) -> String {
let correctSeconds = seconds % 60
let minutes = (seconds / 60) % 60
let hours = seconds / 3600
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
}
return String(format: "%02d:%02d", minutes, correctSeconds)
}
}
extension AudioPlayerModel: AudioPlayerServiceDelegate {
func didStartPlaying(id: AudioEntryId) {
self.displayLink = DisplayLink(onTick: { [weak self] _ in
self?.onTick()
})
displayLink?.activate()
}
func didStopPlaying(id: AudioEntryId, reason: AudioPlayerStopReason) {
resetLabels()
liveAudioMetadata = nil
playbackRate = 1.0
displayLink?.deactivate()
}
func statusChanged(status: AudioStreaming.AudioPlayerState) {
isPlaying = status == .playing
displayLink?.isPaused = !isPlaying
switch status {
case .bufferring:
currentTrack?.status = .buffering
case .error:
currentTrack?.status = .error
currentTrack = nil
case .playing:
currentTrack?.status = .playing
case .paused:
currentTrack?.status = .paused
case .stopped:
currentTrack?.status = .idle
default:
currentTrack?.status = .idle
}
}
func errorOccurred(error: AudioStreaming.AudioPlayerError) {
}
func metadataReceived(metadata: [String : String]) {
guard !metadata.isEmpty else { return }
if let title = metadata["StreamTitle"] {
liveAudioMetadata = title.isEmpty ? "-" : title
} else {
liveAudioMetadata = nil
}
}
}
func audioTracksProvider() -> [AudioPlaylist] {
@@ -42,7 +42,7 @@ struct AudioPlayerView: View {
}
}
.safeAreaInset(edge: .bottom) {
AudioPlayerControls(viewModel: model)
AudioPlayerControls(appModel: appModel, currentTrack: $model.currentTrack)
.background(
.ultraThinMaterial.shadow(
ShadowStyle.drop(color: .black.opacity(0.1), radius: 8, x: 0, y: -10)
@@ -82,26 +82,35 @@ struct AudioPlayerView: View {
}
struct AudioPlayerControls: View {
@Bindable var viewModel: AudioPlayerModel
@State var model: Model
@Binding var currentTrack: AudioTrack?
init(appModel: AppModel, currentTrack: Binding<AudioTrack?>) {
self._model = State(wrappedValue: Model(audioPlayerService: appModel.audioPlayerService))
self._currentTrack = currentTrack
}
var body: some View {
VStack(alignment: .leading) {
HStack {
Button(action: { viewModel.playPause() }) {
Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill")
Button(action: { model.playPause() }) {
Image(systemName: model.isPlaying ? "pause.fill" : "play.fill")
.font(.title)
.imageScale(.small)
}
.contentTransition(.symbolEffect(.replace))
Button(action: { viewModel.stop() }) {
Button(action: {
model.stop()
currentTrack = nil
}) {
Image(systemName: "stop.fill")
.font(.title)
.imageScale(.small)
}
.padding(.leading, 8)
Spacer()
Button(action: { viewModel.mute() }) {
Image(systemName: viewModel.isMuted ? "speaker.slash.fill" : "speaker.fill")
Button(action: { model.mute() }) {
Image(systemName: model.isMuted ? "speaker.slash.fill" : "speaker.fill")
.font(.title)
.imageScale(.small)
}
@@ -110,7 +119,7 @@ struct AudioPlayerControls: View {
}
.tint(.mint)
.padding(16)
if let audioMetadata = viewModel.liveAudioMetadata, viewModel.isLiveAudioStreaming {
if let audioMetadata = model.liveAudioMetadata, model.isLiveAudioStreaming {
Text("Now Playing: \(audioMetadata)")
.font(.caption)
.foregroundStyle(.black)
@@ -119,21 +128,21 @@ struct AudioPlayerControls: View {
Divider()
VStack {
Slider(
value: $viewModel.currentTime,
in: 0...(viewModel.totalTime ?? 1.0),
value: $model.currentTime,
in: 0...(model.totalTime ?? 1.0),
onEditingChanged: { scrubStarted in
if scrubStarted {
viewModel.scrubState = .started
model.scrubState = .started
} else {
viewModel.scrubState = .ended(viewModel.currentTime)
model.scrubState = .ended(model.currentTime)
}
}
)
.disabled(viewModel.totalTime == nil)
.disabled(model.totalTime == nil)
HStack {
Text(viewModel.formattedCurrentTime ?? "--:--")
Text(model.formattedCurrentTime ?? "--:--")
Spacer()
Text(viewModel.formattedTotalTime ?? "")
Text(model.formattedTotalTime ?? "")
}
.foregroundStyle(.black)
.font(.caption)
@@ -143,21 +152,209 @@ struct AudioPlayerControls: View {
.padding(.horizontal, 16)
Divider()
VStack(alignment: .leading) {
Text("Playback Rate: \(String(format: "%0.1f", viewModel.playbackRate))")
Text("Playback Rate: \(String(format: "%0.1f", model.playbackRate))")
.font(.subheadline)
.fontWeight(.medium)
.foregroundStyle(.black)
Slider(value: $viewModel.playbackRate, in: 1.0...4.0, step: 0.2)
.onChange(of: viewModel.playbackRate) { _, new in
viewModel.update(rate: Float(new))
Slider(value: $model.playbackRate, in: 1.0...4.0, step: 0.2)
.onChange(of: model.playbackRate) { _, new in
model.update(rate: Float(new))
}
}
.padding(.bottom, 8)
.padding(.horizontal, 16)
}
.onChange(of: currentTrack) { oldValue, newValue in
if let track = newValue {
model.play(track)
}
}
}
}
extension AudioPlayerControls {
@Observable
final class Model {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
@ObservationIgnored
private var displayLink: DisplayLink?
var isLiveAudioStreaming: Bool {
totalTime == 0
}
var liveAudioMetadata: String?
var isPlaying: Bool = false
var isMuted: Bool = false
var playbackRate: Double = 0.0
var currentTime: Double = 0
var totalTime: Double?
var scrubState: ScrubState = .idle
var formattedCurrentTime: String?
var formattedTotalTime: String?
var currentTrack: AudioTrack?
init(audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
registerObservations()
}
deinit {
displayLink?.deactivate()
displayLink = nil
}
func registerObservations() {
Task { @MainActor in
for await status in await audioPlayerService.statusChangedNotifier.values() {
isPlaying = status == .playing
displayLink?.isPaused = !isPlaying
switch status {
case .bufferring:
currentTrack?.status = .buffering
case .error:
currentTrack?.status = .error
currentTrack = nil
case .playing:
currentTrack?.status = .playing
case .paused:
currentTrack?.status = .paused
case .stopped:
currentTrack?.status = .idle
default:
currentTrack?.status = .idle
}
}
}
Task { @MainActor in
for await metadata in await audioPlayerService.metadataReceivedNotifier.values() {
guard !metadata.isEmpty else { break }
if let title = metadata["StreamTitle"] {
liveAudioMetadata = title.isEmpty ? "-" : title
} else {
liveAudioMetadata = nil
}
}
}
Task { @MainActor in
for await startStopped in await audioPlayerService.playingStartedStopped.values() {
if startStopped.started {
self.didStartPlaying()
} else {
self.didStopPlaying()
}
}
}
}
func mute() {
isMuted.toggle()
audioPlayerService.toggleMute()
}
func playPause() {
if audioPlayerService.state == .playing {
audioPlayerService.pause()
} else {
audioPlayerService.resume()
}
}
func update(rate: Float) {
let rate = round(rate / 0.2) * 0.2
audioPlayerService.update(rate: rate)
}
func stop() {
isPlaying = false
audioPlayerService.stop()
currentTrack?.status = .idle
currentTrack = nil
}
func play(_ track: AudioTrack) {
if track != currentTrack {
currentTrack?.status = .idle
audioPlayerService.play(url: track.url)
currentTrack = track
}
}
func onTick() {
let duration = audioPlayerService.duration
let progress = audioPlayerService.progress
if duration > 0 {
let elapsed = Int(progress)
let remaining = Int(duration - progress)
totalTime = duration
switch scrubState {
case .idle:
currentTime = progress
case .started:
break
case .ended(let seekTime):
currentTime = seekTime
if audioPlayerService.duration > 0 {
audioPlayerService.seek(at: seekTime)
}
scrubState = .idle
}
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
formattedTotalTime = timeFrom(seconds: remaining)
} else {
let elapsed = Int(progress)
formattedCurrentTime = timeFrom(seconds: Int(elapsed))
if formattedTotalTime != nil {
formattedTotalTime = nil
}
}
}
func resetLabels() {
currentTime = 0
totalTime = 0
formattedCurrentTime = nil
formattedTotalTime = nil
}
private func timeFrom(seconds: Int) -> String {
let correctSeconds = seconds % 60
let minutes = (seconds / 60) % 60
let hours = seconds / 3600
if hours > 0 {
return String(format: "%02d:%02d:%02d", hours, minutes, correctSeconds)
}
return String(format: "%02d:%02d", minutes, correctSeconds)
}
private func didStartPlaying() {
self.displayLink = DisplayLink(onTick: { [weak self] _ in
self?.onTick()
})
displayLink?.activate()
}
private func didStopPlaying() {
resetLabels()
liveAudioMetadata = nil
playbackRate = 1.0
displayLink?.deactivate()
}
}
}
#Preview {
AudioPlayerView(appModel: AppModel())
}
@@ -127,7 +127,6 @@ struct EQSliderView: View {
}
GeometryReader { innerGeo in
ZStack {
LineShape(values: eqModel.shouldReset ? resetPoints : dragPointYLocations.map { Double($0) })
.stroke(Color.mint, lineWidth: 2)
.animation(.easeInOut(duration: 0.2), value: eqModel.shouldReset)
@@ -4,3 +4,16 @@
//
import Foundation
@Observable
class AudioQueueModel {
@ObservationIgnored
private(set) var audioPlayerService: AudioPlayerService
@ObservationIgnored
private var displayLink: DisplayLink?
init(audioPlayerService: AudioPlayerService) {
self.audioPlayerService = audioPlayerService
}
}
@@ -6,11 +6,22 @@
import SwiftUI
struct AudioQueueView: View {
@Environment(AppModel.self) var appModel
@State var model: AudioPlayerModel
@State var eqSheetIsShown: Bool = false
@State var addNewAudioIsShown: Bool = false
init(appModel: AppModel) {
self._model = State(wrappedValue: AudioPlayerModel(audioPlayerService: appModel.audioPlayerService))
}
var body: some View {
Text(/*@START_MENU_TOKEN@*/"Hello, World!"/*@END_MENU_TOKEN@*/)
return Text("")
}
}
#Preview {
AudioQueueView()
AudioQueueView(appModel: AppModel())
}
@@ -40,6 +40,10 @@ final class AudioPlayerService {
player.state
}
var statusChangedNotifier = Notifier<AudioPlayerState>()
var metadataReceivedNotifier = Notifier<[String: String]>()
var playingStartedStopped = Notifier<(started: Bool, AudioEntryId, AudioPlayerStopReason?)>()
init(audioPlayer: AudioPlayer) {
player = audioPlayer
player.delegate = self
@@ -150,12 +154,14 @@ extension AudioPlayerService: AudioPlayerDelegate {
func audioPlayerDidStartPlaying(player _: AudioPlayer, with id: AudioEntryId) {
print("audioPlayerDidStartPlaying entryId: \(id)")
delegate?.didStartPlaying(id: id)
Task { await playingStartedStopped.send((true, id, nil)) }
}
func audioPlayerDidFinishBuffering(player _: AudioPlayer, with _: AudioEntryId) {}
func audioPlayerStateChanged(player _: AudioPlayer, with newState: AudioPlayerState, previous _: AudioPlayerState) {
print("audioPlayerDidStartPlaying newState: \(newState)")
Task { await statusChangedNotifier.send(newState) }
delegate?.statusChanged(status: newState)
}
@@ -166,6 +172,7 @@ extension AudioPlayerService: AudioPlayerDelegate {
duration _: Double)
{
print("audioPlayerDidFinishPlaying entryId: \(id), reason: \(reason)")
Task { await playingStartedStopped.send((false, id, reason)) }
delegate?.didStopPlaying(id: id, reason: reason)
}
@@ -176,6 +183,7 @@ extension AudioPlayerService: AudioPlayerDelegate {
func audioPlayerDidCancel(player _: AudioPlayer, queuedItems _: [AudioEntryId]) {}
func audioPlayerDidReadMetadata(player _: AudioPlayer, metadata: [String: String]) {
Task { await metadataReceivedNotifier.send(metadata) }
delegate?.metadataReceived(metadata: metadata)
}
}
@@ -0,0 +1,29 @@
//
// Created by Dimitris Chatzieleftheriou on 25/04/2024.
//
import Foundation
actor Notifier<Output> {
private var continuations: [UUID: AsyncStream<Output>.Continuation] = [:]
func values(bufferingPolicy limit: AsyncStream<Output>.Continuation.BufferingPolicy = .bufferingNewest(1)) -> AsyncStream<Output> {
AsyncStream<Output>(bufferingPolicy: limit) { continuation in
let id = UUID()
continuations[id] = continuation
continuation.onTermination = { _ in
Task { await self.cancel(id) }
}
}
}
func send(_ value: Output) {
for continuation in continuations.values {
continuation.yield(value)
}
}
private func cancel(_ id: UUID) {
continuations[id] = nil
}
}
@@ -16,7 +16,7 @@ struct DetailView: View {
case .audioPlayer:
AudioPlayerView(appModel: appModel)
case .audioQueue:
Text("Audio Queue")
AudioQueueView(appModel: appModel)
}
}
}