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.
Files changed (33) hide show
  1. livechat_desktop-1.0.0/PKG-INFO +23 -0
  2. livechat_desktop-1.0.0/README.md +8 -0
  3. livechat_desktop-1.0.0/livechat_desktop/__main__.py +36 -0
  4. livechat_desktop-1.0.0/livechat_desktop/assets/icons/romain_guillon.jpg +0 -0
  5. livechat_desktop-1.0.0/livechat_desktop/config.py +41 -0
  6. livechat_desktop-1.0.0/livechat_desktop/overlay.py +333 -0
  7. livechat_desktop-1.0.0/livechat_desktop/providers/__init__.py +27 -0
  8. livechat_desktop-1.0.0/livechat_desktop/providers/base.py +17 -0
  9. livechat_desktop-1.0.0/livechat_desktop/providers/generic.py +19 -0
  10. livechat_desktop-1.0.0/livechat_desktop/providers/scripts/tiktok_bypass.js +27 -0
  11. livechat_desktop-1.0.0/livechat_desktop/providers/scripts/youtube_embed.html +30 -0
  12. livechat_desktop-1.0.0/livechat_desktop/providers/tiktok.py +41 -0
  13. livechat_desktop-1.0.0/livechat_desktop/providers/twitter.py +45 -0
  14. livechat_desktop-1.0.0/livechat_desktop/providers/youtube.py +40 -0
  15. livechat_desktop-1.0.0/livechat_desktop/socket_manager.py +77 -0
  16. livechat_desktop-1.0.0/livechat_desktop/tray.py +94 -0
  17. livechat_desktop-1.0.0/livechat_desktop.egg-info/PKG-INFO +23 -0
  18. livechat_desktop-1.0.0/livechat_desktop.egg-info/SOURCES.txt +31 -0
  19. livechat_desktop-1.0.0/livechat_desktop.egg-info/dependency_links.txt +1 -0
  20. livechat_desktop-1.0.0/livechat_desktop.egg-info/entry_points.txt +2 -0
  21. livechat_desktop-1.0.0/livechat_desktop.egg-info/requires.txt +6 -0
  22. livechat_desktop-1.0.0/livechat_desktop.egg-info/top_level.txt +1 -0
  23. livechat_desktop-1.0.0/pyproject.toml +31 -0
  24. livechat_desktop-1.0.0/setup.cfg +4 -0
  25. livechat_desktop-1.0.0/tests/test_handle.py +35 -0
  26. livechat_desktop-1.0.0/tests/test_image_resize.py +1 -0
  27. livechat_desktop-1.0.0/tests/test_logic.py +13 -0
  28. livechat_desktop-1.0.0/tests/test_overlay_logic.py +15 -0
  29. livechat_desktop-1.0.0/tests/test_timer.py +9 -0
  30. livechat_desktop-1.0.0/tests/test_vxt.py +24 -0
  31. livechat_desktop-1.0.0/tests/test_webview.py +36 -0
  32. livechat_desktop-1.0.0/tests/test_webview2.py +37 -0
  33. 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,8 @@
1
+ # LiveChat Desktop
2
+
3
+ A desktop client for LiveChat, built with Python and PyQt6.
4
+
5
+ ## Features
6
+ - Displays media from chat using an overlay.
7
+ - Supports generic images/videos, YouTube, TikTok, and Twitter embeds.
8
+ - 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()
@@ -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,2 @@
1
+ [gui_scripts]
2
+ livechat-desktop = livechat_desktop.__main__:main
@@ -0,0 +1,6 @@
1
+ PyQt6>=6.11.0
2
+ python-socketio[client]>=5.11.0
3
+ websocket-client>=1.8.0
4
+ requests>=2.32.0
5
+ yt-dlp>=2024.0.0
6
+ PyQt6-WebEngine>=6.11.0
@@ -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,4 @@
1
+ [egg_info]
2
+ tag_build =
3
+ tag_date = 0
4
+
@@ -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,9 @@
1
+ from PyQt6.QtCore import QTimer, QCoreApplication
2
+ import sys
3
+ app = QCoreApplication(sys.argv)
4
+ t = QTimer()
5
+ try:
6
+ t.start("2000")
7
+ print("Success")
8
+ except Exception as e:
9
+ print("Error:", repr(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}")