-
-
+
+
-
+
+
+
+
+
+
+
+
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]
+ )
+
+
- 0 person tuned in
+
+
+
+




