diff --git a/mainline.py b/mainline.py index 7d34886..2dfd9eb 100755 --- a/mainline.py +++ b/mainline.py @@ -39,7 +39,7 @@ sys.path.insert(0, str(next((_VENV / "lib").glob("python*/site-packages")))) import feedparser # noqa: E402 from PIL import Image, ImageDraw, ImageFont # noqa: E402 -import random, time, re, signal, atexit, textwrap # noqa: E402 +import random, time, re, signal, atexit, textwrap, threading # noqa: E402 try: import sounddevice as _sd import numpy as _np @@ -58,6 +58,11 @@ MIC_THRESHOLD_DB = 50 # dB above which glitches intensify MODE = 'poetry' if '--poetry' in sys.argv or '-p' in sys.argv else 'news' FIREHOSE = '--firehose' in sys.argv +# ntfy message queue +NTFY_TOPIC = "https://ntfy.sh/klubhaus_terminal_mainline/json?since=20s&poll=1" +NTFY_POLL_INTERVAL = 15 # seconds between polls +MESSAGE_DISPLAY_SECS = 30 # how long a message holds the screen + # Poetry/literature sources — public domain via Project Gutenberg POETRY_SOURCES = { "Whitman": "https://www.gutenberg.org/cache/epub/1322/pg1322.txt", @@ -516,6 +521,42 @@ def _start_mic(): return False +# ─── NTFY MESSAGE QUEUE ─────────────────────────────────── +_ntfy_message = None # (title, body, monotonic_timestamp) or None +_ntfy_lock = threading.Lock() + + +def _start_ntfy_poller(): + """Start background thread polling ntfy for messages.""" + def _poll(): + global _ntfy_message + while True: + try: + req = urllib.request.Request( + NTFY_TOPIC, headers={"User-Agent": "mainline/0.1"}) + resp = urllib.request.urlopen(req, timeout=10) + for line in resp.read().decode('utf-8', errors='replace').strip().split('\n'): + if not line.strip(): + continue + try: + data = json.loads(line) + except json.JSONDecodeError: + continue + if data.get("event") == "message": + with _ntfy_lock: + _ntfy_message = ( + data.get("title", ""), + data.get("message", ""), + time.monotonic(), + ) + except Exception: + pass + time.sleep(NTFY_POLL_INTERVAL) + t = threading.Thread(target=_poll, daemon=True) + t.start() + return True + + def _render_line(text, font=None): """Render a line of text as terminal rows using OTF font + half-blocks.""" if font is None: @@ -754,6 +795,7 @@ def _firehose_line(items, w): def stream(items): + global _ntfy_message random.shuffle(items) pool = list(items) seen = set() @@ -781,12 +823,85 @@ def stream(items): noise_cache[cy] = noise(w) if random.random() < 0.15 else None return noise_cache[cy] + # Message color: bright cyan/white — distinct from headline greens + MSG_COLOR = "\033[1;38;5;87m" # sky cyan + MSG_META = "\033[38;5;245m" # cool grey + MSG_BORDER = "\033[2;38;5;37m" # dim teal + _msg_cache = (None, None) # (cache_key, rendered_rows) + while queued < HEADLINE_LIMIT or active: t0 = time.monotonic() w, h = tw(), th() fh = FIREHOSE_H if FIREHOSE else 0 sh = h - fh + # ── Check for ntfy message ──────────────────────── + msg_active = False + with _ntfy_lock: + if _ntfy_message is not None: + m_title, m_body, m_ts = _ntfy_message + if time.monotonic() - m_ts < MESSAGE_DISPLAY_SECS: + msg_active = True + else: + _ntfy_message = None # expired + + if msg_active: + # ── MESSAGE state: freeze scroll, render message ── + buf = [] + # Render message text with OTF font (cached across frames) + display_text = m_body or m_title or "(empty)" + display_text = re.sub(r"\s+", " ", display_text.upper()) + cache_key = (display_text, w) + if _msg_cache[0] != cache_key: + msg_rows = _big_wrap(display_text, w - 4) + msg_rows = _lr_gradient(msg_rows) + _msg_cache = (cache_key, msg_rows) + else: + msg_rows = _msg_cache[1] + # Center vertically in scroll zone + total_h = len(msg_rows) + 4 # +4 for border + meta + padding + y_off = max(0, (sh - total_h) // 2) + for r in range(sh): + ri = r - y_off + if ri == 0 or ri == total_h - 1: + # Border lines + bar = "─" * (w - 4) + buf.append(f"\033[{r+1};1H {MSG_BORDER}{bar}{RST}\033[K") + elif 1 <= ri <= len(msg_rows): + ln = _vis_trunc(msg_rows[ri - 1], w) + buf.append(f"\033[{r+1};1H {ln}{RST}\033[K") + elif ri == len(msg_rows) + 1: + # Title line (if present and different from body) + if m_title and m_title != m_body: + meta = f" {MSG_META}\u2591 {m_title}{RST}" + else: + meta = "" + buf.append(f"\033[{r+1};1H{meta}\033[K") + elif ri == len(msg_rows) + 2: + # Source + timestamp + elapsed_s = int(time.monotonic() - m_ts) + remaining = max(0, MESSAGE_DISPLAY_SECS - elapsed_s) + ts_str = datetime.now().strftime("%H:%M:%S") + meta = f" {MSG_META}\u2591 ntfy \u00b7 {ts_str} \u00b7 {remaining}s{RST}" + buf.append(f"\033[{r+1};1H{meta}\033[K") + else: + # Sparse noise outside the message + if random.random() < 0.06: + buf.append(f"\033[{r+1};1H{noise(w)}") + else: + buf.append(f"\033[{r+1};1H\033[K") + # Firehose keeps running during messages + if FIREHOSE and fh > 0: + for fr in range(fh): + fline = _firehose_line(items, w) + buf.append(f"\033[{sh + fr + 1};1H{fline}\033[K") + sys.stdout.buffer.write("".join(buf).encode()) + sys.stdout.flush() + elapsed = time.monotonic() - t0 + time.sleep(max(0, _FRAME_DT - elapsed)) + continue + + # ── SCROLL state: normal headline rendering ─────── # Advance scroll on schedule scroll_accum += _FRAME_DT while scroll_accum >= scroll_interval: @@ -937,6 +1052,8 @@ def main(): mic_ok = _start_mic() if _HAS_MIC: boot_ln("Microphone", "ACTIVE" if mic_ok else "OFFLINE · check System Settings → Privacy → Microphone", mic_ok) + ntfy_ok = _start_ntfy_poller() + boot_ln("ntfy", "LISTENING" if ntfy_ok else "OFFLINE", ntfy_ok) if FIREHOSE: boot_ln("Firehose", "ENGAGED", True)