livechat-desktop 1.0.0__tar.gz
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- livechat_desktop-1.0.0/PKG-INFO +23 -0
- livechat_desktop-1.0.0/README.md +8 -0
- livechat_desktop-1.0.0/livechat_desktop/__main__.py +36 -0
- livechat_desktop-1.0.0/livechat_desktop/assets/icons/romain_guillon.jpg +0 -0
- livechat_desktop-1.0.0/livechat_desktop/config.py +41 -0
- livechat_desktop-1.0.0/livechat_desktop/overlay.py +333 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/__init__.py +27 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/base.py +17 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/generic.py +19 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/scripts/tiktok_bypass.js +27 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/scripts/youtube_embed.html +30 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/tiktok.py +41 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/twitter.py +45 -0
- livechat_desktop-1.0.0/livechat_desktop/providers/youtube.py +40 -0
- livechat_desktop-1.0.0/livechat_desktop/socket_manager.py +77 -0
- livechat_desktop-1.0.0/livechat_desktop/tray.py +94 -0
- livechat_desktop-1.0.0/livechat_desktop.egg-info/PKG-INFO +23 -0
- livechat_desktop-1.0.0/livechat_desktop.egg-info/SOURCES.txt +31 -0
- livechat_desktop-1.0.0/livechat_desktop.egg-info/dependency_links.txt +1 -0
- livechat_desktop-1.0.0/livechat_desktop.egg-info/entry_points.txt +2 -0
- livechat_desktop-1.0.0/livechat_desktop.egg-info/requires.txt +6 -0
- livechat_desktop-1.0.0/livechat_desktop.egg-info/top_level.txt +1 -0
- livechat_desktop-1.0.0/pyproject.toml +31 -0
- livechat_desktop-1.0.0/setup.cfg +4 -0
- livechat_desktop-1.0.0/tests/test_handle.py +35 -0
- livechat_desktop-1.0.0/tests/test_image_resize.py +1 -0
- livechat_desktop-1.0.0/tests/test_logic.py +13 -0
- livechat_desktop-1.0.0/tests/test_overlay_logic.py +15 -0
- livechat_desktop-1.0.0/tests/test_timer.py +9 -0
- livechat_desktop-1.0.0/tests/test_vxt.py +24 -0
- livechat_desktop-1.0.0/tests/test_webview.py +36 -0
- livechat_desktop-1.0.0/tests/test_webview2.py +37 -0
- livechat_desktop-1.0.0/tests/test_ytdlp.py +27 -0
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livechat-desktop
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A desktop overlay for LiveChat built with PyQt6
|
|
5
|
+
Author: Romain Guillon
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: PyQt6>=6.11.0
|
|
10
|
+
Requires-Dist: python-socketio[client]>=5.11.0
|
|
11
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
12
|
+
Requires-Dist: requests>=2.32.0
|
|
13
|
+
Requires-Dist: yt-dlp>=2024.0.0
|
|
14
|
+
Requires-Dist: PyQt6-WebEngine>=6.11.0
|
|
15
|
+
|
|
16
|
+
# LiveChat Desktop
|
|
17
|
+
|
|
18
|
+
A desktop client for LiveChat, built with Python and PyQt6.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
- Displays media from chat using an overlay.
|
|
22
|
+
- Supports generic images/videos, YouTube, TikTok, and Twitter embeds.
|
|
23
|
+
- Customizes overlay sizing automatically.
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
import os
|
|
3
|
+
import builtins
|
|
4
|
+
|
|
5
|
+
if os.environ.get("LIVECHAT_DEBUG") != "1":
|
|
6
|
+
builtins.print = lambda *args, **kwargs: None
|
|
7
|
+
from PyQt6.QtWidgets import QApplication
|
|
8
|
+
|
|
9
|
+
# Obligatoire pour garantir que Wayland ne capture pas l'overlay et laisse passer les clics
|
|
10
|
+
os.environ['QT_QPA_PLATFORM'] = 'xcb'
|
|
11
|
+
|
|
12
|
+
from livechat_desktop.socket_manager import SocketManager
|
|
13
|
+
from livechat_desktop.overlay import Overlay
|
|
14
|
+
from livechat_desktop.tray import TrayManager
|
|
15
|
+
|
|
16
|
+
def main():
|
|
17
|
+
app = QApplication(sys.argv)
|
|
18
|
+
|
|
19
|
+
# Initialisation des différents modules de l'infrastructure
|
|
20
|
+
overlay = Overlay()
|
|
21
|
+
socket_manager = SocketManager()
|
|
22
|
+
tray_manager = TrayManager(app.style(), socket_manager, overlay)
|
|
23
|
+
|
|
24
|
+
# Connexion des signaux (Events)
|
|
25
|
+
socket_manager.media_signal.connect(overlay.handle_media)
|
|
26
|
+
socket_manager.stop_signal.connect(overlay.hide_all)
|
|
27
|
+
socket_manager.guilds_updated_signal.connect(tray_manager.update_menu)
|
|
28
|
+
|
|
29
|
+
# Démarrage du thread réseau
|
|
30
|
+
socket_manager.start()
|
|
31
|
+
|
|
32
|
+
# Lancement de la boucle d'événements Qt
|
|
33
|
+
sys.exit(app.exec())
|
|
34
|
+
|
|
35
|
+
if __name__ == '__main__':
|
|
36
|
+
main()
|
|
Binary file
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import json
|
|
3
|
+
|
|
4
|
+
CONFIG_PATH = os.path.expanduser("~/.config/livechat-desktop/configLiveChat.json")
|
|
5
|
+
|
|
6
|
+
class ConfigManager:
|
|
7
|
+
def __init__(self):
|
|
8
|
+
self.user_id = None
|
|
9
|
+
self.active_rooms = set()
|
|
10
|
+
self.load_config()
|
|
11
|
+
|
|
12
|
+
def load_config(self):
|
|
13
|
+
print("Chemin recherché pour la config :", CONFIG_PATH)
|
|
14
|
+
if os.path.exists(CONFIG_PATH):
|
|
15
|
+
try:
|
|
16
|
+
with open(CONFIG_PATH, 'r', encoding='utf-8') as f:
|
|
17
|
+
data = json.load(f)
|
|
18
|
+
self.user_id = data.get('userId')
|
|
19
|
+
self.active_rooms = set(data.get('activeRooms', []))
|
|
20
|
+
print("Configuration chargée depuis :", CONFIG_PATH)
|
|
21
|
+
except Exception as e:
|
|
22
|
+
print("Erreur lors du chargement de la config :", e)
|
|
23
|
+
|
|
24
|
+
def save_config(self):
|
|
25
|
+
os.makedirs(os.path.dirname(CONFIG_PATH), exist_ok=True)
|
|
26
|
+
try:
|
|
27
|
+
config = {
|
|
28
|
+
'userId': self.user_id,
|
|
29
|
+
'activeRooms': list(self.active_rooms)
|
|
30
|
+
}
|
|
31
|
+
with open(CONFIG_PATH, 'w', encoding='utf-8') as f:
|
|
32
|
+
json.dump(config, f, indent=2)
|
|
33
|
+
print("Configuration enregistrée à :", CONFIG_PATH)
|
|
34
|
+
except Exception as e:
|
|
35
|
+
print("Erreur lors de la sauvegarde de la config :", e)
|
|
36
|
+
|
|
37
|
+
def set_user_id(self, user_id):
|
|
38
|
+
self.user_id = user_id
|
|
39
|
+
self.save_config()
|
|
40
|
+
|
|
41
|
+
config_manager = ConfigManager()
|
|
@@ -0,0 +1,333 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import requests
|
|
3
|
+
from PyQt6.QtCore import Qt, QTimer, QUrl
|
|
4
|
+
from PyQt6.QtWidgets import QMainWindow, QLabel, QVBoxLayout, QWidget, QApplication
|
|
5
|
+
from PyQt6.QtGui import QPixmap
|
|
6
|
+
from PyQt6.QtMultimedia import QMediaPlayer, QAudioOutput
|
|
7
|
+
from PyQt6.QtMultimediaWidgets import QVideoWidget
|
|
8
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
9
|
+
from PyQt6.QtWebEngineCore import QWebEngineSettings
|
|
10
|
+
from livechat_desktop.providers import process_link
|
|
11
|
+
|
|
12
|
+
class Overlay(QMainWindow):
|
|
13
|
+
# --- CONFIGURATION & CONSTANTS ---
|
|
14
|
+
MAX_WIDTH_RATIO = 0.85
|
|
15
|
+
MEDIA_SCALE_WITH_TEXT = 0.70
|
|
16
|
+
MEDIA_SCALE_NO_TEXT = 0.85
|
|
17
|
+
|
|
18
|
+
DEFAULT_DURATION_BOT = 2000
|
|
19
|
+
|
|
20
|
+
TEXT_LEN_SHORT = 50
|
|
21
|
+
TEXT_LEN_MED = 100
|
|
22
|
+
TEXT_LEN_LONG = 200
|
|
23
|
+
|
|
24
|
+
FONT_SCALE_SHORT = 0.02
|
|
25
|
+
FONT_SCALE_MED = 0.018
|
|
26
|
+
FONT_SCALE_LONG = 0.015
|
|
27
|
+
FONT_SCALE_XLONG = 0.012
|
|
28
|
+
|
|
29
|
+
DEFAULT_WEB_WIDTH = 1280
|
|
30
|
+
DEFAULT_WEB_HEIGHT = 720
|
|
31
|
+
|
|
32
|
+
BOTTOM_MARGIN = 120 # Remonte le contenu pour ne pas chevaucher la barre des tâches
|
|
33
|
+
|
|
34
|
+
def __init__(self):
|
|
35
|
+
super().__init__()
|
|
36
|
+
|
|
37
|
+
self.setWindowFlags(
|
|
38
|
+
Qt.WindowType.FramelessWindowHint |
|
|
39
|
+
Qt.WindowType.WindowStaysOnTopHint |
|
|
40
|
+
Qt.WindowType.Tool |
|
|
41
|
+
Qt.WindowType.WindowTransparentForInput
|
|
42
|
+
)
|
|
43
|
+
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
44
|
+
|
|
45
|
+
screen = QApplication.primaryScreen().geometry()
|
|
46
|
+
self.setGeometry(screen)
|
|
47
|
+
|
|
48
|
+
self.central_widget = QWidget()
|
|
49
|
+
self.setCentralWidget(self.central_widget)
|
|
50
|
+
|
|
51
|
+
self.layout = QVBoxLayout(self.central_widget)
|
|
52
|
+
self.layout.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
53
|
+
self.layout.setContentsMargins(0, 0, 0, self.BOTTOM_MARGIN)
|
|
54
|
+
|
|
55
|
+
self.image_label = QLabel()
|
|
56
|
+
self.image_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
57
|
+
self.image_label.hide()
|
|
58
|
+
|
|
59
|
+
self.top_text_label = QLabel()
|
|
60
|
+
self.top_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
61
|
+
self.top_text_label.hide()
|
|
62
|
+
|
|
63
|
+
self.bottom_text_label = QLabel()
|
|
64
|
+
self.bottom_text_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
|
65
|
+
self.bottom_text_label.hide()
|
|
66
|
+
|
|
67
|
+
self.video_widget = QVideoWidget()
|
|
68
|
+
self.video_widget.videoSink().videoSizeChanged.connect(self.on_video_size_changed)
|
|
69
|
+
self.media_player = QMediaPlayer()
|
|
70
|
+
self.audio_output = QAudioOutput()
|
|
71
|
+
self.media_player.setAudioOutput(self.audio_output)
|
|
72
|
+
self.media_player.setVideoOutput(self.video_widget)
|
|
73
|
+
self.video_widget.hide()
|
|
74
|
+
|
|
75
|
+
# Le navigateur intégré (remplace l'iframe de l'ancien client)
|
|
76
|
+
self.web_view = QWebEngineView()
|
|
77
|
+
self.web_view.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
|
78
|
+
self.web_view.page().setBackgroundColor(Qt.GlobalColor.transparent)
|
|
79
|
+
# Autoriser l'autoplay des vidéos sans interaction utilisateur
|
|
80
|
+
self.web_view.settings().setAttribute(QWebEngineSettings.WebAttribute.PlaybackRequiresUserGesture, False)
|
|
81
|
+
self.web_view.hide()
|
|
82
|
+
self.web_view.loadFinished.connect(self.on_web_load_finished)
|
|
83
|
+
self.web_view.titleChanged.connect(self.on_title_changed)
|
|
84
|
+
|
|
85
|
+
self.layout.addWidget(self.top_text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
86
|
+
self.layout.addWidget(self.image_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
87
|
+
self.layout.addWidget(self.video_widget, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
88
|
+
self.layout.addWidget(self.web_view, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
89
|
+
self.layout.addWidget(self.bottom_text_label, alignment=Qt.AlignmentFlag.AlignCenter)
|
|
90
|
+
|
|
91
|
+
self.timer = QTimer(self)
|
|
92
|
+
self.timer.timeout.connect(self.hide_all)
|
|
93
|
+
|
|
94
|
+
def _get_text_height(self):
|
|
95
|
+
h = 0
|
|
96
|
+
if not self.top_text_label.isHidden():
|
|
97
|
+
h += self.top_text_label.height() + self.layout.spacing()
|
|
98
|
+
if not self.bottom_text_label.isHidden():
|
|
99
|
+
h += self.bottom_text_label.height() + self.layout.spacing()
|
|
100
|
+
return h
|
|
101
|
+
|
|
102
|
+
def _get_media_max_height(self, has_text):
|
|
103
|
+
is_fullscreen = getattr(self, 'current_format', 'illustration') == 'fullscreen'
|
|
104
|
+
|
|
105
|
+
if is_fullscreen:
|
|
106
|
+
base_h = self.height() - getattr(self, 'current_bottom_margin', 0)
|
|
107
|
+
else:
|
|
108
|
+
base_h = int(self.height() * self.MAX_WIDTH_RATIO) # 85% de l'écran
|
|
109
|
+
|
|
110
|
+
if has_text:
|
|
111
|
+
return max(100, base_h - self._get_text_height() - 20)
|
|
112
|
+
return base_h
|
|
113
|
+
|
|
114
|
+
def on_video_size_changed(self, size=None):
|
|
115
|
+
if size is None or not size.isValid():
|
|
116
|
+
size = self.video_widget.videoSink().videoSize()
|
|
117
|
+
if size.width() > 0 and size.height() > 0:
|
|
118
|
+
scale_factor = getattr(self, 'media_scale_factor', self.MEDIA_SCALE_NO_TEXT)
|
|
119
|
+
has_text = scale_factor != self.MEDIA_SCALE_NO_TEXT
|
|
120
|
+
|
|
121
|
+
is_fullscreen = getattr(self, 'current_format', 'illustration') == 'fullscreen'
|
|
122
|
+
if is_fullscreen:
|
|
123
|
+
max_w = self.width()
|
|
124
|
+
else:
|
|
125
|
+
max_w = int(self.width() * self.MAX_WIDTH_RATIO)
|
|
126
|
+
|
|
127
|
+
max_h = self._get_media_max_height(has_text)
|
|
128
|
+
|
|
129
|
+
scaled = size.scaled(max_w, max_h, Qt.AspectRatioMode.KeepAspectRatio)
|
|
130
|
+
self.video_widget.setFixedSize(scaled)
|
|
131
|
+
|
|
132
|
+
def on_title_changed(self, title):
|
|
133
|
+
if title == "VIDEO_ENDED":
|
|
134
|
+
self.hide_all()
|
|
135
|
+
|
|
136
|
+
def on_web_load_finished(self, ok):
|
|
137
|
+
if not ok: return
|
|
138
|
+
js_code = getattr(self, 'current_js_injection', None)
|
|
139
|
+
if js_code:
|
|
140
|
+
self.web_view.page().runJavaScript(js_code)
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
|
|
144
|
+
def hide_all(self):
|
|
145
|
+
self.timer.stop()
|
|
146
|
+
self.image_label.hide()
|
|
147
|
+
self.video_widget.hide()
|
|
148
|
+
self.top_text_label.hide()
|
|
149
|
+
self.bottom_text_label.hide()
|
|
150
|
+
self.web_view.hide()
|
|
151
|
+
self.web_view.setUrl(QUrl("about:blank"))
|
|
152
|
+
self.media_player.stop()
|
|
153
|
+
try:
|
|
154
|
+
self.media_player.mediaStatusChanged.disconnect(self.on_media_status_changed)
|
|
155
|
+
except:
|
|
156
|
+
pass
|
|
157
|
+
self.hide()
|
|
158
|
+
|
|
159
|
+
def play_direct_video(self, direct_url, duration=None):
|
|
160
|
+
try:
|
|
161
|
+
self.media_player.setSource(QUrl(direct_url))
|
|
162
|
+
self.video_widget.show()
|
|
163
|
+
self.media_player.play()
|
|
164
|
+
if duration:
|
|
165
|
+
self.timer.start(duration)
|
|
166
|
+
else:
|
|
167
|
+
self.media_player.mediaStatusChanged.connect(self.on_media_status_changed)
|
|
168
|
+
except Exception as e:
|
|
169
|
+
print("Erreur lecteur vidéo:", e)
|
|
170
|
+
|
|
171
|
+
def on_media_status_changed(self, status):
|
|
172
|
+
print(f"DEBUG: Media status changed to {status}")
|
|
173
|
+
if status == QMediaPlayer.MediaStatus.EndOfMedia:
|
|
174
|
+
print("DEBUG: EndOfMedia reached, hiding all.")
|
|
175
|
+
self.hide_all()
|
|
176
|
+
try:
|
|
177
|
+
self.media_player.mediaStatusChanged.disconnect(self.on_media_status_changed)
|
|
178
|
+
except:
|
|
179
|
+
pass
|
|
180
|
+
|
|
181
|
+
def handle_media(self, data):
|
|
182
|
+
print(f"DEBUG: Media received: {data}")
|
|
183
|
+
self.hide_all()
|
|
184
|
+
|
|
185
|
+
url = data.get('url')
|
|
186
|
+
lien = data.get('lien')
|
|
187
|
+
text = data.get('text')
|
|
188
|
+
media_type = data.get('type')
|
|
189
|
+
duration_raw = data.get('duration')
|
|
190
|
+
try:
|
|
191
|
+
duration = int(duration_raw) if duration_raw is not None else None
|
|
192
|
+
except ValueError:
|
|
193
|
+
duration = None
|
|
194
|
+
self.current_format = data.get('format', 'illustration')
|
|
195
|
+
|
|
196
|
+
if self.current_format == 'fullscreen':
|
|
197
|
+
self.current_bottom_margin = 0
|
|
198
|
+
self.layout.setContentsMargins(0, 0, 0, 0)
|
|
199
|
+
else:
|
|
200
|
+
self.current_bottom_margin = self.BOTTOM_MARGIN
|
|
201
|
+
self.layout.setContentsMargins(0, 0, 0, self.BOTTOM_MARGIN)
|
|
202
|
+
|
|
203
|
+
top_text = None
|
|
204
|
+
bottom_text = text
|
|
205
|
+
provider_result = {}
|
|
206
|
+
|
|
207
|
+
# PROCESSEUR DE LIENS (Twitter, YouTube, TikTok...)
|
|
208
|
+
self.current_js_injection = None
|
|
209
|
+
if lien:
|
|
210
|
+
usable_window_size = (self.width(), self.height() - self.current_bottom_margin)
|
|
211
|
+
provider_result = process_link(lien, data, usable_window_size)
|
|
212
|
+
self.current_js_injection = provider_result.get("js_injection")
|
|
213
|
+
|
|
214
|
+
# Application des modifs de données (ex: Twitter convertit le lien en url direct media)
|
|
215
|
+
if "url" in provider_result: url = provider_result["url"]
|
|
216
|
+
if "type" in provider_result: media_type = provider_result["type"]
|
|
217
|
+
if "lien" in provider_result: lien = provider_result["lien"]
|
|
218
|
+
if "top_text" in provider_result: top_text = provider_result["top_text"]
|
|
219
|
+
|
|
220
|
+
# --- WORKAROUND ---
|
|
221
|
+
# Le bot distant (sur iceboxer) envoie par défaut duration=2000 si non précisé.
|
|
222
|
+
# On l'ignore pour les vidéos et les liens pour les laisser se terminer naturellement.
|
|
223
|
+
# SAUF pour les pages web génériques où on laisse le timer s'appliquer.
|
|
224
|
+
is_generic_web = provider_result.get("is_generic_web", False)
|
|
225
|
+
|
|
226
|
+
# Si c'est une page web générique, on s'assure qu'il y a un timer
|
|
227
|
+
if is_generic_web and not duration:
|
|
228
|
+
duration = self.DEFAULT_DURATION_BOT
|
|
229
|
+
|
|
230
|
+
if ((lien and not is_generic_web) or media_type in ('video', 'audio')) and duration == self.DEFAULT_DURATION_BOT:
|
|
231
|
+
duration = None
|
|
232
|
+
|
|
233
|
+
print(f"DEBUG: Processed duration is: {duration}")
|
|
234
|
+
|
|
235
|
+
has_text = bool(top_text or bottom_text)
|
|
236
|
+
self.media_scale_factor = self.MEDIA_SCALE_WITH_TEXT if has_text else self.MEDIA_SCALE_NO_TEXT
|
|
237
|
+
|
|
238
|
+
def setup_label(label, t):
|
|
239
|
+
if not t: return
|
|
240
|
+
|
|
241
|
+
# Nettoyage des sauts de ligne inutiles et des espaces multiples
|
|
242
|
+
t = " ".join(t.split())
|
|
243
|
+
|
|
244
|
+
length = len(t)
|
|
245
|
+
# Base text sizes on screen width for perfect scaling
|
|
246
|
+
if length < self.TEXT_LEN_SHORT: fs_factor = self.FONT_SCALE_SHORT
|
|
247
|
+
elif length < self.TEXT_LEN_MED: fs_factor = self.FONT_SCALE_MED
|
|
248
|
+
elif length < self.TEXT_LEN_LONG: fs_factor = self.FONT_SCALE_LONG
|
|
249
|
+
else: fs_factor = self.FONT_SCALE_XLONG
|
|
250
|
+
|
|
251
|
+
font_size = int(self.width() * fs_factor)
|
|
252
|
+
padding = int(font_size / 3)
|
|
253
|
+
radius = int(font_size / 4)
|
|
254
|
+
|
|
255
|
+
label.setStyleSheet(f"color: white; font-size: {font_size}px; font-weight: bold; background-color: rgba(0,0,0,150); padding: {padding}px; border-radius: {radius}px;")
|
|
256
|
+
label.setWordWrap(True)
|
|
257
|
+
label.setMaximumWidth(int(self.width() * self.MAX_WIDTH_RATIO))
|
|
258
|
+
label.setText(t)
|
|
259
|
+
label.adjustSize() # Ensure it calculates its own bounds AFTER text is set
|
|
260
|
+
label.setMinimumHeight(label.sizeHint().height())
|
|
261
|
+
label.show()
|
|
262
|
+
|
|
263
|
+
setup_label(self.top_text_label, top_text)
|
|
264
|
+
setup_label(self.bottom_text_label, bottom_text)
|
|
265
|
+
|
|
266
|
+
if lien:
|
|
267
|
+
embed_url = provider_result.get("embed_url", lien)
|
|
268
|
+
web_size = provider_result.get("web_size")
|
|
269
|
+
html = provider_result.get("html")
|
|
270
|
+
|
|
271
|
+
if web_size:
|
|
272
|
+
max_h = self._get_media_max_height(has_text)
|
|
273
|
+
is_fullscreen = self.current_format == 'fullscreen'
|
|
274
|
+
target_w = self.width() if is_fullscreen else int(self.width() * self.MAX_WIDTH_RATIO)
|
|
275
|
+
|
|
276
|
+
if provider_result.get("is_generic_web", False):
|
|
277
|
+
# La page web générique prend tout l'espace ciblé
|
|
278
|
+
self.web_view.setFixedSize(target_w, max_h)
|
|
279
|
+
else:
|
|
280
|
+
if web_size[1] > 0:
|
|
281
|
+
ratio = web_size[0] / web_size[1]
|
|
282
|
+
if abs(web_size[0] - self.width()) < 10:
|
|
283
|
+
self.web_view.setFixedSize(target_w, max_h)
|
|
284
|
+
else:
|
|
285
|
+
new_w = int(max_h * ratio)
|
|
286
|
+
# S'assurer de ne pas dépasser la largeur cible
|
|
287
|
+
if new_w > target_w:
|
|
288
|
+
new_w = target_w
|
|
289
|
+
max_h = int(new_w / ratio)
|
|
290
|
+
self.web_view.setFixedSize(new_w, max_h)
|
|
291
|
+
else:
|
|
292
|
+
self.web_view.setFixedSize(target_w, max_h)
|
|
293
|
+
else:
|
|
294
|
+
self.web_view.setFixedSize(self.DEFAULT_WEB_WIDTH, self.DEFAULT_WEB_HEIGHT)
|
|
295
|
+
|
|
296
|
+
if html:
|
|
297
|
+
self.web_view.setHtml(html, QUrl("http://localhost"))
|
|
298
|
+
else:
|
|
299
|
+
self.web_view.setUrl(QUrl(embed_url))
|
|
300
|
+
|
|
301
|
+
self.web_view.show()
|
|
302
|
+
if duration:
|
|
303
|
+
self.timer.start(duration)
|
|
304
|
+
|
|
305
|
+
elif url:
|
|
306
|
+
if media_type == 'image':
|
|
307
|
+
try:
|
|
308
|
+
resp = requests.get(url)
|
|
309
|
+
if resp.status_code == 200:
|
|
310
|
+
pixmap = QPixmap()
|
|
311
|
+
pixmap.loadFromData(resp.content)
|
|
312
|
+
scale_factor = getattr(self, 'media_scale_factor', self.MEDIA_SCALE_NO_TEXT)
|
|
313
|
+
has_text = scale_factor != self.MEDIA_SCALE_NO_TEXT
|
|
314
|
+
max_h = self._get_media_max_height(has_text)
|
|
315
|
+
|
|
316
|
+
is_fullscreen = self.current_format == 'fullscreen'
|
|
317
|
+
target_w = self.width() if is_fullscreen else int(self.width() * self.MAX_WIDTH_RATIO)
|
|
318
|
+
|
|
319
|
+
scaled_pix = pixmap.scaled(target_w, max_h, Qt.AspectRatioMode.KeepAspectRatio, Qt.TransformationMode.SmoothTransformation)
|
|
320
|
+
self.image_label.setPixmap(scaled_pix)
|
|
321
|
+
self.image_label.show()
|
|
322
|
+
except Exception as e:
|
|
323
|
+
print("Error image:", e)
|
|
324
|
+
elif media_type in ('video', 'audio'):
|
|
325
|
+
self.play_direct_video(url, duration)
|
|
326
|
+
|
|
327
|
+
if not lien and media_type not in ('video', 'audio'):
|
|
328
|
+
if duration:
|
|
329
|
+
self.timer.start(duration)
|
|
330
|
+
elif media_type == 'image' or text:
|
|
331
|
+
pass
|
|
332
|
+
|
|
333
|
+
self.show()
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
from .twitter import TwitterProvider
|
|
2
|
+
from .youtube import YouTubeProvider
|
|
3
|
+
from .tiktok import TikTokProvider
|
|
4
|
+
from .generic import GenericProvider
|
|
5
|
+
|
|
6
|
+
# Initialisation des providers dans l'ordre de priorité
|
|
7
|
+
PROVIDERS = [
|
|
8
|
+
TwitterProvider(),
|
|
9
|
+
YouTubeProvider(),
|
|
10
|
+
TikTokProvider(),
|
|
11
|
+
GenericProvider() # Fallback qui gère tout ou presque
|
|
12
|
+
]
|
|
13
|
+
|
|
14
|
+
def process_link(lien, data, window_size):
|
|
15
|
+
"""
|
|
16
|
+
Routage vers le bon provider selon l'URL.
|
|
17
|
+
Retourne un dictionnaire contenant les modifications de 'data' ou
|
|
18
|
+
des instructions spécifiques de rendu (html, embed_url, web_size).
|
|
19
|
+
"""
|
|
20
|
+
if not lien:
|
|
21
|
+
return {}
|
|
22
|
+
|
|
23
|
+
for provider in PROVIDERS:
|
|
24
|
+
if provider.can_handle(lien):
|
|
25
|
+
return provider.process(lien, data, window_size)
|
|
26
|
+
|
|
27
|
+
return {}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
class MediaProvider:
|
|
2
|
+
"""
|
|
3
|
+
Classe de base (interface) pour tous les fournisseurs de médias.
|
|
4
|
+
Normalise le traitement des liens entrants.
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
def can_handle(self, url: str) -> bool:
|
|
8
|
+
"""Retourne True si ce fournisseur peut gérer l'URL donnée."""
|
|
9
|
+
raise NotImplementedError("doit être implémenté par la classe fille")
|
|
10
|
+
|
|
11
|
+
def process(self, url: str, data: dict, window_size: tuple) -> dict:
|
|
12
|
+
"""
|
|
13
|
+
Traite l'URL et retourne un dictionnaire contenant les instructions
|
|
14
|
+
de rendu (embed_url, html, web_size, js_injection, etc.) ou les
|
|
15
|
+
modifications à apporter à l'objet `data` (url, type, etc.).
|
|
16
|
+
"""
|
|
17
|
+
raise NotImplementedError("doit être implémenté par la classe fille")
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
from .base import MediaProvider
|
|
2
|
+
|
|
3
|
+
class GenericProvider(MediaProvider):
|
|
4
|
+
GENERIC_WEB_WIDTH_RATIO = 0.85
|
|
5
|
+
GENERIC_WEB_HEIGHT_RATIO = 0.85
|
|
6
|
+
|
|
7
|
+
def can_handle(self, url: str) -> bool:
|
|
8
|
+
return True # Fallback for everything else
|
|
9
|
+
|
|
10
|
+
def process(self, url: str, data: dict, window_size: tuple) -> dict:
|
|
11
|
+
max_w = int(window_size[0] * self.GENERIC_WEB_WIDTH_RATIO)
|
|
12
|
+
max_h = int(window_size[1] * self.GENERIC_WEB_HEIGHT_RATIO)
|
|
13
|
+
|
|
14
|
+
return {
|
|
15
|
+
"type": "web",
|
|
16
|
+
"embed_url": url,
|
|
17
|
+
"web_size": (max_w, max_h),
|
|
18
|
+
"is_generic_web": True
|
|
19
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
// Masquer les bandeaux avec CSS au cas où on n'arrive pas à cliquer
|
|
2
|
+
const style = document.createElement('style');
|
|
3
|
+
style.innerHTML = '.tiktok-cookie-banner, [data-testid="CookieConsent"], ytd-consent-bump-v2-lightbox, .tp-yt-paper-dialog { display: none !important; }';
|
|
4
|
+
document.head.appendChild(style);
|
|
5
|
+
|
|
6
|
+
// Boucle agressive pour cliquer sur les popups asynchrones
|
|
7
|
+
let attempts = 0;
|
|
8
|
+
const intervalId = setInterval(() => {
|
|
9
|
+
attempts++;
|
|
10
|
+
// On cherche tous les éléments cliquables
|
|
11
|
+
document.querySelectorAll('button, a, div[role="button"]').forEach(btn => {
|
|
12
|
+
const text = (btn.innerText || '').toLowerCase();
|
|
13
|
+
if (text.includes('allow all') || text.includes('tout accepter') ||
|
|
14
|
+
text.includes('decline') || text.includes('refuser') || text.includes('accepter cookies')) {
|
|
15
|
+
btn.click();
|
|
16
|
+
}
|
|
17
|
+
});
|
|
18
|
+
|
|
19
|
+
// Clic sur Play
|
|
20
|
+
const playBtn = document.querySelector('.xgplayer-play, .play-button, button[aria-label="Play"], button[aria-label="Watch"]');
|
|
21
|
+
if (playBtn) {
|
|
22
|
+
playBtn.click();
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
// On arrête de spammer après 20 essais (10 secondes)
|
|
26
|
+
if (attempts > 20) clearInterval(intervalId);
|
|
27
|
+
}, 500);
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
<!DOCTYPE html>
|
|
2
|
+
<html>
|
|
3
|
+
<head>
|
|
4
|
+
<style>
|
|
5
|
+
body, html { margin: 0; padding: 0; width: 100%; height: 100%; overflow: hidden; background-color: transparent; }
|
|
6
|
+
iframe { width: 100%; height: 100%; border: none; }
|
|
7
|
+
</style>
|
|
8
|
+
</head>
|
|
9
|
+
<body>
|
|
10
|
+
<iframe id="player" src="{embed_url}" allow="autoplay; fullscreen" referrerpolicy="strict-origin-when-cross-origin"></iframe>
|
|
11
|
+
<script>
|
|
12
|
+
var iframe = document.getElementById("player");
|
|
13
|
+
iframe.onload = function() {
|
|
14
|
+
iframe.contentWindow.postMessage('{"event":"listening","id":1}', '*');
|
|
15
|
+
};
|
|
16
|
+
window.addEventListener("message", function(event) {
|
|
17
|
+
try {
|
|
18
|
+
var data = JSON.parse(event.data);
|
|
19
|
+
// YT.PlayerState.ENDED == 0
|
|
20
|
+
if (data.event === "infoDelivery" && data.info && data.info.playerState === 0) {
|
|
21
|
+
document.title = "VIDEO_ENDED";
|
|
22
|
+
}
|
|
23
|
+
if (data.event === "onStateChange" && data.info === 0) {
|
|
24
|
+
document.title = "VIDEO_ENDED";
|
|
25
|
+
}
|
|
26
|
+
} catch(e) {}
|
|
27
|
+
});
|
|
28
|
+
</script>
|
|
29
|
+
</body>
|
|
30
|
+
</html>
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
from .base import MediaProvider
|
|
4
|
+
|
|
5
|
+
class TikTokProvider(MediaProvider):
|
|
6
|
+
TIKTOK_URL_REGEX = r'tiktok\.com\/.*\/video\/([0-9]+)'
|
|
7
|
+
TIKTOK_EMBED_BASE = "https://www.tiktok.com/embed/v2/"
|
|
8
|
+
TIKTOK_ASPECT_RATIO = 9 / 16
|
|
9
|
+
TIKTOK_DEFAULT_WIDTH = 405
|
|
10
|
+
TIKTOK_DEFAULT_HEIGHT = 720
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
script_path = os.path.join(script_dir, 'scripts', 'tiktok_bypass.js')
|
|
15
|
+
with open(script_path, 'r', encoding='utf-8') as f:
|
|
16
|
+
self.js_bypass_script = f.read()
|
|
17
|
+
|
|
18
|
+
def can_handle(self, url: str) -> bool:
|
|
19
|
+
return bool(re.search(self.TIKTOK_URL_REGEX, url))
|
|
20
|
+
|
|
21
|
+
def process(self, url: str, data: dict, window_size: tuple) -> dict:
|
|
22
|
+
match = re.search(self.TIKTOK_URL_REGEX, url)
|
|
23
|
+
if not match:
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
video_id = match.group(1)
|
|
27
|
+
embed_url = f"{self.TIKTOK_EMBED_BASE}{video_id}"
|
|
28
|
+
|
|
29
|
+
current_format = data.get('format', 'illustration')
|
|
30
|
+
if current_format == "fullscreen":
|
|
31
|
+
h = window_size[1]
|
|
32
|
+
w = int(h * self.TIKTOK_ASPECT_RATIO)
|
|
33
|
+
web_size = (w, h)
|
|
34
|
+
else:
|
|
35
|
+
web_size = (self.TIKTOK_DEFAULT_WIDTH, self.TIKTOK_DEFAULT_HEIGHT)
|
|
36
|
+
|
|
37
|
+
return {
|
|
38
|
+
"embed_url": embed_url,
|
|
39
|
+
"web_size": web_size,
|
|
40
|
+
"js_injection": self.js_bypass_script
|
|
41
|
+
}
|
|
@@ -0,0 +1,45 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import requests
|
|
3
|
+
from .base import MediaProvider
|
|
4
|
+
|
|
5
|
+
class TwitterProvider(MediaProvider):
|
|
6
|
+
TWITTER_URL_REGEX = r'(?:twitter\.com|x\.com)\/[^\/]+\/status\/(\d+)'
|
|
7
|
+
FXTWITTER_API_BASE = "https://api.fxtwitter.com/status/"
|
|
8
|
+
API_TIMEOUT = 5
|
|
9
|
+
|
|
10
|
+
def can_handle(self, url: str) -> bool:
|
|
11
|
+
return "x.com" in url or "twitter.com" in url
|
|
12
|
+
|
|
13
|
+
def process(self, url: str, data: dict, window_size: tuple) -> dict:
|
|
14
|
+
match = re.search(self.TWITTER_URL_REGEX, url)
|
|
15
|
+
if not match:
|
|
16
|
+
return {}
|
|
17
|
+
|
|
18
|
+
tweet_id = match.group(1)
|
|
19
|
+
result = {}
|
|
20
|
+
|
|
21
|
+
try:
|
|
22
|
+
resp = requests.get(f"{self.FXTWITTER_API_BASE}{tweet_id}", timeout=self.API_TIMEOUT)
|
|
23
|
+
if resp.status_code == 200:
|
|
24
|
+
api_data = resp.json()
|
|
25
|
+
tweet = api_data.get("tweet", {})
|
|
26
|
+
if not tweet:
|
|
27
|
+
return {}
|
|
28
|
+
|
|
29
|
+
xt_text = tweet.get("text")
|
|
30
|
+
if xt_text:
|
|
31
|
+
result["top_text"] = xt_text
|
|
32
|
+
|
|
33
|
+
media = tweet.get("media", {}).get("all", [])
|
|
34
|
+
if media:
|
|
35
|
+
first_media = media[0]
|
|
36
|
+
m_type = "video" if first_media.get("type") in ("video", "gif") else "image"
|
|
37
|
+
m_url = first_media.get("url")
|
|
38
|
+
|
|
39
|
+
result["url"] = m_url
|
|
40
|
+
result["type"] = m_type
|
|
41
|
+
result["lien"] = None
|
|
42
|
+
except Exception as e:
|
|
43
|
+
print("Erreur extraction Twitter:", e)
|
|
44
|
+
|
|
45
|
+
return result
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import os
|
|
3
|
+
from .base import MediaProvider
|
|
4
|
+
|
|
5
|
+
class YouTubeProvider(MediaProvider):
|
|
6
|
+
YOUTUBE_URL_REGEX = r'(?:youtube\.com\/(?:[^\/]+\/.+\/|(?:v|e(?:mbed)?)\/|.*[?&]v=)|youtu\.be\/)([^"&?\/\s]{11})'
|
|
7
|
+
YOUTUBE_EMBED_BASE = "https://www.youtube-nocookie.com/embed/"
|
|
8
|
+
YOUTUBE_EMBED_PARAMS = "?autoplay=1&controls=0&showinfo=0&rel=0&iv_load_policy=3&enablejsapi=1"
|
|
9
|
+
YOUTUBE_DEFAULT_WIDTH = 1280
|
|
10
|
+
YOUTUBE_DEFAULT_HEIGHT = 720
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
script_dir = os.path.dirname(os.path.abspath(__file__))
|
|
14
|
+
template_path = os.path.join(script_dir, 'scripts', 'youtube_embed.html')
|
|
15
|
+
with open(template_path, 'r', encoding='utf-8') as f:
|
|
16
|
+
self.youtube_html_template = f.read()
|
|
17
|
+
|
|
18
|
+
def can_handle(self, url: str) -> bool:
|
|
19
|
+
return bool(re.search(self.YOUTUBE_URL_REGEX, url))
|
|
20
|
+
|
|
21
|
+
def process(self, url: str, data: dict, window_size: tuple) -> dict:
|
|
22
|
+
match = re.search(self.YOUTUBE_URL_REGEX, url)
|
|
23
|
+
if not match:
|
|
24
|
+
return {}
|
|
25
|
+
|
|
26
|
+
video_id = match.group(1)
|
|
27
|
+
embed_url = f"{self.YOUTUBE_EMBED_BASE}{video_id}{self.YOUTUBE_EMBED_PARAMS}"
|
|
28
|
+
html = self.youtube_html_template.replace("{embed_url}", embed_url)
|
|
29
|
+
|
|
30
|
+
current_format = data.get('format', 'illustration')
|
|
31
|
+
if current_format == "fullscreen":
|
|
32
|
+
web_size = (window_size[0], window_size[1])
|
|
33
|
+
else:
|
|
34
|
+
web_size = (self.YOUTUBE_DEFAULT_WIDTH, self.YOUTUBE_DEFAULT_HEIGHT)
|
|
35
|
+
|
|
36
|
+
return {
|
|
37
|
+
"embed_url": embed_url,
|
|
38
|
+
"html": html,
|
|
39
|
+
"web_size": web_size
|
|
40
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import socketio
|
|
3
|
+
from PyQt6.QtCore import QThread, pyqtSignal
|
|
4
|
+
from livechat_desktop.config import config_manager
|
|
5
|
+
|
|
6
|
+
class SocketManager(QThread):
|
|
7
|
+
# Signaux pour communiquer avec l'interface graphique (Overlay)
|
|
8
|
+
media_signal = pyqtSignal(dict)
|
|
9
|
+
stop_signal = pyqtSignal()
|
|
10
|
+
guilds_updated_signal = pyqtSignal(list)
|
|
11
|
+
|
|
12
|
+
def __init__(self):
|
|
13
|
+
super().__init__()
|
|
14
|
+
self.sio = socketio.Client()
|
|
15
|
+
self.bot_client_id = None
|
|
16
|
+
self.client_guilds = []
|
|
17
|
+
|
|
18
|
+
self.setup_events()
|
|
19
|
+
|
|
20
|
+
def setup_events(self):
|
|
21
|
+
@self.sio.on('connect')
|
|
22
|
+
def on_connect():
|
|
23
|
+
print("Connecté au serveur Socket")
|
|
24
|
+
print("Vérification de user_id :", config_manager.user_id)
|
|
25
|
+
if config_manager.user_id:
|
|
26
|
+
print("Envoi de get-my-guilds avec l'id :", config_manager.user_id)
|
|
27
|
+
self.get_my_guilds()
|
|
28
|
+
else:
|
|
29
|
+
print("Aucun user_id trouvé dans config_manager!")
|
|
30
|
+
|
|
31
|
+
@self.sio.on('bot-config')
|
|
32
|
+
def on_bot_config(config):
|
|
33
|
+
print("Config reçue du bot :", config)
|
|
34
|
+
self.bot_client_id = config.get('clientId')
|
|
35
|
+
self.guilds_updated_signal.emit(self.client_guilds)
|
|
36
|
+
|
|
37
|
+
@self.sio.on('list-guilds')
|
|
38
|
+
def on_list_guilds(guilds):
|
|
39
|
+
print(f"Liste des serveurs reçue ({len(guilds)} serveurs)")
|
|
40
|
+
self.client_guilds = guilds
|
|
41
|
+
|
|
42
|
+
# On rejoint les rooms actives
|
|
43
|
+
for room_id in list(config_manager.active_rooms):
|
|
44
|
+
if any(g.get('id') == room_id for g in guilds):
|
|
45
|
+
print(f"Rejoint la room active : {room_id}")
|
|
46
|
+
self.join_room(room_id)
|
|
47
|
+
else:
|
|
48
|
+
config_manager.active_rooms.remove(room_id)
|
|
49
|
+
|
|
50
|
+
config_manager.save_config()
|
|
51
|
+
self.guilds_updated_signal.emit(self.client_guilds)
|
|
52
|
+
|
|
53
|
+
@self.sio.on('display-media')
|
|
54
|
+
def on_display_media(data):
|
|
55
|
+
self.media_signal.emit(data)
|
|
56
|
+
|
|
57
|
+
@self.sio.on('stop')
|
|
58
|
+
def on_stop():
|
|
59
|
+
self.stop_signal.emit()
|
|
60
|
+
|
|
61
|
+
def get_my_guilds(self):
|
|
62
|
+
if config_manager.user_id:
|
|
63
|
+
self.sio.emit("get-my-guilds", config_manager.user_id)
|
|
64
|
+
|
|
65
|
+
def join_room(self, guild_id):
|
|
66
|
+
self.sio.emit("join-server-room", guild_id)
|
|
67
|
+
|
|
68
|
+
def leave_room(self, guild_id):
|
|
69
|
+
self.sio.emit("leave-server-room", guild_id)
|
|
70
|
+
|
|
71
|
+
def run(self):
|
|
72
|
+
socket_url = os.environ.get("SOCKET_URL", "http://iceboxer.hd.free.fr:8080")
|
|
73
|
+
try:
|
|
74
|
+
self.sio.connect(socket_url)
|
|
75
|
+
self.sio.wait()
|
|
76
|
+
except Exception as e:
|
|
77
|
+
print("SocketIO Error:", e)
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import os
|
|
2
|
+
import webbrowser
|
|
3
|
+
import urllib.parse
|
|
4
|
+
from PyQt6.QtWidgets import QSystemTrayIcon, QMenu, QApplication
|
|
5
|
+
from PyQt6.QtGui import QAction, QIcon
|
|
6
|
+
from livechat_desktop.config import config_manager
|
|
7
|
+
|
|
8
|
+
class TrayManager:
|
|
9
|
+
def __init__(self, style, socket_manager, overlay):
|
|
10
|
+
self.tray_icon = QSystemTrayIcon()
|
|
11
|
+
|
|
12
|
+
# Load custom icon if available
|
|
13
|
+
icon_path = os.path.join(os.path.dirname(__file__), 'assets', 'icons', 'romain_guillon.jpg')
|
|
14
|
+
if os.path.exists(icon_path):
|
|
15
|
+
self.tray_icon.setIcon(QIcon(icon_path))
|
|
16
|
+
else:
|
|
17
|
+
# Fallback system icon
|
|
18
|
+
self.tray_icon.setIcon(style.standardIcon(style.StandardPixmap.SP_ComputerIcon))
|
|
19
|
+
|
|
20
|
+
self.menu = QMenu()
|
|
21
|
+
self.tray_icon.setContextMenu(self.menu)
|
|
22
|
+
|
|
23
|
+
self.socket_manager = socket_manager
|
|
24
|
+
self.overlay = overlay
|
|
25
|
+
|
|
26
|
+
self.update_menu([])
|
|
27
|
+
self.tray_icon.show()
|
|
28
|
+
|
|
29
|
+
def update_menu(self, guilds):
|
|
30
|
+
self.menu.clear()
|
|
31
|
+
|
|
32
|
+
# User status
|
|
33
|
+
status_text = f"Connecté: {config_manager.user_id}" if config_manager.user_id else "Non connecté"
|
|
34
|
+
action_status = self.menu.addAction(status_text)
|
|
35
|
+
action_status.setEnabled(True) # GNOME masque parfois les éléments désactivés
|
|
36
|
+
|
|
37
|
+
if not config_manager.user_id and self.socket_manager.bot_client_id:
|
|
38
|
+
action_login = self.menu.addAction("Se connecter à Discord")
|
|
39
|
+
action_login.triggered.connect(self.login_discord)
|
|
40
|
+
|
|
41
|
+
if config_manager.user_id:
|
|
42
|
+
action_refresh = self.menu.addAction("Recharger la liste des serveurs")
|
|
43
|
+
action_refresh.triggered.connect(self.socket_manager.get_my_guilds)
|
|
44
|
+
|
|
45
|
+
action_logout = self.menu.addAction("Se déconnecter")
|
|
46
|
+
action_logout.triggered.connect(self.logout)
|
|
47
|
+
|
|
48
|
+
self.menu.addSeparator()
|
|
49
|
+
|
|
50
|
+
action_rooms = self.menu.addAction("Rooms disponibles :")
|
|
51
|
+
action_rooms.setEnabled(True)
|
|
52
|
+
|
|
53
|
+
if not guilds:
|
|
54
|
+
label = self.menu.addAction("Aucune room disponible")
|
|
55
|
+
label.setEnabled(True)
|
|
56
|
+
else:
|
|
57
|
+
for guild in guilds:
|
|
58
|
+
guild_id = guild.get('id')
|
|
59
|
+
action = QAction(guild.get('name', 'Serveur inconnu'), self.menu)
|
|
60
|
+
action.setCheckable(True)
|
|
61
|
+
action.setChecked(guild_id in config_manager.active_rooms)
|
|
62
|
+
# On utilise lambda avec paramètre par défaut pour figer la valeur de guild_id
|
|
63
|
+
action.triggered.connect(lambda checked, gid=guild_id: self.toggle_room(gid))
|
|
64
|
+
self.menu.addAction(action)
|
|
65
|
+
|
|
66
|
+
self.menu.addSeparator()
|
|
67
|
+
|
|
68
|
+
quit_action = QAction("Quitter LiveChat Python", self.menu)
|
|
69
|
+
quit_action.triggered.connect(QApplication.instance().quit)
|
|
70
|
+
self.menu.addAction(quit_action)
|
|
71
|
+
|
|
72
|
+
def login_discord(self):
|
|
73
|
+
redirect_uri = urllib.parse.quote("http://iceboxer.hd.free.fr:8080/callback")
|
|
74
|
+
auth_url = f"https://discord.com/api/oauth2/authorize?client_id={self.socket_manager.bot_client_id}&redirect_uri={redirect_uri}&response_type=code&scope=identify%20guilds"
|
|
75
|
+
webbrowser.open(auth_url)
|
|
76
|
+
|
|
77
|
+
def logout(self):
|
|
78
|
+
config_manager.set_user_id(None)
|
|
79
|
+
config_manager.active_rooms.clear()
|
|
80
|
+
config_manager.save_config()
|
|
81
|
+
self.socket_manager.client_guilds = []
|
|
82
|
+
self.update_menu([])
|
|
83
|
+
|
|
84
|
+
def toggle_room(self, guild_id):
|
|
85
|
+
if guild_id in config_manager.active_rooms:
|
|
86
|
+
config_manager.active_rooms.remove(guild_id)
|
|
87
|
+
self.socket_manager.leave_room(guild_id)
|
|
88
|
+
else:
|
|
89
|
+
config_manager.active_rooms.add(guild_id)
|
|
90
|
+
self.socket_manager.join_room(guild_id)
|
|
91
|
+
|
|
92
|
+
config_manager.save_config()
|
|
93
|
+
# Rafraîchir le menu pour mettre à jour les cases cochées
|
|
94
|
+
self.update_menu(self.socket_manager.client_guilds)
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: livechat-desktop
|
|
3
|
+
Version: 1.0.0
|
|
4
|
+
Summary: A desktop overlay for LiveChat built with PyQt6
|
|
5
|
+
Author: Romain Guillon
|
|
6
|
+
License: MIT
|
|
7
|
+
Requires-Python: >=3.11
|
|
8
|
+
Description-Content-Type: text/markdown
|
|
9
|
+
Requires-Dist: PyQt6>=6.11.0
|
|
10
|
+
Requires-Dist: python-socketio[client]>=5.11.0
|
|
11
|
+
Requires-Dist: websocket-client>=1.8.0
|
|
12
|
+
Requires-Dist: requests>=2.32.0
|
|
13
|
+
Requires-Dist: yt-dlp>=2024.0.0
|
|
14
|
+
Requires-Dist: PyQt6-WebEngine>=6.11.0
|
|
15
|
+
|
|
16
|
+
# LiveChat Desktop
|
|
17
|
+
|
|
18
|
+
A desktop client for LiveChat, built with Python and PyQt6.
|
|
19
|
+
|
|
20
|
+
## Features
|
|
21
|
+
- Displays media from chat using an overlay.
|
|
22
|
+
- Supports generic images/videos, YouTube, TikTok, and Twitter embeds.
|
|
23
|
+
- Customizes overlay sizing automatically.
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
README.md
|
|
2
|
+
pyproject.toml
|
|
3
|
+
livechat_desktop/__main__.py
|
|
4
|
+
livechat_desktop/config.py
|
|
5
|
+
livechat_desktop/overlay.py
|
|
6
|
+
livechat_desktop/socket_manager.py
|
|
7
|
+
livechat_desktop/tray.py
|
|
8
|
+
livechat_desktop.egg-info/PKG-INFO
|
|
9
|
+
livechat_desktop.egg-info/SOURCES.txt
|
|
10
|
+
livechat_desktop.egg-info/dependency_links.txt
|
|
11
|
+
livechat_desktop.egg-info/entry_points.txt
|
|
12
|
+
livechat_desktop.egg-info/requires.txt
|
|
13
|
+
livechat_desktop.egg-info/top_level.txt
|
|
14
|
+
livechat_desktop/assets/icons/romain_guillon.jpg
|
|
15
|
+
livechat_desktop/providers/__init__.py
|
|
16
|
+
livechat_desktop/providers/base.py
|
|
17
|
+
livechat_desktop/providers/generic.py
|
|
18
|
+
livechat_desktop/providers/tiktok.py
|
|
19
|
+
livechat_desktop/providers/twitter.py
|
|
20
|
+
livechat_desktop/providers/youtube.py
|
|
21
|
+
livechat_desktop/providers/scripts/tiktok_bypass.js
|
|
22
|
+
livechat_desktop/providers/scripts/youtube_embed.html
|
|
23
|
+
tests/test_handle.py
|
|
24
|
+
tests/test_image_resize.py
|
|
25
|
+
tests/test_logic.py
|
|
26
|
+
tests/test_overlay_logic.py
|
|
27
|
+
tests/test_timer.py
|
|
28
|
+
tests/test_vxt.py
|
|
29
|
+
tests/test_webview.py
|
|
30
|
+
tests/test_webview2.py
|
|
31
|
+
tests/test_ytdlp.py
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
livechat_desktop
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["setuptools>=61.0"]
|
|
3
|
+
build-backend = "setuptools.build_meta"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "livechat-desktop"
|
|
7
|
+
version = "1.0.0"
|
|
8
|
+
description = "A desktop overlay for LiveChat built with PyQt6"
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.11"
|
|
11
|
+
license = {text = "MIT"}
|
|
12
|
+
authors = [
|
|
13
|
+
{name = "Romain Guillon"}
|
|
14
|
+
]
|
|
15
|
+
dependencies = [
|
|
16
|
+
"PyQt6>=6.11.0",
|
|
17
|
+
"python-socketio[client]>=5.11.0",
|
|
18
|
+
"websocket-client>=1.8.0",
|
|
19
|
+
"requests>=2.32.0",
|
|
20
|
+
"yt-dlp>=2024.0.0",
|
|
21
|
+
"PyQt6-WebEngine>=6.11.0"
|
|
22
|
+
]
|
|
23
|
+
|
|
24
|
+
[project.gui-scripts]
|
|
25
|
+
livechat-desktop = "livechat_desktop.__main__:main"
|
|
26
|
+
|
|
27
|
+
[tool.setuptools]
|
|
28
|
+
packages = ["livechat_desktop", "livechat_desktop.providers", "livechat_desktop.assets.icons", "livechat_desktop.providers.scripts"]
|
|
29
|
+
|
|
30
|
+
[tool.setuptools.package-data]
|
|
31
|
+
livechat_desktop = ["assets/icons/*", "providers/scripts/*"]
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
import json
|
|
2
|
+
|
|
3
|
+
def run_test():
|
|
4
|
+
data = {'url': None, 'type': 'image', 'format': 'illustration', 'duration': 2000, 'text': 'dfrge', 'lien': 'https://store.steampowered.com/app/2445980/GRebels/'}
|
|
5
|
+
|
|
6
|
+
url = data.get('url')
|
|
7
|
+
lien = data.get('lien')
|
|
8
|
+
text = data.get('text')
|
|
9
|
+
media_type = data.get('type')
|
|
10
|
+
duration_raw = data.get('duration')
|
|
11
|
+
try:
|
|
12
|
+
duration = int(duration_raw) if duration_raw is not None else None
|
|
13
|
+
except ValueError:
|
|
14
|
+
duration = None
|
|
15
|
+
|
|
16
|
+
provider_result = {}
|
|
17
|
+
if lien:
|
|
18
|
+
# simulate process_link
|
|
19
|
+
provider_result = {'type': 'web', 'embed_url': 'https://store.steampowered.com/app/2445980/GRebels/', 'web_size': (1632, 918), 'is_generic_web': True}
|
|
20
|
+
|
|
21
|
+
if "url" in provider_result: url = provider_result["url"]
|
|
22
|
+
if "type" in provider_result: media_type = provider_result["type"]
|
|
23
|
+
if "lien" in provider_result: lien = provider_result["lien"]
|
|
24
|
+
if "top_text" in provider_result: top_text = provider_result["top_text"]
|
|
25
|
+
|
|
26
|
+
is_generic_web = provider_result.get("is_generic_web", False)
|
|
27
|
+
if is_generic_web and not duration:
|
|
28
|
+
duration = 2000
|
|
29
|
+
|
|
30
|
+
if ((lien and not is_generic_web) or media_type in ('video', 'audio')) and duration == 2000:
|
|
31
|
+
duration = None
|
|
32
|
+
|
|
33
|
+
print(f"DEBUG: Processed duration is: {duration}")
|
|
34
|
+
|
|
35
|
+
run_test()
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
# Just to be completely sure about image_label size, we don't need to change it if it's already transparent
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
def test():
|
|
2
|
+
lien = "https://store.steampowered.com/"
|
|
3
|
+
media_type = "web"
|
|
4
|
+
is_generic_web = True
|
|
5
|
+
duration = 2000
|
|
6
|
+
DEFAULT_DURATION_BOT = 2000
|
|
7
|
+
|
|
8
|
+
if ((lien and not is_generic_web) or media_type in ('video', 'audio')) and duration == DEFAULT_DURATION_BOT:
|
|
9
|
+
duration = None
|
|
10
|
+
|
|
11
|
+
print("Duration:", duration)
|
|
12
|
+
|
|
13
|
+
test()
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from PyQt6.QtWidgets import QApplication
|
|
3
|
+
from livechat_desktop.overlay import Overlay
|
|
4
|
+
|
|
5
|
+
app = QApplication(sys.argv)
|
|
6
|
+
o = Overlay()
|
|
7
|
+
data = {'url': None, 'type': 'image', 'format': 'illustration', 'duration': 2000, 'text': 'dfrge', 'lien': 'https://store.steampowered.com/app/2445980/GRebels/'}
|
|
8
|
+
# We just want to see the prints up to Processed duration
|
|
9
|
+
o.hide_all = lambda: None
|
|
10
|
+
o.show = lambda: None
|
|
11
|
+
o.timer.start = lambda d: print(f"Timer started with {d}")
|
|
12
|
+
try:
|
|
13
|
+
o.handle_media(data)
|
|
14
|
+
except Exception as e:
|
|
15
|
+
print(e)
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import requests
|
|
2
|
+
import json
|
|
3
|
+
import re
|
|
4
|
+
|
|
5
|
+
url = "https://x.com/SpaceX/status/1768273641756557454"
|
|
6
|
+
# Extract ID
|
|
7
|
+
match = re.search(r'(?:twitter\.com|x\.com)\/[^\/]+\/status\/(\d+)', url)
|
|
8
|
+
if match:
|
|
9
|
+
tweet_id = match.group(1)
|
|
10
|
+
# try fxtwitter API
|
|
11
|
+
try:
|
|
12
|
+
api_url = f"https://api.fxtwitter.com/status/{tweet_id}"
|
|
13
|
+
resp = requests.get(api_url, timeout=5)
|
|
14
|
+
print("fxtwitter:", resp.text)
|
|
15
|
+
except Exception as e:
|
|
16
|
+
print("fxtwitter err:", e)
|
|
17
|
+
|
|
18
|
+
# try vxtwitter API
|
|
19
|
+
try:
|
|
20
|
+
api_url = f"https://api.vxtwitter.com/status/{tweet_id}"
|
|
21
|
+
resp = requests.get(api_url, timeout=5)
|
|
22
|
+
print("vxtwitter:", resp.text)
|
|
23
|
+
except Exception as e:
|
|
24
|
+
print("vxtwitter err:", e)
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from PyQt6.QtCore import QTimer, QUrl, Qt
|
|
3
|
+
from PyQt6.QtWidgets import QApplication, QMainWindow
|
|
4
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
5
|
+
|
|
6
|
+
class Overlay(QMainWindow):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.web_view = QWebEngineView(self)
|
|
10
|
+
self.setCentralWidget(self.web_view)
|
|
11
|
+
self.timer = QTimer(self)
|
|
12
|
+
self.timer.timeout.connect(self.hide_all)
|
|
13
|
+
self.web_view.loadFinished.connect(self.on_load)
|
|
14
|
+
|
|
15
|
+
def hide_all(self):
|
|
16
|
+
print("HIDE ALL CALLED")
|
|
17
|
+
self.timer.stop()
|
|
18
|
+
self.web_view.hide()
|
|
19
|
+
self.web_view.setUrl(QUrl("about:blank"))
|
|
20
|
+
self.hide()
|
|
21
|
+
QApplication.quit()
|
|
22
|
+
|
|
23
|
+
def show_url(self, url):
|
|
24
|
+
print("SHOW URL CALLED")
|
|
25
|
+
self.web_view.setUrl(QUrl(url))
|
|
26
|
+
self.web_view.show()
|
|
27
|
+
self.show()
|
|
28
|
+
self.timer.start(2000)
|
|
29
|
+
|
|
30
|
+
def on_load(self, ok):
|
|
31
|
+
print("LOAD FINISHED", ok)
|
|
32
|
+
|
|
33
|
+
app = QApplication(sys.argv)
|
|
34
|
+
o = Overlay()
|
|
35
|
+
o.show_url("https://wikipedia.org")
|
|
36
|
+
sys.exit(app.exec())
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import sys
|
|
2
|
+
from PyQt6.QtCore import QTimer, QUrl, Qt
|
|
3
|
+
from PyQt6.QtWidgets import QApplication, QMainWindow
|
|
4
|
+
from PyQt6.QtWebEngineWidgets import QWebEngineView
|
|
5
|
+
|
|
6
|
+
class Overlay(QMainWindow):
|
|
7
|
+
def __init__(self):
|
|
8
|
+
super().__init__()
|
|
9
|
+
self.web_view = QWebEngineView(self)
|
|
10
|
+
self.setCentralWidget(self.web_view)
|
|
11
|
+
self.timer = QTimer(self)
|
|
12
|
+
self.timer.timeout.connect(self.hide_all)
|
|
13
|
+
self.web_view.loadFinished.connect(self.on_load)
|
|
14
|
+
|
|
15
|
+
def hide_all(self):
|
|
16
|
+
print("HIDE ALL CALLED")
|
|
17
|
+
self.timer.stop()
|
|
18
|
+
self.web_view.hide()
|
|
19
|
+
self.web_view.setUrl(QUrl("about:blank"))
|
|
20
|
+
self.hide()
|
|
21
|
+
# Don't quit, let's see if it shows up again
|
|
22
|
+
QTimer.singleShot(2000, lambda: QApplication.quit())
|
|
23
|
+
|
|
24
|
+
def show_url(self, url):
|
|
25
|
+
print("SHOW URL CALLED")
|
|
26
|
+
self.web_view.setUrl(QUrl(url))
|
|
27
|
+
self.web_view.show()
|
|
28
|
+
self.show()
|
|
29
|
+
self.timer.start(2000)
|
|
30
|
+
|
|
31
|
+
def on_load(self, ok):
|
|
32
|
+
print("LOAD FINISHED", ok)
|
|
33
|
+
|
|
34
|
+
app = QApplication(sys.argv)
|
|
35
|
+
o = Overlay()
|
|
36
|
+
o.show_url("https://wikipedia.org")
|
|
37
|
+
sys.exit(app.exec())
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
import yt_dlp
|
|
2
|
+
import json
|
|
3
|
+
import sys
|
|
4
|
+
|
|
5
|
+
ydl_opts = {
|
|
6
|
+
'quiet': True,
|
|
7
|
+
'extract_flat': False,
|
|
8
|
+
'format': 'bestvideo[ext=mp4]+bestaudio[ext=m4a]/best[ext=mp4]/best', # Preferred format for QMediaPlayer
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
try:
|
|
12
|
+
with yt_dlp.YoutubeDL(ydl_opts) as ydl:
|
|
13
|
+
info = ydl.extract_info("https://www.youtube.com/watch?v=jNQXAC9IVRw", download=False)
|
|
14
|
+
# It could be in 'url' or we have to pick from 'formats'
|
|
15
|
+
video_url = info.get('url')
|
|
16
|
+
if not video_url and 'formats' in info:
|
|
17
|
+
# find best format that has both video and audio or just the best one
|
|
18
|
+
for f in reversed(info['formats']):
|
|
19
|
+
if f.get('vcodec') != 'none' and f.get('acodec') != 'none':
|
|
20
|
+
video_url = f.get('url')
|
|
21
|
+
break
|
|
22
|
+
if not video_url:
|
|
23
|
+
video_url = info['formats'][-1].get('url')
|
|
24
|
+
|
|
25
|
+
print(json.dumps({'title': info.get('title'), 'description': info.get('description'), 'url': video_url}))
|
|
26
|
+
except Exception as e:
|
|
27
|
+
print(f"Error: {e}")
|