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.
246 lines
8.4 KiB
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))"
|
|
}
|
|
}
|
|
|