import SwiftUI /// Compact player bar with transport, shuffle/repeat, cursor mode, track info, time, volume, skin. struct PlayerView: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { VStack(spacing: 0) { // Waveform display (clickable for seek) WaveformDisplay() // Separator between waveform and slider if !playerVM.waveformSamples.isEmpty { Rectangle() .fill(theme.waveformSeparator) .frame(height: 10) } // Thin seek slider below the waveform SeekSlider() HStack(spacing: 0) { TransportButtons() divider() ShuffleRepeatButtons() divider() CursorModeButton() divider() TrackInfoStrip() Spacer(minLength: 4) // Now Playing button if playerVM.currentTrack != nil { NowPlayingButton() divider() } TimeDisplay() divider() VolumeControl() divider() SettingsButton() } .padding(.horizontal, 10) .padding(.vertical, 5) .frame(height: 52) } .background(.bar) } private func divider() -> some View { Divider() .frame(height: 28) .padding(.horizontal, 7) } } // MARK: - Waveform Display (visual, clickable for seek) 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)) } // Playhead line 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: 32) } } } // MARK: - Seek Slider (thin progress bar) 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) { // Background track Rectangle() .fill(theme.seekbarBackground) // Filled portion — matches volume slider color (accent) 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: - Transport Buttons private struct TransportButtons: View { @Environment(PlayerViewModel.self) private var playerVM var body: some View { HStack(spacing: 5) { btn("stop.fill", help: "Stop") { playerVM.stop() } btn("backward.end.fill", help: "Previous Track") { playerVM.playPrevious() } btn(playerVM.isPlaying ? "pause.fill" : "play.fill", help: playerVM.isPlaying ? "Pause (Space)" : "Play (Space)") { playerVM.togglePlayPause() } btn("forward.end.fill", help: "Next Track") { playerVM.playNext() } } } private func btn(_ icon: String, help: String, action: @escaping () -> Void) -> some View { Button(action: action) { Image(systemName: icon) .font(.system(size: 20)) .frame(width: 40, height: 40) .contentShape(Rectangle()) } .buttonStyle(.plain) .disabled(playerVM.currentTrack == nil) .help(help) } } // MARK: - Shuffle & Repeat private struct ShuffleRepeatButtons: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { HStack(spacing: 4) { Button { playerVM.shuffleEnabled.toggle() } label: { Image(systemName: "shuffle") .font(.system(size: 17)) .frame(width: 36, height: 36) .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: 17)) .frame(width: 36, height: 36) .foregroundStyle(playerVM.repeatMode != .off ? theme.accent : theme.tertiaryText) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Repeat: \(playerVM.repeatMode.rawValue)") } } } // MARK: - Cursor Mode Toggle private struct CursorModeButton: View { @EnvironmentObject private var theme: AppTheme @ObservedObject private var viewConfig = PlaylistViewConfig.shared var body: some View { Button { // Toggle between the two modes (always keep at least one on) if viewConfig.cursorFollowsPlayback { viewConfig.cursorFollowsPlayback = false viewConfig.playbackFollowsCursor = true } else { viewConfig.cursorFollowsPlayback = true viewConfig.playbackFollowsCursor = false } } label: { // Both arrows point right (= playback direction) // Line position = cursor: |→ vs →| Group { if viewConfig.cursorFollowsPlayback { // |→ (cursor behind, playback pulls cursor forward) Image(systemName: "arrow.right.to.line") .scaleEffect(x: -1, y: 1) } else { // →| (cursor ahead, playback goes to where cursor points) Image(systemName: "arrow.right.to.line") } } .font(.system(size: 17)) .frame(width: 36, height: 36) .foregroundStyle(theme.accent) .contentShape(Rectangle()) } .buttonStyle(.plain) .help(viewConfig.cursorFollowsPlayback ? "Cursor follows playback (click to switch)" : "Playback follows cursor (click to switch)") } } // MARK: - Track Info Strip private struct TrackInfoStrip: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { if let track = playerVM.currentTrack { HStack(spacing: 5) { if playerVM.isPlaying { Image(systemName: "speaker.wave.2.fill") .font(.system(size: 10)) .foregroundStyle(theme.playingHighlight) } Text(trackDescription(track)) .font(.system(size: theme.dataFontSize)) .lineLimit(1) .truncationMode(.tail) .foregroundStyle(theme.primaryText) } } else { Text("Stopped") .font(.system(size: theme.dataFontSize)) .foregroundStyle(theme.tertiaryText) } } private func trackDescription(_ track: Track) -> String { var parts: [String] = [] if !track.artist.isEmpty { parts.append(track.artist) } parts.append(track.title) if !track.album.isEmpty { parts.append("[\(track.album)]") } return parts.joined(separator: " - ") } } // MARK: - Time Display private struct TimeDisplay: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { if playerVM.currentTrack != nil { Text("\(playerVM.currentTimeFormatted) / \(playerVM.durationFormatted)") .font(.system(size: 14, design: .monospaced)) .foregroundStyle(theme.secondaryText) .fixedSize() } } } // MARK: - Volume Control private struct VolumeControl: View { @Environment(PlayerViewModel.self) private var playerVM @EnvironmentObject private var theme: AppTheme var body: some View { @Bindable var vm = playerVM HStack(spacing: 4) { Image(systemName: "speaker.fill") .font(.system(size: 10)) .foregroundStyle(theme.secondaryText) Slider(value: $vm.volume, in: 0...1) .frame(width: 70) .controlSize(.small) .tint(theme.accent) } .help("Volume: \(Int(playerVM.volume * 100))%") } } // MARK: - Settings Button private struct SettingsButton: View { @EnvironmentObject private var theme: AppTheme @Environment(\.openSettings) private var openSettings var body: some View { Button { openSettings() } label: { Image(systemName: "gearshape") .font(.system(size: 14)) .foregroundStyle(theme.secondaryText) .frame(width: 30, height: 30) .contentShape(Rectangle()) } .buttonStyle(.plain) .help("Settings (⌘,)") } } // 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)") } }