/* * Atoll (DynamicIsland) * Copyright (C) 2024-2026 Atoll Contributors * * Originally from boring.notch project * Modified and adapted for Atoll (DynamicIsland) * See NOTICE for details. * * This program is free software: you can redistribute it and/or modify * it under the terms of the GNU General Public License as published by * the Free Software Foundation, either version 3 of the License, or * (at your option) any later version. * * This program is distributed in the hope that it will be useful, * but WITHOUT ANY WARRANTY; without even the implied warranty of * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * GNU General Public License for more details. * * You should have received a copy of the GNU General Public License * along with this program. If not, see . */ import Defaults import SwiftUI import UniformTypeIdentifiers struct MusicSlotConfigurationView: View { @Default(.musicControlSlots) private var musicControlSlots @Default(.showMediaOutputControl) private var showMediaOutputControl @ObservedObject private var musicManager = MusicManager.shared @State private var hoveredSlotIndex: Int? = nil @State private var targetedSlotIndex: Int? = nil @State private var trashDropIsTargeted: Bool = false private let slotCount = MusicControlButton.slotCount var body: some View { VStack(alignment: .leading, spacing: 12) { header layoutPreview Divider() palette resetButton } .onAppear { ensureSlotCapacity(slotCount) removeDisallowedControls() } .onChange(of: showMediaOutputControl) { _, _ in removeDisallowedControls() } } private var header: some View { VStack(alignment: .leading, spacing: 4) { Text("Layout Preview") .font(.headline) Text("Drag items between slots or drop from the palette to remap controls.") .font(.subheadline) .foregroundStyle(.secondary) } } private var layoutPreview: some View { HStack(alignment: .top, spacing: 12) { HStack(spacing: 8) { ForEach(0.. some View { let slot = slotValue(at: index) let isHovered = hoveredSlotIndex == index let isDropTarget = targetedSlotIndex == index return ZStack { RoundedRectangle(cornerRadius: 10) .fill(slotBackgroundColor(isHovered: isHovered, isTargeted: isDropTarget)) .overlay( RoundedRectangle(cornerRadius: 10) .stroke(slotBorderColor(isHovered: isHovered, isTargeted: isDropTarget), lineWidth: borderWidth(isHovered: isHovered, isTargeted: isDropTarget)) ) slotContent(for: slot) } .frame(width: 48, height: 48) .contentShape(RoundedRectangle(cornerRadius: 10)) .onHover { isInside in withAnimation(.easeInOut(duration: 0.12)) { hoveredSlotIndex = isInside ? index : nil } } .onDrag { hoveredSlotIndex = index return NSItemProvider(object: NSString(string: "slot:\(index)")) } .onDrop(of: [UTType.plainText.identifier], isTargeted: dropTargetBinding(for: index)) { providers in return handleDrop(providers, toIndex: index) } } @ViewBuilder private func slotContent(for slot: MusicControlButton) -> some View { if slot == .none { RoundedRectangle(cornerRadius: 6) .stroke(style: StrokeStyle(lineWidth: 1, dash: [4, 4])) .foregroundStyle(.secondary.opacity(0.35)) .frame(width: 26, height: 26) } else { Image(systemName: slot.iconName) .font(.system(size: slot.prefersLargeScale ? 18 : 15, weight: .medium)) .foregroundStyle(previewIconColor(for: slot)) } } private func paletteItem(for control: MusicControlButton) -> some View { VStack(spacing: 6) { RoundedRectangle(cornerRadius: 8) .fill(Color(nsColor: .controlBackgroundColor)) .frame(width: 44, height: 44) .overlay { Image(systemName: control.iconName) .font(.system(size: control.prefersLargeScale ? 18 : 15, weight: .medium)) .foregroundStyle(control == .mediaOutput && !showMediaOutputControl ? .secondary : .primary) } .onDrag { NSItemProvider(object: NSString(string: "control:\(control.rawValue)")) } .onTapGesture { place(control) } Text(control.label) .font(.caption2) .foregroundStyle(.secondary) .frame(width: 72) .lineLimit(1) .minimumScaleFactor(0.8) .multilineTextAlignment(.center) } .opacity(control == .mediaOutput && !showMediaOutputControl ? 0.4 : 1) .disabled(control == .mediaOutput && !showMediaOutputControl) } private func place(_ control: MusicControlButton) { if let emptyIndex = musicControlSlots.firstIndex(of: .none) { updateSlot(control, at: emptyIndex) } else { updateSlot(control, at: 0) } } private func slotValue(at index: Int) -> MusicControlButton { let normalized = musicControlSlots.normalized(allowingMediaOutput: showMediaOutputControl) guard normalized.indices.contains(index) else { return .none } return normalized[index] } private var pickerOptions: [MusicControlButton] { let base = MusicControlButton.pickerOptions if showMediaOutputControl { return base } return base.filter { $0 != .mediaOutput } } private func previewIconColor(for slot: MusicControlButton) -> Color { switch slot { case .shuffle: return musicManager.isShuffled ? .red : .primary case .repeatMode: return musicManager.repeatMode == .off ? .primary : .red case .lyrics: return Defaults[.enableLyrics] ? .accentColor : .primary default: return .primary } } private func ensureSlotCapacity(_ target: Int) { guard target > musicControlSlots.count else { return } let padding = target - musicControlSlots.count musicControlSlots.append(contentsOf: Array(repeating: .none, count: padding)) } private func handleDrop(_ providers: [NSItemProvider], toIndex: Int) -> Bool { for provider in providers where provider.canLoadObject(ofClass: NSString.self) { provider.loadObject(ofClass: NSString.self) { item, _ in guard let raw = item as? String else { return } DispatchQueue.main.async { processDropString(raw, toIndex: toIndex) } } return true } return false } private func handleDropOnTrash(_ providers: [NSItemProvider]) -> Bool { for provider in providers where provider.canLoadObject(ofClass: NSString.self) { provider.loadObject(ofClass: NSString.self) { item, _ in guard let raw = item as? String else { return } DispatchQueue.main.async { if raw.hasPrefix("slot:"), let index = Int(raw.dropFirst(5)) { clearSlot(at: index) } } } return true } return false } private func processDropString(_ raw: String, toIndex: Int) { if raw.hasPrefix("slot:"), let source = Int(raw.dropFirst(5)) { swapSlot(from: source, to: toIndex) } else if raw.hasPrefix("control:"), let control = MusicControlButton(rawValue: String(raw.dropFirst(8))) { updateSlot(control, at: toIndex) } } private func clearSlot(at index: Int) { guard index >= 0 && index < musicControlSlots.count else { return } musicControlSlots[index] = .none } private func swapSlot(from source: Int, to destination: Int) { guard source != destination else { return } ensureSlotCapacity(max(source, destination) + 1) musicControlSlots.swapAt(source, destination) } private func updateSlot(_ value: MusicControlButton, at index: Int) { ensureSlotCapacity(index + 1) var current = musicControlSlots current[index] = value musicControlSlots = current } private func removeDisallowedControls() { if showMediaOutputControl { return } let filtered = musicControlSlots.map { $0 == .mediaOutput ? .none : $0 } musicControlSlots = filtered } private func slotBackgroundColor(isHovered: Bool, isTargeted: Bool) -> Color { if isTargeted { return Color.accentColor.opacity(0.25) } else if isHovered { return Color.accentColor.opacity(0.15) } return Color(nsColor: .controlBackgroundColor) } private func slotBorderColor(isHovered: Bool, isTargeted: Bool) -> Color { if isTargeted { return Color.accentColor } else if isHovered { return Color.accentColor.opacity(0.8) } return .clear } private func borderWidth(isHovered: Bool, isTargeted: Bool) -> CGFloat { if isTargeted { return 2 } if isHovered { return 1.5 } return 0 } private func dropTargetBinding(for index: Int) -> Binding { Binding( get: { targetedSlotIndex == index }, set: { newValue in if newValue { targetedSlotIndex = index } else if targetedSlotIndex == index { targetedSlotIndex = nil } } ) } }