Skip to content

Commit c11efb2

Browse files
committed
feat(core): Refonte Architecture, QMediaPlayer (multithread) & AioPresence
Séparation de la logique de l'UI (ui.py, core). Remplacement de pygame par QtMultimedia. Isolation de Discord RPC (AioPresence) sans threads. Sauvegarde playlist locale (Appdata). Ajout Workflow Actions CI/CD Windows/Linux/Mac
1 parent edd227e commit c11efb2

17 files changed

+1051
-1163
lines changed

.env

Lines changed: 0 additions & 2 deletions
This file was deleted.

.github/workflows/build.yml

Lines changed: 83 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,83 @@
1+
name: Build Music Player
2+
3+
on:
4+
push:
5+
branches: [ "main", "master" ]
6+
pull_request:
7+
branches: [ "main", "master" ]
8+
9+
jobs:
10+
build:
11+
runs-on: ${{ matrix.os }}
12+
strategy:
13+
matrix:
14+
os: [ubuntu-latest, macos-latest, windows-latest]
15+
python-version: ["3.11"]
16+
17+
steps:
18+
- uses: actions/checkout@v4
19+
20+
- name: Set up Python ${{ matrix.python-version }}
21+
uses: actions/setup-python@v5
22+
with:
23+
python-version: ${{ matrix.python-version }}
24+
25+
# Installation des dépendances selon l'OS
26+
- name: Install dependencies (Windows)
27+
if: matrix.os == 'windows-latest'
28+
run: |
29+
python -m pip install --upgrade pip
30+
pip install -r requirements.txt
31+
pip install pyinstaller
32+
33+
- name: Install dependencies (macOS)
34+
if: matrix.os == 'macos-latest'
35+
run: |
36+
python -m pip install --upgrade pip
37+
pip install -r requirements-mac.txt
38+
pip install pyinstaller
39+
40+
- name: Install dependencies (Linux/Ubuntu)
41+
if: matrix.os == 'ubuntu-latest'
42+
run: |
43+
# Les dépendances systèmes PyQt5/Audio
44+
sudo apt-get update
45+
sudo apt-get install -y python3-pyqt5 python3-pyqt5.qtwebengine libsdl2-mixer-2.0-0 libsdl2-image-2.0-0 libsdl2-2.0-0
46+
python -m pip install --upgrade pip
47+
# Installe les requirements globaux + linux (qui rajoute numba, scipy, etc.)
48+
pip install -r requirements.txt
49+
pip install -r requirements-Linux.txt
50+
pip install pyinstaller
51+
52+
# Création de l'exécutable
53+
- name: Build executable (Windows/Linux)
54+
if: matrix.os == 'windows-latest' || matrix.os == 'ubuntu-latest'
55+
run: pyinstaller main.spec --clean
56+
57+
- name: Build executable (macOS)
58+
if: matrix.os == 'macos-latest'
59+
run: pyinstaller main-macos.spec --clean
60+
61+
# Upload des Artifacts générés par PyInstaller
62+
- name: Upload Windows executable
63+
if: matrix.os == 'windows-latest'
64+
uses: actions/upload-artifact@v4
65+
with:
66+
name: Musique-Windows
67+
path: |
68+
dist/*.exe
69+
dist/*/
70+
71+
- name: Upload macOS App Bundle
72+
if: matrix.os == 'macos-latest'
73+
uses: actions/upload-artifact@v4
74+
with:
75+
name: Musique-macOS
76+
path: dist/BIT_SCRIPTS_-_Musique.app/
77+
78+
- name: Upload Linux executable
79+
if: matrix.os == 'ubuntu-latest'
80+
uses: actions/upload-artifact@v4
81+
with:
82+
name: Musique-Linux
83+
path: dist/*/

.gitignore

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -2,4 +2,6 @@ build/
22
dist/
33
venv-lecteur-multimedia/
44
requirements-Test.txt
5-
main-test.py
5+
main-test.pyenv/
6+
env/
7+
.env
7.39 KB
Binary file not shown.
5.42 KB
Binary file not shown.
8.48 KB
Binary file not shown.
2.1 KB
Binary file not shown.

core/audio_engine.py

Lines changed: 117 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,117 @@
1+
import os
2+
import numpy as np
3+
from pydub import AudioSegment
4+
from PyQt5.QtCore import QObject, QTimer, pyqtSignal, QUrl, QThread
5+
from PyQt5.QtMultimedia import QMediaPlayer, QMediaContent
6+
7+
class WaveformWorker(QThread):
8+
"""
9+
QThread asynchrone pour la génération du visuel spectral audio (Waveform)
10+
qui empêche le blocage de l'interface principale durant le chargement.
11+
"""
12+
waveformReady = pyqtSignal(np.ndarray)
13+
14+
def __init__(self, audio_file):
15+
super().__init__()
16+
self.audio_file = audio_file
17+
self.is_running = True
18+
19+
def run(self):
20+
try:
21+
# Traitement parfois long selon la taille du fichier
22+
ext = os.path.splitext(self.audio_file)[-1].lower()
23+
format_map = {'.flac': 'flac', '.mp3': 'mp3', '.ogg': 'ogg', '.wav': 'wav'}
24+
audio_format = format_map.get(ext, 'mp3')
25+
26+
# TODO : Utiliser un cache pour éviter de recalculer "samples" chaque fois
27+
# pour la même chanson.
28+
if self.is_running:
29+
audio = AudioSegment.from_file(self.audio_file, format=audio_format)
30+
samples = np.array(audio.get_array_of_samples())[::1000]
31+
self.waveformReady.emit(samples)
32+
except Exception as e:
33+
print(f"Erreur WaveForm pour {self.audio_file} : {e}")
34+
finally:
35+
audio = None
36+
37+
def stop(self):
38+
self.is_running = False
39+
self.wait()
40+
41+
42+
class AudioEngine(QObject):
43+
"""
44+
Moteur audio basé sur PyQt5.QtMultimedia (QMediaPlayer).
45+
Remplace pygame.mixer pour une meilleure gestion native asynchrone
46+
et des capacités de lecture directes plus permissives pour PyQt5.
47+
"""
48+
positionChanged = pyqtSignal(int)
49+
durationChanged = pyqtSignal(int)
50+
trackFinished = pyqtSignal()
51+
52+
def __init__(self):
53+
super().__init__()
54+
self.player = QMediaPlayer()
55+
56+
self.is_playing = False
57+
self._current_position = 0
58+
self._total_duration = 0
59+
self.current_filepath = ""
60+
61+
# Signaux du lecteur vers l'interface
62+
self.player.positionChanged.connect(self._on_position_changed)
63+
self.player.durationChanged.connect(self._on_duration_changed)
64+
self.player.stateChanged.connect(self._on_state_changed)
65+
66+
def load_track(self, filepath):
67+
"""Charge une piste dans le lecteur."""
68+
self.current_filepath = filepath
69+
self.is_playing = False # Prévient l'émission intempestive de 'trackFinished' en cas de skip manuel
70+
url = QUrl.fromLocalFile(filepath)
71+
self.player.setMedia(QMediaContent(url))
72+
self.player.stop()
73+
74+
def play(self, start_pos_ms=0):
75+
if self.current_filepath:
76+
if start_pos_ms > 0:
77+
self.player.setPosition(start_pos_ms)
78+
self.player.play()
79+
self.is_playing = True
80+
81+
def pause(self):
82+
self.is_playing = False
83+
self.player.pause()
84+
85+
def stop(self):
86+
self.is_playing = False
87+
self.player.stop()
88+
89+
def set_position(self, pos_ms):
90+
self.player.setPosition(pos_ms)
91+
92+
def set_volume(self, volume):
93+
"""Volume entre 0 et 100."""
94+
self.player.setVolume(volume)
95+
96+
@property
97+
def current_position(self):
98+
return self._current_position
99+
100+
@property
101+
def total_duration(self):
102+
return self._total_duration
103+
104+
# --- Écouteurs Internes ---
105+
def _on_position_changed(self, pos):
106+
self._current_position = pos
107+
self.positionChanged.emit(pos)
108+
109+
def _on_duration_changed(self, duration):
110+
self._total_duration = duration
111+
self.durationChanged.emit(duration)
112+
113+
def _on_state_changed(self, state):
114+
if state == QMediaPlayer.StoppedState and self.is_playing:
115+
# Si le lecteur s'arrête de lui-même (non pressé par l'utilisateur), fin de piste.
116+
self.is_playing = False
117+
self.trackFinished.emit()

core/discord_rpc.py

Lines changed: 100 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,100 @@
1+
import os
2+
import asyncio
3+
import pyimgur
4+
from pypresence import AioPresence
5+
6+
class DiscordRPCManager:
7+
"""Gestionnaire asynchrone pour la Rich Presence Discord et l'upload Imgur."""
8+
9+
def __init__(self, client_id_discord, client_id_imgur):
10+
self.client_id_discord = client_id_discord
11+
self.client_id_imgur = client_id_imgur
12+
self.rpc = None
13+
self.uploaded_images_cache = {}
14+
15+
async def connect(self):
16+
"""Initialise la connexion à Discord RPC."""
17+
if not self.client_id_discord:
18+
print("Discord RPC désactivé : Aucun Client ID fourni.")
19+
return
20+
21+
try:
22+
self.rpc = AioPresence(self.client_id_discord)
23+
await self.rpc.connect()
24+
print("Connecté à Discord RPC via AioPresence.")
25+
except Exception as e:
26+
print(f"Erreur d'initialisation Discord RPC (Discord fermé ?) : {e}")
27+
self.rpc = None
28+
29+
async def update_rpc(self, title, artist, image_url):
30+
"""Met à jour la présence Discord de manière asynchrone."""
31+
if self.rpc is None:
32+
return
33+
34+
try:
35+
if not title and not artist:
36+
await self.rpc.clear()
37+
return
38+
39+
await self.rpc.update(
40+
details=title or "Inconnu",
41+
state=artist or "Inconnu",
42+
large_image=image_url or "music_bot",
43+
large_text="Entrain d'écouter"
44+
)
45+
print("Présence Discord mise à jour.")
46+
except Exception as e:
47+
print(f"Erreur de mise à jour RPC : {e}")
48+
49+
async def upload_image_and_update_rpc(self, image_path, title, artist):
50+
"""Gère intelligemment l'upload Imgur et met à jour Discord."""
51+
if self.rpc is None:
52+
return
53+
54+
if not title and not artist:
55+
await self.update_rpc("", "", "")
56+
return
57+
58+
image_url = "music_bot"
59+
60+
# On n'upload que si une image est réellement fournie
61+
if image_path and os.path.exists(image_path):
62+
safe_image_path = image_path.replace("file://", "")
63+
64+
if safe_image_path in self.uploaded_images_cache:
65+
image_url = self.uploaded_images_cache[safe_image_path]
66+
elif self.client_id_imgur:
67+
try:
68+
def upload():
69+
im = pyimgur.Imgur(self.client_id_imgur)
70+
return im.upload_image(safe_image_path, title="Cover").link
71+
72+
url = await asyncio.to_thread(upload)
73+
self.uploaded_images_cache[safe_image_path] = url
74+
image_url = url
75+
print(f"Upload Imgur réussi : {image_url}")
76+
except Exception as e:
77+
print(f"Échec de l'upload Imgur : {e}")
78+
79+
await self.update_rpc(title, artist, image_url)
80+
81+
async def close_async(self):
82+
"""Purge asynchrone de la présence sur les serveurs Discord avant la coupure locale."""
83+
if self.rpc:
84+
try:
85+
await self.rpc.clear()
86+
except Exception:
87+
pass
88+
try:
89+
self.rpc.close()
90+
except Exception:
91+
pass
92+
93+
def close(self):
94+
"""Sécurité."""
95+
if self.rpc:
96+
try:
97+
self.rpc.close()
98+
except:
99+
pass
100+

0 commit comments

Comments
 (0)