diff --git a/.gitignore b/.gitignore index 0b3aa71..5502dcb 100644 --- a/.gitignore +++ b/.gitignore @@ -4,8 +4,7 @@ ### Linux ### *~ -# temporary files which can be created if a process still has a handle open of a deleted file -.fuse_hidden* +# temporary files which can be created if a process still has a handle open of a deleted file .fuse_hidden* # KDE directory preferences .directory @@ -249,4 +248,13 @@ $RECYCLE.BIN/ # End of https://www.toptal.com/developers/gitignore/api/python,windows,macos,linux -*.mp3 +0.mp3 +1.mp3 +2.mp3 +3.mp3 +4.mp3 +5.mp3 +6.mp3 +7.mp3 +8.mp3 +9.mp3 diff --git a/README.md b/README.md index 882b7e6..21e7a28 100644 --- a/README.md +++ b/README.md @@ -38,3 +38,7 @@ INFERENCE_SERVER_WS_URL=ws://2.3.1.4:1938 fastapi run server.py --port ${ANY_POR if you have any feature idea, feel free to use the issue tracker to let me know! +## credits: + +- cats by [@PixElthen](https://x.com/pixelthen) + diff --git a/server.py b/server.py index d6d7f70..e5649b4 100644 --- a/server.py +++ b/server.py @@ -3,10 +3,11 @@ import os import websocket from contextlib import asynccontextmanager -from fastapi import FastAPI +from fastapi import FastAPI, WebSocket, WebSocketDisconnect from fastapi.responses import FileResponse from fastapi.staticfiles import StaticFiles from logger import log_info, log_warn +from websocket_connection_manager import WebSocketConnectionManager # the index of the current audio track from 0 to 9 current_index = -1 @@ -15,6 +16,8 @@ t = None # websocket connection to the inference server ws = None ws_url = "" +ws_connection_manager = WebSocketConnectionManager() +active_listeners = set() @asynccontextmanager @@ -83,7 +86,6 @@ def advance(): current_index = 0 else: current_index = current_index + 1 - threading.Thread(target=generate_new_audio).start() t = threading.Timer(60, advance) @@ -98,4 +100,34 @@ def get_current_audio(): return FileResponse(f"{current_index}.mp3") +@app.websocket("/ws") +async def ws_endpoint(ws: WebSocket): + await ws_connection_manager.connect(ws) + + addr = "" + if ws.client: + addr, _ = ws.client + else: + await ws.close() + ws_connection_manager.disconnect(ws) + + await ws_connection_manager.broadcast(f"{len(active_listeners)}") + + try: + while True: + msg = await ws.receive_text() + + if msg == "playing": + active_listeners.add(addr) + await ws_connection_manager.broadcast(f"{len(active_listeners)}") + elif msg == "paused": + active_listeners.discard(addr) + await ws_connection_manager.broadcast(f"{len(active_listeners)}") + + except WebSocketDisconnect: + active_listeners.discard(addr) + ws_connection_manager.disconnect(ws) + await ws_connection_manager.broadcast(f"{len(active_listeners)}") + + app.mount("/", StaticFiles(directory="web", html=True), name="web") diff --git a/web/audio/achievement-unlocked.mp3 b/web/audio/achievement-unlocked.mp3 new file mode 100644 index 0000000..9940318 Binary files /dev/null and b/web/audio/achievement-unlocked.mp3 differ diff --git a/web/audio/cat-meow.mp3 b/web/audio/cat-meow.mp3 new file mode 100644 index 0000000..f76e72c Binary files /dev/null and b/web/audio/cat-meow.mp3 differ diff --git a/web/favicon.png b/web/favicon.png new file mode 100644 index 0000000..6f34ab1 Binary files /dev/null and b/web/favicon.png differ diff --git a/web/images/heart.png b/web/images/heart.png new file mode 100644 index 0000000..6bea22c Binary files /dev/null and b/web/images/heart.png differ diff --git a/web/index.html b/web/index.html index 97a6c82..add0d6d 100644 --- a/web/index.html +++ b/web/index.html @@ -1,9 +1,11 @@ - + + infinifi + @@ -11,13 +13,14 @@ + - +
@@ -26,24 +29,38 @@
-
- 100% - + +
+

