Wolverine
Wolverine

Reputation: 4329

Create Audio Player cell with slider in SwiftUI

I am trying to create a view like Audio message view as shows in WhatsApp.

Player code.

struct AudioPlayerControlsView: View {
    private enum PlaybackState: Int {
        case waitingForSelection
        case buffering
        case playing
    }
    
    let player: AVPlayer
    let timeObserver: PlayerTimeObserver
    let durationObserver: PlayerDurationObserver
    let itemObserver: PlayerItemObserver
    @State private var currentTime: TimeInterval = 0
    @State private var currentDuration: TimeInterval = 0
    @State private var state = PlaybackState.waitingForSelection
    
    var body: some View {
        VStack {
            if state == .waitingForSelection {
                Text("Select a song below")
            } else if state == .buffering {
                Text("Buffering...")
            } else {
                Text("Great choice!")
            }
            
            Slider(value: $currentTime,
                   in: 0...currentDuration,
                   onEditingChanged: sliderEditingChanged,
                   minimumValueLabel: Text("\(Utility.formatSecondsToHMS(currentTime))"),
                   maximumValueLabel: Text("\(Utility.formatSecondsToHMS(currentDuration))")) {
                    // I have no idea in what scenario this View is shown...
                    Text("seek/progress slider")
            }
            .disabled(state != .playing)
        }
        .padding()
        // Listen out for the time observer publishing changes to the player's time
        .onReceive(timeObserver.publisher) { time in
            // Update the local var
            self.currentTime = time
            // And flag that we've started playback
            if time > 0 {
                self.state = .playing
            }
        }
        // Listen out for the duration observer publishing changes to the player's item duration
        .onReceive(durationObserver.publisher) { duration in
            // Update the local var
            self.currentDuration = duration
        }
        // Listen out for the item observer publishing a change to whether the player has an item
        .onReceive(itemObserver.publisher) { hasItem in
            self.state = hasItem ? .buffering : .waitingForSelection
            self.currentTime = 0
            self.currentDuration = 0
        }
        // TODO the below could replace the above but causes a crash
//        // Listen out for the player's item changing
//        .onReceive(player.publisher(for: \.currentItem)) { item in
//            self.state = item != nil ? .buffering : .waitingForSelection
//            self.currentTime = 0
//            self.currentDuration = 0
//        }
    }
    
    // MARK: Private functions
    private func sliderEditingChanged(editingStarted: Bool) {
        if editingStarted {
            // Tell the PlayerTimeObserver to stop publishing updates while the user is interacting
            // with the slider (otherwise it would keep jumping from where they've moved it to, back
            // to where the player is currently at)
            timeObserver.pause(true)
        }
        else {
            // Editing finished, start the seek
            state = .buffering
            let targetTime = CMTime(seconds: currentTime,
                                    preferredTimescale: 600)
            player.seek(to: targetTime) { _ in
                // Now the (async) seek is completed, resume normal operation
                self.timeObserver.pause(false)
                self.state = .playing
            }
        }
    }
}

Now I am trying to add it to cells of list in SwiftUI.

struct Audiomessage : Identifiable {
    var id: UUID
    var url : String
    var isPlaying : Bool
}

struct ChatView : View {
    let player = AVPlayer()
    private let items = [ Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", isPlaying: false),Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3", isPlaying: false)]
    
    
    var body: some View {
        VStack{
            LazyVStack {
                ForEach(items) { reason in
                    AudioPlayerControlsView(player: player,
                                            timeObserver: PlayerTimeObserver(player: player),
                                            durationObserver: PlayerDurationObserver(player: player),
                                            itemObserver: PlayerItemObserver(player: player)).onTapGesture {
                        guard let url = URL(string: reason.url) else {
                            return
                        }
                        let playerItem = AVPlayerItem(url: url)
                        self.player.replaceCurrentItem(with: playerItem)
                        self.player.play()

                    }

                }
            }
            .padding(.bottom,37)
            .padding()
        }
        .padding(.horizontal,10)
    }
}

The out for this is like below:

Both starts playing on Tap. I want to play one song at a time. How Can I achieve this in SwiftUI?

Upvotes: 3

Views: 3941

Answers (1)

Dad
Dad

Reputation: 6488

You are ending up with multiple AVPlayers allocated because the ContentView struct is recreated often (this is the case for all SwiftUI views) and so a new AVPlayer is allocated each time that happens in this code:

