Initial commit

This commit is contained in:
TutorialsGHG
2026-04-12 21:57:21 +02:00
commit b011c7ad75
12 changed files with 2762 additions and 0 deletions

120
README.md Normal file
View File

@@ -0,0 +1,120 @@
# NotifyPulse V2
A fast, polished Windows background notification app with a desktop Web UI and a **mobile PWA** for wallpaper delivery.
---
## What's new in V2
| Feature | V1 | V2 |
|---|---|---|
| Architecture | Monolithic single file | Modular: backend + `/ui` + `/pwa` |
| PWA support | ❌ | ✅ iOS + Android |
| Mobile wallpaper push | ❌ | ✅ Lockscreen + Background |
| Settings persistence | Config file only | `settings.json` + live UI |
| Settings options | Name + interval + stretch | +opacity, +duration, +sound, +hotkey, +auth token, +startup behavior |
| Poll speed | 5s file watch / 1s UI | 4s file watch / 1s UI / 3s countdown |
| CORS | ❌ | ✅ (needed for PWA) |
| API | Basic | Extended: `/api/fire_now`, `/api/entries` CRUD, `/api/log`, `/api/pwa/*` |
| Desktop UI | Inline HTML in .py | Separate `ui/index.html` (sidebar layout) |
---
## Folder structure
```
NotifyPulse/
├── notifier.py ← Main app
├── make_ico.py ← Icon generator
├── requirements.txt
├── build.bat
├── ui/
│ └── index.html ← Desktop Web UI
└── pwa/
├── index.html ← Mobile PWA
├── manifest.json
└── sw.js ← Service Worker
```
Config lives in `%APPDATA%\Roaming\NotifyPulse\`:
```
NotifyPulse/
├── notifications.txt ← Your entries (auto-reload on save)
├── settings.json ← Persisted settings
├── icon.png ← Optional custom icon
├── wallpapers/ ← PC wallpapers
├── Overlay/ ← Screen overlay images
└── Mobile/
├── Lockscreen.jpg ← Mobile lock screen image
└── Background.jpg ← Mobile home screen background
```
---
## Quick start
```bat
pip install -r requirements.txt
python make_ico.py
python notifier.py
```
Or build an `.exe`:
```bat
build.bat
```
---
## Mobile PWA setup
1. Start NotifyPulse on your PC.
2. Find your PC's local IP (e.g. `192.168.1.50`).
3. On your phone, open: `http://192.168.1.50:5000/pwa`
4. **iOS**: tap Share → Add to Home Screen
5. **Android**: tap ⋮ → Install App
The PWA polls the PC every 4 seconds. When a `change.wallpaper.mobile` entry fires (or you click "Request New Wallpaper"), the PWA instantly receives the `Lockscreen` and `Background` images and lets you save them to your camera roll.
---
## notifications.txt syntax
```
@name My App Name
@interval 10 30 # min/max minutes
# Weighted random (picks by % weight)
Take a break! | 35%
Drink water! | 30%
# Daily at fixed time
Morning standup | 09:00
End of day | 17:30
# Special commands
change.wallpaper | 20% # change PC wallpaper
change.wallpaper.mobile | 10% # push new wallpaper to phone
show.overlay | 15% # fullscreen image overlay (6s)
show.overlay.10 | 10% # overlay for 10s
```
---
## API reference
| Method | Endpoint | Description |
|---|---|---|
| GET | `/api/state` | Full app state |
| POST | `/api/pause` | Toggle pause |
| POST | `/api/fire_now` | Fire random entry now |
| GET/POST | `/api/entries` | Read/write notifications.txt |
| GET/POST | `/api/settings` | Read/write settings |
| GET | `/api/log` | Event log |
| POST | `/api/test_notification` | Send test toast |
| POST | `/api/test_wallpaper` | Test PC wallpaper change |
| POST | `/api/test_overlay` | Test screen overlay |
| POST | `/api/test_mobile_wallpaper` | Push mobile wallpaper |
| POST | `/api/pwa/ping` | PWA heartbeat |
| GET | `/api/pwa/wallpaper` | PWA polls for pending wallpaper |
| POST | `/api/pwa/trigger_wallpaper` | PWA requests new wallpaper |

75
build.bat Normal file
View File

@@ -0,0 +1,75 @@
@echo off
setlocal enabledelayedexpansion
echo.
echo =====================================================
echo NotifyPulse V2 ^| Builder
echo =====================================================
echo.
REM ── 1. Dependencies ─────────────────────────────────────────────────────────
echo [1/4] Installing dependencies...
python -m pip install -r requirements.txt --quiet
if errorlevel 1 (
echo.
echo ERROR: pip install failed. Make sure Python is in your PATH.
pause & exit /b 1
)
echo OK
REM ── 2. Icons ─────────────────────────────────────────────────────────────────
echo.
echo [2/4] Generating icons...
python make_ico.py
if errorlevel 1 (
echo WARNING: Icon generation failed - building without custom icon.
)
REM ── 3. Bundle PWA + UI into dist ──────────────────────────────────────────────
echo.
echo [3/4] Building executable...
REM Collect data files: pwa folder and ui folder
set DATA_ARGS=--add-data "pwa;pwa" --add-data "ui;ui"
if exist icon.ico (
python -m PyInstaller --onefile --noconsole ^
--name NotifyPulse ^
--icon icon.ico ^
%DATA_ARGS% ^
notifier.py
) else (
python -m PyInstaller --onefile --noconsole ^
--name NotifyPulse ^
%DATA_ARGS% ^
notifier.py
)
if errorlevel 1 (
echo.
echo ERROR: Build failed. Check output above.
pause & exit /b 1
)
REM ── 4. Copy assets to dist ────────────────────────────────────────────────────
echo.
echo [4/4] Finalizing dist folder...
if not exist dist\pwa mkdir dist\pwa
if not exist dist\ui mkdir dist\ui
xcopy /E /Y /Q pwa dist\pwa >nul 2>&1
xcopy /E /Y /Q ui dist\ui >nul 2>&1
echo.
echo =====================================================
echo BUILD COMPLETE
echo.
echo Executable : dist\NotifyPulse.exe
echo PWA files : dist\pwa\
echo Web UI : dist\ui\
echo.
echo Run NotifyPulse.exe, then visit:
echo Desktop : http://localhost:5000
echo Mobile : http://^<your-lan-ip^>:5000/pwa
echo =====================================================
echo.
explorer dist
pause

57
make_ico.py Normal file
View File

