| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397 |
- 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)")
- }
- }
|