import SwiftUI struct PlayerView: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme @AppStorage("playbackMode") private var playbackMode: String = "queue" var body: some View { @Bindable var vm = playerVM VStack(spacing: 0) { WaveformDisplay() if !playerVM.waveformSamples.isEmpty { Rectangle() .fill(theme.waveformSeparator) .frame(height: 10) } SeekSlider() HStack(spacing: 0) { // Transport zone (left) HStack(spacing: 4) { Button { playerVM.playPrevious() } label: { Image(systemName: "backward.end.fill") .font(.system(size: 18)) .frame(width: 36, height: 36) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) .help("Previous Track") Button { playerVM.togglePlayPause() } label: { Image(systemName: playerVM.isPlaying ? "pause.fill" : "play.fill") .font(.system(size: 24)) .frame(width: 44, height: 44) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) .help(playerVM.isPlaying ? "Pause (Space)" : "Play (Space)") Button { playerVM.playNext() } label: { Image(systemName: "forward.end.fill") .font(.system(size: 18)) .frame(width: 36, height: 36) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) .help("Next Track") HStack(spacing: 4) { Button { playerVM.shuffleEnabled.toggle() } label: { Image(systemName: "shuffle") .font(.system(size: 14)) .frame(width: 28, height: 28) .foregroundStyle(playerVM.shuffleEnabled ? theme.accent : theme.tertiaryText) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(playerVM.shuffleEnabled ? "Shuffle: On" : "Shuffle: Off") Button { switch playerVM.repeatMode { case .off: playerVM.repeatMode = .all case .all: playerVM.repeatMode = .one case .one: playerVM.repeatMode = .off } } label: { Image(systemName: playerVM.repeatMode == .one ? "repeat.1" : "repeat") .font(.system(size: 14)) .frame(width: 28, height: 28) .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Repeat: \(playerVM.repeatMode.rawValue)") } .padding(.leading, 8) } .padding(.leading, 12) Spacer(minLength: 8) // Track info zone (center) if let track = playerVM.currentTrack { HStack(spacing: 8) { ArtworkView(track: track, size: 44) VStack(alignment: .leading, spacing: 2) { Text(track.title) .font(.system(size: 13, weight: .semibold)) .lineLimit(1) .foregroundStyle(theme.primaryText) Text(track.artist.isEmpty ? "Unknown Artist" : track.artist) .font(.system(size: 11)) .lineLimit(1) .foregroundStyle(theme.secondaryText) } } .frame(maxWidth: 360) } else { Text("Not Playing") .font(.system(size: 13)) .foregroundStyle(theme.tertiaryText) } Spacer(minLength: 8) // Time + Volume zone (right) HStack(spacing: 12) { if playerVM.currentTrack != nil { Text("\(playerVM.currentTimeFormatted) / \(playerVM.durationFormatted)") .font(.system(size: 11, design: .monospaced)) .foregroundStyle(theme.secondaryText) .fixedSize() } HStack(spacing: 4) { Image(systemName: volumeIcon) .font(.system(size: 10)) .foregroundStyle(theme.secondaryText) Slider(value: $vm.volume, in: 0...1) .frame(width: 80) .controlSize(.small) .tint(theme.accent) } .help("Volume: \(Int(playerVM.volume * 100))%") if playerVM.currentTrack != nil { NowPlayingButton() } } .padding(.trailing, 12) } .frame(height: 64) } .background(.bar) } private var volumeIcon: String { if playerVM.volume == 0 { return "speaker.slash.fill" } if playerVM.volume < 0.33 { return "speaker.wave.1.fill" } if playerVM.volume < 0.66 { return "speaker.wave.2.fill" } return "speaker.wave.3.fill" } } // MARK: - Waveform Display private struct WaveformDisplay: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme @State private var isDragging = false @State private var dragProgress: Double = 0 var body: some View { if !playerVM.waveformSamples.isEmpty { GeometryReader { geo in let progress = isDragging ? dragProgress : playerVM.progress let samples = playerVM.waveformSamples Canvas { context, size in let midY = size.height / 2 let count = samples.count guard count > 0 else { return } let playedIndex = Int(progress * Double(count)) for (index, sample) in samples.enumerated() { let x = CGFloat(index) / CGFloat(count) * size.width let barWidth = max(0.5, size.width / CGFloat(count) - 0.3) let topHeight = CGFloat(sample.max) * midY let bottomHeight = CGFloat(-sample.min) * midY let rect = CGRect( x: x, y: midY - topHeight, width: barWidth, height: topHeight + bottomHeight ) let color = index < playedIndex ? theme.primaryText : theme.waveformBackground context.fill(Path(rect), with: .color(color)) } let playheadX = progress * size.width var playheadPath = Path() playheadPath.move(to: CGPoint(x: playheadX, y: 0)) playheadPath.addLine(to: CGPoint(x: playheadX, y: size.height)) context.stroke(playheadPath, with: .color(theme.accent), lineWidth: 1.5) } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true dragProgress = max(0, min(1, value.location.x / geo.size.width)) } .onEnded { value in let prog = max(0, min(1, value.location.x / geo.size.width)) playerVM.seekToProgress(prog) isDragging = false } ) } .frame(height: 48) } } } // MARK: - Seek Slider private struct SeekSlider: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme @State private var isDragging = false @State private var dragProgress: Double = 0 var body: some View { GeometryReader { geo in let progress = isDragging ? dragProgress : playerVM.progress ZStack(alignment: .leading) { Rectangle() .fill(theme.seekbarBackground) Rectangle() .fill(theme.accent) .frame(width: max(0, progress * geo.size.width)) } .contentShape(Rectangle()) .gesture( DragGesture(minimumDistance: 0) .onChanged { value in isDragging = true dragProgress = max(0, min(1, value.location.x / geo.size.width)) } .onEnded { value in let prog = max(0, min(1, value.location.x / geo.size.width)) playerVM.seekToProgress(prog) isDragging = false } ) } .frame(height: 4) } } // MARK: - Now Playing Button private struct NowPlayingButton: View { @EnvironmentObject private var theme: AppTheme var body: some View { Button { NotificationCenter.default.post(name: .toggleNowPlaying, object: nil) } label: { Image(systemName: "text.below.photo") .font(.system(size: 14)) .frame(width: 30, height: 30) .foregroundStyle(theme.secondaryText) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Now Playing (⇧⌘P)") } }