PlayerView.swift
1 // 2 // PlayerView.swift 3 // flo 4 // 5 // Created by rizaldy on 01/06/24. 6 // 7 8 import NukeUI 9 import SwiftUI 10 11 struct PlayerView: View { 12 @Binding var isExpanded: Bool 13 14 @ObservedObject var viewModel: PlayerViewModel 15 16 @State private var offset = CGSize.zero 17 @State private var isDragging = false 18 19 @State private var showQueue = false 20 21 @GestureState private var queueDragOffset: CGSize = .zero 22 23 var body: some View { 24 GeometryReader { 25 let size = $0.size 26 let imageSize: CGFloat = 300 27 28 // FIXME: Refactor this? 29 ZStack(alignment: .topLeading) { 30 Color(.systemBackground) 31 .ignoresSafeArea() 32 .clipShape( 33 RoundedRectangle(cornerRadius: 15, style: .continuous) 34 ) 35 VStack(alignment: .leading) { 36 HStack { 37 Spacer() 38 39 Rectangle() 40 .foregroundColor(Color.gray.opacity(0.3)) 41 .frame(width: 50, height: 5) 42 .cornerRadius(30) 43 .padding(.top) 44 45 Spacer() 46 } 47 VStack(alignment: .leading, spacing: 3) { 48 Text("Playing Next").customFont(.headline) 49 50 HStack(alignment: .bottom, spacing: 10) { 51 if viewModel.queue.isEmpty { 52 Text("").customFont(.subheadline) 53 } else { 54 Text( 55 "From \(viewModel.nowPlaying.contextName ?? viewModel.nowPlaying.albumName ?? "")" 56 ).customFont(.subheadline) 57 } 58 59 Spacer() 60 61 Button { 62 viewModel.shuffleCurrentQueue() 63 } label: { 64 Image(systemName: "shuffle") 65 .foregroundColor(Color.accentColor) 66 .fontWeight(.bold) 67 .padding(5) 68 .background( 69 viewModel.isShuffling ? Color.gray.opacity(0.2) : Color(.systemBackground) 70 ) 71 .cornerRadius(5) 72 } 73 74 Button { 75 viewModel.setPlaybackMode() 76 } label: { 77 Image(systemName: "repeat") 78 .foregroundColor(Color.accentColor) 79 .fontWeight(.bold) 80 .overlay( 81 Group { 82 Text("1") 83 .font(.caption) 84 .clipShape(Circle()) 85 .offset(x: 10, y: -5) 86 .fontWeight(.bold) 87 }.opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) 88 ) 89 .padding(5) 90 .background( 91 viewModel.playbackMode == PlaybackMode.defaultPlayback 92 ? Color(.systemBackground) : Color.gray.opacity(0.2) 93 ) 94 .cornerRadius(5) 95 } 96 } 97 } 98 .padding(.horizontal) 99 .padding(.bottom, 5) 100 101 ScrollView { 102 LazyVStack(alignment: .leading) { 103 ForEach(viewModel.queue.indices, id: \.self) { idx in 104 HStack(alignment: .top) { 105 VStack(alignment: .leading) { 106 Text(viewModel.queue[idx].songName ?? "") 107 .customFont(.callout) 108 .fontWeight(.medium) 109 .padding(.bottom, 3) 110 111 Text(viewModel.queue[idx].artistName ?? "") 112 .customFont(.caption1) 113 } 114 .frame(maxWidth: .infinity, alignment: .leading) 115 116 Spacer() 117 118 Text(timeString(for: viewModel.queue[idx].duration)).customFont(.caption1) 119 .padding(.top, 4) 120 } 121 .padding(.vertical, 5) 122 .padding(.horizontal) 123 .background( 124 viewModel.activeQueueIdx == idx 125 ? Color.gray.opacity(0.1) : Color(.systemBackground) 126 ) 127 .onTapGesture { 128 viewModel.playFromQueue(idx: idx) 129 } 130 } 131 } 132 }.padding(.bottom, 60) 133 } 134 } 135 .gesture( 136 DragGesture() 137 .updating($queueDragOffset) { value, state, _ in 138 if value.translation.height > 0 { 139 state = value.translation 140 } 141 } 142 .onEnded { value in 143 if value.translation.height > 100 { 144 self.showQueue = false 145 } 146 } 147 ) 148 .animation(.spring(duration: 0.4), value: queueDragOffset.height) 149 .foregroundColor(.primary) 150 .zIndex(1) 151 .offset( 152 y: showQueue 153 ? UIScreen.main.bounds.height - 500 + queueDragOffset.height : UIScreen.main.bounds.height 154 ) 155 .frame(height: 500) 156 .animation(.spring(duration: 0.2), value: showQueue) 157 158 ZStack { 159 if viewModel.isLyricsMode { 160 LyricsView( 161 viewModel: viewModel, 162 showQueue: $showQueue, 163 imageSize: imageSize 164 ).transition(.opacity.combined(with: .move(edge: .bottom))) 165 } 166 167 if !viewModel.isLyricsMode { 168 mainPlayerView(size: size, imageSize: imageSize).transition(.opacity) 169 } 170 } 171 .frame(maxHeight: .infinity) 172 .onChange(of: viewModel.isLiveRadio) { isLive in 173 if isLive { 174 showQueue = false 175 } 176 } 177 .background { 178 ZStack { 179 if UserDefaultsManager.playerBackground == PlayerBackground.translucent { 180 if let image = UIImage(contentsOfFile: viewModel.getAlbumCoverArt()) { 181 Image(uiImage: image) 182 .resizable() 183 .frame(maxWidth: .infinity, maxHeight: .infinity) 184 .blur(radius: 50, opaque: true) 185 .edgesIgnoringSafeArea(.all) 186 } else { 187 LazyImage(url: URL(string: viewModel.getAlbumCoverArt())) { state in 188 if let image = state.image { 189 image 190 .resizable() 191 .frame(maxWidth: .infinity, maxHeight: .infinity) 192 .blur(radius: 50, opaque: true) 193 .edgesIgnoringSafeArea(.all) 194 } 195 } 196 } 197 198 Rectangle().fill(.thinMaterial).edgesIgnoringSafeArea(.all) 199 } else { 200 Rectangle().fill(Color("PlayerColor")).edgesIgnoringSafeArea(.all) 201 } 202 } 203 .environment(\.colorScheme, .dark) 204 .clipShape( 205 RoundedRectangle(cornerRadius: 25, style: .continuous) 206 ).edgesIgnoringSafeArea(.all) 207 } 208 .offset(y: offset.height) 209 .gesture( 210 DragGesture() 211 .onChanged { gesture in 212 if !viewModel.isLyricsMode { 213 if gesture.translation.height > 0 { 214 offset = gesture.translation 215 isDragging = true 216 } 217 } 218 } 219 .onEnded { _ in 220 if offset.height > size.height / 3 { 221 isExpanded = false 222 } 223 offset = .zero 224 isDragging = false 225 } 226 ) 227 228 } 229 .foregroundColor(.white) 230 } 231 232 @ViewBuilder 233 private func mainPlayerView(size: CGSize, imageSize: CGFloat) -> some View { 234 VStack { 235 Rectangle() 236 .foregroundColor(Color.gray.opacity(0.8)) 237 .frame(width: 50, height: 5) 238 .cornerRadius(30) 239 .padding(.top, 20) 240 241 Spacer() 242 let coverArtUrl = viewModel.getAlbumCoverArt() 243 if let image = UIImage(contentsOfFile: coverArtUrl) { 244 Image(uiImage: image) 245 .resizable() 246 .aspectRatio(contentMode: .fit) 247 .frame(width: imageSize, height: imageSize) 248 .clipShape( 249 RoundedRectangle(cornerRadius: 15, style: .continuous) 250 ) 251 } else { 252 LazyImage(url: URL(string: coverArtUrl)) { state in 253 if state.isLoading { 254 Color.gray.opacity(0.3) 255 .frame(width: imageSize, height: imageSize) 256 .clipShape( 257 RoundedRectangle(cornerRadius: 15, style: .continuous) 258 ) 259 } else { 260 if let image = state.image { 261 image 262 .resizable() 263 .aspectRatio(contentMode: .fit) 264 .frame(width: imageSize, height: imageSize) 265 .clipShape( 266 RoundedRectangle(cornerRadius: 15, style: .continuous) 267 ) 268 } else if state.error != nil { 269 Image("placeholder") 270 .resizable() 271 .aspectRatio(contentMode: .fit) 272 .frame(width: imageSize, height: imageSize) 273 .clipShape( 274 RoundedRectangle(cornerRadius: 15, style: .continuous) 275 ) 276 } 277 } 278 } 279 } 280 281 Spacer() 282 283 VStack(alignment: .center, spacing: 10) { 284 Text(viewModel.nowPlaying.songName ?? "") 285 .foregroundColor(.white) 286 .customFont(.title2) 287 .fontWeight(.bold) 288 .multilineTextAlignment(.center) 289 .lineLimit(3) 290 291 Text(viewModel.nowPlaying.artistName ?? "") 292 .foregroundColor(.white.opacity(0.8)) 293 .customFont(.title3) 294 .multilineTextAlignment(.center) 295 .lineLimit(2) 296 } 297 298 Spacer() 299 300 if viewModel.isLiveRadio { 301 HStack { 302 Spacer() 303 304 Button { 305 viewModel.isPlaying ? viewModel.pause() : viewModel.play() 306 } label: { 307 Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") 308 .font(.system(size: 50)) 309 } 310 .foregroundColor(viewModel.isMediaLoading ? .gray : .white) 311 .disabled(viewModel.isMediaLoading) 312 313 Spacer() 314 } 315 } else { 316 HStack(spacing: size.width * 0.15) { 317 Button { 318 viewModel.prevSong() 319 } label: { 320 Image(systemName: "backward.fill").font(.title) 321 } 322 323 Button { 324 viewModel.isPlaying ? viewModel.pause() : viewModel.play() 325 } label: { 326 Image(systemName: viewModel.isPlaying ? "pause.fill" : "play.fill") 327 .font(.system(size: 50)) 328 } 329 .foregroundColor(viewModel.isMediaLoading ? .gray : .white) 330 .disabled(viewModel.isMediaLoading) 331 332 Button { 333 viewModel.nextSong() 334 } label: { 335 Image(systemName: "forward.fill").font(.title) 336 } 337 } 338 } 339 340 Spacer() 341 342 VStack { 343 if viewModel.isLiveRadio { 344 liveProgressBar() 345 } else { 346 PlayerCustomSlider( 347 isMediaLoading: viewModel.isMediaLoading, 348 isSeeking: $viewModel.isSeeking, value: $viewModel.progress, range: 0...1 349 ) { newValue in 350 viewModel.seek(to: newValue) 351 } 352 } 353 354 HStack { 355 Text(viewModel.isLiveRadio ? "" : viewModel.currentTimeString) 356 .foregroundColor(.white) 357 .customFont(.caption2) 358 .frame(width: 60, alignment: .leading) 359 360 Spacer() 361 362 Text( 363 viewModel.isLiveRadio 364 ? "LIVE" 365 : (viewModel.isPlayFromSource 366 ? "\(viewModel.nowPlaying.suffix ?? "") \(viewModel.nowPlaying.bitRate.description)" 367 : "\(TranscodingSettings.targetFormat) \(UserDefaultsManager.maxBitRate)") 368 ) 369 .foregroundColor(.white) 370 .customFont(.caption2) 371 .fontWeight(.bold) 372 .textCase(.uppercase) 373 .frame(maxWidth: .infinity, alignment: .center) 374 375 Spacer() 376 377 Text(viewModel.isLiveRadio ? "" : viewModel.totalTimeString) 378 .foregroundColor(.white) 379 .customFont(.caption2) 380 .frame(width: 60, alignment: .trailing) 381 } 382 } 383 384 Spacer() 385 386 bottomControlBar(showQueue: $showQueue) 387 } 388 .padding(.horizontal, 30) 389 } 390 391 @ViewBuilder 392 private func bottomControlBar(showQueue: Binding<Bool>) -> some View { 393 let isLyricsDisabled = 394 viewModel.isLiveRadio || (viewModel.lyrics.isEmpty && (viewModel.lyricsError != nil)) 395 396 let isQueueDisabled = viewModel.isLiveRadio 397 398 HStack(spacing: 0) { 399 Button { 400 viewModel.toggleLyricsMode() 401 } label: { 402 Image(systemName: "quote.bubble") 403 .font(.title2) 404 .foregroundColor(isLyricsDisabled ? .white.opacity(0.4) : .white) 405 } 406 .disabled(isLyricsDisabled) 407 .frame(width: 56, alignment: .leading) 408 409 AirPlayRoutePicker(tintColor: UIColor.white, activeTintColor: UIColor.white) 410 .frame(width: 36, height: 36, alignment: .center) 411 .frame(maxWidth: .infinity, alignment: .center) 412 .overlay(alignment: .bottom) { 413 if let outputName = viewModel.externalOutputName { 414 Text(outputName) 415 .foregroundColor(.white) 416 .customFont(.caption2) 417 .fontWeight(.bold) 418 .lineLimit(2) 419 .multilineTextAlignment(.center) 420 .frame(maxWidth: 260) 421 .fixedSize(horizontal: false, vertical: true) 422 .offset(y: 13) 423 } 424 } 425 426 Button { 427 if !isQueueDisabled { 428 showQueue.wrappedValue.toggle() 429 } 430 } label: { 431 Image(systemName: "list.bullet") 432 .font(.title2) 433 .overlay( 434 Group { 435 Image(systemName: "repeat") 436 .font(.caption) 437 .overlay( 438 Group { 439 Text("1") 440 .font(.system(size: 8)) 441 } 442 .offset(x: 7, y: -4) 443 .opacity(viewModel.playbackMode == PlaybackMode.repeatOnce ? 1 : 0) 444 ) 445 .opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 1) 446 } 447 .padding(5) 448 .background( 449 .black.opacity(viewModel.playbackMode == PlaybackMode.defaultPlayback ? 0 : 0.2) 450 ) 451 .clipShape(Circle()) 452 .offset(x: 10, y: -10) 453 ) 454 } 455 .disabled(isQueueDisabled) 456 .opacity(isQueueDisabled ? 0.4 : 1) 457 .frame(width: 56, alignment: .trailing) 458 } 459 } 460 461 @ViewBuilder 462 private func liveProgressBar() -> some View { 463 GeometryReader { geometry in 464 ZStack(alignment: .leading) { 465 Capsule() 466 .fill(Color.gray.opacity(0.8)) 467 .frame(height: 5) 468 469 Capsule() 470 .fill(Color.white) 471 .frame(width: geometry.size.width, height: 4) 472 } 473 } 474 .frame(height: 20) 475 } 476 } 477 478 struct PlayerView_previews: PreviewProvider { 479 @StateObject static var viewModel = PlayerViewModel() 480 @State static var isExpanded: Bool = true 481 482 static var previews: some View { 483 PlayerView(isExpanded: $isExpanded, viewModel: viewModel) 484 } 485 }