NowPlayingView.swift 26 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704
  1. import SwiftUI
  2. /// Full-screen Now Playing view with large artwork, waveform, transport controls, and quick-add.
  3. struct NowPlayingView: View {
  4. @Environment(PlayerViewModel.self) private var playerVM
  5. @Environment(PlaylistViewModel.self) private var playlistVM
  6. @EnvironmentObject private var theme: AppTheme
  7. @Environment(\.modelContext) private var modelContext
  8. @Environment(\.dismiss) private var dismiss
  9. @State private var isDragging = false
  10. @State private var dragProgress: Double = 0
  11. // Lyrics state
  12. @State private var showLyrics = false
  13. @State private var lyrics: [LyricsParser.LyricLine] = []
  14. @State private var lyricsState: LyricsState = .idle
  15. @State private var isSynced = false
  16. @State private var lastLoadedTrackID: UUID?
  17. @State private var showAsPlainText = false
  18. enum LyricsState {
  19. case idle
  20. case loading
  21. case loaded
  22. case notFound
  23. case error(String)
  24. case instrumental
  25. }
  26. var body: some View {
  27. NavigationStack {
  28. ZStack {
  29. theme.background.ignoresSafeArea()
  30. VStack(spacing: 0) {
  31. if showLyrics {
  32. // Lyrics mode: scrollable lyrics panel
  33. lyricsPanel
  34. .transition(.opacity)
  35. } else {
  36. // Normal mode: artwork
  37. artworkSection
  38. .padding(.top, 20)
  39. Spacer(minLength: 16)
  40. }
  41. // Track info
  42. trackInfoSection
  43. .padding(.horizontal, 24)
  44. Spacer(minLength: showLyrics ? 8 : 16)
  45. // Waveform (hide in lyrics mode)
  46. if !showLyrics && playerVM.showingWaveform && !playerVM.waveformSamples.isEmpty {
  47. WaveformView()
  48. .frame(height: 60)
  49. .padding(.horizontal, 24)
  50. }
  51. // Seekbar + time
  52. seekbarSection
  53. .padding(.horizontal, 24)
  54. .padding(.top, 12)
  55. // Transport controls
  56. transportSection
  57. .padding(.top, 16)
  58. // Extra controls
  59. extraControlsSection
  60. .padding(.top, 8)
  61. .padding(.bottom, 20)
  62. }
  63. }
  64. .toolbar {
  65. ToolbarItem(placement: .topBarLeading) {
  66. Button {
  67. dismiss()
  68. } label: {
  69. Image(systemName: "chevron.down")
  70. .font(.title3)
  71. .foregroundStyle(theme.secondaryText)
  72. }
  73. .accessibilityIdentifier("nowPlayingDismiss")
  74. }
  75. ToolbarItem(placement: .topBarTrailing) {
  76. HStack(spacing: 12) {
  77. // Queue button
  78. Button {
  79. playerVM.showQueue = true
  80. } label: {
  81. Image(systemName: "list.bullet")
  82. .foregroundStyle(theme.secondaryText)
  83. }
  84. .accessibilityIdentifier("queueButton")
  85. // Lyrics toggle button
  86. Button {
  87. withAnimation(.easeInOut(duration: 0.3)) {
  88. showLyrics.toggle()
  89. }
  90. if showLyrics {
  91. if case .idle = lyricsState {
  92. loadLyrics()
  93. }
  94. }
  95. } label: {
  96. Image(systemName: showLyrics ? "text.quote.fill" : "text.quote")
  97. .foregroundStyle(showLyrics ? theme.accent : theme.secondaryText)
  98. }
  99. .accessibilityIdentifier("lyricsButton")
  100. Menu {
  101. if let track = playerVM.currentTrack {
  102. Button {
  103. _ = playlistVM.quickAddToTarget(track: track, context: modelContext)
  104. } label: {
  105. Label("Add to Target Playlist", systemImage: "star.fill")
  106. }
  107. Button {
  108. playlistVM.addCuePoint(
  109. to: track,
  110. at: playerVM.currentTime,
  111. name: "Marker at \(playerVM.currentTimeFormatted)",
  112. context: modelContext
  113. )
  114. playlistVM.showStatus("Cue point added")
  115. } label: {
  116. Label("Add Cue Point Here", systemImage: "bookmark.fill")
  117. }
  118. }
  119. } label: {
  120. Image(systemName: "ellipsis.circle")
  121. .foregroundStyle(theme.secondaryText)
  122. }
  123. }
  124. }
  125. }
  126. .toolbarBackground(theme.background, for: .navigationBar)
  127. .onChange(of: playerVM.currentTrack?.id) { _, newID in
  128. if newID != lastLoadedTrackID {
  129. loadLyrics()
  130. }
  131. }
  132. }
  133. }
  134. // MARK: - Artwork
  135. private var artworkSection: some View {
  136. Group {
  137. if let track = playerVM.currentTrack {
  138. LargeArtwork(track: track)
  139. .frame(width: 280, height: 280)
  140. .shadow(radius: 20)
  141. .overlay(alignment: .bottomTrailing) {
  142. if track.isCloud {
  143. Image(systemName: "cloud.fill")
  144. .font(.caption)
  145. .foregroundStyle(.white)
  146. .padding(4)
  147. .background(theme.accent.opacity(0.8))
  148. .clipShape(Circle())
  149. .offset(x: -8, y: -8)
  150. }
  151. }
  152. } else if playerVM.isCloudPlayback {
  153. RoundedRectangle(cornerRadius: 16)
  154. .fill(theme.cardBackground)
  155. .frame(width: 280, height: 280)
  156. .overlay {
  157. VStack(spacing: 8) {
  158. Image(systemName: "cloud.fill")
  159. .font(.system(size: 60))
  160. .foregroundStyle(theme.accent)
  161. if playerVM.isBuffering {
  162. ProgressView()
  163. }
  164. }
  165. }
  166. } else {
  167. RoundedRectangle(cornerRadius: 16)
  168. .fill(theme.cardBackground)
  169. .frame(width: 280, height: 280)
  170. .overlay {
  171. Image(systemName: "music.note")
  172. .font(.system(size: 60))
  173. .foregroundStyle(theme.tertiaryText)
  174. }
  175. }
  176. }
  177. }
  178. // MARK: - Track Info
  179. private var trackInfoSection: some View {
  180. VStack(spacing: 4) {
  181. Text(playerVM.currentTrack?.title ?? playerVM.currentCloudTrack?.title ?? "Not Playing")
  182. .font(.title2.bold())
  183. .foregroundStyle(theme.primaryText)
  184. .lineLimit(1)
  185. .accessibilityIdentifier("nowPlayingTitle")
  186. Text(playerVM.currentTrack?.artist ?? playerVM.currentCloudTrack?.artist ?? "")
  187. .font(.body)
  188. .foregroundStyle(theme.secondaryText)
  189. .lineLimit(1)
  190. .accessibilityIdentifier("nowPlayingArtist")
  191. HStack(spacing: 12) {
  192. if let bpm = playerVM.currentTrack?.bpm {
  193. Label("\(String(format: "%.0f", bpm)) BPM", systemImage: "metronome")
  194. .font(.caption)
  195. .foregroundStyle(theme.tertiaryText)
  196. }
  197. if let key = playerVM.currentTrack?.musicalKey {
  198. Label(key, systemImage: "music.quarternote.3")
  199. .font(.caption)
  200. .foregroundStyle(theme.tertiaryText)
  201. }
  202. if let format = playerVM.currentTrack?.fileFormat {
  203. Text(format)
  204. .font(.caption.weight(.medium))
  205. .foregroundStyle(theme.tertiaryText)
  206. .padding(.horizontal, 5)
  207. .padding(.vertical, 1)
  208. .background(theme.tertiaryText.opacity(0.15))
  209. .clipShape(RoundedRectangle(cornerRadius: 3))
  210. }
  211. }
  212. }
  213. }
  214. // MARK: - Seekbar
  215. private var seekbarSection: some View {
  216. VStack(spacing: 4) {
  217. GeometryReader { geo in
  218. ZStack(alignment: .leading) {
  219. Capsule()
  220. .fill(theme.seekbarBackground)
  221. Capsule()
  222. .fill(theme.seekbarForeground)
  223. .frame(width: max(0, (isDragging ? dragProgress : playerVM.progress) * geo.size.width))
  224. }
  225. .gesture(
  226. DragGesture(minimumDistance: 0)
  227. .onChanged { value in
  228. isDragging = true
  229. dragProgress = max(0, min(1, value.location.x / geo.size.width))
  230. }
  231. .onEnded { value in
  232. let prog = max(0, min(1, value.location.x / geo.size.width))
  233. playerVM.seekToProgress(prog)
  234. isDragging = false
  235. }
  236. )
  237. }
  238. .frame(height: 6)
  239. HStack {
  240. Text(playerVM.currentTimeFormatted)
  241. .font(.caption.monospacedDigit())
  242. .foregroundStyle(theme.secondaryText)
  243. Spacer()
  244. Text(playerVM.remainingTimeFormatted)
  245. .font(.caption.monospacedDigit())
  246. .foregroundStyle(theme.secondaryText)
  247. }
  248. }
  249. }
  250. // MARK: - Transport
  251. private var transportSection: some View {
  252. HStack(spacing: 36) {
  253. Button { playerVM.shuffleEnabled.toggle() } label: {
  254. Image(systemName: "shuffle")
  255. .font(.system(size: 18))
  256. .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText)
  257. .frame(width: 44, height: 44)
  258. }
  259. .buttonStyle(.plain)
  260. .accessibilityIdentifier("shuffleButton")
  261. Button { playerVM.playPrevious() } label: {
  262. Image(systemName: "backward.fill")
  263. .font(.system(size: 28))
  264. .foregroundStyle(theme.primaryText)
  265. .frame(width: 44, height: 44)
  266. }
  267. .buttonStyle(.plain)
  268. .accessibilityIdentifier("previousButton")
  269. Button { playerVM.togglePlayPause() } label: {
  270. Image(systemName: playerVM.isPlaying ? "pause.circle.fill" : "play.circle.fill")
  271. .font(.system(size: 64))
  272. .foregroundStyle(theme.accent)
  273. }
  274. .buttonStyle(.plain)
  275. .accessibilityIdentifier("playPauseButton")
  276. Button { playerVM.playNext() } label: {
  277. Image(systemName: "forward.fill")
  278. .font(.system(size: 28))
  279. .foregroundStyle(theme.primaryText)
  280. .frame(width: 44, height: 44)
  281. }
  282. .buttonStyle(.plain)
  283. .accessibilityIdentifier("nextButton")
  284. Button {
  285. switch playerVM.repeatMode {
  286. case .off: playerVM.repeatMode = .all
  287. case .all: playerVM.repeatMode = .one
  288. case .one: playerVM.repeatMode = .off
  289. }
  290. } label: {
  291. Image(systemName: playerVM.repeatMode.icon)
  292. .font(.system(size: 18))
  293. .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText)
  294. .frame(width: 44, height: 44)
  295. }
  296. .buttonStyle(.plain)
  297. }
  298. }
  299. // MARK: - Extra Controls
  300. private static let mixColors: [Color] = [
  301. Color(red: 0.95, green: 0.3, blue: 0.3),
  302. Color(red: 0.3, green: 0.75, blue: 0.95),
  303. Color(red: 0.95, green: 0.75, blue: 0.2),
  304. ]
  305. private var extraControlsSection: some View {
  306. HStack(spacing: 20) {
  307. // 3 Mix buttons
  308. ForEach(0..<3, id: \.self) { slot in
  309. Button {
  310. guard let track = playerVM.currentTrack else { return }
  311. _ = playlistVM.quickAddToMix(slot: slot, track: track, context: modelContext)
  312. } label: {
  313. VStack(spacing: 4) {
  314. Text("\(slot + 1)")
  315. .font(.system(size: 16, weight: .bold, design: .rounded))
  316. .frame(width: 36, height: 36)
  317. .foregroundStyle(
  318. playlistVM.mixTargets[slot] != nil
  319. ? Self.mixColors[slot]
  320. : theme.tertiaryText
  321. )
  322. .background(
  323. playlistVM.mixTargets[slot] != nil
  324. ? Self.mixColors[slot].opacity(0.15)
  325. : theme.tertiaryText.opacity(0.08)
  326. )
  327. .clipShape(RoundedRectangle(cornerRadius: 8))
  328. Text(playlistVM.mixTargetName(slot))
  329. .font(.system(size: 9))
  330. .foregroundStyle(theme.tertiaryText)
  331. .lineLimit(1)
  332. }
  333. .frame(minWidth: 50, minHeight: 44)
  334. }
  335. .buttonStyle(.plain)
  336. }
  337. Spacer()
  338. // Skip backward
  339. Button { playerVM.skipBackward() } label: {
  340. VStack(spacing: 4) {
  341. Image(systemName: "gobackward.10")
  342. .font(.system(size: 20))
  343. Text("-10s")
  344. .font(.caption2)
  345. }
  346. .foregroundStyle(theme.secondaryText)
  347. .frame(minWidth: 44, minHeight: 44)
  348. }
  349. .buttonStyle(.plain)
  350. // Skip forward
  351. Button { playerVM.skipForward() } label: {
  352. VStack(spacing: 4) {
  353. Image(systemName: "goforward.10")
  354. .font(.system(size: 20))
  355. Text("+10s")
  356. .font(.caption2)
  357. }
  358. .foregroundStyle(theme.secondaryText)
  359. .frame(minWidth: 44, minHeight: 44)
  360. }
  361. .buttonStyle(.plain)
  362. }
  363. .padding(.horizontal, 24)
  364. }
  365. // MARK: - Lyrics Panel
  366. private var lyricsPanel: some View {
  367. VStack(spacing: 0) {
  368. // Lyrics header with synced/plain toggle
  369. HStack {
  370. Text("Lyrics")
  371. .font(.headline)
  372. .foregroundStyle(theme.secondaryText)
  373. Spacer()
  374. if case .loaded = lyricsState, isSynced {
  375. Button {
  376. showAsPlainText.toggle()
  377. } label: {
  378. HStack(spacing: 4) {
  379. Image(systemName: showAsPlainText ? "text.alignleft" : "waveform")
  380. .font(.system(size: 10))
  381. Text(showAsPlainText ? "Plain" : "Synced")
  382. .font(.caption)
  383. }
  384. .padding(.horizontal, 8)
  385. .padding(.vertical, 3)
  386. .background(showAsPlainText ? theme.tertiaryText.opacity(0.15) : theme.accent.opacity(0.2))
  387. .foregroundStyle(showAsPlainText ? theme.secondaryText : theme.accent)
  388. .clipShape(Capsule())
  389. }
  390. .buttonStyle(.plain)
  391. }
  392. }
  393. .padding(.horizontal, 24)
  394. .padding(.top, 12)
  395. .padding(.bottom, 4)
  396. // Lyrics content
  397. switch lyricsState {
  398. case .idle, .loading:
  399. Spacer()
  400. ProgressView()
  401. .scaleEffect(0.8)
  402. Text("Searching for lyrics…")
  403. .font(.callout)
  404. .foregroundStyle(theme.tertiaryText)
  405. .padding(.top, 8)
  406. Spacer()
  407. case .notFound:
  408. Spacer()
  409. VStack(spacing: 12) {
  410. Image(systemName: "text.page.slash")
  411. .font(.system(size: 36))
  412. .foregroundStyle(theme.tertiaryText)
  413. Text("No lyrics found")
  414. .font(.title3)
  415. .foregroundStyle(theme.secondaryText)
  416. if let track = playerVM.currentTrack {
  417. Text("\(track.artist) — \(track.title)")
  418. .font(.caption)
  419. .foregroundStyle(theme.tertiaryText)
  420. }
  421. }
  422. Spacer()
  423. case .instrumental:
  424. Spacer()
  425. VStack(spacing: 12) {
  426. Image(systemName: "pianokeys")
  427. .font(.system(size: 36))
  428. .foregroundStyle(theme.tertiaryText)
  429. Text("Instrumental")
  430. .font(.title3)
  431. .foregroundStyle(theme.secondaryText)
  432. }
  433. Spacer()
  434. case .error(let message):
  435. Spacer()
  436. VStack(spacing: 12) {
  437. Image(systemName: "exclamationmark.triangle")
  438. .font(.system(size: 36))
  439. .foregroundStyle(theme.tertiaryText)
  440. Text(message)
  441. .font(.callout)
  442. .foregroundStyle(theme.secondaryText)
  443. }
  444. Spacer()
  445. case .loaded:
  446. if isSynced && !showAsPlainText {
  447. SyncedLyricsView(
  448. lines: lyrics,
  449. currentTime: playerVM.currentTime,
  450. accent: theme.accent,
  451. secondaryText: theme.secondaryText,
  452. primaryText: theme.primaryText,
  453. onSeek: { time in playerVM.seek(to: time) }
  454. )
  455. } else {
  456. PlainLyricsView(lines: lyrics, primaryText: theme.primaryText)
  457. }
  458. }
  459. }
  460. .accessibilityIdentifier("lyricsPanel")
  461. }
  462. // MARK: - Lyrics Loading
  463. private func loadLyrics() {
  464. guard let track = playerVM.currentTrack else {
  465. lyricsState = .idle
  466. lyrics = []
  467. return
  468. }
  469. lastLoadedTrackID = track.id
  470. lyricsState = .loading
  471. lyrics = []
  472. Task {
  473. do {
  474. let result = try await LRCLIBService.shared.fetchLyrics(
  475. artist: track.artist,
  476. title: track.title,
  477. album: track.album.isEmpty ? nil : track.album,
  478. duration: track.duration
  479. )
  480. if result.isInstrumental {
  481. lyricsState = .instrumental
  482. return
  483. }
  484. if let synced = result.syncedLyrics, !synced.isEmpty {
  485. lyrics = LyricsParser.parseSynced(synced)
  486. isSynced = true
  487. lyricsState = .loaded
  488. } else if let plain = result.plainLyrics, !plain.isEmpty {
  489. lyrics = LyricsParser.parsePlain(plain)
  490. isSynced = false
  491. lyricsState = .loaded
  492. } else {
  493. lyricsState = .notFound
  494. }
  495. } catch is LyricsError {
  496. lyricsState = .notFound
  497. } catch {
  498. lyricsState = .error(error.localizedDescription)
  499. }
  500. }
  501. }
  502. }
  503. // MARK: - Large Artwork
  504. struct LargeArtwork: View {
  505. let track: Track
  506. @EnvironmentObject private var theme: AppTheme
  507. @State private var artwork: UIImage?
  508. var body: some View {
  509. Group {
  510. if let image = artwork {
  511. Image(uiImage: image)
  512. .resizable()
  513. .aspectRatio(contentMode: .fill)
  514. } else {
  515. ZStack {
  516. RoundedRectangle(cornerRadius: 16)
  517. .fill(theme.cardBackground)
  518. Image(systemName: "music.note")
  519. .font(.system(size: 60))
  520. .foregroundStyle(theme.tertiaryText)
  521. }
  522. }
  523. }
  524. .clipShape(RoundedRectangle(cornerRadius: 16))
  525. .task {
  526. let url = track.fileURL
  527. artwork = await ArtworkService.shared.artwork(for: url)
  528. }
  529. }
  530. }
  531. // MARK: - Volume Slider
  532. struct VolumeSlider: View {
  533. @Environment(PlayerViewModel.self) private var playerVM
  534. @EnvironmentObject private var theme: AppTheme
  535. var body: some View {
  536. VStack(spacing: 4) {
  537. Image(systemName: volumeIcon)
  538. .font(.system(size: 20))
  539. .foregroundStyle(theme.secondaryText)
  540. Text("\(Int(playerVM.volume * 100))%")
  541. .font(.caption2)
  542. .foregroundStyle(theme.tertiaryText)
  543. }
  544. .onTapGesture {
  545. // Toggle between mute and previous volume
  546. if playerVM.volume > 0 {
  547. playerVM.volume = 0
  548. } else {
  549. playerVM.volume = 0.8
  550. }
  551. }
  552. }
  553. private var volumeIcon: String {
  554. if playerVM.volume == 0 { return "speaker.slash" }
  555. if playerVM.volume < 0.3 { return "speaker.wave.1" }
  556. if playerVM.volume < 0.7 { return "speaker.wave.2" }
  557. return "speaker.wave.3"
  558. }
  559. }
  560. // MARK: - Synced Lyrics View (auto-scrolling, highlighted)
  561. struct SyncedLyricsView: View {
  562. let lines: [LyricsParser.LyricLine]
  563. let currentTime: TimeInterval
  564. let accent: Color
  565. let secondaryText: Color
  566. let primaryText: Color
  567. let onSeek: (TimeInterval) -> Void
  568. @State private var currentIndex: Int?
  569. var body: some View {
  570. ScrollViewReader { proxy in
  571. ScrollView {
  572. LazyVStack(alignment: .leading, spacing: 8) {
  573. Spacer()
  574. .frame(height: 20)
  575. ForEach(Array(lines.enumerated()), id: \.element.id) { index, line in
  576. let isCurrent = index == currentIndex
  577. Text(line.text.isEmpty ? "♪" : line.text)
  578. .font(.system(size: isCurrent ? 20 : 17, weight: isCurrent ? .semibold : .regular))
  579. .foregroundStyle(isCurrent ? accent : (index < (currentIndex ?? 0) ? secondaryText : primaryText))
  580. .opacity(isCurrent ? 1.0 : (index < (currentIndex ?? 0) ? 0.5 : 0.7))
  581. .padding(.horizontal, 24)
  582. .padding(.vertical, 2)
  583. .id(line.id)
  584. .onTapGesture {
  585. onSeek(line.timestamp)
  586. }
  587. .animation(.easeInOut(duration: 0.3), value: isCurrent)
  588. }
  589. Spacer()
  590. .frame(height: 60)
  591. }
  592. }
  593. .onChange(of: currentTime) { _, time in
  594. let newIndex = LyricsParser.currentLineIndex(in: lines, at: time)
  595. if newIndex != currentIndex {
  596. currentIndex = newIndex
  597. if let idx = newIndex, idx < lines.count {
  598. withAnimation(.easeInOut(duration: 0.4)) {
  599. proxy.scrollTo(lines[idx].id, anchor: .center)
  600. }
  601. }
  602. }
  603. }
  604. .onAppear {
  605. currentIndex = LyricsParser.currentLineIndex(in: lines, at: currentTime)
  606. if let idx = currentIndex, idx < lines.count {
  607. proxy.scrollTo(lines[idx].id, anchor: .center)
  608. }
  609. }
  610. }
  611. }
  612. }
  613. // MARK: - Plain Lyrics View (no timestamps)
  614. struct PlainLyricsView: View {
  615. let lines: [LyricsParser.LyricLine]
  616. let primaryText: Color
  617. var body: some View {
  618. ScrollView {
  619. LazyVStack(alignment: .leading, spacing: 6) {
  620. Spacer()
  621. .frame(height: 16)
  622. ForEach(lines) { line in
  623. Text(line.text.isEmpty ? " " : line.text)
  624. .font(.system(size: 16))
  625. .foregroundStyle(line.text.isEmpty ? .clear : primaryText.opacity(0.8))
  626. .padding(.horizontal, 24)
  627. }
  628. Spacer()
  629. .frame(height: 40)
  630. }
  631. }
  632. }
  633. }