如何在 SwiftUI 中控制 AVPlayer?
How to control AVPlayer in SwiftUI?
在我的新 SwiftUI 项目中,我有一个用于从 url 流式传输音乐的 AVPlayer。现在我需要通过滑块控制当前播放曲目的时间和音量,这里是设计部分:
现在我可以使用 UserData final class 及其 @Published 变量来控制播放器,例如 isPlaying:
final class UserData: ObservableObject {
// ...
@Published var player: AVPlayer? = nil
@Published var isPlaying: Bool = false
//...
func playPausePlayer(withSong song: Song, forPlaylist playlist: [Song]?) {
//...
if isPlaying {
player?.pause()
} else {
player?.play()
}
isPlaying.toggle()
}
}
很高兴知道这部分是否有更好的决定
问题是属性 currentTime、duration 我只能从 player
或 player?.currentItem
获取,所以我不能像这样制作滑块:
@EnvironmentObject var userData: UserData
// ...
Slider(value: userData.player?.currentItem?.currentTime()!, in: 0...userData.player?.currentItem?.duration as! Double, step: 1)
我如何控制这些东西?
我没有找到任何解决方案,所以尝试自己解决。学了一点Combine
框架,继承了AVPlayer
class并在协议ObservableObject
下签名,使用了KVO
。可能这不是最好的解决方案,但它确实有效,希望有人能给我建议,以便将来改进代码。以下是一些代码片段:
import Foundation
import AVKit
import Combine
final class AudioPlayer: AVPlayer, ObservableObject {
@Published var currentTimeInSeconds: Double = 0.0
private var timeObserverToken: Any?
// ... some other staff
// MARK: Publishers
var currentTimeInSecondsPass: AnyPublisher<Double, Never> {
return $currentTimeInSeconds
.eraseToAnyPublisher()
}
// in init() method I add observer, which update time in seconds
override init() {
super.init()
registerObserves()
}
private func registerObserves() {
let interval = CMTime(seconds: 1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObserverToken = self.addPeriodicTimeObserver(forInterval: interval, queue: .main) {
[weak self] _ in
self?.currentTimeInSeconds = self?.currentTime().seconds ?? 0.0
}
}
// func for rewind song time
func rewindTime(to seconds: Double) {
let timeCM = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
self.seek(to: timeCM)
}
// sure I need to remove observer:
deinit {
if let token = timeObserverToken {
self.removeTimeObserver(token)
timeObserverToken = nil
}
}
}
// simplified slider
import SwiftUI
struct PlayerSlider: View {
@EnvironmentObject var player: AudioPlayer
@State private var currentPlayerTime: Double = 0.0
var song: Song // struct which contains the song length as Int
var body: some View {
HStack {
GeometryReader { geometry in
Slider(value: self.$currentPlayerTime, in: 0.0...Double(self.song.songLength))
.onReceive(self.player.currentTimeInSecondsPass) { _ in
// here I changed the value every second
self.currentPlayerTime = self.player.currentTimeInSeconds
}
// controlling rewind
.gesture(DragGesture(minimumDistance: 0)
.onChanged({ value in
let coefficient = abs(Double(self.song.songLength) / Double(geometry.size.width))
self.player.rewindTime(to: Double(value.location.x) * coefficient)
}))
}
.frame(height: 30)
}
}
}
VolumeView 更新
对于音量控制,我制作了新的 UIViewRepresentable
结构:
import SwiftUI
import UIKit
import MediaPlayer
struct MPVolumeViewRepresenter: UIViewRepresentable {
func makeUIView(context: Context) -> MPVolumeView {
let volumeView = MPVolumeView()
volumeView.showsRouteButton = false // TODO: 'showsRouteButton' was deprecated in iOS 13.0: Use AVRoutePickerView instead.
if let sliderView = volumeView.subviews.first as? UISlider {
// custom design colors
sliderView.minimumTrackTintColor = UIColor(red: 0.805, green: 0.813, blue: 0.837, alpha: 1)
sliderView.thumbTintColor = UIColor(red: 0.805, green: 0.813, blue: 0.837, alpha: 1)
sliderView.maximumTrackTintColor = UIColor(red: 0.906, green: 0.91, blue: 0.929, alpha: 1)
}
return volumeView
}
func updateUIView(_ uiView: MPVolumeView, context: UIViewRepresentableContext<MPVolumeViewRepresenter>) {
// nothing here. really, nothing
}
}
// and you can use it like:
struct VolumeView: View {
var body: some View {
HStack(alignment: .center) {
Image("volumeDown")
.renderingMode(.original)
.resizable()
.frame(width: 24, height: 24)
MPVolumeViewRepresenter()
.frame(height: 24)
.offset(y: 2) // centering
Image("volumeUp")
.renderingMode(.original)
.resizable()
.frame(width: 24, height: 24)
}.padding(.horizontal)
}
}
使用 'UIViewRepresentable' 创建一个 'MPVolumeView' 作为您的要求
import SwiftUI
import MediaPlayer
import UIKit
struct VolumeSlider: UIViewRepresentable {
func makeUIView(context: Context) -> MPVolumeView {
MPVolumeView(frame: .zero)
}
func updateUIView(_ view: MPVolumeView, context: Context) {}
}
然后在您的 'View' 文件中使用它,如下所示
VolumeSlider()
.frame(height: 40)
.padding(.horizontal)
我建议将 combine 注入到您的代码中。例如为了让滑块更新。
class MediaPlayer {
var player: AVPlayer!
var currentTimePublisher: PassthroughSubject<Double, Never> = .init()
var currentProgressPublisher: PassthroughSubject<Float, Never> = .init()
...
private func setupPeriodicObservation(for player: AVPlayer) {
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
playerPeriodicObserver = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] (time) in
guard let `self` = self else { return }
let progress = self.calculateProgress(currentTime: time.seconds)
currentProgressPublisher.send(progress)
currentTimePublisher.send(time.seconds)
}
}
private func calculateProgress(currentTime: Double) -> Float {
let duration = player.currentItem!.duration.seconds
return Float(currentTime / duration)
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func seek(to time: CMTime) {
player.seek(to: time)
}
}
class PlayerSliderViewModel: ObservableObject {
@Published currentTime: String
@Published progressValue: Float
var player: MediaPlayer!
var invalidateProgress: Bool = false
var subscriptions: Set<AnyCancellable> = .init()
func listenToProgress {
player.currentProgressPublisher.sink { [weak self] progress in
guard !invalidateProgress else { return }
self?.progressValue = progress
}.store(in: &subscriptions)
}
}
struct SliderView: View {
@ObservedObject var viewModel: PlayerSliderViewModel
init(player: MediaPlayer) {
viewModel = .init(player: player)
}
var body: some View {
Slider(value: $viewModel.progressValue, onEditingChanged: { didChange in
self.viewModel.invalidateProgress = didChange
if didChange {
self.viewModel.player.pause()
}
if !didChange {
let percentage = self.viewModel.progressValue
let time = self.convertFloatToCMTime(progress: percentage)
self.viewModel.player.seek(to: time)
self.viewModel.player.play()
}
})
}
func convertFloatToCMTime(progress: Float) -> CMTime {
...
}
}
你可以做类似的事情来获取当前时间。此外,我添加了使用滑块内置的 onEditingChanged 闭包搜索轨道的代码。
为了防止发布者在您移动滑块时将结果写入滑块,您必须停止发布值,这就是为什么我包含 invalidateProgress
布尔值
在我的新 SwiftUI 项目中,我有一个用于从 url 流式传输音乐的 AVPlayer。现在我需要通过滑块控制当前播放曲目的时间和音量,这里是设计部分:
现在我可以使用 UserData final class 及其 @Published 变量来控制播放器,例如 isPlaying:
final class UserData: ObservableObject {
// ...
@Published var player: AVPlayer? = nil
@Published var isPlaying: Bool = false
//...
func playPausePlayer(withSong song: Song, forPlaylist playlist: [Song]?) {
//...
if isPlaying {
player?.pause()
} else {
player?.play()
}
isPlaying.toggle()
}
}
很高兴知道这部分是否有更好的决定
问题是属性 currentTime、duration 我只能从 player
或 player?.currentItem
获取,所以我不能像这样制作滑块:
@EnvironmentObject var userData: UserData
// ...
Slider(value: userData.player?.currentItem?.currentTime()!, in: 0...userData.player?.currentItem?.duration as! Double, step: 1)
我如何控制这些东西?
我没有找到任何解决方案,所以尝试自己解决。学了一点Combine
框架,继承了AVPlayer
class并在协议ObservableObject
下签名,使用了KVO
。可能这不是最好的解决方案,但它确实有效,希望有人能给我建议,以便将来改进代码。以下是一些代码片段:
import Foundation
import AVKit
import Combine
final class AudioPlayer: AVPlayer, ObservableObject {
@Published var currentTimeInSeconds: Double = 0.0
private var timeObserverToken: Any?
// ... some other staff
// MARK: Publishers
var currentTimeInSecondsPass: AnyPublisher<Double, Never> {
return $currentTimeInSeconds
.eraseToAnyPublisher()
}
// in init() method I add observer, which update time in seconds
override init() {
super.init()
registerObserves()
}
private func registerObserves() {
let interval = CMTime(seconds: 1, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
timeObserverToken = self.addPeriodicTimeObserver(forInterval: interval, queue: .main) {
[weak self] _ in
self?.currentTimeInSeconds = self?.currentTime().seconds ?? 0.0
}
}
// func for rewind song time
func rewindTime(to seconds: Double) {
let timeCM = CMTime(seconds: seconds, preferredTimescale: CMTimeScale(NSEC_PER_SEC))
self.seek(to: timeCM)
}
// sure I need to remove observer:
deinit {
if let token = timeObserverToken {
self.removeTimeObserver(token)
timeObserverToken = nil
}
}
}
// simplified slider
import SwiftUI
struct PlayerSlider: View {
@EnvironmentObject var player: AudioPlayer
@State private var currentPlayerTime: Double = 0.0
var song: Song // struct which contains the song length as Int
var body: some View {
HStack {
GeometryReader { geometry in
Slider(value: self.$currentPlayerTime, in: 0.0...Double(self.song.songLength))
.onReceive(self.player.currentTimeInSecondsPass) { _ in
// here I changed the value every second
self.currentPlayerTime = self.player.currentTimeInSeconds
}
// controlling rewind
.gesture(DragGesture(minimumDistance: 0)
.onChanged({ value in
let coefficient = abs(Double(self.song.songLength) / Double(geometry.size.width))
self.player.rewindTime(to: Double(value.location.x) * coefficient)
}))
}
.frame(height: 30)
}
}
}
VolumeView 更新
对于音量控制,我制作了新的 UIViewRepresentable
结构:
import SwiftUI
import UIKit
import MediaPlayer
struct MPVolumeViewRepresenter: UIViewRepresentable {
func makeUIView(context: Context) -> MPVolumeView {
let volumeView = MPVolumeView()
volumeView.showsRouteButton = false // TODO: 'showsRouteButton' was deprecated in iOS 13.0: Use AVRoutePickerView instead.
if let sliderView = volumeView.subviews.first as? UISlider {
// custom design colors
sliderView.minimumTrackTintColor = UIColor(red: 0.805, green: 0.813, blue: 0.837, alpha: 1)
sliderView.thumbTintColor = UIColor(red: 0.805, green: 0.813, blue: 0.837, alpha: 1)
sliderView.maximumTrackTintColor = UIColor(red: 0.906, green: 0.91, blue: 0.929, alpha: 1)
}
return volumeView
}
func updateUIView(_ uiView: MPVolumeView, context: UIViewRepresentableContext<MPVolumeViewRepresenter>) {
// nothing here. really, nothing
}
}
// and you can use it like:
struct VolumeView: View {
var body: some View {
HStack(alignment: .center) {
Image("volumeDown")
.renderingMode(.original)
.resizable()
.frame(width: 24, height: 24)
MPVolumeViewRepresenter()
.frame(height: 24)
.offset(y: 2) // centering
Image("volumeUp")
.renderingMode(.original)
.resizable()
.frame(width: 24, height: 24)
}.padding(.horizontal)
}
}
使用 'UIViewRepresentable' 创建一个 'MPVolumeView' 作为您的要求
import SwiftUI
import MediaPlayer
import UIKit
struct VolumeSlider: UIViewRepresentable {
func makeUIView(context: Context) -> MPVolumeView {
MPVolumeView(frame: .zero)
}
func updateUIView(_ view: MPVolumeView, context: Context) {}
}
然后在您的 'View' 文件中使用它,如下所示
VolumeSlider()
.frame(height: 40)
.padding(.horizontal)
我建议将 combine 注入到您的代码中。例如为了让滑块更新。
class MediaPlayer {
var player: AVPlayer!
var currentTimePublisher: PassthroughSubject<Double, Never> = .init()
var currentProgressPublisher: PassthroughSubject<Float, Never> = .init()
...
private func setupPeriodicObservation(for player: AVPlayer) {
let timeScale = CMTimeScale(NSEC_PER_SEC)
let time = CMTime(seconds: 0.5, preferredTimescale: timeScale)
playerPeriodicObserver = player.addPeriodicTimeObserver(forInterval: time, queue: .main) { [weak self] (time) in
guard let `self` = self else { return }
let progress = self.calculateProgress(currentTime: time.seconds)
currentProgressPublisher.send(progress)
currentTimePublisher.send(time.seconds)
}
}
private func calculateProgress(currentTime: Double) -> Float {
let duration = player.currentItem!.duration.seconds
return Float(currentTime / duration)
}
func play() {
player.play()
}
func pause() {
player.pause()
}
func seek(to time: CMTime) {
player.seek(to: time)
}
}
class PlayerSliderViewModel: ObservableObject {
@Published currentTime: String
@Published progressValue: Float
var player: MediaPlayer!
var invalidateProgress: Bool = false
var subscriptions: Set<AnyCancellable> = .init()
func listenToProgress {
player.currentProgressPublisher.sink { [weak self] progress in
guard !invalidateProgress else { return }
self?.progressValue = progress
}.store(in: &subscriptions)
}
}
struct SliderView: View {
@ObservedObject var viewModel: PlayerSliderViewModel
init(player: MediaPlayer) {
viewModel = .init(player: player)
}
var body: some View {
Slider(value: $viewModel.progressValue, onEditingChanged: { didChange in
self.viewModel.invalidateProgress = didChange
if didChange {
self.viewModel.player.pause()
}
if !didChange {
let percentage = self.viewModel.progressValue
let time = self.convertFloatToCMTime(progress: percentage)
self.viewModel.player.seek(to: time)
self.viewModel.player.play()
}
})
}
func convertFloatToCMTime(progress: Float) -> CMTime {
...
}
}
你可以做类似的事情来获取当前时间。此外,我添加了使用滑块内置的 onEditingChanged 闭包搜索轨道的代码。
为了防止发布者在您移动滑块时将结果写入滑块,您必须停止发布值,这就是为什么我包含 invalidateProgress
布尔值