@@ -0,0 +1,57 @@
"""Generate icon files for NotifyPulse from a photo (no more blue placeholder).
Drop your desired square-ish image in icon.png, then run: python make_ico.py
"""
from pathlib import Path
from PIL import Image
SIZES_PWA = (192, 512)
SIZES_ICO = (256, 128, 64, 48, 32, 16)
def load_base(img_path: Path) -> Image.Image:
if not img_path.exists():
raise FileNotFoundError(f"Source image not found: {img_path}")
img = Image.open(img_path).convert("RGBA")
# Centre-crop to square so the icon fills evenly.
w, h = img.size
side = min(w, h)
left = (w - side) // 2
top = (h - side) // 2
img = img.crop((left, top, left + side, top + side))
return img
def save_resized(img: Image.Image, size: int, dest: Path):
resized = img.resize((size, size), Image.Resampling.LANCZOS)
dest.parent.mkdir(parents=True, exist_ok=True)
resized.save(dest, format="PNG")
def main():
here = Path(__file__).parent
src = here / "icon.png"
base = load_base(src)
# PWA icons
for sz in SIZES_PWA:
save_resized(base, sz, here / "pwa" / f"icon-{sz}.png")
# Multi-resolution .ico for PyInstaller / Windows (uses same photo)
ico_frames = [base.resize((s, s), Image.Resampling.LANCZOS) for s in SIZES_ICO]
ico_path = here / "icon.ico"
ico_frames[0].save(
ico_path,
format="ICO",
sizes=[(s, s) for s in SIZES_ICO],
append_images=ico_frames[1:],
)
print("Icons generated from icon.png:")
print(" pwa/icon-192.png, pwa/icon-512.png")
print(f" {ico_path.name} (multi-size)")
if __name__ == "__main__":
main()

47
notifications.txt Normal file
View File

@@ -0,0 +1,47 @@
# ── NotifyPulse Configuration ─────────────────────────────────────────────────
#
# SETTINGS (lines starting with @)
# @name Goddess Lia <- shown in tray and in notifications
#
# NOTIFICATIONS
# Text | XX% -> picked randomly, weighted by percentage
# Text | HH:MM -> fires every day at that exact time (24h format)
# Lines without | get equal random weight
# Lines starting with # are comments
#
# ICON
# Drop a file called icon.png in this folder to use it as the app icon.
# It will show in the system tray and on notifications.
# ──────────────────────────────────────────────────────────────────────────────
@name Goddess Lia
@interval 2 5
Hey Little Baybe, I think you want to watch a video from Powerfullisten and pee yourself | 4%
Do 10 PushUPs | 20%
Get on your knees and bark like a dog 5 times | 30%
do 10 squats | 30%
Until next notification you can only crawl and keep your pacifier in at all times | 18%
Edge on your Wallpaper for 60seconds DONT CUM | 18%
Until next notification you must crawl everywhere on all fours | 20%
Get on your knees and lick the floor like a thirsty puppy 5 times | 23%
Drink a full glass of water out of a Bowl like a Dog | 12%
until next Notification you can only go to the toilet like a dog, You go in the shower and lift your leg | 20%
Change your Phones Wallpaper with the random generator | 20%
until next notification you hear WhiteGirl musik | 15%
until next Notification you wear your Pacifier | 20%
Rub yourself through your clothes for 30 seconds, DONT CUM | 20%
Rub yourself through your clothes for 30 seconds, DONT CUM, Moan like a Bitch | 15%
nothing | 20%
Stick your tongue out until the next notification | 20%
Stick your tongue out and pant like a happy dog for 30 seconds | 25%
change.wallpaper | 35%
show.overlay.10 | 20%
show.overlay.30 | 35%
show.overlay.15 | 10
# Time-based triggers (fire every day at HH:MM)
Change the Interval to 2-7 min | 09:45
Change the Interval to 2-5 min | 12:00
Take of the Bra | 14:00
Quit the Program | 14:10

1317
notifier.py Normal file

File diff suppressed because it is too large Load Diff

BIN
pwa/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 54 KiB

BIN
pwa/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 300 KiB

474
pwa/index.html Normal file
View File

