Merge pull request #2 from trigg/master

Pull latest version into this fork
This commit is contained in:
Murdo Maclachlan 2020-10-20 11:00:18 +01:00 committed by GitHub
commit 74f664d0d2
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
19 changed files with 1654 additions and 448 deletions

View file

@ -20,28 +20,27 @@ python3 -m pip install .
## Dependencies
Requirements should be handled by pip.
Requires PyGTK3 3.22 or newer, websocket_client
Most requirements should be handled by pip.
A compositor is strongly advised but there is a non-compositor mode optionally
It is advised to install python-gobject from your own package manager.
#### Debian/Ubuntu
`apt install python3-gi`
#### Arch
`pacman -S python-gobject`
## Usage
run `discover-overlay` if this fails it is most likely in `~/.local/bin/discover-overlay`
Comes with sane-enough defaults but has a system tray icon & settings window if needed.
Comes with sane-enough default but has a configuration screen to tweak to your own use. Configuration can be reached either via System tray or by running `discover-overlay --configure`
## Still in progress
Not all features are at the level I would like.
To do list:
- Text channels
- Text notifications (different from above)
- Open to suggestions
## Why do you keep making Discord Overlays?

View file

@ -1 +1,14 @@
# 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 <https://www.gnu.org/licenses/>.
"""Entry point for Discover Overlay"""
from .discover_overlay import *

View file

@ -1,4 +1,16 @@
import sys
# 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 <https://www.gnu.org/licenses/>.
"""A class to assist auto-start"""
import os
import logging
try:
@ -9,6 +21,8 @@ except ModuleNotFoundError:
class Autostart:
"""A class to assist auto-start"""
def __init__(self, app_name):
if not app_name.endswith(".desktop"):
app_name = "%s.desktop" % (app_name)
@ -19,35 +33,37 @@ class Autostart:
xdg_data_home, 'applications/'), '/usr/share/applications/']
self.auto = self.find_auto()
self.desktop = self.find_desktop()
logging.info("Autostart info : desktop %s auto %s" %
(self.desktop, self.auto))
logging.info("Autostart info : desktop %s auto %s",
self.desktop, self.auto)
def find_auto(self):
for p in self.auto_locations:
file = os.path.join(p, self.app_name)
"""Check all known locations for auto-started apps"""
for path in self.auto_locations:
file = os.path.join(path, self.app_name)
if os.path.exists(file):
return file
return None
def find_desktop(self):
for p in self.desktop_locations:
file = os.path.join(p, self.app_name)
"""Check all known locations for desktop apps"""
for path in self.desktop_locations:
file = os.path.join(path, self.app_name)
if os.path.exists(file):
return file
return None
def set_autostart(self, b):
if b and not self.auto:
def set_autostart(self, enable):
"""Set or Unset auto-start state"""
if enable and not self.auto:
# Enable
d = os.path.join(xdg_config_home, 'autostart')
self.auto = os.path.join(d, self.app_name)
directory = os.path.join(xdg_config_home, 'autostart')
self.auto = os.path.join(directory, self.app_name)
os.symlink(self.desktop, self.auto)
pass
elif not b and self.auto:
elif not enable and self.auto:
# Disable
if os.path.islink(self.auto):
os.remove(self.auto)
pass
def is_auto(self):
"""Check if it's already set to auto-start"""
return True if self.auto else False

View file

