You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
Music/Music/Views/PlayerControlsView.swift

246 lines
8.4 KiB

import SwiftUI
import AVFoundation
struct PlayerControlsView: View {
let currentTrack: Track?
let isPlaying: Bool
let currentTime: Double
let duration: Double
let volume: Float
let isShuffled: Bool
var isBuffering: Bool = false
var streamingError: String? = nil
let onPlayPause: () -> Void
let onNext: () -> Void
let onPrevious: () -> Void
let onSeek: (Double) -> Void
let onScrubStart: () -> Void
let onScrub: (Double) -> Void
let onScrubEnd: (Double) -> Void
let onVolumeChange: (Float) -> Void
let onShuffleToggle: () -> Void
let onNowPlayingTap: () -> Void
var contextMenuConfig: TrackContextMenuConfig? = nil
@State private var isDragging = false
@State private var dragValue: Double = 0
@State private var artworkImage: NSImage?
var body: some View {
VStack(spacing: 0) {
progressTrack
HStack(spacing: 0) {
nowPlayingSection
.frame(maxWidth: .infinity, alignment: .leading)
transportSection
.frame(maxWidth: .infinity)
volumeSection
.frame(maxWidth: .infinity, alignment: .trailing)
}
.padding(.horizontal, 16)
.padding(.vertical, 8)
}
.background(.bar)
.onChange(of: currentTrack?.id) {
loadArtwork()
}
.onAppear {
loadArtwork()
}
}
private func loadArtwork() {
guard let urlString = currentTrack?.fileURL,
let url = URL(string: urlString) else {
artworkImage = nil
return
}
Task.detached {
let asset = AVURLAsset(url: url)
let metadata = try? await asset.load(.metadata)
let data = try? await metadata?
.first { $0.commonKey == .commonKeyArtwork }?
.load(.dataValue)
let image = data.flatMap { NSImage(data: $0) }
await MainActor.run { artworkImage = image }
}
}
private var nowPlayingSection: some View {
HStack(spacing: 12) {
RoundedRectangle(cornerRadius: 6)
.fill(.quaternary)
.frame(width: 44, height: 44)
.overlay {
if let artworkImage {
Image(nsImage: artworkImage)
.resizable()
.aspectRatio(contentMode: .fill)
} else {
Image(systemName: "music.note")
.foregroundStyle(.secondary)
}
}
.clipShape(RoundedRectangle(cornerRadius: 6))
if let track = currentTrack {
VStack(alignment: .leading, spacing: 2) {
HStack(spacing: 6) {
Text(track.title)
.font(.system(size: 13, weight: .medium))
.lineLimit(1)
if isBuffering {
ProgressView()
.controlSize(.mini)
}
}
if let error = streamingError {
Text(error)
.font(.system(size: 11))
.foregroundStyle(.red)
.lineLimit(1)
} else {
Text("\(track.artist)\(track.album)")
.font(.system(size: 11))
.foregroundStyle(.secondary)
.lineLimit(1)
}
}
}
}
.contentShape(Rectangle())
.onTapGesture {
if currentTrack != nil {
onNowPlayingTap()
}
}
.trackContextMenu(track: currentTrack, config: contextMenuConfig)
}
private var progressTrack: some View {
let trackHeight: CGFloat = 4
let thumbWidth: CGFloat = 4
let thumbHeight: CGFloat = 12
let displayedTime = isDragging ? dragValue : currentTime
let maxDuration = max(duration, 1)
let fraction = displayedTime / maxDuration
return VStack(spacing: 2) {
GeometryReader { geo in
let trackWidth = geo.size.width
ZStack(alignment: .leading) {
Rectangle()
.fill(.quaternary)
.frame(height: trackHeight)
Rectangle()
.fill(.blue)
.frame(width: trackWidth * fraction, height: trackHeight)
RoundedRectangle(cornerRadius: 1)
.fill(.blue)
.frame(width: thumbWidth, height: thumbHeight)
.offset(x: trackWidth * fraction - thumbWidth / 2)
}
.frame(maxHeight: .infinity)
.contentShape(Rectangle())
.gesture(
DragGesture(minimumDistance: 0)
.onChanged { value in
let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
if !isDragging {
isDragging = true
dragValue = currentTime
onScrubStart()
}
dragValue = newValue
onScrub(newValue)
}
.onEnded { value in
let newValue = min(max(Double(value.location.x / trackWidth) * maxDuration, 0), maxDuration)
onScrubEnd(newValue)
isDragging = false
}
)
}
.frame(height: thumbHeight)
HStack {
Text(Self.formatTime(displayedTime))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
Spacer()
Text(Self.formatTime(duration))
.font(.system(size: 10).monospacedDigit())
.foregroundStyle(.secondary)
}
.padding(.horizontal, 8)
}
}
private var transportSection: some View {
HStack(spacing: 20) {
Button(action: onShuffleToggle) {
Image(systemName: "shuffle")
.font(.system(size: 12))
.foregroundStyle(isShuffled ? .blue : .secondary)
}
.buttonStyle(.plain)
Button(action: onPrevious) {
Image(systemName: "backward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
Button(action: onPlayPause) {
Image(systemName: isPlaying ? "pause.fill" : "play.fill")
.font(.system(size: 22))
.frame(width: 24, height: 24)
}
.buttonStyle(.plain)
Button(action: onNext) {
Image(systemName: "forward.fill")
.font(.system(size: 14))
}
.buttonStyle(.plain)
}
.frame(maxWidth: 400)
}
private var volumeSection: some View {
HStack(spacing: 8) {
Image(systemName: volumeIconName)
.font(.system(size: 12))
.foregroundStyle(.secondary)
.frame(width: 16)
Slider(
value: Binding(
get: { Double(volume) },
set: { onVolumeChange(Float($0)) }
),
in: 0...1
)
.controlSize(.small)
.frame(width: 80)
}
}
private var volumeIconName: String {
if volume == 0 { return "speaker.slash.fill" }
if volume < 0.33 { return "speaker.wave.1.fill" }
if volume < 0.66 { return "speaker.wave.2.fill" }
return "speaker.wave.3.fill"
}
static func formatTime(_ seconds: Double) -> String {
guard seconds.isFinite, seconds >= 0 else { return "0:00" }
let mins = Int(seconds) / 60
let secs = Int(seconds) % 60
return "\(mins):\(String(format: "%02d", secs))"
}
}