@@ -0,0 +1,474 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width,initial-scale=1,viewport-fit=cover">
<meta name="theme-color" content="#0d0d0f">
<meta name="apple-mobile-web-app-capable" content="yes">
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<meta name="apple-mobile-web-app-title" content="NotifyPulse">
<link rel="apple-touch-icon" href="icon-192.png">
<link rel="manifest" href="/pwa/manifest.json">
<title>NotifyPulse</title>
<style>
:root {
--bg: #0d0d0f;
--card: #16161a;
--border: #2a2a32;
--accent: #4f8ef7;
--accent2: #7c5cfc;
--text: #e2e2e8;
--muted: #6b6b7b;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--mono: 'Cascadia Code','Consolas',monospace;
--safe-top: env(safe-area-inset-top, 0px);
--safe-bottom: env(safe-area-inset-bottom, 0px);
}
* { box-sizing: border-box; margin: 0; padding: 0; -webkit-tap-highlight-color: transparent; }
html { height: 100%; background: var(--bg); }
body {
min-height: 100%; min-height: -webkit-fill-available;
background: var(--bg); color: var(--text);
font-family: 'Segoe UI', system-ui, sans-serif;
padding: calc(var(--safe-top) + 18px) 16px calc(var(--safe-bottom) + 20px);
display: flex; flex-direction: column; gap: 14px;
}
/* Header */
header {
display: flex; align-items: center; justify-content: space-between;
padding: 4px 0 8px;
}
header h1 { font-size: 1.2rem; font-weight: 700; letter-spacing: .03em; color: var(--accent); }
.conn-badge {
display: flex; align-items: center; gap: 6px;
padding: 5px 12px; border-radius: 999px; font-size: .72rem; font-weight: 600;
border: 1px solid var(--border); background: var(--card); color: var(--muted);
transition: .3s;
}
.conn-badge .dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); transition: .3s; }
.conn-badge.ok { border-color: rgba(34,197,94,.35); color: var(--green); }
.conn-badge.ok .dot { background: var(--green); box-shadow: 0 0 6px var(--green); }
.conn-badge.err { border-color: rgba(239,68,68,.35); color: var(--red); }
.conn-badge.err .dot { background: var(--red); }
/* Cards */
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 16px; }
.card h2 {
font-size: .68rem; text-transform: uppercase; letter-spacing: .1em;
color: var(--muted); margin-bottom: 12px;
}
/* Stat grid */
.stat-grid { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
.stat { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 11px; }
.stat-label { font-size: 9px; color: var(--muted); font-family: var(--mono); letter-spacing: .07em; text-transform: uppercase; margin-bottom: 4px; }
.stat-val { font-size: 1rem; font-weight: 700; color: var(--text); }
.stat-val.green { color: var(--green); }
.stat-val.red { color: var(--red); }
.stat-val.yellow { color: var(--yellow); }
.stat-val.accent { color: var(--accent); }
/* Wallpaper tabs */
.wp-tabs { display: flex; gap: 6px; margin-bottom: 11px; }
.wp-tab {
flex: 1; padding: 8px 10px; border-radius: 8px; border: 1px solid var(--border);
background: transparent; color: var(--muted); font-size: .78rem; font-weight: 600;
cursor: pointer; transition: .15s; text-align: center; font-family: inherit;
}
.wp-tab.active { background: rgba(79,142,247,.12); border-color: var(--accent); color: var(--accent); }
/* Wallpaper preview */
.wp-frame {
border-radius: 10px; overflow: hidden; background: var(--bg);
border: 1px solid var(--border);
aspect-ratio: 9/16; max-height: 300px;
display: flex; align-items: center; justify-content: center; position: relative;
}
.wp-frame img { width: 100%; height: 100%; object-fit: cover; transition: opacity .4s; }
.wp-placeholder {
display: flex; flex-direction: column; align-items: center; gap: 8px;
color: var(--muted); font-size: .75rem;
}
.wp-placeholder svg { opacity: .4; }
/* Buttons */
.btn {
width: 100%; padding: 11px 14px; border: none; border-radius: 9px; cursor: pointer;
font-size: .85rem; font-weight: 600; font-family: inherit;
display: flex; align-items: center; justify-content: center; gap: 7px;
transition: opacity .15s, transform .1s;
}
.btn:active { opacity: .82; transform: scale(.98); }
.btn-primary { background: var(--accent); color: #fff; }
.btn-purple { background: var(--accent2); color: #fff; }
.btn-outline { background: transparent; border: 1px solid var(--border); color: var(--text); }
.btn-green { background: rgba(34,197,94,.12); border: 1px solid rgba(34,197,94,.3); color: var(--green); }
.btn-gap { display: flex; flex-direction: column; gap: 8px; margin-top: 10px; }
.btn-row2 { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
/* Install hint */
.install-hint {
background: rgba(79,142,247,.07); border: 1px solid rgba(79,142,247,.2);
border-radius: 10px; padding: 12px 14px; font-size: .78rem; line-height: 1.6; color: var(--muted);
}
.install-hint strong { color: var(--text); }
/* Toast */
.toast {
position: fixed; bottom: calc(var(--safe-bottom) + 20px); left: 50%;
transform: translateX(-50%) translateY(70px);
background: var(--card); border: 1px solid var(--border); border-radius: 9px;
padding: 9px 16px; font-size: .8rem; white-space: nowrap;
transition: .3s cubic-bezier(.34,1.56,.64,1); pointer-events: none; z-index: 999;
box-shadow: 0 4px 20px rgba(0,0,0,.5);
}
.toast.show { transform: translateX(-50%) translateY(0); }
.toast.ok { border-color: rgba(34,197,94,.4); color: var(--green); }
.toast.err { border-color: rgba(239,68,68,.4); color: var(--red); }
::-webkit-scrollbar { width: 4px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 2px; }
.splash-overlay {
position: fixed;
inset: 0;
background: linear-gradient(135deg, rgba(13,13,15,0.96), rgba(22,22,26,0.95));
display: flex;
align-items: center;
justify-content: center;
padding: calc(var(--safe-top) + 20px) 16px calc(var(--safe-bottom) + 20px);
z-index: 9999;
transition: opacity .75s ease;
}
.splash-overlay.hidden {
opacity: 0;
pointer-events: none;
}
.splash-overlay img {
position: absolute;
inset: 0;
width: 100%;
height: 100%;
object-fit: cover;
opacity: 0;
transition: opacity .6s ease;
}
.splash-overlay.loaded img {
opacity: 1;
}
.splash-credit {
position: relative;
z-index: 2;
font-size: .75rem;
letter-spacing: .25em;
text-transform: uppercase;
color: rgba(255,255,255,.85);
border: 1px solid rgba(255,255,255,.25);
padding: 6px 12px;
border-radius: 999px;
background: rgba(0,0,0,.35);
}
</style>
</head>
<body>
<div class="splash-overlay" id="splash">
<img id="splash-img" alt="NotifyPulse splash">
<div class="splash-credit" id="splash-credit">NotifyPulse</div>
</div>
<header>
<h1 id="app-title">NotifyPulse</h1>
<div class="conn-badge" id="conn-badge">
<div class="dot"></div>
<span id="conn-label">Connecting…</span>
</div>
</header>
<!-- Status -->
<div class="card">
<h2>Status</h2>
<div class="stat-grid">
<div class="stat">
<div class="stat-label">PC Connection</div>
<div class="stat-val green" id="stat-conn"></div>
</div>
<div class="stat">
<div class="stat-label">Last Heartbeat</div>
<div class="stat-val accent" id="stat-hb"></div>
</div>
<div class="stat">
<div class="stat-label">WPs Received</div>
<div class="stat-val" id="stat-wpcount">0</div>
</div>
<div class="stat">
<div class="stat-label">App Mode</div>
<div class="stat-val yellow" id="stat-mode">Browser</div>
</div>
</div>
</div>
<!-- Wallpapers -->
<div class="card">
<h2>Wallpapers</h2>
<div class="wp-tabs">
<button class="wp-tab active" id="tab-ls" onclick="switchTab('lockscreen')">🔒 Lock Screen</button>
<button class="wp-tab" id="tab-bg" onclick="switchTab('background')">🏠 Home Screen</button>
</div>
<div class="wp-frame" id="wp-frame">
<div class="wp-placeholder" id="wp-ph">
<svg width="28" height="28" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.5"><rect x="3" y="3" width="18" height="18" rx="2"/><circle cx="8.5" cy="8.5" r="1.5"/><polyline points="21 15 16 10 5 21"/></svg>
<span>Waiting for wallpaper…</span>
</div>
<img id="wp-img" style="display:none;opacity:0" alt="Wallpaper">
</div>
<div class="btn-gap">
<button class="btn btn-primary" id="save-btn" onclick="saveWallpaper()" disabled>
↓ Save to Photos
</button>
<div class="btn-row2">
<button class="btn btn-outline" onclick="requestBothWallpapers()">↺ Request Both</button>
<button class="btn btn-outline" onclick="requestActiveWallpaper()">↺ Request This</button>
</div>
</div>
</div>
<!-- iOS install hint -->
<div class="install-hint" id="install-hint" style="display:none">
<strong>Install as App (iOS)</strong><br>
Tap <strong>Share</strong><strong>"Add to Home Screen"</strong> for the best experience and background wallpaper saving.
</div>
<div class="toast" id="toast"></div>
<script>
const CLIENT_ID = (() => {
let id = localStorage.getItem('np_cid');
if (!id) { id = 'pwa_' + Math.random().toString(36).slice(2,10); localStorage.setItem('np_cid', id); }
return id;
})();
const BASE = window.location.origin;
const APP_NAME_KEY = 'np_app_name';
const splashEl = document.getElementById('splash');
const splashImg = document.getElementById('splash-img');
let splashTimeout;
function applyAppName(name) {
if (!name || !name.trim()) return;
const appName = name.trim();
document.getElementById('app-title').textContent = appName;
document.title = appName;
const splashCredit = document.getElementById('splash-credit');
if (splashCredit) splashCredit.textContent = appName;
if (splashImg) splashImg.alt = `${appName} splash`;
const appleTitle = document.querySelector('meta[name="apple-mobile-web-app-title"]');
if (appleTitle) appleTitle.setAttribute('content', appName);
try { localStorage.setItem(APP_NAME_KEY, appName); } catch(e) {}
}
function applyCachedAppName() {
try {
const cachedName = localStorage.getItem(APP_NAME_KEY);
if (cachedName) applyAppName(cachedName);
} catch(e) {}
}
function hideSplash(delay = 2000) {
if (!splashEl) return;
clearTimeout(splashTimeout);
splashTimeout = setTimeout(() => {
splashEl.classList.add('hidden');
}, delay);
}
async function loadSplashImage() {
if (!splashEl || !splashImg) { hideSplash(0); return; }
try {
const resp = await fetch(`${BASE}/api/pwa/splash_image`);
const data = await resp.json();
if (data.image) {
splashImg.src = data.image;
splashEl.classList.add('loaded');
}
} catch (e) {
console.warn('Splash fetch failed', e);
}
hideSplash();
}
if (splashEl) {
splashEl.addEventListener('transitionend', () => {
if (splashEl.classList.contains('hidden')) {
splashEl.style.display = 'none';
}
});
}
let connected = false;
let activeTab = 'lockscreen';
let wallpapers = { lockscreen: null, background: null };
let wpCount = parseInt(localStorage.getItem('np_wpc') || '0');
// Restore saved wallpapers
try {
const saved = JSON.parse(localStorage.getItem('np_wps') || '{}');
if (saved.lockscreen) wallpapers.lockscreen = saved.lockscreen;
if (saved.background) wallpapers.background = saved.background;
} catch(e) {}
// Service worker
if ('serviceWorker' in navigator) {
navigator.serviceWorker.register('/pwa/sw.js', { scope: '/pwa/' }).catch(e => console.warn('SW:', e));
}
// iOS / standalone detection
const isIOS = /iphone|ipad|ipod/i.test(navigator.userAgent);
const isStandalone = window.navigator.standalone === true || window.matchMedia('(display-mode: standalone)').matches;
document.getElementById('stat-mode').textContent = isStandalone ? 'Installed PWA' : 'Browser';
document.getElementById('stat-mode').className = 'stat-val ' + (isStandalone ? 'green' : 'yellow');
if (isIOS && !isStandalone) document.getElementById('install-hint').style.display = 'block';
// Fetch app name and sync
async function fetchAppName() {
try {
const r = await fetch(BASE + '/api/pwa/app_name', { cache: 'no-store' });
const d = await r.json();
if (d.app_name) {
applyAppName(d.app_name);
return;
}
} catch(e) {}
try {
const r = await fetch(BASE + '/api/state', { cache: 'no-store' });
const d = await r.json();
if (d.app_name) {
applyAppName(d.app_name);
}
} catch(e) {}
}
// Ping / heartbeat
async function ping() {
try {
const r = await fetch(BASE + '/api/pwa/ping', {
method: 'POST', headers: {'Content-Type':'application/json'},
body: JSON.stringify({ client_id: CLIENT_ID })
});
const d = await r.json();
if (d.ok) {
setConn(true);
const t = new Date().toLocaleTimeString();
document.getElementById('stat-hb').textContent = t;
}
} catch(e) { setConn(false); }
}
function setConn(v) {
connected = v;
const badge = document.getElementById('conn-badge');
badge.className = 'conn-badge ' + (v ? 'ok' : 'err');
document.getElementById('conn-label').textContent = v ? 'Connected' : 'Offline';
document.getElementById('stat-conn').textContent = v ? 'Connected ✓' : 'Disconnected';
document.getElementById('stat-conn').className = 'stat-val ' + (v ? 'green' : 'red');
}
// Wallpaper poll
async function pollWallpaper() {
if (!connected) return;
try {
const r = await fetch(`${BASE}/api/pwa/wallpaper?client_id=${encodeURIComponent(CLIENT_ID)}`);
const d = await r.json();
if (d.pending) {
if (d.lockscreen) wallpapers.lockscreen = d.lockscreen;
if (d.background) wallpapers.background = d.background;
wpCount++;
localStorage.setItem('np_wpc', wpCount);
try { localStorage.setItem('np_wps', JSON.stringify(wallpapers)); } catch(e){}
document.getElementById('stat-wpcount').textContent = wpCount;
refreshPreview();
toast('📱 New wallpaper received!', 'ok');
}
} catch(e) {}
}
function switchTab(t) {
activeTab = t;
document.getElementById('tab-ls').classList.toggle('active', t === 'lockscreen');
document.getElementById('tab-bg').classList.toggle('active', t === 'background');
refreshPreview();
}
function refreshPreview() {
const src = wallpapers[activeTab];
const img = document.getElementById('wp-img');
const ph = document.getElementById('wp-ph');
const btn = document.getElementById('save-btn');
if (src) {
img.style.display = 'block'; img.style.opacity = '0';
img.onload = () => { img.style.opacity = '1'; };
img.src = src; ph.style.display = 'none'; btn.disabled = false;
} else {
img.style.display = 'none'; ph.style.display = 'flex'; btn.disabled = true;
}
}
async function saveWallpaper() {
const src = wallpapers[activeTab];
if (!src) { toast('No image to save', 'err'); return; }
const btn = document.getElementById('save-btn');
btn.disabled = true; btn.textContent = 'Saving…';
try {
const res = await fetch(src);
const blob = await res.blob();
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url; a.download = `notifypulse-${activeTab}.jpg`;
document.body.appendChild(a); a.click(); document.body.removeChild(a);
setTimeout(() => URL.revokeObjectURL(url), 5000);
toast(isIOS ? 'Long-press image → Save to Photos' : '✓ Download started!', 'ok');
} catch(e) { toast('Save failed', 'err'); }
btn.textContent = '↓ Save to Photos'; btn.disabled = false;
}
async function requestBothWallpapers() {
try { await fetch(BASE + '/api/pwa/trigger_wallpaper', {method:'POST'}); toast('Requesting both wallpapers…', 'ok'); }
catch(e) { toast('Could not reach PC', 'err'); }
}
async function requestActiveWallpaper() {
try {
await fetch(BASE + '/api/pwa/trigger_wallpaper', {
method:'POST', headers:{'Content-Type':'application/json'},
body: JSON.stringify({ type: activeTab })
});
toast('Requesting ' + activeTab + ' wallpaper…', 'ok');
} catch(e) { toast('Could not reach PC', 'err'); }
}
// Toast
let toastTimer;
function toast(msg, type = '') {
const el = document.getElementById('toast');
el.textContent = msg;
el.className = 'toast show ' + type;
clearTimeout(toastTimer);
toastTimer = setTimeout(() => { el.className = 'toast ' + type; }, 2800);
}
// Boot
document.getElementById('stat-wpcount').textContent = wpCount;
refreshPreview();
applyCachedAppName();
fetchAppName();
ping();
setInterval(ping, 10000);
setInterval(pollWallpaper, 4000);
setTimeout(pollWallpaper, 1500);
loadSplashImage();
</script>
</body>
</html>

15
pwa/manifest.json Normal file
View File

@@ -0,0 +1,15 @@
{
"name": "NotifyPulse",
"short_name": "NotifyPulse",
"description": "Wallpaper & notification receiver for NotifyPulse desktop",
"start_url": "/pwa/",
"scope": "/pwa/",
"display": "standalone",
"background_color": "#0d0d0f",
"theme_color": "#0d0d0f",
"orientation": "portrait",
"icons": [
{ "src": "icon-192.png", "sizes": "192x192", "type": "image/png", "purpose": "any maskable" },
{ "src": "icon-512.png", "sizes": "512x512", "type": "image/png", "purpose": "any maskable" }
]
}

39
pwa/sw.js Normal file
View File

@@ -0,0 +1,39 @@
// NotifyPulse PWA Service Worker v3
const CACHE = 'notifypulse-v3';
const STATIC = ['/pwa/', '/pwa/index.html'];
self.addEventListener('install', e => {
e.waitUntil(
caches.open(CACHE).then(c => c.addAll(STATIC).catch(() => {}))
);
self.skipWaiting();
});
self.addEventListener('activate', e => {
e.waitUntil(
caches.keys().then(keys =>
Promise.all(keys.filter(k => k !== CACHE).map(k => caches.delete(k)))
)
);
self.clients.claim();
});
self.addEventListener('fetch', e => {
const url = new URL(e.request.url);
// Never cache API calls
if (url.pathname.startsWith('/api/')) return;
e.respondWith(
caches.match(e.request).then(r => r || fetch(e.request).then(res => {
if (res.ok && e.request.method === 'GET') {
const clone = res.clone();
caches.open(CACHE).then(c => c.put(e.request, clone));
}
return res;
}).catch(() => caches.match('/pwa/')))
);
});
// Handle messages from main thread
self.addEventListener('message', e => {
if (e.data === 'skipWaiting') self.skipWaiting();
});

8
requirements.txt Normal file
View File

@@ -0,0 +1,8 @@
keyboard==0.13.5
winotify==1.1.0
pystray==0.19.5
Pillow>=10.0.0
schedule==1.2.2
flask>=3.0.0
flask-cors>=4.0.0
pyinstaller>=6.0.0

610
ui/index.html Normal file
View File

@@ -0,0 +1,610 @@
<!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: #4f8ef7;
--accent2: #7c5cfc;
--text: #e2e2e8;
--muted: #6b6b7b;
--green: #22c55e;
--red: #ef4444;
--yellow: #f59e0b;
--mono: 'Cascadia Code', 'Consolas', monospace;
}
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: 16px 28px; border-bottom: 1px solid var(--border); background: var(--card);
}
header h1 { font-size: 1.25rem; font-weight: 700; letter-spacing: .03em; color: var(--accent); }
.header-right { display: flex; align-items: center; gap: 10px; }
.pwa-pill {
display: flex; align-items: center; gap: 6px; font-family: var(--mono); font-size: 11px;
background: var(--bg); border: 1px solid var(--border); padding: 4px 10px; border-radius: 20px; color: var(--muted);
}
.pwa-dot { width: 7px; height: 7px; border-radius: 50%; background: var(--muted); transition: .3s; }
.pwa-dot.on { background: var(--green); box-shadow: 0 0 6px var(--green); }
.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: 1100px; margin: 0 auto; padding: 28px 20px; display: grid; gap: 20px; grid-template-columns: 1fr 1fr; }
@media(max-width: 700px) { main { grid-template-columns: 1fr; } }
.full-width { grid-column: 1 / -1; }
.card { background: var(--card); border: 1px solid var(--border); border-radius: 12px; padding: 20px; }
.card h2 { font-size: .72rem; text-transform: uppercase; letter-spacing: .1em; color: var(--muted); margin-bottom: 14px; }
#countdown-ring { display: flex; align-items: center; justify-content: center; flex-direction: column; padding: 8px 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.5rem; font-weight: 700; line-height: 1; color: var(--accent); }
.ring-label .small { font-size: .65rem; color: var(--muted); margin-top: 3px; }
#next-label { margin-top: 10px; font-size: .8rem; color: var(--muted); text-align: center; }
.stat-row { display: grid; grid-template-columns: repeat(3,1fr); gap: 10px; margin-top: 14px; }
.stat { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 12px; }
.stat-label { font-size: 10px; color: var(--muted); font-family: var(--mono); letter-spacing: .06em; margin-bottom: 4px; }
.stat-val { font-size: 1.2rem; font-weight: 700; color: var(--text); }
.stat-val.green { color: var(--green); }
.stat-val.red { color: var(--red); }
.stat-val.accent { color: var(--accent); }
.btn { flex: 1; padding: 9px 14px; border: none; border-radius: 8px; cursor: pointer; font-size: .83rem; font-weight: 600; transition: opacity .15s, transform .1s; display: inline-flex; align-items: center; gap: 5px; justify-content: center; }
.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); }
.btn-row { display: flex; gap: 8px; flex-wrap: wrap; }
.field { margin-bottom: 13px; }
.field:last-child { margin-bottom: 0; }
.field label { display: block; font-size: .72rem; color: var(--muted); font-family: var(--mono); margin-bottom: 5px; letter-spacing: .04em; }
.field input[type=text], .field input[type=number], .field textarea {
width: 100%; padding: 8px 11px; background: var(--bg); border: 1px solid var(--border);
border-radius: 7px; color: var(--text); font-size: .85rem; outline: none; transition: border-color .15s;
}
.field input:focus, .field textarea:focus { border-color: var(--accent); }
.field textarea { resize: vertical; min-height: 160px; font-family: var(--mono); font-size: .78rem; line-height: 1.55; }
.two-col { display: grid; grid-template-columns: 1fr 1fr; gap: 10px; }
.save-msg { font-size: .72rem; color: var(--green); min-height: 14px; margin-top: 5px; font-family: var(--mono); }
.save-msg.err { color: var(--red); }
.toggle-row { display: flex; align-items: center; justify-content: space-between; padding: 9px 11px; background: var(--bg); border: 1px solid var(--border); border-radius: 7px; margin-bottom: 8px; }
.toggle-row:last-child { margin-bottom: 0; }
.toggle-label { font-size: .83rem; color: var(--text); }
.toggle { position: relative; width: 38px; height: 21px; flex-shrink: 0; }
.toggle input { opacity: 0; width: 0; height: 0; }
.slider { position: absolute; inset: 0; background: var(--border); border-radius: 11px; cursor: pointer; transition: .2s; }
.slider:before { content: ''; position: absolute; width: 15px; height: 15px; top: 3px; left: 3px; background: #fff; border-radius: 50%; transition: .2s; }
.toggle input:checked + .slider { background: var(--accent); }
.toggle input:checked + .slider:before { transform: translateX(17px); }
input[type=range] { -webkit-appearance: none; height: 4px; border-radius: 2px; background: var(--border); outline: none; width: 100%; margin-top: 6px; }
input[type=range]::-webkit-slider-thumb { -webkit-appearance: none; width: 16px; height: 16px; border-radius: 50%; background: var(--accent); cursor: pointer; }
.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-tag { flex-shrink: 0; padding: 2px 7px; border-radius: 4px; font-size: .7rem; font-family: var(--mono); font-weight: 600; }
.tag-pct { background: rgba(79,142,247,.15); color: var(--accent); }
.tag-time { background: rgba(245,158,11,.15); color: var(--yellow); }
.tag-special{ background: rgba(124,92,252,.15); color: var(--accent2); }
.prog-big { font-size: 2.4rem; font-weight: 700; color: var(--accent); line-height: 1; }
.prog-sub { font-size: .75rem; color: var(--muted); margin-top: 4px; }
.prog-row { display: flex; gap: 14px; margin-top: 14px; flex-wrap: wrap; }
.prog-cell { flex: 1; min-width: 80px; 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: .07em; }
.prog-cell .val { font-size: 1.15rem; font-weight: 700; color: var(--text); margin-top: 2px; }
.log-list { list-style: none; max-height: 240px; overflow-y: auto; }
.log-list li { display: grid; grid-template-columns: 52px 1fr; gap: 10px; padding: 6px 0; border-bottom: 1px solid var(--border); font-size: .78rem; }
.log-list li:last-child { border-bottom: none; }
.log-time { color: var(--muted); font-family: var(--mono); }
.log-msg { color: var(--text); }
.log-msg.error { color: var(--red); }
.log-msg.warn { color: var(--yellow); }
.card-heading { display:flex; align-items:center; justify-content:space-between; gap:12px; }
.display-mode-toggle { display:flex; gap:5px; align-items:center; }
.toggle-pill { border:none; border-radius:999px; padding:4px 10px; font-size:.7rem; font-weight:600; background: var(--bg); color: var(--muted); border:1px solid var(--border); cursor:pointer; transition:.2s; }
.toggle-pill.active { background: var(--accent); color:#fff; border-color: var(--accent); }
.pwa-url-box { background: var(--bg); border: 1px solid var(--border); border-radius: 8px; padding: 10px 14px; margin-bottom: 12px; }
.pwa-url-label { font-size: 10px; color: var(--muted); font-family: var(--mono); margin-bottom: 4px; }
.pwa-url-val { font-family: var(--mono); font-size: .82rem; color: var(--accent); word-break: break-all; }
.divider { border: none; border-top: 1px solid var(--border); margin: 16px 0; }
::-webkit-scrollbar { width: 5px; }
::-webkit-scrollbar-track { background: transparent; }
::-webkit-scrollbar-thumb { background: var(--border); border-radius: 3px; }
body.is-paused header, body.is-paused main { display: none; }
</style>
</head>
<body>
<header>
<h1 id="app-name">NotifyPulse</h1>
<div class="header-right">
<div class="pwa-pill">
<div class="pwa-dot" id="pwa-dot"></div>
<span id="pwa-count">0 PWA</span>
</div>
<div id="status-pill" class="status-pill running">
<div class="dot"></div>
<span id="status-text">Running</span>
</div>
</div>
</header>
<main>
<!-- Countdown -->
<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-time">--</span>
<span class="small" id="cd-label"></span>
</div>
</div>
<div id="next-label">Loading…</div>
</div>
<div class="stat-row">
<div class="stat">
<div class="stat-label">STATUS</div>
<div class="stat-val green" id="s-status">Running</div>
</div>
<div class="stat">
<div class="stat-label">INTERVAL</div>
<div class="stat-val accent" id="s-interval"></div>
</div>
<div class="stat">
<div class="stat-label">ENTRIES</div>
<div class="stat-val" id="s-entries"></div>
</div>
</div>
</div>
<!-- Controls -->
<div class="card">
<h2>Controls</h2>
<div class="btn-row">
<button class="btn btn-primary" onclick="togglePause()" id="pause-btn" style="flex:2">⏸ Pause / Resume</button>
<button class="btn btn-outline" onclick="fireNow()" style="flex:1">⚡ Fire Now</button>
</div>
<div style="margin-top:16px; border-top:1px solid var(--border); padding-top:14px">
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px">🖥️ Desktop Tests</div>
<div class="btn-row">
<button class="btn btn-outline" onclick="testNotif()">🔔 Toast</button>
<button class="btn btn-outline" onclick="testWallpaper()">🖼️ Wallpaper</button>
<button class="btn btn-outline" onclick="testOverlay()">✨ Overlay</button>
</div>
</div>
<div style="margin-top:14px; border-top:1px solid var(--border); padding-top:14px">
<div style="font-size:.68rem;text-transform:uppercase;letter-spacing:.1em;color:var(--muted);margin-bottom:10px">📱 Mobile Tests</div>
<div class="btn-row">
<button class="btn btn-outline" onclick="testMobileLockscreen()">🔒 Lockscreen WP</button>
<button class="btn btn-outline" onclick="testMobileBackground()">🏠 Home WP</button>
</div>
<div class="btn-row" style="margin-top:8px">
<button class="btn btn-outline" onclick="testMobileWallpaper()">📱 Both WPs</button>
<button class="btn btn-outline" onclick="testMobileNotif()">📳 Push Ping</button>
</div>
</div>
<hr class="divider">
<h2 style="margin-bottom:12px">Mobile PWA</h2>
<div class="pwa-url-box">
<div class="pwa-url-label">PWA URL — open on your phone (same network)</div>
<div class="pwa-url-val" id="pwa-url">Loading…</div>
</div>
<div class="stat" style="margin-top:10px">
<div class="stat-label">CONNECTED CLIENTS</div>
<div class="stat-val" id="client-count">0</div>
</div>
</div>
<!-- Settings -->
<div class="card">
<h2>Settings</h2>
<div class="two-col">
<div class="field">
<label>APP NAME</label>
<input id="s-name" type="text" placeholder="NotifyPulse" oninput="markDirty()">
</div>
<div class="field">
<label>HOTKEY</label>
<input id="s-hotkey" type="text" placeholder="F13" oninput="markDirty()">
</div>
<div class="field">
<label>MIN INTERVAL (min)</label>
<input id="s-min" type="number" min="1" placeholder="10" oninput="markDirty()">
</div>
<div class="field">
<label>MAX INTERVAL (min)</label>
<input id="s-max" type="number" min="1" placeholder="30" oninput="markDirty()">
</div>
</div>
<div style="margin: 14px 0 12px; border-top: 1px solid var(--border); padding-top: 12px;">
<div class="toggle-row">
<span class="toggle-label">Startup toast</span>
<label class="toggle"><input type="checkbox" id="t-startup"><span class="slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Notification sound</span>
<label class="toggle"><input type="checkbox" id="t-sound"><span class="slider"></span></label>
</div>
<div class="toggle-row">
<span class="toggle-label">Auto-open browser on start</span>
<label class="toggle"><input type="checkbox" id="t-browser"><span class="slider"></span></label>
</div>
</div>
<button class="btn btn-primary" onclick="saveSettings()" style="width:100%">Save Settings</button>
<div class="save-msg" id="save-msg"></div>
</div>
<!-- Overlay + Advanced -->
<div class="card">
<h2>Overlay Settings</h2>
<div class="two-col">
<div class="field">
<label>DEFAULT DURATION (sec)</label>
<input id="s-ovr-dur" type="number" min="1" max="300" oninput="markDirty()">
</div>
<div class="field">
<label>OPACITY — <span id="opacity-val">0.4</span></label>
<input type="range" id="s-ovr-opacity" min="0.05" max="1" step="0.05"
oninput="document.getElementById('opacity-val').textContent=this.value; markDirty()">
</div>
</div>
<div class="toggle-row" style="margin-top:10px">
<span class="toggle-label">Stretch to fill (vs letterbox fit)</span>
<label class="toggle"><input type="checkbox" id="t-stretch" onchange="markDirty()"><span class="slider"></span></label>
</div>
<div class="btn-row" style="margin-top:12px">
<button class="btn btn-primary" onclick="saveSettings()">Save</button>
<button class="btn btn-outline" onclick="testOverlay()">Preview Overlay</button>
</div>
<div class="save-msg" id="save-msg2"></div>
<hr class="divider">
<h2 style="margin-bottom:12px">Advanced</h2>
<div class="field">
<label>PWA AUTH TOKEN (optional)</label>
<input type="text" id="s-token" placeholder="leave empty = no auth" oninput="markDirty()">
</div>
<div class="btn-row" style="margin-top:8px">
<button class="btn btn-danger" onclick="if(confirm('Reset all settings to defaults?'))resetSettings()">Reset to Defaults</button>
</div>
</div>
<!-- Active Entries -->
<div class="card">
<div class="card-heading">
<h2>Active Entries</h2>
<div class="display-mode-toggle" id="display-mode-toggle">
<button type="button" class="toggle-pill active" data-mode="percent">Percent</button>
<button type="button" class="toggle-pill" data-mode="chance">Chance</button>
</div>
</div>
<ul class="entry-list" id="entry-list"></ul>
</div>
<!-- Prognosis -->
<div class="card">
<h2>Prognosis — next hour</h2>
<div style="display:flex;align-items:flex-end;gap:14px;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:5px;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>
<!-- Editor -->
<div class="card full-width">
<h2>Edit notifications.txt</h2>
<div class="pwa-url-box" style="margin-bottom:10px">
<div class="pwa-url-label">ACTIVE CONFIG FILE</div>
<div class="pwa-url-val" id="config-path">-</div>
</div>
<div class="btn-row" style="margin-bottom:10px">
<button class="btn btn-primary" style="flex:0" onclick="saveEditor()">💾 Save</button>
<button class="btn btn-outline" style="flex:0" onclick="loadEditor()">↺ Reload</button>
<span class="save-msg" id="editor-msg" style="margin-top:0;align-self:center"></span>
</div>
<div class="field" style="margin-bottom:0">
<textarea id="editor-textarea" spellcheck="false" placeholder="One notification per line."></textarea>
</div>
</div>
<!-- Log -->
<div class="card full-width">
<div style="display:flex;align-items:center;justify-content:space-between;margin-bottom:12px">
<h2 style="margin-bottom:0">Log</h2>
<button class="btn btn-outline" style="flex:0;padding:4px 10px;font-size:.7rem" onclick="clearLogUI()">Clear</button>
</div>
<ul class="log-list" id="log-list"></ul>
</div>
</main>
<script>
let state = {};
let _logCache = [];
let totalInterval = 0;
let settingsDirty = false;
const ENTRY_DISPLAY_MODE_KEY = 'entryDisplayMode';
let entryDisplayMode = localStorage.getItem(ENTRY_DISPLAY_MODE_KEY) || 'percent';
function markDirty() { settingsDirty = true; }
function setEntryDisplayMode(mode) {
if (mode !== 'percent' && mode !== 'chance') return;
if (entryDisplayMode === mode) return;
entryDisplayMode = mode;
localStorage.setItem(ENTRY_DISPLAY_MODE_KEY, mode);
updateDisplayModeToggleButtons();
updateDisplayModeToggleButtons();
renderEntries(state.entries || []);
}
function updateDisplayModeToggleButtons() {
const toggle = document.getElementById('display-mode-toggle');
if (!toggle) return;
toggle.querySelectorAll('[data-mode]').forEach(btn => {
btn.classList.toggle('active', btn.dataset.mode === entryDisplayMode);
});
}
function initDisplayModeToggle() {
const toggle = document.getElementById('display-mode-toggle');
if (!toggle) return;
toggle.addEventListener('click', (event) => {
const btn = event.target.closest('[data-mode]');
if (!btn) return;
setEntryDisplayMode(btn.dataset.mode);
});
updateDisplayModeToggleButtons();
}
async function fetchState() {
try {
const r = await fetch('/api/state');
state = await r.json();
_logCache = state.log || [];
updateUI();
} catch(e) {}
}
function updateUI() {
const paused = state.paused;
const appName = state.app_name || 'NotifyPulse';
document.getElementById('app-name').textContent = appName;
document.title = paused ? 'Paused' : appName;
document.body.classList.toggle('is-paused', paused);
const pill = document.getElementById('status-pill');
pill.className = 'status-pill ' + (paused ? 'paused' : 'running');
document.getElementById('status-text').textContent = paused ? 'Paused' : 'Running';
document.getElementById('s-status').textContent = paused ? 'Paused' : 'Running';
document.getElementById('s-status').className = 'stat-val ' + (paused ? 'red' : 'green');
document.getElementById('s-interval').textContent = (state.min_interval||'?') + '' + (state.max_interval||'?') + ' min';
document.getElementById('s-entries').textContent = (state.entries||[]).length;
const pwa = state.pwa_clients || 0;
document.getElementById('pwa-count').textContent = pwa + ' PWA';
document.getElementById('pwa-dot').classList.toggle('on', pwa > 0);
document.getElementById('client-count').textContent = pwa;
document.getElementById('config-path').textContent = state.config_path || '-';
const host = window.location.hostname;
const port = window.location.port || 5000;
document.getElementById('pwa-url').textContent = 'http://' + host + ':' + port + '/pwa';
// Countdown ring
const now = Date.now() / 1000;
const secsLeft = Math.max(0, (state.next_fire_at || 0) - now);
totalInterval = state.total_interval || ((state.max_interval || 30) * 60);
const pct = totalInterval > 0 ? secsLeft / totalInterval : 1;
document.getElementById('arc').style.strokeDashoffset = 345.4 * (1 - pct);
const m = Math.floor(secsLeft / 60);
const s = Math.floor(secsLeft % 60);
document.getElementById('cd-time').textContent = paused ? '--' : (m + 'm ' + String(s).padStart(2,'0') + 's');
document.getElementById('next-label').textContent = paused
? 'Paused — notifications suspended'
: 'Next in ' + m + 'm ' + String(s).padStart(2,'0') + 's';
document.getElementById('pause-btn').textContent = paused ? '▶ Resume' : '⏸ Pause';
if (!settingsDirty) {
const s2 = state.settings || {};
setVal('s-name', s2.name || state.app_name || '');
setVal('s-hotkey', s2.hotkey || 'F13');
setVal('s-min', state.min_interval || 10);
setVal('s-max', state.max_interval || 30);
setVal('s-ovr-dur', s2.overlay_duration || 6);
setVal('s-ovr-opacity', s2.overlay_opacity || 0.4);
document.getElementById('opacity-val').textContent = s2.overlay_opacity || 0.4;
setVal('s-token', s2.pwa_token || '');
setCheck('t-startup', s2.startup_toast !== false);
setCheck('t-sound', s2.notify_sound !== false);
setCheck('t-browser', s2.auto_open_browser !== false);
setCheck('t-stretch', !!s2.overlay_stretch);
}
renderEntries(state.entries || []);
updatePrognosis(state.min_interval, state.max_interval, state.entries || []);
renderLog();
}
function renderEntries(entries) {
const totalWeight = entries.filter(e => e.weight != null).reduce((s,e) => s + e.weight, 0);
document.getElementById('entry-list').innerHTML = entries.map(e => {
let tag = '';
if (e.trigger_time) {
tag = '<span class="entry-tag tag-time">⏰ ' + e.trigger_time + '</span>';
} else if (/wallpaper|overlay/i.test(e.text)) {
tag = '<span class="entry-tag tag-special">' + (/wallpaper/i.test(e.text) ? '🖼 wallpaper' : '✨ overlay') + '</span>';
} else if (e.weight != null) {
const weight = Number(e.weight);
if (totalWeight > 0 && weight > 0) {
if (entryDisplayMode === 'chance') {
const chance = totalWeight / weight;
tag = '<span class="entry-tag tag-pct">' + formatChanceLabel(chance) + '</span>';
} else {
const pct = weight / totalWeight * 100;
tag = '<span class="entry-tag tag-pct">' + pct.toFixed(1) + '%</span>';
}
}
}
return '<li><span>' + escHtml(e.text) + '</span>' + tag + '</li>';
}).join('');
}
function formatChanceLabel(value) {
if (!isFinite(value) || value <= 0) return '1 in ?';
if (value < 2) return '1 in 1';
if (value < 10) return '1 in ' + value.toFixed(1);
return '1 in ' + Math.round(value);
}
function updatePrognosis(minInt, maxInt, entries) {
const avg = ((minInt||10) + (maxInt||30)) / 2;
const rph = 60 / avg;
const now = new Date();
const nowMins = now.getHours() * 60 + now.getMinutes();
const endMins = nowMins + 60;
let timed = 0;
for (const e of entries) {
if (e.trigger_time) {
const [hh,mm] = e.trigger_time.split(':').map(Number);
const em = hh * 60 + mm;
if (endMins >= 1440) { if (em >= nowMins || em < endMins - 1440) timed++; }
else { if (em >= nowMins && em < endMins) timed++; }
}
}
document.getElementById('prog-total').textContent = (rph + timed).toFixed(1);
document.getElementById('prog-random').textContent = rph.toFixed(1);
document.getElementById('prog-timed').textContent = timed;
document.getElementById('prog-range').textContent =
Math.floor(60/(maxInt||30)) + '' + Math.ceil(60/(minInt||10)) + ' random + ' + timed + ' timed';
}
function renderLog() {
document.getElementById('log-list').innerHTML = (_logCache||[]).map(e =>
'<li><span class="log-time">' + e.time + '</span><span class="log-msg ' + (e.level||'info') + '">' + escHtml(e.msg) + '</span></li>'
).join('');
}
function clearLogUI() { _logCache = []; renderLog(); }
async function togglePause() {
const r = await fetch('/api/pause', {method:'POST'});
const d = await r.json();
document.body.classList.toggle('is-paused', d.paused);
document.getElementById('status-pill').className = 'status-pill ' + (d.paused ? 'paused' : 'running');
document.getElementById('status-text').textContent = d.paused ? 'Paused' : 'Running';
document.getElementById('pause-btn').textContent = d.paused ? '▶ Resume' : '⏸ Pause';
fetchState();
}
async function fireNow() { await fetch('/api/fire_now',{method:'POST'}); fetchState(); }
async function testNotif() { await fetch('/api/test_notification',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'Test from Web UI!'})}); }
async function testWallpaper() { await fetch('/api/test_wallpaper',{method:'POST'}); }
async function testOverlay() { await fetch('/api/test_overlay',{method:'POST'}); }
async function testMobileWallpaper() { await fetch('/api/test_mobile_wallpaper',{method:'POST'}); }
async function testMobileLockscreen(){ await fetch('/api/test_mobile_wallpaper',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'lockscreen'})}); }
async function testMobileBackground(){ await fetch('/api/test_mobile_wallpaper',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({type:'background'})}); }
async function testMobileNotif() { await fetch('/api/test_notification',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({message:'Mobile ping test!',mobile:true})}); }
async function loadEditor() {
try { const r = await fetch('/api/entries'); const d = await r.json(); document.getElementById('editor-textarea').value = d.content||''; } catch(e){}
}
async function saveEditor() {
const msg = document.getElementById('editor-msg');
try {
const r = await fetch('/api/entries',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify({content:document.getElementById('editor-textarea').value})});
const d = await r.json();
msg.textContent = d.ok ? '✓ Saved' : '✗ Error'; msg.className='save-msg'+(d.ok?'':' err');
setTimeout(()=>{msg.textContent='';},3000);
if (d.ok) fetchState();
} catch(e){ msg.textContent='✗ Network error'; msg.className='save-msg err'; }
}
async function saveSettings() {
const payload = {
name: getVal('s-name'), hotkey: getVal('s-hotkey'),
min_interval: parseInt(getVal('s-min')), max_interval: parseInt(getVal('s-max')),
overlay_duration: parseInt(getVal('s-ovr-dur')), overlay_opacity: parseFloat(getVal('s-ovr-opacity')),
overlay_stretch: getCheck('t-stretch'), startup_toast: getCheck('t-startup'),
notify_sound: getCheck('t-sound'), auto_open_browser: getCheck('t-browser'),
pwa_token: getVal('s-token'),
};
try {
const r = await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},body:JSON.stringify(payload)});
const d = await r.json();
['save-msg','save-msg2'].forEach(id=>{
const el=document.getElementById(id);
if(el){el.textContent=d.ok?'✓ Saved!':'✗ Error';el.className='save-msg'+(d.ok?'':' err');}
});
settingsDirty = false;
setTimeout(()=>['save-msg','save-msg2'].forEach(id=>{const el=document.getElementById(id);if(el)el.textContent='';}),2500);
fetchState();
} catch(e){}
}
async function resetSettings() {
await fetch('/api/settings',{method:'POST',headers:{'Content-Type':'application/json'},
body:JSON.stringify({overlay_stretch:false,overlay_opacity:0.4,overlay_duration:6,startup_toast:true,notify_sound:true,auto_open_browser:true})});
settingsDirty=false; fetchState();
}
function setVal(id,v){const el=document.getElementById(id);if(el)el.value=v;}
function setCheck(id,v){const el=document.getElementById(id);if(el)el.checked=v;}
function getVal(id){return document.getElementById(id)?.value;}
function getCheck(id){return document.getElementById(id)?.checked;}
function escHtml(s){return String(s).replace(/&/g,'&amp;').replace(/</g,'&lt;').replace(/>/g,'&gt;');}
fetchState();
loadEditor();
initDisplayModeToggle();
setInterval(fetchState, 1000);
</script>
</body>
</html>