@ -1,13 +1,29 @@
import websocket
# 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 <https://www.gnu.org/licenses/>.
"""The connector for discord. Connects in as if it was Streamkit for OBS or Xsplit and communicates to get voice & text info to display"""
import select
import time
import json
import requests
import sys
import logging
import calendar
import websocket
import requests
class DiscordConnector:
"""The connector for discord. Connects in as if it was Streamkit for OBS or Xsplit and communicates to get voice & text info to display"""
def __init__(self, text_settings, voice_settings, text_overlay, voice_overlay):
self.text_settings = text_settings
self.text_overlay = text_overlay
@ -15,7 +31,6 @@ class DiscordConnector:
self.voice_overlay = voice_overlay
self.ws = None
self.access_token = "none"
# TODO Magic number
self.oauth_token = "207646673902501888"
self.access_delay = 0
self.warn_connection = True
@ -33,9 +48,9 @@ class DiscordConnector:
self.last_connection = ""
self.text = []
self.authed = False
self.last_text_channel = None
def get_access_token_stage1(self):
global oauth_token
self.ws.send("{\"cmd\":\"AUTHORIZE\",\"args\":{\"client_id\":\"%s\",\"scopes\":[\"rpc\",\"messages.read\"],\"prompt\":\"none\"},\"nonce\":\"deadbeef\"}" % (
self.oauth_token))
@ -45,7 +60,7 @@ class DiscordConnector:
x = requests.post(url, json=myobj)
try:
j = json.loads(x.text)
except:
except json.JSONDecodeError:
j = {}
if "access_token" in j:
self.access_token = j["access_token"]
@ -60,7 +75,8 @@ class DiscordConnector:
if channel != self.current_voice:
cn = self.channels[channel]['name']
logging.info(
"Joined room: %s" % (cn))
"Joined room: %s", cn)
self.sub_voice_channel(channel)
self.current_voice = channel
if need_req:
self.req_channel_details(channel)
@ -72,7 +88,7 @@ class DiscordConnector:
if channel != self.current_text:
self.current_text = channel
logging.info(
"Changing text room: %s" % (channel))
"Changing text room: %s", channel)
if need_req:
self.req_channel_details(channel)
@ -87,7 +103,6 @@ class DiscordConnector:
def add_text(self, message):
utc_time = time.strptime(
message["timestamp"], "%Y-%m-%dT%H:%M:%S.%f%z")
t = time.time()
epoch_time = calendar.timegm(utc_time)
un = message["author"]["username"]
if "nick" in message and message['nick'] and len(message["nick"]) > 1:
@ -101,6 +116,7 @@ class DiscordConnector:
'nick': un,
'nick_col': ac,
'time': epoch_time,
'attach': self.get_attachment_from_message(message),
})
self.text_altered = True
@ -112,13 +128,13 @@ class DiscordConnector:
'content': self.get_message_from_message(message_in),
'nick': message['nick'],
'nick_col': message['nick_col'],
'time': message['time']}
'time': message['time'],
'attach': message['attach']}
self.text[idx] = new_message
self.text_altered = True
return
def delete_text(self, message_in):
global text, text_altered
for idx in range(0, len(self.text)):
message = self.text[idx]
if message['id'] == message_in['id']:
@ -127,7 +143,9 @@ class DiscordConnector:
return
def get_message_from_message(self, message):
if "content" in message and len(message["content"]) > 0:
if "content_parsed" in message:
return message["content_parsed"]
elif "content" in message and len(message["content"]) > 0:
return message["content"]
elif len(message["embeds"]) == 1:
if "rawDescription" in message["embeds"][0]:
@ -135,20 +153,33 @@ class DiscordConnector:
if "author" in message["embeds"][0]:
return message["embeds"][0]["author"]["name"]
elif len(message["attachments"]) == 1:
# Need to care
return "-- attachment --"
return ""
return ""
def get_attachment_from_message(self, message):
if len(message["attachments"]) == 1:
return message["attachments"]
return None
def update_user(self, user):
if user["id"] in self.userlist:
if not "mute" in user and "mute" in self.userlist[user["id"]]:
user["mute"] = self.userlist[user["id"]]["mute"]
if not "deaf" in user and "deaf" in self.userlist[user["id"]]:
user["deaf"] = self.userlist[user["id"]]["deaf"]
if not "speaking" in user and "speaking" in self.userlist[user["id"]]:
user["speaking"] = self.userlist[user["id"]]["speaking"]
if self.userlist[user["id"]]["avatar"] != user["avatar"]:
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.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):
@ -162,7 +193,8 @@ class DiscordConnector:
elif j["evt"] == "VOICE_STATE_UPDATE":
self.list_altered = True
thisuser = j["data"]["user"]
un = j["data"]["user"]["username"]
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
@ -170,10 +202,12 @@ class DiscordConnector:
self.update_user(thisuser)
elif j["evt"] == "VOICE_STATE_CREATE":
self.list_altered = True
self.update_user(j["data"]["user"])
thisuser = j["data"]["user"]
nick = j["data"]["nick"]
thisuser["nick"] = nick
self.update_user(thisuser)
# If someone joins any voice room grab it fresh from server
self.req_channel_details(self.current_voice)
un = j["data"]["user"]["username"]
if j["data"]["user"]["id"] == self.user["id"]:
self.find_user()
elif j["evt"] == "VOICE_STATE_DELETE":
@ -181,14 +215,14 @@ class DiscordConnector:
self.set_in_room(j["data"]["user"]["id"], False)
if j["data"]["user"]["id"] == self.user["id"]:
self.in_room = []
self.sub_all_voice()
else:
un = j["data"]["user"]["username"]
# self.sub_all_voice()
elif j["evt"] == "SPEAKING_START":
self.list_altered = True
# It's only possible to get alerts for the room you're in
self.set_channel(j["data"]["channel_id"])
self.userlist[j["data"]["user_id"]]["speaking"] = True
self.userlist[j["data"]["user_id"]]["lastspoken"] = time.time()
self.list_altered = True
self.set_in_room(j["data"]["user_id"], True)
elif j["evt"] == "SPEAKING_STOP":
self.list_altered = True
@ -222,9 +256,9 @@ class DiscordConnector:
self.req_guilds()
self.user = j["data"]["user"]
logging.info(
"ID is %s" % (self.user["id"]))
"ID is %s", self.user["id"])
logging.info(
"Logged in as %s" % (self.user["username"]))
"Logged in as %s", self.user["username"])
self.authed = True
return
elif j["cmd"] == "GET_GUILDS":
@ -241,8 +275,8 @@ class DiscordConnector:
if channel["type"] == 2:
self.req_channel_details(channel["id"])
self.check_guilds()
self.sub_all_voice_guild(j["nonce"])
self.sub_all_text_guild(j["nonce"])
# self.sub_all_voice_guild(j["nonce"])
# self.sub_all_text_guild(j["nonce"])
return
elif j["cmd"] == "SUBSCRIBE":
return
@ -258,8 +292,11 @@ class DiscordConnector:
self.list_altered = True
self.in_room = []
for voice in j["data"]["voice_states"]:
self.update_user(voice["user"])
self.set_in_room(voice["user"]["id"], True)
thisuser = voice["user"]
if "nick" in j["data"]:
thisuser["nick"] = j["data"]["nick"]
self.update_user(thisuser)
self.set_in_room(thisuser["id"], True)
if self.current_text == j["data"]["id"]:
self.text = []
for message in j["data"]["messages"]:
@ -281,12 +318,14 @@ class DiscordConnector:
for channel in guild["channels"]:
channels = channels + " " + channel["name"]
logging.info(
u"%s: %s" % (guild["name"], channels))
u"%s: %s", guild["name"], channels)
self.sub_server()
self.find_user()
if self.last_text_channel:
self.sub_text_channel(self.last_text_channel)
def on_error(self, error):
logging.error("ERROR : %s" % (error))
logging.error("ERROR : %s", error)
def on_close(self):
logging.info("Connection closed")
@ -308,9 +347,12 @@ class DiscordConnector:
channel, channel))
def find_user(self):
count = 0
for channel in self.channels:
if self.channels[channel]["type"] == 2:
self.req_channel_details(channel)
count += 1
logging.warning("Getting %s rooms", count)
def sub_raw(self, cmd, channel, nonce):
self.ws.send("{\"cmd\":\"SUBSCRIBE\",\"args\":{%s},\"evt\":\"%s\",\"nonce\":\"%s\"}" % (
@ -377,7 +419,7 @@ class DiscordConnector:
newlist.append(self.userlist[userid])
self.voice_overlay.set_user_list(newlist, self.list_altered)
self.voice_overlay.set_connection(self.last_connection)
list_altered = False
self.list_altered = False
# Update text list
if self.text_overlay.popup_style:
self.text_altered = True
@ -386,31 +428,38 @@ class DiscordConnector:
self.text_altered = False
# Update text channels
self.text_settings.set_channels(self.channels)
# Update guilds
self.text_settings.set_guilds(self.guilds)
# Check for changed channel
if self.authed:
self.set_text_channel(self.text_settings.get_channel())
# Poll socket for new information
r, w, e = select.select((self.ws.sock,), (), (), 0)
r, _w, _e = select.select((self.ws.sock,), (), (), 0)
while r:
try:
# Recieve & send to on_message
msg = self.ws.recv()
self.on_message(msg)
r, w, e = select.select((self.ws.sock,), (), (), 0)
except websocket._exceptions.WebSocketConnectionClosedException:
r, _w, _e = select.select((self.ws.sock,), (), (), 0)
except websocket.WebSocketConnectionClosedException:
self.on_close()
return True
return True
def start_listening_text(self, ch):
if self.ws:
self.sub_text_channel(ch)
else:
self.last_text_channel = ch
def connect(self):
if self.ws:
return
try:
self.ws = websocket.create_connection("ws://127.0.0.1:6463/?v=1&client_id=%s" % (self.oauth_token),
origin="https://streamkit.discord.com")
except Exception as e:
except ConnectionError as e:
if self.error_connection:
logging.error(e)
self.error_connection = False
pass

View file

@ -10,48 +10,77 @@
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <https://www.gnu.org/licenses/>.
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
"""Main application class"""
import os
import sys
import json
import math
import time
import cairo
import base64
import select
import urllib
import requests
import websocket
from .voice_settings import VoiceSettingsWindow
from .text_settings import TextSettingsWindow
import logging
import gi
import pidfile
from .settings_window import MainSettingsWindow
from .voice_overlay import VoiceOverlayWindow
from .text_overlay import TextOverlayWindow
from .discord_connector import DiscordConnector
from .autostart import Autostart
import logging
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position,wrong-import-order
from gi.repository import Gtk, GLib, Gio
try:
from xdg.BaseDirectory import xdg_config_home
except ModuleNotFoundError:
from xdg import XDG_CONFIG_HOME as xdg_config_home
class Discover:
def __init__(self):
pass
"""Main application class"""
def __init__(self, rpc_file, args):
self.ind = None
self.tray = None
self.create_gui()
self.connection = DiscordConnector(
self.settings.text_settings, self.settings.voice_settings,
self.text_overlay, self.voice_overlay)
self.settings.text_settings.add_connector(self.connection)
self.connection.connect()
GLib.timeout_add((1000 / 60), self.connection.do_read)
self.rpc_file = rpc_file
rpc_file = Gio.File.new_for_path(rpc_file)
monitor = rpc_file.monitor_file(0, None)
monitor.connect("changed", self.rpc_changed)
self.do_args(args)
Gtk.main()
def do_args(self, data):
if "--help" in data:
pass
elif "--about" in data:
pass
elif "--configure" in data:
self.show_settings()
elif "--close" in data:
sys.exit(0)
def rpc_changed(self, _a=None, _b=None, _c=None, _d=None):
with open(self.rpc_file, "r") as tfile:
data = tfile.readlines()
if len(data) >= 1:
self.do_args(data[0])
def create_gui(self):
self.voice_overlay = VoiceOverlayWindow()
self.text_overlay = TextOverlayWindow()
self.voice_overlay = VoiceOverlayWindow(self)
self.text_overlay = TextOverlayWindow(self)
self.menu = self.make_menu()
self.make_sys_tray_icon(self.menu)
self.voice_settings = VoiceSettingsWindow(self.voice_overlay)
self.text_settings = TextSettingsWindow(self.text_overlay)
self.settings = MainSettingsWindow(
self.text_overlay, self.voice_overlay)
def make_sys_tray_icon(self, menu):
# Create AppIndicator
try:
gi.require_version('AppIndicator3', '0.1')
# pylint: disable=import-outside-toplevel
from gi.repository import AppIndicator3
self.ind = AppIndicator3.Indicator.new(
"discover_overlay",
@ -59,67 +88,53 @@ class Discover:
AppIndicator3.IndicatorCategory.APPLICATION_STATUS)
self.ind.set_status(AppIndicator3.IndicatorStatus.ACTIVE)
self.ind.set_menu(menu)
except Exception as e:
except ImportError as exception:
# Create System Tray
logging.info("Falling back to Systray : %s" % (e))
self.tray = Gtk.StatusIcon.new_from_icon_name("discover_overlay")
logging.info("Falling back to Systray : %s", exception)
self.tray = Gtk.StatusIcon.new_from_icon_name("discover-overlay")
self.tray.connect('popup-menu', self.show_menu)
def make_menu(self):
# Create System Menu
menu = Gtk.Menu()
vsettings_opt = Gtk.MenuItem.new_with_label("Voice Settings")
tsettings_opt = Gtk.MenuItem.new_with_label("Text Settings")
autostart_opt = Gtk.CheckMenuItem("Start on boot")
autostart_opt.set_active(self.a.is_auto())
settings_opt = Gtk.MenuItem.new_with_label("Settings")
close_opt = Gtk.MenuItem.new_with_label("Close")
menu.append(vsettings_opt)
menu.append(tsettings_opt)
menu.append(autostart_opt)
menu.append(settings_opt)
menu.append(close_opt)
vsettings_opt.connect("activate", self.show_vsettings)
tsettings_opt.connect("activate", self.show_tsettings)
autostart_opt.connect("toggled", self.toggle_auto)
settings_opt.connect("activate", self.show_settings)
close_opt.connect("activate", self.close)
menu.show_all()
return menu
def toggle_auto(self, button):
self.a.set_autostart(button.get_active())
def show_menu(self, obj, button, time):
self.menu.show_all()
self.menu.popup(
None, None, Gtk.StatusIcon.position_menu, obj, button, time)
def show_vsettings(self, obj=None, data=None):
self.voice_settings.present()
def show_settings(self, _obj=None, _data=None):
self.settings.present_settings()
def show_tsettings(self, obj=None, data=None):
self.text_settings.present()
def close(self, a=None, b=None, c=None):
def close(self, _a=None, _b=None, _c=None):
Gtk.main_quit()
def main(self):
self.a = Autostart("discover_overlay")
# a.set_autostart(True)
self.create_gui()
self.connection = DiscordConnector(
self.text_settings, self.voice_settings,
self.text_overlay, self.voice_overlay)
self.connection.connect()
GLib.timeout_add((1000 / 60), self.connection.do_read)
try:
Gtk.main()
except:
pass
def entrypoint():
logging.getLogger().setLevel(logging.INFO)
discover = Discover()
discover.main()
configDir = os.path.join(xdg_config_home, "discover_overlay")
os.makedirs(configDir, exist_ok=True)
line = ""
for arg in sys.argv[1:]:
line = "%s %s" % (line, arg)
pid_file = os.path.join(configDir, "discover_overlay.pid")
rpc_file = os.path.join(configDir, "discover_overlay.rpc")
try:
with pidfile.PIDFile(pid_file):
logging.getLogger().setLevel(logging.INFO)
Discover(rpc_file, line)
except pidfile.AlreadyRunningError:
logging.warning("Discover overlay is currently running")
with open(rpc_file, "w") as tfile:
tfile.write(line)
logging.warning("Sent RPC command")

View file

@ -1,15 +1,27 @@
# 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 <https://www.gnu.org/licenses/>.
"""An X11 window which can be moved and resized"""
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.0')
import cairo
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import logging
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk
class DraggableWindow(Gtk.Window):
def __init__(self, x=0, y=0, w=300, h=300, message="Message"):
"""An X11 window which can be moved and resized"""
def __init__(self, x=0, y=0, w=300, h=300, message="Message", settings=None):
Gtk.Window.__init__(self, type=Gtk.WindowType.POPUP)
if w < 100:
w = 100
@ -19,10 +31,11 @@ class DraggableWindow(Gtk.Window):
self.y = y
self.w = w
self.h = h
self.settings = settings
self.message = message
self.set_size_request(50, 50)
self.connect('draw', self.draw)
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)
@ -47,28 +60,15 @@ class DraggableWindow(Gtk.Window):
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)
display = Gdk.Display.get_default()
if "get_monitor" in dir(display):
monitor = display.get_monitor(self.monitor)
geometry = monitor.get_geometry()
scale_factor = monitor.get_scale_factor()
w = scale_factor * geometry.width
h = scale_factor * geometry.height
x = geometry.x
y = geometry.y
else:
screen = display.get_default_screen()
w = screen.width()
h = screen.height()
x = 0
y = 0
#self.resize(400, h)
self.move(self.x, self.y)
self.resize(self.w, self.h)
def drag(self, w, event):
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
@ -94,6 +94,7 @@ class DraggableWindow(Gtk.Window):
self.force_location()
def button_press(self, w, event):
"""Called when a mouse button is pressed on this window"""
(w, h) = self.get_size()
if not self.drag_type:
self.drag_type = 1
@ -105,10 +106,12 @@ class DraggableWindow(Gtk.Window):
self.drag_x = event.x
self.drag_y = event.y
def button_release(self, w, event):
def button_release(self, _w, _event):
"""Called when a mouse button is released"""
self.drag_type = None
def draw(self, widget, context):
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)
@ -119,7 +122,7 @@ class DraggableWindow(Gtk.Window):
# Draw text
context.set_source_rgba(0.0, 0.0, 0.0, 1.0)
xb, yb, w, h, dx, dy = context.text_extents(self.message)
_xb, _yb, w, h, _dx, _dy = context.text_extents(self.message)
context.move_to(sw / 2 - w / 2, sh / 2 - h / 2)
context.show_text(self.message)
@ -130,3 +133,9 @@ class DraggableWindow(Gtk.Window):
context.rectangle(0, sh - 32, sw, 32)
context.fill()
def get_coords(self):
"""Return window position and size"""
(x, y) = self.get_position()
(w, h) = self.get_size()
return (x, y, w, h)

