Files
voice-cat/clients/apple/macOS/VoiceCatMac/Sheets/ChannelEditSheet.swift

242 lines
10 KiB
Swift
Raw Permalink Normal View History

import AppKit
import VoiceCatCore
final class ChannelEditSheet: NSViewController {
var onComplete: ((ChannelEdit?) -> Void)?
private let channels: [Channel]
private var editing: ChannelEdit?
private let nameField = NSTextField()
private let topicField = NSTextField()
private let parentPicker = NSPopUpButton()
private let pwCheckbox = NSButton(checkboxWithTitle: "Password protected", target: nil, action: nil)
private let pwField = NSSecureTextField()
private let maxUsersField: NSTextField = {
let f = NSTextField(); f.stringValue = "0"; return f
}()
private let sortOrderField: NSTextField = {
let f = NSTextField(); f.stringValue = "0"; return f
}()
private let stereoCheckbox = NSButton(checkboxWithTitle: "Stereo", target: nil, action: nil)
private let bitrateField: NSTextField = {
let f = NSTextField(); f.stringValue = "64000"; return f
}()
private let fecCheckbox = NSButton(checkboxWithTitle: "FEC", target: nil, action: nil)
private let dtxCheckbox = NSButton(checkboxWithTitle: "DTX", target: nil, action: nil)
private let dredCheckbox = NSButton(checkboxWithTitle: "DRED", target: nil, action: nil)
private let frameMsPicker = NSPopUpButton()
private let applicationPicker = NSPopUpButton()
private let sampleRateField: NSTextField = {
let f = NSTextField(); f.stringValue = "48000"; return f
}()
private let packetLossField: NSTextField = {
let f = NSTextField(); f.stringValue = "5"; return f
}()
private let complexityField: NSTextField = {
let f = NSTextField(); f.stringValue = "10"; return f
}()
init(channels: [Channel], editing: ChannelEdit?) {
self.channels = channels
self.editing = editing
super.init(nibName: nil, bundle: nil)
}
required init?(coder: NSCoder) { fatalError() }
override func loadView() {
view = NSView(frame: NSRect(x: 0, y: 0, width: 440, height: 360))
}
override func viewDidLoad() {
super.viewDidLoad()
buildUI()
populateIfEditing()
}
private func buildUI() {
let titleText = editing == nil ? "Create Channel" : "Edit Channel"
let titleLabel = NSTextField(labelWithString: titleText)
titleLabel.font = .boldSystemFont(ofSize: 13)
nameField.placeholderString = "Channel name"
nameField.setAccessibilityLabel("Channel name")
topicField.placeholderString = "Topic (optional)"
topicField.setAccessibilityLabel("Channel topic")
parentPicker.addItem(withTitle: "(root)")
parentPicker.menu?.items.last?.representedObject = nil as UInt32?
for ch in channels.sorted(by: { $0.name < $1.name }) {
let item = NSMenuItem(title: ch.name, action: nil, keyEquivalent: "")
item.representedObject = ch.id
parentPicker.menu?.addItem(item)
}
parentPicker.setAccessibilityLabel("Parent channel")
pwCheckbox.target = self; pwCheckbox.action = #selector(pwToggled)
pwField.placeholderString = "password (leave blank to keep existing)"
pwField.setAccessibilityLabel("Channel password")
pwField.isEnabled = false
maxUsersField.setAccessibilityLabel("Max users (0 = unlimited)")
sortOrderField.setAccessibilityLabel("Sort order")
for ms in ["10", "20", "40", "60"] { frameMsPicker.addItem(withTitle: "\(ms) ms") }
frameMsPicker.selectItem(withTitle: "20 ms")
frameMsPicker.setAccessibilityLabel("Opus frame duration")
applicationPicker.addItem(withTitle: "VoIP")
applicationPicker.addItem(withTitle: "Audio")
applicationPicker.addItem(withTitle: "Low delay")
applicationPicker.setAccessibilityLabel("Opus application profile")
fecCheckbox.state = .on
stereoCheckbox.setAccessibilityLabel("Stereo audio")
bitrateField.setAccessibilityLabel("Bitrate in bits per second")
sampleRateField.setAccessibilityLabel("Sample rate in Hz")
packetLossField.setAccessibilityLabel("Expected packet loss percent (0 to 100)")
complexityField.setAccessibilityLabel("Opus complexity (0 to 10)")
fecCheckbox.setAccessibilityLabel("Forward error correction")
dtxCheckbox.setAccessibilityLabel("Discontinuous transmission")
dredCheckbox.setAccessibilityLabel("Deep redundancy (DRED)")
let generalGrid = NSGridView(views: [
[NSTextField(labelWithString: "Name:"), nameField],
[NSTextField(labelWithString: "Topic:"), topicField],
[NSTextField(labelWithString: "Parent:"), parentPicker],
[pwCheckbox, pwField],
[NSTextField(labelWithString: "Max users:"), maxUsersField],
[NSTextField(labelWithString: "Sort order:"), sortOrderField],
])
generalGrid.rowSpacing = 8; generalGrid.columnSpacing = 8
generalGrid.column(at: 0).xPlacement = .trailing
let audioGrid = NSGridView(views: [
[NSTextField(labelWithString: "Bitrate:"), bitrateField],
[NSTextField(labelWithString: "Sample rate:"), sampleRateField],
[NSTextField(labelWithString: "Frame:"), frameMsPicker],
[NSTextField(labelWithString: "Application:"), applicationPicker],
[NSTextField(labelWithString: "Packet loss %:"), packetLossField],
[NSTextField(labelWithString: "Complexity:"), complexityField],
[stereoCheckbox, fecCheckbox],
[dtxCheckbox, dredCheckbox],
])
audioGrid.rowSpacing = 8; audioGrid.columnSpacing = 8
audioGrid.column(at: 0).xPlacement = .trailing
let tabs = NSTabView()
let generalTab = NSTabViewItem(identifier: "general")
generalTab.label = "General"
generalTab.view = generalGrid
let audioTab = NSTabViewItem(identifier: "audio")
audioTab.label = "Audio"
audioTab.view = audioGrid
tabs.addTabViewItem(generalTab)
tabs.addTabViewItem(audioTab)
let cancelButton = NSButton(title: "Cancel", target: self, action: #selector(cancelClicked))
cancelButton.bezelStyle = .rounded
let saveButton = NSButton(title: editing == nil ? "Create" : "Save",
target: self, action: #selector(saveClicked))
saveButton.bezelStyle = .rounded; saveButton.keyEquivalent = "\r"
let buttonRow = NSStackView(views: [NSView(), cancelButton, saveButton])
buttonRow.orientation = .horizontal; buttonRow.spacing = 8
let stack = NSStackView(views: [titleLabel, tabs, buttonRow])
stack.orientation = .vertical
stack.spacing = 12
stack.edgeInsets = NSEdgeInsets(top: 16, left: 16, bottom: 16, right: 16)
stack.translatesAutoresizingMaskIntoConstraints = false
view.addSubview(stack)
NSLayoutConstraint.activate([
stack.topAnchor.constraint(equalTo: view.topAnchor),
stack.leadingAnchor.constraint(equalTo: view.leadingAnchor),
stack.trailingAnchor.constraint(equalTo: view.trailingAnchor),
stack.bottomAnchor.constraint(equalTo: view.bottomAnchor),
])
}
private func populateIfEditing() {
guard let e = editing else { return }
nameField.stringValue = e.name
topicField.stringValue = e.topic
if e.parentId != 0 {
for item in parentPicker.itemArray where (item.representedObject as? UInt32) == e.parentId {
parentPicker.select(item); break
}
}
pwCheckbox.state = e.passwordProtected ? .on : .off
pwField.isEnabled = e.passwordProtected
maxUsersField.stringValue = "\(e.maxUsers)"
sortOrderField.stringValue = "\(e.sortOrder)"
stereoCheckbox.state = e.audio.stereo ? .on : .off
bitrateField.stringValue = "\(e.audio.bitrateBps)"
sampleRateField.stringValue = "\(e.audio.sampleRate)"
packetLossField.stringValue = "\(e.audio.expectedPacketLoss)"
complexityField.stringValue = "\(e.audio.complexity)"
fecCheckbox.state = e.audio.fec ? .on : .off
dtxCheckbox.state = e.audio.dtx ? .on : .off
dredCheckbox.state = e.audio.dred ? .on : .off
applicationPicker.selectItem(at: Int(min(e.audio.application, 2)))
let frameStr = "\(e.audio.frameMs) ms"
if let item = frameMsPicker.item(withTitle: frameStr) { frameMsPicker.select(item) }
}
@objc private func pwToggled() {
pwField.isEnabled = pwCheckbox.state == .on
}
@objc private func saveClicked() {
let name = nameField.stringValue.trimmingCharacters(in: .whitespaces)
guard !name.isEmpty else { nameField.becomeFirstResponder(); return }
let parentId = parentPicker.selectedItem?.representedObject as? UInt32 ?? 0
let maxUsers = UInt32(maxUsersField.stringValue) ?? 0
let sortOrder = UInt32(sortOrderField.stringValue) ?? 0
let bitrate = UInt32(bitrateField.stringValue) ?? 64000
let sampleRate = UInt32(sampleRateField.stringValue) ?? 48000
let packetLoss = min(UInt32(packetLossField.stringValue) ?? 5, 100)
let complexity = min(UInt32(complexityField.stringValue) ?? 10, 10)
let frameMsStr = frameMsPicker.titleOfSelectedItem?.replacingOccurrences(of: " ms", with: "") ?? "20"
let frameMs = UInt32(frameMsStr) ?? 20
let application = UInt32(max(applicationPicker.indexOfSelectedItem, 0))
let audio = AudioConfig(
stereo: stereoCheckbox.state == .on,
sampleRate: sampleRate,
bitrateBps: bitrate,
frameMs: frameMs,
application: application,
fec: fecCheckbox.state == .on,
expectedPacketLoss: packetLoss,
dtx: dtxCheckbox.state == .on,
complexity: complexity,
dred: dredCheckbox.state == .on
)
let pwProtected = pwCheckbox.state == .on
let pw: String? = pwProtected ? (pwField.stringValue.isEmpty ? nil : pwField.stringValue) : nil
let result = ChannelEdit(
id: editing?.id ?? 0,
parentId: parentId,
name: name,
topic: topicField.stringValue,
passwordProtected: pwProtected,
password: pw,
maxUsers: maxUsers,
sortOrder: sortOrder,
audio: audio
)
dismiss(nil)
onComplete?(result)
}
@objc private func cancelClicked() {
dismiss(nil)
onComplete?(nil)
}
}