How to control AVPlayer playback in SwiftUI - ios

I am trying to play music on my app and manage to play/stop the music from the app's settings.
First I am creating an ObservableObject class called MusicPlayer:
class MusicPlayer: ObservableObject {
#Published var isPlaying = AppDefaults.shared.isMusicPlaying()
#Published var music : AVAudioPlayer? = nil
func playMusic() {
guard let strFilePath = Bundle.main.path(forResource: "music", ofType: "mp3") else { return }
do {
music = try AVAudioPlayer(contentsOf: URL(fileURLWithPath: strFilePath))
} catch {
print(error)
}
music?.volume = 0.60
music?.numberOfLoops = -1
if isPlaying {
music?.play()
} else {
music?.stop()
}
}
}
and then play the music in main app file:
#main
struct AppName: App {
#StateObject private var player = MusicPlayer()
var body: some Scene {
WindowGroup {
ContentsView()
.onAppear {
player.playMusic()
}
}
}
}
and then trying to stop/play the music using toggle from settings:
struct SettingsView: View {
#StateObject private var player = MusicPlayer()
var body: some View {
Toggle("Music", isOn: $player.isPlaying)
.onChange(of: player.isPlaying, perform: { _ in
AppDefaults.shared.setMusic(player.isPlaying)
if player.isPlaying {
player.music?.stop()
} else {
player.music?.play()
}
})
}
}
now the problem is switching to on or off doesn't change the state of playing. How can I fix this issue?

The issue here is you are initializing your Viewmodel twice. So you have 2 different sources of truth. So there are 2 different AVAudioPlayer.
Solution: Create one single instance in the top View and pass this on to the views that need this.
As you decided to omit how SettingsView correlate with the other Views I can only give a more general solution.
Let´s asume the SettingsView is used in AppName:
#StateObject private var player = MusicPlayer()
WindowGroup {
....(ContentView stuff)
SettingsView()
// pass the observableObject on to the SettingsView and its children
.environmentObject(player)
}
Then in SettingsView:
struct SettingsView: View {
// get the observableObject from the environment
#EnvironmentObject private var player: MusicPlayer
var body: some View {
Toggle("Music", isOn: $player.isPlaying)
.onChange(of: player.isPlaying, perform: { _ in
AppDefaults.shared.setMusic(player.isPlaying)
if player.isPlaying {
player.music?.stop()
} else {
player.music?.play()
}
})
}
}

Related

Why is my .onAppear not getting triggered when an EnvironmentObject changes?

I'm trying to learn SwiftUI, but i can't seem to get my view to update. I want my WorkoutsView to refresh with the newly added workout when the user presses the "Add" button:
WorkoutTrackerApp:
#main
struct WorkoutTrackerApp: App {
var body: some Scene {
WindowGroup {
WorkoutTrackerView()
}
}
}
extension WorkoutTrackerApp {
struct WorkoutTrackerView: View {
#StateObject var workoutService = WorkoutService.instance
var body: some View {
NavigationView {
WorkoutsView { $workout in
NavigationLink(destination: WorkoutView(workout: $workout)){
Text(workout.title)
}
}
.toolbar {
Button("Add") {
workoutService.addNewWorkout()
}
}
.navigationTitle("Workouts")
}
.environmentObject(workoutService)
}
}
}
WorkoutsView:
import Foundation
import SwiftUI
struct WorkoutsView<Wrapper>: View where Wrapper: View {
#EnvironmentObject var workoutService: WorkoutService
#StateObject var viewModel: ViewModel
let workoutWrapper: (Binding<Workout>) -> Wrapper
init(_ viewModel: ViewModel = .init(), workoutWrapper: #escaping (Binding<Workout>) -> Wrapper) {
_viewModel = StateObject(wrappedValue: viewModel)
self.workoutWrapper = workoutWrapper
}
var body: some View {
List {
Section(header: Text("All Workouts")) {
ForEach($viewModel.workouts) { $workout in
workoutWrapper($workout)
}
}
}
.onAppear {
viewModel.workoutService = self.workoutService
viewModel.getWorkouts()
}
}
}
extension WorkoutsView {
class ViewModel: ObservableObject {
#Published var workouts = [Workout]()
var workoutService: WorkoutService?
func getWorkouts() {
workoutService?.getWorkouts { workouts in
self.workouts = workouts
}
}
}
}
WorkoutService:
import Foundation
class WorkoutService: ObservableObject {
static let instance = WorkoutService()
#Published var workouts = [Workout]()
private init() {
for i in 0...5 {
let workout = Workout(id: i, title: "Workout \(i)", exercises: [])
workouts.append(workout)
}
}
func getWorkouts(completion: #escaping ([Workout]) -> Void) {
DispatchQueue.main.async {
completion(self.workouts)
}
}
func addNewWorkout() {
let newWorkout = Workout(title: "New Workout")
workouts = workouts + [newWorkout]
}
}
The .onAppear in WorkoutsView only gets called once - when the view gets initialised for the first time. I want it to also get triggered when workoutService.addNewWorkout() gets called.
FYI: The WorkoutService is a 'mock' service, in the future i want to call an API there.
Figured it out, changed the body of WorkoutsView to this:
var body: some View {
List {
Section(header: Text("All Workouts")) {
ForEach($viewModel.workouts) { $workout in
workoutWrapper($workout)
}
}
}
.onAppear {
viewModel.workoutService = self.workoutService
viewModel.getWorkouts()
}
.onReceive(workoutService.objectWillChange) {
viewModel.getWorkouts()
}
}
Now the workouts list gets refreshed when workoutService publisher emits. The solution involved using the .onReceive to do something when the WorkoutService changes.