View file

@ -0,0 +1,161 @@
# 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 <https://www.gnu.org/licenses/>.
"""A Wayland full-screen window which can be moved and resized"""
import cairo
import gi
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk
try:
from gi.repository import GtkLayerShell
except ImportError:
pass
class DraggableWindowWayland(Gtk.Window):
"""A Wayland full-screen window which can be moved and resized"""
def __init__(self, x=0, y=0, w=300, h=300, message="Message", settings=None):
Gtk.Window.__init__(self, type=Gtk.WindowType.TOPLEVEL)
if w < 100:
w = 100
if h < 100:
h = 100
self.x = x
self.y = y
self.w = w
self.h = h
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.set_app_paintable(True)
self.monitor = 0
self.drag_type = None
self.drag_x = 0
self.drag_y = 0
GtkLayerShell.init_for_window(self)
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)
self.show_all()
# self.force_location()
def force_location(self):
"""Move the window to previously given co-ords. In wayland just clip to current screen"""
(size_x, size_y) = self.get_size()
if self.x < 0:
self.x = 0
if self.y < 0:
self.y = 0
if self.x + self.w > size_x:
self.x = size_x - self.w
if self.y + self.h > size_y:
self.y = size_y - self.h
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.x += event.x - self.drag_x
self.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.w += event.x - self.drag_x
self.drag_x = event.x
self.force_location()
elif self.drag_type == 3:
# Bottom edge
self.h += event.y - self.drag_y
self.drag_y = event.y
self.force_location()
else:
# Bottom Right
self.w += event.x - self.drag_x
self.h += 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"""
px = event.x - self.x
py = event.y - self.y
if not self.drag_type:
self.drag_type = 1
# Where in the window did we press?
if px < 20 and py < 20:
self.settings.change_placement(None)
if py > self.h - 32:
self.drag_type += 2
if px > self.w - 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.x, self.y)
context.save()
context.rectangle(0, 0, self.w, self.h)
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.w / 2 - width / 2, self.h / 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.w - 32, 0, 32, self.h)
context.fill()
context.rectangle(0, self.h - 32, self.w, 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_coords(self):
"""Return the position and size of the window"""
return (self.x, self.y, self.w, self.h)

View file

@ -0,0 +1,91 @@
# 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 <https://www.gnu.org/licenses/>.
"""Core Settings Tab"""
from configparser import ConfigParser
import gi
from .settings import SettingsWindow
from .autostart import Autostart
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk
class GeneralSettingsWindow(SettingsWindow):
"""Core Settings Tab"""
def __init__(self, overlay, overlay2):
SettingsWindow.__init__(self)
self.overlay = overlay
self.overlay2 = overlay2
self.xshape = None
self.autostart = None
self.set_size_request(400, 200)
self.connect("destroy", self.close_window)
self.connect("delete-event", self.close_window)
self.init_config()
self.a = Autostart("discover_overlay")
self.placement_window = None
self.create_gui()
def read_config(self):
config = ConfigParser(interpolation=None)
config.read(self.configFile)
self.xshape = config.getboolean("general", "xshape", fallback=False)
# Pass all of our config over to the overlay
self.overlay.set_force_xshape(self.xshape)
self.overlay2.set_force_xshape(self.xshape)
def save_config(self):
config = ConfigParser(interpolation=None)
config.read(self.configFile)
if not config.has_section("general"):
config.add_section("general")
config.set("general", "xshape", "%d" % (int(self.xshape)))
with open(self.configFile, 'w') as file:
config.write(file)
def create_gui(self):
box = Gtk.Grid()
# Auto start
autostart_label = Gtk.Label.new("Autostart on boot")
autostart = Gtk.CheckButton.new()
autostart.set_active(self.a.is_auto())
autostart.connect("toggled", self.change_autostart)
# Force XShape
xshape_label = Gtk.Label.new("Force XShape")
xshape = Gtk.CheckButton.new()
xshape.set_active(self.xshape)
xshape.connect("toggled", self.change_xshape)
box.attach(autostart_label, 0, 0, 1, 1)
box.attach(autostart, 1, 0, 1, 1)
box.attach(xshape_label, 0, 1, 1, 1)
box.attach(xshape, 1, 1, 1, 1)
self.add(box)
def change_autostart(self, button):
self.autostart = button.get_active()
self.a.set_autostart(self.autostart)
def change_xshape(self, button):
self.overlay.set_force_xshape(button.get_active())
self.overlay2.set_force_xshape(button.get_active())
self.xshape = button.get_active()
self.save_config()

View file

@ -1,18 +1,34 @@
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.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 <https://www.gnu.org/licenses/>.
"""Functions & Classes to assist image loading."""
import urllib
import threading
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import logging
import gi
import requests
import cairo
import PIL.Image as Image
gi.require_version('GdkPixbuf', '2.0')
# pylint: disable=wrong-import-position
from gi.repository import Gio, GdkPixbuf
class Image_Getter():
def __init__(self, func, url, id, size):
class ImageGetter():
"""Older attempt. Not advised as it can't manage anything but PNG. Loads to GDK Pixmap"""
def __init__(self, func, url, identifier, size):
self.func = func
self.id = id
self.id = identifier
self.url = url
self.size = size
@ -25,17 +41,128 @@ class Image_Getter():
response = urllib.request.urlopen(req)
input_stream = Gio.MemoryInputStream.new_from_data(
response.read(), None)
pixbuf = Pixbuf.new_from_stream(input_stream, None)
pixbuf = pixbuf.scale_simple(self.size, self.size,
GdkPixbuf.InterpType.BILINEAR)
pixbuf = GdkPixbuf.Pixbuf.new_from_stream(input_stream, None)
if self.size:
pixbuf = pixbuf.scale_simple(self.size, self.size,
GdkPixbuf.InterpType.BILINEAR)
self.func(self.id, pixbuf)
except Exception as e:
except urllib.error.URLError as exception:
logging.error(
"Could not access : %s" % (url))
logging.error(e)
"Could not access : %s", self.url)
logging.error(exception)
def get_image(func, id, ava, size):
image_getter = Image_Getter(func, id, ava, size)
class SurfaceGetter():
"""Download and decode image using PIL and store as a cairo surface"""
def __init__(self, func, url, identifier, size):
self.func = func
self.id = identifier
self.url = url
self.size = size
def get_url(self):
"""Downloads and decodes"""
try:
resp = requests.get(self.url, stream=True, headers={
'Referer': 'https://streamkit.discord.com/overlay/voice', 'User-Agent': 'Mozilla/5.0'})
raw = resp.raw
im = Image.open(raw)
surf = self.from_pil(im)
self.func(self.id, surf)
except requests.HTTPError:
logging.error("Unable to open %s", self.url)
except requests.TooManyRedirects:
logging.error("Unable to open %s - Too many redirects", self.url)
except requests.Timeout:
logging.error("Unable to open %s - Timeout", self.url)
except requests.ConnectionError:
logging.error("Unable to open %s - Connection error", self.url)
except ValueError:
logging.error("Unable to read %s", self.url)
except TypeError:
logging.error("Unable to read %s", self.url)
def from_pil(self, im, alpha=1.0):
"""
:param im: Pillow Image
:param alpha: 0..1 alpha to add to non-alpha images
:param format: Pixel format for output surface
"""
if 'A' not in im.getbands():
im.putalpha(int(alpha * 256.))
arr = bytearray(im.tobytes('raw', 'BGRa'))
surface = cairo.ImageSurface.create_for_data(
arr, cairo.FORMAT_ARGB32, im.width, im.height)
return surface
def get_image(func, identifier, ava, size):
"""Download to GDK Pixmap"""
image_getter = ImageGetter(func, identifier, ava, size)
t = threading.Thread(target=image_getter.get_url, args=())
t.start()
def get_surface(func, identifier, ava, size):
"""Download to cairo surface"""
image_getter = SurfaceGetter(func, identifier, ava, size)
t = threading.Thread(target=image_getter.get_url, args=())
t.start()
def get_aspected_size(img, w, h, anchor=0, hanchor=0):
"""Get dimensions of image keeping current aspect ratio"""
px = img.get_width()
py = img.get_height()
if py < 1 or h < 1:
return (0, 0, 0, 0)
img_aspect = px / py
rect_aspect = w / h
y = 0
x = 0
if img_aspect > rect_aspect:
oh = h
h = w / img_aspect
if anchor == 0:
y = y + (oh - h)
if anchor == 1:
y = y + ((oh - h) / 2)
elif img_aspect < rect_aspect:
ow = w
w = h * img_aspect
if hanchor == 2:
x = x + (ow - w)
if hanchor == 1:
x = x + ((ow - w) / 2)
return (x, y, w, h)
def draw_img_to_rect(img, ctx, x, y, w, h, path=False, aspect=False, anchor=0, hanchor=0):
"""Draw cairo surface onto context"""
# Path - only add the path do not fill : True/False
# Aspect - keep aspect ratio : True/False
# Anchor - with aspect : 0=left 1=middle 2=right
# HAnchor - with apect : 0=bottom 1=middle 2=top
ctx.save()
px = img.get_width()
py = img.get_height()
x_off = 0
y_off = 0
if aspect:
(x_off, y_off, w, h) = get_aspected_size(
img, w, h, anchor=anchor, hanchor=hanchor)
ctx.translate(x + x_off, y + y_off)
ctx.scale(w, h)
ctx.scale(1 / px, 1 / py)
ctx.set_source_surface(img, 0, 0)
ctx.rectangle(0, 0, px, py)
if not path:
ctx.fill()
ctx.restore()
return (w, h)

View file