struct ChatView : View {
    let player = AVPlayer()

Allocate the AVPlayer once outside of the ChatView and pass it in so there is only a single AVPlayer.

Even something as simple as:

let player = AVPlayer()

struct ContentView: View {
    private let items = [ Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", isPlaying: false),
                          Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3", isPlaying: false)]

var body: some View { 
   // ... rest of your code here

works in my test.


Here's the full code of my test (I stubbed out the classes you didn't provide and commented out some code for updating the values because that required the classes you didn't provide)

In the simulator this plays the music and switches between the tracks as you seem to desire.

//
//  AudioPlayerControlsView.swift
//  TestForSOQuestion
//
//

import SwiftUI
import AVKit

enum Utility {
  static func formatSecondsToHMS(_ seconds: TimeInterval) -> String {
    let secondsInt:Int = Int(seconds.rounded(.towardZero))
    
    let dh: Int = (secondsInt/3600)
    let dm: Int = (secondsInt - (dh*3600))/60
    let ds: Int = secondsInt - (dh*3600) - (dm*60)
    
    let hs = "\(dh > 0 ? "\(dh):" : "")"
    let ms = "\(dm<10 ? "0" : "")\(dm):"
    let s = "\(ds<10 ? "0" : "")\(ds)"
    
    return hs + ms + s
  }
}

struct AudioPlayerControlsView: View {
  private enum PlaybackState: Int {
    case waitingForSelection
    case buffering
    case playing
  }
  
  let player: AVPlayer
  let timeObserver: PlayerTimeObserver
  let durationObserver: PlayerDurationObserver
  let itemObserver: PlayerItemObserver
  @State private var currentTime: TimeInterval = 0
  @State private var currentDuration: TimeInterval = 0
  @State private var state = PlaybackState.waitingForSelection
  
  var body: some View {
    VStack {
      if state == .waitingForSelection {
        Text("Select a song below")
      } else if state == .buffering {
        Text("Buffering...")
      } else {
        Text("Great choice!")
      }
      
      Slider(value: $currentTime,
           in: 0...currentDuration,
           onEditingChanged: sliderEditingChanged,
           minimumValueLabel: Text("\(Utility.formatSecondsToHMS(currentTime))"),
           maximumValueLabel: Text("\(Utility.formatSecondsToHMS(currentDuration))")) {
          // I have no idea in what scenario this View is shown...
          Text("seek/progress slider")
      }
      .disabled(state != .playing)
    }
    .padding()
  }
  
  // MARK: Private functions
  private func sliderEditingChanged(editingStarted: Bool) {
    if editingStarted {
      // Tell the PlayerTimeObserver to stop publishing updates while the user is interacting
      // with the slider (otherwise it would keep jumping from where they've moved it to, back
      // to where the player is currently at)
      timeObserver.pause(true)
    }
    else {
      // Editing finished, start the seek
      state = .buffering
      let targetTime = CMTime(seconds: currentTime,
                  preferredTimescale: 600)
      player.seek(to: targetTime) { _ in
        // Now the (async) seek is completed, resume normal operation
        self.timeObserver.pause(false)
        self.state = .playing
      }
    }
  }
}

struct AudioPlayerControlsView_Previews: PreviewProvider {
  static let previewPlayer: AVPlayer = AVPlayer()
  
    static var previews: some View {
    AudioPlayerControlsView(player: previewPlayer,
                timeObserver: PlayerTimeObserver(player: player),
                durationObserver: PlayerDurationObserver(player: player),
                itemObserver: PlayerItemObserver(player: player))
    }
}

And the ContentView.swift

//
//  ContentView.swift
//  TestForSOQuestion
//

import SwiftUI
import AVKit
import Combine


class PlayerTimeObserver: ObservableObject {
  let player: AVPlayer
  init(player: AVPlayer) {
    self.player = player
  }
  
  func pause(_ flag: Bool) {}
}

class PlayerDurationObserver {
  let player: AVPlayer
  
  init(player: AVPlayer) {
    self.player = player
  }
}

class PlayerItemObserver {
  let player: AVPlayer
  init(player: AVPlayer) {
    self.player = player
  }
}


struct Audiomessage : Identifiable {
  var id: UUID
  var url : String
  var isPlaying : Bool
}

let player = AVPlayer()

struct ContentView: View {
  private let items = [ Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3", isPlaying: false),
              Audiomessage(id: UUID(), url: "https://www.soundhelix.com/examples/mp3/SoundHelix-Song-2.mp3", isPlaying: false)]
  
  var body: some View {
    VStack{
      LazyVStack {
        ForEach(items) { reason in
          AudioPlayerControlsView(player: player,
                      timeObserver: PlayerTimeObserver(player: player),
                      durationObserver: PlayerDurationObserver(player: player),
                      itemObserver: PlayerItemObserver(player: player)).onTapGesture {
            guard let url = URL(string: reason.url) else {
              return
            }
            let playerItem = AVPlayerItem(url: url)
            player.replaceCurrentItem(with: playerItem)
            player.play()
          }

        }
      }
      .padding(.bottom,37)
      .padding()
    }
    .padding(.horizontal,10)
  }
}

struct ContentView_Previews: PreviewProvider {
    static var previews: some View {
        ContentView()
    }
}

Create a new iOS app project, replace the template's ContentView with the implementation above and add a new file AudioPlayerControlsView.swift and put the contents provided above for that into it. Run and click on one of the rows and then on the other row and notice the music playing and switching from one to the other.

There seems to be a bit of a delay when clicking initially, but I think that's the loading of the music url. You're going to have to figure out some UX to make it clear the click action was received and the user needs to wait. Once you've clicked on each row and given it time to load the data seems to load more quickly or something.

Cheers.

Upvotes: 2

Related Questions