diff --git a/.pylintrc b/.pylintrc
index daee0a9..94ca7f4 100644
--- a/.pylintrc
+++ b/.pylintrc
@@ -1,4 +1,642 @@
[MAIN]
+
+# Analyse import fallback blocks. This can be used to support both Python 2 and
+# 3 compatible code, which means that the block might have code that exists
+# only in one or another interpreter, leading to false positives when analysed.
+analyse-fallback-blocks=no
+
+# Clear in-memory caches upon conclusion of linting. Useful if running pylint
+# in a server-like mode.
+clear-cache-post-run=no
+
+# Load and enable all available extensions. Use --list-extensions to see a list
+# all available extensions.
+#enable-all-extensions=
+
+# In error mode, messages with a category besides ERROR or FATAL are
+# suppressed, and no reports are done by default. Error mode is compatible with
+# disabling specific errors.
+#errors-only=
+
+# Always return a 0 (non-error) status code, even if lint errors are found.
+# This is primarily useful in continuous integration scripts.
+#exit-zero=
+
+# A comma-separated list of package or module names from where C extensions may
+# be loaded. Extensions are loading into the active Python interpreter and may
+# run arbitrary code.
+extension-pkg-allow-list=[gi]
+
+
+# Return non-zero exit code if any of these messages/categories are detected,
+# even if score is above --fail-under value. Syntax same as enable. Messages
+# specified are enabled, while categories only check already-enabled messages.
+fail-on=
+
+# Specify a score threshold under which the program will exit with error.
+fail-under=10
+
+# Interpret the stdin as a python script, whose filename needs to be passed as
+# the module_or_package argument.
+#from-stdin=
+
+# Files or directories to be skipped. They should be base names, not paths.
+ignore=CVS
+
+# Add files or directories matching the regular expressions patterns to the
+# ignore-list. The regex matches against paths and can be in Posix or Windows
+# format. Because '\\' represents the directory delimiter on Windows systems,
+# it can't be used as an escape character.
+ignore-paths=
+
+# Files or directories matching the regular expression patterns are skipped.
+# The regex matches against base names, not paths. The default value ignores
+# Emacs file locks
+ignore-patterns=^\.#
+
+# List of module names for which member attributes should not be checked and
+# will not be imported (useful for modules/projects where namespaces are
+# manipulated during runtime and thus existing member attributes cannot be
+# deduced by static analysis). It supports qualified module names, as well as
+# Unix pattern matching.
+ignored-modules=
+
+# Python code to execute, usually for sys.path manipulation such as
+# pygtk.require().
+#init-hook=
+
+# Use multiple processes to speed up Pylint. Specifying 0 will auto-detect the
+# number of processors available to use, and will cap the count on Windows to
+# avoid hangs.
+jobs=1
+
+# Control the amount of potential inferred values when inferring a single
+# object. This can help the performance when dealing with large functions or
+# complex, nested conditions.
+limit-inference-results=100
+
+# List of plugins (as comma separated values of python module names) to load,
+# usually to register additional checkers.
+load-plugins=
+
+# Pickle collected data for later comparisons.
+persistent=yes
+
+# Resolve imports to .pyi stubs if available. May reduce no-member messages and
+# increase not-an-iterable messages.
+prefer-stubs=no
+
+# Minimum Python version to use for version dependent checks. Will default to
+# the version used to run pylint.
+py-version=3.13
+
+# Discover python modules and packages in the file system subtree.
+recursive=yes
+
+# Add paths to the list of the source roots. Supports globbing patterns. The
+# source root is an absolute path or a path relative to the current working
+# directory used to determine a package namespace for modules located under the
+# source root.
+source-roots=
+
+# When enabled, pylint would attempt to guess common misconfiguration and emit
+# user-friendly hints instead of false-positive error messages.
+suggestion-mode=yes
+
+# Allow loading of arbitrary C extensions. Extensions are imported into the
+# active Python interpreter and may run arbitrary code.
+unsafe-load-any-extension=no
+
+# In verbose mode, extra non-checker-related info will be displayed.
+#verbose=
+
+
+[BASIC]
+
+# Naming style matching correct argument names.
+argument-naming-style=snake_case
+
+# Regular expression matching correct argument names. Overrides argument-
+# naming-style. If left empty, argument names will be checked with the set
+# naming style.
+#argument-rgx=
+
+# Naming style matching correct attribute names.
+attr-naming-style=snake_case
+
+# Regular expression matching correct attribute names. Overrides attr-naming-
+# style. If left empty, attribute names will be checked with the set naming
+# style.
+#attr-rgx=
+
+# Bad variable names which should always be refused, separated by a comma.
+bad-names=foo,
+ bar,
+ baz,
+ toto,
+ tutu,
+ tata
+
+# Bad variable names regexes, separated by a comma. If names match any regex,
+# they will always be refused
+bad-names-rgxs=
+
+# Naming style matching correct class attribute names.
+class-attribute-naming-style=any
+
+# Regular expression matching correct class attribute names. Overrides class-
+# attribute-naming-style. If left empty, class attribute names will be checked
+# with the set naming style.
+#class-attribute-rgx=
+
+# Naming style matching correct class constant names.
+class-const-naming-style=UPPER_CASE
+
+# Regular expression matching correct class constant names. Overrides class-
+# const-naming-style. If left empty, class constant names will be checked with
+# the set naming style.
+#class-const-rgx=
+
+# Naming style matching correct class names.
+class-naming-style=PascalCase
+
+# Regular expression matching correct class names. Overrides class-naming-
+# style. If left empty, class names will be checked with the set naming style.
+#class-rgx=
+
+# Naming style matching correct constant names.
+const-naming-style=UPPER_CASE
+
+# Regular expression matching correct constant names. Overrides const-naming-
+# style. If left empty, constant names will be checked with the set naming
+# style.
+#const-rgx=
+
+# Minimum line length for functions/classes that require docstrings, shorter
+# ones are exempt.
+docstring-min-length=-1
+
+# Naming style matching correct function names.
+function-naming-style=snake_case
+
+# Regular expression matching correct function names. Overrides function-
+# naming-style. If left empty, function names will be checked with the set
+# naming style.
+#function-rgx=
+
+# Good variable names which should always be accepted, separated by a comma.
+good-names=i,
+ j,
+ k,
+ ex,
+ Run,
+ _
+
+# Good variable names regexes, separated by a comma. If names match any regex,
+# they will always be accepted
+good-names-rgxs=
+
+# Include a hint for the correct naming format with invalid-name.
+include-naming-hint=yes
+
+# Naming style matching correct inline iteration names.
+inlinevar-naming-style=any
+
+# Regular expression matching correct inline iteration names. Overrides
+# inlinevar-naming-style. If left empty, inline iteration names will be checked
+# with the set naming style.
+#inlinevar-rgx=
+
+# Naming style matching correct method names.
+method-naming-style=snake_case
+
+# Regular expression matching correct method names. Overrides method-naming-
+# style. If left empty, method names will be checked with the set naming style.
+#method-rgx=
+
+# Naming style matching correct module names.
+module-naming-style=snake_case
+
+# Regular expression matching correct module names. Overrides module-naming-
+# style. If left empty, module names will be checked with the set naming style.
+#module-rgx=
+
+# Colon-delimited sets of names that determine each other's naming style when
+# the name regexes allow several styles.
+name-group=
+
+# Regular expression which should only match function or class names that do
+# not require a docstring.
+no-docstring-rgx=^_
+
+# List of decorators that produce properties, such as abc.abstractproperty. Add
+# to this list to register other decorators that produce valid properties.
+# These decorators are taken in consideration only for invalid-name.
+property-classes=abc.abstractproperty
+
+# Regular expression matching correct type alias names. If left empty, type
+# alias names will be checked with the set naming style.
+#typealias-rgx=
+
+# Regular expression matching correct type variable names. If left empty, type
+# variable names will be checked with the set naming style.
+#typevar-rgx=
+
+# Naming style matching correct variable names.
+variable-naming-style=snake_case
+
+# Regular expression matching correct variable names. Overrides variable-
+# naming-style. If left empty, variable names will be checked with the set
+# naming style.
+#variable-rgx=
+
+
+[CLASSES]
+
+# Warn about protected attribute access inside special methods
+check-protected-access-in-special-methods=no
+
+# List of method names used to declare (i.e. assign) instance attributes.
+defining-attr-methods=__init__,
+ __new__,
+ setUp,
+ asyncSetUp,
+ __post_init__
+
+# List of member names, which should be excluded from the protected access
+# warning.
+exclude-protected=_asdict,_fields,_replace,_source,_make,os._exit
+
+# List of valid names for the first argument in a class method.
+valid-classmethod-first-arg=cls
+
+# List of valid names for the first argument in a metaclass class method.
+valid-metaclass-classmethod-first-arg=mcs
+
+
+[DESIGN]
+
+# List of regular expressions of class ancestor names to ignore when counting
+# public methods (see R0903)
+exclude-too-few-public-methods=
+
+# List of qualified class names to ignore when counting class parents (see
+# R0901)
+ignored-parents=
+
+# Maximum number of arguments for function / method.
+max-args=5
+
+# Maximum number of attributes for a class (see R0902).
+max-attributes=7
+
+# Maximum number of boolean expressions in an if statement (see R0916).
+max-bool-expr=5
+
+# Maximum number of branch for function / method body.
+max-branches=12
+
+# Maximum number of locals for function / method body.
+max-locals=15
+
+# Maximum number of parents for a class (see R0901).
+max-parents=7
+
+# Maximum number of public methods for a class (see R0904).
+max-public-methods=20
+
+# Maximum number of return / yield for function / method body.
+max-returns=6
+
+# Maximum number of statements in function / method body.
+max-statements=50
+
+# Minimum number of public methods for a class (see R0903).
+min-public-methods=2
+
+
+[EXCEPTIONS]
+
+# Exceptions that will emit a warning when caught.
+overgeneral-exceptions=builtins.BaseException,builtins.Exception
+
+
+[FORMAT]
+
+# Expected format of line ending, e.g. empty (any line ending), LF or CRLF.
+expected-line-ending-format=
+
+# Regexp for a line that is allowed to be longer than the limit.
+ignore-long-lines=^\s*(# )??$
+
+# Number of spaces of indent required inside a hanging or continued line.
+indent-after-paren=4
+
+# String used as indentation unit. This is usually " " (4 spaces) or "\t" (1
+# tab).
+indent-string=' '
+
+# Maximum number of characters on a single line.
max-line-length=150
+
+# Maximum number of lines in a module.
max-module-lines=2000
-generated-member=cairo.*
\ No newline at end of file
+
+# Allow the body of a class to be on the same line as the declaration if body
+# contains single statement.
+single-line-class-stmt=no
+
+# Allow the body of an if to be on the same line as the test if there is no
+# else.
+single-line-if-stmt=no
+
+
+[IMPORTS]
+
+# List of modules that can be imported at any level, not just the top level
+# one.
+allow-any-import-level=
+
+# Allow explicit reexports by alias from a package __init__.
+allow-reexport-from-package=no
+
+# Allow wildcard imports from modules that define __all__.
+allow-wildcard-with-all=no
+
+# Deprecated modules which should not be used, separated by a comma.
+deprecated-modules=
+
+# Output a graph (.gv or any supported image format) of external dependencies
+# to the given file (report RP0402 must not be disabled).
+ext-import-graph=
+
+# Output a graph (.gv or any supported image format) of all (i.e. internal and
+# external) dependencies to the given file (report RP0402 must not be
+# disabled).
+import-graph=
+
+# Output a graph (.gv or any supported image format) of internal dependencies
+# to the given file (report RP0402 must not be disabled).
+int-import-graph=
+
+# Force import order to recognize a module as part of the standard
+# compatibility libraries.
+known-standard-library=
+
+# Force import order to recognize a module as part of a third party library.
+known-third-party=enchant
+
+# Couples of modules and preferred modules, separated by a comma.
+preferred-modules=
+
+
+[LOGGING]
+
+# The type of string formatting that logging methods do. `old` means using %
+# formatting, `new` is for `{}` formatting.
+logging-format-style=old
+
+# Logging modules to check that the string format arguments are in logging
+# function parameter format.
+logging-modules=logging
+
+
+[MESSAGES CONTROL]
+
+# Only show warnings with the listed confidence levels. Leave empty to show
+# all. Valid levels: HIGH, CONTROL_FLOW, INFERENCE, INFERENCE_FAILURE,
+# UNDEFINED.
+confidence=HIGH,
+ CONTROL_FLOW,
+ INFERENCE,
+ INFERENCE_FAILURE,
+ UNDEFINED
+
+# Disable the message, report, category or checker with the given id(s). You
+# can either give multiple identifiers separated by comma (,) or put this
+# option multiple times (only on the command line, not in the configuration
+# file where it should appear only once). You can also use "--disable=all" to
+# disable everything first and then re-enable specific checks. For example, if
+# you want to run only the similarities checker, you can use "--disable=all
+# --enable=similarities". If you want to run only the classes checker, but have
+# no Warning level messages displayed, use "--disable=all --enable=classes
+# --disable=W".
+disable=raw-checker-failed,
+ bad-inline-option,
+ locally-disabled,
+ file-ignored,
+ suppressed-message,
+ useless-suppression,
+ deprecated-pragma,
+ use-symbolic-message-instead,
+ use-implicit-booleaness-not-comparison-to-string,
+ use-implicit-booleaness-not-comparison-to-zero,
+ wrong-import-position,
+ wrong-import-order
+
+# Enable the message, report, category or checker with the given id(s). You can
+# either give multiple identifier separated by comma (,) or put this option
+# multiple time (only on the command line, not in the configuration file where
+# it should appear only once). See also the "--disable" option for examples.
+enable=
+
+
+[METHOD_ARGS]
+
+# List of qualified names (i.e., library.method) which require a timeout
+# parameter e.g. 'requests.api.get,requests.api.post'
+timeout-methods=requests.api.delete,requests.api.get,requests.api.head,requests.api.options,requests.api.patch,requests.api.post,requests.api.put,requests.api.request
+
+
+[MISCELLANEOUS]
+
+# List of note tags to take in consideration, separated by a comma.
+notes=FIXME,
+ XXX,
+ TODO
+
+# Regular expression of note tags to take in consideration.
+notes-rgx=
+
+
+[REFACTORING]
+
+# Maximum number of nested blocks for function / method body
+max-nested-blocks=5
+
+# Complete name of functions that never returns. When checking for
+# inconsistent-return-statements if a never returning function is called then
+# it will be considered as an explicit return statement and no message will be
+# printed.
+never-returning-functions=sys.exit,argparse.parse_error
+
+# Let 'consider-using-join' be raised when the separator to join on would be
+# non-empty (resulting in expected fixes of the type: ``"- " + " -
+# ".join(items)``)
+suggest-join-with-non-empty-separator=yes
+
+
+[REPORTS]
+
+# Python expression which should return a score less than or equal to 10. You
+# have access to the variables 'fatal', 'error', 'warning', 'refactor',
+# 'convention', and 'info' which contain the number of messages in each
+# category, as well as 'statement' which is the total number of statements
+# analyzed. This score is used by the global evaluation report (RP0004).
+evaluation=max(0, 0 if fatal else 10.0 - ((float(5 * error + warning + refactor + convention) / statement) * 10))
+
+# Template used to display messages. This is a python new-style format string
+# used to format the message information. See doc for all details.
+msg-template=
+
+# Set the output format. Available formats are: 'text', 'parseable',
+# 'colorized', 'json2' (improved json format), 'json' (old json format), msvs
+# (visual studio) and 'github' (GitHub actions). You can also give a reporter
+# class, e.g. mypackage.mymodule.MyReporterClass.
+#output-format=
+
+# Tells whether to display a full report or only the messages.
+reports=no
+
+# Activate the evaluation score.
+score=yes
+
+
+[SIMILARITIES]
+
+# Comments are removed from the similarity computation
+ignore-comments=yes
+
+# Docstrings are removed from the similarity computation
+ignore-docstrings=yes
+
+# Imports are removed from the similarity computation
+ignore-imports=yes
+
+# Signatures are removed from the similarity computation
+ignore-signatures=yes
+
+# Minimum lines number of a similarity.
+min-similarity-lines=4
+
+
+[SPELLING]
+
+# Limits count of emitted suggestions for spelling mistakes.
+max-spelling-suggestions=4
+
+# Spelling dictionary name. No available dictionaries : You need to install
+# both the python package and the system dependency for enchant to work.
+spelling-dict=
+
+# List of comma separated words that should be considered directives if they
+# appear at the beginning of a comment and should not be checked.
+spelling-ignore-comment-directives=fmt: on,fmt: off,noqa:,noqa,nosec,isort:skip,mypy:
+
+# List of comma separated words that should not be checked.
+spelling-ignore-words=
+
+# A path to a file that contains the private dictionary; one word per line.
+spelling-private-dict-file=
+
+# Tells whether to store unknown words to the private dictionary (see the
+# --spelling-private-dict-file option) instead of raising a message.
+spelling-store-unknown-words=no
+
+
+[STRING]
+
+# This flag controls whether inconsistent-quotes generates a warning when the
+# character used as a quote delimiter is used inconsistently within a module.
+check-quote-consistency=no
+
+# This flag controls whether the implicit-str-concat should generate a warning
+# on implicit string concatenation in sequences defined over several lines.
+check-str-concat-over-line-jumps=no
+
+
+[TYPECHECK]
+
+# List of decorators that produce context managers, such as
+# contextlib.contextmanager. Add to this list to register other decorators that
+# produce valid context managers.
+contextmanager-decorators=contextlib.contextmanager
+
+# List of members which are set dynamically and missed by pylint inference
+# system, and so shouldn't trigger E1101 when accessed. Python regular
+# expressions are accepted.
+generated-members=
+
+# Tells whether to warn about missing members when the owner of the attribute
+# is inferred to be None.
+ignore-none=yes
+
+# This flag controls whether pylint should warn about no-member and similar
+# checks whenever an opaque object is returned when inferring. The inference
+# can return multiple potential results while evaluating a Python object, but
+# some branches might not be evaluated, which results in partial inference. In
+# that case, it might be useful to still emit no-member and other checks for
+# the rest of the inferred objects.
+ignore-on-opaque-inference=yes
+
+# List of symbolic message names to ignore for Mixin members.
+ignored-checks-for-mixins=no-member,
+ not-async-context-manager,
+ not-context-manager,
+ attribute-defined-outside-init
+
+# List of class names for which member attributes should not be checked (useful
+# for classes with dynamically set attributes). This supports the use of
+# qualified names.
+ignored-classes=optparse.Values,thread._local,_thread._local,argparse.Namespace
+
+# Show a hint with possible names when a member name was not found. The aspect
+# of finding the hint is based on edit distance.
+missing-member-hint=yes
+
+# The maximum edit distance a name should have in order to be considered a
+# similar match for a missing member name.
+missing-member-hint-distance=1
+
+# The total number of similar names that should be taken in consideration when
+# showing a hint for a missing member.
+missing-member-max-choices=1
+
+# Regex pattern to define which classes are considered mixins.
+mixin-class-rgx=.*[Mm]ixin
+
+# List of decorators that change the signature of a decorated function.
+signature-mutators=
+
+
+[VARIABLES]
+
+# List of additional names supposed to be defined in builtins. Remember that
+# you should avoid defining new builtins when possible.
+additional-builtins=
+
+# Tells whether unused global variables should be treated as a violation.
+allow-global-unused-variables=yes
+
+# List of names allowed to shadow builtins
+allowed-redefined-builtins=
+
+# List of strings which can identify a callback function by name. A callback
+# name must start or end with one of those strings.
+callbacks=cb_,
+ _cb
+
+# A regular expression matching the name of dummy variables (i.e. expected to
+# not be used).
+dummy-variables-rgx=_+$|(_[a-zA-Z0-9_]*[a-zA-Z0-9]+?$)|dummy|^ignored_|^unused_
+
+# Argument names that match this expression will be ignored.
+ignored-argument-names=_.*|^ignored_|^unused_
+
+# Tells whether we should check for unused import in __init__ files.
+init-import=no
+
+# List of qualified module names which can have objects that can redefine
+# builtins.
+redefining-builtins-modules=six.moves,past.builtins,future.builtins,builtins,io
+
diff --git a/CODING b/CODING
index 2d3382b..b03c7f9 100644
--- a/CODING
+++ b/CODING
@@ -1,5 +1,5 @@
-Attempts have been made to keep this project meeting PEP8 standards. While
-meeting this standard would be nice pull request & patch acceptance will
+Attempts have been made to keep this project readable, using pylint and python black.
+While meeting this standard would be nice pull request & patch acceptance will
be based on merit first and coding standard second.
Due to a quirk in the way pygobject functions and the fact we use soft
diff --git a/README.md b/README.md
index 49aa571..76729c5 100644
--- a/README.md
+++ b/README.md
@@ -147,29 +147,18 @@ It is advised to install python-gobject from your system's own package manager.
#### Debian/Ubuntu
-`apt install python3-gi python3-gi-cairo libappindicator3-dev`
+`apt install python4-gi gtk4-layer-shell libgtk4-layer-shell-dev`
Libappindicator might conflict with other installed packages, but is optional
-with Wayland support
-
-`apt install gtk-layer-shell libgtk-layer-shell-dev`
-
#### Arch
-`pacman -S python-gobject libappindicator-gtk3`
-
-with Wayland support
-
-`pacman -S gtk-layer-shell`
+`pacman -S python-gobject gtk4-layer-shell`
#### Fedora
-`dnf install python3-pip python3-gobject gtk3-devel python3-cairo python-devel python-gobject python-gobject-devel`
-
-with Wayland support
+`dnf install python3-pip python3-gobject gtk4-devel python3-cairo python-devel python-gobject python-gobject-devel gtk4-layer-shell`
-`dnf install gtk-layer-shell`
## Usage
diff --git a/_version.py b/_version.py
new file mode 100644
index 0000000..777f190
--- /dev/null
+++ b/_version.py
@@ -0,0 +1 @@
+__version__ = "0.8.0"
diff --git a/discover_overlay/audio_assist.py b/discover_overlay/audio_assist.py
index 80a8dd4..5893c39 100644
--- a/discover_overlay/audio_assist.py
+++ b/discover_overlay/audio_assist.py
@@ -28,7 +28,10 @@ def __init__(self, discover):
self.thread = None
self.enabled = False
- self.source = None # String containing the name of the PA/PW microphone or other input
+
+ # String containing the name of the PA/PW microphone or other input
+ self.source = None
+
self.sink = None # String containing the name of the PA/PW output
self.discover = discover
@@ -68,9 +71,9 @@ def thread_loop(self):
async def listen(self):
"""Async to connect to pulse and listen for events"""
try:
- async with pulsectl_asyncio.PulseAsync('Discover-Monitor') as pulse:
+ async with pulsectl_asyncio.PulseAsync("Discover-Monitor") as pulse:
await self.get_device_details(pulse)
- async for event in pulse.subscribe_events('all'):
+ async for event in pulse.subscribe_events("all"):
await self.handle_events(pulse, event)
except pulsectl.pulsectl.PulseDisconnected:
log.info("Pulse has gone away")
@@ -118,28 +121,28 @@ async def get_device_details(self, pulse):
self.discover.set_mute_async(mute)
async def handle_events(self, pulse, ev):
- """ `Sink` and `Source` events are fired for changes to output and inputs
- `Server` is fired when default sink or source changes."""
+ """`Sink` and `Source` events are fired for changes to output and inputs
+ `Server` is fired when default sink or source changes."""
if not self.enabled:
return
match ev.facility:
- case 'sink':
+ case "sink":
await self.get_device_details(pulse)
- case 'source':
+ case "source":
await self.get_device_details(pulse)
- case 'server':
+ case "server":
await self.get_device_details(pulse)
- case 'source_output':
+ case "source_output":
pass
- case 'sink_input':
+ case "sink_input":
pass
- case 'client':
+ case "client":
pass
case _:
diff --git a/discover_overlay/autostart.py b/discover_overlay/autostart.py
index b858bb0..e56c7d2 100644
--- a/discover_overlay/autostart.py
+++ b/discover_overlay/autostart.py
@@ -13,7 +13,7 @@
"""A class to assist auto-start"""
import os
import logging
-import shutil
+
try:
from xdg.BaseDirectory import xdg_config_home, xdg_data_home
except ModuleNotFoundError:
@@ -30,14 +30,17 @@ def __init__(self, app_name):
if not app_name.endswith(".desktop"):
app_name = f"{app_name}.desktop"
self.app_name = app_name
- self.auto_locations = [os.path.join(
- xdg_config_home, 'autostart/'), '/etc/xdg/autostart/']
- self.desktop_locations = [os.path.join(
- xdg_data_home, 'applications/'), '/usr/share/applications/']
+ self.auto_locations = [
+ os.path.join(xdg_config_home, "autostart/"),
+ "/etc/xdg/autostart/",
+ ]
+ self.desktop_locations = [
+ os.path.join(xdg_data_home, "applications/"),
+ "/usr/share/applications/",
+ ]
self.auto = self.find_auto()
self.desktop = self.find_desktop()
- log.info("Autostart info : desktop %s auto %s",
- self.desktop, self.auto)
+ log.info("Autostart info : desktop %s auto %s", self.desktop, self.auto)
def find_auto(self):
"""Check all known locations for auto-started apps"""
@@ -59,7 +62,7 @@ def set_autostart(self, enable):
"""Set or Unset auto-start state"""
if enable and not self.auto:
# Enable
- directory = os.path.join(xdg_config_home, 'autostart')
+ directory = os.path.join(xdg_config_home, "autostart")
self.auto = os.path.join(directory, self.app_name)
os.makedirs(directory, exist_ok=True)
os.symlink(self.desktop, self.auto)
@@ -71,41 +74,3 @@ def set_autostart(self, enable):
def is_auto(self):
"""Check if it's already set to auto-start"""
return True if self.auto else False
-
-
-class BazziteAutostart:
- """A class to assist auto-start"""
-
- def __init__(self):
- self.auto = False
- with open("/etc/default/discover-overlay", encoding="utf-8") as f:
- content = f.readlines()
- for line in content:
- if line.startswith("AUTO_LAUNCH_DISCOVER_OVERLAY="):
- self.auto = int(line.split("=")[1]) > 0
- log.info("Bazzite Autostart info : %s",
- self.auto)
-
- def set_autostart(self, enable):
- """Set or Unset auto-start state"""
- if enable and not self.auto:
- self.change_file("1")
- elif not enable and self.auto:
- self.change_file("0")
- self.auto = enable
-
- def change_file(self, value):
- """Alter bazzite config via pkexec and sed"""
- root = ''
- if shutil.which('pkexec'):
- root = 'pkexec'
- else:
- log.error("No ability to request root privs. Cancel")
- return
- command = f" sed -i 's/AUTO_LAUNCH_DISCOVER_OVERLAY=./AUTO_LAUNCH_DISCOVER_OVERLAY={value}/g' /etc/default/discover-overlay"
- command_with_permissions = root + command
- os.system(command_with_permissions)
-
- def is_auto(self):
- """Check if it's already set to auto-start"""
- return self.auto
diff --git a/discover_overlay/connection_state.py b/discover_overlay/connection_state.py
new file mode 100644
index 0000000..d6b6d0e
--- /dev/null
+++ b/discover_overlay/connection_state.py
@@ -0,0 +1,13 @@
+"""Enum of states of discover connection to discord"""
+
+from enum import Enum
+
+
+class ConnectionState(Enum):
+ """Possible states of service"""
+
+ NO_DISCORD = 0 # We havn't managed to reach Discord on localhost
+ DISCORD_INVALID = 1 # Port connection works but turns away RPC.
+ NO_VOICE_CHAT = 2 # We're connected but the user is not in a room
+ VOICE_CHAT_NOT_CONNECTED = 3 # We've chosen a room but not successfully connected to it yet (or connection has degraded)
+ CONNECTED = 4 # Connected and working
diff --git a/discover_overlay/css_helper.py b/discover_overlay/css_helper.py
new file mode 100644
index 0000000..a75871d
--- /dev/null
+++ b/discover_overlay/css_helper.py
@@ -0,0 +1,56 @@
+# This program is free software: you can redistribute it and/or modify
+# it under the terms of the GNU General Public License as published by
+# the Free Software Foundation, either version 3 of the License, or
+# (at your option) any later version.
+#
+# This program is distributed in the hope that it will be useful,
+# but WITHOUT ANY WARRANTY; without even the implied warranty of
+# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
+# GNU General Public License for more details.
+#
+# You should have received a copy of the GNU General Public License
+# along with this program. If not, see .
+"""Functions to assist font picking"""
+import gi
+import json
+
+gi.require_version("Gtk", "4.0")
+from gi.repository import Gtk, Pango
+
+
+# https://toshiocp.github.io/Gtk4-tutorial/sec23.html
+# TODO Weights, Italics
+def desc_to_css_font(desc):
+ """Formats a font description into a CSS rule"""
+ if desc.get_size_is_absolute():
+ size = f"{desc.get_size() / Pango.SCALE}px"
+ else:
+ size = f"{desc.get_size() / Pango.SCALE}pt"
+ mods = ""
+ family = desc.get_family()
+ font = f'{size} {mods} "{family}"'
+ return font
+
+
+def font_string_to_css_font_string(string_in):
+ """Takes a string of uncertain origin and feeds it into a
+ Gtk.FontButton in the hopes of turning it into a font
+ description, then turning that into a CSS rule"""
+ if string_in[0].isnumeric(): # If it starts with a number it is Probably correct
+ return string_in
+ # It might be an old-style font string...
+ fb = Gtk.FontButton()
+ fb.set_font(string_in)
+ return desc_to_css_font(fb.get_font_desc())
+
+
+def col_to_css(col):
+ """Convert a JSON-encoded string or a tuple into a CSS colour"""
+ if isinstance(col, str):
+ col = json.loads(col)
+ assert len(col) == 4
+ red = int(col[0] * 255)
+ green = int(col[1] * 255)
+ blue = int(col[2] * 255)
+ alpha = col[3]
+ return f"rgba({red},{green},{blue},{alpha:2.2f})"
diff --git a/discover_overlay/discord_connector.py b/discover_overlay/discord_connector.py
index c13bc81..bbf2994 100644
--- a/discover_overlay/discord_connector.py
+++ b/discover_overlay/discord_connector.py
@@ -27,6 +27,7 @@
import calendar
import websocket
import requests
+from .connection_state import ConnectionState
from gi.repository import GLib
@@ -44,21 +45,17 @@ def __init__(self, discover):
self.discover = discover
self.websocket = None
self.access_token = discover.config().get(
- "cache", "access_token", fallback=None)
+ "cache", "access_token", fallback=None
+ )
self.oauth_token = "207646673902501888"
self.guilds = {}
self.channels = {}
self.user = {}
- self.userlist = {}
- self.in_room = []
self.current_guild = "0"
self.current_voice = "0"
self.current_text = "0"
self.current_text_guild = "0"
- self.list_altered = False
- self.text_altered = False
- self.text = []
self.authed = False
self.last_rate_limit_send = 0
self.muted = False
@@ -68,6 +65,11 @@ def __init__(self, discover):
self.rate_limited_channels = []
self.reconnect_cb = None
+ self.reconnect_time = 5
+
+ self.rate_limit = None
+
+ self.state = ConnectionState.NO_DISCORD
def get_access_token_stage1(self):
"""
@@ -78,13 +80,12 @@ def get_access_token_stage1(self):
return
cmd = {
"cmd": "AUTHORIZE",
- "args":
- {
+ "args": {
"client_id": self.oauth_token,
"scopes": ["rpc", "messages.read", "rpc.notifications.read"],
"prompt": "none",
},
- "nonce": "deadbeef"
+ "nonce": "deadbeef",
}
self.websocket.send(json.dumps(cmd))
@@ -98,8 +99,8 @@ def get_access_token_stage2(self, code1):
try:
jsonresponse = json.loads(response.text)
except requests.exceptions.Timeout:
- # TODO This probably needs a retry, not a quit
- jsonresponse = {}
+ self.websocket.close()
+ return
except requests.exceptions.TooManyRedirects:
jsonresponse = {}
except json.JSONDecodeError:
@@ -118,16 +119,18 @@ def set_channel(self, channel, guild, need_req=True):
Set currently active voice channel
"""
if not channel:
+ self.set_state(ConnectionState.NO_VOICE_CHAT)
if self.current_voice:
self.unsub_voice_channel(self.current_voice)
self.current_voice = "0"
self.current_guild = "0"
self.discover.voice_overlay.set_blank()
- self.in_room = []
return
if channel != self.current_voice:
+ self.set_state(ConnectionState.VOICE_CHAT_NOT_CONNECTED)
if self.current_voice != "0":
self.unsub_voice_channel(self.current_voice)
+ self.discover.voice_overlay.set_blank()
self.sub_voice_channel(channel)
self.current_voice = channel
self.current_guild = guild
@@ -152,72 +155,51 @@ def set_text_channel(self, channel, guild, need_req=True):
if need_req:
self.req_channel_details(channel)
- def set_in_room(self, userid, present):
- """
- Set user currently in given room
- """
- if present:
- if userid not in self.in_room:
- self.in_room.append(userid)
- else:
- if userid in self.in_room:
- self.in_room.remove(userid)
-
def add_text(self, message):
"""
Add line of text to text list. Assumes the message is from the correct room
"""
utc_time = None
try:
- utc_time = time.strptime(
- message["timestamp"], "%Y-%m-%dT%H:%M:%S.%f%z")
+ utc_time = time.strptime(message["timestamp"], "%Y-%m-%dT%H:%M:%S.%f%z")
except ValueError:
- utc_time = time.strptime(
- message["timestamp"], "%Y-%m-%dT%H:%M:%S%z")
+ utc_time = time.strptime(message["timestamp"], "%Y-%m-%dT%H:%M:%S%z")
epoch_time = calendar.timegm(utc_time)
username = message["author"]["username"]
- if ("nick" in message and message['nick'] and len(message["nick"]) > 1
- and 'object Object' not in json.dumps(message["nick"])):
+ if (
+ "nick" in message
+ and message["nick"]
+ and len(message["nick"]) > 1
+ and "object Object" not in json.dumps(message["nick"])
+ ):
username = message["nick"]
colour = "#ffffff"
if "author_color" in message:
colour = message["author_color"]
- self.text.append({'id': message["id"],
- 'content': self.get_message_from_message(message),
- 'nick': username,
- 'nick_col': colour,
- 'time': epoch_time,
- 'attach': self.get_attachment_from_message(message),
- })
- self.text_altered = True
+ self.discover.text_overlay.new_line(
+ {
+ "id": message["id"],
+ "content": self.get_message_from_message(message),
+ "nick": username,
+ "nick_col": colour,
+ "time": epoch_time,
+ "attach": self.get_attachment_from_message(message),
+ }
+ )
def update_text(self, message_in):
"""
Update a line of text
"""
- for idx, message in enumerate(self.text):
- if message['id'] == message_in['id']:
- new_message = {'id': message['id'],
- 'content': self.get_message_from_message(message_in),
- 'nick': message['nick'],
- 'nick_col': message['nick_col'],
- 'time': message['time'],
- 'attach': message['attach']}
- self.text[idx] = new_message
- self.text_altered = True
- return
+ self.discover.text_overlay.update_message(message_in["id"], message_in)
def delete_text(self, message_in):
"""
Delete a line of text
"""
- for idx, message in enumerate(self.text):
- if message['id'] == message_in['id']:
- del self.text[idx]
- self.text_altered = True
- return
+ self.discover.text_overlay.update_message(message_in["id"])
def get_message_from_message(self, message):
"""
@@ -246,38 +228,13 @@ def get_attachment_from_message(self, message):
return message["attachments"]
return None
- def update_user(self, user):
- """
- Update user information
- Pass along our custom user information from version to version
- """
- if user["id"] in self.userlist:
- olduser = self.userlist[user["id"]]
- if "mute" not in user and "mute" in olduser:
- user["mute"] = olduser["mute"]
- if "deaf" not in user and "deaf" in olduser:
- user["deaf"] = olduser["deaf"]
- if "speaking" not in user and "speaking" in olduser:
- user["speaking"] = olduser["speaking"]
- if "nick" not in user and "nick" in olduser:
- user["nick"] = olduser["nick"]
- if "lastspoken" not in user and "lastspoken" in olduser:
- user["lastspoken"] = olduser["lastspoken"]
- if olduser["avatar"] != user["avatar"]:
- self.discover.voice_overlay.delete_avatar(user["id"])
- if "lastspoken" not in user: # Still nothing?
- user["lastspoken"] = 0 # EEEEPOOCH EEEEEPOCH! BELIEVE MEEEE
- if "speaking" not in user:
- user["speaking"] = False
- self.userlist[user["id"]] = user
-
def on_message(self, message):
"""
Recieve websocket message super-function
"""
j = json.loads(message)
if j["cmd"] == "AUTHORIZE":
- if 'data' in j and 'code' in j['data']:
+ if "data" in j and "code" in j["data"]:
self.get_access_token_stage2(j["data"]["code"])
else:
log.error("Authorization rejected")
@@ -287,56 +244,79 @@ def on_message(self, message):
if j["evt"] == "READY":
self.req_auth()
elif j["evt"] == "VOICE_STATE_UPDATE":
- self.list_altered = True
thisuser = j["data"]["user"]
nick = j["data"]["nick"]
thisuser["nick"] = nick
- mute = (j["data"]["voice_state"]["mute"] or
- j["data"]["voice_state"]["self_mute"] or
- j["data"]["voice_state"]["suppress"])
- deaf = j["data"]["voice_state"]["deaf"] or j["data"]["voice_state"]["self_deaf"]
- thisuser["mute"] = mute
- thisuser["deaf"] = deaf
- if self.current_voice != "0":
- self.update_user(thisuser)
- self.set_in_room(thisuser["id"], True)
+ mute = (
+ j["data"]["voice_state"]["mute"]
+ or j["data"]["voice_state"]["self_mute"]
+ or j["data"]["voice_state"]["suppress"]
+ )
+ deaf = (
+ j["data"]["voice_state"]["deaf"]
+ or j["data"]["voice_state"]["self_deaf"]
+ )
+ if "mute" not in thisuser or thisuser["mute"] != mute:
+ thisuser["mute"] = mute
+ self.discover.voice_overlay.set_mute(thisuser["id"], mute)
+ if "deaf" not in thisuser or thisuser["deaf"] != deaf:
+ thisuser["deaf"] = deaf
+ self.discover.voice_overlay.set_deaf(thisuser["id"], deaf)
+ self.discover.voice_overlay.update_user(thisuser)
elif j["evt"] == "VOICE_STATE_CREATE":
- self.list_altered = True
thisuser = j["data"]["user"]
nick = j["data"]["nick"]
thisuser["nick"] = nick
- self.update_user(thisuser)
+ mute = (
+ j["data"]["voice_state"]["mute"]
+ or j["data"]["voice_state"]["self_mute"]
+ or j["data"]["voice_state"]["suppress"]
+ )
+ deaf = (
+ j["data"]["voice_state"]["deaf"]
+ or j["data"]["voice_state"]["self_deaf"]
+ )
+ if "mute" not in thisuser or thisuser["mute"] != mute:
+ thisuser["mute"] = mute
+ self.discover.voice_overlay.set_mute(thisuser["id"], mute)
+ if "deaf" not in thisuser or thisuser["deaf"] != deaf:
+ thisuser["deaf"] = deaf
+ self.discover.voice_overlay.set_deaf(thisuser["id"], deaf)
# We've joined a room... but where?
if j["data"]["user"]["id"] == self.user["id"]:
self.find_user()
- self.userlist[thisuser["id"]]["lastspoken"] = time.perf_counter()
+ self.discover.voice_overlay.update_user(thisuser)
elif j["evt"] == "VOICE_STATE_DELETE":
- self.list_altered = True
- self.set_in_room(j["data"]["user"]["id"], False)
if j["data"]["user"]["id"] == self.user["id"]:
- self.in_room = []
+ # We've left the room, empty overlay and ask where we are now
self.find_user()
- self.discover.voice_overlay.set_channel_title(None)
- self.discover.voice_overlay.set_channel_icon(None)
- # User might have been forcibly moved room
+ self.discover.voice_overlay.set_blank()
+ else:
+ # Remove this user from overlay
+ self.discover.voice_overlay.del_user(thisuser)
elif j["evt"] == "SPEAKING_START":
- self.list_altered = True
- self.userlist[j["data"]["user_id"]]["speaking"] = True
- self.userlist[j["data"]["user_id"]]["lastspoken"] = time.perf_counter()
- self.set_in_room(j["data"]["user_id"], True)
+ self.discover.voice_overlay.set_talking(j["data"]["user_id"], True)
elif j["evt"] == "SPEAKING_STOP":
- self.list_altered = True
- if j["data"]["user_id"] in self.userlist:
- self.userlist[j["data"]["user_id"]]["speaking"] = False
- self.set_in_room(j["data"]["user_id"], True)
+ self.discover.voice_overlay.set_talking(j["data"]["user_id"], False)
elif j["evt"] == "VOICE_CHANNEL_SELECT":
if j["data"]["channel_id"]:
- self.set_channel(j["data"]["channel_id"],
- j["data"]["guild_id"])
+ self.set_channel(j["data"]["channel_id"], j["data"]["guild_id"])
else:
self.set_channel(None, None)
elif j["evt"] == "VOICE_CONNECTION_STATUS":
- self.discover.voice_overlay.set_connection_status(j["data"])
+ state = j["data"]["state"]
+ if (
+ state == "NO_ROUTE"
+ or state == "VOICE_DISCONNECTED"
+ or state == "ICE_CHECKING"
+ or state == "AWAITING_ENDPOINT"
+ or state == "AUTHENTICATING"
+ or state == "VOICE_CONNECTING"
+ or state == "CONNECTING"
+ ):
+ self.set_state(ConnectionState.VOICE_CHAT_NOT_CONNECTED)
+ elif state == "CONNECTED" or state == "VOICE_CONNECTED":
+ self.set_state(ConnectionState.CONNECTED)
elif j["evt"] == "MESSAGE_CREATE":
if self.current_text == j["data"]["channel_id"]:
self.add_text(j["data"]["message"])
@@ -348,39 +328,37 @@ def on_message(self, message):
self.delete_text(j["data"]["message"])
elif j["evt"] == "CHANNEL_CREATE":
# We haven't been told what guild this is in
- self.req_channel_details(j["data"]["id"], 'new')
+ self.req_channel_details(j["data"]["id"], "new")
elif j["evt"] == "NOTIFICATION_CREATE":
self.discover.notification_overlay.add_notification_message(j)
elif j["evt"] == "VOICE_SETTINGS_UPDATE":
- source = j['data']['input']['device_id']
- sink = j['data']['output']['device_id']
- if sink == 'default':
- for available_sink in j['data']['output']['available_devices']:
- if available_sink['id'] == 'default':
- sink = available_sink['name'][9:]
- if source == 'default':
- for available_source in j['data']['input']['available_devices']:
- if available_source['id'] == 'default':
- source = available_source['name'][9:]
+ source = j["data"]["input"]["device_id"]
+ sink = j["data"]["output"]["device_id"]
+ if sink == "default":
+ for available_sink in j["data"]["output"]["available_devices"]:
+ if available_sink["id"] == "default":
+ sink = available_sink["name"][9:]
+ if source == "default":
+ for available_source in j["data"]["input"]["available_devices"]:
+ if available_source["id"] == "default":
+ source = available_source["name"][9:]
self.discover.audio_assist.set_devices(sink, source)
else:
log.warning(j)
return
elif j["cmd"] == "AUTHENTICATE":
+ self.set_state(ConnectionState.NO_VOICE_CHAT)
+
if j["evt"] == "ERROR":
self.access_token = None
self.get_access_token_stage1()
return
else:
- self.discover.config_set(
- "cache", "access_token", self.access_token)
+ self.discover.config_set("cache", "access_token", self.access_token)
self.req_guilds()
self.user = j["data"]["user"]
- log.info(
- "ID is %s", self.user["id"])
- log.info(
- "Logged in as %s", self.user["username"])
+ log.info("Successfully connected to a Discord client")
self.authed = True
self.on_connected()
return
@@ -398,13 +376,13 @@ def on_message(self, message):
return
elif j["cmd"] == "GET_CHANNELS":
- if j['evt'] == 'ERROR':
- log.error('%s', j['data']['message'])
+ if j["evt"] == "ERROR":
+ log.error("%s", j["data"]["message"])
return
- self.guilds[j['nonce']]["channels"] = j["data"]["channels"]
+ self.guilds[j["nonce"]]["channels"] = j["data"]["channels"]
for channel in j["data"]["channels"]:
- channel['guild_id'] = j['nonce']
- channel['guild_name'] = self.guilds[j['nonce']]["name"]
+ channel["guild_id"] = j["nonce"]
+ channel["guild_name"] = self.guilds[j["nonce"]]["name"]
self.channels[channel["id"]] = channel
if channel["type"] == 2:
self.req_channel_details(channel["id"])
@@ -412,47 +390,47 @@ def on_message(self, message):
return
elif j["cmd"] == "SUBSCRIBE":
# Only log errors
- if j['evt']:
+ if j["evt"]:
log.warning(j)
return
elif j["cmd"] == "UNSUBSCRIBE":
return
elif j["cmd"] == "GET_SELECTED_VOICE_CHANNEL":
- if 'data' in j and j['data'] and 'id' in j['data']:
- self.set_channel(j['data']['id'], j['data']['guild_id'])
- self.discover.voice_overlay.set_channel_title(
- j["data"]["name"])
- if (self.current_guild in self.guilds and
- 'icon_url' in self.guilds[self.current_guild]):
+ if "data" in j and j["data"] and "id" in j["data"]:
+ self.set_channel(j["data"]["id"], j["data"]["guild_id"])
+ self.discover.voice_overlay.set_channel_title(j["data"]["name"])
+ if (
+ self.current_guild in self.guilds
+ and "icon_url" in self.guilds[self.current_guild]
+ ):
self.discover.voice_overlay.set_channel_icon(
- self.guilds[self.current_guild]['icon_url'])
+ self.guilds[self.current_guild]["icon_url"]
+ )
else:
self.discover.voice_overlay.set_channel_icon(None)
- self.list_altered = True
- self.in_room = []
- for u in j['data']['voice_states']:
+ for u in j["data"]["voice_states"]:
thisuser = u["user"]
nick = u["nick"]
thisuser["nick"] = nick
- mute = (u["voice_state"]["mute"] or
- u["voice_state"]["self_mute"] or
- u["voice_state"]["suppress"])
+ mute = (
+ u["voice_state"]["mute"]
+ or u["voice_state"]["self_mute"]
+ or u["voice_state"]["suppress"]
+ )
deaf = u["voice_state"]["deaf"] or u["voice_state"]["self_deaf"]
thisuser["mute"] = mute
thisuser["deaf"] = deaf
- self.update_user(thisuser)
- self.set_in_room(thisuser["id"], True)
+ self.discover.voice_overlay.update_user(thisuser)
return
elif j["cmd"] == "GET_CHANNEL":
if j["evt"] == "ERROR":
- log.info(
- "Could not get room")
+ log.info("Could not get room")
return
if j["nonce"] == "new":
self.req_channels(j["data"]["guild_id"])
if j["data"]["type"] == 0: # Text channel
if self.current_text == j["data"]["id"]:
- self.text = []
+ self.discover.text_overlay.set_blank()
for message in j["data"]["messages"]:
self.add_text(message)
@@ -460,18 +438,18 @@ def on_message(self, message):
elif j["cmd"] == "SELECT_VOICE_CHANNEL":
return
elif j["cmd"] == "SET_VOICE_SETTINGS":
- self.muted = j['data']['mute']
- self.deafened = j['data']['deaf']
+ # Keep this for toggling mute from RPC
+ self.muted = j["data"]["mute"]
+ self.deafened = j["data"]["deaf"]
return
elif j["cmd"] == "GET_VOICE_SETTINGS":
return
log.warning(j)
def dump_channel_data(self):
- """ Write all channel data out to file"""
- with open(self.discover.channel_file, 'w', encoding="utf-8") as f:
- f.write(json.dumps(
- {'channels': self.channels, 'guild': self.guilds}))
+ """Write all channel data out to file"""
+ with open(self.discover.channel_file, "w", encoding="utf-8") as f:
+ f.write(json.dumps({"channels": self.channels, "guild": self.guilds}))
def on_connected(self):
"""
@@ -497,7 +475,7 @@ def on_close(self):
GLib.source_remove(self.socket_watch)
self.socket_watch = None
self.websocket = None
- self.update_overlays_from_data()
+ self.blank_overlays()
self.current_voice = "0"
self.schedule_reconnect()
@@ -507,10 +485,8 @@ def req_auth(self):
"""
cmd = {
"cmd": "AUTHENTICATE",
- "args": {
- "access_token": self.access_token
- },
- "nonce": "deadbeef"
+ "args": {"access_token": self.access_token},
+ "nonce": "deadbeef",
}
self.websocket.send(json.dumps(cmd))
@@ -518,22 +494,16 @@ def req_guild(self, guild_id, nonce):
"""
Request info on one guild
"""
- cmd = {
- "cmd": "GET_GUILD",
- "args": {"guild_id": guild_id},
- "nonce": nonce
- }
+ cmd = {"cmd": "GET_GUILD", "args": {"guild_id": guild_id}, "nonce": nonce}
self.websocket.send(json.dumps(cmd))
def req_guilds(self):
"""
Request all guilds information for logged in user
"""
- cmd = {
- "cmd": "GET_GUILDS",
- "args": {},
- "nonce": "deadbeef"
- }
+ if not self.websocket:
+ return
+ cmd = {"cmd": "GET_GUILDS", "args": {}, "nonce": "deadbeef"}
self.websocket.send(json.dumps(cmd))
def req_channels(self, guild):
@@ -555,13 +525,7 @@ def req_channel_details(self, channel, nonce=None):
return
if not nonce:
nonce = channel
- cmd = {
- "cmd": "GET_CHANNEL",
- "args": {
- "channel_id": channel
- },
- "nonce": nonce
- }
+ cmd = {"cmd": "GET_CHANNEL", "args": {"channel_id": channel}, "nonce": nonce}
self.websocket.send(json.dumps(cmd))
def find_user(self):
@@ -569,37 +533,21 @@ def find_user(self):
Find the user
"""
- cmd = {
- "cmd": "GET_SELECTED_VOICE_CHANNEL",
- "args": {
-
- },
- "nonce": "test"
- }
+ cmd = {"cmd": "GET_SELECTED_VOICE_CHANNEL", "args": {}, "nonce": "test"}
self.websocket.send(json.dumps(cmd))
def sub_raw(self, event, args, nonce):
"""
Subscribe to event helper function
"""
- cmd = {
- "cmd": "SUBSCRIBE",
- "args": args,
- "evt": event,
- "nonce": nonce
- }
+ cmd = {"cmd": "SUBSCRIBE", "args": args, "evt": event, "nonce": nonce}
self.websocket.send(json.dumps(cmd))
def unsub_raw(self, event, args, nonce):
"""
Subscribe to event helper function
"""
- cmd = {
- "cmd": "UNSUBSCRIBE",
- "args": args,
- "evt": event,
- "nonce": nonce
- }
+ cmd = {"cmd": "UNSUBSCRIBE", "args": args, "evt": event, "nonce": nonce}
self.websocket.send(json.dumps(cmd))
def sub_server(self):
@@ -669,32 +617,24 @@ def get_voice_settings(self):
"""
Request a recent version of voice settings
"""
- cmd = {
- "cmd": "GET_VOICE_SETTINGS",
- "args": {},
- "nonce": "deadbeef"
- }
+ cmd = {"cmd": "GET_VOICE_SETTINGS", "args": {}, "nonce": "deadbeef"}
if self.websocket:
self.websocket.send(json.dumps(cmd))
def set_mute(self, muted):
- """ Set client muted status """
+ """Set client muted status"""
cmd = {
"cmd": "SET_VOICE_SETTINGS",
"args": {"mute": muted},
- "nonce": "deadbeef"
+ "nonce": "deadbeef",
}
if self.websocket:
self.websocket.send(json.dumps(cmd))
return False
def set_deaf(self, deaf):
- """ Set client deafened status """
- cmd = {
- "cmd": "SET_VOICE_SETTINGS",
- "args": {"deaf": deaf},
- "nonce": "deadbeef"
- }
+ """Set client deafened status"""
+ cmd = {"cmd": "SET_VOICE_SETTINGS", "args": {"deaf": deaf}, "nonce": "deadbeef"}
if self.websocket:
self.websocket.send(json.dumps(cmd))
return False
@@ -705,11 +645,8 @@ def change_voice_room(self, room_id):
"""
cmd = {
"cmd": "SELECT_VOICE_CHANNEL",
- "args": {
- "channel_id": room_id,
- "force": True
- },
- "nonce": "deadbeef"
+ "args": {"channel_id": room_id, "force": True},
+ "nonce": "deadbeef",
}
if self.websocket:
self.websocket.send(json.dumps(cmd))
@@ -720,50 +657,35 @@ def change_text_room(self, room_id):
"""
cmd = {
"cmd": "SELECT_TEXT_CHANNEL",
- "args": {
- "channel_id": room_id
- },
- "nonce": "deadbeef"
+ "args": {"channel_id": room_id},
+ "nonce": "deadbeef",
}
if self.websocket:
self.websocket.send(json.dumps(cmd))
- def update_overlays_from_data(self):
- """Send new data out to overlay windows"""
- if self.websocket is None:
- self.discover.voice_overlay.set_blank()
- if self.discover.text_overlay:
- self.discover.text_overlay.set_blank()
- if self.discover.notification_overlay:
- self.discover.notification_overlay.set_blank()
- return
- newlist = []
- for userid in self.in_room:
- newlist.append(self.userlist[userid])
- self.discover.voice_overlay.set_user_list(newlist, self.list_altered)
- self.list_altered = False
- # Update text list
- if self.discover.text_overlay.popup_style:
- self.text_altered = True
- if self.text_altered:
- self.discover.text_overlay.set_text_list(
- self.text, self.text_altered)
- self.text_altered = False
-
- if self.authed and len(self.rate_limited_channels) > 0:
- now = time.time()
- if self.last_rate_limit_send < now - 60:
- guild = self.rate_limited_channels.pop()
-
- cmd = {
- "cmd": "GET_CHANNELS",
- "args": {
- "guild_id": guild
- },
- "nonce": guild
- }
- self.websocket.send(json.dumps(cmd))
- self.last_rate_limit_send = now
+ def channel_rate_limit(self):
+ """Called regularly to pull in any required channels"""
+ if self.websocket and self.authed and len(self.rate_limited_channels) > 0:
+ guild = self.rate_limited_channels.pop()
+ log.info("Getting guild : %s", guild)
+ cmd = {
+ "cmd": "GET_CHANNELS",
+ "args": {"guild_id": guild},
+ "nonce": guild,
+ }
+ self.websocket.send(json.dumps(cmd))
+
+ continue_rate_limit = len(self.rate_limited_channels) > 0
+ if not continue_rate_limit:
+ self.rate_limit = None
+ return continue_rate_limit
+
+ def blank_overlays(self):
+ """Clear information from overlays"""
+ self.discover.voice_overlay.set_blank()
+ if self.discover.text_overlay:
+ self.discover.text_overlay.set_blank()
+ return
def start_listening_text(self, channel):
"""
@@ -787,13 +709,24 @@ def request_text_rooms_for_guild(self, guild_id):
"""
if guild_id == 0:
return
- self.rate_limited_channels.append(guild_id)
+ if guild_id not in self.rate_limited_channels:
+ self.rate_limited_channels.append(guild_id)
+ if not self.rate_limit:
+ # Run once now and schedule for 15 seconds.
+ # Any others added suddently will have to wait, or timeout will clear eventually
+ self.channel_rate_limit()
+ self.rate_limit = GLib.timeout_add_seconds(15, self.channel_rate_limit)
def schedule_reconnect(self):
"""Set a timer to attempt reconnection"""
if self.reconnect_cb is None:
- log.info("Scheduled a reconnect")
- self.reconnect_cb = GLib.timeout_add_seconds(60, self.connect)
+ log.info("Scheduled a reconnect in %s seconds", self.reconnect_time)
+ self.reconnect_cb = GLib.timeout_add_seconds(
+ self.reconnect_time, self.connect
+ )
+ self.reconnect_time += 5
+ if self.reconnect_time > 60:
+ self.reconnect_time = 60
else:
log.error("Reconnect already scheduled")
@@ -803,6 +736,7 @@ def connect(self):
Should not throw simply for being unable to connect, only for more serious issues
"""
+ self.authed = False
log.info("Connecting...")
if self.websocket:
log.warning("Already connected?")
@@ -814,7 +748,7 @@ def connect(self):
self.websocket = websocket.create_connection(
f"ws://127.0.0.1:6463/?v=1&client_id={self.oauth_token}",
origin="http://localhost:3000",
- timeout=0.1
+ timeout=0.2,
)
if self.socket_watch:
GLib.source_remove(self.socket_watch)
@@ -822,8 +756,9 @@ def connect(self):
self.websocket.sock,
GLib.PRIORITY_DEFAULT_IDLE,
GLib.IOCondition.HUP | GLib.IOCondition.IN | GLib.IOCondition.ERR,
- self.socket_glib
+ self.socket_glib,
)
+ self.reconnect_time = 5
except ConnectionError as _error:
self.schedule_reconnect()
@@ -839,13 +774,34 @@ def socket_glib(self, _fd, condition):
if not self.websocket:
# Connection was closed in the meantime
break
- recv, _w, _e = select.select(
- (self.websocket.sock,), (), (), 0)
- except (websocket.WebSocketConnectionClosedException, json.decoder.JSONDecodeError):
+ recv, _w, _e = select.select((self.websocket.sock,), (), (), 0)
+ except websocket.WebSocketConnectionClosedException as e:
+ log.error("Connector Websocket closed : %s", e)
+ self.on_close()
+ break
+ except json.decoder.JSONDecodeError as e:
+ log.error("Invalid JSON from Discord : %s", e)
+ log.error("This is probably a modded client...")
+ self.set_state(ConnectionState.DISCORD_INVALID)
+ # It's VERY unlikely this will be fixed in sensible time frame
+ # So set a high retry time to limit wasted CPU
+ self.reconnect_time = 60
self.on_close()
break
- self.update_overlays_from_data()
else:
- self.update_overlays_from_data()
+ self.blank_overlays()
+ self.authed = False
+ self.set_state(ConnectionState.NO_DISCORD)
return False
return True
+
+ def set_state(self, state):
+ """Passes state of play to voice overlay for user feedback"""
+ if ( # This state remains until a successful connection
+ state == ConnectionState.NO_DISCORD
+ and self.state == ConnectionState.DISCORD_INVALID
+ ):
+ return
+ if self.state != state:
+ self.state = state
+ self.discover.voice_overlay.set_connection_status(state)
diff --git a/discover_overlay/discover_overlay.py b/discover_overlay/discover_overlay.py
index 4e75283..4ca588b 100755
--- a/discover_overlay/discover_overlay.py
+++ b/discover_overlay/discover_overlay.py
@@ -11,28 +11,33 @@
# You should have received a copy of the GNU General Public License
# along with this program. If not, see .
"""Main application class"""
+
import gettext
import os
import sys
import re
import traceback
import logging
-import json
import signal
+import importlib_resources
from configparser import ConfigParser
+from ctypes import CDLL
+from _version import __version__
+
+CDLL("libgtk4-layer-shell.so")
+
import gi
-import pkg_resources
-from .settings_window import MainSettingsWindow
+from .overlay import OverlayWindow
+from .settings_window import Settings
from .voice_overlay import VoiceOverlayWindow
from .text_overlay import TextOverlayWindow
from .notification_overlay import NotificationOverlayWindow
from .discord_connector import DiscordConnector
from .audio_assist import DiscoverAudioAssist
-gi.require_version("Gtk", "3.0")
-# pylint: disable=wrong-import-position,wrong-import-order
-from gi.repository import Gtk, GLib, Gio # nopep8
+gi.require_version("Gtk", "4.0")
+from gi.repository import Gtk, GLib, Gio
try:
from xdg.BaseDirectory import xdg_config_home
@@ -40,15 +45,29 @@
from xdg import XDG_CONFIG_HOME as xdg_config_home
log = logging.getLogger(__name__)
-t = gettext.translation(
- 'default', pkg_resources.resource_filename('discover_overlay', 'locales'), fallback=True)
-_ = t.gettext
+with importlib_resources.as_file(
+ importlib_resources.files("discover_overlay") / "locales"
+) as path:
+ t = gettext.translation(
+ "default",
+ path,
+ fallback=True,
+ )
+ _ = t.gettext
class Discover:
"""Main application class"""
def __init__(self, rpc_file, config_file, channel_file, debug_file, args):
+ unsupported_desktops = ["gnome", "weston", "gamescope"]
+ if os.getenv("XDG_SESSION_DESKTOP", "none").lower() in unsupported_desktops:
+ log.warning(
+ "GTK Layer Shell is not supported on this Wayland compositor. Removing WAYLAND_DISPLAY to fallback to X11"
+ )
+ os.unsetenv("WAYLAND_DISPLAY")
+ # pylint: disable=E1120
+ Gtk.init()
self.mix_settings = False
self.ind = None
self.tray = None
@@ -61,18 +80,21 @@ def __init__(self, rpc_file, config_file, channel_file, debug_file, args):
self.channel_file = channel_file
self.config_file = config_file
self.rpc_file = rpc_file
+ self.skip_config_read = False
self.do_args(args, True)
if "GAMESCOPE_WAYLAND_DISPLAY" in os.environ:
log.info(
- "GameScope session detected. Enabling steam and gamescope integration")
+ "GameScope session detected. Enabling steam and gamescope integration"
+ )
self.steamos = True
self.show_settings_delay = True
self.mix_settings = True
+
+ # pylint: disable=E1120
settings = Gtk.Settings.get_default()
if settings:
- settings.set_property(
- "gtk-application-prefer-dark-theme", Gtk.true)
+ settings.set_property("gtk-application-prefer-dark-theme", True)
self.create_gui()
@@ -91,7 +113,9 @@ def __init__(self, rpc_file, config_file, channel_file, debug_file, args):
self.config_changed()
- Gtk.main()
+ # pylint: disable=E1120
+ while len(Gtk.Window.get_toplevels()) > 0:
+ GLib.MainContext.iteration(GLib.MainContext.default(), True)
def do_args(self, data, normal_close):
"""
@@ -139,16 +163,19 @@ def do_args(self, data, normal_close):
self.connection.request_text_rooms_for_guild(match.group(1))
def exit(self):
+ """Kills self, works from threads"""
os.kill(os.getpid(), signal.SIGTERM)
def config_set(self, context, key, value):
- """Set a config value and save to disk"""
+ """Set a config value and save to disk. Avoid re-reading automatically"""
config = self.config()
+ self.skip_config_read = True
if not context in config.sections():
config.add_section(context)
config.set(context, key, value)
- with open(self.config_file, 'w', encoding="utf-8") as file:
+ with open(self.config_file, "w", encoding="utf-8") as file:
config.write(file)
+ self.skip_config_read = False
def config(self):
"""Read config from disk"""
@@ -169,224 +196,46 @@ def config_changed(self, _a=None, _b=None, _c=None, _d=None):
"""
Called when the config file has been altered
"""
+ if self.skip_config_read:
+ log.warning("Config skipped")
+ return
# Read new config
config = self.config()
- # Set Voice overlay options
- self.voice_overlay.set_align_x(config.getboolean(
- "main", "rightalign", fallback=False))
- self.voice_overlay.set_align_y(
- config.getint("main", "topalign", fallback=1))
- self.voice_overlay.set_bg(json.loads(config.get(
- "main", "bg_col", fallback="[0.0,0.0,0.0,0.5]")))
- self.voice_overlay.set_fg(json.loads(config.get(
- "main", "fg_col", fallback="[1.0,1.0,1.0,1.0]")))
- self.voice_overlay.set_fg_hi(json.loads(config.get(
- "main", "fg_hi_col", fallback="[1.0,1.0,1.0,1.0]")))
- self.voice_overlay.set_tk(json.loads(config.get(
- "main", "tk_col", fallback="[0.0,0.7,0.0,1.0]")))
- self.voice_overlay.set_mt(json.loads(config.get(
- "main", "mt_col", fallback="[0.6,0.0,0.0,1.0]")))
- self.voice_overlay.set_mute_bg(json.loads(config.get(
- "main", "mt_bg_col", fallback="[0.0,0.0,0.0,0.5]")))
- self.voice_overlay.set_hi(json.loads(config.get(
- "main", "hi_col", fallback="[0.0,0.0,0.0,0.5]")))
- self.voice_overlay.set_bo(json.loads(config.get(
- "main", "bo_col", fallback="[0.0,0.0,0.0,0.0]")))
- self.voice_overlay.set_avatar_bg_col(json.loads(config.get(
- "main", "avatar_bg_col", fallback="[0.0,0.0,0.0,0.0]")))
- self.voice_overlay.set_avatar_size(
- config.getint("main", "avatar_size", fallback=48))
- self.voice_overlay.set_nick_length(
- config.getint("main", "nick_length", fallback=32))
- self.voice_overlay.set_icon_spacing(
- config.getint("main", "icon_spacing", fallback=8))
- self.voice_overlay.set_text_padding(
- config.getint("main", "text_padding", fallback=6))
- self.voice_overlay.set_text_baseline_adj(config.getint(
- "main", "text_baseline_adj", fallback=0))
- font = config.get("main", "font", fallback=None)
- title_font = config.get("main", "title_font", fallback=None)
- self.voice_overlay.set_square_avatar(config.getboolean(
- "main", "square_avatar", fallback=True))
- self.voice_overlay.set_only_speaking(config.getboolean(
- "main", "only_speaking", fallback=False))
- self.voice_overlay.set_only_speaking_grace_period(config.getint(
- "main", "only_speaking_grace", fallback=0))
- self.voice_overlay.set_highlight_self(config.getboolean(
- "main", "highlight_self", fallback=False))
- self.voice_overlay.set_icon_only(config.getboolean(
- "main", "icon_only", fallback=False))
- self.voice_overlay.set_vert_edge_padding(config.getint(
- "main", "vert_edge_padding", fallback=0))
- self.voice_overlay.set_horz_edge_padding(config.getint(
- "main", "horz_edge_padding", fallback=0))
- floating = config.getboolean("main", "floating", fallback=False)
- floating_x = config.getfloat("main", "floating_x", fallback=0.0)
- floating_y = config.getfloat("main", "floating_y", fallback=0.0)
- floating_w = config.getfloat("main", "floating_w", fallback=0.1)
- floating_h = config.getfloat("main", "floating_h", fallback=0.1)
- self.voice_overlay.set_order(
- config.getint("main", "order", fallback=0))
- self.voice_overlay.set_hide_on_mouseover(
- config.getboolean("main", "autohide", fallback=False))
- self.voice_overlay.set_mouseover_timer(
- config.getint("main", "autohide_timer", fallback=1))
-
- self.voice_overlay.set_horizontal(config.getboolean(
- "main", "horizontal", fallback=False))
- self.voice_overlay.set_overflow_style(
- config.getint("main", "overflow", fallback=0))
- self.voice_overlay.set_show_connection(config.getboolean(
- "main", "show_connection", fallback=False))
- self.voice_overlay.set_show_title(config.getboolean(
- "main", "show_title", fallback=False))
- self.voice_overlay.set_show_disconnected(config.getboolean(
- "main", "show_disconnected", fallback=False))
- self.voice_overlay.set_drawn_border_width(
- config.getint("main", "border_width", fallback=2))
- self.voice_overlay.set_icon_transparency(config.getfloat(
- "main", "icon_transparency", fallback=1.0))
- self.voice_overlay.set_show_avatar(
- config.getboolean("main", "show_avatar", fallback=True))
- self.voice_overlay.set_fancy_border(config.getboolean("main",
- "fancy_border", fallback=True))
- self.voice_overlay.set_show_dummy(config.getboolean("main",
- "show_dummy", fallback=False))
- self.voice_overlay.set_dummy_count(config.getint("main",
- "dummy_count", fallback=10))
-
- self.voice_overlay.set_monitor(
- config.get("main", "monitor", fallback="Any")
- )
-
- self.voice_overlay.set_enabled(True)
-
- self.voice_overlay.set_floating(
- floating, floating_x, floating_y, floating_w, floating_h)
-
- if font:
- self.voice_overlay.set_font(font)
- if title_font:
- self.voice_overlay.set_title_font(title_font)
+ hidden = config.getboolean("general", "hideoverlay", fallback=False)
- self.voice_overlay.set_fade_out_inactive(
- config.getboolean("main", "fade_out_inactive", fallback=False),
- config.getint("main", "inactive_time", fallback=10),
- config.getint("main", "inactive_fade_time", fallback=30),
- config.getfloat("main", "fade_out_limit", fallback=0.3)
- )
+ if not config.has_section("main"):
+ config["main"] = {}
+ voice_section = config["main"]
+ if self.voice_overlay_window:
+ self.voice_overlay_window.set_config(voice_section)
+ self.voice_overlay_window.set_hidden(hidden)
+ self.voice_overlay.set_config(voice_section)
# Set Text overlay options
- self.text_overlay.set_enabled(config.getboolean(
- "text", "enabled", fallback=False))
- self.text_overlay.set_align_x(config.getboolean(
- "text", "rightalign", fallback=True))
- self.text_overlay.set_align_y(
- config.getint("text", "topalign", fallback=2))
- floating = config.getboolean("text", "floating", fallback=True)
- floating_x = config.getfloat("text", "floating_x", fallback=0.0)
- floating_y = config.getfloat("text", "floating_y", fallback=0.0)
- floating_w = config.getfloat("text", "floating_w", fallback=0.1)
- floating_h = config.getfloat("text", "floating_h", fallback=0.1)
-
- channel = config.get("text", "channel", fallback="0")
- guild = config.get("text", "guild", fallback="0")
- self.connection.set_text_channel(channel, guild)
-
- self.font = config.get("text", "font", fallback=None)
- self.text_overlay.set_bg(json.loads(config.get(
- "text", "bg_col", fallback="[0.0,0.0,0.0,0.5]")))
- self.text_overlay.set_fg(json.loads(config.get(
- "text", "fg_col", fallback="[1.0,1.0,1.0,1.0]")))
- self.text_overlay.set_popup_style(config.getboolean(
- "text", "popup_style", fallback=False))
- self.text_overlay.set_text_time(
- config.getint("text", "text_time", fallback=30))
- self.text_overlay.set_show_attach(config.getboolean(
- "text", "show_attach", fallback=True))
- self.text_overlay.set_line_limit(
- config.getint("text", "line_limit", fallback=20))
- self.text_overlay.set_hide_on_mouseover(
- config.getboolean("text", "autohide", fallback=False))
- self.text_overlay.set_mouseover_timer(
- config.getint("text", "autohide_timer", fallback=1))
-
- self.text_overlay.set_monitor(
- config.get("text", "monitor", fallback="Any")
- )
- self.text_overlay.set_floating(
- floating, floating_x, floating_y, floating_w, floating_h)
-
- if self.font:
- self.text_overlay.set_font(self.font)
+ if not config.has_section("text"):
+ config["text"] = {}
+ text_section = config["text"]
+ if self.text_overlay_window:
+ self.text_overlay_window.set_config(text_section)
+ self.text_overlay_window.set_hidden(hidden)
+ self.text_overlay.set_config(text_section)
# Set Notification overlay options
- self.notification_overlay.set_enabled(config.getboolean(
- "notification", "enabled", fallback=False))
- self.notification_overlay.set_align_x(config.getboolean(
- "notification", "rightalign", fallback=True))
- self.notification_overlay.set_align_y(
- config.getint("notification", "topalign", fallback=2))
- floating = config.getboolean(
- "notification", "floating", fallback=False)
- floating_x = config.getfloat(
- "notification", "floating_x", fallback=0.0)
- floating_y = config.getfloat(
- "notification", "floating_y", fallback=0.0)
- floating_w = config.getfloat(
- "notification", "floating_w", fallback=0.1)
- floating_h = config.getfloat(
- "notification", "floating_h", fallback=0.1)
- font = config.get("notification", "font", fallback=None)
- self.notification_overlay.set_bg(json.loads(config.get(
- "notification", "bg_col", fallback="[0.0,0.0,0.0,0.5]")))
- self.notification_overlay.set_fg(json.loads(config.get(
- "notification", "fg_col", fallback="[1.0,1.0,1.0,1.0]")))
- self.notification_overlay.set_text_time(config.getint(
- "notification", "text_time", fallback=10))
- self.notification_overlay.set_show_icon(config.getboolean(
- "notification", "show_icon", fallback=True))
- self.notification_overlay.set_reverse_order(config.getboolean(
- "notification", "rev", fallback=False))
- self.notification_overlay.set_limit_width(config.getint(
- "notification", "limit_width", fallback=400))
- self.notification_overlay.set_icon_left(config.getboolean(
- "notification", "icon_left", fallback=True))
- self.notification_overlay.set_icon_pad(config.getint(
- "notification", "icon_padding", fallback=8))
- self.notification_overlay.set_icon_size(config.getint(
- "notification", "icon_size", fallback=32))
- self.notification_overlay.set_padding(config.getint(
- "notification", "padding", fallback=8))
- self.notification_overlay.set_border_radius(config.getint(
- "notification", "border_radius", fallback=8))
- self.notification_overlay.set_testing(config.getboolean(
- "notification", "show_dummy", fallback=False))
- self.font = config.get("notification", "font", fallback=None)
-
- if self.font:
- self.notification_overlay.set_font(self.font)
-
- self.notification_overlay.set_monitor(
- config.get("notification", "monitor", fallback="Any")
+ if not config.has_section("notification"):
+ config["notification"] = {}
+ notification_section = config["notification"]
+ if self.notification_overlay_window:
+ self.notification_overlay_window.set_config(notification_section)
+ self.notification_overlay_window.set_hidden(hidden)
+ self.notification_overlay.set_config(notification_section)
+
+ if self.one_window:
+ self.one_window.set_hidden(hidden)
+
+ self.audio_assist.set_enabled(
+ config.getboolean("general", "audio_assist", fallback=False)
)
- self.notification_overlay.set_floating(
- floating, floating_x, floating_y, floating_w, floating_h)
- if self.font:
- self.notification_overlay.set_font(self.font)
-
- # Set Core settings
- self.set_force_xshape(
- config.getboolean("general", "xshape", fallback=False))
-
- hidden = config.getboolean("general", "hideoverlay", fallback=False)
- self.voice_overlay.set_hidden(hidden)
- self.text_overlay.set_hidden(hidden)
- self.notification_overlay.set_hidden(hidden)
-
- self.audio_assist.set_enabled(config.getboolean(
- "general", "audio_assist", fallback=False))
def parse_guild_ids(self, guild_ids_str):
"""Parse the guild_ids from a str and return them in a list"""
@@ -401,55 +250,48 @@ def create_gui(self):
"""
Create Systray & associated menu, overlays & settings windows
"""
- self.voice_overlay = VoiceOverlayWindow(self)
+ self.one_window = self.voice_overlay_window = self.text_overlay_window = (
+ self.notification_overlay_window
+ ) = None
if self.steamos:
- self.text_overlay = TextOverlayWindow(self, self.voice_overlay)
- self.notification_overlay = NotificationOverlayWindow(
- self, self.text_overlay)
+ self.one_window = OverlayWindow(self)
+ self.voice_overlay = VoiceOverlayWindow(self)
+ self.text_overlay = TextOverlayWindow(self)
+ self.notification_overlay = NotificationOverlayWindow(self)
+ self.one_window.merged_overlay(
+ [self.voice_overlay, self.text_overlay, self.notification_overlay]
+ )
else:
+ self.voice_overlay_window = OverlayWindow(self)
+ self.voice_overlay = VoiceOverlayWindow(self)
+ self.voice_overlay_window.overlay(self.voice_overlay)
+
+ self.text_overlay_window = OverlayWindow(self)
self.text_overlay = TextOverlayWindow(self)
+ self.text_overlay_window.overlay(self.text_overlay)
+
+ self.notification_overlay_window = OverlayWindow(self)
self.notification_overlay = NotificationOverlayWindow(self)
+ self.notification_overlay_window.overlay(self.notification_overlay)
if self.mix_settings:
- MainSettingsWindow(
- self.config_file, self.rpc_file, self.channel_file, [])
-
- def toggle_show(self, _obj=None):
- """Toggle all overlays off or on"""
- if self.voice_overlay:
- hide = not self.voice_overlay.hidden
- self.voice_overlay.set_hidden(hide)
- if self.text_overlay:
- self.text_overlay.set_hidden(hide)
- if self.notification_overlay:
- self.notification_overlay.set_hidden(hide)
+ app = Settings(
+ "io.github.trigg.discover_overlay",
+ Gio.ApplicationFlags.FLAGS_NONE,
+ self.config_file,
+ self.rpc_file,
+ self.channel_file,
+ sys.argv[1:],
+ )
+ app.connect("activate", app.start)
+ app.run()
def close(self, _a=None, _b=None, _c=None):
"""
End of the program
"""
- Gtk.main_quit()
-
- def set_force_xshape(self, force):
- """
- Set if XShape should be forced
- """
- self.voice_overlay.set_force_xshape(force)
- if self.text_overlay:
- self.text_overlay.set_force_xshape(force)
- if self.notification_overlay:
- self.notification_overlay.set_force_xshape(force)
-
- def set_show_task(self, visible):
- """Set if the overlay should allow itself to appear on taskbar.
- Not working at last check"""
- if self.voice_overlay:
- self.voice_overlay.set_task(visible)
- if self.text_overlay:
- self.text_overlay.set_task(visible)
- if self.notification_overlay:
- self.notification_overlay.set_task(visible)
+ sys.exit()
def set_mute_async(self, mute):
"""Set mute status from another thread"""
@@ -547,8 +389,18 @@ def entrypoint():
debug_file = os.path.join(config_dir, "output.txt")
if "-c" in sys.argv or "--configure" in sys.argv:
- _settings = MainSettingsWindow(config_file, rpc_file, channel_file, sys.argv[1:])
- Gtk.main()
+ # Show config window
+ # pylint: disable=E1101
+ app = Settings(
+ "io.github.trigg.discover_overlay",
+ Gio.ApplicationFlags.FLAGS_NONE,
+ config_file,
+ rpc_file,
+ channel_file,
+ sys.argv[1:],
+ )
+ app.connect("activate", app.start)
+ app.run()
return
if is_a_controller(sys.argv):
@@ -565,15 +417,17 @@ def entrypoint():
logging.basicConfig(filename=debug_file, format=log_format)
else:
logging.basicConfig(format=log_format)
- log.info("Starting Discover Overlay: %s",
- pkg_resources.get_distribution('discover_overlay').version)
+ log.info(
+ "Starting Discover Overlay: %s",
+ __version__,
+ )
# Hedge against the bet gamescope ships with some WAYLAND_DISPLAY
# Compatibility and we're not ready yet
- if 'GAMESCOPE_WAYLAND_DISPLAY' in os.environ:
+ if "GAMESCOPE_WAYLAND_DISPLAY" in os.environ:
+ os.environ["GDK_BACKEND"] = "x11"
os.unsetenv("WAYLAND_DISPLAY")
-
# Catch any errors and log them
try:
with open(rpc_file, "w", encoding="utf-8") as tfile:
diff --git a/discover_overlay/draggable_window.py b/discover_overlay/draggable_window.py
deleted file mode 100644
index 3dfd964..0000000
--- a/discover_overlay/draggable_window.py
+++ /dev/null
@@ -1,178 +0,0 @@
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-"""An X11 window which can be moved and resized"""
-import logging
-import gi
-import cairo
-gi.require_version("Gtk", "3.0")
-# pylint: disable=wrong-import-position
-from gi.repository import Gtk, Gdk # nopep8
-
-log = logging.getLogger(__name__)
-
-
-class DraggableWindow(Gtk.Window):
- """An X11 window which can be moved and resized"""
-
- def __init__(self, pos_x=0.0, pos_y=0.0, width=0.1, height=0.1,
- message="Message", settings=None, monitor=None):
- Gtk.Window.__init__(self, type=Gtk.WindowType.POPUP)
- self.monitor = monitor
- (_screen_x, _screen_y, screen_width,
- screen_height) = self.get_display_coords()
- self.pos_x = pos_x * screen_width
- self.pos_y = pos_y * screen_height
- self.width = max(40, width * screen_width)
- self.height = max(40, height * screen_height)
- self.settings = settings
- self.message = message
- self.set_size_request(50, 50)
-
- self.connect('draw', self.dodraw)
- self.connect('motion-notify-event', self.drag)
- self.connect('button-press-event', self.button_press)
- self.connect('button-release-event', self.button_release)
-
- self.compositing = False
- # Set RGBA
- screen = self.get_screen()
- visual = screen.get_rgba_visual()
- if visual:
- # Set the visual even if we can't use it right now
- self.set_visual(visual)
- if screen.is_composited():
- self.compositing = True
-
- self.set_app_paintable(True)
-
- self.drag_type = None
- self.drag_x = 0
- self.drag_y = 0
- self.force_location()
- self.show_all()
-
- def force_location(self):
- """
- Move the window to previously given co-ords.
- Also double check sanity on layer & decorations
- """
- self.set_decorated(False)
- self.set_keep_above(True)
-
- (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords()
-
- self.width = min(self.width, screen_width)
- self.height = min(self.height, screen_height)
- self.pos_x = max(0, self.pos_x)
- self.pos_x = min(screen_width - self.width, self.pos_x)
- self.pos_y = max(0, self.pos_y)
- self.pos_y = min(screen_height - self.height, self.pos_y)
-
- self.move(self.pos_x + screen_x, self.pos_y + screen_y)
- self.resize(self.width, self.height)
-
- def drag(self, _w, event):
- """Called by GTK while mouse is moving over window. Used to resize and move"""
- if event.state & Gdk.ModifierType.BUTTON1_MASK:
- if self.drag_type == 1:
- # Center is move
- (screen_x, screen_y, _screen_width,
- _screen_height) = self.get_display_coords()
- self.pos_x = (event.x_root - screen_x) - self.drag_x
- self.pos_y = (event.y_root - screen_y) - self.drag_y
- self.force_location()
- elif self.drag_type == 2:
- # Right edge
- self.width += event.x - self.drag_x
- self.drag_x = event.x
- self.force_location()
- elif self.drag_type == 3:
- # Bottom edge
- self.height += event.y - self.drag_y
- self.drag_y = event.y
- self.force_location()
- elif self.drag_type == 4:
- # Bottom Right
- self.width += event.x - self.drag_x
- self.height += event.y - self.drag_y
- self.drag_x = event.x
- self.drag_y = event.y
- self.force_location()
-
- def button_press(self, _widget, event):
- """Called when a mouse button is pressed on this window"""
- (width, height) = self.get_size()
- if not self.drag_type:
- self.drag_type = 1
- # Where in the window did we press?
- if event.y > height - 32:
- self.drag_type += 2
- if event.x > width - 32:
- self.drag_type += 1
- self.drag_x = event.x
- self.drag_y = event.y
-
- def button_release(self, _w, _event):
- """Called when a mouse button is released"""
- self.drag_type = None
-
- def dodraw(self, _widget, context):
- """Draw our window."""
- context.set_source_rgba(1.0, 1.0, 0.0, 0.7)
- # Don't layer drawing over each other, always replace
- context.set_operator(cairo.OPERATOR_SOURCE)
- context.paint()
- context.set_operator(cairo.OPERATOR_OVER)
- # Get size of window
- (window_width, window_height) = self.get_size()
-
- # Draw text
- context.set_source_rgba(0.0, 0.0, 0.0, 1.0)
- _xb, _yb, text_width, text_height, _dx, _dy = context.text_extents(
- self.message)
- context.move_to(window_width / 2 - text_width / 2,
- window_height / 2 - text_height / 2)
- context.show_text(self.message)
-
- # Draw resizing edges
- context.set_source_rgba(0.0, 0.0, 1.0, 0.5)
- context.rectangle(window_width - 32, 0, 32, window_height)
- context.fill()
-
- context.rectangle(0, window_height - 32, window_width, 32)
- context.fill()
-
- def get_display_coords(self):
- """Get coordinates for this display"""
- display = Gdk.Display.get_default()
- if "get_monitor" in dir(display):
- monitor = display.get_monitor(self.monitor)
- if monitor:
- geometry = monitor.get_geometry()
- return (geometry.x, geometry.y, geometry.width, geometry.height)
- return (0, 0, 1920, 1080) # We're in trouble
-
- def get_coords(self):
- """Return window position and size"""
- (screen_x, screen_y, screen_width, screen_height) = self.get_display_coords()
- scale = self.get_scale_factor()
- (pos_x, pos_y) = self.get_position()
- pos_x = float(max(0, pos_x - screen_x))
- pos_y = float(max(0, pos_y - screen_y))
- (width, height) = self.get_size()
- width = float(width)
- height = float(height)
- pos_x = pos_x / scale
- pos_y = pos_y / scale
- return (pos_x / screen_width, pos_y / screen_height,
- width / screen_width, height / screen_height)
diff --git a/discover_overlay/draggable_window_wayland.py b/discover_overlay/draggable_window_wayland.py
deleted file mode 100644
index 530d2df..0000000
--- a/discover_overlay/draggable_window_wayland.py
+++ /dev/null
@@ -1,211 +0,0 @@
-# This program is free software: you can redistribute it and/or modify
-# it under the terms of the GNU General Public License as published by
-# the Free Software Foundation, either version 3 of the License, or
-# (at your option) any later version.
-#
-# This program is distributed in the hope that it will be useful,
-# but WITHOUT ANY WARRANTY; without even the implied warranty of
-# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
-# GNU General Public License for more details.
-#
-# You should have received a copy of the GNU General Public License
-# along with this program. If not, see .
-"""A Wayland full-screen window which can be moved and resized"""
-import logging
-import cairo
-import gi
-gi.require_version("Gtk", "3.0")
-# pylint: disable=wrong-import-position
-from gi.repository import Gtk, Gdk # nopep8
-try:
- gi.require_version('GtkLayerShell', '0.1')
- from gi.repository import GtkLayerShell
-except (ImportError, ValueError):
- GtkLayerShell = None
-
-log = logging.getLogger(__name__)
-
-
-class DraggableWindowWayland(Gtk.Window):
- """A Wayland full-screen window which can be moved and resized"""
-
- def __init__(self, pos_x=0.0, pos_y=0.0, width=0.1, height=0.1,
- message="Message", settings=None, steamos=False, monitor=None):
- Gtk.Window.__init__(self, type=Gtk.WindowType.TOPLEVEL)
- if steamos:
- monitor = 0
- self.monitor = monitor
- (_screen_x, _screen_y, screen_width,
- screen_height) = self.get_display_coords()
- self.pos_x = pos_x * screen_width
- self.pos_y = pos_y * screen_height
- self.width = max(40, width * screen_width)
- self.height = max(40, height * screen_height)
- self.settings = settings
- self.message = message
- self.set_size_request(50, 50)
-
- self.connect('draw', self.dodraw)
- self.connect('motion-notify-event', self.drag)
- self.connect('button-press-event', self.button_press)
- self.connect('button-release-event', self.button_release)
-
- log.info("Starting: %d,%d %d x %d",
- self.pos_x, self.pos_y, self.width, self.height)
-
- self.set_app_paintable(True)
-
- self.drag_type = None
- self.drag_x = 0
- self.drag_y = 0
- if GtkLayerShell and not steamos:
- GtkLayerShell.init_for_window(self)
- display = Gdk.Display.get_default()
- if "get_monitor" in dir(display):
- monitor = display.get_monitor(self.monitor)
- if monitor:
- GtkLayerShell.set_monitor(self, monitor)
- GtkLayerShell.set_layer(self, GtkLayerShell.Layer.TOP)
- GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.LEFT, True)
- GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.RIGHT, True)
- GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.BOTTOM, True)
- GtkLayerShell.set_anchor(self, GtkLayerShell.Edge.TOP, True)
- if steamos:
- self.steamos = steamos
- self.set_steamos_window_size()
-
- self.show_all()
- self.force_location()
-
- def set_steamos_window_size(self):
- """Prepare window for a gamescope steamos session"""
- # Huge bunch of assumptions.
- # Gamescope only has one monitor
- # Gamescope has no scale factor
- # Probably never possible to reach here, as Gamescope/SteamOS
- # is X11 for overlays
- display = Gdk.Display.get_default()
- if "get_monitor" in dir(display):
- monitor = display.get_monitor(0)
- if monitor:
- geometry = monitor.get_geometry()
- log.info("%d %d", geometry.width, geometry.height)
- self.set_size_request(geometry.width, geometry.height)
-
- def force_location(self):
- """Move the window to previously given co-ords. In wayland just clip to current screen"""
- (_screen_x, _screen_y, screen_width,
- screen_height) = self.get_display_coords()
- self.width = min(self.width, screen_width)
- self.height = min(self.height, screen_height)
- self.pos_x = max(0, self.pos_x)
- self.pos_x = min(screen_width - self.width, self.pos_x)
- self.pos_y = max(0, self.pos_y)
- self.pos_y = min(screen_height - self.height, self.pos_y)
-
- self.queue_draw()
-
- def drag(self, _w, event):
- """Called by GTK while mouse is moving over window. Used to resize and move"""
- if event.state & Gdk.ModifierType.BUTTON1_MASK:
- if self.drag_type == 1:
- # Center is move
- self.pos_x += event.x - self.drag_x
- self.pos_y += event.y - self.drag_y
- self.drag_x = event.x
- self.drag_y = event.y
-
- self.force_location()
- elif self.drag_type == 2:
- # Right edge
- self.width += event.x - self.drag_x
- self.drag_x = event.x
- self.force_location()
- elif self.drag_type == 3:
- # Bottom edge
- self.height += event.y - self.drag_y
- self.drag_y = event.y
- self.force_location()
- elif self.drag_type == 4:
- # Bottom Right
- self.width += event.x - self.drag_x
- self.height += event.y - self.drag_y
- self.drag_x = event.x
- self.drag_y = event.y
- self.force_location()
-
- def button_press(self, _w, event):
- """Called when a mouse button is pressed on this window"""
- press_x = event.x - self.pos_x
- press_y = event.y - self.pos_y
-
- if not self.drag_type:
- self.drag_type = 1
- # Where in the window did we press?
- if press_x < 20 and press_y < 20:
- self.settings.change_placement(self)
- if press_y > self.height - 32:
- self.drag_type += 2
- if press_x > self.width - 32:
- self.drag_type += 1
- self.drag_x = event.x
- self.drag_y = event.y
-
- def button_release(self, _w, _event):
- """Called when a mouse button is released"""
- self.drag_type = None
-
- def dodraw(self, _widget, context):
- """
- Draw our window. For wayland we're secretly a
- fullscreen app and need to draw only a single
- rectangle of the overlay
- """
- context.translate(self.pos_x, self.pos_y)
- context.save()
- context.rectangle(0, 0, self.width, self.height)
- context.clip()
-
- context.set_source_rgba(1.0, 1.0, 0.0, 0.7)
- # Don't layer drawing over each other, always replace
- context.set_operator(cairo.OPERATOR_SOURCE)
- context.paint()
- # Get size of window
-
- # Draw text
- context.set_source_rgba(0.0, 0.0, 0.0, 1.0)
- _xb, _yb, width, height, _dx, _dy = context.text_extents(self.message)
- context.move_to(self.width / 2 - width / 2,
- self.height / 2 - height / 2)
- context.show_text(self.message)
-
- # Draw resizing edges
- context.set_source_rgba(0.0, 0.0, 1.0, 0.5)
- context.rectangle(self.width - 32, 0, 32, self.height)
- context.fill()
-
- context.rectangle(0, self.height - 32, self.width, 32)
- context.fill()
-
- # Draw Done!
- context.set_source_rgba(0.0, 1.0, 0.0, 0.5)
- context.rectangle(0, 0, 20, 20)
- context.fill()
- context.restore()
-
- def get_display_coords(self):
- """Get coordinates from display"""
- display = Gdk.Display.get_default()
- if "get_monitor" in dir(display):
- monitor = display.get_monitor(self.monitor)
- if monitor:
- geometry = monitor.get_geometry()
- return (geometry.x, geometry.y, geometry.width, geometry.height)
- return (0, 0, 1920, 1080) # We're in trouble
-
- def get_coords(self):
- """Return the position and size of the window"""
- (_screen_x, _screen_y, screen_width,
- screen_height) = self.get_display_coords()
- return (float(self.pos_x) / screen_width, float(self.pos_y) / screen_height,
- float(self.width) / screen_width, float(self.height) / screen_height)
diff --git a/discover_overlay/glade/settings.glade b/discover_overlay/glade/settings.glade
deleted file mode 100644
index c6d671c..0000000
--- a/discover_overlay/glade/settings.glade
+++ /dev/null
@@ -1,2847 +0,0 @@
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
-
diff --git a/discover_overlay/glade/settings.xml b/discover_overlay/glade/settings.xml
new file mode 100644
index 0000000..94a2761
--- /dev/null
+++ b/discover_overlay/glade/settings.xml
@@ -0,0 +1,2220 @@
+
+
+
+
+ 0.10
+ 1
+ 0.5
+ 0.01
+ 0.10
+
+
+ 8
+ 128
+ 48
+ 1
+ 8
+
+
+ 1
+ 100
+ 1
+ 10
+
+
+ 64
+ 1
+ 1
+
+
+ 128
+ 1
+ 8
+
+
+ 100
+ 8
+ 1
+ 10
+
+
+ 8
+ 128
+ 48
+ 1
+ 8
+
+
+ 100
+ 4000
+ 400
+ 1
+ 10
+
+
+ 128
+ 1
+ 8
+
+
+ 1
+ 4000
+ 10
+ 1
+ 10
+
+
+
+
+
+
+
+