BLACK FRIDAY: Save 50% on all my Swift books and bundles! >>

Stopping audio playblack when another disclosure group is opened in voice recorder app

Forums > SwiftUI

Hi all,

I'm working on a voice recorder app using SwiftUI. I currently have the player interface in a DisclosureGroup, as so: https://imgur.com/a/GmLvmMh. The audio stops playback when you close the current DisclosureGroup, and also only one DisclosureGroup can be opened at a time. However, I can't make it so that the audio stops playback when opening another DisclosureGroup.

The code for my player is here:

import Foundation
import AVFoundation
import SwiftUI
import Combine

class Player: NSObject, ObservableObject, AVAudioPlayerDelegate {
    var player: AVAudioPlayer?
    let objectWillChange = PassthroughSubject<Void, Never>()

    var isPlaying = false {
        didSet {
            objectWillChange.send()
        }
    }

    @Published var currentTime: TimeInterval = 0
    private var timer: AnyCancellable?

    init(soundURL: URL) throws {
        super.init()
        if FileManager().fileExists(atPath: soundURL.path) {
            do {
                self.player = try AVAudioPlayer(contentsOf: soundURL)
                player?.prepareToPlay()
                self.player?.delegate = self
            } catch {
                throw Errors.FailedToPlayURL
            }
        } else {
            print("URL not valid!")
        }
    }

    func play() {
        self.player?.play()
        isPlaying = true
        startTimer()
    }

    func pause() {
        if isPlaying {
            self.player?.pause()
            isPlaying = false
            stopTimer()
        }
    }

    func stop() {
        player?.stop()
        isPlaying = false
        resetPlayback()
    }

    func seek(to time: TimeInterval) {
        player?.currentTime = time
        currentTime = time
    }

    var duration: TimeInterval {
        player?.duration ?? 0
    }

    private func startTimer() {
        timer = Timer.publish(every: 0.1, on: .main, in: .common)
            .autoconnect()
            .sink { [weak self] _ in
                guard let self = self else { return }
                self.currentTime = self.player?.currentTime ?? 0
                self.objectWillChange.send()
            }
    }

    private func stopTimer() {
        timer?.cancel()
        timer = nil
    }

    func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) {
        if flag {
            isPlaying = false
            stopTimer()
            resetPlayback()
        }
    }

    private func resetPlayback() {
        currentTime = 0
        player?.currentTime = 0
        objectWillChange.send()
    }
}

The code for my PlayerViewModel is here:

import Foundation
import Combine

class PlayerViewModel: ObservableObject {
    @Published var currentTime: TimeInterval = 0
    var player: Player
    private var cancellables = Set<AnyCancellable>()
    private var seekingSubject = PassthroughSubject<TimeInterval, Never>()

    init(player: Player) {
        self.player = player
        self.player.objectWillChange
            .sink { [weak self] in
                self?.currentTime = self?.player.currentTime ?? 0
            }
            .store(in: &cancellables)

        seekingSubject
            .throttle(for: .milliseconds(100), scheduler: RunLoop.main, latest: true)
            .sink { [weak self] time in
                self?.player.seek(to: time)
            }
            .store(in: &cancellables)
    }

    func play() {
        player.play()
    }

    func pause() {
        player.pause()
    }

    func stop() {
        player.stop()
    }

    func seek(to time: TimeInterval) {
        seekingSubject.send(time)
    }

    var duration: TimeInterval {
        player.duration
    }
}

Finally, the code for my PlayerView is here. Currently, the way I'm detecting a disclosure group is opened is by the URL it's bound to. This way when a disclosure group is opened, opening another one closes the current one:

import SwiftUI
import AVFoundation

struct PlayerView: View {
    @State var soundURL: URL
    @Binding var openedGroup: URL?
    @State private var isOpened: Bool = false

    @StateObject private var viewModel: PlayerViewModel
    @State private var sliderValue: TimeInterval = 0

    init(soundURL: URL, openedGroup: Binding<URL?>) {
        self._soundURL = State(initialValue: soundURL)
        self._openedGroup = openedGroup
        let player = try? Player(soundURL: soundURL)
        self._viewModel = StateObject(wrappedValue: PlayerViewModel(player: player!))
    }

    var body: some View {
        DisclosureGroup(isExpanded: Binding(
            get: { self.openedGroup == self.soundURL },
            set: { newValue in
                if newValue {
                    self.openedGroup = self.soundURL
                } else if self.openedGroup == self.soundURL {
                    self.openedGroup = nil
                    viewModel.stop()
                }
            }
        )) {
            VStack {
                Slider(value: $sliderValue, in: 0...viewModel.duration, onEditingChanged: { editing in
                    if editing {
                        viewModel.pause()
                    } else {
                        viewModel.seek(to: sliderValue)
                        viewModel.play()
                    }
                })
                .padding()
                .onChange(of: viewModel.currentTime) {
                    sliderValue = viewModel.currentTime
                }

                HStack {
                    Text(timeString(from: viewModel.currentTime))
                    Spacer()
                    Text(timeString(from: viewModel.duration))
                }
                .padding(.horizontal)

                HStack {
                    Spacer()
                    Image(systemName: viewModel.player.isPlaying ? "pause.fill" : "play.fill")
                        .onTapGesture {
                            if viewModel.player.isPlaying {
                                viewModel.pause()
                            } else {
                                viewModel.play()
                            }
                        }
                    Spacer()
                }
                Spacer()
                FileNameButtonView(soundURL: soundURL)
            }
        } label: {
            Text(soundURL.lastPathComponent)
        }
    }

    private func timeString(from timeInterval: TimeInterval) -> String {
        let minutes = Int(timeInterval) / 60
        let seconds = Int(timeInterval) % 60
        return String(format: "%02d:%02d", minutes, seconds)
    }
}

If you want to take a look at the full repo, it is here: https://github.com/aabagdi/MemoMan/. Thanks for any help!

   

Save 50% in my WWDC sale.

SAVE 50% All our books and bundles are half price for Black Friday, so you can take your Swift knowledge further without spending big! Get the Swift Power Pack to build your iOS career faster, get the Swift Platform Pack to builds apps for macOS, watchOS, and beyond, or get the Swift Plus Pack to learn advanced design patterns, testing skills, and more.

Save 50% on all our books and bundles!

Reply to this topic…

You need to create an account or log in to reply.

All interactions here are governed by our code of conduct.

 
Unknown user

You are not logged in

Log in or create account
 

Link copied to your pasteboard.