Refactor main_window into multiples files

This commit is contained in:
2026-03-27 17:19:46 +01:00
parent b5820eb301
commit 457f7b11e5
5 changed files with 381 additions and 371 deletions
+103 -371
View File
@@ -2,10 +2,10 @@ import webbrowser
from os import environ, unlink from os import environ, unlink
from sys import platform from sys import platform
from PySide6.QtCore import QThread, Signal from PySide6.QtCore import QTimer
from PySide6.QtCore import Qt, QTimer from PySide6.QtCore import Qt
from PySide6.QtGui import QIcon, QMouseEvent, QCursor, QGuiApplication from PySide6.QtGui import QIcon, QMouseEvent, QGuiApplication
from PySide6.QtWidgets import QMainWindow, QSizePolicy from PySide6.QtWidgets import QMainWindow
from config.config_manager import ConfigManager from config.config_manager import ConfigManager
from config.constants import PlayerServerInfo, Urls from config.constants import PlayerServerInfo, Urls
@@ -13,29 +13,33 @@ from controllers.audio_controller import AudioController
from controllers.glow_animator import GlowAnimator from controllers.glow_animator import GlowAnimator
from controllers.window_dragger import WindowDragger from controllers.window_dragger import WindowDragger
from discord import discord_oauth from discord import discord_oauth
from fake_patch_notes import patch_note from fake_patch_notes import patch_note # <- TO REMOVE USE API
from fivemserver.auth_worker import AuthWorker from fivemserver.auth_worker import AuthWorker
from fivemserver.fivemlauncher import FiveMLauncher from fivemserver.fivemlauncher import FiveMLauncher
from fivemserver.get_server_token import GetServerTokenForDiscord from fivemserver.get_server_token import GetServerTokenForDiscord
from fivemserver.queuemanager import QueueManager
from fivemserver.whitelistmanager import WhiteList from fivemserver.whitelistmanager import WhiteList
from tools.http_client import ApiError from tools.http_client import ApiError
from ui.error_dialog import show_qt_error from ui.error_dialog import show_qt_error
from ui.hazard_stripes import HazardButton
from ui.ui_mainwindow_vertical_pager import Ui_MainWindow from ui.ui_mainwindow_vertical_pager import Ui_MainWindow
from uitools.countdown_manager import CountdownManager
from uitools.queue_thread import QueueThread
from uitools.ui_builder import set_en_chantier, replace_with_hazard_button, hide_staff_btn_and_recenter
from uitools.window_utility import center_window
# For Linux Wayland to authorize moving window # For Linux Wayland to authorize moving window
if platform.startswith('linux'): if platform.startswith('linux'):
environ["QT_QPA_PLATFORM"] = "xcb" environ["QT_QPA_PLATFORM"] = "xcb"
class MainWindow(QMainWindow): class MainWindow(QMainWindow):
""" """
MainWindow class for managing the main application window and its functionalities. MainWindow class for managing the main application window and its functionalities.
This class is responsible for managing the UI, user interactions, and application state. This class is responsible for managing the UI, user interactions, and application state.
It provides mechanisms to handle user authentication, connection to external services, and queued tasks. It provides mechanisms to handle user authentication, connection to external services,
The class is designed to be used with the PyQt framework and provides logic for dynamically updating and queued tasks. The class is designed to be used with the PySide6 framework and
the UI elements and interacting with other components. provides logic for dynamically updating the UI elements and interacting with other
components.
:ivar ui: The main UI object for managing visual components and layouts. :ivar ui: The main UI object for managing visual components and layouts.
:type ui: Ui_MainWindow :type ui: Ui_MainWindow
@@ -44,17 +48,10 @@ class MainWindow(QMainWindow):
:ivar stored_user_id: The Discord user ID stored in the configuration. :ivar stored_user_id: The Discord user ID stored in the configuration.
:type stored_user_id: str :type stored_user_id: str
:ivar queue_thread: The thread handling queue operations. :ivar queue_thread: The thread handling queue operations.
:type queue_thread: Optional[Thread] :type queue_thread: Optional[QueueThread]
:ivar queue_position_value: The current queue position. :ivar queue_position_value: The current queue position.
:type queue_position_value: Optional[int] :type queue_position_value: Optional[int]
:ivar close_timer: Timer object for handling the final closure of the application.
:type close_timer: QTimer
:ivar countdown_timer: Timer object for updating visual countdown timers.
:type countdown_timer: QTimer
:ivar remaining_time: The remaining time for queue or countdown operations, in seconds.
:type remaining_time: int
""" """
#update = Signal(str) # Reçoit les callbacks de QueueManager
def __init__(self, bundle_dir: str, config_manager: ConfigManager): def __init__(self, bundle_dir: str, config_manager: ConfigManager):
super().__init__() super().__init__()
@@ -62,53 +59,51 @@ class MainWindow(QMainWindow):
self.ui = Ui_MainWindow() self.ui = Ui_MainWindow()
self.ui.setupUi(self) self.ui.setupUi(self)
self.auth_worker = None # <--- Pour stocker l'instance du thread self.auth_worker = None
self.config = config_manager self.config = config_manager
self.stored_user_id = self.config.get_discord_user() self.stored_user_id = self.config.get_discord_user()
self.queue_thread = None self.queue_thread = None
self.queue_position_value = None self.queue_position_value = None
self.close_timer = None
self.countdown_timer = None
self.remaining_time = 0 # en secondes
# Préparation du timer de fermeture finale # ------------------------------------------------------------------
self.close_timer = QTimer(self) # Timers (owned here so they share the QObject tree of MainWindow)
self.close_timer.setSingleShot(True) # ------------------------------------------------------------------
self.close_timer.timeout.connect(self.close) close_timer = QTimer(self)
close_timer.setSingleShot(True)
close_timer.timeout.connect(self.close)
# Préparation du timer de mise à jour visuelle (1s) countdown_timer = QTimer(self)
self.countdown_timer = QTimer(self)
self.countdown_timer.timeout.connect(self._update_countdown)
# UI self._countdown = CountdownManager(
# self.ui = QUiLoader().load(f"{bundle_dir}/ui/mainwindow_vertical_pager.ui", self) close_timer=close_timer,
#self.setCentralWidget(self.ui.centralWidget()) countdown_timer=countdown_timer,
label=self.ui.queue_lbl,
)
# ------------------------------------------------------------------
# Window flags
# ------------------------------------------------------------------
self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window) self.setWindowFlags(Qt.WindowType.FramelessWindowHint | Qt.WindowType.Window)
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground) self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
self.setWindowIcon(QIcon(str(bundle_dir / "assets" / "Icon.ico"))) self.setWindowIcon(QIcon(str(bundle_dir / "assets" / "Icon.ico")))
# Par défaut on affiche la page normal pour la connexion au serveur # ------------------------------------------------------------------
# Initial page / whitelist check
# ------------------------------------------------------------------
self.ui.stackedWidget.setCurrentIndex(0) self.ui.stackedWidget.setCurrentIndex(0)
# On cache par défaut les infos liste d'attente
self.ui.queue_lbl.hide() self.ui.queue_lbl.hide()
self.ui.queue_position.hide() self.ui.queue_position.hide()
# Si l'id discord = "" ou des espace, alors on affiche la page comme quoi faut être connecté à discord.
if self.stored_user_id == "" or self.stored_user_id.isspace(): if self.stored_user_id == "" or self.stored_user_id.isspace():
self.ui.stackedWidget.setCurrentIndex(1) self.ui.stackedWidget.setCurrentIndex(1)
else: else:
try: try:
# on vérifie si le joueur est whitelisté
WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id) WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id)
except ApiError as exc: except ApiError as exc:
show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}") show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}")
PlayerServerInfo.is_whitelist = False PlayerServerInfo.is_whitelist = False
PlayerServerInfo.is_staff = False PlayerServerInfo.is_staff = False
# si on est whitelisté, on démarre la file d'attente
if PlayerServerInfo.is_whitelist: if PlayerServerInfo.is_whitelist:
self.start_queue() self.start_queue()
self.ui.queue_lbl.show() self.ui.queue_lbl.show()
@@ -116,65 +111,50 @@ class MainWindow(QMainWindow):
else: else:
self.ui.stackedWidget.setCurrentIndex(2) self.ui.stackedWidget.setCurrentIndex(2)
# Test bouton en contruction # ------------------------------------------------------------------
# Under-construction button (delegated to ui_builder)
# ------------------------------------------------------------------
en_chantier = False en_chantier = False
# on set la css du bouton en fonction de la valeur de la variable en_chantier set_en_chantier(self.ui.connexion_btn, en_chantier)
self.set_en_chantier(en_chantier)
if en_chantier: if en_chantier:
old_btn = self.ui.connexion_btn new_btn = replace_with_hazard_button(
parent_layout = self.ui.verticalLayout_6 # layout direct du bouton dans le .ui self.ui.connexion_btn,
self.ui.verticalLayout_6,
index = parent_layout.indexOf(old_btn) )
new_btn = HazardButton(old_btn.parentWidget())
new_btn.setObjectName("connexion_btn")
new_btn.setText("EN MAINTENANCE")
new_btn.setIcon(old_btn.icon())
new_btn.setIconSize(old_btn.iconSize())
new_btn.setMinimumSize(old_btn.minimumSize())
new_btn.set_hazard(True)
parent_layout.takeAt(index)
old_btn.deleteLater()
parent_layout.insertWidget(index, new_btn)
self.ui.connexion_btn = new_btn self.ui.connexion_btn = new_btn
self.ui.connexion_btn.clicked.connect(self._on_connexion) self.ui.connexion_btn.clicked.connect(self._on_connexion)
# centrage vertical du bouton connexion # ------------------------------------------------------------------
# Staff button / spacer adjustment (delegated to ui_builder)
# ------------------------------------------------------------------
if not PlayerServerInfo.is_staff: if not PlayerServerInfo.is_staff:
self.ui.staff_btn.hide() hide_staff_btn_and_recenter(self.ui.staff_btn, self.ui.verticalLayout_6)
layout = self.ui.verticalLayout_6
# Trouver et modifier le spacer item
for i in range(layout.count()):
item = layout.itemAt(i)
if item.spacerItem(): # C'est un spacer
item.spacerItem().changeSize(20, 15, QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
layout.invalidate() # Forcer le recalcul du layout
break
# ------------------------------------------------------------------
# Misc UI
# ------------------------------------------------------------------
self.ui.info_text.setMarkdown(patch_note) self.ui.info_text.setMarkdown(patch_note)
# Sous-systèmes # ------------------------------------------------------------------
# Sub-systems
# ------------------------------------------------------------------
self._audio = AudioController(self.config, self.ui.audio_volume_adjust, self.ui.mute_btn) self._audio = AudioController(self.config, self.ui.audio_volume_adjust, self.ui.mute_btn)
self._glow = GlowAnimator(self.ui.connexion_btn) self._glow = GlowAnimator(self.ui.connexion_btn)
self._dragger = WindowDragger(self) self._dragger = WindowDragger(self)
self._connect_signals() self._connect_signals()
self._center_window() center_window(self) # delegated to window_utils
self.show() self.show()
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Setup # Setup
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def _connect_signals(self) -> None: def _connect_signals(self) -> None:
""" """
Establishes connections between UI elements and their corresponding Establishes connections between UI elements and their corresponding handler methods.
functionality. Each signal emitted by a user interaction with a specific
UI button is linked to its respective handler method.
:return: None :return: None
""" """
@@ -186,23 +166,6 @@ class MainWindow(QMainWindow):
self.ui.discord_auth_btn.clicked.connect(self._on_discord_auth_btn) self.ui.discord_auth_btn.clicked.connect(self._on_discord_auth_btn)
self.ui.no_whitelist_btn.clicked.connect(self.close) self.ui.no_whitelist_btn.clicked.connect(self.close)
def _center_window(self) -> None:
"""
Centers the window on the screen that the cursor is currently located on.
If the cursor is not on any screen, it uses the primary screen for centering.
The window's geometry is adjusted and moved accordingly.
:return: None
"""
self.adjustSize()
screen = (
QGuiApplication.screenAt(QCursor.pos())
or QGuiApplication.primaryScreen()
)
rect = self.frameGeometry()
rect.moveCenter(screen.availableGeometry().center())
self.move(rect.topLeft())
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Button handlers # Button handlers
# ------------------------------------------------------------------ # ------------------------------------------------------------------
@@ -210,33 +173,25 @@ class MainWindow(QMainWindow):
def _on_connexion(self) -> None: def _on_connexion(self) -> None:
""" """
Handles the user connection process, including verification of prerequisites, Handles the user connection process, including verification of prerequisites,
session validation, and background authentication for the current user. This session validation, and background authentication for the current user.
method ensures a smooth user experience by implementing UI locking during
authentication and proceeding with appropriate actions if a valid session
already exists.
:return: None :return: None
""" """
# 1. Sécurités de base
if not self.stored_user_id or self.stored_user_id.isspace(): if not self.stored_user_id or self.stored_user_id.isspace():
show_qt_error(self, "Connexion impossible", "Identifiant Discord absent.") show_qt_error(self, "Connexion impossible", "Identifiant Discord absent.")
return return
# 2. Si on a déjà une session valide, on passe directement à la suite
if PlayerServerInfo.session_id: if PlayerServerInfo.session_id:
self._proceed_to_queue_or_launch() self._proceed_to_queue_or_launch()
return return
# 3. Sinon, on lance l'authentification en arrière-plan
# Verrouillage de l'UI pour éviter le double-clic
self.ui.connexion_btn.setEnabled(False) self.ui.connexion_btn.setEnabled(False)
self.ui.connexion_btn.setText("Authentification...") self.ui.connexion_btn.setText("Authentification...")
QGuiApplication.setOverrideCursor(Qt.WaitCursor) # Curseur de chargement QGuiApplication.setOverrideCursor(Qt.WaitCursor)
# Création et lancement du worker
self.auth_worker = AuthWorker(self.stored_user_id) self.auth_worker = AuthWorker(self.stored_user_id)
self.auth_worker.finished.connect(self._on_auth_finished) self.auth_worker.finished.connect(self._on_auth_finished)
self.auth_worker.finished.connect(self.auth_worker.deleteLater) # Nettoyage automatique self.auth_worker.finished.connect(self.auth_worker.deleteLater)
self.auth_worker.start() self.auth_worker.start()
@staticmethod @staticmethod
@@ -244,18 +199,13 @@ class MainWindow(QMainWindow):
""" """
Opens the Discord URL in the default web browser. Opens the Discord URL in the default web browser.
This method is a static utility function that launches the Discord URL defined :return: None
in the Urls enumeration. It uses the `webbrowser.open` method to handle the
opening process.
:return: Nothing is returned
:rtype: None
""" """
webbrowser.open(Urls.DISCORD.value) webbrowser.open(Urls.DISCORD.value)
def _on_intranet(self) -> None: def _on_intranet(self) -> None:
""" """
Opens the intranet URL using the default web browser and starts the glow process. Opens the intranet URL and starts the glow animation.
:return: None :return: None
""" """
@@ -266,32 +216,22 @@ class MainWindow(QMainWindow):
""" """
Handles the authentication process for Discord when the auth button is clicked. Handles the authentication process for Discord when the auth button is clicked.
This method retrieves the Discord user ID and token via OAuth, updates the
local configuration, and initiates a background worker to handle server
registration. The UI button is disabled and the cursor is set to a waiting
state during the network operations to prevent multiple attempts.
:return: None :return: None
""" """
try: try:
# 1. Récupération OAuth (souvent via navigateur, assez rapide)
test = discord_oauth.get_discord_user_id() test = discord_oauth.get_discord_user_id()
user_id = test[0] user_id = test[0]
token = test[1] token = test[1]
# 2. Mise à jour locale immédiate
self.config.set_discord_user(user_id) self.config.set_discord_user(user_id)
self.stored_user_id = user_id self.stored_user_id = user_id
PlayerServerInfo.session_id = token PlayerServerInfo.session_id = token
self.config.save() self.config.save()
# 3. Lancement du Worker pour la partie réseau (Enregistrement serveur)
# On verrouille l'UI pour éviter les clics multiples
self.ui.discord_auth_btn.setEnabled(False) self.ui.discord_auth_btn.setEnabled(False)
QGuiApplication.setOverrideCursor(Qt.WaitCursor) QGuiApplication.setOverrideCursor(Qt.WaitCursor)
self.auth_worker = AuthWorker(self.stored_user_id) self.auth_worker = AuthWorker(self.stored_user_id)
# On connecte le signal de fin à une nouvelle méthode dédiée
self.auth_worker.finished.connect(self._on_discord_auth_finished) self.auth_worker.finished.connect(self._on_discord_auth_finished)
self.auth_worker.finished.connect(self.auth_worker.deleteLater) self.auth_worker.finished.connect(self.auth_worker.deleteLater)
self.auth_worker.start() self.auth_worker.start()
@@ -305,17 +245,9 @@ class MainWindow(QMainWindow):
""" """
Handles the completion of the Discord authentication process. Handles the completion of the Discord authentication process.
This method is triggered once the Discord authentication flow is completed.
It determines the success or failure state of the process and accordingly
triggers user interface updates and calls associated actions, such as verifying
the whitelist status.
:param success: Indicates whether the authentication was successful. :param success: Indicates whether the authentication was successful.
:type success: bool
:param session_id: The session ID obtained after the authentication process. :param session_id: The session ID obtained after the authentication process.
:type session_id: str
:param error_message: The error message returned if the process failed. :param error_message: The error message returned if the process failed.
:type error_message: str
:return: None :return: None
""" """
QGuiApplication.restoreOverrideCursor() QGuiApplication.restoreOverrideCursor()
@@ -323,16 +255,15 @@ class MainWindow(QMainWindow):
if success: if success:
try: try:
# Maintenant que la session est OK côté serveur, on vérifie la Whitelist
WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id) WhiteList.check_whitelist(Urls.API_URL.value, self.stored_user_id)
if PlayerServerInfo.is_whitelist: if PlayerServerInfo.is_whitelist:
self.start_queue() self.start_queue()
self.ui.queue_lbl.show() self.ui.queue_lbl.show()
self.ui.queue_position.show() self.ui.queue_position.show()
self.ui.stackedWidget.setCurrentIndex(0) # Retour page principale self.ui.stackedWidget.setCurrentIndex(0)
else: else:
self.ui.stackedWidget.setCurrentIndex(2) # Page non-whitelisté self.ui.stackedWidget.setCurrentIndex(2)
except ApiError as exc: except ApiError as exc:
show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}") show_qt_error(self, "La Tanière", f"Impossible de vérifier la whitelist.\n\n{exc}")
else: else:
@@ -340,53 +271,20 @@ class MainWindow(QMainWindow):
f"L'authentification a réussi mais l'enregistrement a échoué.\n\n{error_message}") f"L'authentification a réussi mais l'enregistrement a échoué.\n\n{error_message}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Mouse events → délégués au WindowDragger # Mouse events → delegated to WindowDragger
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def mousePressEvent(self, event: QMouseEvent) -> None: def mousePressEvent(self, event: QMouseEvent) -> None:
"""
Handles mouse press event for the widget.
This method is invoked when a mouse button is pressed over the widget. It
delegates the event to an internal dragger object for handling. If the event
is not marked as accepted by the dragger, the parent class implementation
of the mouse press event is called.
:param event: The QMouseEvent instance representing the mouse press event.
:type event: QMouseEvent
:return: None
"""
self._dragger.mouse_press(event) self._dragger.mouse_press(event)
# On ne remonte pas au parent si on a déjà "accepté" l'event
if not event.isAccepted(): if not event.isAccepted():
super().mousePressEvent(event) super().mousePressEvent(event)
def mouseMoveEvent(self, event: QMouseEvent) -> None: def mouseMoveEvent(self, event: QMouseEvent) -> None:
"""
Handle the mouse move event.
This method processes the mouse movement event and delegates the functionality
to the associated dragger. If the event is not accepted after processing, it
passes the event up to the superclass implementation.
:param event: The mouse event being handled.
:type event: QMouseEvent
:return: None
"""
self._dragger.mouse_move(event) self._dragger.mouse_move(event)
if not event.isAccepted(): if not event.isAccepted():
super().mouseMoveEvent(event) super().mouseMoveEvent(event)
def mouseReleaseEvent(self, event: QMouseEvent) -> None: def mouseReleaseEvent(self, event: QMouseEvent) -> None:
"""
Handles the mouse release event. This function processes the event, allowing
custom handling logic through a dragger object, and then delegates to the parent
class's handler if the event is not already accepted.
:param event: The mouse release event to be processed.
:type event: QMouseEvent
:return: None
"""
self._dragger.mouse_release(event) self._dragger.mouse_release(event)
if not event.isAccepted(): if not event.isAccepted():
super().mouseReleaseEvent(event) super().mouseReleaseEvent(event)
@@ -397,39 +295,24 @@ class MainWindow(QMainWindow):
def closeEvent(self, event) -> None: def closeEvent(self, event) -> None:
""" """
Handles the close event of the application. Ensures the background thread is stopped and configuration is saved before closing.
This method is executed when the application window is being closed. It ensures
the termination of any running threads, saves the application configuration, and
performs necessary cleanup routines before invoking the superclass's close event
handler.
:param event: The close event instance that provides event-related data and allows
control over the closing process.
:return: None :return: None
""" """
if self.queue_thread and self.queue_thread.isRunning(): if self.queue_thread and self.queue_thread.isRunning():
self.queue_thread.stop() self.queue_thread.stop()
self.queue_thread.wait() # Attend que le thread se termine proprement self.queue_thread.wait()
self.config.save() self.config.save()
self.cleanup() self.cleanup()
super().closeEvent(event) super().closeEvent(event)
def cleanup(self): def cleanup(self) -> None:
""" """
Stops all active timers and performs cleanup operations. Stops all active timers and removes temporary files.
This method ensures that any active resources, such as timers or temporary :return: None
files, are properly stopped or removed to avoid resource leaks. It is a
utility to clean up and reset components associated with the instance.
:raises OSError: If an error occurs during the removal of the temporary file.
""" """
if self.close_timer: self._countdown.stop()
self.close_timer.stop()
if self.countdown_timer:
self.countdown_timer.stop()
if hasattr(self, '_sound'): if hasattr(self, '_sound'):
self._sound.stop() self._sound.stop()
@@ -440,120 +323,44 @@ class MainWindow(QMainWindow):
pass pass
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Change ui on runtime # Scheduled close (delegated to CountdownManager)
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def set_en_chantier(self, valeur: bool):
self.en_chantier = valeur # ta variable Python
self.ui.connexion_btn.setProperty("en_chantier", valeur) # propriété Qt
self.ui.connexion_btn.style().unpolish(self.ui.connexion_btn)
self.ui.connexion_btn.style().polish(self.ui.connexion_btn)
# ------------------------------------------------------------------ def schedule_close(self, delay_ms: int = 60_000) -> None:
# Schedule de fermeture du launcher
# ------------------------------------------------------------------
def schedule_close(self, delay_ms: int = 60000):
""" """
Schedules a process to close after a specified delay in milliseconds. The countdown Schedules the launcher to close after *delay_ms* milliseconds and starts
timer also starts to reflect the remaining time in seconds. Timers already running the visual countdown.
are reset to prevent duplicate behavior, and the display is updated immediately.
:param delay_ms: The delay in milliseconds after which the process should :param delay_ms: Delay in milliseconds (default: 60 000).
be closed. Defaults to 60000 milliseconds (1 minute) if not specified
or an invalid value is provided.
:type delay_ms: int :type delay_ms: int
:return: None :return: None
""" """
# 1. Sécurité sur les entrées self._countdown.schedule(delay_ms)
if not isinstance(delay_ms, int) or delay_ms <= 0:
delay_ms = 60000
# 2. Reset du temps restant (conversion ms -> s)
self.remaining_time = delay_ms // 1000
# 3. On stop les timers s'ils tournaient déjà (évite les doublons)
self.close_timer.stop()
self.countdown_timer.stop()
# 4. On lance
self.close_timer.start(delay_ms)
self.countdown_timer.start(1000)
# 5. Mise à jour immédiate de l'affichage (évite d'attendre 1s)
self._update_countdown_display()
def _update_countdown(self):
"""
Decreases the remaining time by one and updates the countdown display.
This method is responsible for decrementing the `remaining_time` attribute
on each invocation. If the remaining time reaches zero or below, the
`countdown_timer` is stopped immediately and no further action is
performed in this method. Otherwise, it proceeds to update the countdown
display to reflect the current remaining time.
:return: None
"""
self.remaining_time -= 1
if self.remaining_time <= 0:
self.countdown_timer.stop()
return
self._update_countdown_display()
def _update_countdown_display(self):
"""
Updates the countdown display on the user interface with the remaining time
in minutes and seconds format. The remaining time will always be formatted
as a positive value, even if it is less than zero.
:return: None
"""
minutes = max(0, self.remaining_time // 60)
seconds = max(0, self.remaining_time % 60)
self.ui.queue_lbl.setText(f"⏳ Fermeture dans {minutes:02d}:{seconds:02d}")
# ------------------------------------------------------------------ # ------------------------------------------------------------------
# Queue managment # Queue management
# ------------------------------------------------------------------ # ------------------------------------------------------------------
def start_queue(self):
def start_queue(self) -> None:
""" """
Starts a separate thread for managing a queue and connects it to the relevant Initialises and starts the :class:`~tools.queue_thread.QueueThread`.
update handler. This method initializes and starts a `QueueThread` instance
using the stored user ID and links it with the update handling functionality
to ensure real-time queue updates are properly handled.
All threading and queue update mechanisms are encapsulated within this method.
:param self: Reference to the calling object.
:return: None :return: None
""" """
self.queue_thread = QueueThread(self.stored_user_id, parent=self) # ← parent=self self.queue_thread = QueueThread(self.stored_user_id, parent=self)
self.queue_thread.update.connect(self.handle_update) self.queue_thread.update.connect(self.handle_update)
self.queue_thread.start() self.queue_thread.start()
# 🧪 TEMP - Simule une position en queue pour tester l'UI def handle_update(self, message: str) -> None:
# self.handle_update("position:3:10")
def handle_update(self, message: str):
""" """
Handles updates based on a given message and performs relevant UI actions. Reacts to queue status messages and updates the relevant UI labels.
:param message: The message received, which determines the update logic. Accepts: :param message: Status string ``"ok"``, ``"ready"``, or
- "ok": Indicates that it's the user's turn, updating the UI accordingly. ``"position:<current>:<total>"``.
- "ready": Equivalent to "ok", indicating the user's turn. :type message: str
- Messages starting with "position:": Provide the current position and total
in the queue in the format "position:<current>:<total>", updating the queue
information in the UI.
:return: None :return: None
""" """
if message == "ok": if message in ("ok", "ready"):
self.ui.queue_lbl.setVisible(True)
self.ui.queue_position.setVisible(False)
self.ui.queue_lbl.setText("🚀 C'est votre tour !")
self.queue_position_value = 0
elif message == "ready":
self.ui.queue_lbl.setVisible(True) self.ui.queue_lbl.setVisible(True)
self.ui.queue_position.setVisible(False) self.ui.queue_position.setVisible(False)
self.ui.queue_lbl.setText("🚀 C'est votre tour !") self.ui.queue_lbl.setText("🚀 C'est votre tour !")
@@ -569,43 +376,28 @@ class MainWindow(QMainWindow):
def launch_fivem(self): def launch_fivem(self):
pass pass
# ------------------------------------------------------------------
# Session helpers
# ------------------------------------------------------------------
def _ensure_server_session(self) -> bool: def _ensure_server_session(self) -> bool:
""" """
Ensures the establishment of a server session, handling authentication Ensures a valid server session exists, authenticating if necessary.
and session registration for a Discord user.
The method first checks if a valid stored user ID exists. If there is :return: ``True`` if the session was established successfully.
no stored user ID or it is blank, the session creation will fail. If a
session token (`session_id`) is already present, it will return
immediately with success. Otherwise, it proceeds to authenticate with
the server to obtain a session token and registers the Discord user
against this session.
Errors during registration are caught and handled separately, and the
method logs any issues encountered during the process. If the
authentication and registration are successful, the session is
considered established.
:return: A boolean indicating whether the server session was successfully
established.
:rtype: bool :rtype: bool
""" """
if not self.stored_user_id or self.stored_user_id.isspace(): if not self.stored_user_id or self.stored_user_id.isspace():
return False return False
try: try:
# 1. Si on a déjà la session, on ne fait rien
if PlayerServerInfo.session_id: if PlayerServerInfo.session_id:
return True return True
# 2. Authentification (avec protection timeout)
# On suppose que authenticate renvoie le token
token = GetServerTokenForDiscord.authenticate(Urls.API_URL.value) token = GetServerTokenForDiscord.authenticate(Urls.API_URL.value)
if token: if token:
PlayerServerInfo.session_id = token PlayerServerInfo.session_id = token
# 3. L'enregistrement est souvent la partie qui bloque (SSL handshake)
# On l'entoure d'un try spécifique
try: try:
GetServerTokenForDiscord.register_discord_user( GetServerTokenForDiscord.register_discord_user(
self.stored_user_id, self.stored_user_id,
@@ -623,107 +415,47 @@ class MainWindow(QMainWindow):
def _on_auth_finished(self, success: bool, session_id: str, error_message: str): def _on_auth_finished(self, success: bool, session_id: str, error_message: str):
""" """
Handles the completion of the authentication process by updating the UI, Restores the UI and proceeds after the background authentication completes.
cleaning up the worker, and proceeding with subsequent logic based on the
authentication result.
:param success: A boolean indicating whether the authentication was successful. :param success: Whether authentication succeeded.
:param session_id: The session identifier returned upon successful authentication. :param session_id: Session token returned on success.
:param error_message: A descriptive error message in case authentication failed. :param error_message: Human-readable error on failure.
:return: None :return: None
""" """
# 1. Restauration de l'UI
QGuiApplication.restoreOverrideCursor() QGuiApplication.restoreOverrideCursor()
self.ui.connexion_btn.setEnabled(True) self.ui.connexion_btn.setEnabled(True)
self.ui.connexion_btn.setText("SE CONNECTER") # Remettre le texte par défaut self.ui.connexion_btn.setText("SE CONNECTER")
# Nettoyage du worker
if self.auth_worker: if self.auth_worker:
self.auth_worker.deleteLater() self.auth_worker.deleteLater()
self.auth_worker = None self.auth_worker = None
if success: if success:
# 2. Mise à jour des infos globales
PlayerServerInfo.session_id = session_id PlayerServerInfo.session_id = session_id
# 3. On continue la logique normale
self._proceed_to_queue_or_launch() self._proceed_to_queue_or_launch()
else: else:
# 4. Affichage de l'erreur propre (le handshake SSL a sûrement timeout)
show_qt_error(self, "Erreur d'Authentification", show_qt_error(self, "Erreur d'Authentification",
f"Impossible de se connecter au serveur.\n\n{error_message}") f"Impossible de se connecter au serveur.\n\n{error_message}")
def _proceed_to_queue_or_launch(self): def _proceed_to_queue_or_launch(self) -> None:
""" """
Handles the process of either starting the queue, updating the user's Starts the queue if not yet entered, shows the position, or launches FiveM
position in the queue, or launching the FiveM application if all conditions when the user reaches position 0.
are met. This function first checks the queue position. If there is none,
the queue is started. If the current position in the queue is not at the
front (position 0), an error message is displayed. If the user is at the
front of the queue, the application launches, and further connections are
disabled.
:raises Exception: If an error occurs during the launching process.
:return: None :return: None
""" """
try: try:
# Si on n'a pas encore de position en file d'attente, on la lance
if self.queue_position_value is None: if self.queue_position_value is None:
self.start_queue() self.start_queue()
return return
# Si on est en file d'attente mais pas au début
if self.queue_position_value != 0: if self.queue_position_value != 0:
show_qt_error(self, "Attente", f"Position actuelle : {self.queue_position_value}") show_qt_error(self, "Attente", f"Position actuelle : {self.queue_position_value}")
return return
# Si tout est OK (Position 0)
FiveMLauncher.launch() FiveMLauncher.launch()
self.ui.connexion_btn.setEnabled(False) self.ui.connexion_btn.setEnabled(False)
self.schedule_close() self.schedule_close()
except Exception as exc: except Exception as exc:
show_qt_error(self, "Erreur de Lancement", f"Détails : {exc}") show_qt_error(self, "Erreur de Lancement", f"Détails : {exc}")
class QueueThread(QThread):
"""
QueueThread class.
Implements a thread that manages a queue operation using a `QueueManager`. The
class emits a signal to update external components whenever a relevant change
occurs in the queue. Designed to run processing tasks in a separate thread.
:ivar update: Signal emitted with a string message to notify about updates.
:type update: Signal
"""
update = Signal(str)
def __init__(self, user_id: str, parent=None): # ← parent=None
super().__init__(parent) # ← passé à QThread
self.manager = QueueManager(
user_id=user_id,
on_update=self.update.emit
)
def run(self):
"""
Initiates the start operation for the associated manager. This method
is responsible for invoking the `start` method of the `manager` object,
ensuring that the necessary operations managed by the `manager` commence
execution.
:return: None
:rtype: None
"""
self.manager.start()
def stop(self):
"""
Stops the operation managed by the associated manager instance.
This method delegates the stop operation to the `manager` object, ensuring
the termination of any ongoing processes or operations it controls.
:return: None
"""
self.manager.stop()
+102
View File
@@ -0,0 +1,102 @@
from PySide6.QtCore import QTimer
from PySide6.QtWidgets import QLabel
class CountdownManager:
"""
Manages a visual countdown that triggers a closing callback when it expires.
Designed to be composed into a QMainWindow (or any QObject owner) rather than
subclassed. The owner must supply:
- a ``close_timer`` (QTimer, singleShot)
- a ``countdown_timer`` (QTimer, repeating 1 s)
- a ``queue_lbl`` (QLabel) on which the remaining time is displayed
- a ``on_timeout`` callable invoked when the close timer fires
Usage::
self._countdown = CountdownManager(
close_timer=self.close_timer,
countdown_timer=self.countdown_timer,
label=self.ui.queue_lbl,
)
self.close_timer.timeout.connect(self.close) # wire your own close action
self._countdown.schedule(delay_ms=60_000)
"""
def __init__(self, close_timer: QTimer, countdown_timer: QTimer, label: QLabel) -> None:
self._close_timer = close_timer
self._countdown_timer = countdown_timer
self._label = label
self.remaining_time: int = 0
self._countdown_timer.timeout.connect(self._tick)
# ------------------------------------------------------------------
# Public API
# ------------------------------------------------------------------
def schedule(self, delay_ms: int = 60_000) -> None:
"""
Starts (or restarts) the countdown for *delay_ms* milliseconds.
:param delay_ms: Duration in milliseconds. Must be a positive integer;
invalid values silently default to 60 000 ms.
:type delay_ms: int
:return: None
"""
if not isinstance(delay_ms, int) or delay_ms <= 0:
delay_ms = 60_000
self.remaining_time = delay_ms // 1000
# Reset running timers to avoid duplicates
self._close_timer.stop()
self._countdown_timer.stop()
self._close_timer.start(delay_ms)
self._countdown_timer.start(1_000)
# Immediate display refresh (avoids a 1 s blank)
self._refresh_display()
def stop(self) -> None:
"""Stops both timers without triggering the close callback."""
self._close_timer.stop()
self._countdown_timer.stop()
# ------------------------------------------------------------------
# Internal
# ------------------------------------------------------------------
def _tick(self) -> None:
"""
Decrements the remaining time by one and updates the countdown timer's state.
If the remaining time reaches zero or below, the countdown timer stops
and no further operations are performed. If time remains, the display
is refreshed to reflect the updated state.
:return: None
"""
self.remaining_time -= 1
if self.remaining_time <= 0:
self._countdown_timer.stop()
return
self._refresh_display()
def _refresh_display(self) -> None:
"""
Updates the display label with the formatted remaining time.
The method calculates the remaining minutes and seconds from the total
remaining time and updates the label's text with a countdown timer
format.
:return: None
"""
minutes = max(0, self.remaining_time // 60)
seconds = max(0, self.remaining_time % 60)
self._label.setText(f"⏳ Fermeture dans {minutes:02d}:{seconds:02d}")
+55
View File
@@ -0,0 +1,55 @@
from PySide6.QtCore import QThread, Signal
from fivemserver.queuemanager import QueueManager
class QueueThread(QThread):
"""
A :class:`QThread` that drives a :class:`QueueManager` in a background thread
and forwards its updates via a Qt signal.
:ivar update: Emitted with a plain-text status string whenever the queue
state changes (e.g. ``"position:3:10"``, ``"ok"``).
:type update: Signal(str)
Example::
thread = QueueThread(user_id="123456789", parent=self)
thread.update.connect(self.handle_update)
thread.start()
# … later …
thread.stop()
thread.wait()
"""
update = Signal(str)
def __init__(self, user_id: str, parent=None) -> None:
super().__init__(parent)
self.manager = QueueManager(
user_id=user_id,
on_update=self.update.emit,
)
# ------------------------------------------------------------------
# QThread interface
# ------------------------------------------------------------------
def run(self) -> None:
"""
Entry point for the background thread: starts the queue manager loop.
:return: None
"""
self.manager.start()
def stop(self) -> None:
"""
Requests a graceful stop of the queue manager.
Call :py:meth:`wait` afterwards to ensure the thread has fully exited
before destroying this object.
:return: None
"""
self.manager.stop()
+100
View File
@@ -0,0 +1,100 @@
"""
uitools/ui_builder.py
-------------------
Helpers for dynamic UI mutations at runtime.
These functions operate on PySide6 widgets and layouts but carry no dependency
on MainWindow, making them independently testable and reusable.
"""
from PySide6.QtWidgets import QAbstractButton, QLayout, QSizePolicy
from ui.hazard_stripes import HazardButton
def set_en_chantier(button: QAbstractButton, valeur: bool) -> None:
"""
Toggles the *en_chantier* (under-construction) Qt property on *button* and
forces a style refresh so QSS selectors relying on that property take effect
immediately.
:param button: The push-button whose visual state must be updated.
:type button: QAbstractButton
:param valeur: ``True`` to enable the under-construction style, ``False`` to
restore the normal style.
:type valeur: bool
:return: None
"""
button.setProperty("en_chantier", valeur)
button.style().unpolish(button)
button.style().polish(button)
def replace_with_hazard_button(
old_btn: QAbstractButton,
parent_layout: QLayout,
label: str = "EN MAINTENANCE",
) -> HazardButton:
"""
Swaps *old_btn* in *parent_layout* for a :class:`HazardButton` that keeps the
same geometry, icon, and object name, then returns the new button.
The caller is responsible for connecting signals on the returned button.
:param old_btn: The existing button widget to replace.
:type old_btn: QAbstractButton
:param parent_layout: The layout that directly owns *old_btn*.
:type parent_layout: QLayout
:param label: Text displayed on the replacement button.
:type label: str
:return: The newly inserted :class:`HazardButton`.
:rtype: HazardButton
Example::
new_btn = replace_with_hazard_button(
self.ui.connexion_btn,
self.ui.verticalLayout_6,
)
self.ui.connexion_btn = new_btn
self.ui.connexion_btn.clicked.connect(self._on_connexion)
"""
index = parent_layout.indexOf(old_btn)
new_btn = HazardButton(old_btn.parentWidget())
new_btn.setObjectName(old_btn.objectName())
new_btn.setText(label)
new_btn.setIcon(old_btn.icon())
new_btn.setIconSize(old_btn.iconSize())
new_btn.setMinimumSize(old_btn.minimumSize())
new_btn.set_hazard(True)
parent_layout.takeAt(index)
old_btn.deleteLater()
parent_layout.insertWidget(index, new_btn)
return new_btn
def hide_staff_btn_and_recenter(staff_btn: QAbstractButton, layout: QLayout) -> None:
"""
Hides *staff_btn* and adjusts the first spacer found in *layout* to
vertically re-centre the remaining connexion button.
:param staff_btn: The staff-only button to hide.
:type staff_btn: QAbstractButton
:param layout: The vertical layout that contains both the spacer and
*staff_btn*.
:type layout: QLayout
:return: None
"""
staff_btn.hide()
for i in range(layout.count()):
item = layout.itemAt(i)
if item and item.spacerItem():
item.spacerItem().changeSize(
20, 15,
QSizePolicy.Policy.Fixed,
QSizePolicy.Policy.Fixed,
)
layout.invalidate()
break
+21
View File
@@ -0,0 +1,21 @@
from PySide6.QtGui import QCursor, QGuiApplication
from PySide6.QtWidgets import QWidget
def center_window(window: QWidget) -> None:
"""
Centers the given window on the screen where the cursor is currently located.
Falls back to the primary screen if the cursor is not on any screen.
:param window: The Qt window/widget to center.
:type window: QWidget
:return: None
"""
window.adjustSize()
screen = (
QGuiApplication.screenAt(QCursor.pos())
or QGuiApplication.primaryScreen()
)
rect = window.frameGeometry()
rect.moveCenter(screen.availableGeometry().center())
window.move(rect.topLeft())