aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-03 17:22:48 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-03 17:22:48 -0700
commit14172f9dd64ce91ba5cf51f82c53deb6a81d68a6 (patch)
tree5e12ce4e30ecaed9a2aac48d2959d99a4d8b4ef7
parent818db3ef4aadf489dba5ba8ba4f3bb4e150f0b22 (diff)
create daily/unlimited mode, CDN audio file for daily mode
-rw-r--r--package.json2
-rw-r--r--playlist_generator/.env.template0
-rw-r--r--playlist_generator/.python-version1
-rw-r--r--playlist_generator/generate_daily.py91
-rw-r--r--playlist_generator/playlist_generator.py1
-rw-r--r--playlist_generator/pyproject.toml12
-rw-r--r--playlist_generator/uv.lock209
-rw-r--r--pnpm-lock.yaml53
-rw-r--r--server/data/songs.ts1
-rw-r--r--server/index.ts28
-rw-r--r--src/app.tsx237
-rw-r--r--src/components/Game/index.tsx21
-rw-r--r--src/components/Player/index.tsx137
-rw-r--r--src/components/Result/index.tsx40
-rw-r--r--src/components/YTPlayer/index.styled.ts (renamed from src/components/Player/index.styled.ts)0
-rw-r--r--src/components/YTPlayer/index.tsx100
-rw-r--r--src/components/index.ts1
-rw-r--r--src/helpers/fetchSolution.ts21
-rw-r--r--src/hooks/useGameState.ts173
-rw-r--r--src/pages/DailyPage.tsx111
-rw-r--r--src/pages/LandingPage.tsx92
-rw-r--r--src/pages/UnlimitedPage.tsx78
22 files changed, 1110 insertions, 299 deletions
diff --git a/package.json b/package.json
index d9877dd..33817f6 100644
--- a/package.json
+++ b/package.json
@@ -9,6 +9,7 @@
"@types/node": "^18.16.0",
"@types/react": "^17.0.20",
"@types/react-dom": "^17.0.9",
+ "@types/react-router-dom": "^5",
"cors": "^2.8.6",
"dotenv": "^17.4.2",
"express": "^5.2.1",
@@ -18,6 +19,7 @@
"react-dom": "^17.0.2",
"react-icons": "^4.3.1",
"react-is": "^18.2.0",
+ "react-router-dom": "^6",
"react-youtube": "^7.14.0",
"styled-components": "^5.3.3",
"typescript": "^4.4.2",
diff --git a/playlist_generator/.env.template b/playlist_generator/.env.template
new file mode 100644
index 0000000..e69de29
--- /dev/null
+++ b/playlist_generator/.env.template
diff --git a/playlist_generator/.python-version b/playlist_generator/.python-version
new file mode 100644
index 0000000..24ee5b1
--- /dev/null
+++ b/playlist_generator/.python-version
@@ -0,0 +1 @@
+3.13
diff --git a/playlist_generator/generate_daily.py b/playlist_generator/generate_daily.py
new file mode 100644
index 0000000..7bd4683
--- /dev/null
+++ b/playlist_generator/generate_daily.py
@@ -0,0 +1,91 @@
+import os
+import boto3
+import json
+import requests
+import random
+from dotenv import load_dotenv
+from datetime import datetime, timezone
+import yt_dlp
+
+load_dotenv()
+
+ACCOUNT_ID = os.getenv("R2_ACCOUNT_ID")
+ACCESS_KEY = os.getenv("R2_ACCESS_KEY")
+SECRET_KEY = os.getenv("R2_SECRET_KEY")
+BUCKET = os.getenv("R2_BUCKET")
+API_URL = os.getenv("API_URL")
+OBFUSCATION_KEY = os.getenv("OBFUSCATION_KEY")
+
+
+def xor_buffer(data: bytes, key: bytes) -> bytes:
+ return bytes(b ^ key[i % len(key)] for i, b in enumerate(data))
+
+def get_obfuscation_key() -> bytes:
+ date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ return (OBFUSCATION_KEY + date).encode("utf-8")
+
+
+def decode_data(hex_data: str):
+ encrypted = bytes.fromhex(hex_data)
+ key = get_obfuscation_key()
+ decrypted = xor_buffer(encrypted, key)
+ return json.loads(decrypted.decode("utf-8"))
+
+
+def fetch_daily() -> dict:
+ url = f"{API_URL}/today"
+ response = requests.get(url)
+ response.raise_for_status()
+ return response.json()
+
+
+def download_random_segment_mp3(youtube_id: str, output_file="today.mp3") -> str:
+ url = f"https://www.youtube.com/watch?v={youtube_id}"
+ with yt_dlp.YoutubeDL({"quiet": True}) as ydl:
+ info = ydl.extract_info(url, download=False)
+ duration = info.get("duration", 60)
+ start = 0 if duration <= 17 else random.randint(0, duration - 17)
+ ydl_opts = {
+ "format": "bestaudio/best",
+ "outtmpl": "today.%(ext)s",
+ "quiet": True,
+ "download_ranges": lambda info, _: [
+ {"start_time": start, "end_time": start + 17}
+ ],
+ "force_keyframes_at_cuts": True,
+ "postprocessors": [
+ {
+ "key": "FFmpegExtractAudio",
+ "preferredcodec": "mp3",
+ "preferredquality": "192",
+ }
+ ],
+ }
+
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ ydl.download([url])
+ return output_file
+
+
+def upload_to_r2(file_path: str, object_key: str):
+ s3 = boto3.client(
+ "s3",
+ endpoint_url=f"https://{ACCOUNT_ID}.r2.cloudflarestorage.com",
+ aws_access_key_id=ACCESS_KEY,
+ aws_secret_access_key=SECRET_KEY,
+ region_name="auto",
+ )
+ s3.upload_file(file_path, BUCKET, object_key)
+
+
+def main():
+ daily_data = fetch_daily()
+ data = decode_data(daily_data["data"])
+ print(data)
+ youtube_id = data["youtubeId"]
+ clip_path = download_random_segment_mp3(youtube_id)
+ date = datetime.now(timezone.utc).strftime("%Y-%m-%d")
+ upload_to_r2(clip_path, f"kheardle/{date}.mp3")
+
+if __name__ == "__main__":
+ main()
diff --git a/playlist_generator/playlist_generator.py b/playlist_generator/playlist_generator.py
index c937a87..1bcc0b7 100644
--- a/playlist_generator/playlist_generator.py
+++ b/playlist_generator/playlist_generator.py
@@ -1,5 +1,4 @@
#!/usr/bin/python3
-# Glitch has Python 3.7.10 installed
import argparse
from random import shuffle
import yt_dlp
diff --git a/playlist_generator/pyproject.toml b/playlist_generator/pyproject.toml
new file mode 100644
index 0000000..5ac1b1f
--- /dev/null
+++ b/playlist_generator/pyproject.toml
@@ -0,0 +1,12 @@
+[project]
+name = "playlist-generator"
+version = "0.1.0"
+description = "Add your description here"
+readme = "README.md"
+requires-python = ">=3.13"
+dependencies = [
+ "boto3>=1.43.22",
+ "python-dotenv>=1.2.2",
+ "requests>=2.34.2",
+ "yt-dlp>=2026.3.17",
+]
diff --git a/playlist_generator/uv.lock b/playlist_generator/uv.lock
new file mode 100644
index 0000000..4583031
--- /dev/null
+++ b/playlist_generator/uv.lock
@@ -0,0 +1,209 @@
+version = 1
+revision = 3
+requires-python = ">=3.13"
+
+[[package]]
+name = "boto3"
+version = "1.43.22"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+ { name = "jmespath" },
+ { name = "s3transfer" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/64/31/32388d5ec332ffe81d8f3650860f94b66294009172d188c390cad58c6f5f/boto3-1.43.22.tar.gz", hash = "sha256:2a7fe12d8e0731bb8aa7c1e59b4ccc770fda031b8659c2f6f497393bdcec3051", size = 113203, upload-time = "2026-06-03T19:33:13.39Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8b/6b/c2fb3b91e849882df5426e68cc15eb2b5ba6ac28325ab2aa3a1065da5884/boto3-1.43.22-py3-none-any.whl", hash = "sha256:0597fb9fe1613e636ac55219a5a54ad0fcb7c15e6be32c799301f7fb53ff04e1", size = 140536, upload-time = "2026-06-03T19:33:10.939Z" },
+]
+
+[[package]]
+name = "botocore"
+version = "1.43.22"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "jmespath" },
+ { name = "python-dateutil" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/76/cf/840f1b8db16d45e3807c23d1ea779723eed1cd9cf3b6c49e16f372d2a777/botocore-1.43.22.tar.gz", hash = "sha256:b00de525e538289ed4a7a85263f1be4e47473c124cec87be6b23be49356bf745", size = 15458781, upload-time = "2026-06-03T19:33:02.882Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/de/6b/576a1b0f915871e35f14a33104f2bcae635f19c6a72486ba639db0d1fc70/botocore-1.43.22-py3-none-any.whl", hash = "sha256:ceec9f81d0891abe7b28ca2b2ee47e32de7b3360ad11e80d351470f015217379", size = 15141375, upload-time = "2026-06-03T19:32:58.324Z" },
+]
+
+[[package]]
+name = "certifi"
+version = "2026.5.20"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/f3/ce/ee2ecad540810a79593028e88299baeae54d346cc7a0d94b6199988b89b1/certifi-2026.5.20.tar.gz", hash = "sha256:69dea482ab64caa7b9f6aba1c6bf48bb6a5448d1c0f1b17ab42ad8c763a5344d", size = 135422, upload-time = "2026-05-20T11:46:50.073Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/59/8c/57e832b7af6d7c5abe66eb3fbe3a3a32f4d11ea23a1aa7131371035be991/certifi-2026.5.20-py3-none-any.whl", hash = "sha256:3c52e209ba0a4ad7aebe60436a4ab349c39e1e602e8c134221e546902ad25897", size = 134134, upload-time = "2026-05-20T11:46:48.578Z" },
+]
+
+[[package]]
+name = "charset-normalizer"
+version = "3.4.7"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/a1/67fe25fac3c7642725500a3f6cfe5821ad557c3abb11c9d20d12c7008d3e/charset_normalizer-3.4.7.tar.gz", hash = "sha256:ae89db9e5f98a11a4bf50407d4363e7b09b31e55bc117b4f7d80aab97ba009e5", size = 144271, upload-time = "2026-04-02T09:28:39.342Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/c1/3b/66777e39d3ae1ddc77ee606be4ec6d8cbd4c801f65e5a1b6f2b11b8346dd/charset_normalizer-3.4.7-cp313-cp313-macosx_10_13_universal2.whl", hash = "sha256:f496c9c3cc02230093d8330875c4c3cdfc3b73612a5fd921c65d39cbcef08063", size = 309627, upload-time = "2026-04-02T09:26:45.198Z" },
+ { url = "https://files.pythonhosted.org/packages/2e/4e/b7f84e617b4854ade48a1b7915c8ccfadeba444d2a18c291f696e37f0d3b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:0ea948db76d31190bf08bd371623927ee1339d5f2a0b4b1b4a4439a65298703c", size = 207008, upload-time = "2026-04-02T09:26:46.824Z" },
+ { url = "https://files.pythonhosted.org/packages/c4/bb/ec73c0257c9e11b268f018f068f5d00aa0ef8c8b09f7753ebd5f2880e248/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:a277ab8928b9f299723bc1a2dabb1265911b1a76341f90a510368ca44ad9ab66", size = 228303, upload-time = "2026-04-02T09:26:48.397Z" },
+ { url = "https://files.pythonhosted.org/packages/85/fb/32d1f5033484494619f701e719429c69b766bfc4dbc61aa9e9c8c166528b/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:3bec022aec2c514d9cf199522a802bd007cd588ab17ab2525f20f9c34d067c18", size = 224282, upload-time = "2026-04-02T09:26:49.684Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/07/330e3a0dda4c404d6da83b327270906e9654a24f6c546dc886a0eb0ffb23/charset_normalizer-3.4.7-cp313-cp313-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:e044c39e41b92c845bc815e5ae4230804e8e7bc29e399b0437d64222d92809dd", size = 215595, upload-time = "2026-04-02T09:26:50.915Z" },
+ { url = "https://files.pythonhosted.org/packages/e3/7c/fc890655786e423f02556e0216d4b8c6bcb6bdfa890160dc66bf52dee468/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_armv7l.whl", hash = "sha256:f495a1652cf3fbab2eb0639776dad966c2fb874d79d87ca07f9d5f059b8bd215", size = 201986, upload-time = "2026-04-02T09:26:52.197Z" },
+ { url = "https://files.pythonhosted.org/packages/d8/97/bfb18b3db2aed3b90cf54dc292ad79fdd5ad65c4eae454099475cbeadd0d/charset_normalizer-3.4.7-cp313-cp313-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:e712b419df8ba5e42b226c510472b37bd57b38e897d3eca5e8cfd410a29fa859", size = 211711, upload-time = "2026-04-02T09:26:53.49Z" },
+ { url = "https://files.pythonhosted.org/packages/6f/a5/a581c13798546a7fd557c82614a5c65a13df2157e9ad6373166d2a3e645d/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:7804338df6fcc08105c7745f1502ba68d900f45fd770d5bdd5288ddccb8a42d8", size = 210036, upload-time = "2026-04-02T09:26:54.975Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/bf/b3ab5bcb478e4193d517644b0fb2bf5497fbceeaa7a1bc0f4d5b50953861/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_armv7l.whl", hash = "sha256:481551899c856c704d58119b5025793fa6730adda3571971af568f66d2424bb5", size = 202998, upload-time = "2026-04-02T09:26:56.303Z" },
+ { url = "https://files.pythonhosted.org/packages/e7/4e/23efd79b65d314fa320ec6017b4b5834d5c12a58ba4610aa353af2e2f577/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_ppc64le.whl", hash = "sha256:f59099f9b66f0d7145115e6f80dd8b1d847176df89b234a5a6b3f00437aa0832", size = 230056, upload-time = "2026-04-02T09:26:57.554Z" },
+ { url = "https://files.pythonhosted.org/packages/b9/9f/1e1941bc3f0e01df116e68dc37a55c4d249df5e6fa77f008841aef68264f/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_riscv64.whl", hash = "sha256:f59ad4c0e8f6bba240a9bb85504faa1ab438237199d4cce5f622761507b8f6a6", size = 211537, upload-time = "2026-04-02T09:26:58.843Z" },
+ { url = "https://files.pythonhosted.org/packages/80/0f/088cbb3020d44428964a6c97fe1edfb1b9550396bf6d278330281e8b709c/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_s390x.whl", hash = "sha256:3dedcc22d73ec993f42055eff4fcfed9318d1eeb9a6606c55892a26964964e48", size = 226176, upload-time = "2026-04-02T09:27:00.437Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/9f/130394f9bbe06f4f63e22641d32fc9b202b7e251c9aef4db044324dac493/charset_normalizer-3.4.7-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:64f02c6841d7d83f832cd97ccf8eb8a906d06eb95d5276069175c696b024b60a", size = 217723, upload-time = "2026-04-02T09:27:02.021Z" },
+ { url = "https://files.pythonhosted.org/packages/73/55/c469897448a06e49f8fa03f6caae97074fde823f432a98f979cc42b90e69/charset_normalizer-3.4.7-cp313-cp313-win32.whl", hash = "sha256:4042d5c8f957e15221d423ba781e85d553722fc4113f523f2feb7b188cc34c5e", size = 148085, upload-time = "2026-04-02T09:27:03.192Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/78/1b74c5bbb3f99b77a1715c91b3e0b5bdb6fe302d95ace4f5b1bec37b0167/charset_normalizer-3.4.7-cp313-cp313-win_amd64.whl", hash = "sha256:3946fa46a0cf3e4c8cb1cc52f56bb536310d34f25f01ca9b6c16afa767dab110", size = 158819, upload-time = "2026-04-02T09:27:04.454Z" },
+ { url = "https://files.pythonhosted.org/packages/68/86/46bd42279d323deb8687c4a5a811fd548cb7d1de10cf6535d099877a9a9f/charset_normalizer-3.4.7-cp313-cp313-win_arm64.whl", hash = "sha256:80d04837f55fc81da168b98de4f4b797ef007fc8a79ab71c6ec9bc4dd662b15b", size = 147915, upload-time = "2026-04-02T09:27:05.971Z" },
+ { url = "https://files.pythonhosted.org/packages/97/c8/c67cb8c70e19ef1960b97b22ed2a1567711de46c4ddf19799923adc836c2/charset_normalizer-3.4.7-cp314-cp314-macosx_10_15_universal2.whl", hash = "sha256:c36c333c39be2dbca264d7803333c896ab8fa7d4d6f0ab7edb7dfd7aea6e98c0", size = 309234, upload-time = "2026-04-02T09:27:07.194Z" },
+ { url = "https://files.pythonhosted.org/packages/99/85/c091fdee33f20de70d6c8b522743b6f831a2f1cd3ff86de4c6a827c48a76/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1c2aed2e5e41f24ea8ef1590b8e848a79b56f3a5564a65ceec43c9d692dc7d8a", size = 208042, upload-time = "2026-04-02T09:27:08.749Z" },
+ { url = "https://files.pythonhosted.org/packages/87/1c/ab2ce611b984d2fd5d86a5a8a19c1ae26acac6bad967da4967562c75114d/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:54523e136b8948060c0fa0bc7b1b50c32c186f2fceee897a495406bb6e311d2b", size = 228706, upload-time = "2026-04-02T09:27:09.951Z" },
+ { url = "https://files.pythonhosted.org/packages/a8/29/2b1d2cb00bf085f59d29eb773ce58ec2d325430f8c216804a0a5cd83cbca/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:715479b9a2802ecac752a3b0efa2b0b60285cf962ee38414211abdfccc233b41", size = 224727, upload-time = "2026-04-02T09:27:11.175Z" },
+ { url = "https://files.pythonhosted.org/packages/47/5c/032c2d5a07fe4d4855fea851209cca2b6f03ebeb6d4e3afdb3358386a684/charset_normalizer-3.4.7-cp314-cp314-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:bd6c2a1c7573c64738d716488d2cdd3c00e340e4835707d8fdb8dc1a66ef164e", size = 215882, upload-time = "2026-04-02T09:27:12.446Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/c2/356065d5a8b78ed04499cae5f339f091946a6a74f91e03476c33f0ab7100/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_armv7l.whl", hash = "sha256:c45e9440fb78f8ddabcf714b68f936737a121355bf59f3907f4e17721b9d1aae", size = 200860, upload-time = "2026-04-02T09:27:13.721Z" },
+ { url = "https://files.pythonhosted.org/packages/0c/cd/a32a84217ced5039f53b29f460962abb2d4420def55afabe45b1c3c7483d/charset_normalizer-3.4.7-cp314-cp314-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3534e7dcbdcf757da6b85a0bbf5b6868786d5982dd959b065e65481644817a18", size = 211564, upload-time = "2026-04-02T09:27:15.272Z" },
+ { url = "https://files.pythonhosted.org/packages/44/86/58e6f13ce26cc3b8f4a36b94a0f22ae2f00a72534520f4ae6857c4b81f89/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:e8ac484bf18ce6975760921bb6148041faa8fef0547200386ea0b52b5d27bf7b", size = 211276, upload-time = "2026-04-02T09:27:16.834Z" },
+ { url = "https://files.pythonhosted.org/packages/8f/fe/d17c32dc72e17e155e06883efa84514ca375f8a528ba2546bee73fc4df81/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_armv7l.whl", hash = "sha256:a5fe03b42827c13cdccd08e6c0247b6a6d4b5e3cdc53fd1749f5896adcdc2356", size = 201238, upload-time = "2026-04-02T09:27:18.229Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/29/f33daa50b06525a237451cdb6c69da366c381a3dadcd833fa5676bc468b3/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_ppc64le.whl", hash = "sha256:2d6eb928e13016cea4f1f21d1e10c1cebd5a421bc57ddf5b1142ae3f86824fab", size = 230189, upload-time = "2026-04-02T09:27:19.445Z" },
+ { url = "https://files.pythonhosted.org/packages/b6/6e/52c84015394a6a0bdcd435210a7e944c5f94ea1055f5cc5d56c5fe368e7b/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_riscv64.whl", hash = "sha256:e74327fb75de8986940def6e8dee4f127cc9752bee7355bb323cc5b2659b6d46", size = 211352, upload-time = "2026-04-02T09:27:20.79Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/d7/4353be581b373033fb9198bf1da3cf8f09c1082561e8e922aa7b39bf9fe8/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_s390x.whl", hash = "sha256:d6038d37043bced98a66e68d3aa2b6a35505dc01328cd65217cefe82f25def44", size = 227024, upload-time = "2026-04-02T09:27:22.063Z" },
+ { url = "https://files.pythonhosted.org/packages/30/45/99d18aa925bd1740098ccd3060e238e21115fffbfdcb8f3ece837d0ace6c/charset_normalizer-3.4.7-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:7579e913a5339fb8fa133f6bbcfd8e6749696206cf05acdbdca71a1b436d8e72", size = 217869, upload-time = "2026-04-02T09:27:23.486Z" },
+ { url = "https://files.pythonhosted.org/packages/5c/05/5ee478aa53f4bb7996482153d4bfe1b89e0f087f0ab6b294fcf92d595873/charset_normalizer-3.4.7-cp314-cp314-win32.whl", hash = "sha256:5b77459df20e08151cd6f8b9ef8ef1f961ef73d85c21a555c7eed5b79410ec10", size = 148541, upload-time = "2026-04-02T09:27:25.146Z" },
+ { url = "https://files.pythonhosted.org/packages/48/77/72dcb0921b2ce86420b2d79d454c7022bf5be40202a2a07906b9f2a35c97/charset_normalizer-3.4.7-cp314-cp314-win_amd64.whl", hash = "sha256:92a0a01ead5e668468e952e4238cccd7c537364eb7d851ab144ab6627dbbe12f", size = 159634, upload-time = "2026-04-02T09:27:26.642Z" },
+ { url = "https://files.pythonhosted.org/packages/c6/a3/c2369911cd72f02386e4e340770f6e158c7980267da16af8f668217abaa0/charset_normalizer-3.4.7-cp314-cp314-win_arm64.whl", hash = "sha256:67f6279d125ca0046a7fd386d01b311c6363844deac3e5b069b514ba3e63c246", size = 148384, upload-time = "2026-04-02T09:27:28.271Z" },
+ { url = "https://files.pythonhosted.org/packages/94/09/7e8a7f73d24dba1f0035fbbf014d2c36828fc1bf9c88f84093e57d315935/charset_normalizer-3.4.7-cp314-cp314t-macosx_10_15_universal2.whl", hash = "sha256:effc3f449787117233702311a1b7d8f59cba9ced946ba727bdc329ec69028e24", size = 330133, upload-time = "2026-04-02T09:27:29.474Z" },
+ { url = "https://files.pythonhosted.org/packages/8d/da/96975ddb11f8e977f706f45cddd8540fd8242f71ecdb5d18a80723dcf62c/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_aarch64.manylinux_2_17_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:fbccdc05410c9ee21bbf16a35f4c1d16123dcdeb8a1d38f33654fa21d0234f79", size = 216257, upload-time = "2026-04-02T09:27:30.793Z" },
+ { url = "https://files.pythonhosted.org/packages/e5/e8/1d63bf8ef2d388e95c64b2098f45f84758f6d102a087552da1485912637b/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_ppc64le.manylinux_2_17_ppc64le.manylinux_2_28_ppc64le.whl", hash = "sha256:733784b6d6def852c814bce5f318d25da2ee65dd4839a0718641c696e09a2960", size = 234851, upload-time = "2026-04-02T09:27:32.44Z" },
+ { url = "https://files.pythonhosted.org/packages/9b/40/e5ff04233e70da2681fa43969ad6f66ca5611d7e669be0246c4c7aaf6dc8/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_s390x.manylinux_2_17_s390x.manylinux_2_28_s390x.whl", hash = "sha256:a89c23ef8d2c6b27fd200a42aa4ac72786e7c60d40efdc76e6011260b6e949c4", size = 233393, upload-time = "2026-04-02T09:27:34.03Z" },
+ { url = "https://files.pythonhosted.org/packages/be/c1/06c6c49d5a5450f76899992f1ee40b41d076aee9279b49cf9974d2f313d5/charset_normalizer-3.4.7-cp314-cp314t-manylinux2014_x86_64.manylinux_2_17_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:6c114670c45346afedc0d947faf3c7f701051d2518b943679c8ff88befe14f8e", size = 223251, upload-time = "2026-04-02T09:27:35.369Z" },
+ { url = "https://files.pythonhosted.org/packages/2b/9f/f2ff16fb050946169e3e1f82134d107e5d4ae72647ec8a1b1446c148480f/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_armv7l.whl", hash = "sha256:a180c5e59792af262bf263b21a3c49353f25945d8d9f70628e73de370d55e1e1", size = 206609, upload-time = "2026-04-02T09:27:36.661Z" },
+ { url = "https://files.pythonhosted.org/packages/69/d5/a527c0cd8d64d2eab7459784fb4169a0ac76e5a6fc5237337982fd61347e/charset_normalizer-3.4.7-cp314-cp314t-manylinux_2_31_riscv64.manylinux_2_39_riscv64.whl", hash = "sha256:3c9a494bc5ec77d43cea229c4f6db1e4d8fe7e1bbffa8b6f0f0032430ff8ab44", size = 220014, upload-time = "2026-04-02T09:27:38.019Z" },
+ { url = "https://files.pythonhosted.org/packages/7e/80/8a7b8104a3e203074dc9aa2c613d4b726c0e136bad1cc734594b02867972/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:8d828b6667a32a728a1ad1d93957cdf37489c57b97ae6c4de2860fa749b8fc1e", size = 218979, upload-time = "2026-04-02T09:27:39.37Z" },
+ { url = "https://files.pythonhosted.org/packages/02/9a/b759b503d507f375b2b5c153e4d2ee0a75aa215b7f2489cf314f4541f2c0/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_armv7l.whl", hash = "sha256:cf1493cd8607bec4d8a7b9b004e699fcf8f9103a9284cc94962cb73d20f9d4a3", size = 209238, upload-time = "2026-04-02T09:27:40.722Z" },
+ { url = "https://files.pythonhosted.org/packages/c2/4e/0f3f5d47b86bdb79256e7290b26ac847a2832d9a4033f7eb2cd4bcf4bb5b/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_ppc64le.whl", hash = "sha256:0c96c3b819b5c3e9e165495db84d41914d6894d55181d2d108cc1a69bfc9cce0", size = 236110, upload-time = "2026-04-02T09:27:42.33Z" },
+ { url = "https://files.pythonhosted.org/packages/96/23/bce28734eb3ed2c91dcf93abeb8a5cf393a7b2749725030bb630e554fdd8/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_riscv64.whl", hash = "sha256:752a45dc4a6934060b3b0dab47e04edc3326575f82be64bc4fc293914566503e", size = 219824, upload-time = "2026-04-02T09:27:43.924Z" },
+ { url = "https://files.pythonhosted.org/packages/2c/6f/6e897c6984cc4d41af319b077f2f600fc8214eb2fe2d6bcb79141b882400/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_s390x.whl", hash = "sha256:8778f0c7a52e56f75d12dae53ae320fae900a8b9b4164b981b9c5ce059cd1fcb", size = 233103, upload-time = "2026-04-02T09:27:45.348Z" },
+ { url = "https://files.pythonhosted.org/packages/76/22/ef7bd0fe480a0ae9b656189ec00744b60933f68b4f42a7bb06589f6f576a/charset_normalizer-3.4.7-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:ce3412fbe1e31eb81ea42f4169ed94861c56e643189e1e75f0041f3fe7020abe", size = 225194, upload-time = "2026-04-02T09:27:46.706Z" },
+ { url = "https://files.pythonhosted.org/packages/c5/a7/0e0ab3e0b5bc1219bd80a6a0d4d72ca74d9250cb2382b7c699c147e06017/charset_normalizer-3.4.7-cp314-cp314t-win32.whl", hash = "sha256:c03a41a8784091e67a39648f70c5f97b5b6a37f216896d44d2cdcb82615339a0", size = 159827, upload-time = "2026-04-02T09:27:48.053Z" },
+ { url = "https://files.pythonhosted.org/packages/7a/1d/29d32e0fb40864b1f878c7f5a0b343ae676c6e2b271a2d55cc3a152391da/charset_normalizer-3.4.7-cp314-cp314t-win_amd64.whl", hash = "sha256:03853ed82eeebbce3c2abfdbc98c96dc205f32a79627688ac9a27370ea61a49c", size = 174168, upload-time = "2026-04-02T09:27:49.795Z" },
+ { url = "https://files.pythonhosted.org/packages/de/32/d92444ad05c7a6e41fb2036749777c163baf7a0301a040cb672d6b2b1ae9/charset_normalizer-3.4.7-cp314-cp314t-win_arm64.whl", hash = "sha256:c35abb8bfff0185efac5878da64c45dafd2b37fb0383add1be155a763c1f083d", size = 153018, upload-time = "2026-04-02T09:27:51.116Z" },
+ { url = "https://files.pythonhosted.org/packages/db/8f/61959034484a4a7c527811f4721e75d02d653a35afb0b6054474d8185d4c/charset_normalizer-3.4.7-py3-none-any.whl", hash = "sha256:3dce51d0f5e7951f8bb4900c257dad282f49190fdbebecd4ba99bcc41fef404d", size = 61958, upload-time = "2026-04-02T09:28:37.794Z" },
+]
+
+[[package]]
+name = "idna"
+version = "3.18"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/cd/63/9496c57188a2ee585e0f1db071d75089a11e98aa86eb99d9d7618fc1edce/idna-3.18.tar.gz", hash = "sha256:ffb385a7e039654cef1ab9ef32c6fafe283c0c0467bba1d9029738ce4a14a848", size = 196711, upload-time = "2026-06-02T14:34:07.794Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/1e/5e/d4e9f1a599fb8e573b7b87160658329fbf28d19eac2718f51fc3def3aa5a/idna-3.18-py3-none-any.whl", hash = "sha256:7f952cbe720b688055e3f87de14f5c3e5fdaa8bc3928985c4077ca689de849a2", size = 65455, upload-time = "2026-06-02T14:34:06.319Z" },
+]
+
+[[package]]
+name = "jmespath"
+version = "1.1.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/d3/59/322338183ecda247fb5d1763a6cbe46eff7222eaeebafd9fa65d4bf5cb11/jmespath-1.1.0.tar.gz", hash = "sha256:472c87d80f36026ae83c6ddd0f1d05d4e510134ed462851fd5f754c8c3cbb88d", size = 27377, upload-time = "2026-01-22T16:35:26.279Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/14/2f/967ba146e6d58cf6a652da73885f52fc68001525b4197effc174321d70b4/jmespath-1.1.0-py3-none-any.whl", hash = "sha256:a5663118de4908c91729bea0acadca56526eb2698e83de10cd116ae0f4e97c64", size = 20419, upload-time = "2026-01-22T16:35:24.919Z" },
+]
+
+[[package]]
+name = "playlist-generator"
+version = "0.1.0"
+source = { virtual = "." }
+dependencies = [
+ { name = "boto3" },
+ { name = "python-dotenv" },
+ { name = "requests" },
+ { name = "yt-dlp" },
+]
+
+[package.metadata]
+requires-dist = [
+ { name = "boto3", specifier = ">=1.43.22" },
+ { name = "python-dotenv", specifier = ">=1.2.2" },
+ { name = "requests", specifier = ">=2.34.2" },
+ { name = "yt-dlp", specifier = ">=2026.3.17" },
+]
+
+[[package]]
+name = "python-dateutil"
+version = "2.9.0.post0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "six" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/66/c0/0c8b6ad9f17a802ee498c46e004a0eb49bc148f2fd230864601a86dcf6db/python-dateutil-2.9.0.post0.tar.gz", hash = "sha256:37dd54208da7e1cd875388217d5e00ebd4179249f90fb72437e91a35459a0ad3", size = 342432, upload-time = "2024-03-01T18:36:20.211Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/ec/57/56b9bcc3c9c6a792fcbaf139543cee77261f3651ca9da0c93f5c1221264b/python_dateutil-2.9.0.post0-py2.py3-none-any.whl", hash = "sha256:a8b2bc7bffae282281c8140a97d3aa9c14da0b136dfe83f850eea9a5f7470427", size = 229892, upload-time = "2024-03-01T18:36:18.57Z" },
+]
+
+[[package]]
+name = "python-dotenv"
+version = "1.2.2"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/82/ed/0301aeeac3e5353ef3d94b6ec08bbcabd04a72018415dcb29e588514bba8/python_dotenv-1.2.2.tar.gz", hash = "sha256:2c371a91fbd7ba082c2c1dc1f8bf89ca22564a087c2c287cd9b662adde799cf3", size = 50135, upload-time = "2026-03-01T16:00:26.196Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/0b/d7/1959b9648791274998a9c3526f6d0ec8fd2233e4d4acce81bbae76b44b2a/python_dotenv-1.2.2-py3-none-any.whl", hash = "sha256:1d8214789a24de455a8b8bd8ae6fe3c6b69a5e3d64aa8a8e5d68e694bbcb285a", size = 22101, upload-time = "2026-03-01T16:00:25.09Z" },
+]
+
+[[package]]
+name = "requests"
+version = "2.34.2"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "certifi" },
+ { name = "charset-normalizer" },
+ { name = "idna" },
+ { name = "urllib3" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/ac/c3/e2a2b89f2d3e2179abd6d00ebd70bff6273f37fb3e0cc209f48b39d00cbf/requests-2.34.2.tar.gz", hash = "sha256:f288924cae4e29463698d6d60bc6a4da69c89185ad1e0bcc4104f584e960b9ed", size = 142856, upload-time = "2026-05-14T19:25:27.735Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/a0/f4/c67b0b3f1b9245e8d266f0f112c500d50e5b4e83cb6f3b71b6528104182a/requests-2.34.2-py3-none-any.whl", hash = "sha256:2a0d60c172f83ac6ab31e4554906c0f3b3588d37b5cb939b1c061f4907e278e0", size = 73075, upload-time = "2026-05-14T19:25:26.443Z" },
+]
+
+[[package]]
+name = "s3transfer"
+version = "0.18.0"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "botocore" },
+]
+sdist = { url = "https://files.pythonhosted.org/packages/e0/1f/12417f7f493fc45e1f9fd5d4a9b6c125cf8d2cf3f8ddbdfab3e76406e9d6/s3transfer-0.18.0.tar.gz", hash = "sha256:3760b8b7ec1315da54048b2d626276732bee4300d054d492d4e1d43e20d4ecbd", size = 160560, upload-time = "2026-05-28T19:39:09.124Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/2b/58/a58fc997655386daa2e25784e30c288aa3e3819e401f77029ee4899fb55a/s3transfer-0.18.0-py3-none-any.whl", hash = "sha256:239c13b09e65ad0346e1be7348b8a202dcad44ac7ea7c6eb858fc881dce739b6", size = 88572, upload-time = "2026-05-28T19:39:07.999Z" },
+]
+
+[[package]]
+name = "six"
+version = "1.17.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/94/e7/b2c673351809dca68a0e064b6af791aa332cf192da575fd474ed7d6f16a2/six-1.17.0.tar.gz", hash = "sha256:ff70335d468e7eb6ec65b95b99d3a2836546063f63acc5171de367e834932a81", size = 34031, upload-time = "2024-12-04T17:35:28.174Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/b7/ce/149a00dd41f10bc29e5921b496af8b574d8413afcd5e30dfa0ed46c2cc5e/six-1.17.0-py2.py3-none-any.whl", hash = "sha256:4721f391ed90541fddacab5acf947aa0d3dc7d27b2e1e8eda2be8970586c3274", size = 11050, upload-time = "2024-12-04T17:35:26.475Z" },
+]
+
+[[package]]
+name = "urllib3"
+version = "2.7.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/53/0c/06f8b233b8fd13b9e5ee11424ef85419ba0d8ba0b3138bf360be2ff56953/urllib3-2.7.0.tar.gz", hash = "sha256:231e0ec3b63ceb14667c67be60f2f2c40a518cb38b03af60abc813da26505f4c", size = 433602, upload-time = "2026-05-07T16:13:18.596Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/7f/3e/5db95bcf282c52709639744ca2a8b149baccf648e39c8cc87553df9eae0c/urllib3-2.7.0-py3-none-any.whl", hash = "sha256:9fb4c81ebbb1ce9531cce37674bbc6f1360472bc18ca9a553ede278ef7276897", size = 131087, upload-time = "2026-05-07T16:13:17.151Z" },
+]
+
+[[package]]
+name = "yt-dlp"
+version = "2026.3.17"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/8b/34/7c6b4e3f89cb6416d2cd7ab6dab141a1df97ab0fb22d15816db2c92148c9/yt_dlp-2026.3.17.tar.gz", hash = "sha256:ba7aa31d533f1ffccfe70e421596d7ca8ff0bf1398dc6bb658b7d9dec057d2c9", size = 3119221, upload-time = "2026-03-17T23:43:00.244Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/cd/13/5093bcb954878e50f7217fd2ab94282b53934022e4e4a03265582da83bf5/yt_dlp-2026.3.17-py3-none-any.whl", hash = "sha256:32992db94303a8a5d211a183f2174834fe7f8c29d83ed2e7a324eae97a8f26d8", size = 3315134, upload-time = "2026-03-17T23:42:57.863Z" },
+]
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index 8a3a79c..8c92d97 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -28,6 +28,9 @@ dependencies:
'@types/react-dom':
specifier: 17.0.14
version: 17.0.14
+ '@types/react-router-dom':
+ specifier: ^5
+ version: 5.3.3
cors:
specifier: ^2.8.6
version: 2.8.6
@@ -55,6 +58,9 @@ dependencies:
react-is:
specifier: ^18.2.0
version: 18.3.1
+ react-router-dom:
+ specifier: ^6
+ version: 6.30.4(react-dom@17.0.2)(react@17.0.2)
react-youtube:
specifier: ^7.14.0
version: 7.14.0(react@17.0.2)
@@ -783,6 +789,11 @@ packages:
resolution: {integrity: sha512-wwQAWhWSuHaag8c4q/KN/vCoeOJYshAIvMQwD4GpSb3OiZklFfvAgmj0VCBBImRpuF/aFgIRzllXlVX93Jevww==}
dev: true
+ /@remix-run/router@1.23.3:
+ resolution: {integrity: sha512-4An71tdz9X8+3sI4Qqqd2LWd9vS39J7sqd9EU4Scw7TJE/qB10Flv/UuqbPVgfQV9XoK8Np6jNquZitnZq5i+Q==}
+ engines: {node: '>=14.0.0'}
+ dev: false
+
/@rolldown/binding-android-arm64@1.0.3:
resolution: {integrity: sha512-454rs7jHngixp/NMxd5srYD57OnzSlZ/eFTETjORQHLwJG1lRtmNOJcBerZlfu4GjKqeq8aCCIQrMdHyhI51Hw==}
engines: {node: ^20.19.0 || >=22.12.0}
@@ -1243,6 +1254,10 @@ packages:
'@types/serve-static': 2.2.0
dev: true
+ /@types/history@4.7.11:
+ resolution: {integrity: sha512-qjDJRrmvBMiTx+jyLxvLfJU7UznFuokDv4f3WRuriHKERccVpFU+8XMQUAbDzoiJCsmexxRExQeMwwCdamSKDA==}
+ dev: false
+
/@types/hoist-non-react-statics@3.3.7(@types/react@17.0.93):
resolution: {integrity: sha512-PQTyIulDkIDro8P+IHbKCsw7U2xxBYflVzW/FgWdCAePD9xGSidgA76/GeJ6lBKoblyhf9pBY763gbrN+1dI8g==}
peerDependencies:
@@ -1297,6 +1312,21 @@ packages:
'@types/react': 17.0.93
dev: false
+ /@types/react-router-dom@5.3.3:
+ resolution: {integrity: sha512-kpqnYK4wcdm5UaWI3fLcELopqLrHgLqNsdpHauzlQktfkHL3npOSwtj1Uz9oKBAzs7lFtVkV8j83voAz2D8fhw==}
+ dependencies:
+ '@types/history': 4.7.11
+ '@types/react': 17.0.93
+ '@types/react-router': 5.1.20
+ dev: false
+
+ /@types/react-router@5.1.20:
+ resolution: {integrity: sha512-jGjmu/ZqS7FjSH6owMcD5qpq19+1RS9DeVRqfl1FeBMxTDQAGwlMWOcs52NDoXaNKyG3d1cYQFMs9rCrb88o9Q==}
+ dependencies:
+ '@types/history': 4.7.11
+ '@types/react': 17.0.93
+ dev: false
+
/@types/react@17.0.93:
resolution: {integrity: sha512-KM4Ty/ZTLZupiYxZVAlP+InNJS3De6uBMdq0ePa6/04+eG9Y7ftnWfst1xTLQ5rwAhgHwQ4momt/O4KepdGBTw==}
dependencies:
@@ -4060,6 +4090,29 @@ packages:
/react-is@18.3.1:
resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==}
+ /react-router-dom@6.30.4(react-dom@17.0.2)(react@17.0.2):
+ resolution: {integrity: sha512-q4HvNl+mmDdkS0g+MqiBZNteQJCuimWoOyHMy4T/RQLAn9Z29+E91QXRaxOujeMl2HTzRSS0KFPd7lxX3PjV0Q==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ react: '>=16.8'
+ react-dom: '>=16.8'
+ dependencies:
+ '@remix-run/router': 1.23.3
+ react: 17.0.2
+ react-dom: 17.0.2(react@17.0.2)
+ react-router: 6.30.4(react@17.0.2)
+ dev: false
+
+ /react-router@6.30.4(react@17.0.2):
+ resolution: {integrity: sha512-SVUsDe+DybHM/WmYKIVYhZh1o5Dcuf16yM6WjG02Q9XVFMZIJyHYhwrr6bFBXZkVP6z69kNkMyBCujt8FaFLJA==}
+ engines: {node: '>=14.0.0'}
+ peerDependencies:
+ react: '>=16.8'
+ dependencies:
+ '@remix-run/router': 1.23.3
+ react: 17.0.2
+ dev: false
+
/react-youtube@7.14.0(react@17.0.2):
resolution: {integrity: sha512-SUHZ4F4pd1EHmQu0CV0KSQvAs5KHOT5cfYaq4WLCcDbU8fBo1ouTXaAOIASWbrz8fHwg+G1evfoSIYpV2AwSAg==}
engines: {node: '>= 10.x'}
diff --git a/server/data/songs.ts b/server/data/songs.ts
index 08e9230..7a1ebf8 100644
--- a/server/data/songs.ts
+++ b/server/data/songs.ts
@@ -422,7 +422,6 @@ export const songs = [
{ artist: "aespa", name: "Flights, Not Feelings", youtubeId: "Qe7FP1abS5s" },
{ artist: "LE SSERAFIM", name: "HOT", youtubeId: "cCkAcVOS3ig" },
{ artist: "i-dle", name: "Allergy", youtubeId: "Gcp87-ZegRA" },
- { artist: "NewJeans", name: "Get Up", youtubeId: "SXM1q0CTfew" },
{ artist: "9MUSES", name: "Dolls", youtubeId: "QM58UGunHKY" },
{ artist: "Red Velvet", name: "Cosmic", youtubeId: "46FxItq18h0" },
{ artist: "ILLIT", name: "Midnight Fiction", youtubeId: "Sr7dWdf4Z3U" },
diff --git a/server/index.ts b/server/index.ts
index c78352a..54b3c18 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -25,10 +25,32 @@ function xorBuffer(data: Buffer, key: Buffer): Buffer {
return output;
}
+function getUtcDate(): string {
+ return new Date().toISOString().slice(0, 10);
+}
+function hashString(str: string): number {
+ let hash = 0;
+ for (let i = 0; i < str.length; i++) {
+ hash = (hash * 31 + str.charCodeAt(i)) >>> 0;
+ }
+ return hash;
+}
+
app.get('/today', (_req, res) => {
- const msInDay = 86_400_000;
- const index = Math.floor((Date.now() - startDate.getTime()) / msInDay);
- const song = songs[index % songs.length];
+ const date = getUtcDate();
+ const seed = hashString(date);
+ const index = seed % songs.length;
+ const song = songs[index];
+ const obfuscationKey = getObfuscationKey();
+ const songJson = JSON.stringify(song);
+ const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey);
+ res.json({
+ data: obfuscatedData.toString('hex'),
+ });
+});
+
+app.get('/select', (_req, res) => {
+ const song = songs[Math.floor(Math.random() * songs.length)];
const obfuscationKey = getObfuscationKey();
const songJson = JSON.stringify(song);
const obfuscatedData = xorBuffer(Buffer.from(songJson, 'utf8'), obfuscationKey);
diff --git a/src/app.tsx b/src/app.tsx
index e9a41ef..044cbfc 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -1,234 +1,19 @@
import React from "react";
-import _ from "lodash";
+import { BrowserRouter, Routes, Route } from "react-router-dom";
-import { Song } from "./types/song";
-import { GuessState, GuessType } from "./types/guess";
-import { getDailySolution } from "./helpers/fetchSolution";
-
-import { Header, InfoPopUp, Game, Footer } from "./components";
-
-import * as Styled from "./app.styled";
+import { LandingPage } from "./pages/LandingPage";
+import { DailyPage } from "./pages/DailyPage";
+import { UnlimitedPage } from "./pages/UnlimitedPage";
function App() {
- const initialGuess = {
- song: undefined,
- state: undefined,
- } as GuessType;
-
- const [guesses, setGuesses] = React.useState<GuessType[]>(
- Array.from({ length: 6 }).fill(initialGuess) as GuessType[]
- );
- const [currentTry, setCurrentTry] = React.useState<number>(0);
- const [selectedSong, setSelectedSong] = React.useState<Song>();
- const [didGuess, setDidGuess] = React.useState<boolean>(false);
- const [todaysSolution, setTodaysSolution] = React.useState<Song | null>(null);
-
- const firstRun = localStorage.getItem("firstRun") === null;
-
- function reloadWithoutQueryParameters() {
- location.replace(location.pathname);
- }
- const urlHash = window.location.hash;
- const urlQueryParametersStart = urlHash.indexOf("?");
- const statsImportQueryParameter =
- new URLSearchParams(urlHash.substring(urlQueryParametersStart)).get(
- "statsImport"
- ) || "";
- function importStats() {
- if (statsImportQueryParameter) {
- const importedStats = JSON.parse(statsImportQueryParameter);
- if (Array.isArray(importedStats)) {
- importedStats.forEach((day) => {
- if (Array.isArray(day.guesses)) {
- if (day.guesses.length == 5) {
- day.guesses.push(initialGuess);
- }
- }
- });
- }
- localStorage.setItem("stats", JSON.stringify(importedStats));
- reloadWithoutQueryParameters();
- }
- }
- if (statsImportQueryParameter) {
- if (
- confirm(
- "Do you want to import your previous stats? This will overwrite any stats on this site."
- )
- ) {
- importStats();
- } else {
- reloadWithoutQueryParameters();
- }
- }
-
- let stats = JSON.parse(localStorage.getItem("stats") || "{}");
- let statsVersion = JSON.parse(localStorage.getItem("version") || "1");
-
- React.useEffect(() => {
- getDailySolution().then((solution) => setTodaysSolution(solution));
- }, []);
-
- React.useEffect(() => {
- if (Array.isArray(stats)) {
- const visitedToday = _.isEqual(
- todaysSolution,
- stats[stats.length - 1].solution
- );
-
- if (!visitedToday) {
- stats.push({
- solution: todaysSolution,
- currentTry: 0,
- didGuess: 0,
- });
- } else {
- const { currentTry, guesses, didGuess } = stats[stats.length - 1];
- setCurrentTry(currentTry);
- setGuesses(guesses);
- setDidGuess(didGuess);
- }
- } else {
- stats = [];
- stats.push({
- solution: todaysSolution,
- });
- }
- const currentVersion = 2;
- if (firstRun) {
- statsVersion = currentVersion;
- } else if (statsVersion < currentVersion) {
- statsVersion = currentVersion;
- if (Array.isArray(stats)) {
- for (let index = 0; index < stats.length; index++) {
- const newGuesses: GuessType[] = [];
- for (
- let guessIndex = 0;
- guessIndex < stats[index].guesses.length;
- guessIndex++
- ) {
- const guess = stats[index].guesses[guessIndex];
- if (guess.skipped !== undefined) {
- let state = undefined;
- if (guess.skipped) {
- state = GuessState.Skipped;
- } else if (guess.isCorrect) {
- state = GuessState.Correct;
- } else if (guess.isCorrect === false) {
- state = GuessState.Incorrect;
- }
- newGuesses.push({
- song: guess.song,
- state: state,
- } as GuessType);
- }
- }
- stats[index].guesses = newGuesses;
- }
- }
- }
- }, []);
-
- React.useEffect(() => {
- if (Array.isArray(stats)) {
- stats[stats.length - 1].currentTry = currentTry;
- stats[stats.length - 1].didGuess = didGuess;
- stats[stats.length - 1].guesses = guesses;
- }
- }),
- [guesses, currentTry, didGuess];
-
- React.useEffect(() => {
- localStorage.setItem("stats", JSON.stringify(stats));
- }, [stats]);
-
- React.useEffect(() => {
- localStorage.setItem("version", JSON.stringify(statsVersion));
- }, [statsVersion]);
-
- const [isInfoPopUpOpen, setIsInfoPopUpOpen] =
- React.useState<boolean>(firstRun);
-
- const openInfoPopUp = React.useCallback(() => {
- setIsInfoPopUpOpen(true);
- }, []);
-
- const closeInfoPopUp = React.useCallback(() => {
- if (firstRun) {
- localStorage.setItem("firstRun", "false");
- setIsInfoPopUpOpen(false);
- } else {
- setIsInfoPopUpOpen(false);
- }
- }, [localStorage.getItem("firstRun")]);
-
- const skip = React.useCallback(() => {
- setGuesses((guesses: GuessType[]) => {
- const newGuesses = [...guesses];
- newGuesses[currentTry] = {
- song: undefined,
- state: GuessState.Skipped,
- };
-
- return newGuesses;
- });
-
- setCurrentTry((currentTry) => currentTry + 1);
- }, [currentTry]);
-
- const guess = React.useCallback(() => {
- let state = GuessState.Incorrect;
- if (!selectedSong) return;
- if (selectedSong?.artist === todaysSolution?.artist && selectedSong?.name === todaysSolution?.name) {
- state = GuessState.Correct;
- } else if (selectedSong?.artist === todaysSolution?.artist) {
- state = GuessState.PartiallyCorrect;
- }
-
- if (!selectedSong) {
- alert("Choose a song");
- return;
- }
-
- setGuesses((guesses: GuessType[]) => {
- const newGuesses = [...guesses];
- newGuesses[currentTry] = {
- song: selectedSong,
- state: state,
- };
-
- return newGuesses;
- });
-
- setCurrentTry((currentTry) => currentTry + 1);
- setSelectedSong(undefined);
-
- if (state === GuessState.Correct) {
- setDidGuess(true);
- }
- }, [guesses, selectedSong]);
-
- if (todaysSolution === null) {
- return null;
- }
-
return (
- <main>
- <Header openInfoPopUp={openInfoPopUp} />
- {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />}
- <Styled.Container>
- <Game
- guesses={guesses}
- didGuess={didGuess}
- todaysSolution={todaysSolution}
- currentTry={currentTry}
- setSelectedSong={setSelectedSong}
- skip={skip}
- guess={guess}
- />
- </Styled.Container>
- <Footer />
- </main>
+ <BrowserRouter>
+ <Routes>
+ <Route path="/" element={<LandingPage />} />
+ <Route path="/daily" element={<DailyPage />} />
+ <Route path="/unlimited" element={<UnlimitedPage />} />
+ </Routes>
+ </BrowserRouter>
);
}
diff --git a/src/components/Game/index.tsx b/src/components/Game/index.tsx
index 2f4a2ec..9024b03 100644
--- a/src/components/Game/index.tsx
+++ b/src/components/Game/index.tsx
@@ -4,7 +4,7 @@ import { GuessType } from "../../types/guess";
import { Song } from "../../types/song";
import { playTimes } from "../../constants";
-import { Button, Guess, Player, Search, Result } from "../";
+import { Button, Guess, YTPlayer, Search, Result, Player } from "../";
import * as Styled from "./index.styled";
@@ -16,6 +16,8 @@ interface Props {
setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>;
skip: () => void;
guess: () => void;
+ mode?: "daily" | "unlimited";
+ onPlayAgain?: () => void;
}
export function Game({
@@ -26,6 +28,8 @@ export function Game({
setSelectedSong,
skip,
guess,
+ mode = "daily",
+ onPlayAgain,
}: Props) {
if (didGuess || currentTry === 6) {
return (
@@ -34,19 +38,22 @@ export function Game({
currentTry={currentTry}
todaysSolution={todaysSolution}
guesses={guesses}
+ mode={mode}
+ onPlayAgain={onPlayAgain}
/>
);
}
return (
<>
{guesses.map((guess: GuessType, index) => (
- <Guess
- key={index}
- guess={guess}
- active={index === currentTry}
- />
+ <Guess key={index} guess={guess} active={index === currentTry} />
))}
- <Player id={todaysSolution.youtubeId} currentTry={currentTry} />
+ {mode === "unlimited" ? (
+ <YTPlayer id={todaysSolution.youtubeId} currentTry={currentTry} />
+ ) : (
+ <Player currentTry={currentTry} />
+ )}
+
<Search currentTry={currentTry} setSelectedSong={setSelectedSong} />
<Styled.Buttons>
diff --git a/src/components/Player/index.tsx b/src/components/Player/index.tsx
index fcfce72..e4dfd9e 100644
--- a/src/components/Player/index.tsx
+++ b/src/components/Player/index.tsx
@@ -1,101 +1,142 @@
import React from "react";
-import YouTube from "react-youtube";
import { IoPlay, IoPause } from "react-icons/io5";
import { playTimes } from "../../constants";
-
-import * as Styled from "./index.styled";
+import * as Styled from "../YTPlayer/index.styled";
interface Props {
- id: string;
currentTry: number;
}
-export function Player({ id, currentTry }: Props) {
- const opts = {
- width: "0",
- height: "0",
- };
+const MAX_TIME = 16;
- // react-youtube doesn't export types for this
- // eslint-disable-next-line @typescript-eslint/no-explicit-any
- const playerRef = React.useRef<any>(null);
+export function Player({ currentTry }: Props) {
+ const audioRef = React.useRef<HTMLAudioElement | null>(null);
const currentPlayTime = playTimes[currentTry];
- const [play, setPlay] = React.useState<boolean>(false);
+ const [play, setPlay] = React.useState(false);
+ const [currentTime, setCurrentTime] = React.useState(0);
+ const [isReady, setIsReady] = React.useState(false);
- const [currentTime, setCurrentTime] = React.useState<number>(0);
+ const CDN_URL =
+ import.meta.env.VITE_CDN_URL || "https://yena.pinapelz.com/kheardle";
- const [isReady, setIsReady] = React.useState<boolean>(false);
+ const dateString = new Date().toISOString().split("T")[0];
- React.useEffect(() => {
- setInterval(() => {
- playerRef.current?.internalPlayer
- .getCurrentTime()
- .then((time: number) => {
- setCurrentTime(time);
- });
- }, 250);
+ const startPlayback = React.useCallback(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ audio.play();
+ setPlay(true);
+ }, []);
+
+ const stopPlayback = React.useCallback(() => {
+ const audio = audioRef.current;
+ if (!audio) return;
+
+ audio.pause();
+ audio.currentTime = 0;
+ setPlay(false);
}, []);
React.useEffect(() => {
- if (play) {
- if (currentTime * 1000 >= currentPlayTime) {
- playerRef.current?.internalPlayer.pauseVideo();
- playerRef.current?.internalPlayer.seekTo(0);
+ const audio = new Audio(`${CDN_URL}/${dateString}.mp3`);
+ audioRef.current = audio;
+
+ audio.addEventListener("loadeddata", () => {
+ setIsReady(true);
+ });
+
+ audio.addEventListener("timeupdate", () => {
+ setCurrentTime(audio.currentTime);
+ });
+
+ audio.addEventListener("ended", () => {
+ setPlay(false);
+ audio.currentTime = 0;
+ });
+
+ return () => {
+ audio.pause();
+ audio.src = "";
+ };
+ }, [dateString]);
+
+ React.useEffect(() => {
+ if (!play || !audioRef.current) return;
+
+ const interval = setInterval(() => {
+ const a = audioRef.current!;
+ const t = a.currentTime * 1000;
+
+ setCurrentTime(a.currentTime);
+
+ if (t >= currentPlayTime || t >= MAX_TIME * 1000) {
+ a.pause();
+ a.currentTime = 0;
setPlay(false);
}
- }
- }, [play, currentTime]);
+ }, 100);
- // don't call play video each time currentTime changes
- const startPlayback = React.useCallback(() => {
- playerRef.current?.internalPlayer.playVideo();
- setPlay(true);
- }, []);
+ return () => clearInterval(interval);
+ }, [play, currentPlayTime]);
+
+ React.useEffect(() => {
+ if (!("mediaSession" in navigator)) return;
+
+ navigator.mediaSession.setActionHandler("play", () => undefined);
+ navigator.mediaSession.setActionHandler("pause", () => undefined);
+ navigator.mediaSession.setActionHandler("previoustrack", () => undefined);
+ navigator.mediaSession.setActionHandler("nexttrack", () => undefined);
- const setReady = React.useCallback(() => {
- setIsReady(true);
+ return () => {
+ navigator.mediaSession.setActionHandler("play", null);
+ navigator.mediaSession.setActionHandler("pause", null);
+ navigator.mediaSession.setActionHandler("previoustrack", null);
+ navigator.mediaSession.setActionHandler("nexttrack", null);
+ };
}, []);
return (
<>
- <YouTube opts={opts} videoId={id} onReady={setReady} ref={playerRef} />
{isReady ? (
<>
<Styled.ProgressBackground>
- {currentTime !== 0 && <Styled.Progress value={currentTime} />}
- {playTimes.map((playTime) => (
+ {currentTime !== 0 && (
+ <Styled.Progress value={currentTime} />
+ )}
+
+ {playTimes.map((t) => (
<Styled.Separator
- style={{ left: `${(playTime / 16000) * 100}%` }}
- key={playTime}
+ key={t}
+ style={{ left: `${(t / 16000) * 100}%` }}
/>
))}
</Styled.ProgressBackground>
+
<Styled.TimeStamps>
<Styled.TimeStamp>1s</Styled.TimeStamp>
<Styled.TimeStamp>16s</Styled.TimeStamp>
</Styled.TimeStamps>
- {!play && (
+
+ {!play ? (
<IoPlay
style={{ cursor: "pointer" }}
size={36}
- color="var(--cl-green-6)"
onClick={startPlayback}
/>
- )}
- {play && (
+ ) : (
<IoPause
style={{ cursor: "pointer" }}
size={36}
- color="var(--cl-green-6)"
- onClick={startPlayback}
+ onClick={stopPlayback}
/>
)}
</>
) : (
- <p>Loading player...</p>
+ <p>Loading audio...</p>
)}
</>
);
diff --git a/src/components/Result/index.tsx b/src/components/Result/index.tsx
index 19f9386..6b4560a 100644
--- a/src/components/Result/index.tsx
+++ b/src/components/Result/index.tsx
@@ -14,17 +14,19 @@ interface SolutionProps {
didGuess: boolean;
currentTry: number;
todaysSolution: Song;
+ isUnlimited?: boolean;
}
function Solution({
didGuess,
todaysSolution,
currentTry,
+ isUnlimited,
}: SolutionProps) {
return (
<>
<Styled.SongTitle>
- Today&apos;s song is {todaysSolution.artist} - {todaysSolution.name}
+ {isUnlimited ? "The song was" : "Today's song is"} {todaysSolution.artist} - {todaysSolution.name}
</Styled.SongTitle>
{didGuess && (
@@ -86,6 +88,8 @@ interface Props {
currentTry: number;
todaysSolution: Song;
guesses: GuessType[];
+ mode?: "daily" | "unlimited";
+ onPlayAgain?: () => void;
}
export function Result({
@@ -93,6 +97,8 @@ export function Result({
todaysSolution,
guesses,
currentTry,
+ mode = "daily",
+ onPlayAgain,
}: Props) {
const hoursToNextDay = Math.floor(
(new Date(new Date().setHours(24, 0, 0, 0)).getTime() -
@@ -102,6 +108,8 @@ export function Result({
60
);
+ const isUnlimited = mode === "unlimited";
+
if (didGuess) {
const textForTry = ["Perfect!", "Wow!", "Super!", "Congrats!", "Nice!"];
@@ -113,13 +121,20 @@ export function Result({
todaysSolution={todaysSolution}
didGuess={didGuess}
currentTry={currentTry}
+ isUnlimited={isUnlimited}
/>
- <ShareButton guesses={guesses} variant="green" />
+ {!isUnlimited && <ShareButton guesses={guesses} variant="green" />}
- <Styled.TimeToNext>
- Remember to come back in {hoursToNextDay} hours!
- </Styled.TimeToNext>
+ {isUnlimited && onPlayAgain ? (
+ <Button variant="green" onClick={onPlayAgain}>
+ Play Again
+ </Button>
+ ) : (
+ <Styled.TimeToNext>
+ Remember to come back in {hoursToNextDay} hours!
+ </Styled.TimeToNext>
+ )}
</>
);
}
@@ -132,13 +147,20 @@ export function Result({
todaysSolution={todaysSolution}
didGuess={didGuess}
currentTry={currentTry}
+ isUnlimited={isUnlimited}
/>
- <ShareButton guesses={guesses} variant="red" />
+ {!isUnlimited && <ShareButton guesses={guesses} variant="red" />}
- <Styled.TimeToNext>
- Try again in {hoursToNextDay} hours.
- </Styled.TimeToNext>
+ {isUnlimited && onPlayAgain ? (
+ <Button variant="red" onClick={onPlayAgain}>
+ Play Again
+ </Button>
+ ) : (
+ <Styled.TimeToNext>
+ Try again in {hoursToNextDay} hours.
+ </Styled.TimeToNext>
+ )}
</>
);
}
diff --git a/src/components/Player/index.styled.ts b/src/components/YTPlayer/index.styled.ts
index 3c98f1e..3c98f1e 100644
--- a/src/components/Player/index.styled.ts
+++ b/src/components/YTPlayer/index.styled.ts
diff --git a/src/components/YTPlayer/index.tsx b/src/components/YTPlayer/index.tsx
new file mode 100644
index 0000000..1aac9ac
--- /dev/null
+++ b/src/components/YTPlayer/index.tsx
@@ -0,0 +1,100 @@
+import React from "react";
+import YouTube from "react-youtube";
+import { IoPlay, IoPause } from "react-icons/io5";
+import { playTimes } from "../../constants";
+import * as Styled from "./index.styled";
+
+interface Props {
+ id: string;
+ currentTry: number;
+}
+
+export function Player({ id, currentTry }: Props) {
+ const opts = {
+ width: "0",
+ height: "0",
+ };
+
+ // react-youtube doesn't export types for this
+ // eslint-disable-next-line @typescript-eslint/no-explicit-any
+ const playerRef = React.useRef<any>(null);
+
+ const currentPlayTime = playTimes[currentTry];
+
+ const [play, setPlay] = React.useState<boolean>(false);
+
+ const [currentTime, setCurrentTime] = React.useState<number>(0);
+
+ const [isReady, setIsReady] = React.useState<boolean>(false);
+
+ React.useEffect(() => {
+ setInterval(() => {
+ playerRef.current?.internalPlayer
+ .getCurrentTime()
+ .then((time: number) => {
+ setCurrentTime(time);
+ });
+ }, 250);
+ }, []);
+
+ React.useEffect(() => {
+ if (play) {
+ if (currentTime * 1000 >= currentPlayTime) {
+ playerRef.current?.internalPlayer.pauseVideo();
+ playerRef.current?.internalPlayer.seekTo(0);
+ setPlay(false);
+ }
+ }
+ }, [play, currentTime]);
+
+ // don't call play video each time currentTime changes
+ const startPlayback = React.useCallback(() => {
+ playerRef.current?.internalPlayer.playVideo();
+ setPlay(true);
+ }, []);
+
+ const setReady = React.useCallback(() => {
+ setIsReady(true);
+ }, []);
+
+ return (
+ <>
+ <YouTube opts={opts} videoId={id} onReady={setReady} ref={playerRef} />
+ {isReady ? (
+ <>
+ <Styled.ProgressBackground>
+ {currentTime !== 0 && <Styled.Progress value={currentTime} />}
+ {playTimes.map((playTime) => (
+ <Styled.Separator
+ style={{ left: `${(playTime / 16000) * 100}%` }}
+ key={playTime}
+ />
+ ))}
+ </Styled.ProgressBackground>
+ <Styled.TimeStamps>
+ <Styled.TimeStamp>1s</Styled.TimeStamp>
+ <Styled.TimeStamp>16s</Styled.TimeStamp>
+ </Styled.TimeStamps>
+ {!play && (
+ <IoPlay
+ style={{ cursor: "pointer" }}
+ size={36}
+ color="var(--cl-green-6)"
+ onClick={startPlayback}
+ />
+ )}
+ {play && (
+ <IoPause
+ style={{ cursor: "pointer" }}
+ size={36}
+ color="var(--cl-green-6)"
+ onClick={startPlayback}
+ />
+ )}
+ </>
+ ) : (
+ <p>Loading player...</p>
+ )}
+ </>
+ );
+}
diff --git a/src/components/index.ts b/src/components/index.ts
index 0e97ada..4264a1c 100644
--- a/src/components/index.ts
+++ b/src/components/index.ts
@@ -5,6 +5,7 @@ export { Guess } from "./Guess";
export { Header } from "./Header";
export { InfoPopUp } from "./InfoPopUp";
export { MiniYouTubePlayer} from "./MiniYouTubePlayer";
+export { Player as YTPlayer } from "./YTPlayer";
export { Player } from "./Player";
export { Result } from "./Result";
export { Search } from "./Search";
diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts
index 2ca2e68..10c4fa1 100644
--- a/src/helpers/fetchSolution.ts
+++ b/src/helpers/fetchSolution.ts
@@ -25,14 +25,27 @@ function getObfuscationKey(): Uint8Array {
return new TextEncoder().encode(SALT + date);
}
+function decryptResponse(data: string): Song {
+ const obfuscationKey = getObfuscationKey();
+ const obfuscatedBytes = hexToBytes(data);
+ const decrypted = xor(obfuscatedBytes, obfuscationKey);
+ return JSON.parse(new TextDecoder().decode(decrypted)) as Song;
+}
+
export async function getDailySolution(): Promise<Song> {
const solutionData = await fetch(`${API_URL}/today`);
if (!solutionData.ok) {
throw new Error(`Failed to fetch solution: ${solutionData.statusText}`);
}
const { data } = await solutionData.json();
- const obfuscationKey = getObfuscationKey();
- const obfuscatedBytes = hexToBytes(data);
- const decrypted = xor(obfuscatedBytes, obfuscationKey);
- return JSON.parse(new TextDecoder().decode(decrypted)) as Song;
+ return decryptResponse(data);
+}
+
+export async function getSelectSolution(): Promise<Song> {
+ const solutionData = await fetch(`${API_URL}/select`);
+ if (!solutionData.ok) {
+ throw new Error(`Failed to fetch solution: ${solutionData.statusText}`);
+ }
+ const { data } = await solutionData.json();
+ return decryptResponse(data);
}
diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts
new file mode 100644
index 0000000..5c419a1
--- /dev/null
+++ b/src/hooks/useGameState.ts
@@ -0,0 +1,173 @@
+import React from "react";
+import _ from "lodash";
+
+import { Song } from "../types/song";
+import { GuessState, GuessType } from "../types/guess";
+
+interface UseGameStateOptions {
+ solution: Song | null;
+ persist: boolean;
+}
+
+const initialGuess: GuessType = {
+ song: undefined,
+ state: undefined,
+};
+
+export function useGameState({ solution, persist }: UseGameStateOptions) {
+ const [guesses, setGuesses] = React.useState<GuessType[]>(
+ Array.from({ length: 6 }).fill(initialGuess) as GuessType[]
+ );
+ const [currentTry, setCurrentTry] = React.useState<number>(0);
+ const [selectedSong, setSelectedSong] = React.useState<Song>();
+ const [didGuess, setDidGuess] = React.useState<boolean>(false);
+
+ // --- localStorage persistence (daily mode) ---
+ let stats = JSON.parse(localStorage.getItem("stats") || "{}");
+ let statsVersion = JSON.parse(localStorage.getItem("version") || "1");
+
+ React.useEffect(() => {
+ if (!persist || !solution) return;
+
+ if (Array.isArray(stats)) {
+ const visitedToday = _.isEqual(
+ solution,
+ stats[stats.length - 1].solution
+ );
+
+ if (!visitedToday) {
+ stats.push({
+ solution: solution,
+ currentTry: 0,
+ didGuess: 0,
+ });
+ } else {
+ const { currentTry, guesses, didGuess } = stats[stats.length - 1];
+ setCurrentTry(currentTry);
+ setGuesses(guesses);
+ setDidGuess(didGuess);
+ }
+ } else {
+ stats = [];
+ stats.push({
+ solution: solution,
+ });
+ }
+
+ const currentVersion = 2;
+ const firstRun = localStorage.getItem("firstRun") === null;
+ if (firstRun) {
+ statsVersion = currentVersion;
+ } else if (statsVersion < currentVersion) {
+ statsVersion = currentVersion;
+ if (Array.isArray(stats)) {
+ for (let index = 0; index < stats.length; index++) {
+ const newGuesses: GuessType[] = [];
+ for (
+ let guessIndex = 0;
+ guessIndex < stats[index].guesses.length;
+ guessIndex++
+ ) {
+ const guess = stats[index].guesses[guessIndex];
+ if (guess.skipped !== undefined) {
+ let state = undefined;
+ if (guess.skipped) {
+ state = GuessState.Skipped;
+ } else if (guess.isCorrect) {
+ state = GuessState.Correct;
+ } else if (guess.isCorrect === false) {
+ state = GuessState.Incorrect;
+ }
+ newGuesses.push({
+ song: guess.song,
+ state: state,
+ } as GuessType);
+ }
+ }
+ stats[index].guesses = newGuesses;
+ }
+ }
+ }
+ }, [solution]);
+
+ React.useEffect(() => {
+ if (!persist) return;
+ if (Array.isArray(stats)) {
+ stats[stats.length - 1].currentTry = currentTry;
+ stats[stats.length - 1].didGuess = didGuess;
+ stats[stats.length - 1].guesses = guesses;
+ }
+ }, [guesses, currentTry, didGuess]);
+
+ React.useEffect(() => {
+ if (!persist) return;
+ localStorage.setItem("stats", JSON.stringify(stats));
+ }, [stats]);
+
+ React.useEffect(() => {
+ if (!persist) return;
+ localStorage.setItem("version", JSON.stringify(statsVersion));
+ }, [statsVersion]);
+
+ const skip = React.useCallback(() => {
+ setGuesses((guesses: GuessType[]) => {
+ const newGuesses = [...guesses];
+ newGuesses[currentTry] = {
+ song: undefined,
+ state: GuessState.Skipped,
+ };
+ return newGuesses;
+ });
+ setCurrentTry((currentTry) => currentTry + 1);
+ }, [currentTry]);
+
+ const guess = React.useCallback(() => {
+ if (!selectedSong || !solution) return;
+
+ let state = GuessState.Incorrect;
+ if (
+ selectedSong.artist === solution.artist &&
+ selectedSong.name === solution.name
+ ) {
+ state = GuessState.Correct;
+ } else if (selectedSong.artist === solution.artist) {
+ state = GuessState.PartiallyCorrect;
+ }
+
+ setGuesses((guesses: GuessType[]) => {
+ const newGuesses = [...guesses];
+ newGuesses[currentTry] = {
+ song: selectedSong,
+ state: state,
+ };
+ return newGuesses;
+ });
+
+ setCurrentTry((currentTry) => currentTry + 1);
+ setSelectedSong(undefined);
+
+ if (state === GuessState.Correct) {
+ setDidGuess(true);
+ }
+ }, [guesses, selectedSong, solution]);
+
+ const reset = React.useCallback(() => {
+ setGuesses(
+ Array.from({ length: 6 }).fill(initialGuess) as GuessType[]
+ );
+ setCurrentTry(0);
+ setSelectedSong(undefined);
+ setDidGuess(false);
+ }, []);
+
+ return {
+ guesses,
+ currentTry,
+ selectedSong,
+ setSelectedSong,
+ didGuess,
+ skip,
+ guess,
+ reset,
+ };
+}
diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx
new file mode 100644
index 0000000..5033366
--- /dev/null
+++ b/src/pages/DailyPage.tsx
@@ -0,0 +1,111 @@
+import React from "react";
+
+import { Song } from "../types/song";
+import { GuessType } from "../types/guess";
+import { getDailySolution } from "../helpers/fetchSolution";
+import { useGameState } from "../hooks/useGameState";
+
+import { Header, InfoPopUp, Game, Footer } from "../components";
+
+import * as Styled from "../app.styled";
+
+export function DailyPage() {
+ const [todaysSolution, setTodaysSolution] = React.useState<Song | null>(null);
+
+ const firstRun = localStorage.getItem("firstRun") === null;
+
+ const initialGuess = {
+ song: undefined,
+ state: undefined,
+ } as GuessType;
+
+ // --- Stats import logic ---
+ function reloadWithoutQueryParameters() {
+ location.replace(location.pathname);
+ }
+ const urlHash = window.location.hash;
+ const urlQueryParametersStart = urlHash.indexOf("?");
+ const statsImportQueryParameter =
+ new URLSearchParams(urlHash.substring(urlQueryParametersStart)).get(
+ "statsImport"
+ ) || "";
+
+ function importStats() {
+ if (statsImportQueryParameter) {
+ const importedStats = JSON.parse(statsImportQueryParameter);
+ if (Array.isArray(importedStats)) {
+ importedStats.forEach((day) => {
+ if (Array.isArray(day.guesses)) {
+ if (day.guesses.length == 5) {
+ day.guesses.push(initialGuess);
+ }
+ }
+ });
+ }
+ localStorage.setItem("stats", JSON.stringify(importedStats));
+ reloadWithoutQueryParameters();
+ }
+ }
+
+ if (statsImportQueryParameter) {
+ if (
+ confirm(
+ "Do you want to import your previous stats? This will overwrite any stats on this site."
+ )
+ ) {
+ importStats();
+ } else {
+ reloadWithoutQueryParameters();
+ }
+ }
+
+ React.useEffect(() => {
+ getDailySolution().then((solution) => setTodaysSolution(solution));
+ }, []);
+
+ const {
+ guesses,
+ currentTry,
+ setSelectedSong,
+ didGuess,
+ skip,
+ guess,
+ } = useGameState({ solution: todaysSolution, persist: true });
+
+ const [isInfoPopUpOpen, setIsInfoPopUpOpen] =
+ React.useState<boolean>(firstRun);
+
+ const openInfoPopUp = React.useCallback(() => {
+ setIsInfoPopUpOpen(true);
+ }, []);
+
+ const closeInfoPopUp = React.useCallback(() => {
+ if (firstRun) {
+ localStorage.setItem("firstRun", "false");
+ }
+ setIsInfoPopUpOpen(false);
+ }, [localStorage.getItem("firstRun")]);
+
+ if (todaysSolution === null) {
+ return null;
+ }
+
+ return (
+ <main>
+ <Header openInfoPopUp={openInfoPopUp} />
+ {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />}
+ <Styled.Container>
+ <Game
+ guesses={guesses}
+ didGuess={didGuess}
+ todaysSolution={todaysSolution}
+ currentTry={currentTry}
+ setSelectedSong={setSelectedSong}
+ skip={skip}
+ guess={guess}
+ />
+ </Styled.Container>
+ <Footer />
+ </main>
+ );
+}
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
new file mode 100644
index 0000000..e1d29cd
--- /dev/null
+++ b/src/pages/LandingPage.tsx
@@ -0,0 +1,92 @@
+import React from "react";
+import { useNavigate } from "react-router-dom";
+import styled from "styled-components";
+
+import { appName } from "../constants";
+
+const Container = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ min-height: 60vh;
+ gap: 24px;
+`;
+
+const Title = styled.h1`
+ font-family: "Roboto Mono", monospace;
+ font-size: 2rem;
+ font-weight: 700;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--cl-white);
+`;
+
+const Subtitle = styled.p`
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.9rem;
+ color: var(--cl-gray-6);
+ margin: 0;
+`;
+
+const ButtonGroup = styled.div`
+ display: flex;
+ gap: 16px;
+ margin-top: 16px;
+
+ @media (max-width: 480px) {
+ flex-direction: column;
+ width: 100%;
+ padding: 0 24px;
+ }
+`;
+
+const ModeButton = styled.button<{ variant?: "green" | "purple" }>`
+ font-family: "Roboto Mono", monospace;
+ font-size: 1rem;
+ font-weight: 600;
+ padding: 16px 32px;
+ border: 2px solid
+ ${({ variant }) =>
+ variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"};
+ background: transparent;
+ color: ${({ variant }) =>
+ variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"};
+ cursor: pointer;
+ transition: all 0.15s ease;
+
+ &:hover {
+ background: ${({ variant }) =>
+ variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"};
+ color: var(--cl-black, #000);
+ }
+`;
+
+const ModeDescription = styled.span`
+ display: block;
+ font-size: 0.7rem;
+ font-weight: 400;
+ color: var(--cl-gray-6);
+ margin-top: 4px;
+`;
+
+export function LandingPage() {
+ const navigate = useNavigate();
+
+ return (
+ <Container>
+ <Title>{appName}</Title>
+ <Subtitle>Choose a game mode</Subtitle>
+ <ButtonGroup>
+ <ModeButton onClick={() => navigate("/daily")}>
+ Daily
+ <ModeDescription>One song per day</ModeDescription>
+ </ModeButton>
+ <ModeButton variant="purple" onClick={() => navigate("/unlimited")}>
+ Unlimited
+ <ModeDescription>Endless songs, no limits</ModeDescription>
+ </ModeButton>
+ </ButtonGroup>
+ </Container>
+ );
+}
diff --git a/src/pages/UnlimitedPage.tsx b/src/pages/UnlimitedPage.tsx
new file mode 100644
index 0000000..761d2b9
--- /dev/null
+++ b/src/pages/UnlimitedPage.tsx
@@ -0,0 +1,78 @@
+import React from "react";
+
+import { Song } from "../types/song";
+import { getSelectSolution } from "../helpers/fetchSolution";
+import { useGameState } from "../hooks/useGameState";
+
+import { Header, InfoPopUp, Game, Footer } from "../components";
+
+import * as Styled from "../app.styled";
+
+export function UnlimitedPage() {
+ const [solution, setSolution] = React.useState<Song | null>(null);
+
+ const firstRun = localStorage.getItem("firstRun") === null;
+
+ function fetchNewSong() {
+ setSolution(null);
+ getSelectSolution().then((s) => setSolution(s));
+ }
+
+ React.useEffect(() => {
+ fetchNewSong();
+ }, []);
+
+ const {
+ guesses,
+ currentTry,
+ setSelectedSong,
+ didGuess,
+ skip,
+ guess,
+ reset,
+ } = useGameState({ solution, persist: false });
+
+ const playAgain = React.useCallback(() => {
+ reset();
+ fetchNewSong();
+ }, [reset]);
+
+ const [isInfoPopUpOpen, setIsInfoPopUpOpen] =
+ React.useState<boolean>(firstRun);
+
+ const openInfoPopUp = React.useCallback(() => {
+ setIsInfoPopUpOpen(true);
+ }, []);
+
+ const closeInfoPopUp = React.useCallback(() => {
+ if (firstRun) {
+ localStorage.setItem("firstRun", "false");
+ }
+ setIsInfoPopUpOpen(false);
+ }, [localStorage.getItem("firstRun")]);
+
+ if (solution === null) {
+ return null;
+ }
+
+ return (
+ <main>
+ <Header openInfoPopUp={openInfoPopUp} />
+ {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />}
+ <Styled.Container>
+ <Game
+ guesses={guesses}
+ didGuess={didGuess}
+ todaysSolution={solution}
+ currentTry={currentTry}
+ setSelectedSong={setSelectedSong}
+ skip={skip}
+ guess={guess}
+ mode="unlimited"
+ onPlayAgain={playAgain}
+ />
+ </Styled.Container>
+ <Footer />
+ </main>
+ );
+}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage