MiniPlayerView.swift 4.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121
  1. import SwiftUI
  2. /// Compact bottom bar showing current track with play/pause and next.
  3. /// Tapping opens the full Now Playing view.
  4. struct MiniPlayerView: View {
  5. @Environment(PlayerViewModel.self) private var playerVM
  6. @EnvironmentObject private var theme: AppTheme
  7. var body: some View {
  8. VStack(spacing: 0) {
  9. // Progress bar
  10. GeometryReader { geo in
  11. ZStack(alignment: .leading) {
  12. Rectangle()
  13. .fill(theme.seekbarBackground)
  14. Rectangle()
  15. .fill(theme.seekbarForeground)
  16. .frame(width: max(0, playerVM.progress * geo.size.width))
  17. }
  18. }
  19. .frame(height: 3)
  20. HStack(spacing: 12) {
  21. // Artwork or icon
  22. if let track = playerVM.currentTrack {
  23. ArtworkThumbnail(track: track)
  24. .frame(width: 40, height: 40)
  25. .overlay(alignment: .bottomTrailing) {
  26. if track.isCloud {
  27. Image(systemName: "cloud.fill")
  28. .font(.system(size: 10))
  29. .foregroundStyle(.white)
  30. .padding(2)
  31. .background(theme.accent.opacity(0.8))
  32. .clipShape(Circle())
  33. .offset(x: 2, y: 2)
  34. }
  35. }
  36. } else if playerVM.isCloudPlayback {
  37. // Cloud track without SwiftData Track (direct play from browser)
  38. Image(systemName: "cloud.fill")
  39. .font(.title2)
  40. .foregroundStyle(theme.accent)
  41. .frame(width: 40, height: 40)
  42. .background(theme.accent.opacity(0.1))
  43. .clipShape(RoundedRectangle(cornerRadius: 8))
  44. }
  45. // Track info
  46. VStack(alignment: .leading, spacing: 1) {
  47. Text(playerVM.currentTrack?.title ?? playerVM.currentCloudTrack?.title ?? "Not Playing")
  48. .font(.system(size: 14, weight: .medium))
  49. .foregroundStyle(theme.primaryText)
  50. .lineLimit(1)
  51. if let artist = playerVM.currentTrack?.artist ?? playerVM.currentCloudTrack?.artist, !artist.isEmpty {
  52. Text(artist)
  53. .font(.system(size: 12))
  54. .foregroundStyle(theme.secondaryText)
  55. .lineLimit(1)
  56. }
  57. }
  58. Spacer()
  59. // Buffering indicator
  60. if playerVM.isBuffering {
  61. ProgressView()
  62. .scaleEffect(0.7)
  63. }
  64. // Transport
  65. HStack(spacing: 16) {
  66. Button {
  67. playerVM.togglePlayPause()
  68. } label: {
  69. Image(systemName: playerVM.isPlaying ? "pause.fill" : "play.fill")
  70. .font(.system(size: 22))
  71. .foregroundStyle(theme.accent)
  72. .frame(width: 44, height: 44)
  73. .contentShape(Rectangle())
  74. }
  75. .buttonStyle(.plain)
  76. .accessibilityIdentifier("miniPlayerPlayPause")
  77. Button {
  78. playerVM.playNext()
  79. } label: {
  80. Image(systemName: "forward.fill")
  81. .font(.system(size: 18))
  82. .foregroundStyle(theme.secondaryText)
  83. .frame(width: 44, height: 44)
  84. .contentShape(Rectangle())
  85. }
  86. .buttonStyle(.plain)
  87. .accessibilityIdentifier("miniPlayerNext")
  88. Button {
  89. playerVM.showQueue = true
  90. } label: {
  91. Image(systemName: "list.bullet")
  92. .font(.system(size: 16))
  93. .foregroundStyle(theme.secondaryText)
  94. .frame(width: 36, height: 44)
  95. .contentShape(Rectangle())
  96. }
  97. .buttonStyle(.plain)
  98. .accessibilityIdentifier("miniPlayerQueue")
  99. }
  100. }
  101. .padding(.horizontal, 16)
  102. .padding(.vertical, 8)
  103. .background(theme.playerBarBackground)
  104. }
  105. .background(theme.playerBarBackground)
  106. .accessibilityIdentifier("miniPlayer")
  107. .onTapGesture {
  108. playerVM.showNowPlaying = true
  109. }
  110. }
  111. }