├── .gitignore ├── screenshot.png ├── Tests ├── SereneAudioPlayerTests │ ├── SereneAudioPlayerTests.swift │ └── XCTestManifests.swift └── LinuxMain.swift ├── .swiftpm └── xcode │ └── package.xcworkspace │ ├── contents.xcworkspacedata │ └── xcshareddata │ └── IDEWorkspaceChecks.plist ├── Package.resolved ├── Sources └── SereneAudioPlayer │ ├── Helpers │ ├── AVPlayerExtension.swift │ ├── AVDelegate.swift │ └── InternetConnectionManager.swift │ ├── Track.swift │ └── Players │ ├── SereneAudioFilePlayer.swift │ └── SereneAudioStreamPlayer.swift ├── LICENSE ├── Package.swift └── README.md /.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | /.build 3 | /Packages 4 | /*.xcodeproj 5 | xcuserdata/ 6 | -------------------------------------------------------------------------------- /screenshot.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/amrezo/SereneAudioPlayer/HEAD/screenshot.png -------------------------------------------------------------------------------- /Tests/SereneAudioPlayerTests/SereneAudioPlayerTests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | @testable import SereneAudioPlayer 3 | 4 | final class SereneAudioPlayerTests: XCTestCase { 5 | 6 | } 7 | -------------------------------------------------------------------------------- /Tests/LinuxMain.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | import SereneAudioPlayerTests 4 | 5 | var tests = [XCTestCaseEntry]() 6 | tests += SereneAudioPlayerTests.allTests() 7 | XCTMain(tests) 8 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/contents.xcworkspacedata: -------------------------------------------------------------------------------- 1 | 2 | 4 | 6 | 7 | 8 | -------------------------------------------------------------------------------- /Tests/SereneAudioPlayerTests/XCTestManifests.swift: -------------------------------------------------------------------------------- 1 | import XCTest 2 | 3 | #if !canImport(ObjectiveC) 4 | public func allTests() -> [XCTestCaseEntry] { 5 | return [ 6 | testCase(SereneAudioPlayerTests.allTests), 7 | ] 8 | } 9 | #endif 10 | -------------------------------------------------------------------------------- /.swiftpm/xcode/package.xcworkspace/xcshareddata/IDEWorkspaceChecks.plist: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | IDEDidComputeMac32BitWarning 6 | 7 | 8 | 9 | -------------------------------------------------------------------------------- /Package.resolved: -------------------------------------------------------------------------------- 1 | { 2 | "object": { 3 | "pins": [ 4 | { 5 | "package": "ActivityIndicatorView", 6 | "repositoryURL": "https://github.com/exyte/ActivityIndicatorView.git", 7 | "state": { 8 | "branch": null, 9 | "revision": "25bc58019d161295bf4ae0a4d764170e882ab856", 10 | "version": "0.0.3" 11 | } 12 | } 13 | ] 14 | }, 15 | "version": 1 16 | } 17 | -------------------------------------------------------------------------------- /Sources/SereneAudioPlayer/Helpers/AVPlayerExtension.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AVPlayerExtension.swift 3 | // 4 | // 5 | // Created by Amr Al-Refae on 2020-08-26. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | import MediaPlayer 11 | 12 | extension AVPlayer { 13 | 14 | public var isPlaying: Bool { 15 | if (self.rate != 0 && self.error == nil) { 16 | return true 17 | } else { 18 | return false 19 | } 20 | } 21 | 22 | } 23 | -------------------------------------------------------------------------------- /Sources/SereneAudioPlayer/Helpers/AVDelegate.swift: -------------------------------------------------------------------------------- 1 | // 2 | // AVDelegate.swift 3 | // 4 | // Created by Amr Al-Refae on 2020-08-26. 5 | // Copyright © 2020 Amr Al-Refae. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | import AVFoundation 10 | import MediaPlayer 11 | 12 | public class AVDelegate: NSObject, AVAudioPlayerDelegate{ 13 | 14 | public func audioPlayerDidFinishPlaying(_ player: AVAudioPlayer, successfully flag: Bool) { 15 | 16 | NotificationCenter.default.post(name: NSNotification.Name("Finish"), object: nil) 17 | } 18 | } 19 | 20 | -------------------------------------------------------------------------------- /Sources/SereneAudioPlayer/Track.swift: -------------------------------------------------------------------------------- 1 | // 2 | // Track.swift 3 | // 4 | // Created by Amr Al-Refae on 2020-08-26. 5 | // Copyright © 2020 Amr Al-Refae. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | 10 | public struct Track { 11 | 12 | var image: String? 13 | var title: String? 14 | var subtitle: String? 15 | var recording: String? 16 | var streamURL: String? 17 | var favourited: Bool? 18 | 19 | public init(image: String, title: String, subtitle: String, recording: String, streamURL: String, favourited: Bool) { 20 | self.image = image 21 | self.title = title 22 | self.subtitle = subtitle 23 | self.recording = recording 24 | self.streamURL = streamURL 25 | self.favourited = favourited 26 | 27 | } 28 | } 29 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Amr Al-Refae 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /Package.swift: -------------------------------------------------------------------------------- 1 | // swift-tools-version:5.2 2 | // The swift-tools-version declares the minimum version of Swift required to build this package. 3 | 4 | import PackageDescription 5 | 6 | let package = Package( 7 | name: "SereneAudioPlayer", 8 | platforms: [ 9 | .iOS(.v13) 10 | ], 11 | products: [ 12 | // Products define the executables and libraries produced by a package, and make them visible to other packages. 13 | .library( 14 | name: "SereneAudioPlayer", 15 | targets: ["SereneAudioPlayer"]), 16 | ], 17 | dependencies: [ 18 | // Dependencies declare other packages that this package depends on. 19 | .package(url: "https://github.com/exyte/ActivityIndicatorView.git", from: "0.0.1") 20 | ], 21 | targets: [ 22 | // Targets are the basic building blocks of a package. A target can define a module or a test suite. 23 | // Targets can depend on other targets in this package, and on products in packages which this package depends on. 24 | .target( 25 | name: "SereneAudioPlayer", 26 | dependencies: [ 27 | "ActivityIndicatorView" 28 | ]), 29 | .testTarget( 30 | name: "SereneAudioPlayerTests", 31 | dependencies: ["SereneAudioPlayer"]), 32 | ] 33 | ) 34 | -------------------------------------------------------------------------------- /Sources/SereneAudioPlayer/Helpers/InternetConnectionManager.swift: -------------------------------------------------------------------------------- 1 | // 2 | // InternetConnectionManager.swift 3 | // 4 | // Created by Amr Al-Refae on 2020-05-31. 5 | // Copyright © 2020 Amr Al-Refae. All rights reserved. 6 | // 7 | 8 | import Foundation 9 | import UIKit 10 | import SystemConfiguration 11 | 12 | public class InternetConnectionManager { 13 | 14 | 15 | private init() { 16 | 17 | } 18 | 19 | public static func isConnectedToNetwork() -> Bool { 20 | 21 | var zeroAddress = sockaddr_in() 22 | zeroAddress.sin_len = UInt8(MemoryLayout.size(ofValue: zeroAddress)) 23 | zeroAddress.sin_family = sa_family_t(AF_INET) 24 | guard let defaultRouteReachability = withUnsafePointer(to: &zeroAddress, { 25 | 26 | $0.withMemoryRebound(to: sockaddr.self, capacity: 1) { 27 | 28 | SCNetworkReachabilityCreateWithAddress(nil, $0) 29 | 30 | } 31 | 32 | }) else { 33 | 34 | return false 35 | } 36 | var flags = SCNetworkReachabilityFlags() 37 | if !SCNetworkReachabilityGetFlags(defaultRouteReachability, &flags) { 38 | return false 39 | } 40 | let isReachable = (flags.rawValue & UInt32(kSCNetworkFlagsReachable)) != 0 41 | let needsConnection = (flags.rawValue & UInt32(kSCNetworkFlagsConnectionRequired)) != 0 42 | return (isReachable && !needsConnection) 43 | } 44 | 45 | } 46 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Serene Audio Player 2 | 3 | Serene Audio Player is a fully functional beautiful audio player developed in SwiftUI. It can play live streams from URLs as well as local bundled files located in a specific folder directory. 4 | 5 | ![Screenshot](screenshot.png) 6 | 7 | ### Features! 8 | 9 | - Play an mp3 file bundled with the app (Using SereneAudioFilePlayer()) 10 | - Play an mp3 file from an online source (Using SereneAudioStreamPlayer()) 11 | - Play in background 12 | 13 | You can also: 14 | - Download audio files for offline usage to the user's device by assigning a directory folder name. (experimental) 15 | - The user can favourite tracks in the player view (experimental) 16 | 17 | ### How to Use (Installation) 18 | 19 | Serene Audio Player requires iOS 13+ as it is a SwiftUI package. 20 | 21 | 1) Add SereneAudioPlayer to your project using Swift Package Manager (copy and paste this link when asked after selecting File > Swift Packages > Add Package Dependency: 22 | 23 | ```swift 24 | https://github.com/amrezo/SereneAudioPlayer.git 25 | ``` 26 | 27 | 2) Import SereneAudioPlayer into your content view: 28 | 29 | ```swift 30 | import SwiftUI 31 | import SereneAudioPlayer 32 | ``` 33 | 34 | 3) Create a track using ```Track()``` : 35 | 36 | ```swift 37 | var track: Track = Track(image: "nature", title: "Test Track", subtitle: "Subtitle goes here.", recording: "clarity", streamURL: "https://serene-music.s3.us-east-2.amazonaws.com/clarity.mp3", favourited: false) 38 | ``` 39 | 40 | - image: an image located in the Assets of the app project 41 | - title: Main title of the track 42 | - subtitle: The accompanying subtitle of the track 43 | - recording: the name of the mp3 file bundled with the app (located within the app files hierarchy). 44 | - streamURL: the location of the mp3 file online (used for streaming). 45 | - favourited: whether the track is favourited by the user or not, 46 | 47 | 4) Use ```SereneAudioFilePlayer()``` for playing a local file, or ```SereneAudioStreamPlayer()``` to play a streamed mp3 file online. 48 | 49 | 5) Be sure to include the Track object and a folder name for the directory to be created when a user downloads the track for offline usage. For example, using the previously created ```track```, add the following inside your content view body: 50 | 51 | ```swift 52 | SereneAudioFilePlayer(track: track, folderName: "Music") 53 | ``` 54 | 55 | ### Todo 56 | - Create functionality to alternate between stream or file automatically if file already exists (downloaded by user for offline usage) 57 | - Enable favouriting and create function to save favourite 58 | - Enhance UI 59 | - More to come...open to suggestions and inquiries. 60 | 61 | ### Collaboration 62 | 63 | I am open to collaborators helping me develop this library. Please let me know of any issues that may arise or future developments you want to work on! 64 | 65 | ### License 66 | --- 67 | MIT 68 | 69 | -------------------------------------------------------------------------------- /Sources/SereneAudioPlayer/Players/SereneAudioFilePlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SereneAudioFilePlayer.swift 3 | // 4 | // Created by Amr Al-Refae on 2020-08-26. 5 | // Copyright © 2020 Amr Al-Refae. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | import AVFoundation 10 | import MediaPlayer 11 | 12 | public struct SereneAudioFilePlayer: View { 13 | 14 | @Environment(\.presentationMode) var presentationMode 15 | 16 | public var track: Track 17 | public var folderName: String 18 | 19 | @State var trackFavourited: Bool = false 20 | 21 | @State var player: AVAudioPlayer! 22 | @State var playing = false 23 | @State var width: CGFloat = 0 24 | @State var finish = false 25 | @State var del = AVDelegate() 26 | 27 | public init(track: Track, folderName: String) { 28 | self.track = track 29 | self.folderName = folderName 30 | } 31 | 32 | public var body: some View { 33 | ZStack { 34 | 35 | // Background Image of current track 36 | Image(track.image ?? "") 37 | .resizable() 38 | .aspectRatio(contentMode: .fill) 39 | .frame(width: UIScreen.main.bounds.width) 40 | .edgesIgnoringSafeArea(.vertical) 41 | 42 | // Gradient Overlay (Clear to Black) 43 | VStack { 44 | Spacer() 45 | Rectangle() 46 | .foregroundColor(.clear) 47 | .background(LinearGradient(gradient: Gradient(colors: [.clear, .black]), startPoint: .top, endPoint: .bottom)) 48 | .edgesIgnoringSafeArea(.bottom) 49 | .frame(height: UIScreen.main.bounds.height / 1.5) 50 | } 51 | 52 | VStack { 53 | Spacer() 54 | 55 | VStack(alignment: .center) { 56 | Text(track.title ?? "No track title") 57 | .foregroundColor(.white) 58 | .font(.custom("Quicksand SemiBold", size: 18)) 59 | .padding(.bottom, 10) 60 | .padding(.horizontal, 30) 61 | .multilineTextAlignment(.center) 62 | 63 | Text(track.subtitle ?? "No track subtitle") 64 | .foregroundColor(Color.white.opacity(0.6)) 65 | .font(.custom("Quicksand SemiBold", size: 16)) 66 | .padding(.bottom, 30) 67 | } 68 | 69 | ZStack(alignment: .leading) { 70 | 71 | Capsule().fill(Color.white.opacity(0.08)).frame(height: 5) 72 | 73 | Capsule().fill(Color.white).frame(width: self.width, height: 5) 74 | .gesture(DragGesture().onChanged({ (value) in 75 | 76 | let x = value.location.x 77 | 78 | self.width = x 79 | 80 | }).onEnded({ (value) in 81 | 82 | let x = value.location.x 83 | 84 | let screen = UIScreen.main.bounds.width - 30 85 | 86 | let percent = x / screen 87 | 88 | self.player.currentTime = Double(percent) * self.player.duration 89 | })) 90 | } 91 | .padding(.horizontal, 30) 92 | 93 | HStack { 94 | 95 | if player != nil { 96 | Text(String(format: "%02d:%02d", ((Int)((player.currentTime))) / 60, ((Int)((player.currentTime))) % 60)) 97 | .foregroundColor(Color.white.opacity(0.6)) 98 | .font(.custom("Quicksand Regular", size: 14)) 99 | } else { 100 | Text("0:00") 101 | .foregroundColor(Color.white.opacity(0.6)) 102 | .font(.custom("Quicksand Regular", size: 14)) 103 | } 104 | 105 | Spacer() 106 | 107 | if player != nil { 108 | Text(String(format: "%02d:%02d", ((Int)((player.duration))) / 60, ((Int)((player.duration))) % 60)) 109 | .foregroundColor(Color.white.opacity(0.6)) 110 | .font(.custom("Quicksand Regular", size: 14)) 111 | } else { 112 | Text("0:00") 113 | .foregroundColor(Color.white.opacity(0.6)) 114 | .font(.custom("Quicksand Regular", size: 14)) 115 | } 116 | 117 | } 118 | .padding(.horizontal, 30) 119 | .padding(.top, 10) 120 | .padding(.bottom, 30) 121 | 122 | HStack { 123 | 124 | Spacer() 125 | 126 | Button(action: { 127 | self.player.stop() 128 | MPRemoteCommandCenter.shared().playCommand.removeTarget(nil) 129 | MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil) 130 | self.presentationMode.wrappedValue.dismiss() 131 | }) { 132 | VStack(alignment: .center) { 133 | Image(systemName: "stop.fill") 134 | .foregroundColor(.white) 135 | .font(.title) 136 | .padding() 137 | Text("Stop") 138 | .foregroundColor(Color.white) 139 | .font(.custom("Quicksand Regular", size: 14)) 140 | } 141 | } 142 | 143 | 144 | } 145 | .padding(.horizontal, 30) 146 | .padding(.bottom, 30) 147 | 148 | HStack { 149 | 150 | Button(action: { 151 | 152 | self.trackFavourited.toggle() 153 | 154 | }) { 155 | 156 | if track.favourited == true { 157 | Image(systemName: "heart.fill") 158 | .foregroundColor(.red) 159 | .font(.headline) 160 | .padding() 161 | } else { 162 | Image(systemName: "heart") 163 | .foregroundColor(.white) 164 | .font(.headline) 165 | .padding() 166 | } 167 | 168 | } 169 | 170 | Spacer() 171 | 172 | Button(action: { 173 | self.player.currentTime -= 15 174 | }) { 175 | Image(systemName: "gobackward.15") 176 | .foregroundColor(.white) 177 | .font(.headline) 178 | .padding() 179 | } 180 | 181 | Spacer() 182 | 183 | Button(action: { 184 | if self.player.isPlaying { 185 | 186 | self.player.pause() 187 | self.playing = false 188 | } 189 | else{ 190 | 191 | if self.finish{ 192 | 193 | self.player.currentTime = 0 194 | self.width = 0 195 | self.finish = false 196 | 197 | } 198 | 199 | self.player.play() 200 | self.playing = true 201 | } 202 | }) { 203 | Image(systemName: self.playing && !self.finish ? "pause.fill" : "play.fill") 204 | .foregroundColor(.white) 205 | .font(.largeTitle) 206 | .padding() 207 | } 208 | 209 | Spacer() 210 | 211 | Button(action: { 212 | let increase = self.player.currentTime + 15 213 | 214 | if increase < self.player.duration { 215 | 216 | self.player.currentTime = increase 217 | } 218 | }) { 219 | Image(systemName: "goforward.15") 220 | .foregroundColor(.white) 221 | .font(.headline) 222 | .padding() 223 | } 224 | 225 | Spacer() 226 | 227 | Image(systemName: "checkmark.circle.fill") 228 | .foregroundColor(.white) 229 | .font(.headline) 230 | .padding() 231 | } 232 | .padding(.horizontal, 30) 233 | .padding(.bottom, 30) 234 | .onAppear { 235 | 236 | let destinationUrl = Bundle.main.path(forResource: self.track.recording, ofType:"mp3") 237 | 238 | self.player = try! AVAudioPlayer(contentsOf: URL(fileURLWithPath: destinationUrl!)) 239 | 240 | self.player.delegate = self.del 241 | 242 | self.player.prepareToPlay() 243 | 244 | Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in 245 | 246 | if self.player.isPlaying { 247 | 248 | let screen = UIScreen.main.bounds.width - 30 249 | 250 | let value = self.player.currentTime / self.player.duration 251 | 252 | self.width = screen * CGFloat(value) 253 | } 254 | } 255 | 256 | NotificationCenter.default.addObserver(forName: NSNotification.Name("Finish"), object: nil, queue: .main) { (_) in 257 | 258 | self.finish = true 259 | } 260 | 261 | self.setupRemoteTransportControls() 262 | self.setupNowPlaying(track: self.track) 263 | 264 | } 265 | } 266 | 267 | } 268 | .navigationBarTitle("") 269 | .navigationBarHidden(true) 270 | } 271 | 272 | func setupRemoteTransportControls() { 273 | // Get the shared MPRemoteCommandCenter 274 | let commandCenter = MPRemoteCommandCenter.shared() 275 | 276 | // Add handler for Play Command 277 | commandCenter.playCommand.addTarget { [self] event in 278 | print("Play command - is playing: \(self.player.isPlaying)") 279 | if !self.player.isPlaying { 280 | self.player.play() 281 | return .success 282 | } 283 | return .commandFailed 284 | } 285 | 286 | // Add handler for Pause Command 287 | commandCenter.pauseCommand.addTarget { [self] event in 288 | print("Pause command - is playing: \(self.player.isPlaying)") 289 | if self.player.isPlaying { 290 | self.player.pause() 291 | return .success 292 | } 293 | return .commandFailed 294 | } 295 | } 296 | 297 | func setupNowPlaying(track: Track) { 298 | // Define Now Playing Info 299 | var nowPlayingInfo = [String : Any]() 300 | nowPlayingInfo[MPMediaItemPropertyTitle] = track.title 301 | 302 | if let image = UIImage(named: track.image ?? "") { 303 | nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { size in 304 | return image 305 | } 306 | } 307 | 308 | nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime 309 | nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.duration 310 | nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate 311 | 312 | // Set the metadata 313 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo 314 | } 315 | } 316 | -------------------------------------------------------------------------------- /Sources/SereneAudioPlayer/Players/SereneAudioStreamPlayer.swift: -------------------------------------------------------------------------------- 1 | // 2 | // SereneAudioStreamPlayer.swift 3 | // 4 | // Created by Amr Al-Refae on 2020-05-31. 5 | // Copyright © 2020 Amr Al-Refae. All rights reserved. 6 | // 7 | 8 | import SwiftUI 9 | import AVFoundation 10 | import MediaPlayer 11 | import ActivityIndicatorView 12 | 13 | public struct SereneAudioStreamPlayer: View { 14 | 15 | @Environment(\.presentationMode) var presentationMode 16 | 17 | public var track: Track 18 | public var folderName: String 19 | 20 | @State var trackFavourited: Bool = false 21 | 22 | @State var player : AVPlayer! 23 | @State var playing = false 24 | @State var width: CGFloat = 0 25 | @State var finish = false 26 | 27 | @State var downloaded = false 28 | @State var disableDownload = false 29 | @State var showingAlert = false 30 | 31 | @State var isDownloading = false 32 | 33 | public init(track: Track, folderName: String) { 34 | self.track = track 35 | self.folderName = folderName 36 | } 37 | 38 | public var body: some View { 39 | ZStack { 40 | 41 | // Background Image of current track 42 | Image(track.image ?? "") 43 | .resizable() 44 | .aspectRatio(contentMode: .fill) 45 | .frame(width: UIScreen.main.bounds.width) 46 | .edgesIgnoringSafeArea(.vertical) 47 | 48 | // Gradient Overlay (Clear to Black) 49 | VStack { 50 | Spacer() 51 | Rectangle() 52 | .foregroundColor(.clear) 53 | .background(LinearGradient(gradient: Gradient(colors: [.clear, .black]), startPoint: .top, endPoint: .bottom)) 54 | .edgesIgnoringSafeArea(.bottom) 55 | .frame(height: UIScreen.main.bounds.height / 1.5) 56 | } 57 | 58 | VStack { 59 | Spacer() 60 | 61 | VStack(alignment: .center) { 62 | Text(track.title ?? "No track title") 63 | .foregroundColor(.white) 64 | .font(.custom("Quicksand SemiBold", size: 18)) 65 | .padding(.bottom, 10) 66 | .padding(.horizontal, 30) 67 | .multilineTextAlignment(.center) 68 | 69 | Text(track.subtitle ?? "No track subtitle") 70 | .foregroundColor(Color.white.opacity(0.6)) 71 | .font(.custom("Quicksand SemiBold", size: 16)) 72 | .padding(.bottom, 30) 73 | } 74 | 75 | ZStack(alignment: .leading) { 76 | 77 | Capsule().fill(Color.white.opacity(0.08)).frame(height: 5) 78 | 79 | Capsule().fill(Color.white).frame(width: self.width, height: 5) 80 | .gesture(DragGesture() 81 | .onChanged({ (value) in 82 | 83 | let x = value.location.x 84 | 85 | self.width = x 86 | 87 | }).onEnded({ (value) in 88 | 89 | let x = value.location.x 90 | 91 | let screen = UIScreen.main.bounds.width - 30 92 | 93 | let percent = x / screen 94 | 95 | let seek = Double(percent) * self.player.currentItem!.asset.duration.seconds 96 | 97 | self.player.seek(to: CMTime(seconds: seek, preferredTimescale: self.player.currentTime().timescale)) 98 | 99 | })) 100 | } 101 | .padding(.horizontal, 30) 102 | 103 | HStack { 104 | Text("Streaming Live") 105 | .foregroundColor(Color.white.opacity(0.6)) 106 | .font(.custom("Quicksand Regular", size: 14)) 107 | 108 | } 109 | .padding(.horizontal, 30) 110 | .padding(.top, 10) 111 | .padding(.bottom, 30) 112 | 113 | HStack { 114 | 115 | Spacer() 116 | 117 | Button(action: { 118 | self.player.pause() 119 | self.player.seek(to: .zero) 120 | MPRemoteCommandCenter.shared().playCommand.removeTarget(nil) 121 | MPRemoteCommandCenter.shared().pauseCommand.removeTarget(nil) 122 | self.presentationMode.wrappedValue.dismiss() 123 | }) { 124 | VStack(alignment: .center) { 125 | Image(systemName: "stop.fill") 126 | .foregroundColor(.white) 127 | .font(.title) 128 | .padding() 129 | Text("Stop") 130 | .foregroundColor(Color.white) 131 | .font(.custom("Quicksand Regular", size: 14)) 132 | } 133 | } 134 | 135 | 136 | } 137 | .padding(.horizontal, 30) 138 | .padding(.bottom, 30) 139 | 140 | HStack { 141 | 142 | Button(action: { 143 | 144 | self.trackFavourited.toggle() 145 | 146 | }) { 147 | 148 | if track.favourited == true { 149 | Image(systemName: "heart.fill") 150 | .foregroundColor(.red) 151 | .font(.headline) 152 | .padding() 153 | } else { 154 | Image(systemName: "heart") 155 | .foregroundColor(.white) 156 | .font(.headline) 157 | .padding() 158 | } 159 | 160 | } 161 | 162 | Spacer() 163 | 164 | Button(action: { 165 | // self.player.currentTime -= 15 166 | }) { 167 | Image(systemName: "gobackward.15") 168 | .foregroundColor(.white) 169 | .opacity(0.5) 170 | .font(.headline) 171 | .padding() 172 | } 173 | .disabled(true) 174 | 175 | Spacer() 176 | 177 | 178 | Button(action: { 179 | if InternetConnectionManager.isConnectedToNetwork() { 180 | print("Internet connection OK") 181 | 182 | 183 | if self.player.isPlaying { 184 | 185 | self.player.pause() 186 | self.playing = false 187 | } else { 188 | 189 | if self.finish { 190 | 191 | self.player.seek(to: .zero) 192 | self.width = 0 193 | self.finish = false 194 | 195 | } 196 | 197 | self.player.play() 198 | self.playing = true 199 | 200 | } 201 | } else { 202 | print("Internet connection FAILED") 203 | 204 | self.showingAlert = true 205 | 206 | } 207 | }) { 208 | 209 | Image(systemName: self.playing && !self.finish ? "pause.fill" : "play.fill") 210 | .foregroundColor(.white) 211 | .font(.largeTitle) 212 | .padding() 213 | 214 | } 215 | 216 | Spacer() 217 | 218 | Button(action: { 219 | var timeForward = self.player.currentTime().seconds 220 | timeForward += 5.0 221 | if (timeForward > (self.player.currentItem?.asset.duration.seconds)!) { 222 | self.player.seek(to: CMTime(seconds: timeForward, preferredTimescale: self.player.currentTime().timescale)) 223 | } else { 224 | self.player.seek(to: (self.player.currentItem?.asset.duration)!) 225 | } 226 | }) { 227 | Image(systemName: "goforward.15") 228 | .foregroundColor(.white) 229 | .opacity(0.5) 230 | .font(.headline) 231 | .padding() 232 | } 233 | .disabled(true) 234 | 235 | Spacer() 236 | 237 | Button(action: { 238 | let urlString = self.track.streamURL ?? "" 239 | 240 | let encodedSoundString = urlString.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) 241 | 242 | self.downloadAndSaveAudioFile(encodedSoundString!) { (url) in 243 | self.downloaded = true 244 | self.disableDownload = true 245 | } 246 | }) { 247 | 248 | if isDownloading { 249 | ActivityIndicatorView(isVisible: $isDownloading, type: .default) 250 | .frame(width: 30, height: 30) 251 | .foregroundColor(.white) 252 | .padding() 253 | } else { 254 | if downloaded { 255 | Image(systemName: "checkmark.circle.fill") 256 | .foregroundColor(.white) 257 | .font(.headline) 258 | .padding() 259 | } else { 260 | 261 | Image(systemName: "icloud.and.arrow.down.fill") 262 | .foregroundColor(.white) 263 | .font(.headline) 264 | .padding() 265 | } 266 | } 267 | 268 | 269 | } 270 | .disabled(disableDownload) 271 | 272 | } 273 | .padding(.horizontal, 30) 274 | .padding(.bottom, 30) 275 | .onAppear { 276 | 277 | 278 | if InternetConnectionManager.isConnectedToNetwork() { 279 | print("Internet connection OK") 280 | } else { 281 | print("Internet connection FAILED") 282 | 283 | self.showingAlert = true 284 | 285 | } 286 | 287 | let urlString = self.track.streamURL ?? "" 288 | 289 | let encodedSoundString = urlString.addingPercentEncoding(withAllowedCharacters: .urlFragmentAllowed) 290 | 291 | let url = URL(string: encodedSoundString!) 292 | 293 | let playerItem = AVPlayerItem(url: url!) 294 | 295 | self.player = AVPlayer.init(playerItem: playerItem) 296 | 297 | self.player.automaticallyWaitsToMinimizeStalling = false 298 | 299 | 300 | Timer.scheduledTimer(withTimeInterval: 1, repeats: true) { (_) in 301 | 302 | if self.player.isPlaying{ 303 | 304 | let screen = UIScreen.main.bounds.width - 30 305 | 306 | let value = self.player.currentItem!.currentTime().seconds / self.player.currentItem!.asset.duration.seconds 307 | 308 | self.width = screen * CGFloat(value) 309 | } 310 | } 311 | 312 | NotificationCenter.default.addObserver(forName: NSNotification.Name("Finish"), object: nil, queue: .main) { (_) in 313 | 314 | self.finish = true 315 | } 316 | 317 | self.setupRemoteTransportControls() 318 | self.setupNowPlaying(track: self.track) 319 | 320 | } 321 | .alert(isPresented: $showingAlert) { 322 | Alert(title: Text("No Internet Connection"), message: Text("Please ensure your device is connected to the internet."), dismissButton: .default(Text("Got it!"))) 323 | 324 | } 325 | 326 | } 327 | 328 | } 329 | .navigationBarTitle("") 330 | .navigationBarHidden(true) 331 | } 332 | 333 | func setupRemoteTransportControls() { 334 | // Get the shared MPRemoteCommandCenter 335 | let commandCenter = MPRemoteCommandCenter.shared() 336 | 337 | // Add handler for Play Command 338 | commandCenter.playCommand.addTarget { [self] event in 339 | print("Play command - is playing: \(self.player.isPlaying)") 340 | if !self.player.isPlaying { 341 | self.player.play() 342 | return .success 343 | } 344 | return .commandFailed 345 | } 346 | 347 | // Add handler for Pause Command 348 | commandCenter.pauseCommand.addTarget { [self] event in 349 | print("Pause command - is playing: \(self.player.isPlaying)") 350 | if self.player.isPlaying { 351 | self.player.pause() 352 | return .success 353 | } 354 | return .commandFailed 355 | } 356 | 357 | } 358 | 359 | func setupNowPlaying(track: Track) { 360 | // Define Now Playing Info 361 | var nowPlayingInfo = [String : Any]() 362 | nowPlayingInfo[MPMediaItemPropertyTitle] = track.title 363 | 364 | if let image = UIImage(named: track.image ?? "") { 365 | nowPlayingInfo[MPMediaItemPropertyArtwork] = MPMediaItemArtwork(boundsSize: image.size) { size in 366 | return image 367 | } 368 | } 369 | 370 | nowPlayingInfo[MPNowPlayingInfoPropertyElapsedPlaybackTime] = player.currentTime().seconds 371 | nowPlayingInfo[MPMediaItemPropertyPlaybackDuration] = player.currentItem!.asset.duration.seconds 372 | nowPlayingInfo[MPNowPlayingInfoPropertyPlaybackRate] = player.rate 373 | 374 | // Set the metadata 375 | MPNowPlayingInfoCenter.default().nowPlayingInfo = nowPlayingInfo 376 | } 377 | 378 | func downloadAndSaveAudioFile(_ audioFile: String, completion: @escaping (String) -> Void) { 379 | 380 | self.isDownloading.toggle() 381 | 382 | //Create directory if not present 383 | let paths = NSSearchPathForDirectoriesInDomains(FileManager.SearchPathDirectory.libraryDirectory, FileManager.SearchPathDomainMask.userDomainMask, true) 384 | let documentDirectory = paths.first! as NSString 385 | let soundDirPathString = documentDirectory.appendingPathComponent(folderName) 386 | 387 | do { 388 | try FileManager.default.createDirectory(atPath: soundDirPathString, withIntermediateDirectories: true, attributes:nil) 389 | print("directory created at \(soundDirPathString)") 390 | } catch let error as NSError { 391 | print("error while creating dir : \(error.localizedDescription)"); 392 | } 393 | 394 | if let audioUrl = URL(string: audioFile) { 395 | // create your document folder url 396 | let documentsUrl = FileManager.default.urls(for: .libraryDirectory, in: .userDomainMask).first! as URL 397 | let documentsFolderUrl = documentsUrl.appendingPathComponent(folderName) 398 | // your destination file url 399 | let destinationUrl = documentsFolderUrl.appendingPathComponent(audioUrl.lastPathComponent) 400 | 401 | print(destinationUrl) 402 | // check if it exists before downloading it 403 | if FileManager().fileExists(atPath: destinationUrl.path) { 404 | print("The file already exists at path") 405 | self.isDownloading.toggle() 406 | } else { 407 | // if the file doesn't exist 408 | // just download the data from your url 409 | DispatchQueue.global(qos: DispatchQoS.QoSClass.background).async(execute: { 410 | if let myAudioDataFromUrl = try? Data(contentsOf: audioUrl){ 411 | // after downloading your data you need to save it to your destination url 412 | if (try? myAudioDataFromUrl.write(to: destinationUrl, options: [.atomic])) != nil { 413 | print("file saved") 414 | completion(destinationUrl.absoluteString) 415 | self.isDownloading.toggle() 416 | } else { 417 | print("error saving file") 418 | completion("") 419 | } 420 | } 421 | }) 422 | } 423 | } 424 | } 425 | 426 | } 427 | --------------------------------------------------------------------------------