@ -1,21 +1,55 @@
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.0')
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import cairo
# 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 <https://www.gnu.org/licenses/>.
"""Overlay parent class. Helpful if we need more overlay types without copy-and-pasting too much code"""
import sys
import logging
import gi
import cairo
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk
try:
from gi.repository import GtkLayerShell
except ImportError:
pass
class OverlayWindow(Gtk.Window):
"""Overlay parent class. Helpful if we need more overlay types without copy-and-pasting too much code"""
def detect_type(self):
window = Gtk.Window()
screen = window.get_screen()
screen_type = "%s" % (screen)
self.is_wayland = False
if "Wayland" in screen_type:
self.is_wayland = True
return Gtk.WindowType.TOPLEVEL
return Gtk.WindowType.POPUP
def __init__(self):
Gtk.Window.__init__(self, type=Gtk.WindowType.POPUP)
self.set_size_request(50, 50)
self.connect('draw', self.draw)
Gtk.Window.__init__(self, type=self.detect_type())
screen = self.get_screen()
self.compositing = False
self.text_font = None
self.text_size = None
self.x = None
self.y = None
self.w = None
self.h = None
self.set_size_request(50, 50)
self.connect('draw', self.overlay_draw)
# Set RGBA
screen = self.get_screen()
visual = screen.get_rgba_visual()
@ -33,17 +67,29 @@ class OverlayWindow(Gtk.Window):
self.set_untouchable()
self.set_skip_pager_hint(True)
self.set_skip_taskbar_hint(True)
self.set_keep_above(True)
self.set_decorated(True)
self.set_accept_focus(False)
self.set_wayland_state()
self.show_all()
self.monitor = 0
self.align_right = True
self.align_vert = 1
self.floating = False
self.force_xshape = False
self.context = None
def draw(self, widget, context):
pass
def set_wayland_state(self):
if self.is_wayland:
GtkLayerShell.init_for_window(self)
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)
def do_draw(self, context):
def overlay_draw(self, _w, context, data=None):
pass
def set_font(self, name, size):
@ -74,53 +120,65 @@ class OverlayWindow(Gtk.Window):
self.get_window().shape_combine_region(None, 0, 0)
def force_location(self):
self.set_decorated(False)
self.set_keep_above(True)
display = Gdk.Display.get_default()
if "get_monitor" in dir(display):
monitor = display.get_monitor(self.monitor)
geometry = monitor.get_geometry()
scale_factor = monitor.get_scale_factor()
if not self.floating:
w = scale_factor * geometry.width
h = scale_factor * geometry.height
x = geometry.x
y = geometry.y
self.resize(w, h)
self.move(x, y)
if not self.is_wayland:
self.set_decorated(False)
self.set_keep_above(True)
display = Gdk.Display.get_default()
if "get_monitor" in dir(display):
monitor = display.get_monitor(self.monitor)
geometry = monitor.get_geometry()
scale_factor = monitor.get_scale_factor()
if not self.floating:
w = scale_factor * geometry.width
h = scale_factor * geometry.height
x = geometry.x
y = geometry.y
self.resize(w, h)
self.move(x, y)
else:
self.move(self.x, self.y)
self.resize(self.w, self.h)
else:
self.move(self.x, self.y)
self.resize(self.w, self.h)
else:
if not self.floating:
screen = display.get_default_screen()
w = screen.width()
h = screen.height()
x = 0
y = 0
else:
self.move(self.x, self.y)
self.resize(self.w, self.h)
if not self.floating:
screen = display.get_default_screen()
w = screen.width()
h = screen.height()
x = 0
y = 0
else:
self.move(self.x, self.y)
self.resize(self.w, self.h)
if not self.floating:
(w, h) = self.get_size()
self.w = w
self.h = h
self.redraw()
def redraw(self):
gdkwin = self.get_window()
if not self.floating:
(w, h) = self.get_size()
self.w = w
self.h = h
if gdkwin:
if not self.compositing:
if not self.compositing or self.force_xshape:
(w, h) = self.get_size()
surface = cairo.ImageSurface(cairo.FORMAT_ARGB32, w, h)
surface_ctx = cairo.Context(surface)
self.do_draw(surface_ctx)
self.overlay_draw(None, surface_ctx)
reg = Gdk.cairo_region_create_from_surface(surface)
gdkwin.shape_combine_region(reg, 0, 0)
else:
gdkwin.shape_combine_region(None, 0, 0)
self.queue_draw()
def set_monitor(self, idx):
def set_monitor(self, idx=None, mon=None):
self.monitor = idx
if self.is_wayland:
if mon:
GtkLayerShell.set_monitor(self, mon)
self.force_location()
self.redraw()
@ -136,3 +194,6 @@ class OverlayWindow(Gtk.Window):
def col(self, c, a=1.0):
self.context.set_source_rgba(c[0], c[1], c[2], c[3] * a)
def set_force_xshape(self, force):
self.force_xshape = force

View file

@ -1,12 +1,22 @@
# 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 <https://www.gnu.org/licenses/>.
"""Settings tab parent class. Helpful if we need more overlay types without copy-and-pasting too much code"""
import os
import logging
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.0')
import sys
import os
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import logging
# pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk
try:
@ -15,14 +25,27 @@ except ModuleNotFoundError:
from xdg import XDG_CONFIG_HOME as xdg_config_home
class SettingsWindow(Gtk.Window):
class SettingsWindow(Gtk.VBox):
"""Settings tab parent class. Helpful if we need more overlay types without copy-and-pasting too much code"""
def __init__(self):
Gtk.VBox.__init__(self)
self.placement_window = None
self.configDir = None
self.configFile = None
self.overlay = None
self.floating_x = None
self.floating_y = None
self.floating_w = None
self.floating_h = None
def init_config(self):
self.configDir = os.path.join(xdg_config_home, "discover_overlay")
os.makedirs(self.configDir, exist_ok=True)
self.configFile = os.path.join(self.configDir, "config.ini")
self.read_config()
def close_window(self, a=None, b=None):
def close_window(self, _a=None, _b=None):
if self.placement_window:
(x, y) = self.placement_window.get_position()
(w, h) = self.placement_window.get_size()
@ -44,8 +67,24 @@ class SettingsWindow(Gtk.Window):
if display.get_monitor(i).get_model() == name:
return i
logging.info(
"Could not find monitor : %s" % (name))
"Could not find monitor : %s", name)
return 0
def present(self):
def get_monitor_obj(self, name):
display = Gdk.Display.get_default()
if "get_n_monitors" in dir(display):
for i in range(0, display.get_n_monitors()):
if display.get_monitor(i).get_model() == name:
return display.get_monitor(i)
logging.info(
"Could not find monitor : %s", name)
return None
def present_settings(self):
self.show_all()
def read_config(self):
pass
def save_config(self):
pass

View file

@ -0,0 +1,67 @@
# 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 <https://www.gnu.org/licenses/>.
"""Settings window holding all settings tab"""
import gi
from .voice_settings import VoiceSettingsWindow
from .text_settings import TextSettingsWindow
from .general_settings import GeneralSettingsWindow
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk
class MainSettingsWindow(Gtk.Window):
"""Settings window holding all settings tab"""
def __init__(self, text_overlay, voice_overlay):
Gtk.Window.__init__(self)
self.connect("destroy", self.close_window)
self.connect("delete-event", self.close_window)
self.text_overlay = text_overlay
self.voice_overlay = voice_overlay
self.set_title("Discover Overlay Configuration")
self.set_icon_name("discover-overlay")
self.set_default_size(280, 180)
# Create
nb = Gtk.Notebook()
# nb.set_tab_pos(Gtk.POS_TOP)
self.voice_settings = VoiceSettingsWindow(self.voice_overlay)
nb.append_page(self.voice_settings)
nb.set_tab_label_text(self.voice_settings, "Voice")
self.text_settings = TextSettingsWindow(self.text_overlay)
nb.append_page(self.text_settings)
nb.set_tab_label_text(self.text_settings, "Text")
self.core_settings = GeneralSettingsWindow(
self.text_overlay, self.voice_overlay)
nb.append_page(self.core_settings)
nb.set_tab_label_text(self.core_settings, "Core")
self.add(nb)
self.nb = nb
def close_window(self, a=None, b=None):
self.text_settings.close_window(a, b)
self.voice_settings.close_window(a, b)
self.core_settings.close_window(a, b)
self.hide()
return True
def present_settings(self):
self.voice_settings.present_settings()
self.text_settings.present_settings()
self.core_settings.present_settings()
self.nb.show()
self.show()

View file

