diff --git a/README b/README
index 123c5e3..0b14b79 100644
--- a/README
+++ b/README
@@ -1,6 +1,7 @@
Install
Requirements:
+ - python package justpy, the framework: pip install justpy --upgrade
- (optionally) for volume buttons: libpulse
local:
diff --git a/VERSION b/VERSION
index fb37ebd..c5536f0 100644
--- a/VERSION
+++ b/VERSION
@@ -1 +1 @@
-2021.08.24
\ No newline at end of file
+2022.10.01
\ No newline at end of file
diff --git a/controldeck.py b/controldeck.py
index 238fc13..dd1429c 100755
--- a/controldeck.py
+++ b/controldeck.py
@@ -1,142 +1,97 @@
#!/usr/bin/env python
"""
-HTML style powered by Tailwind CSS
+HTML style powered by Quasar
NOTE: currently buttons only updated on page reload
-(buttons will be recreated)
"""
import sys
-from os import path, sep, makedirs
+from os import getcwd, path, sep, makedirs
+import shutil
+import shlex
from subprocess import Popen, PIPE, STDOUT
from configparser import ConfigParser
-from re import search, IGNORECASE
-from justpy import Div, I, WebPage, SetRoute, parse_html, run_task, justpy
-from cairosvg import svg2svg
+import re
+import json
+import time
+import argparse
+from addict import Dict # also used in justpy
+from justpy import (
+ Div,
+ I,
+ P,
+ QuasarPage,
+ QBadge,
+ QBar,
+ QBtn,
+ QBtnGroup,
+ QBtnToggle,
+ QCard,
+ QCardSection,
+ QDialog,
+ QEditor,
+ QHeader,
+ QIcon,
+ QInput,
+ QItem,
+ QItemLabel,
+ QItemSection,
+ QLayout,
+ QNotify,
+ QPage,
+ QPageContainer,
+ QSeparator,
+ QSlider,
+ QSpace,
+ QTab,
+ QTabs,
+ QTabPanel,
+ QTabPanels,
+ QToolbar,
+ QTooltip,
+ SetRoute,
+ ToggleDarkModeBtn,
+ WebPage,
+ parse_html,
+ run_task,
+ justpy
+)
APP_NAME = "ControlDeck"
+COLOR_PRIME = "blue-grey-8" # "blue-grey-7" "blue-grey-8" 'light-blue-9'
+COLOR_PRIME_TEXT = "blue-grey-7"
+COLOR_SELECT = "light-blue-9"
-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;"
+CONFIG_FILE = '~/.config/controldeck/controldeck.conf'
+CONFIG_FILE_FULL = path.expanduser(CONFIG_FILE)
def tohtml(text):
return text.replace("\n", " ")
-def mouseenter_status(self, msg):
- """Div style see STATUS_DIV.classes"""
- STATUS_DIV.inner_html = "
"
- STATUS_DIV.inner_html += "
command
"
- STATUS_DIV.inner_html += f"
{tohtml(self.command.strip())}
"
- STATUS_DIV.inner_html += "
command-alt
"
- STATUS_DIV.inner_html += f"
{tohtml(self.command_alt.strip())}
"
- STATUS_DIV.inner_html += "
state
"
- STATUS_DIV.inner_html += f"
{tohtml(self.state.strip())}
"
- STATUS_DIV.inner_html += "
state-pattern
"
- STATUS_DIV.inner_html += f"
{tohtml(self.state_pattern.strip())}
"
- STATUS_DIV.inner_html += "
state-pattern-alt
"
- STATUS_DIV.inner_html += f"
{tohtml(self.state_pattern_alt.strip())}
"
- STATUS_DIV.inner_html += "
switched
"
- STATUS_DIV.inner_html += f"
{str(self.switched)}
"
- STATUS_DIV.inner_html += "
"
-
-def process(args, output=True):
+def process(command_line, shell=False, output=True, stdout=PIPE, stderr=STDOUT):
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)
+ # reading the output blocks also the process -> for buttons use output=False
+ # maybe https://stackoverflow.com/questions/375427/a-non-blocking-read-on-a-subprocess-pipe-in-python
+ # print(command_line)
+ if shell:
+ # shell mode for 'easy' program strings with pipes
+ # e.g. DISPLAY=:0 wmctrl -xl | grep emacs.Emacs && DISPLAY=:0 wmctrl -xa emacs.Emacs || DISPLAY=:0 emacs &
+ args = command_line
+ else:
+ args = shlex.split(command_line)
+ # print(args)
+ result = Popen(args, stdout=stdout, stderr=stderr, shell=shell, start_new_session=True)
if output:
- return result.stdout.read().decode("utf-8").rstrip()
+ res = result.stdout.read().decode("utf-8").rstrip()
+ result.kill() # does not help to unblock
+ # print(res)
+ return res
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):
- 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):
- 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):
- 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
-
-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
-
-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 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
-
-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
-
-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)
if conf:
@@ -155,39 +110,7 @@ def config_load(conf=''):
#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):
+class Button(QBtn):
"""
usage
Button(text, text_alt, btype, command, command_alt,
@@ -196,221 +119,214 @@ class Button(Div):
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""
- 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""
- elif self.text_alt:
- self.text = self.text_alt
-
def __init__(self, **kwargs):
+ self.stack = True
+ # self.outline = True # is set via style, see below
+ # self.color = "blue-grey-8" # is overwritten via text_color
+ self.text_color = COLOR_PRIME_TEXT
+ self.size = 'md'
+ self.push = True
+ # self.padding = 'none' # not working, also not inside init
+ self.dense = True
+
+ # default **kwargs
+ self.btype = None # button or empty
+ self.image = '' # used for files like svg and png
+ self.command = '' # command to run on click
+ self.state = '' # output of the state check command
+ self.state_command = '' # command to check the unclicked state
super().__init__(**kwargs)
- self.text_normal = str(self.text)
- self.on('mouseenter', mouseenter_status)
+ self.style = "width: 90px;"
+ self.style += "min-height: 70px;"
+
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 = ''
-
+ self.classes = 'invisible' # invisible; not shown but still stakes up space
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 += "border: 1px solid #455a64;" # #455a64 blue-grey-8
- 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 ''
+ self.style += "width: 90px;"
+ self.style += "min-height: 77px;" # image + 2 text lines
+ self.style += "line-height: 1em;"
+ if self.image and path.exists(self.image):
+ static_dir = getcwd() + '/static'
+ shutil.copy2(self.image, static_dir)
+ basename = path.basename(self.image)
+ self.icon = f"img:/static/{basename}"
+ #
+ #
if self.command != '':
+ QTooltip(a=self, text=self.command.strip(), delay=500) # setting style white-space:pre does not work, see wp.css below
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()
+ if self.command != '':
+ process(self.command, shell=True, output=False) # output=True freezes controldeck until process finished (until e.g. an emacs button is closed)
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""
- else:
- self.inner_html = f""
- 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"
{self.text_alt}
"
- 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"
{self.text_normal}
"
- self.update_state()
+ def update_state(self):
+ if self.state_command != '':
+ self.state = process(self.state_command, shell=True)
-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
+class Volume(Div):
+ # class variables
+ data = {} # pulseaudio info for all sinks
+ icon_muted = 'volume_mute' # default icon for muted state, 'volume_off' better for disabled or not found?
+ icon_unmuted = 'volume_up' # default icon for unmuted state
+ last_update = 0 # used for updates. init set to zero so the 1st diff is large to go into update at startup
def __init__(self, **kwargs):
+ # instance vars
+ self.slider = None # for handle methods to access slider
+
+ # default **kwargs
+ self.vtype = 'sink' # sink (loudspeaker) or sink-input (app output)
+ self.name = '' # pulseaudio sink name
+ self.description = '' # badge name
+
super().__init__(**kwargs)
- self.classes = "grid-rows-2"
- self.div = Div(classes="flex")
+ self.style = "width:286px;" # three buttons and the two spaces
+ self.update_state() # get self.pa_state
- 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.pa_state:
+ if self.vtype == 'sink':
+ cmdl_toggle = 'pactl set-sink-mute {name} toggle'
+ cmdl_value = 'pactl set-sink-volume {name} {value}%'
+ elif self.vtype == 'sink-input':
+ cmdl_toggle = 'pactl set-sink-input-mute {name} toggle'
+ cmdl_value = 'pactl set-sink-input-volume {name} {value}%'
+ self.description = \
+ self.pa_state['properties']['application.process.binary'] +\
+ ': ' +\
+ self.pa_state['properties']['media.name']
+ # local vars
+ badge_name = self.description if self.description else self.name
+ volume_level = 0
+ if self.pa_state: # might be empty {} if it is not found
+ # pulseaudio 2^16 (65536) volume levels
+ volume_level = float(self.pa_state['volume']['front-left']['value_percent'][:-1]) # remove the % sign
- 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)
+ badge = QBadge(
+ text=badge_name,
+ outline=True,
+ color=COLOR_PRIME_TEXT,
+ style="max-width:286px;", # 3*90 + 2*8
+ classes="ellipsis",
+ a=self,
+ )
+ tt = QTooltip(
+ a=badge, text=badge_name, delay=500, anchor='center left',
+ offset=[0, 14], transition_show='jump-right', transition_hide='jump-left')
+ tt.self='center left'
+ item = QItem(
+ a=self,
+ dense=True, # dense: less spacing: vertical little higher
+ )
+ item_section = QItemSection(
+ side=True, # side=True unstreched btn,
+ #avatar=True, # more spacing than side
+ a=item,
+ )
+ # QIcon(name="volume_up", a=item_section)
+ def handle_btn(widget_self, msg):
+ # not checking the current state
+ process(cmdl_toggle.format(name=self.name), shell=True, output=False)
+ if widget_self.icon == self.icon_unmuted: # switch to mute
+ widget_self.icon = self.icon_muted
+ self.slider.style = "opacity: 0.6 !important;"
+ elif widget_self.icon == self.icon_muted: # switch to unmute
+ widget_self.icon = self.icon_unmuted
+ self.slider.style = "opacity: unset !important;"
+ # toggle disable state
+ # this wont allow to change the volume in a mute state
+ # self.slider.disable = not self.slider.disable
+ QBtn(
+ icon=self.icon_muted if self.is_muted() else self.icon_unmuted,
+ dense=True,
+ flat=True,
+ a=item_section,
+ color=COLOR_PRIME_TEXT,
+ click=handle_btn,
+ )
+ item_section2 = QItemSection(a=item)
+ def handle_slider(widget_self, msg):
+ process(cmdl_value.format(name=self.name,value=msg.value), shell=True, output=False)
+ self.slider = QSlider(
+ value=volume_level,
+ min=0,
+ max=100,
+ step=5,
+ label=True,
+ a=item_section2,
+ input=handle_slider,
+ color=COLOR_SELECT,
+ # track_color=COLOR_PRIME, # not working, see .q-slider__track-container
+ # markers=True,
+ # marker_labels=True, # not working?
+ # track_size="10px", # not working, see cs .q-slider__track-container
+ # style = "height: 4px;"
+ style="opacity: 0.6 !important;" if self.is_muted() else "opacity: unset !important;",
+ )
- 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)}'
+ @classmethod
+ def update_states(cls) -> None:
+ """
+ get pulseaudio state of all sinks and sink-inputs and save it to
+ class variable data
- 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""
+ creates and updates
+ Volume.data['sinks']
+ Volume.data['sink-inputs']
+ both might be empty lists but available
+ """
+ t = time.time()
+ dt = t - cls.last_update
+ if dt > 1.0: # update only if at least a second passed since last update
+ cls.last_update = t
+
+ # wsl not running pulse daemon: Connection failure: Connection refused
+ sinks = process('pactl -f json list sinks', shell=True)
+ if 'failure' in sinks:
+ print("'pactl -f json list sinks' returns: '", sinks, "'")
+ # fill (initialize) key 'sinks' and 'sink-inputs' to empty list so not enter KeyError
+ cls.data['sinks'] = []
+ cls.data['sink-inputs'] = []
else:
- self.bmute.text = 'unmute'
+ cls.data['sinks'] = json.loads(sinks)
- 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)
+ sink_inputs = process('pactl -f json list sink-inputs', shell=True)
+ if 'failure' in sinks:
+ print("'pactl -f json list sink-inputs' returns", sink_inputs)
+ cls.data['sink-inputs'] = []
+ else:
+ cls.data['sink-inputs'] = json.loads(sink_inputs)
- 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"
+ def update_state(self) -> None:
+ "fills self.pa_state, therefore access info via self.pa_state"
+ self.update_states()
+ tmp = []
+ # filter for the given pa name, empty list if not found
+ if self.vtype == 'sink':
+ # match pa name with self.name
+ tmp = list(filter(lambda item: item['name'] == self.name,
+ Volume.data['sinks']))
+ elif self.vtype == 'sink-input':
+ # match pa index with self.name
+ try: # for int casting
+ tmp = list(filter(lambda item: item['index'] == int(self.name),
+ Volume.data['sink-inputs']))
+ except:
+ pass
+ self.pa_state = tmp[0] if tmp else {}
- 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)}'
+ def is_muted(self):
+ return self.pa_state['mute']
- 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""
- 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""
- else:
- self.bmute.text = 'mute'
+class VolumeGroup():
+ def __init__(self, **kwargs):
+ a = kwargs.pop('a', None) # add Volume widgets to the specified component
+ Volume.update_states()
+ for i in Volume.data['sink-inputs']:
+ Volume(a=a, name=i['index'], vtype='sink-input')
async def reload(self, msg):
await msg.page.reload()
@@ -429,143 +345,567 @@ async def kill_gui(self, msg):
await process("pkill controldeck-gui")
def ishexcolor(code):
- return bool(search(r'^#(?:[0-9a-fA-F]{3}){1,2}$', code))
+ return bool(re.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 = ''
- # 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 = {}
+def widget_load(config) -> dict:
+ """scan for widgets to add (adding below) in the config
+ {
+ tab-name: {
+ section-id: [
+ {widget-args}
+ ]
+ }
+ }
+ """
+ widget_dict = {}
for i in config.sections():
iname = None
- # volume buttons
- iname = search("^([0-9]*.?)(volume|mic)", i, flags=IGNORECASE)
+ #iname = re.search(r"^([0-9]*:?)([0-9]*\.?)(button|empty)", i, flags=re.IGNORECASE)
+ iname = re.search(
+ r"^([0-9a-z]*:)?([0-9]*\.)?(button|empty|sink-inputs|sink|source)", # sink-inputs BEFORE sink
+ i, flags=re.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='')}]
+ tab_name = iname.group(1)[:-1] if iname.group(1) is not None else '' # remove collon, id is '' if nothing is given
+ sec_id = iname.group(2)[:-1] if iname.group(2) is not None else '' # remove dot, id is '' if nothing is given
+ wid_type = iname.group(3)
+ wid_name = i[iname.end(0)+1:] # rest; after last group, can have all chars including . and :
+ # print('group ', iname.group(0))
+ # print('tab_id ', tab_name)
+ # print('sec_id ', sec_id)
+ # print('wid_type', wid_type)
+ # print('wid_name', wid_name)
+ # print('')
+ if wid_type in ['button', 'empty']:
+ # button or empty
+ args = [{'widget-class': 'Button',
+ 'type': wid_type,
+ 'text': wid_name,
+ '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='')}]
+ elif wid_type == 'sink':
+ # volume sliders
+ args = [{'widget-class': 'Volume',
+ 'type': wid_type,
+ 'name': wid_name,
+ 'description': config.get(i, 'description', fallback=''),
+ }]
+ elif wid_type == 'sink-inputs':
+ # multiple volume sliders
+ args = [{'widget-class': 'VolumeGroup',
+ 'type': wid_type,
+ }]
+ if tab_name not in widget_dict:
+ widget_dict.update({tab_name: {}})
+ # try:
+ # widget_dict[sec_id] += args
+ # except KeyError:
+ # widget_dict[sec_id] = args
try:
- volume_dict[id] += tmp
+ widget_dict[tab_name][sec_id] += args
except KeyError:
- 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] += tmp
- except KeyError:
- button_dict[id] = tmp
- var_prefix = "_div"
- for i in volume_dict:
- var = var_prefix+i
- for j in volume_dict[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 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))
+ widget_dict[tab_name][sec_id] = args
+ return widget_dict
- # 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();
+@SetRoute('/')
+async def application(request):
"""
- 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)
+ Components:
+ [QLayout]
+ +-[QHeader]
+ +-[QBar]
+ +-[QBtnToggle] - tabs
+ +-[QSpace]
+ +-[QBtn] - edit
+ +-[ToggleDarkModeBtn]
+ +-[QBtn] - fullscreen
+ +-[QBtn] - reload
+ +-([QBtn]) - close
+ +-[QPageContainer]
+ +-[QPage]
+ +-[QTabPanel]
+ +-[QTabPanel]
+ +-[QTabPanel]
+ +-[QTabPanel]
+ +-...
+ """
+ wp = QuasarPage(
+ title=APP_NAME,
+ dark=True,
+ classes="blue-grey-10",
+ )
+ # can be accessed via msg.page.request
+ wp.request = request
+ tab_choice = request.query_params.get('tab', '[all]') # if tab is not specified default to [all]
+
+ wp.page_type = 'main'
+ wp.head_html = """
+ """
+ wp.css = """
+ .q-icon {
+ height: unset; /* overwrite 1em so unused icons in buttons do not use space */
+ }
+ .q-btn img.q-icon { /* for images icons not font icons */
+ padding-bottom: 0.1em; /* some space to the text below */
+ font-size: 2.5em !important; /* increase image size (1.715em) */
+ }
+ .q-btn i.q-icon { /* for font icons not image icons */
+ padding-bottom: 0.1em; /* some space to the text below */
+ }
+ .q-btn-group > .q-btn-item:not(:last-child) {
+ border-right: unset !important;
+ }
+ .q-btn-group {
+ border-radius: 7px;
+ }
+
+ /* bc/ track_size is not working */
+ .q-slider__track-container {
+ height: 4px;
+ margin-top: -2px;
+ }
+ /* bc/ track_color is not working */
+ .q-slider__track-container {
+ background: #455a64 !important; /* blue-grey-8 */
+ }
+
+ .q-tooltip {
+ white-space: pre;
+ }
+ """
+
+ # quasar version installed v1.9.14
+ # v1 bc/ v2 uses vue v3 components but justpy only has vue v2 components
+ # https://github.com/justpy-org/justpy/issues/460
+ # wp.head_html += ''
+ # script_html = """
+ #
+ # """
+ # will be added at the top of body
+ # wp.body_html = script_html
+
+ # scan for widgets to add (adding below) in the config
+ config = config_load()
+ widget_dict = widget_load(config)
+ tab_names = ['[all]'] + list(widget_dict.keys()) # an all tab + all defined tab in the config
+
+ layout = QLayout(view="lHh lpr lFf",
+ #container=True,
+ a=wp, name='layout')
+ header = QHeader(elevated=True, a=layout)
+ #toolbar = QToolbar(a=header) # height 50
+ toolbar = QBar(
+ a=header,
+ classes='bg-'+COLOR_PRIME,
+ ) # height 32, dense 24 'bg-light-blue-10'
+
+ # toolbar_tabs = QTabs(
+ # a=toolbar, outside_arrows=True, mobile_arrows=True,
+ # style='width:200px',
+ # )
+ # QTab(a=toolbar_tabs, label="test1")
+ # QTab(a=toolbar_tabs, label="test2")
+ # QTab(a=toolbar_tabs, label="test3")
+ # QTab(a=toolbar_tabs, label="test4")
+ # QTab(a=toolbar_tabs, label="test5")
+ # QTab(a=toolbar_tabs, label="test6")
+ async def tab_button_change(self, msg):
+ #print(msg)
+ #print(msg['target'])
+ #print(self.value)
+ # tab_panel is defined below
+ if self.value == '[all]':
+ for tab_name in tab_panel.keys():
+ if tab_panel[tab_name].has_class('hidden'):
+ tab_panel[tab_name].remove_class('hidden')
+ else:
+ for tab_name in tab_panel.keys():
+ if not tab_panel[tab_name].has_class('hidden'):
+ tab_panel[tab_name].set_class('hidden')
+ if self.value in tab_panel:
+ tab_panel[self.value].remove_class('hidden')
+ # change the address field in the browser using pushState (to be able to go 'back') (other would be replaceState)
+ await msg.page.run_javascript(f"window.history.pushState('', '', '/?tab={self.value}')")
+ tab_btns = QBtnToggle( # 'deep-purple-9'
+ toggle_color=COLOR_SELECT, dense=False, flat=False, push=False,
+ glossy=False, a=toolbar, input=tab_button_change, value=tab_choice,
+ classes='q-ma-md', unelevated=True)
+ tab_btns.remove_class('q-ma-md')
+ tab_btns.style = 'height:100%;' # buttons full height
+ tab_btns.style += 'width:calc(100vw - 24px - 100.5333px);' # full width minus padding and 4 btns at the right end
+ tab_btns.style += 'overflow-x:auto;' # scroll content
+ for tab in tab_names:
+ label = tab.capitalize()
+ if tab == '[all]':
+ tab_btns.options.append({
+ 'label': '',
+ 'value': tab,
+ #'icon': 'fiber_smart_record',
+ #'icon': 'device_hub',
+ 'icon': 'brightness_auto',
+ #'icon': 'looks',
+ })
+ elif tab == '':
+ tab_btns.options.append({
+ 'label': '',
+ 'value': tab,
+ 'icon': 'radio_button_unchecked',
+ })
+ else:
+ tab_btns.options.append({
+ 'label': label,
+ 'value': tab,
+ })
+
+ QSpace(a=toolbar)
+
+ def toggle_edit_config(self, msg):
+ self.dialog.value = True
+ if path.exists(CONFIG_FILE_FULL):
+ self.dialog_label.text = CONFIG_FILE
+ with open(CONFIG_FILE_FULL, encoding='utf-8') as file:
+ self.dialog_input.value = file.read()
+ def edit_dialog_after(self, msg):
+ self.dialog_input.remove_class('changed')
+ edit_dialog = QDialog(
+ maximized=True,
+ transition_show='slide-down',
+ transition_hide='slide-up',
+ a=toolbar,
+ name="edit-dialog",
+ after=edit_dialog_after,
+ )
+ edit_dialog_card = QCard(
+ a=edit_dialog,
+ )
+ edit_dialog_bar = QBar(a=edit_dialog_card, classes='bg-'+COLOR_PRIME)
+ edit_dialog_label = QItemLabel(a=edit_dialog_bar) # text filled by handle toggle_edit_config
+ QSpace(a=edit_dialog_bar)
+ QSeparator(vertical=True,spaced=True,a=edit_dialog_bar)
+ def edit_dialog_save(self, msg):
+ if path.exists(CONFIG_FILE_FULL):
+ with open(CONFIG_FILE_FULL, mode='w', encoding='utf-8') as file:
+ file.write(self.dialog_input.value)
+ self.dialog_input.remove_class('changed')
+ edit_dialog_btn_save = QBtn(
+ a=edit_dialog_bar,
+ dense=True,
+ flat=True,
+ icon='save',
+ click=edit_dialog_save)
+ QTooltip(a=edit_dialog_btn_save, text='Save')
+ QSeparator(vertical=True,spaced=True,a=edit_dialog_bar)
+ edit_dialog_btn_close = QBtn(
+ a=edit_dialog_bar,
+ dense=True,
+ flat=True,
+ icon='close',
+ v_close_popup=True)
+ QTooltip(a=edit_dialog_btn_close, text='Close')
+ edit_dialog_card_section = QCardSection(
+ a=edit_dialog_card,
+ #classes='q-pt-none',
+ )
+ edit_dialog_input = QInput(
+ #filled=True,
+ type='textarea',
+ style='font-family:monospace,monospace;height:calc(100vh - 64px);', # 32 bar + 16 padding-top + 16 padding-bottom, two components deeper set height 100% see wp.css below
+ a=edit_dialog_card_section,
+ #value='', # filled by handle toggle_edit_config
+ spellcheck=False,
+ #wrap='off', # not working
+ #bg_color="blue-grey-9", # COLOR_PRIME
+ #input_class='text-'+COLOR_PRIME,
+ input_class='text-blue-grey-4',
+ outlined=True,
+ input_style='resize:none;white-space:pre;padding:6px;',
+ color='transparent', # on focus change border color, default is prime (blue)
+ )
+ def edit_dialog_change(self, msg):
+ #print(type(self.classes))
+ # self.set_class('changed') # hangs
+ # self.set_classes('changed') # hangs
+ #if self.classes:
+ # return True # return non None to not update the widget
+ self.classes = 'changed'
+ # self.error=True # red border
+ # return None to update the widget
+ #edit_dialog_input.on('change', edit_dialog_change) # hits after losing focus
+ edit_dialog_input.on('input', edit_dialog_change) # hits during editing
+ wp.css += """
+ .q-dialog .q-field__control {
+ height: 100%;
+ padding: unset;
+ }
+ /* changed coloring */
+ .q-field--dark.changed .q-field__control::before {
+ border-color: #9653f799;
+ }
+ .q-field--dark.changed .q-field__control:hover::before {
+ border-color: #9653f7 !important;
+ }
+ """
+ edit_dialog_btn_save.dialog_input = edit_dialog_input
+ edit_dialog.dialog_input = edit_dialog_input
+ # edit_dialog_editor = QEditor(
+ # a=edit_dialog_card_section,
+ # definitions={
+ # 'save': {
+ # 'tip': 'Save your work',
+ # 'icon': 'save',
+ # 'label': 'Save',
+ # },
+ # 'upload': {
+ # 'tip': 'Upload to cloud',
+ # 'icon': 'cloud_upload',
+ # 'label': 'Upload',
+ # },
+ # },
+ # toolbar=[['undo', 'redo', 'print', 'fullscreen', 'viewsource'],['upload', 'save']],
+ # )
+
+ # edit_dialog_html = """
+ #
+ #
+ #
+ #
+ #
+ #
+ #
+ #
+ # """
+ # edit_dialog = parse_html(edit_dialog_html, a=toolbar)
+
+ edit_config = QBtn(
+ dense=True,
+ flat=True,
+ icon='edit',
+ a=toolbar,
+ click=toggle_edit_config,
+ dialog=edit_dialog,
+ dialog_input=edit_dialog_input,
+ dialog_label=edit_dialog_label,
+ )
+ QTooltip(a=edit_config, text='Config')
+
+ # async def dark_light_mode_toggle(self, msg):
+ # if self.icon == 'dark_mode':
+ # self.icon = 'light_mode'
+ # elif self.icon == 'light_mode':
+ # self.icon = 'dark_mode'
+ btn_toogle_dark = ToggleDarkModeBtn(
+ label='', icon='settings_brightness', dense=True, flat=True, a=toolbar,
+ #click=dark_light_mode_toggle
+ )
+ QTooltip(a=btn_toogle_dark, text='Toggle dark/light mode')
+ async def toggle_screen(self, msg):
+ await msg.page.run_javascript('Quasar.AppFullscreen.toggle()')
+ btn_fullscreen = QBtn(dense=True, flat=True, icon='crop_square', a=toolbar, click=toggle_screen)
+ QTooltip(a=btn_fullscreen, text='Toggle fullscreen')
+ btn_reload = QBtn(dense=True, flat=True, icon="redo", click=reload, a=toolbar)
+ QTooltip(a=btn_reload, text='Reload')
+ if "gui" in request.query_params:
+ btn_close = QBtn(dense=True, flat=True, icon="close", click=kill_gui, a=toolbar)
+ QTooltip(a=btn_close, text='Close')
+
+ page_container = QPageContainer(a=layout)
+ page = QPage(a=page_container, name="page-tabs")
+
+ #tab_panels = QTabPanels(v_model='tab', animated=True, a=layout) # panels not working
+ tab_panel = {i:QTabPanel(name=i, classes="q-pa-none", a=page) for i in tab_names}
+ msg = Dict()
+ msg.page = wp
+ await tab_button_change(tab_btns, msg) # update visibility of tab panels regarding the request
+
+ # add widgets; naming like _div_[tab_name][sec_id]
+ for tab_name in widget_dict:
+ for i in widget_dict[tab_name]:
+ var = "_div_"+tab_name+i # tab_name: chars or sting-letters, i (sec_id) '' or a string-number
+ for j in widget_dict[tab_name][i]:
+ if var not in vars():
+ vars()[var] = Div(
+ name=var,
+ classes="row q-pa-sm q-gutter-sm",
+ a=tab_panel[tab_name])
+ if j['widget-class'] == 'Button':
+ 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))
+ elif j['widget-class'] == 'Volume':
+ Volume(name=j['name'], description=j['description'],
+ vtype=j['type'],
+ a=eval(var))
+ elif j['widget-class'] == 'VolumeGroup':
+ VolumeGroup(vtype=j['type'], a=eval(var))
+
+ # Test
+ test_row = Div(classes="row q-pa-sm q-gutter-sm", a=tab_panel['[all]'])
+ # button with active status led
+ test_btn = Button(
+ a=test_row,
+ text='foo',
+ )
+ Div(
+ a=test_btn,
+ style='position: absolute;top: 2px;right: 2px;width: 5px;background-color: #00ff00;height: 5px;border-radius: 2.5px;'
+ )
+ # notify, position ['top-left', 'top', 'top-right', 'left', 'center', 'right', 'bottom-left', 'bottom', 'bottom-right']
+ test_btn.qnotify = QNotify(message='Test nofify', position='top', a=wp)
+ def test_btn_click(self, msg):
+ self.qnotify.notify = True
+ self.qnotify.caption = 'test caption'
+ test_btn.on('click', test_btn_click)
+ def test_btn_after(self, msg):
+ self.qnotify.notify = False
+ test_btn.on('after', test_btn_after)
+
+ # tmp = process('pactl -f json list sink-inputs', stderr=None)
+ # print('')
+ # #print(tmp)
+ # #print(len(tmp))
+ # tmp = json.loads(tmp)
+ # print(tmp)
+ # print(len(tmp))
+ # if tmp:
+ # print('mute:', tmp[0]['mute'])
+ # print('volume:', tmp[0]['volume']['front-left']['value_percent'][:-1])
+ # print('application.process.binary:', tmp[0]['properties']['application.process.binary'])
+ # print('application.process.id:', tmp[0]['properties']['application.process.id'])
+ # if 'application.icon_name' in tmp[0]['properties']:
+ # print('application.icon_name:', tmp[0]['properties']['application.icon_name'])
+ # if 'media.icon_name' in tmp[0]['properties']:
+ # print('media.icon_name:', tmp[0]['properties']['media.icon_name'])
+ # print('media.name:', tmp[0]['properties']['media.name'])
+ # print('')
+
+ #tmp2 = process('pactl list sink-inputs | grep media.name', shell=True, stderr=None)
+ #print(tmp2)
+ # remove ' name.media = ' and remove quotation marks at front and back
+ # print([i[1:-1] for i in re.sub('[ \t]*media.name = ', '', tmp2, re.S).split('\n')])
+
+ # own pactl list sink-inputs parser (pactl 16.1, Compiled with libpulse 16.1.0)
+ tmp2 = process("pactl list sink-inputs", shell=True, stderr=None)
+ #print(tmp2)
+ tmp3 = []
+ for i in tmp2.split('Sink Input #'): # split block
+ if i:
+ tmp4 = {}
+ for j,k in enumerate(i.split('\n')): # split on every line
+ if j == 0: # fist entry
+ tmp4['id'] = k # is the id
+ tmp4['properties'] = {} # initialize
+ if k: # others are attributes
+ if 'Driver: ' in k:
+ tmp4['driver'] = re.sub('[ \t]*Driver: ', '', k)
+ elif 'Mute: ' in k:
+ tmp5 = re.sub('[ \t]*Mute: ', '', k)
+ tmp5 = tmp5.replace('no', 'false')
+ tmp5 = tmp5.replace('yes', 'true')
+ tmp4['mute'] = tmp5
+ elif 'Volume: ' in k:
+ tmp4['volume'] = {}
+ for l in re.sub('[ \t]*Volume: ', '', k).split(','):
+ tmp5 = l.split(': ')
+ tmp6 = tmp5[1].split(' / ')
+ tmp4['volume'][tmp5[0].strip()] = {
+ 'value': tmp6[0], 'value_percent': tmp6[1], 'db': tmp6[2]}
+ # Properties
+ elif 'application.name = ' in k:
+ # remove '\t\tapplication.name = ' and remove quotation marks at front and back
+ tmp4['properties']['application.name'] = re.sub('[ \t]*application.name = ', '', k)[1:-1]
+ elif 'application.process.id = ' in k:
+ # remove '\t\tapplication.process.id = ' and remove quotation marks at front and back
+ tmp4['properties']['application.process.id'] = re.sub('[ \t]*application.process.id = ', '', k)[1:-1]
+ elif 'application.process.binary = ' in k:
+ # remove '\t\tapplication.process.binary = ' and remove quotation marks at front and back
+ tmp4['properties']['application.process.binary'] = re.sub('[ \t]*application.process.binary = ', '', k)[1:-1]
+ elif 'media.icon_name = ' in k:
+ # remove '\t\tmedia.icon_name = ' and remove quotation marks at front and back
+ tmp4['properties']['media.icon_name'] = re.sub('[ \t]*media.icon_name = ', '', k)[1:-1] # only pipewire?
+ elif 'application.icon_name = ' in k:
+ # remove '\t\tapplication.icon_name = ' and remove quotation marks at front and back
+ tmp4['properties']['application.icon_name'] = re.sub('[ \t]*application.icon_name = ', '', k)[1:-1] # only pulseaudio? (seen on wsl)
+ elif 'media.name = ' in k:
+ # remove '\t\tmedia.name = ' and remove quotation marks at front and back
+ tmp4['properties']['media.name'] = re.sub('[ \t]*media.name = ', '', k)[1:-1]
+ elif 'node.name = ' in k:
+ # remove '\t\tnode.name = ' and remove quotation marks at front and back
+ tmp4['properties']['node.name'] = re.sub('[ \t]*node.name = ', '', k)[1:-1]
+ tmp3.append(tmp4)
+ import xdg.IconTheme
+ #print(tmp3)
+ #icon_name = ''
+ #icon_name = i['properties']['application.icon_name'] if 'application.icon_name' in i['properties'] else icon_name
+ #icon_name = i['properties']['media.icon_name'] if 'media.icon_name' in i['properties'] else icon_name
+ #icon = 'img:' + xdg.IconTheme.getIconPath(icon_name, size=None, extensions=['svg', 'png']),
+
+ # TODO: change reference wp.components to ...
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)
+ #if status:
+ # wp.add(STATUS_DIV)
return wp
-def main():
- justpy(host="0.0.0.0")
+@SetRoute('/hello')
+def hello_function():
+ wp = WebPage()
+ wp.add(P(text='Hello there!', classes='text-5xl m-2'))
+ return wp
+
+def main(args):
+ config = config_load(args.config)
+ host = config.get('default', 'host', fallback='0.0.0.0')
+ port = config.get('default', 'port', fallback='8000')
+ justpy(host=host, port=port, static_directory="./static")
+
+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('-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)
+
+ return 0
if __name__ == '__main__':
- sys.exit(main())
+ sys.exit(cli())
diff --git a/controldeck_gui.py b/controldeck_gui.py
index 67fb531..6114801 100755
--- a/controldeck_gui.py
+++ b/controldeck_gui.py
@@ -14,36 +14,19 @@ def thread_function(name):
# 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)")
+ p = process("comm -12 <(xdotool search --name 'ControlDeck' | sort) <(xdotool search --classname '^$' | sort)", shell=True)
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)
+ process(f"xdotool set_window --classname 'controldeck' --class 'ControlDeck' {p} windowunmap {p} windowmap {p}", shell=True, 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)
+ host = config.get('default', 'host', fallback='0.0.0.0')
+ port = config.get('default', 'port', fallback='8000')
+ url = f"http://{host}:{port}/?gui&pid={str(pid)}"
try:
width = int(config.get('gui', 'width', fallback=800))
except ValueError as e:
@@ -81,6 +64,28 @@ def main(args, pid=-1):
minimized = config.get('gui', 'minimized', fallback='False').title() == 'True'
on_top = config.get('gui', 'always_on_top', fallback='False').title() == 'True'
+ #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", shell=True, output=True)
+
+ if args.start and controldeck_process == "":
+ cmd = "controldeck"
+ cmd += " --config={args.config}" if args.config else ""
+ print(cmd)
+ process(cmd, shell=True, 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)
+
create_window("ControlDeck",
url=url,
html=None,
diff --git a/justpy.env b/justpy.env
new file mode 100644
index 0000000..a10cdac
--- /dev/null
+++ b/justpy.env
@@ -0,0 +1,2 @@
+STATIC_DIRECTORY = ./static
+NO_INTERNET = True
diff --git a/setup.cfg b/setup.cfg
index fc04662..b7aef03 100644
--- a/setup.cfg
+++ b/setup.cfg
@@ -6,7 +6,6 @@ version = file: VERSION
install_requires =
justpy
pywebview
- cairosvg
py_modules =
controldeck
controldeck_gui