3

In my new SwiftUI project I have an AVPlayer for streaming music from url. Now I need to control the current time of playing track and the volume through sliders, here is the part of design:

enter image description here

now I can control the player with UserData final class and its @Published vars, like 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()
    }

}

glad to know if there is better decision for this part

The problem is that properties currentTime, duration I can take only from player or player?.currentItem, so I can't make slider like this:

@EnvironmentObject var userData: UserData
// ...
Slider(value: userData.player?.currentItem?.currentTime()!, in: 0...userData.player?.currentItem?.duration as! Double, step: 1)

How can I control these things?

Hrabovskyi Oleksandr
  • 3,070
  • 2
  • 17
  • 36

3 Answers3

5

I didn't find any solution, so tried to do it on my own. I learned Combine framework a little, inherited AVPlayer class and signed it under the protocol ObservableObject and used KVO. May be it's not the best solution, but it works, hope somebody will give me advices for improving the code in future. Here are some code snippets:

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)

        }

    }

}


update for VolumeView For volume control I made new UIViewRepresentable struct:

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)

    }

}
Hrabovskyi Oleksandr
  • 3,070
  • 2
  • 17
  • 36
2

I would suggest injecting combine into your code. For example in order to get the slider to update.

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

you can do something similar to get the current time. Additionally, I added code for seeking through the track using the slider's built in onEditingChanged closure.

In order to prevent the publisher from writing results to the slider as you move it, you have to stop the values from being published which is why I included that invalidateProgress boolean

Matthew S.
  • 239
  • 2
  • 7
  • 1
    Working version of the code [GitHub gist](https://gist.github.com/AKosmachyov/2b9327545d4b538ec50ca3f3757c6cc7) – Alexander Nov 17 '21 at 16:43
1

Create an 'MPVolumeView' using 'UIViewRepresentable' like below as your requirements

import SwiftUI
import MediaPlayer
import UIKit

struct VolumeSlider: UIViewRepresentable {
   func makeUIView(context: Context) -> MPVolumeView {
      MPVolumeView(frame: .zero)
   }

   func updateUIView(_ view: MPVolumeView, context: Context) {}
}

then use it in your 'View' file like below

VolumeSlider()
   .frame(height: 40)
   .padding(.horizontal)
Muhammed Tanriverdi
  • 3,230
  • 1
  • 23
  • 24