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!