@ -1,27 +1,55 @@
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.0')
import math
from .overlay import OverlayWindow
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import cairo
# 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 <https://www.gnu.org/licenses/>.
"""Overlay window for text"""
import logging
import time
import re
import cairo
import gi
from .image_getter import get_surface, draw_img_to_rect, get_aspected_size
from .overlay import OverlayWindow
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
# pylint: disable=wrong-import-position
from gi.repository import Pango, PangoCairo
class TextOverlayWindow(OverlayWindow):
def __init__(self):
"""Overlay window for voice"""
def __init__(self, discover):
OverlayWindow.__init__(self)
self.discover = discover
self.text_spacing = 4
self.content = []
self.text_font = None
self.text_size = 13
self.text_time = None
self.show_attach = None
self.popup_style = None
# 0, 0, self.text_size, self.text_size)
self.pango_rect = Pango.Rectangle()
self.pango_rect.width = self.text_size * Pango.SCALE
self.pango_rect.height = self.text_size * Pango.SCALE
self.connected = True
self.bg_col = [0.0, 0.6, 0.0, 0.1]
self.fg_col = [1.0, 1.0, 1.0, 1.0]
#self.text_time = 30
self.attachment = {}
self.imgList = []
self.imgFinder = re.compile(r"`")
self.set_title("Discover Text")
def set_text_time(self, t):
self.text_time = t
@ -45,58 +73,194 @@ class TextOverlayWindow(OverlayWindow):
self.bg_col = bg_col
self.redraw()
def set_show_attach(self, att):
self.show_attach = att
self.redraw()
def set_popup_style(self, b):
self.popup_style = b
def do_draw(self, context):
self.context = context
context.set_antialias(self.compositing)
(w, h) = self.get_size()
def set_font(self, name, size):
self.text_font = name
self.text_size = size
self.pango_rect = Pango.Rectangle()
self.pango_rect.width = self.text_size * Pango.SCALE
self.pango_rect.height = self.text_size * Pango.SCALE
self.redraw()
def make_line(self, msg):
ret = ""
if isinstance(msg, list):
for a in msg:
ret = "%s%s" % (ret, self.make_line(a))
elif isinstance(msg, str):
ret = msg
elif msg['type'] == 'strong':
ret = "<b>%s</b>" % (self.make_line(msg['content']))
elif msg['type'] == 'text':
ret = self.santize_string(msg['content'])
elif msg['type'] == 'link':
ret = "<u>%s</u>" % (self.make_line(msg['content']))
elif msg['type'] == 'emoji':
if 'surrogate' in msg:
# ['src'] is SVG URL
#ret = msg
ret = msg['surrogate']
else:
### Add Image ###
url = ("https://cdn.discordapp.com/emojis/%s.png?v=1" %
(msg['emojiId']))
img = {"url": url}
self.imgList.append(img)
ret = "`"
elif msg['type'] == 'inlineCode' or msg['type'] == 'codeBlock' or msg['type'] == 'blockQuote':
ret = "<span font_family=\"monospace\" background=\"#0004\">%s</span>" % (
self.make_line(msg['content']))
elif msg['type'] == 'u':
ret = "<u>%s</u>" % (self.make_line(msg['content']))
elif msg['type'] == 'em':
ret = "<i>%s</i>" % (self.make_line(msg['content']))
elif msg['type'] == 's':
ret = "<s>%s</s>" % (self.make_line(msg['content']))
elif msg['type'] == 'channel':
ret = self.make_line(msg['content'])
elif msg['type'] == 'mention':
ret = self.make_line(msg['content'])
elif msg['type'] == 'br':
ret = '\n'
else:
logging.error("Unknown text type : %s", msg["type"])
return ret
def recv_attach(self, identifier, pix):
self.attachment[identifier] = pix
self.redraw()
def overlay_draw(self, _w, context, data=None):
self.context = context
context.set_antialias(cairo.ANTIALIAS_GOOD)
(w, h) = self.get_size()
# Make background transparent
context.set_source_rgba(0.0, 0.0, 0.0, 0.0)
context.set_operator(cairo.OPERATOR_SOURCE)
context.paint()
if not self.connected:
return
context.save()
if self.is_wayland:
# Special case!
# The window is full-screen regardless of what the user has selected. Because Wayland
# We need to set a clip and a transform to imitate original behaviour
long_string = ""
sep = ""
w = self.w
h = self.h
context.translate(self.x, self.y)
context.rectangle(0, 0, w, h)
context.clip()
cy = h
tnow = time.time()
for line in self.content:
if not self.popup_style or tnow - line['time'] < self.text_time:
col = "#fff"
if 'nick_col' in line and line['nick_col']:
col = line['nick_col']
long_string = "%s%s<span foreground='%s'>%s</span>: %s" % (
long_string,
sep,
self.santize_string(col),
self.santize_string(line["nick"]),
self.santize_string(line["content"]))
sep = '\n'
if len(long_string) == 0:
return
layout = self.create_pango_layout(long_string)
layout.set_markup(long_string, -1)
attr = Pango.AttrList()
for line in reversed(self.content):
if self.popup_style and tnow - line['time'] > self.text_time:
break
out_line = ""
self.imgList = []
layout.set_width(Pango.SCALE * w)
col = "#fff"
if 'nick_col' in line and line['nick_col']:
col = line['nick_col']
for in_line in line['content']:
out_line = "%s%s" % (out_line, self.make_line(in_line))
if line['attach'] and self.show_attach:
at = line['attach'][0]
url = at['url']
if url in self.attachment:
cy = self.draw_attach(cy, url)
else:
get_surface(self.recv_attach,
url,
url, None)
# cy = self.draw_text(cy, "%s" % (line['attach']))
cy = self.draw_text(cy, "<span foreground='%s'>%s</span>: %s" % (self.santize_string(col),
self.santize_string(line["nick"]), out_line))
if cy <= 0:
# We've done enough
break
if self.is_wayland:
context.restore()
def draw_attach(self, y, url):
if url in self.attachment and self.attachment[url]:
pix = self.attachment[url]
iw = pix.get_width()
ih = pix.get_height()
iw = min(iw, self.w)
ih = min(ih, (self.h * .7))
(_ax, _ay, _aw, ah) = get_aspected_size(pix, iw, ih)
self.col(self.bg_col)
self.context.rectangle(0, y - ah, self.w, ah)
self.context.fill()
self.context.set_operator(cairo.OPERATOR_OVER)
_new_w, new_h = draw_img_to_rect(
pix, self.context, 0, y - ih, iw, ih, aspect=True)
return y - new_h
return y
def draw_text(self, y, text):
layout = self.create_pango_layout(text)
layout.set_markup(text, -1)
attr = layout.get_attributes()
layout.set_width(Pango.SCALE * self.w)
layout.set_spacing(Pango.SCALE * 3)
if(self.text_font):
font = Pango.FontDescription(
"%s %s" % (self.text_font, self.text_size))
layout.set_font_description(font)
tw, th = layout.get_pixel_size()
# Don't layer drawing over each other, always replace
_tw, th = layout.get_pixel_size()
self.col(self.bg_col)
context.rectangle(0, -th + h, w, th)
context.fill()
context.set_operator(cairo.OPERATOR_OVER)
self.context.rectangle(0, y - th, self.w, th)
self.context.fill()
self.context.set_operator(cairo.OPERATOR_OVER)
self.col(self.fg_col)
context.move_to(0, -th + h)
PangoCairo.show_layout(context, layout)
self.context.move_to(0, y - th)
PangoCairo.context_set_shape_renderer(
self.get_pango_context(), self.render_custom, None)
text = layout.get_text()
count = 0
for loc in self.imgFinder.finditer(text):
idx = loc.start()
if len(self.imgList) <= count:
break # We fucked up. Who types ` anyway
#url = self.imgList[count]
at = Pango.attr_shape_new_with_data(
self.pango_rect, self.pango_rect, count, None)
at.start_index = idx
at.end_index = idx + 1
attr.insert(at)
count += 1
layout.set_attributes(attr)
PangoCairo.show_layout(self.context, layout)
return y - th
def render_custom(self, ctx, shape, path, _data):
key = self.imgList[shape.data]['url']
if key not in self.attachment:
get_surface(self.recv_attach,
key,
key, None)
return
pix = self.attachment[key]
(x, y) = ctx.get_current_point()
draw_img_to_rect(pix, ctx, x, y - self.text_size, self.text_size,
self.text_size, path=path)
return True
def santize_string(self, string):
# I hate that Pango has nothing for this.

View file

