0

I've created an audio player using SwiftUI in Xcode 15 Beta, the code as you can see below. However, my current goal is to have the audio player automatically play the next track once the preceding song has finished playing (without requiring a trigger from user). I've seen some people use AVAudioPlayerDelegate, but it makes me more confused, since I'm using Struct and the AVAudioPlayerDelegate cannot conform to class protocol. Since I'm still learning SwiftUI, which one is the best fit in this case?


import SwiftUI
import AVFoundation

struct Song: Identifiable {
    let id = UUID()
    let name: String
    let composer: String
    let audioFilename: String
}

struct AudioPlayerView: View {  
    @State var audioPlayer: AVAudioPlayer!
    @State var progress: CGFloat = 0.0
    @State private var playing: Bool = false
    @State var duration: Double = 0.0
    @State var formattedDuration: String = ""
    @State var formattedProgress: String = "00:00"
    @State var currentSong : Song?
    @State var currentIndex : Int?
    
    let songs: [Song] = [
        Song(name: "1", composer: "Composer 1", audioFilename: "1.wav"),
        Song(name: "2", composer: "Composer 2", audioFilename: "2.wav"),
        Song(name: "3", composer: "Composer 3", audioFilename: "3.wav"),
        Song(name: "4", composer: "Composer 4", audioFilename: "4.wav"),
        Song(name: "5", composer: "Composer 5", audioFilename: "5.wav"),
    ]
    
    var body: some View {
        HStack {
            Text(currentSong?.name ?? "Audio Player")
            Text(" - ")
            Text(currentSong?.composer ?? "Composer")
        }
        .fontWeight(.semibold)
        .font(.title2)
        .multilineTextAlignment(.center)
        .minimumScaleFactor(0.75)
        
        // the progress bar
        HStack {
            Text(formattedProgress)
                .font(.caption.monospacedDigit())
            
            // this is a dynamic length progress bar
            GeometryReader { gr in
                Capsule()
                    .stroke(Color.black, lineWidth: 2)
                    .background(
                        Capsule()
                            .foregroundColor(.black)
                            .frame(width: gr.size.width * progress,
                                   height: 8), alignment: .leading)
            }
            .frame( height: 8)
            
            Text(formattedDuration)
                .font(.caption.monospacedDigit())
        }
        .padding()
        .frame(height: 50, alignment: .center)
        
        // the control buttons
        HStack(alignment: .center, spacing: 20) {
            
            Spacer()
            
            Button(action: {
                if audioPlayer.isPlaying {
                    playing = false
                    self.audioPlayer.pause()
                } else if !audioPlayer.isPlaying {
                    playing = true
                    self.audioPlayer.play()
                }
            }) {
                Image(systemName: playing ?
                      "pause.circle.fill" : "play.circle.fill")
                .font(.title)
                .imageScale(.large)
                .tint(.black)
            }
            
            Button(action: {
                shuffle()
            }) {
                Image(systemName: "shuffle")
                    .font(.title)
                    .imageScale(.medium)
                    .tint(.black)
            }
            
            Spacer()
        }
        .onAppear {
            let randomSong = songs.randomElement()!
            initialiseAudioPlayer(for: randomSong)
        }
    }
    
    func initialiseAudioPlayer(for song: Song) {
        let formatter = DateComponentsFormatter()
        formatter.allowedUnits = [.minute, .second]
        formatter.unitsStyle = .positional
        formatter.zeroFormattingBehavior = [ .pad ]
        
        // init audioPlayer
        let path = Bundle.main.path(forResource: song.audioFilename, ofType: nil)!
        self.audioPlayer = try! AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
        self.audioPlayer.play()
        currentSong = song
        playing = true
        
        //The formattedDuration is the string to display
        formattedDuration = formatter.string(from:TimeInterval(self.audioPlayer.duration))!
        duration = self.audioPlayer.duration
        
        Timer.scheduledTimer(withTimeInterval: 0.1, repeats: true) { _ in
            if !audioPlayer.isPlaying {
                playing = false
            }
            progress = CGFloat(audioPlayer.currentTime / audioPlayer.duration)
            formattedProgress = formatter.string(from: TimeInterval(self.audioPlayer.currentTime))!
        }
    }
    
    func shuffle() {
        if let currentSong = currentSong {
            let songs = songs.filter { $0.id != currentSong.id}
            if let randomSong = songs.randomElement() {
                audioPlayer.stop()
                initialiseAudioPlayer(for: randomSong)
            }
        }
    }
    
}

#Preview {
    AudioPlayerView()
}
isherwood
  • 58,414
  • 16
  • 114
  • 157

1 Answers1

1
  1. Create a class that conforms to ObservableObject and AVAudioPlayerDelegate to manage audio playback and notify your view when the song ends:
import AVFoundation
import Combine

class AudioPlayerManager: NSObject, ObservableObject, AVAudioPlayerDelegate {
    static let shared = AudioPlayerManager()
    
    private var audioPlayer: AVAudioPlayer?
    private var cancellables: Set<AnyCancellable> = []
    
    @Published var currentSong: Song?
    
    private override init() {
        super.init()
    }
    
    func play(song: Song) {
        guard let path = Bundle.main.path(forResource: song.audioFilename, ofType: nil) else {
            return
        }
        
        do {
            audioPlayer?.stop()
            audioPlayer = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: path))
            audioPlayer?.delegate = self
            audioPlayer?.play()
            currentSong = song
        } catch {
            print("Error playing audio: \(error)")
        }
    }
    
    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        if flag, let currentSong = currentSong,
           let index = AudioPlayerView.songs.firstIndex(where: { $0.id == currentSong.id }),
           index < AudioPlayerView.songs.count - 1 {
            play(song: AudioPlayerView.songs[index + 1])
        }
    }
}
  1. Update your AudioPlayerView to use the AudioPlayerManager and its methods to control audio playback:
struct AudioPlayerView: View {
    // ... Your existing properties
    
    @ObservedObject private var audioPlayerManager = AudioPlayerManager.shared
    
    // ... Your existing code
    
    var body: some View {
        // ... Your existing UI
        
        HStack(alignment: .center, spacing: 20) {
            Spacer()
            
            Button(action: {
                if audioPlayerManager.currentSong != nil {
                    audioPlayerManager.play(song: audioPlayerManager.currentSong!)
                } else {
                    let randomSong = songs.randomElement()!
                    audioPlayerManager.play(song: randomSong)
                }
            }) {
                Image(systemName: audioPlayerManager.isPlaying ? "pause.circle.fill" : "play.circle.fill")
                    .font(.title)
                    .imageScale(.large)
                    .tint(.black)
            }
            
            // ... Your existing shuffle button and Spacer
            
        }
        .onAppear {
            let randomSong = songs.randomElement()!
            audioPlayerManager.play(song: randomSong)
        }
    }
    
    // ... Your existing functions
    
    static let songs: [Song] = [
        Song(name: "1", composer: "Composer 1", audioFilename: "1.wav"),
        Song(name: "2", composer: "Composer 2", audioFilename: "2.wav"),
        Song(name: "3", composer: "Composer 3", audioFilename: "3.wav"),
        Song(name: "4", composer: "Composer 4", audioFilename: "4.wav"),
        Song(name: "5", composer: "Composer 5", audioFilename: "5.wav"),
    ]
}

Make sure you adapt this code to your specific needs and test it thoroughly to ensure it works as expected.

vlanto
  • 457
  • 6