1210 lines
45 KiB
Python
1210 lines
45 KiB
Python
# 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() |