Files
Goddess/notifier.py
TutorialsGHG 09dd169ebe Initial commit
2026-04-12 22:00:18 +02:00

1210 lines
45 KiB
Python
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# NotifyPulse - Background notification app
# Config folder: AppData/Roaming/NotifyPulse/
# Web UI: http://localhost:5000
import sys
import os
import ctypes
import ctypes.wintypes
import threading
import time
import random
import re
import json
import webbrowser
import tkinter as tk
from datetime import datetime
from pathlib import Path
from collections import deque
# ── Third-party imports ──────────────────────────────────────────────────────
try:
import keyboard
from winotify import Notification, audio
import pystray
from PIL import Image, ImageDraw, ImageTk
import schedule
from flask import Flask, jsonify, request, Response
except ImportError as e:
root = tk.Tk()
root.withdraw()
from tkinter import messagebox
messagebox.showerror(
"Missing dependency",
f"Install requirements first:\n\npip install -r requirements.txt\n\nError: {e}",
)
sys.exit(1)
HOTKEY = "F13"
WEB_PORT = 5000
DEFAULT_APP_NAME = "NotifyPulse"
WALLPAPER_MAGIC = "change.wallpaper"
OVERLAY_MAGIC = "show.overlay" # also matches show.overlay.N
def parse_overlay_duration(text: str) -> int:
"""Return overlay duration in ms from text like 'show.overlay' or 'show.overlay.10'."""
parts = text.strip().lower().split(".")
# parts: ['show', 'overlay'] or ['show', 'overlay', 'N']
if len(parts) >= 3:
try:
return max(1, int(parts[2])) * 1000
except ValueError:
pass
return 6000 # default 6 s
def is_overlay_entry(text: str) -> bool:
t = text.strip().lower()
return t == OVERLAY_MAGIC or t.startswith(OVERLAY_MAGIC + ".")
# ── Global state ──────────────────────────────────────────────────────────────
paused: bool = False
notification_queue = []
state_lock = threading.Lock()
tray_icon = None
APP_NAME: str = DEFAULT_APP_NAME
APP_ID: str = "NotifyPulse"
APPDATA_DIR: Path = None
NOTIFICATIONS_FILE: Path = None
ICON_FILE: Path = None
# Active overlays: list of {"window": tk.Toplevel, "remaining_ms": int, "after_id": str}
_active_overlays: list = []
_overlays_lock = threading.Lock()
WALLPAPERS_DIR: Path = None
OVERLAY_DIR: Path = None
_default_wallpaper: str = ""
_last_wallpaper: str = ""
_overlay_stretch: bool = False # True = stretch to fill, False = fit + letterbox
MIN_INTERVAL_SEC: int = 10 * 60
MAX_INTERVAL_SEC: int = 30 * 60
_entries = []
_entries_lock = threading.Lock()
_file_mtime = 0.0
# Countdown tracking
_next_fire_at: float = 0.0 # unix timestamp of next notification
# Log (last 100 events)
_log: deque = deque(maxlen=100)
_log_lock = threading.Lock()
def log(msg: str):
ts = datetime.now().strftime("%H:%M:%S")
with _log_lock:
_log.appendleft({"time": ts, "msg": msg})
# ── AppData setup ─────────────────────────────────────────────────────────────
def resolve_appdata_dir() -> Path:
appdata_env = os.environ.get("APPDATA", "").strip()
base = Path(appdata_env) if appdata_env else Path.home() / "AppData" / "Roaming"
folder = base / "NotifyPulse"
try:
folder.mkdir(parents=True, exist_ok=True)
probe = folder / ".probe"
probe.write_text("ok", encoding="utf-8")
probe.unlink()
return folder
except Exception as exc:
root = tk.Tk()
root.withdraw()
from tkinter import messagebox
messagebox.showerror("NotifyPulse - Startup Error",
f"Cannot create config folder:\n\n{folder}\n\nError: {exc}")
sys.exit(1)
# ── Config file ───────────────────────────────────────────────────────────────
class Entry:
def __init__(self, text, weight, trigger_time):
self.text = text
self.weight = weight
self.trigger_time = trigger_time
def create_default_config():
NOTIFICATIONS_FILE.write_text(
"# NotifyPulse - notifications.txt\n"
"# Changes are picked up automatically - no restart needed!\n"
"#\n"
"# @name Your App Name <- name shown in tray + notifications\n"
"# @interval 10 30 <- min and max minutes between notifications\n"
"#\n"
"# Text | 30% <- random, picked 30% of the time\n"
"# Text | 14:30 <- fires every day at 14:30\n"
"# change.wallpaper | 20% <- picks a random wallpaper\n"
"# show.overlay | 10% <- shows random image from Overlay folder (6s default)\n"
"# show.overlay.10 | 10% <- shows overlay for 10 seconds\n"
"\n"
"@name NotifyPulse\n"
"@interval 10 30\n"
"\n"
"Take a short break and stretch! | 35%\n"
"Drink some water - stay hydrated! | 30%\n"
"Check your posture! | 20%\n"
"Deep breath - you got this! | 15%\n"
"\n"
"# change.wallpaper | 20%\n"
"# show.overlay | 15%\n"
"\n"
"Morning standup time! | 09:00\n"
"Afternoon focus block | 14:00\n"
"End of day - wrap up! | 17:30\n",
encoding="utf-8",
)
def load_config():
if not NOTIFICATIONS_FILE.exists():
create_default_config()
app_name = DEFAULT_APP_NAME
min_sec = 10 * 60
max_sec = 30 * 60
entries = []
with open(NOTIFICATIONS_FILE, encoding="utf-8") as f:
for raw in f:
line = raw.strip()
if not line or line.startswith("#"):
continue
if line.startswith("@"):
parts = line[1:].split()
key = parts[0].lower() if parts else ""
if key == "name" and len(parts) >= 2:
app_name = " ".join(parts[1:])
elif key == "interval" and len(parts) == 3:
try:
mn = max(1, int(parts[1]))
mx = max(mn, int(parts[2]))
min_sec = mn * 60
max_sec = mx * 60
except ValueError:
pass
continue
if "|" in line:
text, tag = [p.strip() for p in line.split("|", 1)]
else:
text, tag = line, None
if tag and re.match(r"^\d{1,2}:\d{2}$", tag):
h, m = tag.split(":")
tag = f"{int(h):02d}:{m}"
entries.append(Entry(text, None, tag))
elif tag and tag.endswith("%"):
try:
w = float(tag[:-1])
except ValueError:
w = 1.0
entries.append(Entry(text, w, None))
else:
entries.append(Entry(text, 1.0, None))
return app_name, min_sec, max_sec, entries
def apply_config(app_name, min_sec, max_sec, entries, silent=False):
global APP_NAME, APP_ID, MIN_INTERVAL_SEC, MAX_INTERVAL_SEC, _entries, _file_mtime
APP_NAME = app_name
APP_ID = app_name.replace(" ", "")
MIN_INTERVAL_SEC = min_sec
MAX_INTERVAL_SEC = max_sec
with _entries_lock:
_entries = entries
schedule.clear()
for entry in [e for e in entries if e.trigger_time]:
def make_job(e):
def job(): fire_entry(e)
return job
schedule.every().day.at(entry.trigger_time).do(make_job(entry))
_file_mtime = NOTIFICATIONS_FILE.stat().st_mtime
update_tray_title()
if not silent:
log(f"Config reloaded - interval {min_sec//60}-{max_sec//60}min, {len(entries)} entries")
# ── File watcher ──────────────────────────────────────────────────────────────
def file_watcher():
while True:
time.sleep(5)
try:
mtime = NOTIFICATIONS_FILE.stat().st_mtime
if mtime != _file_mtime:
app_name, min_sec, max_sec, entries = load_config()
apply_config(app_name, min_sec, max_sec, entries)
send_toast("Config reloaded!")
except Exception:
pass
# ── Wallpaper ─────────────────────────────────────────────────────────────────
SPI_SETDESKWALLPAPER = 0x0014
SPIF_UPDATEINIFILE = 0x01
SPIF_SENDCHANGE = 0x02
def get_current_wallpaper() -> str:
try:
import winreg
key = winreg.OpenKey(winreg.HKEY_CURRENT_USER, r"Control Panel\Desktop")
val, _ = winreg.QueryValueEx(key, "WallPaper")
winreg.CloseKey(key)
return val
except Exception:
return ""
def set_wallpaper(path: str):
ctypes.windll.user32.SystemParametersInfoW(
SPI_SETDESKWALLPAPER, 0, path, SPIF_UPDATEINIFILE | SPIF_SENDCHANGE)
def do_wallpaper_change():
global _last_wallpaper
if not WALLPAPERS_DIR or not WALLPAPERS_DIR.exists():
send_toast(f"Wallpaper folder not found!")
return
files = []
for ext in ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"]:
files.extend(WALLPAPERS_DIR.glob(ext))
if not files:
send_toast(f"No images found in wallpapers folder!")
return
wp = str(random.choice(files))
_last_wallpaper = wp
set_wallpaper(wp)
log(f"Wallpaper changed: {Path(wp).name}")
# ── Screen Overlay ────────────────────────────────────────────────────────────
def _get_monitors():
"""Return list of (x, y, w, h) tuples for every connected monitor."""
monitors = []
try:
# EnumDisplayMonitors via ctypes
MonitorEnumProc = ctypes.WINFUNCTYPE(
ctypes.c_bool,
ctypes.c_ulong, # hMonitor
ctypes.c_ulong, # hdcMonitor
ctypes.POINTER(ctypes.wintypes.RECT),
ctypes.c_double, # dwData
)
def _cb(hMon, hdcMon, lprcMon, dwData):
r = lprcMon.contents
monitors.append((r.left, r.top, r.right - r.left, r.bottom - r.top))
return True
ctypes.windll.user32.EnumDisplayMonitors(None, None, MonitorEnumProc(_cb), 0)
except Exception:
pass
if not monitors:
# Fallback: single primary screen via tkinter (filled in create_overlay)
monitors = [None]
return monitors
def do_screen_overlay(duration_ms: int = 6000):
if not OVERLAY_DIR or not OVERLAY_DIR.exists():
send_toast("Overlay folder not found!")
return
files = []
for ext in ["*.jpg", "*.jpeg", "*.png", "*.bmp", "*.webp"]:
files.extend(OVERLAY_DIR.glob(ext))
if not files:
send_toast("No images found in Overlay folder!")
return
img_path = str(random.choice(files))
monitors = _get_monitors()
log(f"Showing overlay: {Path(img_path).name} for {duration_ms//1000}s on {len(monitors)} screen(s)")
def _make_tk_image(pil_img, sw, sh):
"""Resize image for a monitor. Stretch to fill, or fit with letterbox."""
if _overlay_stretch:
resized = pil_img.resize((sw, sh), Image.Resampling.LANCZOS)
return ImageTk.PhotoImage(resized)
else:
img_w, img_h = pil_img.size
scale = min(sw / img_w, sh / img_h)
new_w = int(img_w * scale)
new_h = int(img_h * scale)
resized = pil_img.resize((new_w, new_h), Image.Resampling.LANCZOS)
canvas = Image.new("RGB", (sw, sh), "black")
canvas.paste(resized, ((sw - new_w) // 2, (sh - new_h) // 2))
return ImageTk.PhotoImage(canvas)
def create_overlay():
try:
pil_img = Image.open(img_path)
except Exception as e:
log(f"Overlay error opening image: {e}")
return
with state_lock:
is_paused = paused
for mon in monitors:
# Determine monitor geometry
if mon is None:
# Fallback to primary screen
tmp = tk.Toplevel(); tmp.withdraw(); tmp.update_idletasks()
sw, sh, mx, my = tmp.winfo_screenwidth(), tmp.winfo_screenheight(), 0, 0
tmp.destroy()
else:
mx, my, sw, sh = mon
ov = tk.Toplevel()
ov.attributes("-topmost", True)
ov.attributes("-alpha", 0.4)
ov.overrideredirect(True)
ov.update_idletasks()
# Make click-through
h_wnd = ctypes.windll.user32.GetAncestor(ov.winfo_id(), 2)
if not h_wnd:
h_wnd = ov.winfo_id()
style = ctypes.windll.user32.GetWindowLongW(h_wnd, -20)
ctypes.windll.user32.SetWindowLongW(h_wnd, -20, style | 0x80000 | 0x20)
ov.geometry(f"{sw}x{sh}+{mx}+{my}")
try:
tk_img = _make_tk_image(pil_img, sw, sh)
lbl = tk.Label(ov, image=tk_img, bg='black')
lbl.image = tk_img
lbl.pack(fill="both", expand=True)
except Exception as e:
log(f"Overlay render error: {e}")
ov.destroy()
continue
entry = {
"window": ov,
"remaining_ms": duration_ms,
"after_id": None,
"started_at": time.monotonic(),
}
def on_dismiss(e=entry, w=ov):
with _overlays_lock:
if e in _active_overlays:
_active_overlays.remove(e)
if w.winfo_exists():
w.destroy()
def schedule_dismiss(e=entry, w=ov):
e["started_at"] = time.monotonic()
e["after_id"] = w.after(e["remaining_ms"], lambda: on_dismiss(e, w))
entry["schedule_dismiss"] = schedule_dismiss
with _overlays_lock:
_active_overlays.append(entry)
if is_paused:
ov.withdraw()
else:
schedule_dismiss()
root_instance.after(0, create_overlay)
def pause_overlays():
"""Hide all active overlays and freeze their remaining time."""
def _do():
with _overlays_lock:
overlays = list(_active_overlays)
for entry in overlays:
ov = entry["window"]
if not ov.winfo_exists():
continue
if entry["after_id"] is not None:
try:
ov.after_cancel(entry["after_id"])
except Exception:
pass
elapsed_ms = int((time.monotonic() - entry["started_at"]) * 1000)
entry["remaining_ms"] = max(0, entry["remaining_ms"] - elapsed_ms)
entry["after_id"] = None
ov.withdraw()
root_instance.after(0, _do)
def resume_overlays():
"""Show all paused overlays and restart their timers with remaining time."""
def _do():
with _overlays_lock:
overlays = list(_active_overlays)
for entry in overlays:
ov = entry["window"]
if not ov.winfo_exists():
with _overlays_lock:
if entry in _active_overlays:
_active_overlays.remove(entry)
continue
if entry["remaining_ms"] <= 0:
ov.destroy()
with _overlays_lock:
if entry in _active_overlays:
_active_overlays.remove(entry)
continue
ov.deiconify()
entry["schedule_dismiss"]()
root_instance.after(0, _do)
# ── Icon ──────────────────────────────────────────────────────────────────────
def prepare_icons():
if not ICON_FILE.exists():
return None
try:
img = Image.open(ICON_FILE).convert("RGBA")
ico = APPDATA_DIR / "icon.ico"
sizes = [256, 128, 64, 48, 32, 16]
frames = []
for s in sizes:
f = img.copy(); f.thumbnail((s, s), Image.LANCZOS); frames.append(f)
frames[0].save(ico, format="ICO", sizes=[(s,s) for s in sizes], append_images=frames[1:])
return img
except Exception:
return None
def make_default_icon():
size = 256
img = Image.new("RGBA", (size, size), (0, 0, 0, 0))
d = ImageDraw.Draw(img)
s = size / 64
d.ellipse([8*s, 20*s, 56*s, 56*s], fill="#4f8ef7")
d.polygon([(32*s, 4*s), (16*s, 24*s), (48*s, 24*s)], fill="#4f8ef7")
d.rectangle([24*s, 56*s, 40*s, 62*s], fill="#4f8ef7")
return img
def load_tray_icon():
img = prepare_icons()
return img if img is not None else make_default_icon()
# ── Toast ─────────────────────────────────────────────────────────────────────
def send_toast(message: str):
if ICON_FILE and ICON_FILE.exists():
icon_path = str(ICON_FILE)
elif APPDATA_DIR and (APPDATA_DIR / "icon.ico").exists():
icon_path = str(APPDATA_DIR / "icon.ico")
else:
icon_path = ""
notif = Notification(app_id=APP_ID, title=APP_NAME, msg=message,
duration="short", icon=icon_path)
notif.set_audio(audio.Default, loop=False)
notif.show()
log(f"Notification: {message}")
# ── Notification logic ────────────────────────────────────────────────────────
def pick_weighted(entries):
weighted = [e for e in entries if e.weight is not None]
if not weighted:
return None
return random.choices(weighted, weights=[e.weight for e in weighted], k=1)[0]
def fire_entry(entry):
is_wallpaper = entry.text.strip().lower() == WALLPAPER_MAGIC
is_overlay = is_overlay_entry(entry.text)
with state_lock:
if paused:
notification_queue.append(entry.text)
log(f"Queued (paused): {entry.text}")
return
if is_wallpaper:
do_wallpaper_change()
elif is_overlay:
dur = parse_overlay_duration(entry.text)
threading.Thread(target=do_screen_overlay, args=(dur,), daemon=True).start()
else:
send_toast(entry.text)
def flush_queued(queued: list):
for item in queued:
low = item.strip().lower()
if low == WALLPAPER_MAGIC:
do_wallpaper_change()
elif is_overlay_entry(item):
dur = parse_overlay_duration(item)
do_screen_overlay(dur)
else:
send_toast(item)
time.sleep(1.5)
def notification_loop():
global _next_fire_at
while True:
with _entries_lock:
entries = list(_entries)
chosen = pick_weighted(entries)
if chosen:
fire_entry(chosen)
interval = random.randint(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC)
_next_fire_at = time.time() + interval
log(f"Next notification in {interval//60}m {interval%60}s")
while time.time() < _next_fire_at:
time.sleep(5)
def schedule_runner():
while True:
schedule.run_pending()
time.sleep(10)
# ── Hotkey ────────────────────────────────────────────────────────────────────
def toggle_pause():
global paused
queued = []
with state_lock:
paused = not paused
if not paused:
queued = notification_queue.copy()
notification_queue.clear()
if paused:
log("Paused")
pause_overlays() # instant runs on Tk thread immediately
if _default_wallpaper:
threading.Thread(target=set_wallpaper, args=(_default_wallpaper,), daemon=True).start()
else:
log("Resumed")
resume_overlays() # instant
if _last_wallpaper:
threading.Thread(target=set_wallpaper, args=(_last_wallpaper,), daemon=True).start()
threading.Thread(target=flush_queued, args=(queued,), daemon=True).start()
update_tray_title()
def update_tray_title():
if tray_icon:
tray_icon.title = f"{APP_NAME} - {'PAUSED' if paused else 'Running'}"
tray_icon.update_menu()
# ── Web UI ────────────────────────────────────────────────────────────────────
flask_app = Flask(__name__)
HTML = r"""<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
<title>NotifyPulse</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
:root {
--bg: #0d0d0f;
--card: #16161a;
--border: #2a2a32;
--accent: #7c6af7;
--accent2: #a78bfa;
--text: #e2e2e8;
--muted: #6b6b7b;
--green: #4ade80;
--red: #f87171;
--yellow: #fbbf24;
}
body { background: var(--bg); color: var(--text); font-family: 'Segoe UI', system-ui, sans-serif; min-height: 100vh; }
header {
display: flex; align-items: center; justify-content: space-between;
padding: 18px 28px; border-bottom: 1px solid var(--border);
background: var(--card);
}
header h1 { font-size: 1.2rem; font-weight: 700; letter-spacing: .04em; color: var(--accent2); }
.status-pill {
display: flex; align-items: center; gap: 8px;
padding: 6px 14px; border-radius: 999px; font-size: .8rem; font-weight: 600;
border: 1px solid var(--border);
}
.dot { width: 8px; height: 8px; border-radius: 50%; }
.running .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.paused .dot { background: var(--yellow); box-shadow: 0 0 6px var(--yellow); }
main { max-width: 960px; margin: 0 auto; padding: 28px 20px; display: grid; gap: 20px;
grid-template-columns: 1fr 1fr; }
@media(max-width:640px){ main { grid-template-columns: 1fr; } }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.card h2 { font-size: .75rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-bottom: 14px; }
/* Countdown */
#countdown-ring { display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 10px 0; }
.ring-wrap { position: relative; width: 130px; height: 130px; }
.ring-wrap svg { transform: rotate(-90deg); }
.ring-bg { fill: none; stroke: var(--border); stroke-width: 10; }
.ring-arc { fill: none; stroke: var(--accent); stroke-width: 10; stroke-linecap: round;
transition: stroke-dashoffset .9s linear; }
.ring-label { position: absolute; inset: 0; display: flex; flex-direction: column;
align-items: center; justify-content: center; }
.ring-label .big { font-size: 1.6rem; font-weight: 700; line-height: 1; }
.ring-label .small { font-size: .7rem; color: var(--muted); margin-top: 2px; }
#next-label { margin-top: 10px; font-size: .8rem; color: var(--muted); text-align: center; }
/* Controls */
.btn-row { display: flex; gap: 10px; flex-wrap: wrap; }
.btn {
flex: 1; padding: 10px 16px; border: none; border-radius: 8px; cursor: pointer;
font-size: .85rem; font-weight: 600; transition: opacity .15s, transform .1s;
}
.btn:hover { opacity: .85; transform: translateY(-1px); }
.btn:active { transform: scale(.97); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-danger { background: #2d1a1a; border: 1px solid var(--red); color: var(--red); }
/* Settings */
.field { margin-bottom: 14px; }
.field label { display: block; font-size: .75rem; color: var(--muted); margin-bottom: 5px; }
.field input, .field textarea {
width: 100%; padding: 9px 12px; background: var(--bg); border: 1px solid var(--border);
border-radius: 7px; color: var(--text); font-size: .88rem; outline: none;
transition: border-color .15s;
}
.field input:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 80px; font-family: 'Cascadia Code', 'Consolas', monospace; font-size: .8rem; }
.interval-row { display: flex; gap: 10px; }
.interval-row input { flex: 1; }
.save-msg { font-size: .75rem; color: var(--green); margin-top: 6px; min-height: 16px; }
/* Log */
.log-list { list-style: none; max-height: 260px; overflow-y: auto; }
.log-list::-webkit-scrollbar { width: 4px; }
.log-list::-webkit-scrollbar-track { background: transparent; }
.log-list::-webkit-scrollbar-thumb { background: var(--border); border-radius: 4px; }
.log-list li { display: flex; gap: 12px; padding: 7px 0; border-bottom: 1px solid var(--border); font-size: .8rem; }
.log-list li:last-child { border-bottom: none; }
.log-time { color: var(--muted); min-width: 54px; font-variant-numeric: tabular-nums; }
.log-msg { color: var(--text); }
/* Entries preview */
.entry-list { list-style: none; max-height: 220px; overflow-y: auto; }
.entry-list li { display: flex; justify-content: space-between; align-items: center;
padding: 7px 0; border-bottom: 1px solid var(--border); font-size: .82rem; gap: 10px; }
.entry-list li:last-child { border-bottom: none; }
.entry-pct { color: var(--accent2); font-weight: 600; min-width: 38px; text-align: right; flex-shrink: 0; }
.entry-time { color: var(--yellow); font-weight: 600; flex-shrink: 0; }
.entry-wall { color: var(--green); font-style: italic; flex-shrink: 0; }
/* Bar mode */
.entry-bar-wrap { flex: 1; display: flex; align-items: center; gap: 8px; min-width: 0; }
.entry-bar-track { flex: 1; height: 6px; background: var(--border); border-radius: 3px; overflow: hidden; }
.entry-bar-fill { height: 100%; border-radius: 3px; background: var(--accent); transition: width .4s ease; }
.entry-bar-label { color: var(--accent2); font-size: .75rem; font-weight: 600; min-width: 32px; text-align: right; flex-shrink: 0; }
/* Ratio mode */
.entry-ratio { color: var(--accent2); font-weight: 600; font-size: .8rem; flex-shrink: 0; }
/* Prognosis */
.prog-big { font-size: 2.4rem; font-weight: 700; color: var(--accent2); line-height: 1; }
.prog-sub { font-size: .75rem; color: var(--muted); margin-top: 4px; }
.prog-row { display: flex; gap: 16px; margin-top: 14px; }
.prog-cell { flex: 1; background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.prog-cell .label { font-size: .7rem; color: var(--muted); text-transform: uppercase; letter-spacing: .08em; }
.prog-cell .val { font-size: 1.2rem; font-weight: 700; color: var(--text); margin-top: 2px; }
.entry-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 10px; }
.entry-header h2 { margin-bottom: 0; }
.mode-toggle { display: flex; gap: 4px; }
.mode-btn { background: transparent; border: 1px solid var(--border); color: var(--muted);
border-radius: 5px; padding: 3px 8px; font-size: .7rem; cursor: pointer; transition: all .15s; }
.mode-btn.active { background: var(--accent); border-color: var(--accent); color: #fff; }
.full-width { grid-column: 1 / -1; }
/* Paused state - full black screen */
body { transition: background .3s ease; }
body.is-paused { background: #000; }
body.is-paused header,
body.is-paused main { display: none; }
</style>
</head>
<body>
<header>
<h1 id="app-name">NotifyPulse</h1>
<div id="status-pill" class="status-pill running">
<div class="dot"></div>
<span id="status-text">Running</span>
</div>
</header>
<main>
<div class="card">
<h2>Next Notification</h2>
<div id="countdown-ring">
<div class="ring-wrap">
<svg viewBox="0 0 130 130" width="130" height="130">
<circle class="ring-bg" cx="65" cy="65" r="55"/>
<circle class="ring-arc" cx="65" cy="65" r="55" id="arc"
stroke-dasharray="345.4" stroke-dashoffset="0"/>
</svg>
<div class="ring-label">
<span class="big" id="cd-min">--</span>
<span class="small" id="cd-sec-label">min</span>
</div>
</div>
<div id="next-label">Loading...</div>
</div>
</div>
<div class="card">
<h2>Controls</h2>
<div class="btn-row" style="margin-bottom:12px">
<button class="btn btn-primary" onclick="togglePause()">Pause / Resume</button>
</div>
<div class="btn-row" style="margin-bottom:12px">
<button class="btn btn-outline" onclick="testNotif()">Send Test Notification</button>
</div>
<div class="btn-row" style="margin-bottom:12px">
<button class="btn btn-outline" onclick="testWallpaper()">Test Wallpaper Change</button>
</div>
<div class="btn-row">
<button class="btn btn-outline" onclick="testOverlay()">Test Screen Overlay</button>
</div>
</div>
<div class="card">
<h2>Settings</h2>
<div class="field">
<label>App Name</label>
<input id="s-name" type="text" placeholder="NotifyPulse" oninput="markDirty()">
</div>
<div class="field">
<label>Interval (minutes)</label>
<div class="interval-row">
<input id="s-min" type="number" min="1" placeholder="Min" oninput="markDirty()">
<input id="s-max" type="number" min="1" placeholder="Max" oninput="markDirty()">
</div>
</div>
<div class="field" style="margin-bottom:12px">
<label>Overlay Image Mode</label>
<div style="display:flex;gap:8px;margin-top:4px">
<button id="ovr-fit" class="mode-btn active" onclick="setOverlayMode(false)">Fit (letterbox)</button>
<button id="ovr-stretch" class="mode-btn" onclick="setOverlayMode(true)">Stretch to fill</button>
</div>
</div>
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%">Save Settings</button>
<div class="save-msg" id="save-msg"></div>
</div>
<div class="card">
<div class="entry-header">
<h2>Active Entries</h2>
<div class="mode-toggle">
<button class="mode-btn active" onclick="setMode('pct')" id="btn-pct">%</button>
<button class="mode-btn" onclick="setMode('ratio')" id="btn-ratio">1 in X</button>
</div>
</div>
<ul class="entry-list" id="entry-list"></ul>
</div>
<div class="card full-width">
<h2>Prognosis — next hour</h2>
<div style="display:flex;align-items:flex-end;gap:12px;flex-wrap:wrap">
<div>
<div class="prog-big" id="prog-total"></div>
<div class="prog-sub">estimated notifications</div>
</div>
<div class="prog-sub" style="margin-bottom:6px;flex:1" id="prog-range"></div>
</div>
<div class="prog-row">
<div class="prog-cell">
<div class="label">Random</div>
<div class="val" id="prog-random"></div>
</div>
<div class="prog-cell">
<div class="label">Timed (this hour)</div>
<div class="val" id="prog-timed"></div>
</div>
</div>
</div>
<div class="card full-width">
<h2>Log</h2>
<ul class="log-list" id="log-list"></ul>
</div>
</main>
<script>
let totalInterval = 0;
let entryMode = 'pct';
let lastEntries = [];
function setMode(m) {
entryMode = m;
['pct','ratio'].forEach(id => {
document.getElementById('btn-' + id).classList.toggle('active', id === m);
});
renderEntries(lastEntries);
}
function renderEntries(entries) {
lastEntries = entries;
const el = document.getElementById('entry-list');
el.innerHTML = '';
const totalWeight = entries.filter(e => e.weight != null).reduce((s, e) => s + e.weight, 0);
for (const e of entries) {
const li = document.createElement('li');
if (e.trigger_time) {
li.innerHTML = `<span>${e.text}</span><span class="entry-time">${e.trigger_time}</span>`;
} else if (e.text === 'change.wallpaper') {
li.innerHTML = `<span>${e.text}</span><span class="entry-wall">wallpaper</span>`;
} else if (e.text === 'show.overlay') {
li.innerHTML = `<span>${e.text}</span><span class="entry-wall" style="color:var(--accent2)">overlay</span>`;
} else {
const pct = totalWeight > 0 ? (e.weight / totalWeight * 100) : 0;
if (entryMode === 'pct') {
li.innerHTML = `<span>${e.text}</span><span class="entry-pct">${pct.toFixed(1)}%</span>`;
} else {
const ratio = pct > 0 ? Math.round(100 / pct) : '';
li.innerHTML = `<span>${e.text}</span><span class="entry-ratio">1 in ${ratio}</span>`;
}
}
el.appendChild(li);
}
}
function updatePrognosis(minInterval, maxInterval, entries) {
const avgInterval = (minInterval + maxInterval) / 2; // minutes
const randomPerHour = 60 / avgInterval;
// Count timed entries firing within next 60 min from now
const now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes();
const endMins = nowMins + 60;
let timedCount = 0;
for (const e of entries) {
if (e.trigger_time) {
const [hh, mm] = e.trigger_time.split(':').map(Number);
const entryMins = hh * 60 + mm;
// handle midnight wrap
if (endMins >= 1440) {
if (entryMins >= nowMins || entryMins < endMins - 1440) timedCount++;
} else {
if (entryMins >= nowMins && entryMins < endMins) timedCount++;
}
}
}
const total = randomPerHour + timedCount;
const minR = Math.floor(60 / maxInterval);
const maxR = Math.ceil(60 / minInterval);
document.getElementById('prog-total').textContent = total.toFixed(1);
document.getElementById('prog-random').textContent = randomPerHour.toFixed(1);
document.getElementById('prog-timed').textContent = timedCount;
document.getElementById('prog-range').textContent = `${minR}${maxR} random + ${timedCount} timed`;
}
async function fetchState() {
try {
const r = await fetch('/api/state');
const d = await r.json();
document.getElementById('app-name').textContent = d.app_name;
document.title = d.paused ? 'Paused' : d.app_name;
document.body.classList.toggle('is-paused', d.paused);
const pill = document.getElementById('status-pill');
const txt = document.getElementById('status-text');
pill.className = 'status-pill ' + (d.paused ? 'paused' : 'running');
txt.textContent = d.paused ? 'Paused' : 'Running';
if (!settingsDirty) {
document.getElementById('s-name').value = d.app_name;
document.getElementById('s-min').value = d.min_interval;
document.getElementById('s-max').value = d.max_interval;
}
if (d.overlay_stretch !== undefined) {
document.getElementById('ovr-fit').classList.toggle('active', !d.overlay_stretch);
document.getElementById('ovr-stretch').classList.toggle('active', d.overlay_stretch);
}
// Countdown
const secsLeft = Math.max(0, d.next_fire_at - Date.now()/1000);
totalInterval = d.total_interval || totalInterval;
const pct = totalInterval > 0 ? secsLeft / totalInterval : 1;
const circ = 345.4;
document.getElementById('arc').style.strokeDashoffset = circ * (1 - pct);
const m = Math.floor(secsLeft / 60);
const s = Math.floor(secsLeft % 60);
document.getElementById('cd-min').textContent = d.paused ? '--' : (m + 'm ' + String(s).padStart(2,'0') + 's');
document.getElementById('cd-sec-label').textContent = '';
document.getElementById('next-label').textContent = d.paused
? 'Paused - notifications suspended'
: `Next in ${m}m ${String(s).padStart(2,'0')}s`;
// Entries
renderEntries(d.entries);
updatePrognosis(d.min_interval, d.max_interval, d.entries);
// Log
const ll = document.getElementById('log-list');
ll.innerHTML = '';
for (const entry of d.log) {
const li = document.createElement('li');
li.innerHTML = `<span class="log-time">${entry.time}</span><span class="log-msg">${entry.msg}</span>`;
ll.appendChild(li);
}
} catch(e) {}
}
async function togglePause() {
const r = await fetch('/api/pause', {method:'POST'});
const d = await r.json();
// Apply paused state immediately from the response — don't wait for next poll
document.body.classList.toggle('is-paused', d.paused);
document.title = d.paused ? 'Paused' : (document.getElementById('app-name').textContent);
const pill = document.getElementById('status-pill');
const txt = document.getElementById('status-text');
pill.className = 'status-pill ' + (d.paused ? 'paused' : 'running');
txt.textContent = d.paused ? 'Paused' : 'Running';
fetchState();
}
async function testNotif() {
await fetch('/api/test_notification', {method:'POST'});
}
async function testWallpaper() {
await fetch('/api/test_wallpaper', {method:'POST'});
}
async function testOverlay() {
await fetch('/api/test_overlay', {method:'POST'});
}
function setOverlayMode(stretch) {
document.getElementById('ovr-fit').classList.toggle('active', !stretch);
document.getElementById('ovr-stretch').classList.toggle('active', stretch);
fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({overlay_stretch: stretch})
});
}
let settingsDirty = false;
function markDirty() { settingsDirty = true; }
async function saveSettings() {
const name = document.getElementById('s-name').value.trim();
const min = parseInt(document.getElementById('s-min').value);
const max = parseInt(document.getElementById('s-max').value);
if (!name || isNaN(min) || isNaN(max) || min < 1 || max < min) {
document.getElementById('save-msg').textContent = 'Invalid values!';
return;
}
const r = await fetch('/api/settings', {
method: 'POST',
headers: {'Content-Type':'application/json'},
body: JSON.stringify({name, min_interval: min, max_interval: max})
});
const d = await r.json();
settingsDirty = false;
const msg = document.getElementById('save-msg');
msg.textContent = d.ok ? 'Saved!' : 'Error saving.';
setTimeout(() => msg.textContent = '', 2500);
fetchState();
}
// Poll every second
fetchState();
setInterval(fetchState, 1000);
</script>
</body>
</html>"""
@flask_app.route("/")
def index():
return HTML
@flask_app.route("/api/state")
def api_state():
with _entries_lock:
entries_data = [
{"text": e.text, "weight": e.weight, "trigger_time": e.trigger_time}
for e in _entries
]
with _log_lock:
log_data = list(_log)
total = MAX_INTERVAL_SEC # approximate for ring display
return jsonify({
"app_name": APP_NAME,
"paused": paused,
"min_interval": MIN_INTERVAL_SEC // 60,
"max_interval": MAX_INTERVAL_SEC // 60,
"next_fire_at": _next_fire_at,
"total_interval": total,
"entries": entries_data,
"log": log_data,
"overlay_stretch": _overlay_stretch,
})
@flask_app.route("/api/pause", methods=["POST"])
def api_pause():
toggle_pause()
return jsonify({"paused": paused})
@flask_app.route("/api/test_notification", methods=["POST"])
def api_test_notif():
send_toast("Test notification from the web UI!")
return jsonify({"ok": True})
@flask_app.route("/api/test_wallpaper", methods=["POST"])
def api_test_wallpaper():
threading.Thread(target=do_wallpaper_change, daemon=True).start()
return jsonify({"ok": True})
@flask_app.route("/api/test_overlay", methods=["POST"])
def api_test_overlay():
threading.Thread(target=do_screen_overlay, daemon=True).start()
return jsonify({"ok": True})
@flask_app.route("/api/settings", methods=["POST"])
def api_settings():
global MIN_INTERVAL_SEC, MAX_INTERVAL_SEC, APP_NAME, _overlay_stretch
try:
data = request.get_json()
name = data.get("name", APP_NAME).strip()
min_m = max(1, int(data.get("min_interval", 10)))
max_m = max(min_m, int(data.get("max_interval", 30)))
if "overlay_stretch" in data:
_overlay_stretch = bool(data["overlay_stretch"])
log(f"Overlay mode: {'stretch' if _overlay_stretch else 'fit'}")
# Read file, update @name and @interval lines, write back
lines = NOTIFICATIONS_FILE.read_text(encoding="utf-8").splitlines()
new_lines = []
found_name = found_interval = False
for line in lines:
s = line.strip()
if s.startswith("@name"):
new_lines.append(f"@name {name}")
found_name = True
elif s.startswith("@interval"):
new_lines.append(f"@interval {min_m} {max_m}")
found_interval = True
else:
new_lines.append(line)
if not found_name:
new_lines.insert(0, f"@name {name}")
if not found_interval:
new_lines.insert(1, f"@interval {min_m} {max_m}")
NOTIFICATIONS_FILE.write_text("\n".join(new_lines) + "\n", encoding="utf-8")
# Apply interval immediately and reset the countdown timer
if "min_interval" in data or "max_interval" in data:
global _next_fire_at
MIN_INTERVAL_SEC = min_m * 60
MAX_INTERVAL_SEC = max_m * 60
new_interval = random.randint(MIN_INTERVAL_SEC, MAX_INTERVAL_SEC)
_next_fire_at = time.time() + new_interval
log(f"Interval updated to {min_m}-{max_m}min, timer reset")
return jsonify({"ok": True})
except Exception as ex:
return jsonify({"ok": False, "error": str(ex)})
def run_flask():
import logging
log_fn = logging.getLogger("werkzeug")
log_fn.setLevel(logging.ERROR)
flask_app.run(host="127.0.0.1", port=WEB_PORT, debug=False, use_reloader=False)
# ── Tray ──────────────────────────────────────────────────────────────────────
def build_tray_menu():
def pause_action(icon, item): toggle_pause()
def quit_action(icon, item): icon.stop(); os._exit(0)
def open_config(icon, item): os.startfile(str(NOTIFICATIONS_FILE))
def open_folder(icon, item): os.startfile(str(APPDATA_DIR))
def open_webui(icon, item): webbrowser.open(f"http://localhost:{WEB_PORT}")
def test_action(icon, item): send_toast("Test notification!")
def test_wallpaper(icon, item): do_wallpaper_change()
def test_overlay_act(icon, item): do_screen_overlay()
def status_text(item):
return "Paused - press F13 to resume" if paused else "Running - press F13 to pause"
return pystray.Menu(
pystray.MenuItem(status_text, pause_action),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Open Web UI", open_webui),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Send test notification", test_action),
pystray.MenuItem("Test wallpaper change", test_wallpaper),
pystray.MenuItem("Test screen overlay", test_overlay_act),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Open notifications.txt", open_config),
pystray.MenuItem("Open config folder", open_folder),
pystray.Menu.SEPARATOR,
pystray.MenuItem("Quit", quit_action),
)
def run_tray():
global tray_icon
tray_icon = pystray.Icon(
APP_NAME, load_tray_icon(), f"{APP_NAME} - Running", menu=build_tray_menu())
tray_icon.run()
# ── Main ──────────────────────────────────────────────────────────────────────
def main():
global APP_NAME, APP_ID, APPDATA_DIR, NOTIFICATIONS_FILE, ICON_FILE
global WALLPAPERS_DIR, OVERLAY_DIR, _default_wallpaper, _last_wallpaper, _file_mtime, _next_fire_at
global root_instance
APPDATA_DIR = resolve_appdata_dir()
NOTIFICATIONS_FILE = APPDATA_DIR / "notifications.txt"
ICON_FILE = APPDATA_DIR / "icon.png"
WALLPAPERS_DIR = APPDATA_DIR / "wallpapers"
OVERLAY_DIR = APPDATA_DIR / "Overlay"
WALLPAPERS_DIR.mkdir(exist_ok=True)
OVERLAY_DIR.mkdir(exist_ok=True)
_default_wallpaper = get_current_wallpaper()
app_name, min_sec, max_sec, entries = load_config()
apply_config(app_name, min_sec, max_sec, entries, silent=True)
_next_fire_at = time.time() + random.randint(min_sec, max_sec)
keyboard.add_hotkey(HOTKEY, toggle_pause, suppress=True)
# Initialize Tkinter root for handling the Overlay windows
root_instance = tk.Tk()
root_instance.withdraw()
threading.Thread(target=notification_loop, daemon=True).start()
threading.Thread(target=schedule_runner, daemon=True).start()
threading.Thread(target=file_watcher, daemon=True).start()
threading.Thread(target=run_flask, daemon=True).start()
log(f"Started - interval {min_sec//60}-{max_sec//60}min")
send_toast(f"{APP_NAME} is running! Web UI at localhost:{WEB_PORT}")
# Run tray in its own thread so root.mainloop() can run
threading.Thread(target=run_tray, daemon=True).start()
root_instance.mainloop()
if __name__ == "__main__":
main()