@ -1,66 +1,101 @@
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.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 <https://www.gnu.org/licenses/>.
"""Text setting tab on settings window"""
import json
from configparser import ConfigParser
from .draggable_window import DraggableWindow
from .settings import SettingsWindow
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import logging
from configparser import ConfigParser
import gi
from .draggable_window import DraggableWindow
from .draggable_window_wayland import DraggableWindowWayland
from .settings import SettingsWindow
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk, Pango
GUILD_DEFAULT_VALUE = "0"
class TextSettingsWindow(SettingsWindow):
"""Text setting tab on settings window"""
def __init__(self, overlay):
Gtk.Window.__init__(self)
SettingsWindow.__init__(self)
self.overlay = overlay
self.set_size_request(400, 200)
self.connect("destroy", self.close_window)
self.connect("delete-event", self.close_window)
self.placement_window = None
self.init_config()
self.list_channels_keys = []
self.list_channels = {}
self.list_guilds_keys = []
self.list_guilds = {}
self.ignore_channel_change = False
self.ignore_guild_change = False
self.channel_lookup = None
self.channel_model = None
self.connector = None
self.guild_lookup = None
self.guild_model = None
self.guild_widget = None
self.align_x = None
self.align_y = None
self.monitor = None
self.floating = None
self.channel = None
self.guild = None
self.font = None
self.bg_col = None
self.fg_col = None
self.popup_style = None
self.text_time = None
self.show_attach = None
self.enabled = None
self.set_size_request(400, 200)
self.connect("destroy", self.close_window)
self.connect("delete-event", self.close_window)
self.init_config()
self.create_gui()
def present(self):
self.show_all()
if not self.floating:
self.align_x_widget.show()
self.align_y_widget.show()
self.align_monitor_widget.show()
self.align_placement_widget.hide()
else:
self.align_x_widget.hide()
self.align_y_widget.hide()
self.align_monitor_widget.hide()
self.align_placement_widget.show()
def update_channel_model(self):
# potentially organize channels by their group/parent_id
# https://discord.com/developers/docs/resources/channel#channel-object-channel-structure
c_model = Gtk.ListStore(str, bool)
self.channel_lookup = ["0"]
if self.popup_style:
self.text_time_widget.show()
self.text_time_label_widget.show()
else:
self.text_time_widget.hide()
self.text_time_label_widget.hide()
model = monitor_store = Gtk.ListStore(str, bool)
# for c in self.list_channels_keys:
# print(self.list_channels[c])
# model.append([self.list_channels[c]["name"]])
self.channel_lookup = []
for guild in self.guild_list():
guild_id, guild_name = guild
self.channel_lookup.append('0')
model.append([guild_name, False])
# if no guild is specified, populate channel list with every channel from each guild
if self.guild == GUILD_DEFAULT_VALUE:
c_model.append([guild_name, False])
for c in self.list_channels_keys:
chan = self.list_channels[c]
if chan['guild_id'] == guild_id:
c_model.append([chan["name"], True])
self.channel_lookup.append(c)
# if a guild is specified, poulate channel list with every channel from *just that guild*
if self.guild != GUILD_DEFAULT_VALUE:
for c in self.list_channels_keys:
chan = self.list_channels[c]
if chan['guild_id'] == guild_id:
model.append([chan["name"], True])
if chan['guild_id'] == self.guild:
c_model.append([chan["name"], True])
self.channel_lookup.append(c)
self.channel_widget.set_model(model)
self.channel_model = model
self.channel_widget.set_model(c_model)
self.channel_model = c_model
idx = 0
for c in self.channel_lookup:
@ -71,23 +106,78 @@ class TextSettingsWindow(SettingsWindow):
break
idx += 1
def add_connector(self, conn):
self.connector = conn
if self.channel:
self.connector.start_listening_text(self.channel)
def present_settings(self):
self.show_all()
if not self.floating:
self.align_x_widget.show()
self.align_y_widget.show()
self.align_monitor_widget.show()
self.align_placement_widget.hide()
else:
self.align_x_widget.hide()
self.align_y_widget.hide()
self.align_monitor_widget.show()
self.align_placement_widget.show()
if self.popup_style:
self.text_time_widget.show()
self.text_time_label_widget.show()
else:
self.text_time_widget.hide()
self.text_time_label_widget.hide()
g_model = Gtk.ListStore(str, bool)
self.guild_lookup = []
for guild in self.guild_list():
guild_id, guild_name = guild
self.guild_lookup.append(guild_id)
g_model.append([guild_name, True])
self.guild_widget.set_model(g_model)
self.guild_model = g_model
self.update_channel_model()
idxg = 0
for guild_id in self.guild_lookup:
if guild_id == self.guild:
self.ignore_guild_change = True
self.guild_widget.set_active(idxg)
self.ignore_guild_change = False
break
idxg += 1
def guild_list(self):
guilds = []
done = []
for channel in self.list_channels.values():
if not channel["guild_id"] in done:
done.append(channel["guild_id"])
guilds.append([channel["guild_id"], channel["guild_name"]])
for guild in self.list_guilds.values():
if not guild["id"] in done:
done.append(guild["id"])
guilds.append([guild["id"], guild["name"]])
return guilds
def set_channels(self, in_list):
self.list_channels = in_list
self.list_channels_keys = []
for key in in_list.keys():
# filter for only text channels
# https://discord.com/developers/docs/resources/channel#channel-object-channel-types
if in_list[key]["type"] == 0:
self.list_channels_keys.append(key)
self.list_channels_keys.sort()
def set_guilds(self, in_list):
self.list_guilds = in_list
self.list_guilds_keys = []
for key in in_list.keys():
self.list_guilds_keys.append(key)
self.list_guilds_keys.sort()
def read_config(self):
config = ConfigParser(interpolation=None)
config.read(self.configFile)
@ -101,6 +191,7 @@ class TextSettingsWindow(SettingsWindow):
self.floating_w = config.getint("text", "floating_w", fallback=400)
self.floating_h = config.getint("text", "floating_h", fallback=400)
self.channel = config.get("text", "channel", fallback="0")
self.guild = config.get("text", "guild", fallback=GUILD_DEFAULT_VALUE)
self.font = config.get("text", "font", fallback=None)
self.bg_col = json.loads(config.get(
"text", "bg_col", fallback="[0.0,0.0,0.0,0.5]"))
@ -109,21 +200,25 @@ class TextSettingsWindow(SettingsWindow):
self.popup_style = config.getboolean(
"text", "popup_style", fallback=False)
self.text_time = config.getint("text", "text_time", fallback=30)
self.show_attach = config.getboolean(
"text", "show_attach", fallback=True)
logging.info(
"Loading saved channel %s" % (self.channel))
"Loading saved channel %s", self.channel)
# Pass all of our config over to the overlay
self.overlay.set_enabled(self.enabled)
self.overlay.set_align_x(self.align_x)
self.overlay.set_align_y(self.align_y)
self.overlay.set_monitor(self.get_monitor_index(self.monitor))
self.overlay.set_monitor(self.get_monitor_index(
self.monitor), self.get_monitor_obj(self.monitor))
self.overlay.set_floating(
self.floating, self.floating_x, self.floating_y, self.floating_w, self.floating_h)
self.overlay.set_bg(self.bg_col)
self.overlay.set_fg(self.fg_col)
self.overlay.set_popup_style(self.popup_style)
self.overlay.set_text_time(self.text_time)
self.overlay.set_show_attach(self.show_attach)
def save_config(self):
config = ConfigParser(interpolation=None)
@ -136,15 +231,17 @@ class TextSettingsWindow(SettingsWindow):
config.set("text", "monitor", self.monitor)
config.set("text", "enabled", "%d" % (int(self.enabled)))
config.set("text", "floating", "%s" % (int(self.floating)))
config.set("text", "floating_x", "%s" % (self.floating_x))
config.set("text", "floating_y", "%s" % (self.floating_y))
config.set("text", "floating_w", "%s" % (self.floating_w))
config.set("text", "floating_h", "%s" % (self.floating_h))
config.set("text", "floating_x", "%s" % (int(self.floating_x)))
config.set("text", "floating_y", "%s" % (int(self.floating_y)))
config.set("text", "floating_w", "%s" % (int(self.floating_w)))
config.set("text", "floating_h", "%s" % (int(self.floating_h)))
config.set("text", "channel", self.channel)
config.set("text", "guild", self.guild)
config.set("text", "bg_col", json.dumps(self.bg_col))
config.set("text", "fg_col", json.dumps(self.fg_col))
config.set("text", "popup_style", "%s" % (int(self.popup_style)))
config.set("text", "text_time", "%s" % (int(self.text_time)))
config.set("text", "show_attach", "%s" % (int(self.show_attach)))
if self.font:
config.set("text", "font", self.font)
@ -250,15 +347,30 @@ class TextSettingsWindow(SettingsWindow):
channel.connect("changed", self.change_channel)
rt = Gtk.CellRendererText()
#channel.set_row_separator_func(lambda model, path: model[path][1])
channel.pack_start(rt, True)
channel.add_attribute(rt, "text", 0)
channel.add_attribute(rt, 'sensitive', 1)
guild_label = Gtk.Label.new("Server")
guild = Gtk.ComboBox.new()
guild.connect("changed", self.change_guild)
rt = Gtk.CellRendererText()
guild.pack_start(rt, True)
guild.add_attribute(rt, "text", 0)
guild.add_attribute(rt, 'sensitive', 1)
# Show Attachments
show_attach_label = Gtk.Label.new("Show Attachments")
show_attach = Gtk.CheckButton.new()
show_attach.set_active(self.show_attach)
show_attach.connect("toggled", self.change_show_attach)
self.align_x_widget = align_x
self.align_y_widget = align_y
self.align_monitor_widget = monitor
self.align_placement_widget = align_placement_button
self.guild_widget = guild
self.channel_widget = channel
self.text_time_widget = text_time
self.text_time_label_widget = text_time_label
@ -269,20 +381,25 @@ class TextSettingsWindow(SettingsWindow):
box.attach(popup_style, 1, 1, 1, 1)
box.attach(text_time_label, 0, 2, 1, 1)
box.attach(text_time, 1, 2, 1, 1)
box.attach(channel_label, 0, 3, 1, 1)
box.attach(channel, 1, 3, 1, 1)
box.attach(font_label, 0, 4, 1, 1)
box.attach(font, 1, 4, 1, 1)
box.attach(fg_col_label, 0, 5, 1, 1)
box.attach(fg_col, 1, 5, 1, 1)
box.attach(bg_col_label, 0, 6, 1, 1)
box.attach(bg_col, 1, 6, 1, 1)
box.attach(align_label, 0, 7, 1, 5)
#box.attach(align_type_box, 1, 7, 1, 1)
box.attach(monitor, 1, 8, 1, 1)
box.attach(align_x, 1, 9, 1, 1)
box.attach(align_y, 1, 10, 1, 1)
box.attach(align_placement_button, 1, 11, 1, 1)
box.attach(guild_label, 0, 3, 1, 1)
box.attach(guild, 1, 3, 1, 1)
box.attach(channel_label, 0, 4, 1, 1)
box.attach(channel, 1, 4, 1, 1)
box.attach(font_label, 0, 5, 1, 1)
box.attach(font, 1, 5, 1, 1)
box.attach(fg_col_label, 0, 6, 1, 1)
box.attach(fg_col, 1, 6, 1, 1)
box.attach(bg_col_label, 0, 7, 1, 1)
box.attach(bg_col, 1, 7, 1, 1)
box.attach(align_label, 0, 8, 1, 5)
#box.attach(align_type_box, 1, 8, 1, 1)
box.attach(monitor, 1, 9, 1, 1)
box.attach(align_x, 1, 10, 1, 1)
box.attach(align_y, 1, 11, 1, 1)
box.attach(align_placement_button, 1, 12, 1, 1)
box.attach(show_attach_label, 0, 13, 1, 1)
box.attach(show_attach, 1, 13, 1, 1)
self.add(box)
@ -300,28 +417,47 @@ class TextSettingsWindow(SettingsWindow):
def change_channel(self, button):
if self.ignore_channel_change:
return
c = self.channel_lookup[button.get_active()]
self.connector.start_listening_text(c)
self.channel = c
self.save_config()
def change_guild(self, button):
if self.ignore_guild_change:
return
guild_id = self.guild_lookup[button.get_active()]
self.guild = guild_id
self.save_config()
self.update_channel_model()
def change_placement(self, button):
if self.placement_window:
(x, y) = self.placement_window.get_position()
(w, h) = self.placement_window.get_size()
(x, y, w, h) = self.placement_window.get_coords()
self.floating_x = x
self.floating_y = y
self.floating_w = w
self.floating_h = h
self.overlay.set_floating(True, x, y, w, h)
self.save_config()
button.set_label("Place Window")
if not self.overlay.is_wayland:
button.set_label("Place Window")
self.placement_window.close()
self.placement_window = None
else:
self.placement_window = DraggableWindow(
x=self.floating_x, y=self.floating_y, w=self.floating_w, h=self.floating_h, message="Place & resize this window then press Save!")
button.set_label("Save this position")
if self.overlay.is_wayland:
self.placement_window = DraggableWindowWayland(
x=self.floating_x, y=self.floating_y,
w=self.floating_w, h=self.floating_h,
message="Place & resize this window then press Green!", settings=self)
else:
self.placement_window = DraggableWindow(
x=self.floating_x, y=self.floating_y,
w=self.floating_w, h=self.floating_h,
message="Place & resize this window then press Save!", settings=self)
if not self.overlay.is_wayland:
button.set_label("Save this position")
def change_align_type_edge(self, button):
if button.get_active():
@ -352,7 +488,7 @@ class TextSettingsWindow(SettingsWindow):
if "get_monitor" in dir(display):
mon = display.get_monitor(button.get_active())
m_s = mon.get_model()
self.overlay.set_monitor(button.get_active())
self.overlay.set_monitor(button.get_active(), mon)
self.monitor = m_s
self.save_config()
@ -413,3 +549,9 @@ class TextSettingsWindow(SettingsWindow):
self.fg_col = c
self.save_config()
def change_show_attach(self, button):
self.overlay.set_show_attach(button.get_active())
self.show_attach = button.get_active()
self.save_config()

View file

