refactor(src/window/panel/notch): improve visualizer implementation and add static tunables

Replace random-based bar generation with deterministic sine animation, rename internal state and constants, split visualizer logic into explicit methods, update drawing to use style colors, consolidate signal and timer handling, and streamline idle/reset behavior.
This commit is contained in:
sankalp committed 2026-07-05 20:56:52 +05:45
1 parent 5240abd23d
commit 86b02989e4
1 file changed
+115 -50
+115 -50
View File
@@ -1,42 +1,93 @@
import random
import math
import os
import time
from fabric.utils import Gtk, GLib, os
from fabric.utils import GLib, Gtk
from fabric.widgets.box import Box
from fabric.widgets.centerbox import CenterBox
from services.mpris import PlayerService
from utils.utils import svg_file
# tunables
_BARS = 10 # number of bars
_TICK_MS = 150 # ~7 fps — smooth with sine, very cheap
_H_MIN = 3 # minimum total bar height (px, both halves)
_H_MAX = 22 # maximum total bar height (px)
_BAR_W = 1 # bar pixel width
_SPACING = 4 # gap between bars
# Per-bar sine speeds (rad/s) and phases — makes motion look organic
_SPEEDS = [2.1, 3.3, 1.8, 2.9, 3.7, 1.5, 2.6, 3.1, 1.9, 2.4]
_PHASES = [0.0, 1.2, 2.4, 0.8, 1.9, 3.1, 0.5, 2.0, 1.4, 0.3]
_IDLE_HEIGHTS = [_H_MIN] * _BARS
class Visualizer(Box):
"""
Center-anchored bar visualizer with a magenta glow.
Bars grow symmetrically up AND down from the centre line,
"""
def __init__(self, **kwargs):
super().__init__(**kwargs)
self._heights = [4, 4, 4]
self._t0 = time.monotonic()
self._heights = list(_IDLE_HEIGHTS)
self.set_valign(Gtk.Align.CENTER)
self.set_vexpand(False)
self.set_size_request(-1, 20)
self.set_style("margin-right: 8px; margin-bottom: 2px; padding: 0 4px;")
# Fixed widget size so all bars always fit
total_w = _BARS * _BAR_W + (_BARS - 1) * _SPACING
self.set_size_request(total_w, _H_MAX + 4) # +4 gives glow room
self.set_style("margin-right: 8px; margin-bottom: 2px;")
self.connect("draw", self._on_draw)
def set_heights(self, heights):
self._heights = heights
self.queue_draw()
def animate(self) -> None:
t = time.monotonic() - self._t0
new = [
int(
_H_MIN
+ (math.sin(t * _SPEEDS[i] + _PHASES[i]) + 1) * 0.5 * (_H_MAX - _H_MIN)
)
for i in range(_BARS)
]
if new != self._heights:
self._heights = new
self.queue_draw()
def _on_draw(self, widget, cr):
def reset(self) -> None:
if self._heights != _IDLE_HEIGHTS:
self._heights = list(_IDLE_HEIGHTS)
self.queue_draw()
def _on_draw(self, widget, cr) -> None:
w = self.get_allocated_width()
h = self.get_allocated_height()
n = len(self._heights)
if n == 0 or w < 3 or h < 4:
if w < 3 or h < 4:
return
spacing = 3
bar_w = max(3, (w - spacing * (n - 1)) // n)
cr.set_source_rgb(1, 1, 1)
ctx = self.get_style_context()
found, color = ctx.lookup_color("primary")
if found:
r, g, b = color.red, color.green, color.blue
else:
r, g, b = 1.0, 1.0, 1.0 # fallback: white
cy = h / 2.0 # vertical centre line
for i, bh in enumerate(self._heights):
x = i * (bar_w + spacing)
y = h - bh
cr.rectangle(x, y, bar_w, bh)
x = i * (_BAR_W + _SPACING)
half = bh / 2.0
top = cy - half
cr.set_source_rgba(r, g, b, 0.22)
cr.rectangle(x - 2, top - 2, _BAR_W + 4, bh + 4)
cr.fill()
cr.set_source_rgb(r, g, b)
cr.rectangle(x, top, _BAR_W, bh)
cr.fill()
@@ -45,7 +96,8 @@ class NotchPlayer(Box):
super().__init__(**kwargs)
self._service: PlayerService | None = None
self._vis_timeout = None
self._timer: int | None = None
self._sigs: list[int] = []
self.fallback_icon = svg_file("music.svg", size=18)
@@ -62,71 +114,84 @@ class NotchPlayer(Box):
start_children=self.album_art,
end_children=self.visualizer,
)
self.add(self.player_box)
def set_service(self, service: PlayerService | None):
def set_service(self, service: PlayerService | None) -> None:
if self._service is service:
return
self._disconnect_signals()
self._service = service
self._stop_visualizer()
self._stop_timer()
self._show_fallback()
if service is not None:
self._update_from_service()
self._start_visualizer()
def _start_visualizer(self):
if self._vis_timeout is not None:
if service is None:
return
self._vis_timeout = GLib.timeout_add(200, self._animate_bars)
def _stop_visualizer(self):
if self._vis_timeout is not None:
GLib.source_remove(self._vis_timeout)
self._vis_timeout = None
self._update_from_service()
self._sigs.append(service.connect("play", self._on_play))
self._sigs.append(service.connect("pause", self._on_pause))
def _animate_bars(self):
if service.playback_status == "Playing":
self._start_timer()
def _disconnect_signals(self) -> None:
if self._service is not None:
for sid in self._sigs:
try:
self._service.disconnect(sid)
except Exception:
pass
self._sigs.clear()
def _on_play(self, *_) -> None:
self._start_timer()
def _on_pause(self, *_) -> None:
self._stop_timer()
def _start_timer(self) -> None:
if self._timer is not None:
return
self._timer = GLib.timeout_add(_TICK_MS, self._tick)
def _stop_timer(self) -> None:
if self._timer is not None:
GLib.source_remove(self._timer)
self._timer = None
self.visualizer.reset()
def _tick(self) -> bool:
if self._service is None:
self._stop_visualizer()
return False
playing = self._service.playback_status
if playing != "Playing":
self.visualizer.set_heights([4, 4, 4])
return True
heights = [random.randint(4, 20) for _ in range(3)]
self.visualizer.set_heights(heights)
self.visualizer.animate()
return True
def _show_fallback(self):
def _show_fallback(self) -> None:
self.album_art.set_size_request(-1, -1)
self.album_art.set_style("")
self.fallback_icon.set_visible(True)
def _show_artwork(self, path: str):
def _show_artwork(self, path: str) -> None:
self.fallback_icon.set_visible(False)
self.album_art.set_size_request(18, 18)
self.album_art.set_style(f"background-image:url('{path}')")
def _update_from_service(self):
def _update_from_service(self) -> None:
if self._service is None:
return
cached = self._service.get_artwork()
if cached and os.path.exists(cached):
self._show_artwork(cached)
else:
self._show_fallback()
def _on_artwork_change(self, service, local_path: str):
def _on_artwork_change(self, service, local_path: str) -> None:
if os.path.exists(local_path):
self._show_artwork(local_path)
def destroy(self):
self._stop_visualizer()
def destroy(self) -> None:
self._disconnect_signals()
self._stop_timer()
self._service = None
super().destroy()