How to pass array of string from to view model to play song in swiftUI

I've created a model and added nested struct. created an array based on one of the struct details and I want to retrieve the details from Model to view model to play the music.
I want to retrieve this "aURL" strings and insert it inside the play function to play the music.
class myViewModel: ObservableObject {
#Published var isPlaying = false
var player = AVAudioPlayer()
func play(){
do{
player = try AVAudioPlayer(contentsOf: Array[0] as URL )
if player.isPlaying{player.pause()}
else{player.play()}
isPlaying = player.isPlaying
}catch{print("error")}
}
}
Make var feelSongs: [myModel.M.A] to static
like
static var feelSongs: [myModel.M.A]
And use it inside view model like this
class MyViewModel: ObservableObject {
#Published var isPlaying = false
var player = AVAudioPlayer()
var arrData = myModel.M.feelSongs
func play(with url: URL?){
guard let url = url else {
return
}
do{
player = try AVAudioPlayer(contentsOf: url )
if player.isPlaying{player.pause()}
else{player.play()}
isPlaying = player.isPlaying
}catch{print("error")}
}
}
Note: Class Name and struct name start with Capital latter.
Your struct nested in struct nested in struct is strange, anyway I don't know your idea.
please test this:
class MyModel: Codable {
var feelSongs: [MyModel.M.A] = [
MyModel.M.A.init(id: 1, afcontent: "Feeling Happy", aURL: "a1.mp3", isOn: true),
MyModel.M.A.init(id: 2, afcontent: "Feeling Sad", aURL: "a2.mp3", isOn: true),
MyModel.M.A.init(id: 3, afcontent: "Feeling Positive", aURL: "a3.mp3", isOn: true),
MyModel.M.A.init(id: 4, afcontent: "Feeling Healthy", aURL: "a4.mp3", isOn: true)
]
struct M: Codable{
var mURL: String
var bgMusic : String
var bgVol : Double
struct A : Codable, Identifiable {
var id : Int
var afcontent : String
var aURL : String
var isOn : Bool
}
}
}
class MyViewModel: ObservableObject {
#Published var isPlaying = false
var player = AVAudioPlayer()
var theModel = MyModel()
**//ADDED THIS**
var playNow = 0
func play(){
if playNow >= theModel.feelSongs.count {
return
}
let url = theModel.feelSongs[playNow]
guard let url = URL(string: url) else {
return
}
**//ADDED THIS**
NSNotificationCenter.defaultCenter().addObserver(self, selector: "playerDidFinishPlaying:", name: AVPlayerItemDidPlayToEndTimeNotification, object: item)
do {
player = try AVAudioPlayer(contentsOf: url )
if player.isPlaying{player.pause()}
else{player.play()}
isPlaying = player.isPlaying
}
catch{print("error")}
}
**//ADDED THIS**
func playerDidFinishPlaying(note: NSNotification) {
play()
}
}
//
struct ContentView: View {
var mymodel = MyViewModel()
var body: some View {
//Text("3")
List(mymodel.theModel.feelSongs) { song in
Text(song.afcontent)
}
.onAppear {
mymodel.play()
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

How to manage AVPlayer state in SwiftUI

I have a list of URLs in SwiftUI. When I tap an item, I present a full screen video player. I have an #EnvironmentObject that handles some viewer options (for example, whether to show a timecode). I also have a toggle that shows and hides the timecode (I've only included the toggle in this example as the timecode view doesn't matter) but every time I change the toggle the view is created again, which re-sets the AVPlayer. This makes sense since I'm creating the player in the view's initialiser.
I thought about creating my own ObserveredObject class to contain an AVPlayer but I'm not sure how or where I'd initialise it since I need to give it a URL, which I only know from the initialiser of CustomPlayerView. I also thought about setting the player as an #EnvironmentObject but it seems weird to initialise something I might not need (if the user doesn't tap on a URL to start the player).
What is the correct way to create an AVPlayer to hand to AVKit's VideoPlayer please? Here's my example code:
class ViewerOptions: ObservableObject {
#Published var showTimecode = false
}
struct CustomPlayerView: View {
#EnvironmentObject var viewerOptions: ViewerOptions
private let avPlayer: AVPlayer
init(url: URL) {
avPlayer = AVPlayer(url: url)
}
var body: some View {
HStack {
VideoPlayer(player: avPlayer)
Toggle(isOn: $viewerOptions.showTimecode) { Text("Show Timecode") }
}
}
}
There are a couple of approaches you can take here. You can try them out and see which one suits best for you.
Option 1: As you said you can wrap avPlayer in a new ObserveredObject class
class PlayerViewModel: ObservableObject {
#Published var avPlayer: AVPlayer? = nil
}
class ViewerOptions: ObservableObject {
#Published var showTimecode = false
}
#main
struct DemoApp: App {
var playerViewModel = PlayerViewModel()
var viewerOptions = ViewerOptions()
var body: some Scene {
WindowGroup {
CustomPlayerView(url: URL(string: "Your URL here")!)
.environmentObject(playerViewModel)
.environmentObject(viewerOptions)
}
}
}
struct CustomPlayerView: View {
#EnvironmentObject var viewerOptions: ViewerOptions
#EnvironmentObject var playerViewModel: PlayerViewModel
init(url: URL) {
if playerViewModel.avPlayer == nil {
playerViewModel.avPlayer = AVPlayer(url: url)
} else {
playerViewModel.avPlayer?.pause()
playerViewModel.avPlayer?.replaceCurrentItem(with: AVPlayerItem(url: url))
}
}
var body: some View {
HStack {
VideoPlayer(player: playerViewModel.avPlayer)
Toggle(isOn: $viewerOptions.showTimecode) { Text("Show Timecode") }
}
}
}
Option 2: You can add avPlayer to your already existing class ViewerOptions as an optional property and then initialise it when you need it
class ViewerOptions: ObservableObject {
#Published var showTimecode = false
#Published var avPlayer: AVPlayer? = nil
}
struct CustomPlayerView: View {
#EnvironmentObject var viewerOptions: ViewerOptions
init(url: URL) {
if viewerOptions.avPlayer == nil {
viewerOptions.avPlayer = AVPlayer(url: url)
} else {
viewerOptions.avPlayer?.pause()
viewerOptions.avPlayer?.replaceCurrentItem(with: AVPlayerItem(url: url))
}
}
var body: some View {
HStack {
VideoPlayer(player: viewerOptions.avPlayer)
Toggle(isOn: $viewerOptions.showTimecode) { Text("Show Timecode") }
}
}
}
Option 3: Make your avPlayer a state object this way its memory will be managed by the system and it will not re-set it and keep it alive for you until your view exists.
class ViewerOptions: ObservableObject {
#Published var showTimecode = false
}
struct CustomPlayerView: View {
#EnvironmentObject var viewerOptions: ViewerOptions
#State private var avPlayer: AVPlayer
init(url: URL) {
_avPlayer = .init(wrappedValue: AVPlayer(url: url))
}
var body: some View {
HStack {
VideoPlayer(player: avPlayer)
Toggle(isOn: $viewerOptions.showTimecode) { Text("Show Timecode") }
}
}
}
Option 4: Create your avPlayer object when you need it and forget it (Not sure this is the best approach for you but if you do not need your player object to perform custom actions then you can use this option)
class ViewerOptions: ObservableObject {
#Published var showTimecode = false
}
struct CustomPlayerView: View {
#EnvironmentObject var viewerOptions: ViewerOptions
private let url: URL
init(url: URL) {
self.url = url
}
var body: some View {
HStack {
VideoPlayer(player: AVPlayer(url: url))
Toggle(isOn: $viewerOptions.showTimecode) { Text("Show Timecode") }
}
}
}

Audio keeps playing after AVPlayer is removed from VStack (SwiftUI)

I have the following VStack that contains an AVPlayer (in PlayerView):
struct ContentView: View {
#State var url: URL?
private let openFile = NotificationCenter.default.publisher(for: .openFile)
var body: some View {
VStack {
if isVideo(self.url!) {
PlayerView(url: self.url!)
} else {
Image(nsImage: NSImage(contentsOf: self.url!)!).resizable().aspectRatio(contentMode: .fit)
}
}.onReceive(openFile) { notification in
self.url = notification.object as? URL
}
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}
And this is the PlayerView:
struct PlayerView: NSViewRepresentable {
private var url: URL
init(url: URL) {
self.url = url
}
func updateNSView(_ nsView: PlayerNSView, context _: NSViewRepresentableContext<PlayerView>) {
nsView.play(url: url)
}
func makeNSView(context _: Context) -> PlayerNSView {
PlayerNSView(frame: .zero)
}
func dismantleNSView(coordinator _: Coordinator) {
// not called
}
}
After updating from a video to a image the audio of the video keeps playing for a few seconds.
Where can I tell the AVPlayer to pause? Does the VStack notify VideoPlayer?
Updated with code from iUrii:
struct ContentView: View {
#State var url: URL?
#State var playerView: PlayerView?
private let openFile = NotificationCenter.default.publisher(for: .openFile)
var body: some View {
VStack {
if let view = playerView {
view
} else {
Image(nsImage: NSImage(contentsOf: self.url!)!).resizable().aspectRatio(contentMode: .fit)
}
}.onReceive(openFile) { notification in
url = notification.object as? URL
if playerView != nil {
playerView!.player.pause()
playerView = nil
}
if videoExtensions.contains(url!.pathExtension.lowercased()) {
playerView = PlayerView(url: url!)
}
}
}
}
This works great when going video to image to video.
When I go video to video, the first video will be paused and audio from the second video starts playing, it is not shown though (the first video is still visible).
I solved it by updating the player with playerView.player.replaceCurrentItem(with: playerItem) instead of replacing the view.
You should manage your PlayerNSView with AVPlayer manually if you want to control its behaviour e.g.:
struct PlayerView: NSViewRepresentable {
let player: AVPlayer
init(url: URL) {
self.player = AVPlayer(url: url)
}
func updateNSView(_ nsView: AVPlayerView, context: NSViewRepresentableContext<Self>) {
}
func makeNSView(context: NSViewRepresentableContext<Self>) -> AVPlayerView {
let playerView = AVPlayerView(frame: .zero)
playerView.player = player
return playerView
}
}
struct ContentView: View {
#State var playerView: PlayerView?
var body: some View {
VStack {
if let view = playerView {
view
} else {
Image(nsImage: NSImage(named: "iphone12")!)
}
Button("Toogle") {
if playerView == nil {
let url = URL(string: "https://www.apple.com/105/media/us/iphone-12-pro/2020/e70ffbd8-50f1-40f3-ac36-0f03a15ac314/films/product/iphone-12-pro-product-tpl-us-2020_16x9.m3u8")!
playerView = PlayerView(url: url)
playerView?.player.play()
}
else {
playerView?.player.pause()
playerView = nil
}
}
}
}
}

Observing system volume in SwiftUI

I am trying to show a volume indicator in my app, but first I need to monitor the systems current volume.
I am using an observer, and while the print statement shows the correct value, the UI never does.
import SwiftUI
import MediaPlayer
struct ContentView: View {
#State var vol: Float = 1.0
// Audio session object
private let session = AVAudioSession.sharedInstance()
// Observer
private var progressObserver: NSKeyValueObservation!
init() {
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
try session.setActive(true, options: .notifyOthersOnDeactivation)
self.vol = 1.0
} catch {
print("cannot activate session")
}
progressObserver = session.observe(\.outputVolume) { [self] (session, value) in
print(session.outputVolume)
self.vol = session.outputVolume
}
}
var body: some View {
Text(String(self.vol))
}
}
// fixed (set category to ambient)(updated above code)
Also, every time the application is launched, it stops all currently playing music.
Solved. Created a class that conforms to ObservableObject and use the ObservedObject property in the view. Also, the volume observer doesn't work in the simulator, only on device.
VolumeObserver.swift
import Foundation
import MediaPlayer
final class VolumeObserver: ObservableObject {
#Published var volume: Float = AVAudioSession.sharedInstance().outputVolume
// Audio session object
private let session = AVAudioSession.sharedInstance()
// Observer
private var progressObserver: NSKeyValueObservation!
func subscribe() {
do {
try AVAudioSession.sharedInstance().setCategory(AVAudioSession.Category.ambient)
try session.setActive(true, options: .notifyOthersOnDeactivation)
} catch {
print("cannot activate session")
}
progressObserver = session.observe(\.outputVolume) { [self] (session, value) in
DispatchQueue.main.async {
self.volume = session.outputVolume
}
}
}
func unsubscribe() {
self.progressObserver.invalidate()
}
init() {
subscribe()
}
}
ContentView.swift
import SwiftUI
import MediaPlayer
struct ContentView: View {
#ObservedObject private var volObserver = VolumeObserver()
init() {
print(vol.volume)
}
var body: some View {
Text(String(vol.volume))
}
}
struct ContentView_Previews: PreviewProvider {
static var previews: some View {
ContentView()
}
}

Resources