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