Reputation: 4329
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
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