swift - 在 SwiftUI 中使用 slider 创建音频播放器单元格

标签 swift swiftui avplayer combine swiftui-list

我正在尝试创建一个 View ,如 WhatsApp 中显示的音频消息 View 。

播放器代码。

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
            }
        }
    }
}

现在我正在尝试将它添加到 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)
    }
}

输出如下:

两者都在 Tap 上开始播放。我想一次播放一首歌。我如何在 SwiftUI 中实现这一目标?

最佳答案

您最终分配了多个 AVPlayer,因为 ContentView 结构经常被重新创建(所有 SwiftUI View 都是这种情况),因此一个新的 AVPlayer 在此代码中每次发生时都会分配:

struct ChatView : View {
    let player = AVPlayer()

在 ChatView 外部分配 AVPlayer 并将其传入,因此只有一个 AVPlayer。

甚至像这样简单的东西:

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

在我的测试中有效。


这是我测试的完整代码(我删除了您未提供的类并注释掉了一些用于更新值的代码,因为这需要您未提供的类)

在模拟器中,它会播放音乐并根据您的需要在轨道之间切换。

//
//  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))
    }
}

和 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()
    }
}

创建一个新的 iOS 应用程序项目,用上面的实现替换模板的 ContentView 并添加一个新文件 AudioPlayerControlsView.swift 并将上面提供的内容放入其中。运行并单击其中一行,然后单击另一行,注意音乐正在播放并从一行切换到另一行。

最初点击时似乎有点延迟,但我认为这是音乐 url 的加载。您将不得不弄清楚一些 UX 以明确收到点击操作并且用户需要等待。单击每一行并给它加载数据的时间后,数据似乎加载得更快了。

干杯。

关于swift - 在 SwiftUI 中使用 slider 创建音频播放器单元格,我们在Stack Overflow上找到一个类似的问题: https://stackoverflow.com/questions/70498732/

相关文章:

ios - 播放长音乐时,AVPlayer 中的搜索时间更快

arrays - 向下转换多个协议(protocol) Array<protocol<P1, P2>> 到 Array<P1>

xcode - 快速的 UIPopoverPresentationController

swiftui - iOS 17 中使用 Observable 相当于 `onReceive` 的方法

swiftui - 如何检测核心数据对象属性的变化

ios - 通过 HTTP 流式传输 AAC 音频时的 AVPlayer 延迟

ios - 在注册 Parse 后端(Swift)获取 Facebook 个人资料图片

ios - 如何在 Swift 中访问 iOS 私有(private) API?

swift - Xcode 12和SwiftUI : Cannot preview in this file — Failed to update preview

swift - AVPlayerItem 的 canPlaySlowForward 属性从未被调用