diff --git a/requirements.txt b/requirements.txt index 4014a88..c19e323 100644 --- a/requirements.txt +++ b/requirements.txt @@ -4,4 +4,6 @@ twitchAPI==4.4.0 customtkinter==5.2.2 Quart==0.20.0 SQLAlchemy==2.0.36 -aiosqlite==0.20.0 \ No newline at end of file +aiosqlite==0.20.0 +pillow==11.1.0 +requests==2.32.3 \ No newline at end of file diff --git a/src/chatdnd/events/chat_events.py b/src/chatdnd/events/chat_events.py index f4a14fa..fd548f7 100644 --- a/src/chatdnd/events/chat_events.py +++ b/src/chatdnd/events/chat_events.py @@ -6,4 +6,5 @@ chat_on_channel_fetch = Event() chat_bot_on_connect = Event() -chat_say_command = Event() \ No newline at end of file +chat_say_command = Event() +chat_on_join_queue = Event() \ No newline at end of file diff --git a/src/chatdnd/session_manager.py b/src/chatdnd/session_manager.py index 98f871c..45b41f7 100644 --- a/src/chatdnd/session_manager.py +++ b/src/chatdnd/session_manager.py @@ -1,6 +1,6 @@ +import random -from data import Session, Member - +from data import Session, Member, SessionState from custom_logger.logger import logger class SessionManager(): @@ -12,10 +12,16 @@ def join_queue(self, member: Member): def start_session(self, party_size: int = 4) -> bool: if len(self.session.queue) < party_size: - return False + return False + self.session.state = SessionState.STARTED self.session.party.clear() - self.session.party.update(random.sample(self.session.queue, party_size)) + self.session.party.update(random.sample(sorted(self.session.queue), party_size)) + self.session.queue.clear() return True def end(self): - self.session.clear() \ No newline at end of file + self.session.clear() + self.session.state = SessionState.NONE + + def open(self): + self.session.state = SessionState.OPEN \ No newline at end of file diff --git a/src/custom_logger/logger.py b/src/custom_logger/logger.py index 5729785..29baae4 100644 --- a/src/custom_logger/logger.py +++ b/src/custom_logger/logger.py @@ -28,7 +28,7 @@ class CustomLogger: def __init__(self, name): self.logger = logging.getLogger(name) debug_mode = os.environ['TCDND_DEBUG_MODE'] == '1' - self.logger.setLevel(logging.DEBUG if debug_mode else logging.INFO) # TODO env var + self.logger.setLevel(logging.DEBUG if debug_mode else logging.INFO) self.log_queue = Queue() diff --git a/src/data/__init__.py b/src/data/__init__.py index c002b38..ea3f6b9 100644 --- a/src/data/__init__.py +++ b/src/data/__init__.py @@ -1,4 +1,4 @@ from data.member import Member -from data.session import Session +from data.session import Session, SessionState -__all__ = [Member, Session] \ No newline at end of file +__all__ = [Member, Session, SessionState] \ No newline at end of file diff --git a/src/data/member.py b/src/data/member.py index 41c1fdd..0cb04ae 100644 --- a/src/data/member.py +++ b/src/data/member.py @@ -1,6 +1,7 @@ from sqlalchemy.ext.asyncio import AsyncSession, create_async_engine from sqlalchemy.orm import sessionmaker, Mapped, mapped_column -from sqlalchemy import String, Integer, JSON +from sqlalchemy import String, Integer, JSON, asc +from sqlalchemy.future import select from data.base import Base @@ -37,7 +38,13 @@ def __hash__(self): def __repr__(self): - return f"Member(name='{self.name})" + return f"Member(name='{self.name}')" + + def __lt__(self, other): + return self.name < other.name + + def __gt__(self, other): + return self.name > other.name async def create_or_get_member(name: str, pfp_url: str = "") -> Member: @@ -56,6 +63,7 @@ async def _upsert_member(name: str, pfp_url: str) -> Member: # Update existing member if member.pfp_url != pfp_url: member.pfp_url = pfp_url + await session.commit() return member else: # Create new member @@ -63,9 +71,40 @@ async def _upsert_member(name: str, pfp_url: str) -> Member: session.add(new_member) return new_member +async def update_tts(member: Member, preferred_tts: str = ""): + async with async_session() as session: + async with session.begin(): + member_in_db = await session.get(Member, member.id) + if member_in_db: + member_in_db.preferred_tts = preferred_tts + await session.commit() + + async def fetch_member(name: str) -> Member | None: name = name.lower() async with async_session() as session: query = select(Member).where(Member.name == name) result = await session.execute(query) - return result.scalars().first() \ No newline at end of file + return result.scalars().first() + + +async def fetch_paginated_members(page: int, per_page: int=20, + exclude_names: list[str] = None, + name_filter: str = None) -> list[Member]: + if not exclude_names: + exclude_names = [] + + async with async_session() as session: + query = select(Member).order_by(asc(Member.name)) + + if exclude_names: + query = query.where(Member.name.notin_([name.lower()] for name in exclude_names)) + + if name_filter: + query = query.where(Member.name.like(f"%{name_filter.lower()}%")) + + offset = (page -1) * per_page + query = query.offset(offset).limit(per_page) + + result = await session.execute(query) + return result.scalars().all() \ No newline at end of file diff --git a/src/data/session.py b/src/data/session.py index 1d2e76b..7d32950 100644 --- a/src/data/session.py +++ b/src/data/session.py @@ -3,11 +3,19 @@ from data.member import Member +from enum import Enum, auto + +class SessionState(Enum): + NONE = auto() + OPEN = auto() + STARTED = auto() + class Session(): def __init__(self): self.queue: Set[Member] = set() self.party: Set[Member] = set() + self.state: SessionState = SessionState.NONE def join_queue(self, member: Member): self.queue.add(member) @@ -15,3 +23,4 @@ def join_queue(self, member: Member): def clear(self): self.queue.clear() self.party.clear() + self.state: SessionState = SessionState.NONE diff --git a/src/helpers/event.py b/src/helpers/event.py index 54f30fa..7f81a52 100644 --- a/src/helpers/event.py +++ b/src/helpers/event.py @@ -3,6 +3,10 @@ import threading from custom_logger.logger import logger + +# Main sets this at startup. It's cursed, but it works. +_task_queue = None + class Event: def __init__(self): self.__listeners = [] @@ -45,13 +49,14 @@ def trigger(self, args = None): func(*args) except RuntimeError as e: if "main thread is not in main loop" in str(e): - logger.error(f"Error: {e}. This is likely to occur when a trigger is fired from a separate thread to the UI thread.") + _task_queue.put((func, *args)) continue else: raise except Exception as e: raise + async def _run_async_func(self, func, *args): try: await func(*args) diff --git a/src/main.py b/src/main.py index f3c0033..13fb632 100644 --- a/src/main.py +++ b/src/main.py @@ -13,6 +13,11 @@ def parse_args(): args = parse_args() os.environ['TCDND_DEBUG_MODE'] = '1' if args.debug else '0' +from queue import Queue +_tasks = Queue() +import helpers.event as _event_module +setattr(sys.modules[_event_module.__name__], '_task_queue', _tasks) + from twitch.utils import TwitchUtils from twitch.chat import ChatController from twitchAPI.type import TwitchAuthorizationException @@ -45,6 +50,7 @@ def parse_args(): APP_RUNNING = True + async def run_twitch(): async def try_setup(): @@ -131,13 +137,33 @@ async def run_ui(): sys.exit(0) # app.mainloop() +async def run_queued_tasks(): + while APP_RUNNING: + try: + callback = None + args = None + if _tasks.empty(): + await asyncio.sleep(0.5) + else: + items = _tasks.get(False) + callback = items[0] + if len(items) > 1: + args = items[1:] + callback(*args) + else: + callback() + except Exception as e: + logger.error(f"Error in queued task: {callback} ({args}) - {e}") + + async def run_all(): tasks = [ - asyncio.create_task(initialize_database()), + asyncio.create_task(initialize_database(), name="DB-Setup"), asyncio.create_task(run_server(), name="Server"), asyncio.create_task(run_twitch(), name="Twitch"), asyncio.create_task(run_ui(), name="UI"), - asyncio.create_task(run_twitch_bot(), name="Twitch-Bot") + asyncio.create_task(run_twitch_bot(), name="Twitch-Bot"), + asyncio.create_task(run_queued_tasks(), name="Task-Queue") ] try: diff --git a/src/server/app.py b/src/server/app.py index 6a3de14..b023917 100644 --- a/src/server/app.py +++ b/src/server/app.py @@ -4,6 +4,7 @@ from quart import Quart, redirect, request, jsonify, websocket, render_template, send_from_directory, Response import os, sys +from data import Member from tts import LocalTTS from helpers import TCDNDConfig as Config from custom_logger.logger import logger @@ -40,10 +41,10 @@ async def audio_stream(): logger.debug("ws opened") while True: if not message_queue.empty(): - message = await message_queue.get() - logger.info(f"saying {message}") + member, message = await message_queue.get() + logger.info(f"saying {message} from {member}") - async for chunk in self.tts.get_stream(message): + async for chunk in self.tts.get_stream(message, '' if not member else member.preferred_tts): await asyncio.wait_for(websocket.send(chunk), timeout=10) await asyncio.sleep(0.1) except Exception as e: @@ -64,7 +65,7 @@ async def trigger_tts(): return jsonify({"error": "Text is required"}), 400 if clients: - await message_queue.put(text) + await message_queue.put((None, text)) return jsonify({"status": "success", "message": "Text sent to WebSocket for TTS."}) else: return jsonify({"error": "No active WebSocket connection."}), 400 @@ -73,11 +74,11 @@ async def trigger_tts(): async def overlay(): return await send_from_directory(STATIC_DIR, 'overlay.html') - async def chat_say(self, username: str, text: str): - logger.info(f"TTS saying '{text}' from {username}") + async def chat_say(self, member: Member, text: str): + logger.info(f"TTS saying '{text}' from {'UNKNOWN' if not member else member.name}") # If client was connected but dc'd, this can revive connection when it runs. But also, we don't want things to queue up forever... Might not be a problem, needs hard testing later # But cannot simply do an if check here for clients - await message_queue.put(text) + await message_queue.put((member, text)) async def run_task(self, host="0.0.0.0", **kwargs): diff --git a/src/tts/local_tts.py b/src/tts/local_tts.py index cf354e7..f7e55e9 100644 --- a/src/tts/local_tts.py +++ b/src/tts/local_tts.py @@ -1,7 +1,10 @@ import asyncio +import threading import base64, io, struct import pyttsx4 +from tts.tts import TTS + from helpers import TCDNDConfig as Config from custom_logger.logger import logger @@ -32,9 +35,9 @@ def create_wav_header(sample_rate, bits_per_sample, num_channels, data_size): ) return header -class LocalTTS(): # TODO Refactor with some inheritance from a TTS class, so we can abstract both Local and Cloud TTS later on +class LocalTTS(TTS): # TODO Refactor with some inheritance from a TTS class, so we can abstract both Local and Cloud TTS later on def __init__(self, config: Config): - self.config = config + super().__init__(config=None) self.sample_rate = 22050 # PyTTS default self.bits_per_sample = 16 @@ -42,22 +45,34 @@ def __init__(self, config: Config): self.max_chunk_size = 1024*8*8*2 # 128kb - def audio_stream_generator(self, text="Hello World!"): + engine = pyttsx4.init() + for v in engine.getProperty('voices'): + self.voices.setdefault(v.name, v.id) + + + def audio_stream_generator(self, text="Hello World!", voice: str = ''): engine = pyttsx4.init() # We are using the fork for x4 as it works with outputting to bytesIO output = io.BytesIO() + if voice and voice in self.get_voices().keys(): + engine.setProperty('voice', self.get_voices()[voice]) + # TODO Uses default tts on at the moment. Can configure later engine.setProperty('rate', 150) # Speed of speech engine.setProperty('volume', 1) # Volume level (0.0 to 1.0) engine.save_to_file(text, output) - engine.runAndWait() + _th = threading.Thread(target=engine.runAndWait) + _th.daemon = True + _th.start() + _th.join() + output.seek(0) return output - async def get_stream(self, text="Hello World!"): - output = self.audio_stream_generator(text) + async def get_stream(self, text="Hello World!", voice: str = ''): + output = self.audio_stream_generator(text, voice) header = create_wav_header(self.sample_rate, self.bits_per_sample, self.num_channels, len(output.getvalue())) chunk_size = min(self.max_chunk_size, len(output.getvalue())) chunk = output.read(chunk_size) @@ -65,4 +80,15 @@ async def get_stream(self, text="Hello World!"): while chunk: await asyncio.sleep((len(chunk) / (self.sample_rate * self.num_channels * (self.bits_per_sample // 8)))) yield header + chunk - chunk = output.read(chunk_size) \ No newline at end of file + chunk = output.read(chunk_size) + + def test_speak(self, text:str ="Hello there. How are you?", voice:str = None): + def _run(text, voice): + engine = pyttsx4.init() + if voice and voice in self.voices.keys(): + engine.setProperty('voice', self.voices.get(voice)) + engine.say(text) + engine.runAndWait() + thread = threading.Thread(target=_run, args=(text, voice)) + thread.daemon = True + thread.start() \ No newline at end of file diff --git a/src/tts/tts.py b/src/tts/tts.py new file mode 100644 index 0000000..a7d800b --- /dev/null +++ b/src/tts/tts.py @@ -0,0 +1,12 @@ +from helpers import TCDNDConfig as Config +from custom_logger.logger import logger + +class TTS(): + + voices: dict = dict() + + def __init__(self, config: Config): + self.config = config + + def get_voices(self) -> dict: + return self.voices \ No newline at end of file diff --git a/src/twitch/chat.py b/src/twitch/chat.py index 09c1071..4ab29ab 100644 --- a/src/twitch/chat.py +++ b/src/twitch/chat.py @@ -7,7 +7,7 @@ from twitch.utils import TwitchUtils from data import Member -from data.member import create_or_get_member +from data.member import create_or_get_member, fetch_member from chatdnd import SessionManager from custom_logger.logger import logger @@ -113,14 +113,14 @@ def open_session(self): return if self.session_mgr: self.session_mgr.end() + self.session_mgr.open() self.chat.unregister_command(self.command_list['say']) self.chat.register_command(self.command_list['join'], self._add_user_to_queue, command_middleware=[ChannelUserCommandCooldown(30)]) self.send_message(f"Session started! Type {self.chat._prefix}{self.command_list['join']} to queue for the adventuring party") chat_on_session_open.trigger() - def start_session(self): - party_size = 4 + def start_session(self, party_size) -> bool: if self.session_mgr.start_session(party_size=party_size): # config self.chat.unregister_command(self.command_list['join']) party = [x.name for x in self.session_mgr.session.party] @@ -131,8 +131,10 @@ def start_session(self): self.send_message(f"Say welcome to our party members: {", ".join(party)}") self.send_message(f"Party members, type {self.chat._prefix}{self.command_list['say']} to have it spoken via TTS") chat_on_session_start.trigger() + return True else: self.send_message(f"Not enough party members in the queue! Type {self.chat._prefix}{self.command_list['join']} to join ({len(self.session_mgr.session.queue)}/{party_size})") + return False def end_session(self): if self.session_mgr: @@ -142,7 +144,6 @@ def end_session(self): async def _add_user_to_queue(self, cmd: ChatCommand): - # TODO move to diff place? maybe not user: TwitchUser = await self.twitch_utils.get_user_by_name(username=cmd.user.name) if not user: return @@ -151,6 +152,7 @@ async def _add_user_to_queue(self, cmd: ChatCommand): member = await create_or_get_member(name=cmd.user.display_name, pfp_url = user.profile_image_url) if member not in self.session_mgr.session.queue: await cmd.reply(f'{member.name} added to queue') + chat_on_join_queue.trigger([cmd.user.name]) else: await cmd.reply(f'{member.name} already in the queue') self.session_mgr.join_queue(member) @@ -160,4 +162,5 @@ async def _say(self, cmd: ChatCommand): await asyncio.sleep(0.1) if cmd.parameter: # Event trigger *does* work here - chat_say_command.trigger([cmd.user.name, cmd.parameter]) + member = await fetch_member(cmd.user.name.lower()) + chat_say_command.trigger([member, cmd.parameter]) diff --git a/src/ui/tabs/home.py b/src/ui/tabs/home.py index c7d53a3..96cc3bd 100644 --- a/src/ui/tabs/home.py +++ b/src/ui/tabs/home.py @@ -1,7 +1,11 @@ import customtkinter as ctk from custom_logger.logger import logger +from ui.widgets.member_card import MemberCard from twitch.chat import ChatController +from chatdnd.events.chat_events import chat_on_join_queue, chat_bot_on_connect + +# TODO: Display current active party on session start as MemberCards (smaller than on users page) class HomeTab(): def __init__(self, parent, chat_ctrl: ChatController): @@ -9,33 +13,137 @@ def __init__(self, parent, chat_ctrl: ChatController): self.config = chat_ctrl.config self.chat_ctrl = chat_ctrl - label = ctk.CTkLabel(self.parent, text="Welcome to Home") - label.pack(pady=10) + self.parent.grid_columnconfigure(1, weight=0) + self.parent.grid_columnconfigure((0,2), weight=1) + self.parent.grid_rowconfigure((0,1), weight=0) + self.parent.grid_rowconfigure((2,3), weight=1) - button = ctk.CTkButton(self.parent, text="Open New Session", command=self._open_session) - button.pack(pady=10) - - party_size_var = ctk.IntVar(value=self.config.getint(section="DND", option="party_size", fallback=4)) - self.party_label_var = ctk.StringVar(value=f"Party Size - {party_size_var.get()}") - party_label = ctk.CTkLabel(self.parent, textvariable=self.party_label_var) - party_label.pack(padx=(10,10), pady=(4,2)) + label = ctk.CTkLabel(self.parent, text="Session Management") + label.place(anchor= ctk.CENTER, relx=0.5, rely = 0.02) - party_size_slider = ctk.CTkSlider(self.parent, from_=1, to=6,number_of_steps=5, variable=party_size_var, command=self._update_party_limit, height=20) - party_size_slider.pack(padx=(20,20), pady=(2, 100)) + ####### Configure Session ####### + _inner_frame = ctk.CTkFrame(self.parent) + _inner_frame.grid(row=1, column=0, sticky="nw", pady=(40,4)) + + self.open_button = ctk.CTkButton(_inner_frame, text="Open New Session", command=self._open_session) + self.open_button.grid(row=0, column=0, sticky='nw', padx=(4,4), pady=2) + self.open_button.configure(state="disabled") + + self.session_status_var = ctk.StringVar(value="None") # None, Open, Started + session_status_label = ctk.CTkLabel(_inner_frame, textvariable=self.session_status_var, height=20, width=150) + session_status_label.grid(row=0, column=1, sticky='w', padx=(10,0)) + + + self.party_size_var = ctk.IntVar(value=self.config.getint(section="DND", option="party_size", fallback=4)) + self.party_label_var = ctk.StringVar(value=f"Party Size - {self.party_size_var.get()}") + party_label = ctk.CTkLabel(_inner_frame, textvariable=self.party_label_var) + party_label.grid(row=1, column=0, pady=(16,4), columnspan=2) + + self.party_size_slider = ctk.CTkSlider(_inner_frame, from_=1, to=6,number_of_steps=5, variable=self.party_size_var, command=self._update_party_limit, height=20) + self.party_size_slider.grid(row=2, column=0, columnspan=2, pady=(2,30)) + + self.start_session = ctk.CTkButton(_inner_frame, text="Start Session", command=self._start_session) + self.start_session.grid(row=3, column=0, sticky='sw', padx=(4,2), pady=2) + self.start_session.configure(state="disabled") + + self.end_button = ctk.CTkButton(_inner_frame, text="End Session", command=self._end_session) + self.end_button.grid(row=3, column=1, sticky='se', padx=(2,4), pady=2) + self.end_button.configure(state='disabled') + + + ################################## + + ####### Session Queue ####### + + _inner_frame_queue = ctk.CTkFrame(self.parent) + _inner_frame_queue.grid(row=2, column=0, sticky="nw", pady=(6,4)) + self.queue_label_var = ctk.StringVar(value=f"{len(self.chat_ctrl.session_mgr.session.queue)} in Queue") + queue_label = ctk.CTkLabel(_inner_frame_queue, textvariable=self.queue_label_var, height=20, width=306) + queue_label.pack(pady=(8,4)) + + self.queue_list = ctk.CTkScrollableFrame(_inner_frame_queue, height=360) + self.queue_list.pack(padx=4, pady=4, fill="both", expand=True) + + chat_on_join_queue.addListener(self.add_queue_user) + chat_bot_on_connect.addListener(self._allow_session_management) + + ################################## + + ####### Party View ####### + + self._party_frame = ctk.CTkFrame(self.parent, width=550, height=586) + self._party_frame.place(relx=0.268, rely = 0.057) + self._party_frame.grid_propagate(False) + self._fill_party_frame() + + ################################## + + + def _fill_party_frame(self): + for child in self._party_frame.winfo_children(): + child.destroy() + + columns = 3 + for index, member in enumerate(sorted(self.chat_ctrl.session_mgr.session.party)): + row = index // columns + col = index % columns + member_card = MemberCard(self._party_frame, member, width=130, height=170, textsize=10) + member_card.grid(row=row, column=col, padx=(35, 10), pady=(12,12), sticky="w") + + + def _allow_session_management(self, status: bool): + if status: + self.open_button.configure(state="normal") + else: + self.open_button.configure(state="disabled") - - button2 = ctk.CTkButton(self.parent, text="Test TTS Stream", command=self._tts_test) - button2.pack(pady=10) def _open_session(self): + for child in self.queue_list.winfo_children(): + child.destroy() logger.debug("Button pressed to open session") - self.chat_ctrl.open_session() + self.chat_ctrl.open_session() + self.queue_label_var.set(value=f"{len(self.chat_ctrl.session_mgr.session.queue)} in Queue") + self.session_status_var.set(value=self.chat_ctrl.session_mgr.session.state.name.capitalize()) + self.party_size_slider.configure(state="normal") + self.start_session.configure(state="normal") + self.end_button.configure(state="disabled") + self._fill_party_frame() + + + def _start_session(self): + result = self.chat_ctrl.start_session(self.party_size_var.get()) + if result: + for child in self.queue_list.winfo_children(): + child.destroy() + self.queue_label_var.set(value=f"{len(self.chat_ctrl.session_mgr.session.queue)} in Queue") + self.session_status_var.set(value=self.chat_ctrl.session_mgr.session.state.name.capitalize()) + self.party_size_slider.configure(state="disabled") + self.start_session.configure(state="disabled") + self.end_button.configure(state="normal") + self._fill_party_frame() + + + def _end_session(self): + self.chat_ctrl.end_session() + for child in self.queue_list.winfo_children(): + child.destroy() + self.queue_label_var.set(value=f"{len(self.chat_ctrl.session_mgr.session.queue)} in Queue") + self.session_status_var.set(value=self.chat_ctrl.session_mgr.session.state.name.capitalize()) + self.party_size_slider.configure(state="normal") + self.start_session.configure(state="disabled") + self.end_button.configure(state="disabled") + self._fill_party_frame() + + + def add_queue_user(self, name): + user_label = ctk.CTkLabel(self.queue_list, text=name, anchor='e') + user_label.pack(padx=(2,6), pady=4) + self.queue_label_var.set(value=f"{len(self.chat_ctrl.session_mgr.session.queue)} in Queue") + def _update_party_limit(self, value): if int(value) != self.config.getint(section="DND", option="party_size", fallback=-1): self.party_label_var.set(f"Party Size - {int(value)}") self.config.set(section="DND", option="party_size", value=str(int(value))) self.config.write_updates() - - def _tts_test(self): - logger.info("Testing TTS at local webserver") \ No newline at end of file diff --git a/src/ui/tabs/settings.py b/src/ui/tabs/settings.py index 9d1bf4f..423df25 100644 --- a/src/ui/tabs/settings.py +++ b/src/ui/tabs/settings.py @@ -79,7 +79,10 @@ def __init__(self, parent, config: Config, twitch_utils: TwitchUtils): row+=1 column=0 label_web = ctk.CTkLabel(self.parent, text="Browser Source", anchor="w", font=header_font) - label_web.grid(row=row, column=0, padx=10, pady=(50,10)) + label_web.grid(row=row, column=column, padx=10, pady=(50,10)) + row+=1 + label_bs = ctk.CTkLabel(self.parent, text="http://localhost:5000/overlay") # TODO copy to clipboard button, dynamic string with port from config. + label_bs.grid(row=row, column=column, padx=(10,10), pady=(10, 2)) # TODO: Port configuration. Can we easily restart the quart server while live, or require application restart? # TODO: Copy button for the overlay URL diff --git a/src/ui/tabs/users.py b/src/ui/tabs/users.py index ccdcc27..1c5a9b7 100644 --- a/src/ui/tabs/users.py +++ b/src/ui/tabs/users.py @@ -1,19 +1,96 @@ import customtkinter as ctk +import asyncio from custom_logger.logger import logger +from ui.widgets.member_card import MemberCard +from data.member import fetch_paginated_members + from twitch.chat import ChatController class UsersTab(): def __init__(self, parent, chat_ctrl: ChatController): self.parent = parent self.chat_ctrl = chat_ctrl - label = ctk.CTkLabel(self.parent, text="Welcome to Users") - label.pack(pady=10) - button = ctk.CTkButton(self.parent, text="Click Me!") - button.pack(pady=10) + self.load_members_lock = asyncio.Lock() + + self.page = 1 + self.per_page = 6*4 + self.name_filter = "" + self.members_list_frame = ctk.CTkScrollableFrame(self.parent) + self.members_list_frame.pack(padx=20, pady=20, fill="both", expand=True) + + self.search_var = ctk.StringVar() + self.search_var.trace_add("write", self.update_search_filter) + + search_label = ctk.CTkLabel(self.parent, text="Search") + search_label.pack(side=ctk.LEFT, padx=(8,4), pady=10) + self.search_box = ctk.CTkEntry(self.parent, textvariable=self.search_var, placeholder_text="Search by name...", width=260) + self.search_box.pack(side=ctk.LEFT, padx=(4,10), pady=10) + + + self.prev_button = ctk.CTkButton(self.parent, text="Previous", command=self.previous_page) + self.prev_button.pack(side=ctk.LEFT, padx=10, pady=10) + + self.next_button = ctk.CTkButton(self.parent, text="Next", command=self.next_page) + self.next_button.pack(side=ctk.LEFT, padx=10, pady=10) + + + self.refresh_button = ctk.CTkButton(self.parent, text="Refresh", command=self.schedule_load_members) + self.refresh_button.pack(side=ctk.RIGHT, padx=10, pady=10) + + self.load_members_task = asyncio.create_task(self.load_members()) + + + def update_search_filter(self, *args): + self.name_filter = self.search_var.get() + self.page = 1 + asyncio.create_task(self.delay_load_members()) + + async def delay_load_members(self): + await asyncio.sleep(0.2) + self.schedule_load_members() + + def schedule_load_members(self): + if self.load_members_task: + self.load_members_task.cancel() + self.load_members_task = asyncio.create_task(self.load_members()) + + async def load_members(self): + # Clear current members + async with self.load_members_lock: + for widget in self.members_list_frame.winfo_children(): + widget.destroy() + + try: + await asyncio.sleep(0.2) + members = await fetch_paginated_members(self.page, self.per_page, name_filter=self.name_filter) + + new_cards = list() + columns = 6 + for member in members: + member_card = MemberCard(self.members_list_frame, member) + new_cards.append(member_card) + new_cards = sorted(new_cards[:], key=lambda x: x.member.name) + for index, card in enumerate(new_cards): + row = index // columns + col = index % columns + card.grid(row=row, column=col, padx=10, pady=10) + + self.update_pagination_buttons() + except asyncio.CancelledError: + pass + + + def update_pagination_buttons(self): + self.prev_button.configure(state="normal" if self.page > 1 else "disabled") + self.next_button.configure(state="normal" if len(self.members_list_frame.winfo_children()) == self.per_page else "disabled") + + def previous_page(self): + if self.page > 1: + self.page -= 1 + self.schedule_load_members() - # TODO depends on what we think we want here - # For ex, do we want to search the *entire* member's database table for users, and can modify them at whim? - # Or should this be purely for a party/session members configuration? - # Or could we do both? idk, what is the usefulness of any \ No newline at end of file + def next_page(self): + self.page += 1 + self.schedule_load_members() diff --git a/src/ui/widgets/member_card.py b/src/ui/widgets/member_card.py new file mode 100644 index 0000000..0316c0f --- /dev/null +++ b/src/ui/widgets/member_card.py @@ -0,0 +1,115 @@ +import customtkinter as ctk +from PIL import Image +import requests +from io import BytesIO +import asyncio + +from data import Member +from custom_logger.logger import logger + +from tts import LocalTTS +from data.member import update_tts + +class MemberCard(ctk.CTkFrame): + def __init__(self, parent, member: Member, width=160, height=200, textsize=12, *args, **kwargs): + super().__init__(parent, width=width, height=height, *args, **kwargs) + self.member: Member = member + self.width = width + self.height = height + self.textsize = textsize + self.grid_propagate(False) + self.create_card() + + self.bind("", self.open_edit_popup) + self.configure(cursor="hand2") + + def create_card(self): + self.setup_pfp() + + name_label = ctk.CTkLabel(self, text=self.member.name.upper(), font=("Arial", self.textsize), wraplength=self.width-10) + name_label.grid(row=2, column=0, sticky="s", padx=5, pady=(2,10)) + name_label.bind("", self.open_edit_popup) + + def setup_pfp(self): + try: + url = self.member.pfp_url + response = requests.get(url) + img_data = response.content + img = Image.open(BytesIO(img_data)) + + img = img.resize((self.width, self.height), Image.LANCZOS) + + self.bg_image = ctk.CTkImage(img, img, (self.width, self.width)) + + self.bg_label = ctk.CTkLabel(self, image=self.bg_image, text="") + self.bg_label.grid(row=0, column=0, sticky="nsew", rowspan=2) + self.bg_label.bind("", self.open_edit_popup) + except Exception as e: + logger.warn(f"Could not fetch image for {self.member}. {e}") + self.bg_image = None + self.bg_label = ctk.CTkLabel(self, text="No Image", font=("Arial", self.textsize)) + self.bg_label.grid(row=0, column=0, sticky="nsew") + self.bg_label.bind("", self.open_edit_popup) + + def open_edit_popup(self, event=None): + MemberEditCard(self.member) + +class MemberEditCard(ctk.CTkToplevel): + open_popup = None + + def __init__(self, member: Member): + if MemberEditCard.open_popup is not None: + MemberEditCard.open_popup.focus_set() + return + + super().__init__() + MemberEditCard.open_popup = self + self.member: Member = member + self.title(f"Edit {self.member.name.upper()}") + self.geometry("400x400") + self.resizable(False, False) + self.localTTS = LocalTTS(None) + + self.attributes("-topmost", True) + + self.create_widgets() + + self.protocol("WM_DELETE_WINDOW", self.close_popup) + #self.deiconify() + + + def create_widgets(self): + # TODO add stuff, make pretty, idk + self.label = ctk.CTkLabel(self, text="Preferred TTS:") + self.label.pack(pady=(20, 5)) + + + self.tts_options = list(self.localTTS.get_voices().keys()) # Get keys from the voices dict + self.tts_dropdown = ctk.CTkOptionMenu( + self, values=self.tts_options + ) + + self.tts_dropdown.set(self.member.preferred_tts or self.tts_options[0]) + self.tts_dropdown.pack(pady=(5, 20)) + + self.test_button = ctk.CTkButton(self, text="Preview", command=self.test_tts) + self.test_button.pack(pady=10) + + self.save_button = ctk.CTkButton(self, text="Save", command=self.save_changes) + self.save_button.pack(pady=10) + + + def test_tts(self): + self.localTTS.test_speak(voice=self.tts_dropdown.get()) + + + def save_changes(self): + new_tts = self.tts_dropdown.get() + self.member.preferred_tts = new_tts + + asyncio.create_task(update_tts(self.member, new_tts)) + logger.info(f"Updated preferred_tts for {self.member.name} to {new_tts}") + + def close_popup(self): + MemberEditCard.open_popup = None + self.destroy()