Reconstruction propre de l'ancien projet Dictée. Bundle id neuf (cloud.mrtechlab.zonza), chemin de build unique vers /Applications (plus jamais de builds /tmp enregistrés dans LaunchServices). Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
166 lines
6.0 KiB
Python
166 lines
6.0 KiB
Python
"""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)
|