├── .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 | 
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 |
--------------------------------------------------------------------------------