"""Fenêtre flottante translucide affichant un cercle qui pulse avec le niveau audio. Cocoa natif (pyobjc). Tout le dessin se fait sur le thread principal via un NSTimer. Le niveau audio est poussé depuis un thread de fond par set_level() (écriture atomique d'un float — sûr en CPython). """ import threading from objc import super from AppKit import ( NSBackingStoreBuffered, NSBezierPath, NSColor, NSScreen, NSTimer, NSView, NSWindow, NSWindowStyleMaskBorderless, ) from Foundation import NSMakeRect, NSObject from core import CONFIG _WINDOW_SIZE = 200.0 # côté de la fenêtre carrée, en points (contient cercle + anneaux) _BOTTOM_MARGIN = 80.0 # marge au-dessus du bas de l'écran (passe au-dessus du Dock) def _oval(cx, cy, radius): return NSBezierPath.bezierPathWithOvalInRect_( NSMakeRect(cx - radius, cy - radius, 2 * radius, 2 * radius) ) class PulseView(NSView): """Vue centrée : cercle plein qui pulse + anneaux d'onde qui se propagent vers l'extérieur. Le rayon du cercle suit le niveau audio ; les anneaux avancent d'une phase à chaque frame et leur intensité dépend du niveau (plus on parle fort, plus les ondes sont visibles).""" def initWithFrame_(self, frame): self = super().initWithFrame_(frame) if self is None: return None self._level = 0.0 self._phase = 0.0 return self def setLevel_(self, level): self._level = level def drawRect_(self, rect): # fond transparent NSColor.clearColor().set() NSBezierPath.fillRect_(rect) level = self._level base = CONFIG["circle_base_radius"] amp = CONFIG["circle_amplitude"] radius = base + level * amp cx = rect.size.width / 2.0 cy = rect.size.height / 2.0 r, g, b = CONFIG["circle_color_rgb"] # anneaux d'onde : chacun part du cercle et grandit vers l'extérieur selon la # phase ; l'opacité décroît avec la distance → effet d'ondes qui se propagent. # la portée est bornée par l'espace dispo dans la fenêtre (rayon max + ondes ≤ moitié # du côté) pour que les anneaux ne soient jamais coupés, quelle que soit la taille. n_rings = CONFIG["wave_rings"] half = min(rect.size.width, rect.size.height) / 2.0 max_radius = base + amp # rayon du cercle au volume maximal max_reach = max(0.0, half - max_radius - 4.0) # marge de 4px contre le bord for i in range(n_rings): # fraction de progression de cet anneau (décalé pour étaler les ondes) frac = (self._phase + i / float(n_rings)) % 1.0 ring_radius = radius + frac * max_reach # opacité : forte près du cercle, nulle au bout, modulée par le volume alpha = (1.0 - frac) * 0.5 * level if alpha <= 0.01: continue ring = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, alpha) ring.set() path = _oval(cx, cy, ring_radius) path.setLineWidth_(3.0) path.stroke() # halo doux autour du cercle principal halo = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.20) halo.set() _oval(cx, cy, radius + 14).fill() # cercle principal plein main = NSColor.colorWithCalibratedRed_green_blue_alpha_(r, g, b, 0.9) main.set() _oval(cx, cy, radius).fill() # fait avancer la phase des ondes pour la frame suivante self._phase = (self._phase + CONFIG["wave_speed"]) % 1.0 class PulseWindow(NSObject): """Gère la fenêtre flottante : show(), hide(), set_level(). Thread-safe pour le niveau.""" def init(self): self = super().init() if self is None: return None self._level = 0.0 self._lock = threading.Lock() self._window = None self._view = None self._timer = None return self def _ensure_window(self): if self._window is not None: return screen = NSScreen.mainScreen().frame() # bas-centre : centré horizontalement, posé en bas avec une marge (Cocoa : y=0 en bas) x = (screen.size.width - _WINDOW_SIZE) / 2.0 y = _BOTTOM_MARGIN frame = NSMakeRect(x, y, _WINDOW_SIZE, _WINDOW_SIZE) win = NSWindow.alloc().initWithContentRect_styleMask_backing_defer_( frame, NSWindowStyleMaskBorderless, NSBackingStoreBuffered, False ) win.setOpaque_(False) win.setBackgroundColor_(NSColor.clearColor()) win.setLevel_(25) # au-dessus des fenêtres normales (NSStatusWindowLevel ≈ 25) win.setIgnoresMouseEvents_(True) win.setHasShadow_(False) view = PulseView.alloc().initWithFrame_(NSMakeRect(0, 0, _WINDOW_SIZE, _WINDOW_SIZE)) win.setContentView_(view) self._window = win self._view = view def set_level(self, level): """Pousse le dernier niveau audio (appelable depuis un thread de fond).""" with self._lock: self._level = level def _tick_(self, timer): with self._lock: level = self._level if self._view is not None: self._view.setLevel_(level) self._view.setNeedsDisplay_(True) def show(self): """Affiche la fenêtre et démarre l'animation. À appeler sur le thread principal.""" self._ensure_window() with self._lock: self._level = 0.0 self._window.orderFrontRegardless() interval = 1.0 / float(CONFIG["fps"]) self._timer = NSTimer.scheduledTimerWithTimeInterval_target_selector_userInfo_repeats_( interval, self, "_tick:", None, True ) def hide(self): """Arrête l'animation et masque la fenêtre. À appeler sur le thread principal.""" if self._timer is not None: self._timer.invalidate() self._timer = None if self._window is not None: self._window.orderOut_(None)