@ -1,20 +1,29 @@
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.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 <https://www.gnu.org/licenses/>.
"""Overlay window for voice"""
import math
from .overlay import OverlayWindow
from .image_getter import get_image
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import cairo
import logging
from .overlay import OverlayWindow
from .image_getter import get_surface, draw_img_to_rect
class VoiceOverlayWindow(OverlayWindow):
def __init__(self):
"""Overlay window for voice"""
def __init__(self, discover):
OverlayWindow.__init__(self)
self.discover = discover
self.avatars = {}
self.avatar_size = 48
@ -24,21 +33,26 @@ class VoiceOverlayWindow(OverlayWindow):
self.icon_spacing = 8
self.vert_edge_padding = 0
self.horz_edge_padding = 0
self.only_speaking = None
self.highlight_self = None
self.order = None
self.def_avatar = None
self.round_avatar = True
self.icon_only = True
self.talk_col = [0.0, 0.6, 0.0, 0.1]
self.text_col = [1.0, 1.0, 1.0, 1.0]
self.norm_col = [0.0, 0.0, 0.0, 0.5]
self.wind_col = [0.0, 0.0, 0.0, 0.0]
self.mute_col = [0.7, 0.0, 0.0, 1.0]
self.userlist = []
self.users_to_draw = []
self.connected = False
self.force_location()
self.def_avatar = get_image(self.recv_avatar,
"https://cdn.discordapp.com/embed/avatars/3.png",
'def', self.avatar_size)
self.first_draw = True
get_surface(self.recv_avatar,
"https://cdn.discordapp.com/embed/avatars/3.png",
'def', self.avatar_size)
self.set_title("Discover Voice")
def set_bg(self, bg):
self.norm_col = bg
@ -58,7 +72,6 @@ class VoiceOverlayWindow(OverlayWindow):
def set_avatar_size(self, size):
self.avatar_size = size
self.reset_avatar()
self.redraw()
def set_icon_spacing(self, i):
@ -81,6 +94,19 @@ class VoiceOverlayWindow(OverlayWindow):
self.round_avatar = not i
self.redraw()
def set_only_speaking(self, only_speaking):
self.only_speaking = only_speaking
def set_highlight_self(self, highlight_self):
self.highlight_self = highlight_self
def set_order(self, i):
self.order = i
def set_icon_only(self, i):
self.icon_only = i
self.redraw()
def set_wind_col(self):
self.col(self.wind_col)
@ -96,15 +122,20 @@ class VoiceOverlayWindow(OverlayWindow):
def set_mute_col(self, a=1.0):
self.col(self.mute_col, a)
def reset_avatar(self):
self.avatars = {}
self.def_avatar = get_image(self.recv_avatar,
"https://cdn.discordapp.com/embed/avatars/3.png",
'def', self.avatar_size)
def set_user_list(self, userlist, alt):
self.userlist = userlist
self.userlist.sort(key=lambda x: x["username"])
for user in userlist:
if "nick" in user:
user["friendlyname"] = user["nick"]
else:
user["friendlyname"] = user["username"]
if self.order == 1: # ID Sort
self.userlist.sort(key=lambda x: x["id"])
elif self.order == 2: # Spoken sort
self.userlist.sort(key=lambda x: x["lastspoken"], reverse=True)
self.userlist.sort(key=lambda x: x["speaking"], reverse=True)
else: # Name sort
self.userlist.sort(key=lambda x: x["friendlyname"])
screen = self.get_screen()
c = screen.is_composited()
if not self.compositing == c:
@ -119,28 +150,64 @@ class VoiceOverlayWindow(OverlayWindow):
self.connected = is_connected
self.redraw()
def draw(self, widget, context):
# Draw
self.do_draw(context)
def do_draw(self, context):
def overlay_draw(self, _w, context, _data=None):
self.context = context
context.set_antialias(cairo.ANTIALIAS_GOOD)
# Get size of window
(w, h) = self.get_size()
# Make background transparent
self.set_wind_col()
# Don't layer drawing over each other, always replace
context.set_operator(cairo.OPERATOR_SOURCE)
context.paint()
context.save()
if self.is_wayland:
# Special case!
# The window is full-screen regardless of what the user has selected. Because Wayland
# We need to set a clip and a transform to imitate original behaviour
w = self.w
h = self.h
context.translate(self.x, self.y)
context.rectangle(0, 0, w, h)
context.clip()
context.set_operator(cairo.OPERATOR_OVER)
if not self.connected:
return
# Get size of window
(w, h) = self.get_size()
connection = self.discover.connection
self_user = connection.user
# Gather which users to draw
self.users_to_draw = self.userlist[:]
for user in self.userlist:
# Bad object equality here, so we need to reassign
if user["id"] == self_user["id"]:
self_user = user
# Update friendly name with nick if possible
if "nick" in user:
user["friendlyname"] = user["nick"]
else:
user["friendlyname"] = user["username"]
# Remove users that arent speaking
if self.only_speaking:
speaking = "speaking" in user and user["speaking"]
if not speaking:
self.users_to_draw.remove(user)
if self.highlight_self:
try:
self.users_to_draw.remove(self_user)
except ValueError:
pass # Not in list
self.users_to_draw.insert(0, self_user)
# Calculate height needed to show overlay
height = (len(self.userlist) * self.avatar_size) + \
(len(self.userlist) + 1) * self.icon_spacing
height = (len(self.users_to_draw) * self.avatar_size) + \
(len(self.users_to_draw) + 1) * self.icon_spacing
# Choose where to start drawing
rh = 0 + self.vert_edge_padding
@ -149,71 +216,81 @@ class VoiceOverlayWindow(OverlayWindow):
rh = (h / 2) - (height / 2)
elif self.align_vert == 2:
rh = h - height - self.vert_edge_padding
# Iterate users in room.
for user in self.userlist:
for user in self.users_to_draw:
self.draw_avatar(context, user, rh)
# Shift the relative position down to next location
rh += self.avatar_size + self.icon_spacing
# Don't hold a ref
if self.is_wayland:
context.restore()
self.context = None
def recv_avatar(self, id, pix):
if(id == 'def'):
def recv_avatar(self, identifier, pix):
if identifier == 'def':
self.def_avatar = pix
else:
self.avatars[id] = pix
self.avatars[identifier] = pix
self.redraw()
def delete_avatar(self, id):
def delete_avatar(self, identifier):
if id in self.avatars:
del self.avatars[id]
del self.avatars[identifier]
def draw_avatar(self, context, user, y):
# Ensure pixbuf for avatar
if user["id"] not in self.avatars and user["avatar"]:
url = "https://cdn.discordapp.com/avatars/%s/%s.jpg" % (
user['id'], user['avatar'])
get_image(self.recv_avatar, url, user["id"],
self.avatar_size)
get_surface(self.recv_avatar, url, user["id"],
self.avatar_size)
# Set the key with no value to avoid spamming requests
self.avatars[user["id"]] = None
(w, h) = self.get_size()
c = None
mute = False
alpha = 1.0
if "speaking" in user and user["speaking"]:
c = self.talk_col
deaf = False
if "mute" in user and user["mute"]:
mute = True
if "deaf" in user and user["deaf"]:
alpha = 0.5
deaf = True
if "speaking" in user and user["speaking"] and not deaf and not mute:
c = self.talk_col
pix = None
if user["id"] in self.avatars:
pix = self.avatars[user["id"]]
if self.align_right:
self.draw_text(
context, user["username"], w - self.avatar_size - self.horz_edge_padding, y)
if not self.icon_only:
self.draw_text(
context, user["friendlyname"], self.w - self.avatar_size - self.horz_edge_padding, y)
self.draw_avatar_pix(
context, pix, w - self.avatar_size - self.horz_edge_padding, y, c, alpha)
if mute:
self.draw_mute(context, w - self.avatar_size -
self.horz_edge_padding, y, alpha)
context, pix, self.w - self.avatar_size - self.horz_edge_padding, y, c)
if deaf:
self.draw_deaf(context, self.w - self.avatar_size -
self.horz_edge_padding, y)
elif mute:
self.draw_mute(context, self.w - self.avatar_size -
self.horz_edge_padding, y)
else:
self.draw_text(
context, user["username"], self.avatar_size + self.horz_edge_padding, y)
if not self.icon_only:
self.draw_text(
context, user["friendlyname"], self.avatar_size + self.horz_edge_padding, y)
self.draw_avatar_pix(
context, pix, self.horz_edge_padding, y, c, alpha)
if mute:
self.draw_mute(context, self.horz_edge_padding, y, alpha)
context, pix, self.horz_edge_padding, y, c)
if deaf:
self.draw_deaf(context, self.horz_edge_padding, y)
elif mute:
self.draw_mute(context, self.horz_edge_padding, y)
def draw_text(self, context, string, x, y):
if self.text_font:
context.set_font_face(cairo.ToyFontFace(
self.text_font, cairo.FontSlant.NORMAL, cairo.FontWeight.NORMAL))
context.set_font_size(self.text_size)
xb, yb, w, h, dx, dy = context.text_extents(string)
_xb, _yb, w, h, _dx, _dy = context.text_extents(string)
ho = (self.avatar_size / 2) - (h / 2)
if self.align_right:
context.move_to(0, 0)
@ -236,27 +313,24 @@ class VoiceOverlayWindow(OverlayWindow):
context.move_to(x + self.text_pad, y + ho + h)
context.show_text(string)
def draw_avatar_pix(self, context, pixbuf, x, y, c, alpha):
def draw_avatar_pix(self, context, pixbuf, x, y, c):
if not pixbuf:
pixbuf = self.def_avatar
if not pixbuf:
return
if not pixbuf:
return
context.move_to(x, y)
context.save()
#context.set_source_pixbuf(pixbuf, 0.0, 0.0)
if self.round_avatar:
context.arc(x + (self.avatar_size / 2), y +
(self.avatar_size / 2), self.avatar_size / 2, 0, 2 * math.pi)
context.clip()
self.set_wind_col()
self.set_norm_col()
context.set_operator(cairo.OPERATOR_SOURCE)
context.rectangle(x, y, self.avatar_size, self.avatar_size)
context.fill()
context.set_operator(cairo.OPERATOR_OVER)
Gdk.cairo_set_source_pixbuf(context, pixbuf, x, y)
context.paint_with_alpha(alpha)
draw_img_to_rect(pixbuf, context, x, y,
self.avatar_size, self.avatar_size)
context.restore()
if c:
if self.round_avatar:
@ -269,11 +343,11 @@ class VoiceOverlayWindow(OverlayWindow):
self.col(c)
context.stroke()
def draw_mute(self, context, x, y, a):
def draw_mute(self, context, x, y):
context.save()
context.translate(x, y)
context.scale(self.avatar_size, self.avatar_size)
self.set_mute_col(a)
self.set_mute_col()
context.save()
# Clip Strike-through
@ -322,3 +396,50 @@ class VoiceOverlayWindow(OverlayWindow):
context.fill()
context.restore()
def draw_deaf(self, context, x, y):
context.save()
context.translate(x, y)
context.scale(self.avatar_size, self.avatar_size)
self.set_mute_col()
context.save()
# Clip Strike-through
context.set_fill_rule(cairo.FILL_RULE_EVEN_ODD)
context.set_line_width(0.1)
context.move_to(0.0, 0.0)
context.line_to(1.0, 0.0)
context.line_to(1.0, 1.0)
context.line_to(0.0, 1.0)
context.line_to(0.0, 0.0)
context.close_path()
context.new_sub_path()
context.arc(0.9, 0.1, 0.05, 1.25 * math.pi, 2.25 * math.pi)
context.arc(0.1, 0.9, 0.05, .25 * math.pi, 1.25 * math.pi)
context.close_path()
context.clip()
# Top band
context.arc(0.5, 0.5, 0.2, 1.0 * math.pi, 0)
context.stroke()
# Left band
context.arc(0.28, 0.65, 0.075, 1.5 * math.pi, 0.5 * math.pi)
context.move_to(0.3, 0.5)
context.line_to(0.3, 0.75)
context.stroke()
# Right band
context.arc(0.72, 0.65, 0.075, 0.5 * math.pi, 1.5 * math.pi)
context.move_to(0.7, 0.5)
context.line_to(0.7, 0.75)
context.stroke()
context.restore()
# Strike through
context.arc(0.7, 0.3, 0.035, 1.25 * math.pi, 2.25 * math.pi)
context.arc(0.3, 0.7, 0.035, .25 * math.pi, 1.25 * math.pi)
context.close_path()
context.fill()
context.restore()

