Compare commits

...

41 Commits

Author SHA1 Message Date
bc7a5d7239 fix to fit text into button using js and fix classname setting to not find wrong ids and update README 2021-09-07 23:25:52 +02:00
596e188c75 fix to also change icon in window titlebar 2021-08-26 22:37:11 +02:00
9e0ecced8b add windowclassname 2021-08-26 22:00:02 +02:00
a21babbf8d rename .desktop file to indicate local setting and create a new file for a system installation 2021-08-26 00:36:22 +02:00
7b205c761b rename systemd service file to indicate local setting and create a new file for a system installation 2021-08-25 22:43:59 +02:00
528dccb255 add version file and refer to it for the setup 2021-08-24 23:54:09 +02:00
00cdcd0cc9 multi-user.taget not working for user service. Autostart will not work. 2021-08-08 13:48:54 +02:00
c78b767958 update style (border) and logic of buttons 2021-07-24 20:24:35 +02:00
cd13c8d021 update button switch logic 2021-07-24 19:52:31 +02:00
78e27a8460 add example for the optinal status field 2021-07-22 15:22:45 +02:00
fba0e91224 create message box if controldeck process is not running 2021-07-19 14:22:20 +02:00
e83c347442 improve status output for command 2021-07-19 14:10:29 +02:00
87a2e4542e change group style of sound buttons 2021-07-14 21:49:43 +02:00
d9bdd5c9e8 add an optional status field to display command and state of a button 2021-07-14 21:05:37 +02:00
65c37e636c add cairosvg fallback 2021-04-20 01:09:55 +02:00
fec87df9c9 add pid and start flag for gui, add systemd service file and .desktop file with new setup.sh 2021-04-19 21:18:25 +02:00
96829d0b6a import only used funtions 2021-04-15 11:01:16 +02:00
b8f26d6965 add cli with optional config path 2021-04-13 13:34:57 +02:00
7342721715 add config parameter for the gui url 2021-04-13 12:47:52 +02:00
ad79872631 add microphone / source buttons 2021-04-09 00:48:15 +02:00
2f257a1ee7 fix button command execution without altative command 2021-04-08 14:42:49 +02:00
25834e99da normal buttons with alternative text, icon or image 2021-04-07 21:22:18 +02:00
b31e12d148 volume buttons with alternative text, icon or image 2021-04-07 18:17:16 +02:00
b09e1d680e icon and image for volume buttons 2021-04-07 15:18:32 +02:00
e5ddc47587 remove old commented lines 2021-04-07 14:17:06 +02:00
7581078bba change volume button style, volume status with mute and toggle button and move button logic to button class 2021-04-07 14:12:50 +02:00
bbc8d22abb fix wrong catch for unset icon 2021-04-06 15:37:29 +02:00
d82503b4ff make it possible to use home alias ~ for icon-image 2021-04-05 22:26:15 +02:00
67ff81900d add icon-image for svg icon buttons 2021-04-05 22:19:20 +02:00
3447f5b545 add example for empty buttons 2021-04-05 18:47:51 +02:00
1b3d915de6 add empty buttons 2021-04-05 18:46:08 +02:00
bd738d600e add color-bg and color-fg in conf file for volume and normal buttons 2021-04-05 17:14:10 +02:00
cfed2fb677 add flag to process function to not ask for output, add reload button and close button only for gui, change icon config to be able to specify style 2021-04-05 15:53:53 +02:00
8d7c2523f4 add possibility to use icons instead 2021-04-04 23:32:00 +02:00
f108910b87 fix process recognizer 2021-04-04 22:07:53 +02:00
e563470b64 put config block in a function, add config for gui and update example conf 2021-04-04 21:46:50 +02:00
ea1b8f558e add webview gui, define text font, remove unused import 2021-04-04 20:31:19 +02:00
8c4a6a495c fix 'sink not found' for pamixer 2021-04-04 12:36:35 +02:00
c54817edc2 fix pactl fallback for increase and decrease 2021-04-04 00:18:52 +02:00
4ceaad99a3 fix pactl fallback for pamixer 2021-04-04 00:05:16 +02:00
431fa906b1 add setup.cfg 2021-04-03 23:41:09 +02:00
13 changed files with 802 additions and 103 deletions

17
README
View File

@@ -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

1
VERSION Normal file
View File

@@ -0,0 +1 @@
2021.08.24

594
controldeck.py Normal file → Executable file
View 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
View 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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

8
data/controldeck.desktop Normal file
View File

@@ -0,0 +1,8 @@
[Desktop Entry]
Name=ControlDeck
Exec=/usr/bin/controldeck-gui
Terminal=false
Type=Application
StartupNotify=true
StartupWMClass=controldeck
Icon=controldeck

View 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
View 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

View 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

View File

@@ -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
View 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

View File

@@ -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
View 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