/ flo / PlayerView.swift
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  }