Search code examples
swiftswiftuiavaudioplayer

How to kill background audio from view that was already dismissed


The main view of my app is a list of songs.

import SwiftUI

struct ContentView: View {
        var body: some View {
        NavigationView {
            List (fileURLs, id: \.path) { song in
                NavigationLink(destination: PlayView(songURL: song)) {
                    SongRow(songURL: song)
                }
            }
            .padding()
            .navigationBarTitle(Text("Songs"))
                .navigationBarItems(trailing: addButton)
        }

    }

}

You tap on a song and it navigates to a PlayView, which instantiates an audio player:

import SwiftUI
import AVKit

struct PlayView: View {

    var songURL: URL

    @State var player : AVAudioPlayer!

    var body: some View {
        VStack {
            Button(action: {
                self.playPause()
            }) {
                Text("Play/Pause")
            }
        }.onAppear {
            self.player = try! AVAudioPlayer(contentsOf: self.songURL)
            self.playPause()
        }
    }

    func playPause() {
        if self.player.isPlaying {
            self.player.pause()
        } else {
            self.player.play()
        }
    }
}

The problem is when I navigate to another song's PlayView, it plays the audio on top of the first song (i.e. both play simultaneously). How should I kill the first song when the second song plays?


Solution

  • In my project I'm using only one instance of final class everywhere in the app. I think the problem in you case is that you're creating lots of instances of AVPlayer in children views (PlayView) but can control only one of them. Here is example of my solution:

    import AVKit
    import Combine
    
    final class AudioPlayer: AVPlayer, ObservableObject {
    
        @Published var currentPlaylist: [Song]? = nil
        @Published var currentSong: Song? = nil
        // ...
        // MARK: player controls
        func playPausePlayer(withSong song: Song, forPlaylist playlist: [Song]? = nil) {
    
            setCurrentPlaylist(newPlaylist: playlist, newSong: song)
            if currentSong == song {
                continueOrPauseCurrentSong()
            } else {
                changeSong(with: song)
            }
    
            self.controlPanel?.updateNowPlaying()
    
        }
        // simplified func (I leave only what you need)
         private func changeSong(with newSong: Song) {
    
            if let url = URL.init(string: newSong.url) {
    
                let nextPlayerItem = AVPlayerItem.init(url: url)
                self.replaceCurrentItem(with: nextPlayerItem) // here you change the song
                self.play()
    
                currentSong = newSong
            } else {...}
        }
        // ...
    }
    
    

    where Song is just a struct with song data, like:

    struct Song: Codable, Equatable, Identifiable, Hashable {
        let id: String
        let url: String
        // and so on
    }
    

    At the SceneDelegate func I set this AudioPlayer as an environmentObject for using it in HomeView() and all it's children views. So you can play/pause your player or change the song (with .replaceCurrentItem(with: ... method) from everywhere. In your case it should be something like this:

    class SceneDelegate: UIResponder, UIWindowSceneDelegate {
    
        var window: UIWindow?
    
    
        func scene(_ scene: UIScene, willConnectTo session: UISceneSession, options connectionOptions: UIScene.ConnectionOptions) {
        // ...
        let homeView = ContentView()
                .environment(\.managedObjectContext, context)
                .environmentObject(AudioPlayer()) // here I set only one player for main window, so all the children will use it too
    
            // Use a UIHostingController as window root view controller.
            if let windowScene = scene as? UIWindowScene {
                let window = UIWindow(windowScene: windowScene)
                window.rootViewController = UIHostingController(rootView: homeView)
                self.window = window
                window.makeKeyAndVisible()
            }
        }
    }
    
    // Based on your code snippet:
    struct ContentView: View {
    
            @EnvironmentObject var player: AudioPlayer // add this for main view
            var body: some View {
            NavigationView {
                List (fileURLs, id: \.path) { song in
                    NavigationLink(destination: PlayView(songURL: song)) {
                        SongRow(songURL: song)
                    }
                }
                .padding()
                .navigationBarTitle(Text("Songs"))
                    .navigationBarItems(trailing: addButton)
            }
    
        }
    
    }
    
    struct PlayView: View {
    
        @EnvironmentObject var player: AudioPlayer // and for child view (they all will have one object
        var songURL: URL
    
        var body: some View {
            VStack {
                Button(action: {
                     self.playPause()
                }) {
                    Text("Play/Pause")
                }
            }.onAppear {
                // set or change currentItem of player
            }
        }
    
        func playPause() {
            if self.player.isPlaying {
                self.player.pause()
            } else {
                self.player.play()
            }
        }
    }
    

    P.S. if I am wrong with the assumption that several instances of AVPlayer may exist please tell me in comments