0 person tuned in

+
+ 100% + +
+
- - + + + + + +
+ + diff --git a/web/script.js b/web/script.js index c1a49da..93cd3df 100644 --- a/web/script.js +++ b/web/script.js @@ -1,19 +1,35 @@ -const playBtn = document.getElementById("play-btn"); -const catImg = document.getElementsByClassName("cat")[0]; -const volumeSlider = document.getElementById("volume-slider"); -const currentVolumeLabel = document.getElementById("current-volume-label"); -const clickAudio = document.getElementById("click-audio"); -const clickReleaseAudio = document.getElementById("click-release-audio"); - const CROSSFADE_DURATION_MS = 5000; const CROSSFADE_INTERVAL_MS = 20; const AUDIO_DURATION_MS = 60000; +const SAVE_VOLUME_TIMEOUT_MS = 200; + +const ACHIEVEMENT_A_LITTLE_CHATTY = "a-little-chatty"; + +const playBtn = document.getElementById("play-btn"); +const catImg = document.getElementById("cat"); +const heartImg = document.getElementById("heart"); +const volumeSlider = document.getElementById("volume-slider"); +const currentVolumeLabel = document.getElementById("current-volume-label"); +const listenerCountLabel = document.getElementById("listener-count"); +const notificationContainer = document.getElementById("notification"); +const notificationTitle = document.getElementById("notification-title"); +const notificationBody = document.getElementById("notification-body"); + +const clickAudio = document.getElementById("click-audio"); +const clickReleaseAudio = document.getElementById("click-release-audio"); +const meowAudio = document.getElementById("meow-audio"); +const achievementUnlockedAudio = document.getElementById( + "achievement-unlocked-audio", +); let isPlaying = false; let isFading = false; let currentAudio; let maxVolume = 100; let currentVolume = 0; +let saveVolumeTimeout = null; +let meowCount = 0; +let ws = connectToWebSocket(); function playAudio() { // add a random query parameter at the end to prevent browser caching @@ -21,11 +37,17 @@ function playAudio() { currentAudio.onplay = () => { isPlaying = true; playBtn.innerText = "pause"; + if (ws) { + ws.send("playing"); + } }; currentAudio.onpause = () => { isPlaying = false; currentVolume = 0; playBtn.innerText = "play"; + if (ws) { + ws.send("paused"); + } }; currentAudio.onended = () => { currentVolume = 0; @@ -121,6 +143,92 @@ function enableSpaceBarControl() { }); } +function connectToWebSocket() { + const ws = new WebSocket( + `${location.protocol === "https:" ? "wss:" : "ws:"}//${location.host}/ws`, + ); + ws.onmessage = (event) => { + console.log(event.data); + + if (typeof event.data !== "string") { + return; + } + + const listenerCountStr = event.data; + const listenerCount = Number.parseInt(listenerCountStr); + if (Number.isNaN(listenerCount)) { + return; + } + + if (listenerCount <= 1) { + listenerCountLabel.innerText = `${listenerCount} person tuned in`; + } else { + listenerCountLabel.innerText = `${listenerCount} ppl tuned in`; + } + }; + + return ws; +} + +function changeVolume(volume) { + maxVolume = volume; + const v = maxVolume / 100; + + currentVolumeLabel.textContent = `${maxVolume}%`; + + if (!isFading && currentAudio) { + currentAudio.volume = v; + currentVolume = maxVolume; + } + + clickAudio.volume = v; + clickReleaseAudio.volume = v; + meowAudio.volume = v; +} + +function loadInitialVolume() { + const savedVolume = localStorage.getItem("volume"); + let volume = 100; + if (savedVolume) { + volume = Number.parseInt(savedVolume); + if (Number.isNaN(volume)) { + volume = 100; + } + } + volumeSlider.value = volume; + changeVolume(volume); + + document.getElementById("volume-slider-container").style.display = "flex"; +} + +function loadMeowCount() { + const lastMeowCount = localStorage.getItem("meowCount"); + if (!lastMeowCount) { + meowCount = 0; + } else { + const n = Number.parseInt(lastMeowCount); + if (Number.isNaN(n)) { + meowCount = 0; + } else { + meowCount += n; + } + } +} + +function showNotification(title, content, duration) { + notificationTitle.innerText = title; + notificationBody.innerText = content; + notificationContainer.style.display = "block"; + notificationContainer.style.animation = "0.5s linear 0s notification-fade-in"; + setTimeout(() => { + notificationContainer.style.animation = + "0.5s linear 0s notification-fade-out"; + setTimeout(() => { + notificationContainer.style.display = "none"; + }, 450); + }, duration); +} + playBtn.onmousedown = () => { clickAudio.play(); document.addEventListener( @@ -132,6 +240,10 @@ playBtn.onmousedown = () => { ); }; +catImg.onmousedown = () => { + meowAudio.play(); +}; + playBtn.onclick = () => { if (isPlaying) { pauseAudio(); @@ -141,16 +253,50 @@ playBtn.onclick = () => { }; volumeSlider.oninput = () => { - maxVolume = volumeSlider.value; - currentVolumeLabel.textContent = `${maxVolume}%`; - if (!isFading && currentAudio) { - currentAudio.volume = maxVolume / 100; - currentVolume = maxVolume; + const volume = volumeSlider.value; + changeVolume(volume); + if (saveVolumeTimeout) { + clearTimeout(saveVolumeTimeout); + } + saveVolumeTimeout = setTimeout(() => { + localStorage.setItem("volume", `${volume}`); + }, SAVE_VOLUME_TIMEOUT_MS); +}; +volumeSlider.value = 100; + +meowAudio.onplay = () => { + heartImg.style.display = "block"; + heartImg.style.animation = "1s linear 0s heart-animation"; + setTimeout(() => { + heartImg.style.display = "none"; + heartImg.style.animation = ""; + }, 900); + + meowCount += 1; + localStorage.setItem("meowCount", `${meowCount}`); + + if (meowCount === 100) { + showNotification("a little chatty", "make milo meow 100 times", 5000); + achievementUnlockedAudio.play(); + localStorage.setItem( + "achievements", + JSON.stringify([ACHIEVEMENT_A_LITTLE_CHATTY]), + ); } - clickAudio.volume = volumeSlider.value / 100; - clickReleaseAudio.volume = volumeSlider.value / 100; }; -volumeSlider.value = 100; +// don't wanna jumpscare ppl +achievementUnlockedAudio.volume = 0.05; + +window.addEventListener("offline", () => { + ws = null; +}); + +window.addEventListener("online", () => { + ws = connectToWebSocket(); +}); + +loadMeowCount(); +loadInitialVolume(); animateCat(); enableSpaceBarControl(); diff --git a/web/style.css b/web/style.css index f81d752..6184892 100644 --- a/web/style.css +++ b/web/style.css @@ -1,6 +1,7 @@ :root { font-family: monospace; --text: #4c4f69; + --surface0: #ccd0da; --surface1: #bcc0cc; --base: #eff1f5; --lavender: #7287fd; @@ -31,6 +32,7 @@ body { body { width: calc(100% - 4rem - 2px); height: calc(100vh - 6rem - 2px); + height: calc(100svh - 6rem - 2px); padding: 2rem; margin: 0; background-color: var(--base); @@ -40,6 +42,7 @@ body { body { width: calc(100% - 8rem - 2px); height: calc(100vh - 10rem - 2px); + height: calc(100svh - 10rem - 2px); padding: 4rem; } } @@ -108,32 +111,31 @@ a { border-radius: 2px; } +.status-bar { + position: absolute; + top: 0; + left: 0; + right: 0; + z-index: -10; + display: flex; + flex-direction: row; + justify-content: space-between; + padding: 2rem; + z-index: 0; + color: var(--text); +} + +.status-bar > #listener-count { + margin: 0; + opacity: 0.8; + padding-right: 1rem; +} + .header { font-weight: 800; margin-bottom: 1rem; } -.cat { - position: absolute; - bottom: 0px; - right: 2rem; - image-rendering: pixelated; - height: 30px; -} -@media (min-width: 1024px) { - .cat { - right: 4rem; - } -} - -.eeping-cat { - position: absolute; - image-rendering: pixelated; - top: -16px; - right: 50%; - height: 15px; -} - @media (min-width: 768px) { main { width: 80%; @@ -151,22 +153,6 @@ a { display: flex; } -.volume-slider-container { - position: absolute; - top: 0; - right: 0; - padding: 0.5rem 1rem; - margin: 1rem; - display: flex; - justify-content: start; - align-items: center; -} - -.volume-slider-container > output { - color: var(--text); - margin-right: 1rem; -} - @media screen and (-webkit-min-device-pixel-ratio: 0) { input[type="range"] { overflow: hidden; @@ -291,3 +277,104 @@ input[type="range"]::-ms-fill-upper { opacity: 0.5; } } + +#cat { + position: absolute; + bottom: 0px; + right: 2rem; + image-rendering: pixelated; + height: 30px; +} +@media (min-width: 1024px) { + #cat { + right: 4rem; + } +} + +#heart { + display: none; + position: absolute; + bottom: 24px; + right: 2rem; + image-rendering: pixelated; +} +@media (min-width: 1024px) { + #heart { + right: 4rem; + } +} + +@keyframes heart-animation { + 0% { + display: block; + } + + 50% { + transform: translate(0, -24px); + } + + 100% { + opacity: 0; + transform: translate(0, -30px); + } +} + +#eeping-cat { + position: absolute; + image-rendering: pixelated; + top: -16px; + right: 50%; + height: 15px; +} + +#volume-slider-container { + display: none; + justify-content: start; + align-items: center; +} + +#volume-slider-container > output { + color: var(--text); + margin-right: 1rem; +} + +aside#notification { + display: none; + position: absolute; + top: 0; + right: 0; + background: var(--surface0); + padding: 1rem 2rem; + margin: 2rem; + border: 2px solid var(--text); +} + +aside#notification > p { + margin: 0; +} + +aside#notification > #notification-title { + font-weight: 700; + margin-bottom: 0.5rem; +} + +@keyframes notification-fade-in { + 0% { + display: block; + opacity: 0; + } + + 100% { + opacity: 1; + } +} + +@keyframes notification-fade-out { + 0% { + opactiy: 1; + } + + 100% { + opacity: 0; + } +} diff --git a/websocket_connection_manager.py b/websocket_connection_manager.py new file mode 100644 index 0000000..64d7b2c --- /dev/null +++ b/websocket_connection_manager.py @@ -0,0 +1,25 @@ +import asyncio +from fastapi import WebSocket + + +class WebSocketConnectionManager: + def __init__(self) -> None: + self.__active_connections: list[WebSocket] = [] + + async def connect(self, ws: WebSocket): + await ws.accept() + self.__active_connections.append(ws) + + def disconnect(self, ws: WebSocket): + self.__active_connections.remove(ws) + + async def send_text(self, ws: WebSocket, msg: str): + try: + await ws.send_text(msg) + except: + self.__active_connections.remove(ws) + + async def broadcast(self, msg: str): + await asyncio.gather( + *[self.send_text(conn, msg) for conn in self.__active_connections] + )