Files
Kao/index.html
Spencer Grimes 1ec67b4033 Enhance /notify with custom emote, color, animation, sound
- /notify now accepts optional: emote, color, animation, sound
- Backend passes custom properties to status response
- Frontend handles custom sounds (chime, alert, success, etc.)
- Added new sound effects: chime, alert, success
- Updated documentation with full notify options
- Added HA automation examples for doorbell and timer

Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
2026-02-03 21:50:32 -06:00

448 lines
9.7 KiB
HTML

<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta
name="viewport"
content="width=device-width, initial-scale=1.0, user-scalable=no"
/>
<title>Kao</title>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
background: #000000;
min-height: 100vh;
display: flex;
flex-direction: column;
justify-content: center;
align-items: center;
font-family: monospace;
overflow: hidden;
}
#emote {
font-size: 18vw;
font-weight: bold;
text-align: center;
transition: color 0.3s ease;
}
#message {
font-size: 4vw;
margin-top: 2vh;
opacity: 0.7;
text-align: center;
}
/* Breathing animation - slow pulse */
.breathing {
animation: breathe 3s ease-in-out infinite;
}
@keyframes breathe {
0%,
100% {
opacity: 1;
transform: scale(1);
}
50% {
opacity: 0.7;
transform: scale(0.98);
}
}
/* Shaking animation - rapid jitter for Critical */
.shaking {
animation: shake 0.15s linear infinite;
}
@keyframes shake {
0%,
100% {
transform: translateX(0);
}
25% {
transform: translateX(-5px);
}
75% {
transform: translateX(5px);
}
}
/* Popping animation - scale up for Notifications */
.popping {
animation: pop 1s ease-in-out infinite;
}
@keyframes pop {
0%,
100% {
transform: scale(1);
}
50% {
transform: scale(1.08);
}
}
/* Celebrating animation - bounce and wiggle */
.celebrating {
animation: celebrate 0.5s ease-in-out infinite;
}
@keyframes celebrate {
0%,
100% {
transform: translateY(0) rotate(0deg);
}
25% {
transform: translateY(-10px) rotate(-5deg);
}
75% {
transform: translateY(-10px) rotate(5deg);
}
}
/* Floating animation - gentle drift */
.floating {
animation: float 4s ease-in-out infinite;
}
@keyframes float {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-8px);
}
}
/* Bouncing animation - playful hop */
.bouncing {
animation: bounce 1s ease-in-out infinite;
}
@keyframes bounce {
0%,
100% {
transform: translateY(0);
}
50% {
transform: translateY(-12px);
}
}
/* Swaying animation - curious side-to-side */
.swaying {
animation: sway 3s ease-in-out infinite;
}
@keyframes sway {
0%,
100% {
transform: rotate(0deg);
}
25% {
transform: rotate(-3deg);
}
75% {
transform: rotate(3deg);
}
}
/* Blink animation - quick fade for winks */
.blink {
animation: blink 0.3s ease-in-out 1;
}
@keyframes blink {
0%,
100% {
opacity: 1;
}
50% {
opacity: 0.3;
}
}
/* Searching animation - looking around for connection */
.searching {
animation: search 2s ease-in-out infinite;
}
@keyframes search {
0%,
100% {
transform: translateX(0);
opacity: 0.6;
}
25% {
transform: translateX(-10px);
opacity: 0.8;
}
75% {
transform: translateX(10px);
opacity: 0.8;
}
}
/* Sleeping animation - very slow, subtle breathing */
.sleeping {
animation: sleep 6s ease-in-out infinite;
}
@keyframes sleep {
0%,
100% {
opacity: 0.4;
transform: scale(1);
}
50% {
opacity: 0.2;
transform: scale(0.98);
}
}
</style>
</head>
<body>
<div id="emote" class="breathing">( ^_^)</div>
<div id="message">Loading...</div>
<script>
const emoteEl = document.getElementById("emote");
const messageEl = document.getElementById("message");
const POLL_INTERVAL = 2000;
const VERSION = "v1.2.0";
// Sound system
let audioCtx = null;
let soundEnabled =
new URLSearchParams(window.location.search).get("sound") === "on";
let lastState = null;
let lastData = null;
let isReacting = false;
function initAudio() {
if (!audioCtx) {
audioCtx = new (window.AudioContext || window.webkitAudioContext)();
}
if (audioCtx.state === "suspended") {
audioCtx.resume();
}
}
function playTone(frequency, duration, type = "sine", volume = 0.1) {
if (!soundEnabled || !audioCtx) return;
const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain();
osc.type = type;
osc.frequency.value = frequency;
gain.gain.value = volume;
gain.gain.exponentialRampToValueAtTime(
0.001,
audioCtx.currentTime + duration,
);
osc.connect(gain);
gain.connect(audioCtx.destination);
osc.start();
osc.stop(audioCtx.currentTime + duration);
}
function playWarningSound() {
// Soft double-beep
playTone(440, 0.15);
setTimeout(() => playTone(440, 0.15), 180);
}
function playCriticalSound() {
// Urgent descending tone
playTone(600, 0.2);
setTimeout(() => playTone(400, 0.3), 220);
}
function playNotifySound() {
// Gentle ping
playTone(880, 0.1, "sine", 0.08);
}
function playRecoverySound() {
// Happy ascending chirp
playTone(523, 0.1);
setTimeout(() => playTone(659, 0.1), 100);
setTimeout(() => playTone(784, 0.15), 200);
}
function playReactSound() {
// Cute surprised chirp
playTone(600, 0.08, "sine", 0.06);
}
function playChimeSound() {
// Pleasant doorbell-like chime
playTone(659, 0.15);
setTimeout(() => playTone(784, 0.15), 150);
setTimeout(() => playTone(988, 0.2), 300);
}
function playAlertSound() {
// Attention-getting alert
playTone(880, 0.1);
setTimeout(() => playTone(880, 0.1), 150);
setTimeout(() => playTone(880, 0.15), 300);
}
function playSuccessSound() {
// Triumphant success fanfare
playTone(523, 0.1);
setTimeout(() => playTone(659, 0.1), 100);
setTimeout(() => playTone(784, 0.1), 200);
setTimeout(() => playTone(1047, 0.2), 300);
}
// Play sound by name
function playSoundByName(name) {
const sounds = {
chime: playChimeSound,
alert: playAlertSound,
warning: playWarningSound,
critical: playCriticalSound,
success: playSuccessSound,
notify: playNotifySound,
recovery: playRecoverySound,
};
if (sounds[name]) {
sounds[name]();
}
}
// Track which custom sounds we've played to avoid repeats
let lastCustomSound = null;
function handleStateChange(newState, newEmote, customSound) {
// Handle custom sound from notification
if (customSound && customSound !== "none" && customSound !== lastCustomSound) {
playSoundByName(customSound);
lastCustomSound = customSound;
} else if (!customSound) {
lastCustomSound = null;
}
if (!lastState) {
lastState = newState;
return;
}
// State transitions that trigger sounds (only if no custom sound)
if (newState !== lastState && !customSound) {
if (newState === "critical") {
playCriticalSound();
} else if (newState === "warning") {
playWarningSound();
} else if (newState === "notify") {
playNotifySound();
} else if (
newState === "optimal" &&
lastState !== "optimal" &&
lastState !== "sleeping"
) {
// Recovery - also check for celebration emote
playRecoverySound();
}
}
lastState = newState;
}
// Handle tap - enable sound and show reaction
document.body.addEventListener("click", () => {
// Enable sound on first tap (browser autoplay policy)
if (!soundEnabled) {
soundEnabled = true;
initAudio();
}
// Show surprised reaction and version
if (!isReacting) {
isReacting = true;
const prevEmote = emoteEl.textContent;
const prevColor = emoteEl.style.color;
const prevClass = emoteEl.className;
const prevMsg = messageEl.textContent;
// Surprised face!
emoteEl.textContent = "( °o°)";
emoteEl.className = "popping";
messageEl.textContent = `Kao ${VERSION}`;
playReactSound();
// Return to normal after 1.5s
setTimeout(() => {
if (lastData) {
updateDisplay(lastData);
} else {
emoteEl.textContent = prevEmote;
emoteEl.style.color = prevColor;
emoteEl.className = prevClass;
messageEl.textContent = prevMsg;
}
isReacting = false;
}, 1500);
}
});
// Also init if ?sound=on
if (soundEnabled) {
document.addEventListener("DOMContentLoaded", initAudio);
}
async function fetchStatus() {
try {
const response = await fetch("/status");
if (!response.ok) throw new Error("Failed to fetch");
const data = await response.json();
updateDisplay(data);
} catch (err) {
// Connection lost state
emoteEl.textContent = "( ?.?)";
emoteEl.style.color = "#888888";
emoteEl.className = "searching";
messageEl.style.color = "#888888";
messageEl.textContent = "";
}
}
function updateDisplay(data) {
lastData = data;
// Don't update display while showing reaction
if (isReacting) return;
// Check for state changes and play sounds
handleStateChange(data.current_state, data.active_emote, data.sound);
emoteEl.textContent = data.active_emote;
emoteEl.style.color = data.color;
messageEl.style.color = data.color;
// Only show message when there's something to report
const topEvent = data.active_events && data.active_events[0];
messageEl.textContent = (topEvent && topEvent.message) || "";
// Update animation class
emoteEl.className = "";
if (data.animation) {
emoteEl.classList.add(data.animation);
}
}
// Initial fetch and start polling
fetchStatus();
setInterval(fetchStatus, POLL_INTERVAL);
</script>
</body>
</html>