Compare commits
41 Commits
37358ec8d4
...
v1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| bc7a5d7239 | |||
| 596e188c75 | |||
| 9e0ecced8b | |||
| a21babbf8d | |||
| 7b205c761b | |||
| 528dccb255 | |||
| 00cdcd0cc9 | |||
| c78b767958 | |||
| cd13c8d021 | |||
| 78e27a8460 | |||
| fba0e91224 | |||
| e83c347442 | |||
| 87a2e4542e | |||
| d9bdd5c9e8 | |||
| 65c37e636c | |||
| fec87df9c9 | |||
| 96829d0b6a | |||
| b8f26d6965 | |||
| 7342721715 | |||
| ad79872631 | |||
| 2f257a1ee7 | |||
| 25834e99da | |||
| b31e12d148 | |||
| b09e1d680e | |||
| e5ddc47587 | |||
| 7581078bba | |||
| bbc8d22abb | |||
| d82503b4ff | |||
| 67ff81900d | |||
| 3447f5b545 | |||
| 1b3d915de6 | |||
| bd738d600e | |||
| cfed2fb677 | |||
| 8d7c2523f4 | |||
| f108910b87 | |||
| e563470b64 | |||
| ea1b8f558e | |||
| 8c4a6a495c | |||
| c54817edc2 | |||
| 4ceaad99a3 | |||
| 431fa906b1 |
17
README
17
README
@@ -1,16 +1,25 @@
|
||||
Install
|
||||
|
||||
pip install --user -e .
|
||||
|
||||
Requirements:
|
||||
- For volume buttons: libpulse
|
||||
- (optionally) for volume buttons: libpulse
|
||||
|
||||
local:
|
||||
./setup.sh
|
||||
|
||||
Uninstall
|
||||
|
||||
pip uninstall controldeck
|
||||
|
||||
rm ~/.local/share/application/controldeck.desktop
|
||||
rm ~/.config/systemd/user/controldeck.service
|
||||
|
||||
Configuration
|
||||
|
||||
~/.config/controldeck/controldeck.conf
|
||||
See example in example directory.
|
||||
|
||||
Start (autostart) with systemd
|
||||
systemctl --user start controldeck.service
|
||||
systemctl --user enable controldeck.service
|
||||
|
||||
journalctl --user-unit=controldeck -e
|
||||
journalctl --user-unit=controldeck -f
|
||||
|
||||
594
controldeck.py
Normal file → Executable file
594
controldeck.py
Normal file → Executable file
@@ -1,141 +1,567 @@
|
||||
#!/usr/bin/env python
|
||||
"""
|
||||
HTML style powered by Tailwind CSS
|
||||
|
||||
NOTE: currently buttons only updated on page reload
|
||||
(buttons will be recreated)
|
||||
"""
|
||||
|
||||
import sys
|
||||
from os import path, sep, makedirs
|
||||
from subprocess import Popen, PIPE, STDOUT
|
||||
from configparser import ConfigParser, DuplicateSectionError
|
||||
from configparser import ConfigParser
|
||||
from re import search, IGNORECASE
|
||||
from justpy import Div, WebPage, SetRoute, justpy
|
||||
from justpy import Div, I, WebPage, SetRoute, parse_html, run_task, justpy
|
||||
from cairosvg import svg2svg
|
||||
|
||||
APP_NAME = "ControlDeck"
|
||||
|
||||
def process(args):
|
||||
STATUS_DIV = Div()
|
||||
STATUS_DIV.classes = " border-2 border-gray-800 bg-gray-900 text-gray-600 h-40 m-2 pt-1 pl-2 mr-16 rounded-lg flex overflow-y-auto"
|
||||
STATUS_DIV.style = "scrollbar-width: thin;"
|
||||
|
||||
def tohtml(text):
|
||||
return text.replace("\n", "<br>")
|
||||
|
||||
def mouseenter_status(self, msg):
|
||||
"""Div style see STATUS_DIV.classes"""
|
||||
STATUS_DIV.inner_html = "<dl>"
|
||||
STATUS_DIV.inner_html += " <dt class='font-bold'>command</dt>"
|
||||
STATUS_DIV.inner_html += f" <dd class='pl-4'>{tohtml(self.command.strip())}</dd>"
|
||||
STATUS_DIV.inner_html += " <dt class='font-bold'>command-alt</dt>"
|
||||
STATUS_DIV.inner_html += f" <dd class='pl-4'>{tohtml(self.command_alt.strip())}</dd>"
|
||||
STATUS_DIV.inner_html += " <dt class='font-bold'>state</dt>"
|
||||
STATUS_DIV.inner_html += f" <dd class='pl-4'>{tohtml(self.state.strip())}</dd>"
|
||||
STATUS_DIV.inner_html += " <dt class='font-bold'>state-pattern</dt>"
|
||||
STATUS_DIV.inner_html += f" <dd class='pl-4'>{tohtml(self.state_pattern.strip())}</dd>"
|
||||
STATUS_DIV.inner_html += " <dt class='font-bold'>state-pattern-alt</dt>"
|
||||
STATUS_DIV.inner_html += f" <dd class='pl-4'>{tohtml(self.state_pattern_alt.strip())}</dd>"
|
||||
STATUS_DIV.inner_html += " <dt class='font-bold'>switched</dt>"
|
||||
STATUS_DIV.inner_html += f" <dd class='pl-4'>{str(self.switched)}</dd>"
|
||||
STATUS_DIV.inner_html += "</dl>"
|
||||
|
||||
def process(args, output=True):
|
||||
try:
|
||||
# with shell=True args can be a string
|
||||
# detached process https://stackoverflow.com/a/65900355/992129 start_new_session
|
||||
# https://docs.python.org/3/library/subprocess.html#popen-constructor
|
||||
result = Popen(args, stdout=PIPE, stderr=STDOUT, shell=True, start_new_session=True)
|
||||
return result.stdout.read().decode("utf-8").rstrip()
|
||||
if output:
|
||||
return result.stdout.read().decode("utf-8").rstrip()
|
||||
except Exception as e:
|
||||
print(f"{e} failed!")
|
||||
|
||||
def process_shell(command):
|
||||
print(command)
|
||||
# string works only with shell
|
||||
if isinstance(command, (list)):
|
||||
# e.g.: [['pkill', 'ArdourGUI'], ['systemctl', '--user', 'restart', 'pipewire', 'pipewire-pulse'], ['ardour6', '-n', 'productive-pipewire']]
|
||||
if isinstance(command[0], (list)):
|
||||
[process(i, False) for i in command]
|
||||
else:
|
||||
# e.g.: ['pkill', 'ArdourGUI']
|
||||
process(command, False)
|
||||
else:
|
||||
# e.g.: 'pkill ArdourGUI'
|
||||
process(command, False)
|
||||
|
||||
def volume(name):
|
||||
try:
|
||||
return process(f'pamixer --get-volume --sink "{name}"')
|
||||
except OSError as e:
|
||||
#n = process(r"pactl list sinks short | awk '{print $2}'").split()
|
||||
#v = process(r"pactl list sinks | grep '^[[:space:]]Volume:' | sed -e 's,.* \([0-9][0-9]*\)%.*,\1,'").split()
|
||||
#return v[n.index(name)]
|
||||
print(e)
|
||||
result = process(f'pamixer --sink "{name}" --get-volume-human')
|
||||
if search("The sink doesn't exit", result):
|
||||
result = "--"
|
||||
elif search("pamixer: command not found", result) is not None:
|
||||
n = process(r"pactl list sinks short | awk '{print $2}'").split()
|
||||
v = process(r"pactl list sinks | grep '^[[:space:]]Volume:' | sed -e 's,.* \([0-9][0-9]*\)%.*,\1,'").split()
|
||||
if name in n:
|
||||
result = v[n.index(name)] + '%'
|
||||
else:
|
||||
result = '--'
|
||||
return result
|
||||
|
||||
def volume_decrease(name):
|
||||
#process(f'pactl set-sink-volume "{name}" -5db')
|
||||
return process(f'pamixer --get-volume --sink "{name}" --decrease 5')
|
||||
result = process(f'pamixer --sink "{name}" --get-volume-human --decrease 5')
|
||||
if search("pamixer: command not found", result) is not None:
|
||||
process(f'pactl set-sink-volume "{name}" -5%')
|
||||
#process(f'pactl set-sink-volume "{name}" -5db')
|
||||
result = volume(name)
|
||||
return result
|
||||
|
||||
def volume_increase(name):
|
||||
#process(f'pactl set-sink-volume "{name}" -5db')
|
||||
return process(f'pamixer --get-volume --sink "{name}" --increase 5')
|
||||
result = process(f'pamixer --sink "{name}" --get-volume-human --increase 5')
|
||||
if search("pamixer: command not found", result) is not None:
|
||||
process(f'pactl set-sink-volume "{name}" +5%')
|
||||
#process(f'pactl set-sink-volume "{name}" +5db')
|
||||
result = volume(name)
|
||||
return result
|
||||
|
||||
class Button(Div):
|
||||
command = None
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.classes = "bg-gray-800 hover:bg-gray-700 w-20 h-20 m-2 p-1 rounded-lg font-bold flex items-center text-center justify-center select-none"
|
||||
if self.command is not None:
|
||||
def click(self, msg):
|
||||
print(self.command)
|
||||
# string works only with shell
|
||||
if isinstance(self.command, (list)):
|
||||
# e.g.: [['pkill', 'ArdourGUI'], ['systemctl', '--user', 'restart', 'pipewire', 'pipewire-pulse'], ['ardour6', '-n', 'productive-pipewire']]
|
||||
if isinstance(self.command[0], (list)):
|
||||
[process(i) for i in self.command]
|
||||
else:
|
||||
# e.g.: ['pkill', 'ArdourGUI']
|
||||
process(self.command)
|
||||
else:
|
||||
# e.g.: 'pkill ArdourGUI'
|
||||
process(self.command)
|
||||
self.on('click', click)
|
||||
def volume_mute(name):
|
||||
result = process(f'pamixer --sink "{name}" --get-volume-human --toggle-mute')
|
||||
if search("pamixer: command not found", result) is not None:
|
||||
process(f'pactl set-sink-mute "{name}" toggle')
|
||||
result = volume(name)
|
||||
return result
|
||||
|
||||
class ButtonSound(Div):
|
||||
div = None
|
||||
name = None
|
||||
description = None
|
||||
volume = None
|
||||
def source_volume(name):
|
||||
result = process(f'pamixer --source "{name}" --get-volume-human')
|
||||
if search("The source doesn't exit", result):
|
||||
result = "--"
|
||||
elif search("pamixer: command not found", result) is not None:
|
||||
n = process(r"pactl list sources short | awk '{print $2}'").split()
|
||||
v = process(r"pactl list sources | grep '^[[:space:]]Volume:' | sed -e 's,.* \([0-9][0-9]*\)%.*,\1,'").split()
|
||||
if name in n:
|
||||
result = v[n.index(name)] + '%'
|
||||
else:
|
||||
result = '--'
|
||||
return result
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.classes = "grid-rows-2"
|
||||
self.div = Div(classes="flex")
|
||||
Button(inner_html=f'{self.description}<br> - 5%', click=self.decrease, a=self.div)
|
||||
Button(inner_html=f'{self.description}<br> + 5%', click=self.increase, a=self.div)
|
||||
self.add(self.div)
|
||||
self.volume = Div(text=f"Volume: {volume(self.name)}%", classes="text-center -mt-2", a=self)
|
||||
def source_volume_decrease(name):
|
||||
result = process(f'pamixer --source "{name}" --get-volume-human --decrease 5')
|
||||
if search("pamixer: command not found", result) is not None:
|
||||
process(f'pactl set-source-volume "{name}" -5%')
|
||||
#process(f'pactl set-source-volume "{name}" -5db')
|
||||
result = volume(name)
|
||||
return result
|
||||
|
||||
async def decrease(self, msg):
|
||||
self.volume.text = f'Volume: {volume_decrease(self.name)}%'
|
||||
def source_volume_increase(name):
|
||||
result = process(f'pamixer --source "{name}" --get-volume-human --increase 5')
|
||||
if search("pamixer: command not found", result) is not None:
|
||||
process(f'pactl set-source-volume "{name}" +5%')
|
||||
#process(f'pactl set-source-volume "{name}" +5db')
|
||||
result = volume(name)
|
||||
return result
|
||||
|
||||
async def increase(self, msg):
|
||||
self.volume.text = f'Volume: {volume_increase(self.name)}%'
|
||||
|
||||
@SetRoute('/')
|
||||
def application():
|
||||
wp = WebPage(title=APP_NAME, body_classes="bg-gray-900")
|
||||
wp.head_html = '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
|
||||
# div = Div(classes="flex flex-wrap", a=wp)
|
||||
# ButtonSound(name="Stream_sink", description="Stream sink", a=div)
|
||||
# div2 = Div(classes="flex flex-wrap", a=wp)
|
||||
# Button(text="Sleep", command='systemctl suspend', a=div2)
|
||||
def source_volume_mute(name):
|
||||
result = process(f'pamixer --source "{name}" --get-volume-human --toggle-mute')
|
||||
if search("pamixer: command not found", result) is not None:
|
||||
process(f'pactl set-source-mute "{name}" toggle')
|
||||
result = volume(name)
|
||||
return result
|
||||
|
||||
def config_load(conf=''):
|
||||
config = ConfigParser(strict=False)
|
||||
config_file = "controldeck.conf"
|
||||
full_config_file_path = path.dirname(path.realpath(__file__)) + sep + config_file
|
||||
if not path.exists(config_file):
|
||||
config_folder = path.join(path.expanduser("~"), '.config', APP_NAME.lower())
|
||||
makedirs(config_folder, exist_ok=True)
|
||||
full_config_file_path = path.join(config_folder, config_file)
|
||||
if conf:
|
||||
full_config_file_path = conf
|
||||
else:
|
||||
config_file = "controldeck.conf"
|
||||
full_config_file_path = path.dirname(path.realpath(__file__)) + sep + config_file
|
||||
if not path.exists(config_file):
|
||||
config_folder = path.join(path.expanduser("~"), '.config', APP_NAME.lower())
|
||||
makedirs(config_folder, exist_ok=True)
|
||||
full_config_file_path = path.join(config_folder, config_file)
|
||||
try:
|
||||
config.read(full_config_file_path)
|
||||
except Exception as e:
|
||||
print(f"{e}")
|
||||
#print(config.sections())
|
||||
return config
|
||||
|
||||
def svg_element(image):
|
||||
svg = ''
|
||||
parse = False
|
||||
if path.isfile(path.expanduser(image)):
|
||||
try:
|
||||
with open(path.expanduser(image)) as f:
|
||||
svg = f.read()
|
||||
except Exception as e:
|
||||
print(f"{e}")
|
||||
# 1st try direct parsing
|
||||
try: # svg with custom tags, as inkscape is using, cannot be interpreted
|
||||
_svg = parse_html(svg)
|
||||
parse = True
|
||||
except Exception as e:
|
||||
# 2nd try svg2svg parsing
|
||||
try: # svg with custom tags, as inkscape is using, cannot be interpreted
|
||||
svg = svg2svg(bytestring=svg.encode('utf-8')).decode('utf-8')
|
||||
_svg = parse_html(svg)
|
||||
parse = True
|
||||
except Exception as e:
|
||||
print(f"[Error SVG]: {e}")
|
||||
_svg = None
|
||||
if parse:
|
||||
# set width and height to viewBox to update width and height for scaling
|
||||
w = _svg.width if hasattr(_svg, 'width') else "64"
|
||||
h = _svg.height if hasattr(_svg, 'height') else "64"
|
||||
vb = _svg.viewBox if hasattr(_svg, 'viewBox') else '0 0 ' + w + ' ' + h
|
||||
_svg.viewBox = vb
|
||||
_svg.width = 64
|
||||
_svg.height = 64
|
||||
return _svg
|
||||
|
||||
class Button(Div):
|
||||
"""
|
||||
usage
|
||||
Button(text, text_alt, btype, command, command_alt,
|
||||
color_bg=, color_fg=, state_pattern, state_pattern_alt,
|
||||
state_command, state_command_alt,
|
||||
icon, icon_alt, image, image_alt,
|
||||
a)
|
||||
"""
|
||||
text = ''
|
||||
text_normal = ''
|
||||
text_alt = ''
|
||||
btype = None
|
||||
command = ''
|
||||
command_alt = ''
|
||||
color_bg = ''
|
||||
color_fg = ''
|
||||
icon = ''
|
||||
icon_alt = ''
|
||||
image = ''
|
||||
image_alt = ''
|
||||
image_element = None
|
||||
image_alt_element = None
|
||||
state = ''
|
||||
state_pattern = ''
|
||||
state_pattern_alt = ''
|
||||
state_command = ''
|
||||
state_command_alt = ''
|
||||
switched = False
|
||||
def update_state(self):
|
||||
self.state = process(self.state_command)
|
||||
# update switched state
|
||||
if self.state_pattern != '' and search(self.state_pattern, self.state):
|
||||
# search is None if search has no match otherwise re.Match object
|
||||
self.switched = False
|
||||
elif self.state_pattern_alt != '' and search(self.state_pattern_alt, self.state):
|
||||
self.switched = True
|
||||
else:
|
||||
self.switched = False
|
||||
# change text / icon
|
||||
if not self.switched:
|
||||
self.classes = self.classes.replace("border-green-700", "border-gray-700")
|
||||
if self.image:
|
||||
self.components[0] = self.image_element
|
||||
elif self.icon:
|
||||
self.inner_html = f"<i class='fa-2x {self.icon}'><i>"
|
||||
else:
|
||||
self.text = self.text_normal
|
||||
else:
|
||||
self.classes = self.classes.replace("border-gray-700", "border-green-700")
|
||||
if self.image_alt:
|
||||
self.components[0] = self.image_alt_element
|
||||
elif self.icon_alt:
|
||||
self.inner_html = f"<i class='fa-2x {self.icon_alt}'><i>"
|
||||
elif self.text_alt:
|
||||
self.text = self.text_alt
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.text_normal = str(self.text)
|
||||
self.on('mouseenter', mouseenter_status)
|
||||
if self.btype == 'empty':
|
||||
# empty is like an invisible spacer with the size of a button
|
||||
self.classes = "w-20 h-20 m-2 p-1 flex select-none"
|
||||
self.text = ''
|
||||
|
||||
else:
|
||||
self.classes = "fitin border-2 border-gray-700 bg-gray-800 hover:bg-gray-700 text-gray-500 w-20 h-20 m-2 p-1 rounded-lg font-bold flex items-center text-center justify-center select-none"
|
||||
|
||||
self.style = f"background-color:{self.color_bg};" if ishexcolor(self.color_bg) else ''
|
||||
self.style += f"color:{self.color_fg};" if ishexcolor(self.color_fg) else ''
|
||||
|
||||
if self.command != '':
|
||||
def click(self, msg):
|
||||
self.state = process(self.state_command)
|
||||
# run command
|
||||
if not self.switched:
|
||||
process_shell(self.command)
|
||||
else:
|
||||
if self.command_alt != '':
|
||||
process_shell(self.command_alt)
|
||||
else:
|
||||
process_shell(self.command)
|
||||
# update switched state
|
||||
#if self.state_pattern == '':
|
||||
# self.switched = not self.switched
|
||||
self.update_state()
|
||||
self.on('click', click)
|
||||
|
||||
self.state = process(self.state_command)
|
||||
self.image_element = svg_element(self.image)
|
||||
self.image_alt_element = svg_element(self.image_alt)
|
||||
if self.image and self.image_element is not None:
|
||||
self.text = ''
|
||||
if self.image_alt and not search(self.state_pattern, self.state):
|
||||
self.add(self.image_alt_element)
|
||||
else:
|
||||
self.add(self.image_element)
|
||||
elif self.icon:
|
||||
if self.icon_alt and not search(self.state_pattern, self.state):
|
||||
self.inner_html = f"<i class='fa-2x {self.icon_alt}'><i>"
|
||||
else:
|
||||
self.inner_html = f"<i class='fa-2x {self.icon}'><i>"
|
||||
else:
|
||||
if self.text_alt and not search(self.state_pattern, self.state):
|
||||
# self.text = self.text_alt
|
||||
# div for fitin logic
|
||||
self.inner_html = f"<div>{self.text_alt}</div>"
|
||||
elif self.text_normal: # only if string is not empty (for font icons)
|
||||
# self.text = self.text_normal
|
||||
# div for fitin logic
|
||||
self.inner_html = f"<div>{self.text_normal}</div>"
|
||||
self.update_state()
|
||||
|
||||
class ButtonsSound(Div):
|
||||
div = None
|
||||
btype = None
|
||||
name = None
|
||||
description = None
|
||||
volume = None
|
||||
decrease_icon = ''
|
||||
decrease_image = ''
|
||||
increase_icon = ''
|
||||
increase_image = ''
|
||||
mute_icon = ''
|
||||
mute_icon_alt = ''
|
||||
mute_image = ''
|
||||
mute_image_alt = ''
|
||||
bmute = None
|
||||
|
||||
def __init__(self, **kwargs):
|
||||
super().__init__(**kwargs)
|
||||
self.classes = "grid-rows-2"
|
||||
self.div = Div(classes="flex")
|
||||
|
||||
if self.decrease_image:
|
||||
tmp = svg_element(self.decrease_image)
|
||||
if tmp is not None:
|
||||
bdec = Button(click=self.decrease, a=self.div).add(tmp)
|
||||
elif self.decrease_icon:
|
||||
bdec = Button(icon = self.decrease_icon, click=self.decrease, a=self.div)
|
||||
else:
|
||||
bdec = Button(inner_html='- 5%', click=self.decrease, a=self.div)
|
||||
|
||||
if self.increase_image:
|
||||
tmp = svg_element(self.increase_image)
|
||||
if tmp is not None:
|
||||
binc = Button(click=self.increase, a=self.div).add(tmp)
|
||||
elif self.increase_icon:
|
||||
binc = Button(icon=self.increase_icon,
|
||||
click=self.increase, a=self.div)
|
||||
else:
|
||||
binc = Button(inner_html='+ 5%', click=self.increase, a=self.div)
|
||||
|
||||
if self.mute_image and self.mute_image is not None:
|
||||
self.bmute = Button(click=self.mute,
|
||||
image=self.mute_image,
|
||||
image_alt=self.mute_image_alt,
|
||||
a=self.div)
|
||||
else:
|
||||
self.bmute = Button(text='mute',
|
||||
icon=self.mute_icon,
|
||||
icon_alt=self.mute_icon_alt,
|
||||
click=self.mute, a=self.div)
|
||||
if self.btype == 'mic':
|
||||
self.bmute.state = f'{source_volume(self.name)}'
|
||||
else:
|
||||
self.bmute.state = f'{volume(self.name)}'
|
||||
|
||||
if self.bmute.state == 'muted':
|
||||
if self.bmute.image_alt_element:
|
||||
self.bmute.components[0] = self.bmute.image_alt_element
|
||||
elif self.mute_icon:
|
||||
self.bmute.inner_html = f"<i class='fa-2x {self.bmute.icon_alt}'><i>"
|
||||
else:
|
||||
self.bmute.text = 'unmute'
|
||||
|
||||
self.add(self.div)
|
||||
if self.btype == 'mic':
|
||||
self.volume = Div(text=f"{self.description}: {source_volume(self.name)}",
|
||||
classes="text-gray-600 text-center -mt-2", a=self)
|
||||
else:
|
||||
self.volume = Div(text=f"{self.description}: {volume(self.name)}",
|
||||
classes="text-gray-600 text-center -mt-2", a=self)
|
||||
|
||||
self.classes += " border-0 ml-2 mr-2"
|
||||
bdec.classes += " ml-2 mr-1"
|
||||
binc.classes += " ml-1 mr-1"
|
||||
self.bmute.classes += " ml-1 mr-2"
|
||||
|
||||
async def decrease(self, msg):
|
||||
if self.btype == 'mic':
|
||||
self.volume.text = f'{self.description}: {source_volume_decrease(self.name)}'
|
||||
else:
|
||||
self.volume.text = f'{self.description}: {volume_decrease(self.name)}'
|
||||
|
||||
async def increase(self, msg):
|
||||
if self.btype == 'mic':
|
||||
self.volume.text = f'{self.description}: {source_volume_increase(self.name)}'
|
||||
else:
|
||||
self.volume.text = f'{self.description}: {volume_increase(self.name)}'
|
||||
|
||||
async def mute(self, msg):
|
||||
if self.btype == 'mic':
|
||||
self.volume.text = f'{self.description}: {source_volume_mute(self.name)}'
|
||||
self.bmute.state = f'{source_volume(self.name)}'
|
||||
else:
|
||||
self.volume.text = f'{self.description}: {volume_mute(self.name)}'
|
||||
self.bmute.state = f'{volume(self.name)}'
|
||||
if self.bmute.state == 'muted':
|
||||
if self.bmute.image_alt_element:
|
||||
self.bmute.components[0] = self.bmute.image_alt_element
|
||||
elif self.mute_icon_alt:
|
||||
self.bmute.inner_html = f"<i class='fa-2x {self.bmute.icon_alt}'><i>"
|
||||
else:
|
||||
self.bmute.text = 'unmute'
|
||||
else:
|
||||
if self.bmute.image_element:
|
||||
self.bmute.components[0] = self.bmute.image_element
|
||||
elif self.mute_icon:
|
||||
if self.mute_icon:
|
||||
self.bmute.inner_html = f"<i class='fa-2x {self.bmute.icon}'><i>"
|
||||
else:
|
||||
self.bmute.text = 'mute'
|
||||
|
||||
async def reload(self, msg):
|
||||
await msg.page.reload()
|
||||
|
||||
async def reload_all_instances(self, msg):
|
||||
"Reload all browser tabs that the page is rendered on"
|
||||
for page in WebPage.instances.values():
|
||||
if page.page_type == 'main':
|
||||
await page.reload()
|
||||
|
||||
async def kill_gui(self, msg):
|
||||
if 'pid' in msg.page.request.query_params:
|
||||
pid = msg.page.request.query_params.get('pid')
|
||||
await process(f"kill {pid}")
|
||||
else:
|
||||
await process("pkill controldeck-gui")
|
||||
|
||||
def ishexcolor(code):
|
||||
return bool(search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', code))
|
||||
|
||||
@SetRoute('/')
|
||||
def application(request):
|
||||
|
||||
wp = WebPage(title=APP_NAME, body_classes="bg-gray-900")
|
||||
wp.page_type = 'main'
|
||||
wp.head_html = '<meta name="viewport" content="width=device-width, initial-scale=1">'
|
||||
# can be accessed via msg.page.request
|
||||
wp.request = request
|
||||
|
||||
menu = Div(classes="fixed bottom-0 right-0 p-1 grid grid-col-1 select-none text-gray-500", a=wp)
|
||||
I(classes="w-10 h-10 w-1 fa-2x fa-fw fas fa-redo-alt", click=reload, a=menu)
|
||||
if "gui" in request.query_params:
|
||||
I(classes="w-10 h-10 w-1 fa-2x fa-fw fas fa-window-close", click=kill_gui, a=menu)
|
||||
|
||||
config = config_load()
|
||||
volume_dict = {}
|
||||
button_dict = {}
|
||||
for i in config.sections():
|
||||
iname = None
|
||||
iname = search("^([0-9]*.?)volume", i, flags=IGNORECASE)
|
||||
# volume buttons
|
||||
iname = search("^([0-9]*.?)(volume|mic)", i, flags=IGNORECASE)
|
||||
if iname is not None:
|
||||
id = iname.group(1)[:-1] # remove dot
|
||||
if iname.group(2) == 'mic':
|
||||
tmp = [{'type': iname.group(2), 'description': i[iname.end(0)+1:],
|
||||
'color-bg': config.get(i, 'color-bg', fallback=''),
|
||||
'color-fg': config.get(i, 'color-fg', fallback=''),
|
||||
'name': config.get(i, 'name', fallback=None),
|
||||
'decrease-icon': config.get('default', 'mic-decrease-icon', fallback=''),
|
||||
'decrease-image': config.get('default', 'mic-decrease-image', fallback=''),
|
||||
'increase-icon': config.get('default', 'mic-increase-icon', fallback=''),
|
||||
'increase-image': config.get('default', 'mic-increase-image', fallback=''),
|
||||
'mute-icon': config.get('default', 'mic-mute-icon', fallback=''),
|
||||
'mute-icon-alt': config.get('default', 'mic-mute-icon-alt', fallback=''),
|
||||
'mute-image': config.get('default', 'mic-mute-image', fallback=''),
|
||||
'mute-image-alt': config.get('default', 'mic-mute-image-alt', fallback='')}]
|
||||
else:
|
||||
tmp = [{'type': iname.group(2), 'description': i[iname.end(0)+1:],
|
||||
'color-bg': config.get(i, 'color-bg', fallback=''),
|
||||
'color-fg': config.get(i, 'color-fg', fallback=''),
|
||||
'name': config.get(i, 'name', fallback=None),
|
||||
'decrease-icon': config.get('default', 'volume-decrease-icon', fallback=''),
|
||||
'decrease-image': config.get('default', 'volume-decrease-image', fallback=''),
|
||||
'increase-icon': config.get('default', 'volume-increase-icon', fallback=''),
|
||||
'increase-image': config.get('default', 'volume-increase-image', fallback=''),
|
||||
'mute-icon': config.get('default', 'volume-mute-icon', fallback=''),
|
||||
'mute-icon-alt': config.get('default', 'volume-mute-icon-alt', fallback=''),
|
||||
'mute-image': config.get('default', 'volume-mute-image', fallback=''),
|
||||
'mute-image-alt': config.get('default', 'volume-mute-image-alt', fallback='')}]
|
||||
try:
|
||||
volume_dict[id] += [{'description': i[iname.end(0)+1:],
|
||||
'name': config.get(i, 'name', fallback=None)}]
|
||||
volume_dict[id] += tmp
|
||||
except KeyError:
|
||||
volume_dict[id] = [{'description': i[iname.end(0)+1:],
|
||||
'name': config.get(i, 'name', fallback=None)}]
|
||||
iname = search("^([0-9]*.?)button", i, flags=IGNORECASE)
|
||||
volume_dict[id] = tmp
|
||||
# button or empty
|
||||
iname = search("^([0-9]*.?)(button|empty)", i, flags=IGNORECASE)
|
||||
if iname is not None:
|
||||
id = iname.group(1)[:-1] # remove dot
|
||||
tmp = [{'type': iname.group(2), 'text': i[iname.end(0)+1:],
|
||||
'text-alt': config.get(i, 'text-alt', fallback=''),
|
||||
'color-bg': config.get(i, 'color-bg', fallback=''),
|
||||
'color-fg': config.get(i, 'color-fg', fallback=''),
|
||||
'command': config.get(i, 'command', fallback=''),
|
||||
'command-alt': config.get(i, 'command-alt', fallback=''),
|
||||
'state': config.get(i, 'state', fallback=''),
|
||||
'state-alt': config.get(i, 'state-alt', fallback=''),
|
||||
'state-command': config.get(i, 'state-command', fallback=''),
|
||||
'state-command-alt': config.get(i, 'state-command-alt', fallback=''),
|
||||
'icon': config.get(i, 'icon', fallback=''),
|
||||
'icon-alt': config.get(i, 'icon-alt', fallback=''),
|
||||
'image': config.get(i, 'image', fallback=''),
|
||||
'image-alt': config.get(i, 'image-alt', fallback='')}]
|
||||
try:
|
||||
button_dict[id] += [{'text': i[iname.end(0)+1:],
|
||||
'command': config.get(i, 'command', fallback=None)}]
|
||||
button_dict[id] += tmp
|
||||
except KeyError:
|
||||
button_dict[id] = [{'text': i[iname.end(0)+1:],
|
||||
'command': config.get(i, 'command', fallback=None)}]
|
||||
button_dict[id] = tmp
|
||||
var_prefix = "_div"
|
||||
for i in volume_dict:
|
||||
var = var_prefix+i
|
||||
for j in volume_dict[i]:
|
||||
if 'div'+i not in vars():
|
||||
vars()['div'+i] = Div(classes="flex flex-wrap", a=wp)
|
||||
ButtonSound(name=j['name'], description=j['description'], a=eval('div'+i))
|
||||
if var not in vars():
|
||||
vars()[var] = Div(classes="flex flex-wrap", a=wp)
|
||||
color_bg = f"background-color:{j['color-bg']};" if ishexcolor(j['color-bg']) else ''
|
||||
color_fg = f"color:{j['color-fg']};" if ishexcolor(j['color-fg']) else ''
|
||||
ButtonsSound(name=j['name'], description=j['description'], btype=j['type'],
|
||||
color_bg=j['color-bg'], color_fg=j['color-fg'],
|
||||
decrease_icon=j['decrease-icon'], decrease_image=j['decrease-image'],
|
||||
increase_icon=j['increase-icon'], increase_image=j['increase-image'],
|
||||
mute_icon=j['mute-icon'], mute_image=j['mute-image'],
|
||||
mute_icon_alt=j['mute-icon-alt'], mute_image_alt=j['mute-image-alt'],
|
||||
a=eval(var))
|
||||
for i in button_dict:
|
||||
var = var_prefix+i
|
||||
for j in button_dict[i]:
|
||||
if 'div'+i not in vars():
|
||||
vars()['div'+i] = Div(classes="flex flex-wrap", a=wp)
|
||||
Button(text=j['text'], command=j['command'], a=eval('div'+i))
|
||||
if var not in vars():
|
||||
vars()[var] = Div(classes="flex flex-wrap", a=wp)
|
||||
Button(text=j['text'], text_alt=j['text-alt'], btype=j['type'],
|
||||
command=j['command'], command_alt=j['command-alt'],
|
||||
color_bg=j['color-bg'], color_fg=j['color-fg'],
|
||||
state_pattern=j['state'], state_pattern_alt=j['state-alt'],
|
||||
state_command=j['state-command'],
|
||||
state_command_alt=j['state-command-alt'],
|
||||
icon=j['icon'], icon_alt=j['icon-alt'],
|
||||
image=j['image'], image_alt=j['image-alt'], a=eval(var))
|
||||
|
||||
# fit text of button
|
||||
javascript_string = """
|
||||
function fitin() {
|
||||
var fitindiv = document.querySelectorAll(".fitin");
|
||||
var i;
|
||||
for (i = 0; i < fitindiv.length; i++) {
|
||||
// inner div needed to see if inner div is larger in height to reduce the font-size
|
||||
fitindivdiv = fitindiv[i].firstElementChild;
|
||||
while( fitindivdiv.clientHeight > fitindiv[i].clientHeight ) {
|
||||
fitindivdiv.style.fontSize = (parseInt(window.getComputedStyle(fitindivdiv).fontSize) - 1) + "px";
|
||||
}
|
||||
}
|
||||
};
|
||||
fitin();
|
||||
"""
|
||||
async def page_ready(self, msg):
|
||||
run_task(self.run_javascript(javascript_string, request_id='fitin_logic', send=False))
|
||||
wp.on('page_ready', page_ready)
|
||||
|
||||
if not wp.components:
|
||||
# config not found or empty, therefore insert an empty div to not get an error
|
||||
Div(text="add elements in controldeck.conf", classes="flex flex-wrap", a=wp)
|
||||
|
||||
status = config.get('default', 'status', fallback='False').title() == 'True'
|
||||
if status:
|
||||
wp.add(STATUS_DIV)
|
||||
|
||||
return wp
|
||||
|
||||
def main():
|
||||
|
||||
129
controldeck_gui.py
Executable file
129
controldeck_gui.py
Executable file
@@ -0,0 +1,129 @@
|
||||
#!/usr/bin/env python
|
||||
import sys
|
||||
import os
|
||||
import argparse
|
||||
from tkinter import Tk, messagebox
|
||||
from webview import create_window, start
|
||||
from controldeck import config_load, process
|
||||
import threading
|
||||
import time
|
||||
|
||||
def thread_function(name):
|
||||
# print("Thread %s: starting", name)
|
||||
for i in range(10):
|
||||
# print("Thread %s: finishing", name)
|
||||
# p = process("xdotool search --name 'ControlDeck'")
|
||||
# intersection of ControlDeck window name and empty classname
|
||||
p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)")
|
||||
if p:
|
||||
# print(p)
|
||||
# process("xdotool search --name 'ControlDeck' set_window --class 'controldeck'", output=False)
|
||||
# process("xdotool search --name 'ControlDeck' set_window --classname 'controldeck' --class 'ControlDeck' windowunmap windowmap", output=False) # will find to many wrong ids
|
||||
process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", output=False)
|
||||
time.sleep(0.1)
|
||||
|
||||
def main(args, pid=-1):
|
||||
#controldeck_process = process("ps --no-headers -C controldeck")
|
||||
controldeck_process = process("ps --no-headers -C controldeck || ps aux | grep -e 'python.*controldeck.py' | grep -v grep")
|
||||
|
||||
if args.start and controldeck_process == "":
|
||||
process("controldeck &", output=False)
|
||||
|
||||
elif controldeck_process == "":
|
||||
# cli output
|
||||
print("controldeck is not running!")
|
||||
|
||||
# gui output
|
||||
# Tkinter must have a root window. If you don't create one, one will be created for you. If you don't want this root window, create it and then hide it:
|
||||
root = Tk()
|
||||
root.withdraw()
|
||||
messagebox.showinfo("ControlDeck", "controldeck is not running!")
|
||||
# Other option would be to use the root window to display the information (Label, Button)
|
||||
|
||||
sys.exit(2)
|
||||
|
||||
config = config_load(conf=args.config)
|
||||
url = config.get('gui', 'url', fallback='http://0.0.0.0:8000') + "/?gui&pid=" + str(pid)
|
||||
try:
|
||||
width = int(config.get('gui', 'width', fallback=800))
|
||||
except ValueError as e:
|
||||
print(f"{e}")
|
||||
width = 800
|
||||
try:
|
||||
height = int(config.get('gui', 'height', fallback=600))
|
||||
except ValueError as e:
|
||||
print(f"{e}")
|
||||
width = 600
|
||||
try:
|
||||
x = int(config.get('gui', 'x', fallback=''))
|
||||
except ValueError as e:
|
||||
print(f"{e}")
|
||||
x = None
|
||||
try:
|
||||
y = int(config.get('gui', 'y', fallback=''))
|
||||
except ValueError as e:
|
||||
print(f"{e}")
|
||||
y = None
|
||||
resizable = config.get('gui', 'resizable', fallback='True').title() == 'True'
|
||||
fullscreen = config.get('gui', 'fullscreen', fallback='False').title() == 'True'
|
||||
try:
|
||||
min_width = int(config.get('gui', 'min_width', fallback=200))
|
||||
except ValueError as e:
|
||||
print(f"{e}")
|
||||
min_width = 200
|
||||
try:
|
||||
min_height = int(config.get('gui', 'min_height', fallback=100))
|
||||
except ValueError as e:
|
||||
print(f"{e}")
|
||||
min_width = 100
|
||||
min_size = (min_width, min_height)
|
||||
frameless = config.get('gui', 'frameless', fallback='False').title() == 'True'
|
||||
minimized = config.get('gui', 'minimized', fallback='False').title() == 'True'
|
||||
on_top = config.get('gui', 'always_on_top', fallback='False').title() == 'True'
|
||||
|
||||
create_window("ControlDeck",
|
||||
url=url,
|
||||
html=None,
|
||||
js_api=None,
|
||||
width=width,
|
||||
height=height,
|
||||
x=x,
|
||||
y=y,
|
||||
resizable=resizable,
|
||||
fullscreen=fullscreen,
|
||||
min_size=min_size,
|
||||
hidden=False,
|
||||
frameless=frameless,
|
||||
easy_drag=True,
|
||||
minimized=minimized,
|
||||
on_top=on_top,
|
||||
confirm_close=False,
|
||||
background_color='#000000',
|
||||
transparent=True,
|
||||
text_select=False)
|
||||
x = threading.Thread(target=thread_function, args=(1,))
|
||||
x.start()
|
||||
start()
|
||||
|
||||
def cli():
|
||||
parser = argparse.ArgumentParser(
|
||||
description=__doc__, prefix_chars='-',
|
||||
formatter_class=argparse.RawTextHelpFormatter,
|
||||
)
|
||||
parser.add_argument('-c', '--config', nargs='?', type=str, default='',
|
||||
help="Specify a path to a custom config file (default: ~/.config/controldeck/controldeck.conf)")
|
||||
parser.add_argument('-s', '--start', action="store_true",
|
||||
help="Start also controldeck program")
|
||||
parser.add_argument('-v', '--verbose', action="store_true", help="Verbose output")
|
||||
parser.add_argument('-D', '--debug', action='store_true', help=argparse.SUPPRESS)
|
||||
args = parser.parse_args()
|
||||
|
||||
if args.debug:
|
||||
print(args)
|
||||
|
||||
main(args, pid=os.getpid())
|
||||
|
||||
return 0
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(cli())
|
||||
BIN
data/controldeck-48.png
Normal file
BIN
data/controldeck-48.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 14 KiB |
8
data/controldeck.desktop
Normal file
8
data/controldeck.desktop
Normal file
@@ -0,0 +1,8 @@
|
||||
[Desktop Entry]
|
||||
Name=ControlDeck
|
||||
Exec=/usr/bin/controldeck-gui
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
StartupWMClass=controldeck
|
||||
Icon=controldeck
|
||||
7
data/controldeck.desktop.local
Normal file
7
data/controldeck.desktop.local
Normal file
@@ -0,0 +1,7 @@
|
||||
[Desktop Entry]
|
||||
Name=ControlDeck
|
||||
Exec=${HOME}/.local/bin/controldeck-gui
|
||||
Terminal=false
|
||||
Type=Application
|
||||
StartupNotify=true
|
||||
StartupWMClass=controldeck
|
||||
16
data/controldeck.service
Normal file
16
data/controldeck.service
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=ControlDeck
|
||||
ConditionFileIsExecutable=/usr/bin/controldeck
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
TimeoutStartSec=30
|
||||
ExecStartPre=/bin/sh -c 'source /etc/profile'
|
||||
ExecStart=/usr/bin/controldeck
|
||||
Restart=on-failure
|
||||
RestartSec=4
|
||||
StandardOutput=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
16
data/controldeck.service.local
Normal file
16
data/controldeck.service.local
Normal file
@@ -0,0 +1,16 @@
|
||||
[Unit]
|
||||
Description=ControlDeck
|
||||
ConditionFileIsExecutable=%h/.local/bin/controldeck
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
Environment=PYTHONUNBUFFERED=1
|
||||
TimeoutStartSec=30
|
||||
ExecStartPre=/bin/sh -c 'source /etc/profile'
|
||||
ExecStart=%h/.local/bin/controldeck
|
||||
Restart=on-failure
|
||||
RestartSec=4
|
||||
StandardOutput=journal
|
||||
|
||||
[Install]
|
||||
WantedBy=default.target
|
||||
@@ -2,19 +2,85 @@
|
||||
#
|
||||
# [N.volume.NAME]
|
||||
# name = sink_name
|
||||
# : N. optional group/row specification
|
||||
# : NAME name of the button
|
||||
# : name sink name, see name with either:
|
||||
# pactl list sinks short
|
||||
# pamixer --list-sinks
|
||||
# color-fg = hex color code
|
||||
# color-bg = hex color code
|
||||
#
|
||||
# : N. optional number to specify group/row
|
||||
# : NAME id, name of the button
|
||||
# : name sink name, see name with either:
|
||||
# pactl list sinks short
|
||||
# pamixer --list-sinks
|
||||
# : color-bg background color
|
||||
# : color-bg forground color
|
||||
#
|
||||
# [N.button.NAME]
|
||||
# text-alt = name
|
||||
# color-fg = hex color code
|
||||
# color-bg = hex color code
|
||||
# command = shell command
|
||||
# second command
|
||||
# ...
|
||||
# : N. optional group/row specification
|
||||
# : NAME name of the button
|
||||
# : command command(s) to run
|
||||
# command-alt = shell command ...
|
||||
# state = normal state
|
||||
# state-command = shell command ...
|
||||
# icon = Font Awesome
|
||||
# icon-alt = Font Awesom
|
||||
# image = path to svg file
|
||||
# image-alt = path to svg file
|
||||
#
|
||||
# : N. optional group/row specification
|
||||
# : NAME id, name of the button
|
||||
# : text-alt optional alternative button text
|
||||
# : color-bg background color
|
||||
# : color-bg forground color
|
||||
# : command command(s) to run
|
||||
# : command-alt optional back-switch command(s) to run
|
||||
# : state string to define the normal state
|
||||
# : state-command command to get the state
|
||||
# : icon use icon instead of NAME (Font Awesome), e.g.: fas fa-play
|
||||
# : icon-alt optional alternative icon
|
||||
# : image absolute path to svg file
|
||||
# : image-alt optional alternative image
|
||||
#
|
||||
# [N.empty.NAME]
|
||||
#
|
||||
# : N. optional number to specify group/row
|
||||
# : NAME id, name of the button
|
||||
|
||||
[default]
|
||||
# status = False
|
||||
# volume-decrease-icon = fas fa-volume-down
|
||||
# volume-increase-icon = fas fa-volume-up
|
||||
# volume-mute-icon = fas fa-volume-off
|
||||
# volume-mute-icon-alt = fas fa-volume-mute
|
||||
# volume-mute-icon-alt =
|
||||
# volume-decrease-image =
|
||||
# volume-increase-image =
|
||||
# volume-mute-image =
|
||||
# volume-mute-image-alt =
|
||||
# mic-decrease-icon = fas fa-volume-down
|
||||
# mic-increase-icon = fas fa-volume-up
|
||||
# mic-mute-icon = fas fa-microphone-alt
|
||||
# mic-mute-icon-alt = fas fa-microphone-alt-slash
|
||||
# mic-decrease-image =
|
||||
# mic-increase-image =
|
||||
# mic-mute-image =
|
||||
# mic-mute-image-alt =
|
||||
|
||||
[gui]
|
||||
url = http://0.0.0.0:8000
|
||||
width = 800
|
||||
height = 600
|
||||
# x and y specifying the window coordinate (empty = centered)
|
||||
x =
|
||||
y =
|
||||
resizable = True
|
||||
fullscreen = False
|
||||
min_width = 200
|
||||
min_height = 100
|
||||
frameless = False
|
||||
minimized = False
|
||||
always_on_top = False
|
||||
|
||||
[4.button.Test]
|
||||
command = notify-send -a foo baz
|
||||
|
||||
17
setup.cfg
Normal file
17
setup.cfg
Normal file
@@ -0,0 +1,17 @@
|
||||
[metadata]
|
||||
name = ControlDeck
|
||||
version = file: VERSION
|
||||
|
||||
[options]
|
||||
install_requires =
|
||||
justpy
|
||||
pywebview
|
||||
cairosvg
|
||||
py_modules =
|
||||
controldeck
|
||||
controldeck_gui
|
||||
|
||||
[options.entry_points]
|
||||
console_scripts =
|
||||
controldeck = controldeck:main
|
||||
controldeck-gui = controldeck_gui:cli
|
||||
8
setup.py
8
setup.py
@@ -1,8 +1,2 @@
|
||||
from setuptools import setup
|
||||
|
||||
setup(
|
||||
name='ControlDeck',
|
||||
py_modules=['controldeck'],
|
||||
entry_points={
|
||||
'console_scripts': ['controldeck = controldeck:main', ],},
|
||||
)
|
||||
setup()
|
||||
|
||||
10
setup.sh
Executable file
10
setup.sh
Executable file
@@ -0,0 +1,10 @@
|
||||
#!/bin/sh
|
||||
pip install --user -e .
|
||||
|
||||
mkdir -p $HOME/.local/share/applications
|
||||
#ln -sf $PWD/data/controldeck.desktop $HOME/.local/share/applications/controldeck.desktop
|
||||
cp data/controldeck.desktop.local $HOME/.local/share/applications/controldeck.desktop
|
||||
sed -i "s|\${HOME}|${HOME}|" ~/.local/share/applications/controldeck.desktop
|
||||
mkdir -p $HOME/.config/systemd/user
|
||||
ln -sf $PWD/data/controldeck.service.local $HOME/.config/systemd/user/controldeck.service
|
||||
#cp data/controldeck.service $HOME/.config/systemd/user/controldeck.service
|
||||
Reference in New Issue
Block a user