feat(audio-switcher): support grouped outputs (#163)
This commit is contained in:
@@ -2,8 +2,9 @@
|
|||||||
|
|
||||||
Audio Switcher puts PipeWire inputs, outputs, volume controls, and Bluetooth
|
Audio Switcher puts PipeWire inputs, outputs, volume controls, and Bluetooth
|
||||||
audio handoff in one compact Noctalia panel. Devices can be renamed or hidden,
|
audio handoff in one compact Noctalia panel. Devices can be renamed or hidden,
|
||||||
and compositor keybinds can cycle devices or connect a specific Bluetooth
|
multiple outputs can be grouped for simultaneous playback, and compositor
|
||||||
device by its persistent number.
|
keybinds can cycle devices or connect a specific Bluetooth device by its
|
||||||
|
persistent number.
|
||||||
|
|
||||||

|

|
||||||
|
|
||||||
@@ -23,6 +24,8 @@ through visible inputs. Scrolling over it changes the output volume.
|
|||||||
Install `pactl`, `bluetoothctl`, and `sleep` on `PATH`. On Arch Linux they are
|
Install `pactl`, `bluetoothctl`, and `sleep` on `PATH`. On Arch Linux they are
|
||||||
provided by the `libpulse`, `bluez-utils`, and `coreutils` packages respectively.
|
provided by the `libpulse`, `bluez-utils`, and `coreutils` packages respectively.
|
||||||
PipeWire's PulseAudio compatibility service and BlueZ must be running.
|
PipeWire's PulseAudio compatibility service and BlueZ must be running.
|
||||||
|
Output grouping also requires PulseAudio's or PipeWire Pulse's
|
||||||
|
`module-combine-sink`.
|
||||||
|
|
||||||
## Usage
|
## Usage
|
||||||
|
|
||||||
@@ -42,6 +45,12 @@ change a Bluetooth keybind number. The number is assigned automatically after a
|
|||||||
device connects successfully for the first time and can then be changed. Hidden
|
device connects successfully for the first time and can then be changed. Hidden
|
||||||
devices remain available in the panel but are skipped by cycling commands.
|
devices remain available in the panel but are skipped by cycling commands.
|
||||||
|
|
||||||
|
To play through multiple outputs at once, open **Outputs**, choose **Group
|
||||||
|
outputs**, select at least two available physical outputs, then choose **Create
|
||||||
|
group**. The combined output becomes the default and current playback streams
|
||||||
|
move to it. Use **Disband** on its row to remove it; if it is active, playback
|
||||||
|
moves to the first available member before removal.
|
||||||
|
|
||||||
Use the settings button in the panel header to open Noctalia's plugin settings.
|
Use the settings button in the panel header to open Noctalia's plugin settings.
|
||||||
|
|
||||||
## Settings
|
## Settings
|
||||||
@@ -98,6 +107,12 @@ other Bluetooth audio devices connected to this computer. It cannot disconnect
|
|||||||
the target from another computer or phone; that device must release the target
|
the target from another computer or phone; that device must release the target
|
||||||
first unless it supports multipoint connections.
|
first unless it supports multipoint connections.
|
||||||
|
|
||||||
|
Output groups are `module-combine-sink` instances owned by the running audio
|
||||||
|
server. Audio Switcher rediscovers its groups after the plugin service restarts,
|
||||||
|
but they disappear if PipeWire/PulseAudio restarts. Combining outputs may add
|
||||||
|
latency and resampling CPU cost, especially when the devices use different
|
||||||
|
clocks or sample rates.
|
||||||
|
|
||||||
The plugin does not access the network. Its service spawns only the declared
|
The plugin does not access the network. Its service spawns only the declared
|
||||||
`pactl`, `bluetoothctl`, and `sleep` commands. The short sleep is used while
|
`pactl`, `bluetoothctl`, and `sleep` commands. The short sleep is used while
|
||||||
waiting for a newly connected Bluetooth endpoint to appear. The panel may also
|
waiting for a newly connected Bluetooth endpoint to appear. The panel may also
|
||||||
|
|||||||
+125
-18
@@ -31,6 +31,8 @@ local editSlot = ""
|
|||||||
local feedback = ""
|
local feedback = ""
|
||||||
local feedbackError = false
|
local feedbackError = false
|
||||||
local requestCounter = 0
|
local requestCounter = 0
|
||||||
|
local groupingOutputs = false
|
||||||
|
local selectedGroupOutputs = {}
|
||||||
-- Slider onChange is also emitted when the host applies a new controlled value.
|
-- Slider onChange is also emitted when the host applies a new controlled value.
|
||||||
-- Keep the last callback value only for onDragEnd; rendering from it would turn
|
-- Keep the last callback value only for onDragEnd; rendering from it would turn
|
||||||
-- the first external update into a sticky local draft and freeze later updates.
|
-- the first external update into a sticky local draft and freeze later updates.
|
||||||
@@ -89,6 +91,14 @@ local function hiddenCount(kind)
|
|||||||
return count
|
return count
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function selectedGroupCount()
|
||||||
|
local count = 0
|
||||||
|
for _, selected in pairs(selectedGroupOutputs) do
|
||||||
|
if selected then count += 1 end
|
||||||
|
end
|
||||||
|
return count
|
||||||
|
end
|
||||||
|
|
||||||
local function selectedIndex(values, selected)
|
local function selectedIndex(values, selected)
|
||||||
for index, value in ipairs(values) do
|
for index, value in ipairs(values) do
|
||||||
if value == selected then return index - 1 end
|
if value == selected then return index - 1 end
|
||||||
@@ -211,12 +221,56 @@ local function deviceRow(device, kind)
|
|||||||
local hideAction = function()
|
local hideAction = function()
|
||||||
sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden })
|
sendCommand("set_hidden", { id = device.id, kind = kind, hidden = not device.hidden })
|
||||||
end
|
end
|
||||||
|
local selectAction = function()
|
||||||
|
selectedGroupOutputs[device.id] = not selectedGroupOutputs[device.id]
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
local disbandAction = function()
|
||||||
|
sendCommand("remove_output_group", { id = device.id })
|
||||||
|
end
|
||||||
local actionText = device.active and tr("device.current")
|
local actionText = device.active and tr("device.current")
|
||||||
or (device.bluetooth and not device.available and tr("actions.connect_and_use") or tr("actions.use"))
|
or (device.bluetooth and not device.available and tr("actions.connect_and_use") or tr("actions.use"))
|
||||||
local actionVariant = device.active and "ghost" or "primary"
|
local actionVariant = device.active and "ghost" or "primary"
|
||||||
local statusColor = device.active and "secondary" or "on_surface_variant"
|
local statusColor = device.active and "secondary" or "on_surface_variant"
|
||||||
local row = ui.column({ gap = 6 }, {
|
local actions = {}
|
||||||
ui.row({ align = "center", gap = 8, padding = 7, radius = 9, border = device.active and "primary" or "outline/0.55", borderWidth = 1, fill = device.active and "primary/0.07" or "surface/0" }, {
|
if groupingOutputs and kind == "output" then
|
||||||
|
table.insert(actions, ui.button({
|
||||||
|
text = selectedGroupOutputs[device.id] and tr("actions.selected") or tr("actions.select"),
|
||||||
|
glyph = selectedGroupOutputs[device.id] and "check" or "plus",
|
||||||
|
variant = selectedGroupOutputs[device.id] and "primary" or "outline",
|
||||||
|
controlSize = "sm",
|
||||||
|
enabled = device.available == true and device.group ~= true and snapshot.busy ~= true,
|
||||||
|
onClick = selectAction,
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
table.insert(actions, ui.button({
|
||||||
|
text = actionText,
|
||||||
|
variant = actionVariant,
|
||||||
|
controlSize = "sm",
|
||||||
|
enabled = not device.active and snapshot.busy ~= true,
|
||||||
|
onClick = useAction,
|
||||||
|
}))
|
||||||
|
if kind == "output" and device.group then
|
||||||
|
table.insert(actions, ui.button({
|
||||||
|
text = tr("actions.disband"),
|
||||||
|
glyph = "unlink",
|
||||||
|
variant = "outline",
|
||||||
|
controlSize = "sm",
|
||||||
|
enabled = snapshot.busy ~= true,
|
||||||
|
onClick = disbandAction,
|
||||||
|
}))
|
||||||
|
else
|
||||||
|
table.insert(actions, ui.button({ glyph = "edit", variant = "ghost", controlSize = "sm", tooltip = tr("actions.edit"), onClick = editAction }))
|
||||||
|
table.insert(actions, ui.button({
|
||||||
|
glyph = device.hidden and "eye" or "eye-off",
|
||||||
|
variant = "ghost",
|
||||||
|
controlSize = "sm",
|
||||||
|
tooltip = tr(device.hidden and "actions.show" or "actions.hide"),
|
||||||
|
onClick = hideAction,
|
||||||
|
}))
|
||||||
|
end
|
||||||
|
end
|
||||||
|
local rowChildren = {
|
||||||
ui.glyph({ name = device.icon or (kind == "output" and "volume" or "microphone"), size = 19, color = device.active and "primary" or "on_surface_variant" }),
|
ui.glyph({ name = device.icon or (kind == "output" and "volume" or "microphone"), size = 19, color = device.active and "primary" or "on_surface_variant" }),
|
||||||
ui.column({ flexGrow = 1, gap = 2 }, {
|
ui.column({ flexGrow = 1, gap = 2 }, {
|
||||||
ui.label({ text = tostring(device.name or device.description or device.id), fontWeight = "bold", maxLines = 1 }),
|
ui.label({ text = tostring(device.name or device.description or device.id), fontWeight = "bold", maxLines = 1 }),
|
||||||
@@ -234,28 +288,48 @@ local function deviceRow(device, kind)
|
|||||||
fontWeight = "bold",
|
fontWeight = "bold",
|
||||||
visible = device.bluetooth == true,
|
visible = device.bluetooth == true,
|
||||||
}),
|
}),
|
||||||
ui.button({
|
}
|
||||||
text = actionText,
|
for _, action in ipairs(actions) do table.insert(rowChildren, action) end
|
||||||
variant = actionVariant,
|
local row = ui.column({ gap = 6 }, {
|
||||||
controlSize = "sm",
|
ui.row({ align = "center", gap = 8, padding = 7, radius = 9, border = selectedGroupOutputs[device.id] and "primary" or (device.active and "primary" or "outline/0.55"), borderWidth = 1, fill = selectedGroupOutputs[device.id] and "primary/0.1" or (device.active and "primary/0.07" or "surface/0") }, rowChildren),
|
||||||
enabled = not device.active and snapshot.busy ~= true,
|
|
||||||
onClick = useAction,
|
|
||||||
}),
|
|
||||||
ui.button({ glyph = "edit", variant = "ghost", controlSize = "sm", tooltip = tr("actions.edit"), onClick = editAction }),
|
|
||||||
ui.button({
|
|
||||||
glyph = device.hidden and "eye" or "eye-off",
|
|
||||||
variant = "ghost",
|
|
||||||
controlSize = "sm",
|
|
||||||
tooltip = tr(device.hidden and "actions.show" or "actions.hide"),
|
|
||||||
onClick = hideAction,
|
|
||||||
}),
|
|
||||||
}),
|
|
||||||
})
|
})
|
||||||
local children = { row }
|
local children = { row }
|
||||||
if editingId == device.id then table.insert(children, editor(device)) end
|
if editingId == device.id then table.insert(children, editor(device)) end
|
||||||
return ui.column({ gap = 6 }, children)
|
return ui.column({ gap = 6 }, children)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function groupingToolbar()
|
||||||
|
if activeTab ~= "output" then return ui.column({ visible = false }, {}) end
|
||||||
|
if not groupingOutputs then
|
||||||
|
return ui.row({ justify = "end" }, {
|
||||||
|
ui.button({
|
||||||
|
text = tr("actions.group_outputs"),
|
||||||
|
glyph = "link",
|
||||||
|
variant = "outline",
|
||||||
|
enabled = snapshot.busy ~= true,
|
||||||
|
onClick = "onStartGrouping",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
local count = selectedGroupCount()
|
||||||
|
return ui.row({ align = "center", gap = 8, padding = 8, radius = 9, fill = "primary/0.08" }, {
|
||||||
|
ui.label({
|
||||||
|
text = tr("panel.group_hint", { count = count }),
|
||||||
|
flexGrow = 1,
|
||||||
|
color = "on_surface_variant",
|
||||||
|
fontSize = 11,
|
||||||
|
}),
|
||||||
|
ui.button({ text = tr("actions.cancel"), variant = "ghost", onClick = "onCancelGrouping" }),
|
||||||
|
ui.button({
|
||||||
|
text = tr("actions.create_group", { count = count }),
|
||||||
|
glyph = "link",
|
||||||
|
variant = "primary",
|
||||||
|
enabled = count >= 2 and snapshot.busy ~= true,
|
||||||
|
onClick = "onCreateGroup",
|
||||||
|
}),
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
local function deviceList(kind)
|
local function deviceList(kind)
|
||||||
local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
|
local source = kind == "output" and asArray(snapshot.outputs) or asArray(snapshot.inputs)
|
||||||
local rows = {}
|
local rows = {}
|
||||||
@@ -357,6 +431,7 @@ render = function()
|
|||||||
header(),
|
header(),
|
||||||
ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }),
|
ui.row({ gap = 12, align = "stretch" }, { volumeCard("output"), volumeCard("input") }),
|
||||||
toolbar(),
|
toolbar(),
|
||||||
|
groupingToolbar(),
|
||||||
ui.column({ gap = 3 }, statusRows),
|
ui.column({ gap = 3 }, statusRows),
|
||||||
ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }),
|
ui.scroll({ flexGrow = 1, gap = 6 }, { deviceList(activeTab) }),
|
||||||
ui.label({
|
ui.label({
|
||||||
@@ -371,6 +446,8 @@ end
|
|||||||
function onOpen(_context)
|
function onOpen(_context)
|
||||||
feedback = ""
|
feedback = ""
|
||||||
feedbackError = false
|
feedbackError = false
|
||||||
|
groupingOutputs = false
|
||||||
|
selectedGroupOutputs = {}
|
||||||
sendCommand("refresh")
|
sendCommand("refresh")
|
||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
@@ -406,9 +483,35 @@ function onShowInputs()
|
|||||||
activeTab = "input"
|
activeTab = "input"
|
||||||
editingId = nil
|
editingId = nil
|
||||||
showHidden = false
|
showHidden = false
|
||||||
|
groupingOutputs = false
|
||||||
|
selectedGroupOutputs = {}
|
||||||
render()
|
render()
|
||||||
end
|
end
|
||||||
|
|
||||||
|
function onStartGrouping()
|
||||||
|
groupingOutputs = true
|
||||||
|
selectedGroupOutputs = {}
|
||||||
|
editingId = nil
|
||||||
|
showHidden = false
|
||||||
|
feedback = ""
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onCancelGrouping()
|
||||||
|
groupingOutputs = false
|
||||||
|
selectedGroupOutputs = {}
|
||||||
|
render()
|
||||||
|
end
|
||||||
|
|
||||||
|
function onCreateGroup()
|
||||||
|
local ids = {}
|
||||||
|
for _, device in ipairs(asArray(snapshot.outputs)) do
|
||||||
|
if selectedGroupOutputs[device.id] then table.insert(ids, device.id) end
|
||||||
|
end
|
||||||
|
if #ids < 2 then return end
|
||||||
|
sendCommand("create_output_group", { ids = ids })
|
||||||
|
end
|
||||||
|
|
||||||
function onToggleHidden()
|
function onToggleHidden()
|
||||||
showHidden = not showHidden
|
showHidden = not showHidden
|
||||||
editingId = nil
|
editingId = nil
|
||||||
@@ -478,6 +581,10 @@ noctalia.state.watch(RESULT_KEY, function(result)
|
|||||||
if type(result) ~= "table" or not tostring(result.requestId or ""):match("^panel%-") then return end
|
if type(result) ~= "table" or not tostring(result.requestId or ""):match("^panel%-") then return end
|
||||||
feedback = tostring(result.message or "")
|
feedback = tostring(result.message or "")
|
||||||
feedbackError = result.ok ~= true
|
feedbackError = result.ok ~= true
|
||||||
|
if result.ok == true and result.action == "create_output_group" then
|
||||||
|
groupingOutputs = false
|
||||||
|
selectedGroupOutputs = {}
|
||||||
|
end
|
||||||
if result.ok ~= true and (result.action == "set_output_volume" or result.action == "set_input_volume") then
|
if result.ok ~= true and (result.action == "set_output_volume" or result.action == "set_input_volume") then
|
||||||
outputVolumeCommitValue = nil
|
outputVolumeCommitValue = nil
|
||||||
inputVolumeCommitValue = nil
|
inputVolumeCommitValue = nil
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
id = "blackbartblues/audio-switcher"
|
id = "blackbartblues/audio-switcher"
|
||||||
name = "Audio Switcher"
|
name = "Audio Switcher"
|
||||||
version = "0.2.2"
|
version = "0.3.0"
|
||||||
plugin_api = 9
|
plugin_api = 9
|
||||||
author = "blackbartblues"
|
author = "blackbartblues"
|
||||||
license = "MIT"
|
license = "MIT"
|
||||||
|
|||||||
+164
-5
@@ -10,6 +10,8 @@ local PREFERENCES_VERSION = 1
|
|||||||
local REFRESH_INTERVAL_MS = 3000
|
local REFRESH_INTERVAL_MS = 3000
|
||||||
local BLUETOOTH_CONNECT_RETRIES = 16
|
local BLUETOOTH_CONNECT_RETRIES = 16
|
||||||
local BLUETOOTH_CONNECT_RETRY_SECONDS = 0.25
|
local BLUETOOTH_CONNECT_RETRY_SECONDS = 0.25
|
||||||
|
local GROUP_SINK_PREFIX = "noctalia_group_"
|
||||||
|
local GROUP_DESCRIPTION = "Noctalia_Output_Group"
|
||||||
|
|
||||||
local dataDir = noctalia.pluginDataDir()
|
local dataDir = noctalia.pluginDataDir()
|
||||||
local preferencesPath = dataDir and (dataDir .. "/preferences.json") or nil
|
local preferencesPath = dataDir and (dataDir .. "/preferences.json") or nil
|
||||||
@@ -58,6 +60,8 @@ local volumeRefreshes = {
|
|||||||
input = { running = false, again = false },
|
input = { running = false, again = false },
|
||||||
}
|
}
|
||||||
local audioEventRevisions = { output = 0, input = 0 }
|
local audioEventRevisions = { output = 0, input = 0 }
|
||||||
|
local combineSinkOption = "slaves"
|
||||||
|
local groupNameCounter = 0
|
||||||
|
|
||||||
local refreshAll
|
local refreshAll
|
||||||
local setOutput
|
local setOutput
|
||||||
@@ -236,13 +240,14 @@ local function bluetoothAddress(properties)
|
|||||||
return value:upper()
|
return value:upper()
|
||||||
end
|
end
|
||||||
|
|
||||||
local function makeOutput(item, defaultName)
|
local function makeOutput(item, defaultName, group)
|
||||||
local properties = propertiesOf(item)
|
local properties = propertiesOf(item)
|
||||||
local address = bluetoothAddress(properties)
|
local address = bluetoothAddress(properties)
|
||||||
local isBluetooth = address ~= ""
|
local isBluetooth = address ~= ""
|
||||||
local stableId = isBluetooth and bluetoothOutputId(address) or tostring(item.name or "")
|
local stableId = isBluetooth and bluetoothOutputId(address) or tostring(item.name or "")
|
||||||
local fallback = tostring(properties["device.alias"] or item.description or item.name or stableId)
|
local fallback = tostring(properties["device.alias"] or item.description or item.name or stableId)
|
||||||
local icon, iconStyle = resolvedDeviceIcon(stableId, isBluetooth and "headphones" or "device-speaker")
|
if group ~= nil then fallback = noctalia.tr("device.output_group") end
|
||||||
|
local icon, iconStyle = resolvedDeviceIcon(stableId, group ~= nil and "link" or (isBluetooth and "headphones" or "device-speaker"))
|
||||||
return {
|
return {
|
||||||
id = stableId,
|
id = stableId,
|
||||||
targetName = tostring(item.name or ""),
|
targetName = tostring(item.name or ""),
|
||||||
@@ -258,6 +263,9 @@ local function makeOutput(item, defaultName)
|
|||||||
muted = item.mute == true,
|
muted = item.mute == true,
|
||||||
icon = icon,
|
icon = icon,
|
||||||
iconStyle = iconStyle,
|
iconStyle = iconStyle,
|
||||||
|
group = group ~= nil,
|
||||||
|
groupModuleId = group and group.moduleId or nil,
|
||||||
|
groupMembers = group and group.members or nil,
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
@@ -304,16 +312,42 @@ local function parseInfo(output)
|
|||||||
return {
|
return {
|
||||||
defaultSink = tostring(decoded.default_sink_name or ""),
|
defaultSink = tostring(decoded.default_sink_name or ""),
|
||||||
defaultSource = tostring(decoded.default_source_name or ""),
|
defaultSource = tostring(decoded.default_source_name or ""),
|
||||||
|
serverName = tostring(decoded.server_name or ""),
|
||||||
}
|
}
|
||||||
end
|
end
|
||||||
|
|
||||||
local function parseOutputs(output, defaultName)
|
local function moduleArgumentValue(argument, key)
|
||||||
|
return tostring(argument or ""):match("%f[%w_]" .. key .. "=([^%s]+)")
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseOutputGroups(output)
|
||||||
|
local groups = {}
|
||||||
|
for line in (tostring(output or "") .. "\n"):gmatch("(.-)\n") do
|
||||||
|
local moduleId, moduleName, argument = line:match("^(%d+)%s+([^%s]+)%s+(.-)%s*$")
|
||||||
|
if moduleName == "module-combine-sink" then
|
||||||
|
local sinkName = moduleArgumentValue(argument, "sink_name")
|
||||||
|
local membersValue = moduleArgumentValue(argument, "sinks")
|
||||||
|
or moduleArgumentValue(argument, "slaves")
|
||||||
|
if sinkName ~= nil and sinkName:sub(1, #GROUP_SINK_PREFIX) == GROUP_SINK_PREFIX then
|
||||||
|
local members = {}
|
||||||
|
for member in tostring(membersValue or ""):gmatch("[^,]+") do
|
||||||
|
table.insert(members, member)
|
||||||
|
end
|
||||||
|
groups[sinkName] = { moduleId = tonumber(moduleId), members = members }
|
||||||
|
end
|
||||||
|
end
|
||||||
|
end
|
||||||
|
return groups
|
||||||
|
end
|
||||||
|
|
||||||
|
local function parseOutputs(output, defaultName, groups)
|
||||||
local decoded = decodeJson(output)
|
local decoded = decodeJson(output)
|
||||||
local devices = {}
|
local devices = {}
|
||||||
if decoded == nil then return devices end
|
if decoded == nil then return devices end
|
||||||
for _, item in ipairs(decoded) do
|
for _, item in ipairs(decoded) do
|
||||||
if type(item) == "table" and trim(item.name) ~= "" then
|
if type(item) == "table" and trim(item.name) ~= "" then
|
||||||
table.insert(devices, makeOutput(item, defaultName))
|
local name = tostring(item.name or "")
|
||||||
|
table.insert(devices, makeOutput(item, defaultName, groups and groups[name] or nil))
|
||||||
end
|
end
|
||||||
end
|
end
|
||||||
sortDevices(devices)
|
sortDevices(devices)
|
||||||
@@ -586,6 +620,13 @@ local function loadAudio(callback)
|
|||||||
callback(false, noctalia.tr("errors.invalid_audio_data"))
|
callback(false, noctalia.tr("errors.invalid_audio_data"))
|
||||||
return
|
return
|
||||||
end
|
end
|
||||||
|
combineSinkOption = info.serverName:find("PipeWire", 1, true) ~= nil and "sinks" or "slaves"
|
||||||
|
runPactl({ "list", "short", "modules" }, function(moduleResult)
|
||||||
|
if moduleResult.exitCode ~= 0 then
|
||||||
|
callback(false, trim(moduleResult.stderr))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local groups = parseOutputGroups(moduleResult.stdout)
|
||||||
runPactl({ "-f", "json", "list", "sinks" }, function(sinkResult)
|
runPactl({ "-f", "json", "list", "sinks" }, function(sinkResult)
|
||||||
if sinkResult.exitCode ~= 0 then
|
if sinkResult.exitCode ~= 0 then
|
||||||
callback(false, trim(sinkResult.stderr))
|
callback(false, trim(sinkResult.stderr))
|
||||||
@@ -600,7 +641,7 @@ local function loadAudio(callback)
|
|||||||
local currentInputVolume = snapshot.inputVolume
|
local currentInputVolume = snapshot.inputVolume
|
||||||
local currentOutputMuted = snapshot.outputMuted
|
local currentOutputMuted = snapshot.outputMuted
|
||||||
local currentInputMuted = snapshot.inputMuted
|
local currentInputMuted = snapshot.inputMuted
|
||||||
snapshot.outputs = parseOutputs(sinkResult.stdout, info.defaultSink)
|
snapshot.outputs = parseOutputs(sinkResult.stdout, info.defaultSink, groups)
|
||||||
snapshot.inputs = parseInputs(sourceResult.stdout, info.defaultSource)
|
snapshot.inputs = parseInputs(sourceResult.stdout, info.defaultSource)
|
||||||
snapshot.defaultOutputId = ""
|
snapshot.defaultOutputId = ""
|
||||||
snapshot.defaultInputId = ""
|
snapshot.defaultInputId = ""
|
||||||
@@ -642,6 +683,7 @@ local function loadAudio(callback)
|
|||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
end)
|
end)
|
||||||
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
local function loadBluetooth(callback)
|
local function loadBluetooth(callback)
|
||||||
@@ -774,6 +816,119 @@ local function setDefaultTarget(command, kind, device)
|
|||||||
end)
|
end)
|
||||||
end
|
end
|
||||||
|
|
||||||
|
local function uniqueGroupSinkName()
|
||||||
|
groupNameCounter += 1
|
||||||
|
local base = GROUP_SINK_PREFIX .. tostring(os.time()) .. "_" .. tostring(groupNameCounter)
|
||||||
|
local candidate = base
|
||||||
|
local suffix = 1
|
||||||
|
while findOutputById(candidate) ~= nil do
|
||||||
|
suffix += 1
|
||||||
|
candidate = base .. "_" .. tostring(suffix)
|
||||||
|
end
|
||||||
|
return candidate
|
||||||
|
end
|
||||||
|
|
||||||
|
local function createOutputGroup(command)
|
||||||
|
if actionBusy then
|
||||||
|
resultMessage(command, false, noctalia.tr("errors.busy"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local selected = {}
|
||||||
|
local seen = {}
|
||||||
|
for _, id in ipairs(type(command.ids) == "table" and command.ids or {}) do
|
||||||
|
local device = findOutputById(tostring(id or ""))
|
||||||
|
if device ~= nil and device.available and not device.group and trim(device.targetName) ~= "" and not seen[device.id] then
|
||||||
|
seen[device.id] = true
|
||||||
|
table.insert(selected, device)
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if #selected < 2 then
|
||||||
|
resultMessage(command, false, noctalia.tr("errors.group_requires_two"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local targetNames = {}
|
||||||
|
for _, device in ipairs(selected) do table.insert(targetNames, device.targetName) end
|
||||||
|
local sinkName = uniqueGroupSinkName()
|
||||||
|
actionBusy = true
|
||||||
|
publishSnapshot()
|
||||||
|
runPactl({
|
||||||
|
"load-module",
|
||||||
|
"module-combine-sink",
|
||||||
|
"sink_name=" .. sinkName,
|
||||||
|
combineSinkOption .. "=" .. table.concat(targetNames, ","),
|
||||||
|
"sink_properties=device.description=" .. GROUP_DESCRIPTION,
|
||||||
|
}, function(loadResult)
|
||||||
|
local moduleId = tonumber(trim(loadResult.stdout))
|
||||||
|
if loadResult.exitCode ~= 0 or moduleId == nil then
|
||||||
|
local message = trim(loadResult.stderr)
|
||||||
|
finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_create_failed"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
runPactl({ "set-default-sink", sinkName }, function(defaultResult)
|
||||||
|
if defaultResult.exitCode ~= 0 then
|
||||||
|
runPactl({ "unload-module", tostring(moduleId) }, function()
|
||||||
|
local message = trim(defaultResult.stderr)
|
||||||
|
finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_switch_failed"))
|
||||||
|
end)
|
||||||
|
return
|
||||||
|
end
|
||||||
|
moveStreams("output", sinkName, function()
|
||||||
|
finishAction(command, true, noctalia.tr("notifications.group_created"))
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
local function removeOutputGroup(command)
|
||||||
|
if actionBusy then
|
||||||
|
resultMessage(command, false, noctalia.tr("errors.busy"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
local group = findOutputById(tostring(command.id or ""))
|
||||||
|
if group == nil or not group.group or tonumber(group.groupModuleId) == nil then
|
||||||
|
resultMessage(command, false, noctalia.tr("errors.group_not_found"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
|
||||||
|
local fallback = nil
|
||||||
|
for _, targetName in ipairs(type(group.groupMembers) == "table" and group.groupMembers or {}) do
|
||||||
|
for _, device in ipairs(snapshot.outputs) do
|
||||||
|
if not device.group and device.available and device.targetName == targetName then
|
||||||
|
fallback = device
|
||||||
|
break
|
||||||
|
end
|
||||||
|
end
|
||||||
|
if fallback ~= nil then break end
|
||||||
|
end
|
||||||
|
|
||||||
|
actionBusy = true
|
||||||
|
publishSnapshot()
|
||||||
|
local function unloadGroup()
|
||||||
|
runPactl({ "unload-module", tostring(group.groupModuleId) }, function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
local message = trim(result.stderr)
|
||||||
|
finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_remove_failed"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
finishAction(command, true, noctalia.tr("notifications.group_removed"))
|
||||||
|
end)
|
||||||
|
end
|
||||||
|
|
||||||
|
if group.active and fallback ~= nil then
|
||||||
|
runPactl({ "set-default-sink", fallback.targetName }, function(result)
|
||||||
|
if result.exitCode ~= 0 then
|
||||||
|
local message = trim(result.stderr)
|
||||||
|
finishAction(command, false, message ~= "" and message or noctalia.tr("errors.group_switch_failed"))
|
||||||
|
return
|
||||||
|
end
|
||||||
|
moveStreams("output", fallback.targetName, unloadGroup)
|
||||||
|
end)
|
||||||
|
else
|
||||||
|
unloadGroup()
|
||||||
|
end
|
||||||
|
end
|
||||||
|
|
||||||
assignSlot = function(address)
|
assignSlot = function(address)
|
||||||
address = tostring(address or ""):upper()
|
address = tostring(address or ""):upper()
|
||||||
if address == "" or tonumber(preferences.slots[address]) ~= nil then return end
|
if address == "" or tonumber(preferences.slots[address]) ~= nil then return end
|
||||||
@@ -1111,6 +1266,10 @@ local function executeCommand(command)
|
|||||||
setOutput(command, tostring(command.id or ""))
|
setOutput(command, tostring(command.id or ""))
|
||||||
elseif action == "set_input" then
|
elseif action == "set_input" then
|
||||||
setInput(command, tostring(command.id or ""))
|
setInput(command, tostring(command.id or ""))
|
||||||
|
elseif action == "create_output_group" then
|
||||||
|
createOutputGroup(command)
|
||||||
|
elseif action == "remove_output_group" then
|
||||||
|
removeOutputGroup(command)
|
||||||
elseif action == "cycle_output" then
|
elseif action == "cycle_output" then
|
||||||
cycleDevice(command, "output")
|
cycleDevice(command, "output")
|
||||||
elseif action == "cycle_input" then
|
elseif action == "cycle_input" then
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ local decoded = {
|
|||||||
INFO = {
|
INFO = {
|
||||||
default_sink_name = "sink.main",
|
default_sink_name = "sink.main",
|
||||||
default_source_name = "source.main",
|
default_source_name = "source.main",
|
||||||
|
server_name = "PulseAudio (on PipeWire 1.6.8)",
|
||||||
},
|
},
|
||||||
SINKS_UNMUTED = {
|
SINKS_UNMUTED = {
|
||||||
{
|
{
|
||||||
@@ -106,6 +107,7 @@ assert(load(serviceSource, "@service.luau"))()
|
|||||||
assert(type(streamCallback) == "function", "pactl subscription was not started")
|
assert(type(streamCallback) == "function", "pactl subscription was not started")
|
||||||
|
|
||||||
complete("'info'", "INFO")
|
complete("'info'", "INFO")
|
||||||
|
complete("'list' 'short' 'modules'", "")
|
||||||
complete("'list' 'sinks'", "SINKS_UNMUTED")
|
complete("'list' 'sinks'", "SINKS_UNMUTED")
|
||||||
complete("'list' 'sources'", "SOURCES_UNMUTED")
|
complete("'list' 'sources'", "SOURCES_UNMUTED")
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,200 @@
|
|||||||
|
local function clone(value)
|
||||||
|
if type(value) ~= "table" then return value end
|
||||||
|
local result = {}
|
||||||
|
for key, item in pairs(value) do result[clone(key)] = clone(item) end
|
||||||
|
return result
|
||||||
|
end
|
||||||
|
|
||||||
|
local values = {}
|
||||||
|
local watchers = {}
|
||||||
|
local pending = {}
|
||||||
|
|
||||||
|
local decoded = {
|
||||||
|
INFO_PHYSICAL = {
|
||||||
|
default_sink_name = "sink.a",
|
||||||
|
default_source_name = "source.main",
|
||||||
|
server_name = "PulseAudio (on PipeWire 1.6.8)",
|
||||||
|
},
|
||||||
|
INFO_GROUPED = {
|
||||||
|
default_sink_name = "noctalia_group_123_1",
|
||||||
|
default_source_name = "source.main",
|
||||||
|
server_name = "PulseAudio (on PipeWire 1.6.8)",
|
||||||
|
},
|
||||||
|
SINKS_PHYSICAL = {
|
||||||
|
{
|
||||||
|
name = "sink.a",
|
||||||
|
description = "Speakers",
|
||||||
|
mute = false,
|
||||||
|
volume = { front_left = { value_percent = "40%" } },
|
||||||
|
properties = {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "sink.b",
|
||||||
|
description = "Headphones",
|
||||||
|
mute = false,
|
||||||
|
volume = { front_left = { value_percent = "40%" } },
|
||||||
|
properties = {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SINKS_GROUPED = {
|
||||||
|
{
|
||||||
|
name = "sink.a",
|
||||||
|
description = "Speakers",
|
||||||
|
mute = false,
|
||||||
|
volume = { front_left = { value_percent = "40%" } },
|
||||||
|
properties = {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "sink.b",
|
||||||
|
description = "Headphones",
|
||||||
|
mute = false,
|
||||||
|
volume = { front_left = { value_percent = "40%" } },
|
||||||
|
properties = {},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name = "noctalia_group_123_1",
|
||||||
|
description = "Noctalia Output Group",
|
||||||
|
mute = false,
|
||||||
|
volume = { front_left = { value_percent = "40%" } },
|
||||||
|
properties = {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
SOURCES = {
|
||||||
|
{
|
||||||
|
name = "source.main",
|
||||||
|
description = "Microphone",
|
||||||
|
mute = false,
|
||||||
|
volume = { front_left = { value_percent = "30%" } },
|
||||||
|
properties = {},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
STREAMS_EMPTY = {},
|
||||||
|
}
|
||||||
|
|
||||||
|
local fakeTime = 123
|
||||||
|
|
||||||
|
noctalia = {
|
||||||
|
pluginDataDir = function() return nil end,
|
||||||
|
getConfig = function() return nil end,
|
||||||
|
commandExists = function(command) return command == "pactl" end,
|
||||||
|
readFile = function() return nil end,
|
||||||
|
writeFile = function() return true end,
|
||||||
|
setUpdateInterval = function() end,
|
||||||
|
runAsync = function(command, callback)
|
||||||
|
pending[#pending + 1] = { command = command, callback = callback }
|
||||||
|
return true
|
||||||
|
end,
|
||||||
|
runStream = function() return true end,
|
||||||
|
json = {
|
||||||
|
decode = function(value) return clone(decoded[value]) end,
|
||||||
|
encode = function() return "PREFERENCES" end,
|
||||||
|
},
|
||||||
|
string = {
|
||||||
|
trim = function(value) return tostring(value or ""):match("^%s*(.-)%s*$") or "" end,
|
||||||
|
},
|
||||||
|
state = {
|
||||||
|
get = function(key) return clone(values[key]) end,
|
||||||
|
set = function(key, value)
|
||||||
|
values[key] = clone(value)
|
||||||
|
if watchers[key] ~= nil then watchers[key](clone(value)) end
|
||||||
|
end,
|
||||||
|
watch = function(key, callback) watchers[key] = callback end,
|
||||||
|
},
|
||||||
|
tr = function(key) return key end,
|
||||||
|
log = function() end,
|
||||||
|
notify = function() end,
|
||||||
|
notifyError = function() end,
|
||||||
|
}
|
||||||
|
|
||||||
|
local function complete(expected, stdout, exitCode)
|
||||||
|
local call = table.remove(pending, 1)
|
||||||
|
assert(call ~= nil, "expected pending command containing " .. expected)
|
||||||
|
assert(call.command:find(expected, 1, true) ~= nil, "unexpected command: " .. call.command)
|
||||||
|
call.callback({
|
||||||
|
exitCode = exitCode or 0,
|
||||||
|
stdout = stdout or "",
|
||||||
|
stderr = "",
|
||||||
|
timedOut = false,
|
||||||
|
stdoutTruncated = false,
|
||||||
|
stderrTruncated = false,
|
||||||
|
})
|
||||||
|
end
|
||||||
|
|
||||||
|
local function completeRefresh(info, modules, sinks)
|
||||||
|
complete("'info'", info)
|
||||||
|
complete("'list' 'short' 'modules'", modules)
|
||||||
|
complete("'list' 'sinks'", sinks)
|
||||||
|
complete("'list' 'sources'", "SOURCES")
|
||||||
|
end
|
||||||
|
|
||||||
|
local originalTime = os.time
|
||||||
|
os.time = function() return fakeTime end
|
||||||
|
|
||||||
|
local serviceFile = assert(io.open("service.luau", "r"))
|
||||||
|
local serviceSource = serviceFile:read("*a")
|
||||||
|
serviceFile:close()
|
||||||
|
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%+=%s*([^\n]+)", "%1 = %1 + %2")
|
||||||
|
serviceSource = serviceSource:gsub("([%w_%.]+)%s*%-=%s*([^\n]+)", "%1 = %1 - %2")
|
||||||
|
assert(load(serviceSource, "@service.luau"))()
|
||||||
|
|
||||||
|
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||||
|
|
||||||
|
noctalia.state.set("audio_switcher_command", {
|
||||||
|
requestId = "create-group",
|
||||||
|
action = "create_output_group",
|
||||||
|
ids = { "sink.a", "sink.b" },
|
||||||
|
})
|
||||||
|
assert(pending[1].command:find("'sinks=sink.a,sink.b'", 1, true), "PipeWire must use the sinks module option")
|
||||||
|
complete("'load-module' 'module-combine-sink'", "77\n")
|
||||||
|
complete("'set-default-sink' 'noctalia_group_123_1'")
|
||||||
|
complete("'list' 'sink-inputs'", "STREAMS_EMPTY")
|
||||||
|
completeRefresh(
|
||||||
|
"INFO_GROUPED",
|
||||||
|
"77\tmodule-combine-sink\tsink_name=noctalia_group_123_1 sinks=sink.a,sink.b sink_properties=device.description=Noctalia_Output_Group\t\n",
|
||||||
|
"SINKS_GROUPED"
|
||||||
|
)
|
||||||
|
|
||||||
|
local snapshot = values["audio_switcher_snapshot"]
|
||||||
|
local group = nil
|
||||||
|
for _, output in ipairs(snapshot.outputs) do
|
||||||
|
if output.group then group = output end
|
||||||
|
end
|
||||||
|
assert(group ~= nil, "plugin-created combine sink was not detected")
|
||||||
|
assert(group.active == true, "new output group is not active")
|
||||||
|
assert(group.groupModuleId == 77, "module index was not retained")
|
||||||
|
assert(#group.groupMembers == 2, "group members were not parsed")
|
||||||
|
|
||||||
|
noctalia.state.set("audio_switcher_command", {
|
||||||
|
requestId = "remove-group",
|
||||||
|
action = "remove_output_group",
|
||||||
|
id = group.id,
|
||||||
|
})
|
||||||
|
complete("'set-default-sink' 'sink.a'")
|
||||||
|
complete("'list' 'sink-inputs'", "STREAMS_EMPTY")
|
||||||
|
complete("'unload-module' '77'")
|
||||||
|
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||||
|
|
||||||
|
snapshot = values["audio_switcher_snapshot"]
|
||||||
|
for _, output in ipairs(snapshot.outputs) do
|
||||||
|
assert(output.group ~= true, "removed group remained in the snapshot")
|
||||||
|
end
|
||||||
|
|
||||||
|
decoded.INFO_PHYSICAL.server_name = "pulseaudio"
|
||||||
|
noctalia.state.set("audio_switcher_command", {
|
||||||
|
requestId = "refresh-pulseaudio",
|
||||||
|
action = "refresh",
|
||||||
|
})
|
||||||
|
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||||
|
noctalia.state.set("audio_switcher_command", {
|
||||||
|
requestId = "create-pulseaudio-group",
|
||||||
|
action = "create_output_group",
|
||||||
|
ids = { "sink.a", "sink.b" },
|
||||||
|
})
|
||||||
|
assert(pending[1].command:find("'slaves=sink.a,sink.b'", 1, true), "PulseAudio must use the slaves module option")
|
||||||
|
complete("'load-module' 'module-combine-sink'", "", 1)
|
||||||
|
completeRefresh("INFO_PHYSICAL", "", "SINKS_PHYSICAL")
|
||||||
|
|
||||||
|
assert(#pending == 0, "unexpected commands remain pending")
|
||||||
|
|
||||||
|
os.time = originalTime
|
||||||
|
print("audio-switcher output group tests: ok")
|
||||||
@@ -3,11 +3,16 @@
|
|||||||
"cancel": "Cancel",
|
"cancel": "Cancel",
|
||||||
"close": "Close",
|
"close": "Close",
|
||||||
"connect_and_use": "Connect & use",
|
"connect_and_use": "Connect & use",
|
||||||
|
"create_group": "Create group ({count})",
|
||||||
"cycle": "Cycle",
|
"cycle": "Cycle",
|
||||||
|
"disband": "Disband",
|
||||||
"edit": "Edit device",
|
"edit": "Edit device",
|
||||||
|
"group_outputs": "Group outputs",
|
||||||
"hide": "Hide device",
|
"hide": "Hide device",
|
||||||
"refresh": "Refresh",
|
"refresh": "Refresh",
|
||||||
"save": "Save",
|
"save": "Save",
|
||||||
|
"select": "Select",
|
||||||
|
"selected": "Selected",
|
||||||
"settings": "Plugin settings",
|
"settings": "Plugin settings",
|
||||||
"show": "Show device",
|
"show": "Show device",
|
||||||
"use": "Use"
|
"use": "Use"
|
||||||
@@ -17,7 +22,8 @@
|
|||||||
"available": "Available",
|
"available": "Available",
|
||||||
"bluetooth_connected": "Bluetooth connected",
|
"bluetooth_connected": "Bluetooth connected",
|
||||||
"bluetooth_disconnected": "Bluetooth disconnected",
|
"bluetooth_disconnected": "Bluetooth disconnected",
|
||||||
"current": "Current"
|
"current": "Current",
|
||||||
|
"output_group": "Output group"
|
||||||
},
|
},
|
||||||
"editor": {
|
"editor": {
|
||||||
"alias": "Display name",
|
"alias": "Display name",
|
||||||
@@ -43,6 +49,11 @@
|
|||||||
"command_start": "Could not start the system command.",
|
"command_start": "Could not start the system command.",
|
||||||
"device_not_found": "The selected device no longer exists.",
|
"device_not_found": "The selected device no longer exists.",
|
||||||
"device_unavailable": "The selected device is not currently available.",
|
"device_unavailable": "The selected device is not currently available.",
|
||||||
|
"group_create_failed": "Could not create the output group.",
|
||||||
|
"group_not_found": "The output group no longer exists.",
|
||||||
|
"group_remove_failed": "Could not disband the output group.",
|
||||||
|
"group_requires_two": "Select at least two available outputs.",
|
||||||
|
"group_switch_failed": "The output group was created, but could not be selected.",
|
||||||
"invalid_audio_data": "The audio service returned invalid data.",
|
"invalid_audio_data": "The audio service returned invalid data.",
|
||||||
"invalid_slot": "The keybind number must be a whole number from 1 to 99.",
|
"invalid_slot": "The keybind number must be a whole number from 1 to 99.",
|
||||||
"invalid_volume": "The requested volume is invalid.",
|
"invalid_volume": "The requested volume is invalid.",
|
||||||
@@ -55,6 +66,8 @@
|
|||||||
},
|
},
|
||||||
"notifications": {
|
"notifications": {
|
||||||
"bluetooth_disconnected": "Bluetooth device disconnected.",
|
"bluetooth_disconnected": "Bluetooth device disconnected.",
|
||||||
|
"group_created": "Output group created and selected.",
|
||||||
|
"group_removed": "Output group disbanded.",
|
||||||
"input_selected": "Input switched to {device}.",
|
"input_selected": "Input switched to {device}.",
|
||||||
"output_selected": "Output switched to {device}.",
|
"output_selected": "Output switched to {device}.",
|
||||||
"preferences_saved": "Device preferences saved.",
|
"preferences_saved": "Device preferences saved.",
|
||||||
@@ -62,6 +75,7 @@
|
|||||||
"slot_saved": "Keybind number {slot} saved."
|
"slot_saved": "Keybind number {slot} saved."
|
||||||
},
|
},
|
||||||
"panel": {
|
"panel": {
|
||||||
|
"group_hint": "{count} selected. Choose at least two available outputs.",
|
||||||
"hidden": "Hidden",
|
"hidden": "Hidden",
|
||||||
"hidden_count": "Hidden {count}",
|
"hidden_count": "Hidden {count}",
|
||||||
"input_volume": "Microphone",
|
"input_volume": "Microphone",
|
||||||
|
|||||||
Reference in New Issue
Block a user