broken changes
This commit is contained in:
@@ -106,6 +106,15 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
var id: AudioDeviceID
|
||||
var uid: String
|
||||
var name: String
|
||||
var outputChannels = 2
|
||||
static func channelCount(_ id: AudioDeviceID) -> Int {
|
||||
var address = AudioObjectPropertyAddress(mSelector:kAudioDevicePropertyStreamConfiguration,mScope:kAudioDevicePropertyScopeOutput,mElement:kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
guard AudioObjectGetPropertyDataSize(id,&address,0,nil,&size) == noErr,size >= MemoryLayout<AudioBufferList>.size else { return 0 }
|
||||
let memory = UnsafeMutableRawPointer.allocate(byteCount:Int(size),alignment:MemoryLayout<AudioBufferList>.alignment);defer { memory.deallocate() }
|
||||
guard AudioObjectGetPropertyData(id,&address,0,nil,&size,memory) == noErr else { return 0 }
|
||||
return UnsafeMutableAudioBufferListPointer(memory.assumingMemoryBound(to:AudioBufferList.self)).reduce(0) { $0+Int($1.mNumberChannels) }
|
||||
}
|
||||
static func outputs() -> [Self] {
|
||||
var address = AudioObjectPropertyAddress(mSelector:kAudioHardwarePropertyDevices,mScope:kAudioObjectPropertyScopeGlobal,mElement:kAudioObjectPropertyElementMain)
|
||||
var size: UInt32 = 0
|
||||
@@ -123,7 +132,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
guard AudioObjectGetPropertyData(id,&property,0,nil,&bytes,&text) == noErr else { return nil }
|
||||
return text?.takeRetainedValue() as String?
|
||||
}
|
||||
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output")
|
||||
return .init(id:id,uid:string(kAudioDevicePropertyDeviceUID) ?? String(id),name:string(kAudioObjectPropertyName) ?? "Audio output",outputChannels:channelCount(id))
|
||||
}
|
||||
}
|
||||
static func defaultOutput() -> AudioDeviceID? {
|
||||
@@ -152,6 +161,8 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
@Published var devices = [DJAudioDevice]()
|
||||
@Published var masterUID = ""
|
||||
@Published var cueUID = ""
|
||||
@Published private(set) var routing = DJAudioRouting()
|
||||
private var audioDeviceMonitor: DJAudioDeviceMonitor?
|
||||
@Published var settings = DJPreferences() { didSet { saveSettings();applyMix();for deck in [a,b] { deck.player.tempo = min(1+settings.tempoRange/100,max(1-settings.tempoRange/100,deck.player.tempo)) } } }
|
||||
@Published var status = "Load music into A or B. Karaoke keeps its own mixer and output."
|
||||
@Published var error: String?
|
||||
@@ -172,12 +183,17 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
Publishers.CombineLatest3(a.player.$playing,b.player.$playing,$syncActive).sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.updateControllerFeedback() }
|
||||
}.store(in:&syncObservers)
|
||||
Publishers.CombineLatest4(a.player.$hotCues,b.player.$hotCues,a.player.$loopEnabled,b.player.$loopEnabled).sink { [weak self] _ in
|
||||
Task { @MainActor [weak self] in self?.updateControllerFeedback() }
|
||||
}.store(in:&syncObservers)
|
||||
perform {
|
||||
try FileManager.default.createDirectory(at:root,withIntermediateDirectories:true)
|
||||
library = try Library(url:root.appendingPathComponent("Library.sqlite"));try refresh()
|
||||
if let data = try? Data(contentsOf:root.appendingPathComponent("DJSettings.json")),let saved = try? JSONDecoder().decode(DJPreferences.self,from:data) { settings = saved.sanitized }
|
||||
if let data = try? Data(contentsOf:root.appendingPathComponent("DJAudioRouting.json")),let saved = try? JSONDecoder().decode(DJAudioRouting.self,from:data) { applyRouting(saved) }
|
||||
applyMix()
|
||||
}
|
||||
audioDeviceMonitor = DJAudioDeviceMonitor { [weak self] in self?.refreshDevices() }
|
||||
}
|
||||
private func saveSettings() { perform { let data = try JSONEncoder().encode(settings.sanitized);try data.write(to:root.appendingPathComponent("DJSettings.json"),options:.atomic) } }
|
||||
func refresh() throws { songs = try library?.songs() ?? [];playlists = try library?.entities("playlists") ?? [] }
|
||||
@@ -216,7 +232,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
deinit { importTask?.cancel() }
|
||||
|
||||
func perform(_ action: () throws -> Void) { do { try action() } catch { self.error = error.localizedDescription } }
|
||||
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = weights.0;b.crossWeight = weights.1 }
|
||||
func applyMix() { let weights = DJMix.weights(crossfader,curve:settings.crossfaderCurve);let output = master*pow(10,-settings.headroomDB/20);a.master = output;b.master = output;a.crossWeight = routing.mode == "External" ? 1 : weights.0;b.crossWeight = routing.mode == "External" ? 1 : weights.1 }
|
||||
func channel(_ letter: String) -> DJChannel { letter == "A" ? a : b }
|
||||
func load(_ song: Song,into letter: String,overridePlaying: Bool = false) {
|
||||
perform {
|
||||
@@ -225,8 +241,9 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
if syncActive { disableSync() }
|
||||
if previewDeck == letter { stopPreview() }
|
||||
try deck.load(song)
|
||||
if !masterUID.isEmpty {
|
||||
do { try deck.player.routeOutput(try device(masterUID)) } catch { deck.unload();throw error }
|
||||
let route = routing.route(letter)
|
||||
if !route.uid.isEmpty || route.firstChannel != 0 || routing.mode == "External" {
|
||||
do { try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) } catch { deck.unload();throw error }
|
||||
}
|
||||
if settings.resetEQOnLoad { deck.player.resetTone() }
|
||||
if settings.autoPlayOnLoad { deck.player.toggle() }
|
||||
@@ -240,21 +257,35 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
return output.id
|
||||
}
|
||||
func routeMaster(_ uid: String) {
|
||||
let old = masterUID
|
||||
var next = routing;next.main.uid = uid;next.main.firstChannel = 0;applyRouting(next)
|
||||
}
|
||||
func applyRouting(_ next: DJAudioRouting) {
|
||||
let previous = routing
|
||||
perform {
|
||||
let id = try device(uid)
|
||||
try next.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
|
||||
stopPreview()
|
||||
do { try a.player.routeOutput(id);try b.player.routeOutput(id);masterUID = uid }
|
||||
catch { if let prior = try? device(old) { try? a.player.routeOutput(prior);try? b.player.routeOutput(prior) };throw error }
|
||||
do {
|
||||
for deck in [a,b] { let route = next.route(deck.letter);try deck.player.routeOutput(try device(route.uid),firstChannel:route.firstChannel) }
|
||||
try JSONEncoder().encode(next).write(to:root.appendingPathComponent("DJAudioRouting.json"),options:.atomic)
|
||||
routing = next;masterUID = next.main.uid;cueUID = next.cue.uid;applyMix()
|
||||
status = "Audio routing applied · \(next.mode) mixer"
|
||||
} catch {
|
||||
for deck in [a,b] { let route = previous.route(deck.letter);if let output = try? device(route.uid) { try? deck.player.routeOutput(output,firstChannel:route.firstChannel) } }
|
||||
throw error
|
||||
}
|
||||
}
|
||||
}
|
||||
private func validateCue() throws {
|
||||
var config = routing;config.cue.uid = cueUID
|
||||
try config.validate(devices:devices,defaultID:DJAudioDevice.defaultOutput())
|
||||
}
|
||||
func previewLibrary(_ song: Song) {
|
||||
perform {
|
||||
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a headphone output in DJ Devices first.") }
|
||||
let output = try device(cueUID)
|
||||
guard output != (try device(masterUID)) else { throw LibraryError.message("Library preview needs a separate headphone device.") }
|
||||
try validateCue()
|
||||
stopPreview();var audio = song;audio.cdg = nil
|
||||
try preview.load(audio,startPlaying:false);try preview.routeOutput(output);preview.toggle();previewDeck = "Library"
|
||||
try preview.load(audio,startPlaying:false);try preview.routeOutput(output,firstChannel:routing.cue.firstChannel);preview.toggle();previewDeck = "Library"
|
||||
}
|
||||
}
|
||||
func stopPreview() { preview.stop();previewDeck = nil;updateControllerFeedback() }
|
||||
@@ -264,10 +295,10 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
guard !cueUID.isEmpty else { throw LibraryError.message("Choose a separate headphone output in Audio Routing first.") }
|
||||
guard let song = deck.song else { throw LibraryError.message("Load a track first.") }
|
||||
let output = try device(cueUID)
|
||||
guard output != (try device(masterUID)) else { throw LibraryError.message("Headphone preview requires a different output device from the master. Multichannel pairs on one device are not yet supported.") }
|
||||
try validateCue()
|
||||
stopPreview();var audio = song;audio.cdg = nil
|
||||
try preview.load(audio,startPlaying:false)
|
||||
try preview.routeOutput(output)
|
||||
try preview.routeOutput(output,firstChannel:routing.cue.firstChannel)
|
||||
preview.tempo = deck.player.tempo;preview.lowGain = deck.player.lowGain;preview.midGain = deck.player.midGain;preview.highGain = deck.player.highGain;preview.filter = deck.player.filter
|
||||
preview.seek(deck.player.time);preview.toggle();previewDeck = deck.letter;updateControllerFeedback()
|
||||
status = "Headphone preview \(deck.letter) · independent playback from the deck’s current position"
|
||||
@@ -302,7 +333,7 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
}
|
||||
func editCue(_ deck: DJChannel) { cueEditor = deck.letter;cueDraft = deck.player.cuePosition;cueEditing = true }
|
||||
func saveCue() { if let cueEditor { channel(cueEditor).player.setCue(at:cueDraft) };cueEditing = false }
|
||||
private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck) }
|
||||
private func updateControllerFeedback() { midi.updateFeedback(aPlaying:a.player.playing,bPlaying:b.player.playing,sync:syncActive,aLoaded:a.player.canJog,bLoaded:b.player.canJog,cue:previewDeck,aCues:a.player.hotCues.map{$0 != nil},bCues:b.player.hotCues.map{$0 != nil},aLoop:a.player.loopEnabled,bLoop:b.player.loopEnabled) }
|
||||
func hotCue(_ index: Int,deck: DJChannel) {
|
||||
let existing = deck.player.hotCues.indices.contains(index) && deck.player.hotCues[index] != nil
|
||||
deck.player.hotCue(index)
|
||||
@@ -325,8 +356,10 @@ struct DJAudioDevice: Identifiable, Equatable {
|
||||
case "Loop off": p.clearLoop()
|
||||
case "Loop in": p.markLoopIn()
|
||||
case "Loop out": p.markLoopOut()
|
||||
case "Loop double": d.loopBeats = min(32,d.loopBeats*2)
|
||||
case "Loop half": d.loopBeats = max(1,d.loopBeats/2)
|
||||
case "Loop double","Loop half":
|
||||
let factor = parts[1] == "Loop double" ? 2.0 : 0.5
|
||||
d.loopBeats = min(32,max(1,Int(Double(d.loopBeats)*factor)))
|
||||
if p.loopEnabled,let start = p.loopIn,let end = p.loopOut { p.defineLoop(start:start,end:min(p.duration,start+(end-start)*factor)) }
|
||||
case "Tempo XML":
|
||||
let rate = 1+(value*2-1)*settings.tempoRange/100
|
||||
if syncActive,d.letter == "B" { a.player.tempo = b.bpm*rate/a.bpm;maintainSync() } else { p.tempo = rate }
|
||||
@@ -404,11 +437,12 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
private var client = MIDIClientRef(0),port = MIDIPortRef(0),connected = MIDIEndpointRef(0)
|
||||
private var decoder = DJMIDIDecoder()
|
||||
private let defaults: UserDefaults
|
||||
init(defaults: UserDefaults = .standard,connect: Bool = true) {
|
||||
self.defaults = defaults
|
||||
init(defaults: UserDefaults = .standard,connect: Bool = true,catalog: [DJXMLProfile] = DJXMLProfile.bundled) {
|
||||
self.defaults = defaults;self.xmlCatalog = catalog
|
||||
autoDetect = defaults.object(forKey:"DJ.MIDIAutoDetect.v1") as? Bool ?? true
|
||||
if let data = defaults.data(forKey:"DJ.XMLAssignments.v1"),let saved = try? JSONDecoder().decode([String:DJXMLProfile].self,from:data) { xmlAssignments = saved }
|
||||
if let data = defaults.data(forKey:"DJ.XMLCatalog.v1"),let saved = try? JSONDecoder().decode([DJXMLProfile].self,from:data) { mergeXML(saved) }
|
||||
for (source,profile) in xmlAssignments { if let updated = xmlCatalog.first(where:{$0.id == profile.id}) { xmlAssignments[source] = updated } }
|
||||
profiles = defaults.dictionary(forKey:"DJ.MIDIProfiles.v1") as? [String:String] ?? [:]
|
||||
if let data = defaults.data(forKey:"DJ.MIDIMappings.v1"),let saved = try? JSONDecoder().decode([DJMIDIMapping].self,from:data) { mappings = saved }
|
||||
guard connect else { return }
|
||||
@@ -443,7 +477,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
if connected == 0,let saved = defaults.object(forKey:"DJ.MIDISource.v1") as? NSNumber,
|
||||
sources.contains(where:{$0.id == saved.int32Value}) { select(saved.int32Value) }
|
||||
if connected == 0,autoDetect,defaults.object(forKey:"DJ.MIDISource.v1") == nil {
|
||||
let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil || DJXMLProfile.normalized(source.name) == "numark mixtrack go" }
|
||||
let matches = sources.filter { source in DJXMLProfile.match(source.name,in:xmlCatalog) != nil }
|
||||
if matches.count == 1,let match = matches.first { select(match.id) }
|
||||
else if matches.count > 1 { status = "Multiple supported controllers detected · choose a MIDI input" }
|
||||
}
|
||||
@@ -461,13 +495,14 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
if let profile = DJXMLProfile.match(source.name,in:xmlCatalog) { applyXML(profile) }
|
||||
else if DJXMLProfile.normalized(source.name) == "numark mixtrack go" { installMixtrackGo() }
|
||||
}
|
||||
if let profile = activeXML,profile.adapter == "Mixtrack Go" { applyXML(profile) }
|
||||
}
|
||||
else { status = "Could not connect MIDI source (\(result))" }
|
||||
}
|
||||
func receive(_ bytes: [UInt8]) {
|
||||
for message in decoder.feed(bytes) {
|
||||
let type = message.status & 0xF0
|
||||
if let profile = activeXML,learning == nil {
|
||||
if let profile = activeXML,profile.adapter == nil,learning == nil {
|
||||
if type == 0x80 || (type == 0x90 && message.data2 == 0) { continue }
|
||||
for binding in profile.bindings where binding.matches(message) {
|
||||
let value = binding.value(message)
|
||||
@@ -487,7 +522,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
mappings.removeAll{$0.id == mapping.id || ($0.source == sourceID && $0.action == action)};mappings.append(mapping);save();learning = nil;status = "Learned \(action) · channel \((message.status & 0x0F)+1), control \(message.data1)"
|
||||
continue
|
||||
}
|
||||
if profiles[String(sourceID)] == "Mixtrack Go",let events = mixtrack.consume(message) {
|
||||
if mixtrackEnabled,let events = mixtrack.consume(message) {
|
||||
for event in events {
|
||||
if event.action.hasPrefix("Notice: ") { status = String(event.action.dropFirst(8)) }
|
||||
else { onAction?(event.action,event.value,event.relative) }
|
||||
@@ -501,7 +536,7 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
onAction?(map.action,DJMIDIMapping.value(message.data2,mode:map.mode),map.mode != "Absolute")
|
||||
}
|
||||
}
|
||||
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" }
|
||||
var mixtrackEnabled: Bool { profiles[String(sourceID)] == "Mixtrack Go" || activeXML?.adapter == "Mixtrack Go" }
|
||||
func installMixtrackGo() {
|
||||
let preset = MixtrackGoProtocol.mappings(source:sourceID)
|
||||
let ids = Set(preset.map(\.id));mappings.removeAll{ids.contains($0.id)};mappings += preset
|
||||
@@ -527,6 +562,11 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
defaults.set(profiles,forKey:"DJ.MIDIProfiles.v1")
|
||||
if let data = try? JSONEncoder().encode(xmlAssignments) { defaults.set(data,forKey:"DJ.XMLAssignments.v1") }
|
||||
pickupValues = [:];pickedUp = [];learning = nil
|
||||
if profile.adapter == "Mixtrack Go" {
|
||||
mixtrack = .init();let preset = MixtrackGoProtocol.mappings(source:sourceID)
|
||||
let ids = Set(preset.map(\.id));mappings.removeAll { ids.contains($0.id) };mappings += preset;save()
|
||||
sendBytes([0xF0,0x7E,0x7F,0x06,0x01,0xF7]);onAction?("Controller feedback",0,false)
|
||||
}
|
||||
status = "\(profile.name) · \(profile.bindings.count) supported controls · review compatibility report"
|
||||
}
|
||||
private func selectFeedbackOutput(_ name: String) {
|
||||
@@ -538,9 +578,9 @@ struct DJMIDIMapping: Codable, Identifiable, Equatable {
|
||||
if candidate == name.lowercased() || (candidate.contains("mixtrack") && candidate.contains("go")) { outputDestination = endpoint;break }
|
||||
}
|
||||
}
|
||||
func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) {
|
||||
func updateFeedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) {
|
||||
guard mixtrackEnabled else { return }
|
||||
sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue))
|
||||
sendBytes(mixtrack.feedback(aPlaying:aPlaying,bPlaying:bPlaying,sync:sync,aLoaded:aLoaded,bLoaded:bLoaded,cue:cue,aCues:aCues,bCues:bCues,aLoop:aLoop,bLoop:bLoop))
|
||||
}
|
||||
private func sendBytes(_ bytes: [UInt8]) {
|
||||
guard outputDestination != 0,outputPort != 0,!bytes.isEmpty,bytes.count <= 256 else { return }
|
||||
@@ -1163,17 +1203,7 @@ struct DJPreferencesPane: View {
|
||||
}
|
||||
}
|
||||
}
|
||||
private var devices: some View {
|
||||
PreferencesSection("DJ Audio Devices") {
|
||||
VStack(alignment:.leading,spacing:18) {
|
||||
Picker("Main output",selection:Binding(get:{system.masterUID},set:{system.routeMaster($0)})) { Text("System default").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } }
|
||||
Picker("Headphone preview",selection:Binding(get:{system.cueUID},set:{system.stopPreview();system.cueUID = $0})) { Text("None").tag("");ForEach(system.devices) { Text($0.name).tag($0.uid) } }
|
||||
HStack { Text("Preview volume");Slider(value:$system.cueLevel,in:0...1) }
|
||||
Button("Refresh devices") { system.refreshDevices() }
|
||||
Text("Independent headphone playback requires a different device from main output. Each device uses its default stereo pair. Multichannel master/cue pairs, booth output, microphone input and external mixer routing are not implemented. Routing selections apply for this session.").font(.caption).foregroundStyle(.secondary)
|
||||
}.padding(12)
|
||||
}.onAppear { system.refreshDevices() }
|
||||
}
|
||||
private var devices: some View { DJAudioDevicesPane(system:system) }
|
||||
private var sound: some View {
|
||||
VStack(alignment:.leading,spacing:26) {
|
||||
PreferencesSection("Mixer") {
|
||||
@@ -1378,7 +1408,7 @@ private struct DJCueEditor: View {
|
||||
struct MixtrackGoProtocol {
|
||||
struct Event { var action: String;var value: Double = 1;var relative = false }
|
||||
var modes = [0,0]
|
||||
var lowEQ = true
|
||||
var lowEQ = false
|
||||
private var msb = [64,64],lsb = [0,0],gotMSB = [false,false],gotLSB = [false,false]
|
||||
static func mappings(source: Int32) -> [DJMIDIMapping] {
|
||||
var result = [DJMIDIMapping]()
|
||||
@@ -1421,12 +1451,17 @@ struct MixtrackGoProtocol {
|
||||
if [UInt8(0x90),0x91].contains(status),[UInt8(0x46),0x47,0x48,0x49].contains(number) { return [.init(action:"Notice: Acapella/instrumental stems are not implemented.")] }
|
||||
return nil
|
||||
}
|
||||
func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?) -> [UInt8] {
|
||||
func feedback(aPlaying: Bool,bPlaying: Bool,sync: Bool,aLoaded: Bool,bLoaded: Bool,cue: String?,aCues: [Bool] = [],bCues: [Bool] = [],aLoop: Bool = false,bLoop: Bool = false) -> [UInt8] {
|
||||
var bytes = [UInt8]()
|
||||
for i in 0..<2 {
|
||||
let status = UInt8(0x90+i),pad = UInt8(0x94+i),loaded = i == 0 ? aLoaded : bLoaded,playing = i == 0 ? aPlaying : bPlaying
|
||||
bytes += [status,0x00,playing ? 127 : (loaded ? 1 : 0),status,0x01,loaded ? 1 : 0,status,0x02,sync ? 127 : 0]
|
||||
bytes += [UInt8(0x80+i),0x1B,cue == (i == 0 ? "A" : "B") ? 127 : 1]
|
||||
for index in 0..<4 {
|
||||
let cues = i == 0 ? aCues : bCues,loop = i == 0 ? aLoop : bLoop
|
||||
let on = modes[i] == 0 ? (cues.indices.contains(index) && cues[index]) : modes[i] == 1 && loop
|
||||
bytes += [pad,UInt8(0x14+index),on ? 127 : 1]
|
||||
}
|
||||
for mode in 0..<4 {
|
||||
let on = modes[i] == 0 ? mode == 0 : modes[i] == 1 ? mode == 1 : modes[i] == 2 ? (mode == 1 || mode == 2) : modes[i] == 3 ? mode == 2 : mode == 3
|
||||
bytes += [pad,UInt8(mode+1),on ? 127 : 1]
|
||||
|
||||
Reference in New Issue
Block a user