New low-level recorder targeting devices directly. Includes device switching during recording, enhanced logging (transport type, format, buffer), and log export feature.
407 lines
14 KiB
Swift
407 lines
14 KiB
Swift
import SwiftUI
|
|
|
|
struct AudioInputSettingsView: View {
|
|
@ObservedObject var audioDeviceManager = AudioDeviceManager.shared
|
|
@Environment(\.colorScheme) private var colorScheme
|
|
|
|
var body: some View {
|
|
ScrollView {
|
|
VStack(spacing: 0) {
|
|
heroSection
|
|
mainContent
|
|
}
|
|
}
|
|
.background(Color(NSColor.controlBackgroundColor))
|
|
}
|
|
|
|
private var mainContent: some View {
|
|
VStack(spacing: 40) {
|
|
inputModeSection
|
|
|
|
if audioDeviceManager.inputMode == .custom {
|
|
customDeviceSection
|
|
} else if audioDeviceManager.inputMode == .prioritized {
|
|
prioritizedDevicesSection
|
|
}
|
|
}
|
|
.padding(.horizontal, 32)
|
|
.padding(.vertical, 40)
|
|
}
|
|
|
|
private var heroSection: some View {
|
|
CompactHeroSection(
|
|
icon: "waveform",
|
|
title: "Audio Input",
|
|
description: "Configure your microphone preferences"
|
|
)
|
|
}
|
|
|
|
private var inputModeSection: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
Text("Input Mode")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
HStack(spacing: 20) {
|
|
ForEach(AudioInputMode.allCases, id: \.self) { mode in
|
|
InputModeCard(
|
|
mode: mode,
|
|
isSelected: audioDeviceManager.inputMode == mode,
|
|
action: { audioDeviceManager.selectInputMode(mode) }
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var customDeviceSection: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
HStack {
|
|
Text("Available Devices")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
Spacer()
|
|
|
|
Button(action: { audioDeviceManager.loadAvailableDevices() }) {
|
|
Label("Refresh", systemImage: "arrow.clockwise")
|
|
}
|
|
.buttonStyle(.borderless)
|
|
}
|
|
|
|
Text("Note: Selecting a device here will override your Mac\'s system-wide default microphone.")
|
|
.font(.caption)
|
|
.foregroundColor(.secondary)
|
|
.padding(.bottom, 8)
|
|
|
|
VStack(spacing: 12) {
|
|
ForEach(audioDeviceManager.availableDevices, id: \.id) { device in
|
|
DeviceSelectionCard(
|
|
name: device.name,
|
|
isSelected: audioDeviceManager.selectedDeviceID == device.id,
|
|
isActive: audioDeviceManager.getCurrentDevice() == device.id
|
|
) {
|
|
audioDeviceManager.selectDevice(id: device.id)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private var prioritizedDevicesSection: some View {
|
|
VStack(alignment: .leading, spacing: 20) {
|
|
if audioDeviceManager.availableDevices.isEmpty {
|
|
emptyDevicesState
|
|
} else {
|
|
prioritizedDevicesContent
|
|
Divider().padding(.vertical, 8)
|
|
availableDevicesContent
|
|
}
|
|
}
|
|
}
|
|
|
|
private var prioritizedDevicesContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text("Prioritized Devices")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
Text("Devices will be used in order of priority. If a device is unavailable, the next one will be tried. If no prioritized device is available, the built-in microphone will be used.")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
|
|
if audioDeviceManager.prioritizedDevices.isEmpty {
|
|
Text("No prioritized devices")
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 8)
|
|
} else {
|
|
prioritizedDevicesList
|
|
}
|
|
}
|
|
}
|
|
|
|
private var availableDevicesContent: some View {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Text("Available Devices")
|
|
.font(.title2)
|
|
.fontWeight(.semibold)
|
|
|
|
availableDevicesList
|
|
}
|
|
}
|
|
|
|
private var emptyDevicesState: some View {
|
|
VStack(spacing: 16) {
|
|
Image(systemName: "mic.slash.circle.fill")
|
|
.font(.system(size: 48))
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(.secondary)
|
|
|
|
VStack(spacing: 8) {
|
|
Text("No Audio Devices")
|
|
.font(.headline)
|
|
Text("Connect an audio input device to get started")
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity)
|
|
.padding(40)
|
|
.background(CardBackground(isSelected: false))
|
|
}
|
|
|
|
private var prioritizedDevicesList: some View {
|
|
VStack(spacing: 12) {
|
|
ForEach(audioDeviceManager.prioritizedDevices.sorted(by: { $0.priority < $1.priority })) { device in
|
|
devicePriorityCard(for: device)
|
|
}
|
|
}
|
|
}
|
|
|
|
private func devicePriorityCard(for prioritizedDevice: PrioritizedDevice) -> some View {
|
|
let device = audioDeviceManager.availableDevices.first(where: { $0.uid == prioritizedDevice.id })
|
|
return DevicePriorityCard(
|
|
name: prioritizedDevice.name,
|
|
priority: prioritizedDevice.priority,
|
|
isActive: device.map { audioDeviceManager.getCurrentDevice() == $0.id } ?? false,
|
|
isPrioritized: true,
|
|
isAvailable: device != nil,
|
|
canMoveUp: prioritizedDevice.priority > 0,
|
|
canMoveDown: prioritizedDevice.priority < audioDeviceManager.prioritizedDevices.count - 1,
|
|
onTogglePriority: { audioDeviceManager.removePrioritizedDevice(id: prioritizedDevice.id) },
|
|
onMoveUp: { moveDeviceUp(prioritizedDevice) },
|
|
onMoveDown: { moveDeviceDown(prioritizedDevice) }
|
|
)
|
|
}
|
|
|
|
private var availableDevicesList: some View {
|
|
let unprioritizedDevices = audioDeviceManager.availableDevices.filter { device in
|
|
!audioDeviceManager.prioritizedDevices.contains { $0.id == device.uid }
|
|
}
|
|
|
|
return Group {
|
|
if unprioritizedDevices.isEmpty {
|
|
Text("No additional devices available")
|
|
.foregroundStyle(.secondary)
|
|
.padding(.vertical, 8)
|
|
} else {
|
|
ForEach(unprioritizedDevices, id: \.id) { device in
|
|
DevicePriorityCard(
|
|
name: device.name,
|
|
priority: nil,
|
|
isActive: audioDeviceManager.getCurrentDevice() == device.id,
|
|
isPrioritized: false,
|
|
isAvailable: true,
|
|
canMoveUp: false,
|
|
canMoveDown: false,
|
|
onTogglePriority: { audioDeviceManager.addPrioritizedDevice(uid: device.uid, name: device.name) },
|
|
onMoveUp: {},
|
|
onMoveDown: {}
|
|
)
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
private func moveDeviceUp(_ device: PrioritizedDevice) {
|
|
guard device.priority > 0,
|
|
let currentIndex = audioDeviceManager.prioritizedDevices.firstIndex(where: { $0.id == device.id })
|
|
else { return }
|
|
|
|
var devices = audioDeviceManager.prioritizedDevices
|
|
devices.swapAt(currentIndex, currentIndex - 1)
|
|
updatePriorities(devices)
|
|
}
|
|
|
|
private func moveDeviceDown(_ device: PrioritizedDevice) {
|
|
guard device.priority < audioDeviceManager.prioritizedDevices.count - 1,
|
|
let currentIndex = audioDeviceManager.prioritizedDevices.firstIndex(where: { $0.id == device.id })
|
|
else { return }
|
|
|
|
var devices = audioDeviceManager.prioritizedDevices
|
|
devices.swapAt(currentIndex, currentIndex + 1)
|
|
updatePriorities(devices)
|
|
}
|
|
|
|
private func updatePriorities(_ devices: [PrioritizedDevice]) {
|
|
let updatedDevices = devices.enumerated().map { index, device in
|
|
PrioritizedDevice(id: device.id, name: device.name, priority: index)
|
|
}
|
|
audioDeviceManager.updatePriorities(devices: updatedDevices)
|
|
}
|
|
}
|
|
|
|
struct InputModeCard: View {
|
|
let mode: AudioInputMode
|
|
let isSelected: Bool
|
|
let action: () -> Void
|
|
|
|
private var icon: String {
|
|
switch mode {
|
|
case .custom: return "mic.circle.fill"
|
|
case .prioritized: return "list.number"
|
|
}
|
|
}
|
|
|
|
private var description: String {
|
|
switch mode {
|
|
case .custom: return "Select a specific input device"
|
|
case .prioritized: return "Set up device priority order"
|
|
}
|
|
}
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
VStack(alignment: .leading, spacing: 12) {
|
|
Image(systemName: icon)
|
|
.font(.system(size: 28))
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
|
|
|
VStack(alignment: .leading, spacing: 4) {
|
|
Text(mode.rawValue)
|
|
.font(.headline)
|
|
|
|
Text(description)
|
|
.font(.subheadline)
|
|
.foregroundStyle(.secondary)
|
|
.fixedSize(horizontal: false, vertical: true)
|
|
}
|
|
}
|
|
.frame(maxWidth: .infinity, alignment: .leading)
|
|
.padding()
|
|
.background(CardBackground(isSelected: isSelected))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
struct DeviceSelectionCard: View {
|
|
let name: String
|
|
let isSelected: Bool
|
|
let isActive: Bool
|
|
let action: () -> Void
|
|
|
|
var body: some View {
|
|
Button(action: action) {
|
|
HStack {
|
|
Image(systemName: isSelected ? "checkmark.circle.fill" : "circle")
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(isSelected ? .blue : .secondary)
|
|
.font(.system(size: 18))
|
|
|
|
Text(name)
|
|
.foregroundStyle(.primary)
|
|
|
|
Spacer()
|
|
|
|
if isActive {
|
|
Label("Active", systemImage: "wave.3.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.green)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(.green.opacity(0.1))
|
|
)
|
|
}
|
|
}
|
|
.padding()
|
|
.background(CardBackground(isSelected: isSelected))
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
}
|
|
|
|
struct DevicePriorityCard: View {
|
|
let name: String
|
|
let priority: Int?
|
|
let isActive: Bool
|
|
let isPrioritized: Bool
|
|
let isAvailable: Bool
|
|
let canMoveUp: Bool
|
|
let canMoveDown: Bool
|
|
let onTogglePriority: () -> Void
|
|
let onMoveUp: () -> Void
|
|
let onMoveDown: () -> Void
|
|
|
|
var body: some View {
|
|
HStack {
|
|
// Priority number or dash
|
|
if let priority = priority {
|
|
Text("\(priority + 1)")
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 24)
|
|
} else {
|
|
Text("-")
|
|
.font(.system(size: 18, weight: .medium))
|
|
.foregroundStyle(.secondary)
|
|
.frame(width: 24)
|
|
}
|
|
|
|
// Device name
|
|
Text(name)
|
|
.foregroundStyle(isAvailable ? .primary : .secondary)
|
|
|
|
Spacer()
|
|
|
|
// Status and Controls
|
|
HStack(spacing: 12) {
|
|
// Active status
|
|
if isActive {
|
|
Label("Active", systemImage: "wave.3.right")
|
|
.font(.caption)
|
|
.foregroundStyle(.green)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(.green.opacity(0.1))
|
|
)
|
|
} else if !isAvailable && isPrioritized {
|
|
Label("Unavailable", systemImage: "exclamationmark.triangle")
|
|
.font(.caption)
|
|
.foregroundStyle(.secondary)
|
|
.padding(.horizontal, 10)
|
|
.padding(.vertical, 4)
|
|
.background(
|
|
Capsule()
|
|
.fill(Color(.windowBackgroundColor).opacity(0.4))
|
|
)
|
|
}
|
|
|
|
// Priority controls (only show if prioritized)
|
|
if isPrioritized {
|
|
HStack(spacing: 2) {
|
|
Button(action: onMoveUp) {
|
|
Image(systemName: "chevron.up")
|
|
.foregroundStyle(canMoveUp ? .blue : .secondary.opacity(0.5))
|
|
}
|
|
.disabled(!canMoveUp)
|
|
|
|
Button(action: onMoveDown) {
|
|
Image(systemName: "chevron.down")
|
|
.foregroundStyle(canMoveDown ? .blue : .secondary.opacity(0.5))
|
|
}
|
|
.disabled(!canMoveDown)
|
|
}
|
|
}
|
|
|
|
// Toggle priority button
|
|
Button(action: onTogglePriority) {
|
|
Image(systemName: isPrioritized ? "minus.circle.fill" : "plus.circle.fill")
|
|
.symbolRenderingMode(.hierarchical)
|
|
.foregroundStyle(isPrioritized ? .red : .blue)
|
|
}
|
|
}
|
|
.buttonStyle(.plain)
|
|
}
|
|
.padding()
|
|
.background(CardBackground(isSelected: false))
|
|
}
|
|
}
|