125 lines
5.7 KiB
Swift
125 lines
5.7 KiB
Swift
#if os(macOS)
|
||
import Playback
|
||
import SwiftUI
|
||
|
||
struct MacQueuePanel: View {
|
||
@Environment(PlayerModel.self) private var player
|
||
|
||
private static let upcoming = "upcoming"
|
||
|
||
var body: some View {
|
||
ScrollViewReader { proxy in
|
||
List {
|
||
if !player.state.history.isEmpty {
|
||
Section {
|
||
ForEach(player.state.history) { row($0, upcoming: false, last: $0.id == player.state.history.last?.id) }
|
||
} header: {
|
||
header("History", clear: .clearHistory)
|
||
}
|
||
}
|
||
Section {
|
||
if player.state.upcoming.isEmpty {
|
||
Text("There’s no music in the queue.")
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.secondary)
|
||
.frame(maxWidth: .infinity, minHeight: 120)
|
||
.listRowSeparator(.hidden)
|
||
}
|
||
ForEach(player.state.upcoming) { row($0, upcoming: true, last: $0.id == player.state.upcoming.last?.id) }
|
||
.onMove(perform: move)
|
||
} header: {
|
||
header("Continue Playing", subtitle: source, clear: player.state.upcoming.isEmpty ? nil : .clearUpcoming)
|
||
.id(Self.upcoming)
|
||
}
|
||
}
|
||
.listStyle(.plain)
|
||
.scrollContentBackground(.hidden)
|
||
.environment(\.defaultMinListRowHeight, 48)
|
||
.onAppear { proxy.scrollTo(Self.upcoming, anchor: .top) }
|
||
.onChange(of: player.state.current?.id) { proxy.scrollTo(Self.upcoming, anchor: .top) }
|
||
}
|
||
}
|
||
|
||
private var source: String? {
|
||
let album = player.current?.albumTitle.trimmingCharacters(in: .whitespaces) ?? ""
|
||
return album.isEmpty ? nil : album
|
||
}
|
||
|
||
private func header(_ title: LocalizedStringKey, subtitle: String? = nil, clear: PlaybackCommand?) -> some View {
|
||
HStack(alignment: .firstTextBaseline) {
|
||
VStack(alignment: .leading, spacing: 3) {
|
||
Text(title).font(.system(size: 15, weight: .bold)).foregroundStyle(.primary)
|
||
if let subtitle {
|
||
Text("From \(subtitle)").font(.system(size: 12)).foregroundStyle(.secondary)
|
||
}
|
||
}
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
if let clear {
|
||
Button("Clear") { player.send(clear) }
|
||
.buttonStyle(.plain)
|
||
.font(.system(size: 13))
|
||
.foregroundStyle(.tint)
|
||
}
|
||
}
|
||
.padding(.top, 14)
|
||
.padding(.bottom, 8)
|
||
}
|
||
|
||
private func row(_ item: QueueItem, upcoming: Bool, last: Bool) -> some View {
|
||
HStack(spacing: 10) {
|
||
ArtworkView(artwork: item.track.artwork, shape: .rounded(4), pixelSize: 128).frame(width: 34, height: 34)
|
||
HStack(spacing: 8) {
|
||
VStack(alignment: .leading, spacing: 1) {
|
||
Text(item.track.title).font(.system(size: 13))
|
||
Text([item.track.artistName, item.track.albumTitle].map { $0.trimmingCharacters(in: .whitespaces) }
|
||
.filter { !$0.isEmpty }.joined(separator: " — "))
|
||
.font(.system(size: 11))
|
||
.foregroundStyle(.secondary)
|
||
}
|
||
.lineLimit(1)
|
||
.frame(maxWidth: .infinity, alignment: .leading)
|
||
Menu { menu(item, upcoming: upcoming) } label: {
|
||
Image(systemName: "ellipsis")
|
||
.font(.system(size: 13, weight: .semibold))
|
||
.foregroundStyle(.secondary)
|
||
.frame(width: 24, height: 24)
|
||
.contentShape(.rect)
|
||
}
|
||
.menuStyle(.button)
|
||
.menuIndicator(.hidden)
|
||
.buttonStyle(.plain)
|
||
.fixedSize()
|
||
.accessibilityLabel("More")
|
||
}
|
||
.frame(maxHeight: .infinity)
|
||
.overlay(alignment: .bottom) { Rectangle().fill(.separator).frame(height: 1).opacity(last ? 0 : 1) }
|
||
}
|
||
.frame(height: 48)
|
||
.contentShape(.rect)
|
||
.onTapGesture(count: 2) { player.send(.jump(to: item.id)) }
|
||
.contextMenu { menu(item, upcoming: upcoming) }
|
||
.listRowBackground(Color.clear)
|
||
.listRowSeparator(.hidden)
|
||
.listRowInsets(EdgeInsets(top: 0, leading: 16, bottom: 0, trailing: 16))
|
||
}
|
||
|
||
@ViewBuilder private func menu(_ item: QueueItem, upcoming: Bool) -> some View {
|
||
TrackMenu(track: item.track)
|
||
if upcoming {
|
||
Divider()
|
||
Button("Remove from queue", systemImage: "trash", role: .destructive) { player.send(.remove(item.id)) }
|
||
}
|
||
}
|
||
|
||
private func move(from offsets: IndexSet, to destination: Int) {
|
||
let upcoming = player.state.upcoming
|
||
guard let from = offsets.first else { return }
|
||
var reordered = upcoming
|
||
reordered.move(fromOffsets: offsets, toOffset: destination)
|
||
guard let landed = reordered.firstIndex(where: { $0.id == upcoming[from].id }) else { return }
|
||
player.send(.move(item: upcoming[from].id, after: landed > 0 ? reordered[landed - 1].id : nil))
|
||
}
|
||
}
|
||
#endif
|