View file

@ -1,29 +1,61 @@
import gi
gi.require_version("Gtk", "3.0")
gi.require_version('PangoCairo', '1.0')
gi.require_version('GdkPixbuf', '2.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 <https://www.gnu.org/licenses/>.
"""Voice setting tab on settings window"""
import json
from configparser import ConfigParser
import gi
from .draggable_window import DraggableWindow
from .draggable_window_wayland import DraggableWindowWayland
from .settings import SettingsWindow
from gi.repository.GdkPixbuf import Pixbuf
from gi.repository import Gtk, GLib, Gio, GdkPixbuf, Gdk, Pango, PangoCairo
import logging
gi.require_version("Gtk", "3.0")
# pylint: disable=wrong-import-position
from gi.repository import Gtk, Gdk, Pango
class VoiceSettingsWindow(SettingsWindow):
"""Voice setting tab on settings window"""
def __init__(self, overlay):
Gtk.Window.__init__(self)
SettingsWindow.__init__(self)
self.overlay = overlay
self.set_size_request(400, 200)
self.connect("destroy", self.close_window)
self.connect("delete-event", self.close_window)
self.placement_window = None
self.align_x = None
self.align_y = None
self.bg_col = None
self.fg_col = None
self.tk_col = None
self.mt_col = None
self.avatar_size = None
self.icon_spacing = None
self.text_padding = None
self.font = None
self.square_avatar = None
self.only_speaking = None
self.highlight_self = None
self.icon_only = None
self.monitor = None
self.vert_edge_padding = None
self.horz_edge_padding = None
self.floating = None
self.order = None
self.init_config()
self.create_gui()
def present(self):
def present_settings(self):
self.show_all()
if not self.floating:
self.align_x_widget.show()
@ -33,7 +65,7 @@ class VoiceSettingsWindow(SettingsWindow):
else:
self.align_x_widget.hide()
self.align_y_widget.hide()
self.align_monitor_widget.hide()
self.align_monitor_widget.show()
self.align_placement_widget.show()
def read_config(self):
@ -55,6 +87,12 @@ class VoiceSettingsWindow(SettingsWindow):
self.font = config.get("main", "font", fallback=None)
self.square_avatar = config.getboolean(
"main", "square_avatar", fallback=False)
self.only_speaking = config.getboolean(
"main", "only_speaking", fallback=False)
self.highlight_self = config.getboolean(
"main", "highlight_self", fallback=False)
self.icon_only = config.getboolean(
"main", "icon_only", fallback=False)
self.monitor = config.get("main", "monitor", fallback="None")
self.vert_edge_padding = config.getint(
"main", "vert_edge_padding", fallback=0)
@ -65,6 +103,7 @@ class VoiceSettingsWindow(SettingsWindow):
self.floating_y = config.getint("main", "floating_y", fallback=0)
self.floating_w = config.getint("main", "floating_w", fallback=400)
self.floating_h = config.getint("main", "floating_h", fallback=400)
self.order = config.getint("main", "order", fallback=0)
# Pass all of our config over to the overlay
self.overlay.set_align_x(self.align_x)
@ -77,9 +116,14 @@ class VoiceSettingsWindow(SettingsWindow):
self.overlay.set_icon_spacing(self.icon_spacing)
self.overlay.set_text_padding(self.text_padding)
self.overlay.set_square_avatar(self.square_avatar)
self.overlay.set_monitor(self.get_monitor_index(self.monitor))
self.overlay.set_only_speaking(self.only_speaking)
self.overlay.set_highlight_self(self.highlight_self)
self.overlay.set_icon_only(self.icon_only)
self.overlay.set_monitor(self.get_monitor_index(
self.monitor), self.get_monitor_obj(self.monitor))
self.overlay.set_vert_edge_padding(self.vert_edge_padding)
self.overlay.set_horz_edge_padding(self.horz_edge_padding)
self.overlay.set_order(self.order)
self.overlay.set_floating(
self.floating, self.floating_x, self.floating_y, self.floating_w, self.floating_h)
@ -109,6 +153,9 @@ class VoiceSettingsWindow(SettingsWindow):
if self.font:
config.set("main", "font", self.font)
config.set("main", "square_avatar", "%d" % (int(self.square_avatar)))
config.set("main", "only_speaking", "%d" % (int(self.only_speaking)))
config.set("main", "highlight_self", "%d" % (int(self.highlight_self)))
config.set("main", "icon_only", "%d" % (int(self.icon_only)))
config.set("main", "monitor", self.monitor)
config.set("main", "vert_edge_padding", "%d" %
(self.vert_edge_padding))
@ -119,6 +166,7 @@ class VoiceSettingsWindow(SettingsWindow):
config.set("main", "floating_y", "%s" % (self.floating_y))
config.set("main", "floating_w", "%s" % (self.floating_w))
config.set("main", "floating_h", "%s" % (self.floating_h))
config.set("main", "order", "%s" % (self.order))
with open(self.configFile, 'w') as file:
config.write(file)
@ -256,6 +304,35 @@ class VoiceSettingsWindow(SettingsWindow):
square_avatar.set_active(self.square_avatar)
square_avatar.connect("toggled", self.change_square_avatar)
only_speaking_label = Gtk.Label.new("Display Speakers Only")
only_speaking = Gtk.CheckButton.new()
only_speaking.set_active(self.only_speaking)
only_speaking.connect("toggled", self.change_only_speaking)
highlight_self_label = Gtk.Label.new("Highlight Self")
highlight_self = Gtk.CheckButton.new()
highlight_self.set_active(self.highlight_self)
highlight_self.connect("toggled", self.change_highlight_self)
# Display icon only
icon_only_label = Gtk.Label.new("Display Icon Only")
icon_only = Gtk.CheckButton.new()
icon_only.set_active(self.icon_only)
icon_only.connect("toggled", self.change_icon_only)
# Order avatars
order_label = Gtk.Label.new("Order Avatars By")
order_store = Gtk.ListStore(str)
order_store.append(["Alphabetically"])
order_store.append(["ID"])
order_store.append(["Last Spoken"])
order = Gtk.ComboBox.new_with_model(order_store)
order.set_active(self.order)
order.connect("changed", self.change_order)
rt = Gtk.CellRendererText()
order.pack_start(rt, True)
order.add_attribute(rt, "text", 0)
box.attach(font_label, 0, 0, 1, 1)
box.attach(font, 1, 0, 1, 1)
box.attach(bg_col_label, 0, 1, 1, 1)
@ -284,31 +361,43 @@ class VoiceSettingsWindow(SettingsWindow):
box.attach(horz_edge_padding, 1, 14, 1, 1)
box.attach(square_avatar_label, 0, 15, 1, 1)
box.attach(square_avatar, 1, 15, 1, 1)
box.attach(only_speaking_label, 0, 16, 1, 1)
box.attach(only_speaking, 1, 16, 1, 1)
box.attach(highlight_self_label, 0, 17, 1, 1)
box.attach(highlight_self, 1, 17, 1, 1)
box.attach(icon_only_label, 0, 18, 1, 1)
box.attach(icon_only, 1, 18, 1, 1)
box.attach(order_label, 0, 19, 1, 1)
box.attach(order, 1, 19, 1, 1)
self.add(box)
pass
def change_placement(self, button):
if self.placement_window:
(x, y) = self.placement_window.get_position()
(w, h) = self.placement_window.get_size()
(x, y, w, h) = self.placement_window.get_coords()
self.floating_x = x
self.floating_y = y
self.floating_w = w
self.floating_h = h
self.overlay.set_floating(True, x, y, w, h)
self.save_config
button.set_label("Place Window")
self.save_config()
if not self.overlay.is_wayland:
button.set_label("Place Window")
self.placement_window.close()
self.placement_window = None
else:
self.placement_window = DraggableWindow(
x=self.floating_x, y=self.floating_y,
w=self.floating_w, h=self.floating_h,
message="Place & resize this window then press Save!")
button.set_label("Save this position")
if self.overlay.is_wayland:
self.placement_window = DraggableWindowWayland(
x=self.floating_x, y=self.floating_y,
w=self.floating_w, h=self.floating_h,
message="Place & resize this window then press Green!", settings=self)
else:
self.placement_window = DraggableWindow(
x=self.floating_x, y=self.floating_y,
w=self.floating_w, h=self.floating_h,
message="Place & resize this window then press Save!", settings=self)
if not self.overlay.is_wayland:
button.set_label("Save this position")
def change_align_type_edge(self, button):
if button.get_active():
@ -390,7 +479,7 @@ class VoiceSettingsWindow(SettingsWindow):
if "get_monitor" in dir(display):
mon = display.get_monitor(button.get_active())
m_s = mon.get_model()
self.overlay.set_monitor(button.get_active())
self.overlay.set_monitor(button.get_active(), mon)
self.monitor = m_s
self.save_config()
@ -436,3 +525,27 @@ class VoiceSettingsWindow(SettingsWindow):
self.square_avatar = button.get_active()
self.save_config()
def change_only_speaking(self, button):
self.overlay.set_only_speaking(button.get_active())
self.only_speaking = button.get_active()
self.save_config()
def change_highlight_self(self, button):
self.overlay.set_highlight_self(button.get_active())
self.highlight_self = button.get_active()
self.save_config()
def change_icon_only(self, button):
self.overlay.set_icon_only(button.get_active())
self.icon_only = button.get_active()
self.save_config()
def change_order(self, button):
self.overlay.set_order(button.get_active())
self.order = button.get_active()
self.save_config()

View file

@ -0,0 +1,8 @@
[Desktop Entry]
Name=Discover Overlay - Close
Comment=Close Discover Overlay
Exec=discover-overlay --close
Icon=discover-overlay
Terminal=false
Type=Application
Catergories=Network;

View file

@ -0,0 +1,8 @@
[Desktop Entry]
Name=Discover Overlay - Config
Comment=Open configuration for Discover Overlay
Exec=discover-overlay --configure
Icon=discover-overlay
Terminal=false
Type=Application
Catergories=Network;

View file

@ -9,7 +9,7 @@ setup(
name='discover-overlay',
author='trigg',
author_email='',
version='0.2',
version='0.3.1',
description='Voice chat overlay',
long_description=readme(),
long_description_content_type='text/markdown',
@ -17,7 +17,8 @@ setup(
packages=find_packages(),
include_package_data=True,
data_files=[
('share/applications', ['discover_overlay.desktop']),
('share/applications', ['discover_overlay.desktop',
'discover_overlay_conf.desktop', 'discover_overlay_close.desktop']),
('share/icons', ['discover-overlay.png'])
],
install_requires=[
@ -25,6 +26,8 @@ setup(
'websocket-client',
'pyxdg',
'requests',
'python-pidfile>=3.0.0',
'pillow',
],
entry_points={
'console_scripts': [