aboutsummaryrefslogtreecommitdiffstats
diff options
context:
space:
mode:
authorPinapelz <yukais@pinapelz.com>2026-06-26 21:55:46 -0700
committerPinapelz <yukais@pinapelz.com>2026-06-26 21:55:46 -0700
commit00d476a0e51629f06407aed035fbc001d3f6b114 (patch)
tree016338cc4dacff54ba1cbe11fce063152cbd28ab
parent84fbd4ee1e159b0426d664cc98a3dc7165d6381f (diff)
initial guess MV mode
-rw-r--r--playlist_generator/.gitignore3
-rw-r--r--playlist_generator/frame_generator.py85
-rw-r--r--playlist_generator/generate_daily.py68
-rw-r--r--playlist_generator/pyproject.toml2
-rw-r--r--playlist_generator/uv.lock62
-rw-r--r--server/index.ts9
-rw-r--r--src/app.tsx2
-rw-r--r--src/components/Game/index.tsx54
-rw-r--r--src/components/InfoPopUp/index.tsx9
-rw-r--r--src/components/MVPlayer/index.styled.ts73
-rw-r--r--src/components/MVPlayer/index.tsx85
-rw-r--r--src/components/Search/index.tsx8
-rw-r--r--src/components/index.ts1
-rw-r--r--src/helpers/fetchSolution.ts32
-rw-r--r--src/helpers/searchSong.ts5
-rw-r--r--src/hooks/useGameState.ts39
-rw-r--r--src/pages/DailyPage.tsx2
-rw-r--r--src/pages/LandingPage.tsx125
-rw-r--r--src/pages/MVPage.tsx76
-rw-r--r--src/pages/UnlimitedPage.tsx2
20 files changed, 675 insertions, 67 deletions
diff --git a/playlist_generator/.gitignore b/playlist_generator/.gitignore
index d40a36b..6a6b4fa 100644
--- a/playlist_generator/.gitignore
+++ b/playlist_generator/.gitignore
@@ -222,4 +222,5 @@ venvw
*.json
*.mp3
*.ts
-todo.txt
+*.txt
+*.jpg
diff --git a/playlist_generator/frame_generator.py b/playlist_generator/frame_generator.py
new file mode 100644
index 0000000..6bee037
--- /dev/null
+++ b/playlist_generator/frame_generator.py
@@ -0,0 +1,85 @@
+import random
+import subprocess
+import yt_dlp
+import cv2
+import os
+
+
+def get_video_info(url):
+ ydl_opts = {"quiet": True}
+ with yt_dlp.YoutubeDL(ydl_opts) as ydl:
+ return ydl.extract_info(url, download=False)
+
+
+def pick_best_video_url(info):
+ formats = [
+ f for f in info["formats"]
+ if f.get("url") and f.get("vcodec") != "none"
+ ]
+ formats.sort(
+ key=lambda f: (f.get("height") or 0, f.get("tbr") or 0),
+ reverse=True
+ )
+ return formats[0]["url"]
+
+
+def is_low_energy(img_path, size=64, threshold=500):
+ img = cv2.imread(img_path)
+ if img is None:
+ raise RuntimeError("Could not read image")
+ img = cv2.resize(img, (size, size), interpolation=cv2.INTER_AREA)
+ gray = cv2.cvtColor(img, cv2.COLOR_BGR2GRAY)
+ return gray.var() < threshold
+
+
+def download_random_frame(url, name_prefix="frame", selected_timestamps=None, max_tries=10):
+ if selected_timestamps is None:
+ selected_timestamps = []
+
+ info = get_video_info(url)
+ duration = info.get("duration")
+ if not duration:
+ raise RuntimeError("No duration found")
+
+ video_url = pick_best_video_url(info)
+
+ for _ in range(max_tries):
+ t = random.uniform(0, duration)
+ while any(abs(t - ts) < 1 for ts in selected_timestamps):
+ t = random.uniform(0, duration)
+ filename = f"{name_prefix}-{t:.2f}.jpg"
+
+ cmd = [
+ "ffmpeg",
+ "-ss", str(t),
+ "-i", video_url,
+ "-frames:v", "1",
+ "-q:v", "2",
+ "-y",
+ filename,
+ ]
+ subprocess.run(cmd, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
+ if not os.path.exists(filename):
+ continue
+ try:
+ if is_low_energy(filename):
+ os.remove(filename)
+ continue
+ except Exception:
+ pass
+ return t
+
+ raise RuntimeError("Failed to generate a valid frame after retries")
+
+
+def generate_daily_random_frames(url, output_dir, count=3):
+ selected_timestamps = []
+ os.makedirs(output_dir, exist_ok=True)
+ for i in range(count):
+ ts = download_random_frame(
+ url,
+ f"{output_dir}/frame",
+ selected_timestamps
+ )
+ selected_timestamps.append(ts)
+ print(f"Downloaded frame {i} at {ts:.2f}s")
diff --git a/playlist_generator/generate_daily.py b/playlist_generator/generate_daily.py
index 508871c..0bb8f3c 100644
--- a/playlist_generator/generate_daily.py
+++ b/playlist_generator/generate_daily.py
@@ -7,6 +7,7 @@ from dotenv import load_dotenv
from datetime import datetime, timedelta
import yt_dlp
import time
+from frame_generator import generate_daily_random_frames
load_dotenv()
@@ -22,6 +23,12 @@ HEARDLE_SALT = (
NTFY_URL = os.getenv("NTFY_URL")
NTFY_TOKEN = os.getenv("NTFY_TOKEN")
+def fetch_instance_info() -> dict:
+ if not API_URL:
+ raise ValueError("Missing API_URL in environment.")
+ response = requests.get(f"{API_URL}/info")
+ return response.json()
+
def send_notification(message: str):
if not NTFY_URL or not NTFY_TOKEN:
return
@@ -67,6 +74,19 @@ def fetch_daily() -> dict:
return payload
+def fetch_daily_mv() -> dict:
+ if not API_URL:
+ raise ValueError("Missing API_URL in environment.")
+
+ url = f"{API_URL}/todayMV"
+ response = requests.get(url, timeout=15)
+ response.raise_for_status()
+ payload = response.json()
+ if "date" not in payload or "data" not in payload:
+ raise ValueError(f"Unexpected /todayMV response shape: {payload}")
+
+ return payload
+
def download_random_segment_mp3(youtube_id: str, output_file="today.mp3") -> str:
url = f"https://www.youtube.com/watch?v={youtube_id}"
@@ -137,6 +157,9 @@ def read_json(file_path, default=None):
return default
def main():
+ info = fetch_instance_info()
+ dailyMV = info.get("dailyMV", False)
+
new_data = False
daily_data = fetch_daily()
attempt = 0
@@ -171,5 +194,50 @@ def main():
delete_from_r2(f"kheardle/{three_days_ago}.mp3")
send_notification(f"K-HEARDLE: Deleted old clip for {three_days_ago}")
+ if dailyMV:
+ print("Starting Daily MV Generation")
+ mv_data = fetch_daily_mv()
+ mv_decoded = decode_data(mv_data["data"], mv_data["date"])
+ mv_youtube_id = mv_decoded["youtubeId"]
+ mv_date = mv_data["date"]
+
+ mv_output_dir = f"frames_mv_{mv_date}"
+ try:
+ generate_daily_random_frames(
+ f"https://www.youtube.com/watch?v={mv_youtube_id}",
+ mv_output_dir,
+ count=3,
+ )
+ except Exception:
+ print(f"Failed to generate MV frames for {mv_youtube_id}")
+ send_notification(f"K-HEARDLE: Failed to generate MV frames for {mv_youtube_id}")
+ return
+
+ mv_frame_files = sorted(
+ f for f in os.listdir(mv_output_dir)
+ if f.startswith("frame-") and f.endswith(".jpg")
+ )
+ if len(mv_frame_files) < 3:
+ print(f"Only generated {len(mv_frame_files)} MV frames for {mv_youtube_id}")
+ send_notification(f"K-HEARDLE: Only generated {len(mv_frame_files)} MV frames for {mv_youtube_id}")
+ return
+
+ try:
+ for i, fname in enumerate(mv_frame_files[:3], start=1):
+ local_path = os.path.join(mv_output_dir, fname)
+ object_key = f"kheardle/k-heardle-mvs/{mv_date}/{i}.jpg"
+ upload_to_r2(local_path, object_key)
+ print(f"Uploaded {object_key}")
+ except Exception:
+ print(f"Failed to upload MV frames for {mv_date}")
+ send_notification(f"K-HEARDLE: Failed to upload MV frames for {mv_date}")
+ return
+ finally:
+ for fname in mv_frame_files:
+ delete_file(os.path.join(mv_output_dir, fname))
+ os.rmdir(mv_output_dir)
+ write_json("save_mv.json", mv_data)
+ send_notification(f"K-HEARDLE: Successfully generated MV frames for {mv_date} UTC")
+
if __name__ == "__main__":
main()
diff --git a/playlist_generator/pyproject.toml b/playlist_generator/pyproject.toml
index 5151a26..21e42e4 100644
--- a/playlist_generator/pyproject.toml
+++ b/playlist_generator/pyproject.toml
@@ -7,6 +7,8 @@ requires-python = ">=3.13"
dependencies = [
"boto3>=1.43.22",
"json5>=0.15.0",
+ "numpy>=2.5.0",
+ "opencv-python>=4.13.0.92",
"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
index aa4805e..f716b8e 100644
--- a/playlist_generator/uv.lock
+++ b/playlist_generator/uv.lock
@@ -124,12 +124,72 @@ wheels = [
]
[[package]]
+name = "numpy"
+version = "2.5.0"
+source = { registry = "https://pypi.org/simple" }
+sdist = { url = "https://files.pythonhosted.org/packages/e7/05/3d27272d30698dc0ecb7fdfaa41ad70303b444f81722bb99bce1d818638a/numpy-2.5.0.tar.gz", hash = "sha256:5a129578019311b6e56bdd714250f19b518f7dceeeb8d1af5490f4942d3f891c", size = 20652461, upload-time = "2026-06-21T20:57:51.95Z" }
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/8a/33/07675aaad7f26ea013d5e884d9a0d784b79c6bd7566c333f5a52fa3c610b/numpy-2.5.0-cp313-cp313-macosx_10_13_x86_64.whl", hash = "sha256:520e6b8be0a4b65840ac8090d4f51cef4bed66e2b0894d5a520f099adc24a9b2", size = 16784890, upload-time = "2026-06-21T20:56:40.799Z" },
+ { url = "https://files.pythonhosted.org/packages/85/4b/953118a730ee3b35e28645e0eb4cf9beec5bdbb954e1ac2f5fcefba6bbc3/numpy-2.5.0-cp313-cp313-macosx_11_0_arm64.whl", hash = "sha256:146b81cdd3967fdb6beca8ba25f00c58741d8f3cbd797f55af0fbe0bfec3469c", size = 11754584, upload-time = "2026-06-21T20:56:43.094Z" },
+ { url = "https://files.pythonhosted.org/packages/44/9b/56dd530c367c74ae17411027cea4135ca57e1e0583bf5594cee18bd83217/numpy-2.5.0-cp313-cp313-macosx_14_0_arm64.whl", hash = "sha256:126b88d95e8ff9b00c9e717aa540469f21d6180162f84c0caec51b16215d49cd", size = 5313904, upload-time = "2026-06-21T20:56:45.503Z" },
+ { url = "https://files.pythonhosted.org/packages/ce/b0/bcd672edad27ecca7da1f7bb0ce72cd1706a4f2d79ae94990afc97c13e1c/numpy-2.5.0-cp313-cp313-macosx_14_0_x86_64.whl", hash = "sha256:d4313cef1594c5ce46c31b6e54e918338f63f16ee9322304e8c9114d6d81c8bd", size = 6648504, upload-time = "2026-06-21T20:56:47.567Z" },
+ { url = "https://files.pythonhosted.org/packages/80/9e/15cdfcbd30a1544a46c9e487a00df331c4672450216538705a9e51fa6710/numpy-2.5.0-cp313-cp313-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:750fb097caf26fa878746d9d119f6f9da12dedcbff1eea966c3e3447647c4a9e", size = 15150086, upload-time = "2026-06-21T20:56:49.352Z" },
+ { url = "https://files.pythonhosted.org/packages/32/4e/8d7656ccaab3e81e97258b8a9bc5f0c8502513a92fb4ceb0a2cbfebc17bf/numpy-2.5.0-cp313-cp313-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:3893adc2dc7c0412ba76777db55a049215d99c9aa3113003be8f49f4f1290ab9", size = 16647250, upload-time = "2026-06-21T20:56:51.542Z" },
+ { url = "https://files.pythonhosted.org/packages/3c/81/97060281b602ed07f21b12f4ec409eac1f75a2f91fbc829ed8b2becf3ad4/numpy-2.5.0-cp313-cp313-musllinux_1_2_aarch64.whl", hash = "sha256:835e454dd99b238cdc5a3f63bce2371296f5ebc53ca1e0f8e6ddbb6d92a29aab", size = 16512864, upload-time = "2026-06-21T20:56:55.401Z" },
+ { url = "https://files.pythonhosted.org/packages/33/ab/4496208146911f8d8ddb54f68a972aafa6c8d44babcb2ea03b0e5cc87c9d/numpy-2.5.0-cp313-cp313-musllinux_1_2_x86_64.whl", hash = "sha256:6f9836778081a0a3c02a6a21493f3e9f5b311f8d2541934f31f05583dc999ea4", size = 18408407, upload-time = "2026-06-21T20:56:57.75Z" },
+ { url = "https://files.pythonhosted.org/packages/d4/9f/a4df67c181e4ee8b467aa3332dc2db10fd5c515136831302f3ca48bc0a01/numpy-2.5.0-cp313-cp313-win32.whl", hash = "sha256:0b525be4744b60bb0557ac872d53ef07d085b5f39622bc579c98d3809d05b988", size = 6054431, upload-time = "2026-06-21T20:57:00.016Z" },
+ { url = "https://files.pythonhosted.org/packages/30/53/491e1c47c55b62ccc6a63c1c5b8635c73fc2258dddeb9bda27cae4a0ae96/numpy-2.5.0-cp313-cp313-win_amd64.whl", hash = "sha256:44353e2878930039db472b99dc353d749826e4010bd4d2a7f835e94a97a5c748", size = 12414420, upload-time = "2026-06-21T20:57:01.815Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/4a/25c2906f541e9d9f4c5769764db732e6627be91a13f4724fa10634d77db4/numpy-2.5.0-cp313-cp313-win_arm64.whl", hash = "sha256:48f54b00711f83a5f796b70c518e8c2b3c5848dda03a54911f23eb68519b9b60", size = 10339533, upload-time = "2026-06-21T20:57:03.961Z" },
+ { url = "https://files.pythonhosted.org/packages/86/ad/abc44aaceaf7b17ee1edde2bbb4458da591bc79574cffff50c4bb35f00d1/numpy-2.5.0-cp314-cp314-macosx_10_15_x86_64.whl", hash = "sha256:f27582c55ba4c750b7c58c8faf021d2cd9324a662b466229db8a417b41368af9", size = 16783807, upload-time = "2026-06-21T20:57:06.253Z" },
+ { url = "https://files.pythonhosted.org/packages/5d/39/b72e168daf9c00fb20c9fc996d00437ccecdef3102387775d29d7a62576d/numpy-2.5.0-cp314-cp314-macosx_11_0_arm64.whl", hash = "sha256:28e7137057d551e4a83c4ae414e3451f50568409db7569aacc7f9811ee06a446", size = 11765215, upload-time = "2026-06-21T20:57:08.547Z" },
+ { url = "https://files.pythonhosted.org/packages/f7/a0/8400a9c0e3625182347593f5e1f57da9a617a534794805c8df5518154ddc/numpy-2.5.0-cp314-cp314-macosx_14_0_arm64.whl", hash = "sha256:e1da54b53e75cd9fcfc23efcc7edab2c6aecf97b6037566d8a0fe804af8ec57c", size = 5324493, upload-time = "2026-06-21T20:57:11.012Z" },
+ { url = "https://files.pythonhosted.org/packages/f6/8c/0d104deaa0401c93395a629ec902891618a2eff76d19229139cb5a887bfc/numpy-2.5.0-cp314-cp314-macosx_14_0_x86_64.whl", hash = "sha256:694d8f74e156f7fd01179f1aa8faa2f648ab6ae0f70b6c3fe57a03249aea2303", size = 6645211, upload-time = "2026-06-21T20:57:12.919Z" },
+ { url = "https://files.pythonhosted.org/packages/6a/d9/4a4a628c812750363786afc3d33492709a5cd64b215469c16b0f6c7bb811/numpy-2.5.0-cp314-cp314-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:1a7569a7b53c77716f036bb28cb1c91f166a26ec7d9502cd1e4bdfe502fdec22", size = 15166004, upload-time = "2026-06-21T20:57:14.717Z" },
+ { url = "https://files.pythonhosted.org/packages/a0/5e/2a902317d7fc4aa93236e80c932662dadfc459b323d758329e01775125e1/numpy-2.5.0-cp314-cp314-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:39a0433bd4086ebd462960cf375e19195bb07b53dc1d87dd5fcf47ad78576f03", size = 16650797, upload-time = "2026-06-21T20:57:16.906Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a0/a0090e6329f4ca5992c07847bb579c5259a19953dc57255bb08793142ffb/numpy-2.5.0-cp314-cp314-musllinux_1_2_aarch64.whl", hash = "sha256:929f0c79ac38bcbd7154fe631dc907abfeddbcc5027a896bd1f7767323271e7a", size = 16524647, upload-time = "2026-06-21T20:57:19.165Z" },
+ { url = "https://files.pythonhosted.org/packages/5e/7d/6caf27734c42b65837e7461ed0dbbd6b6fc835060c9714ec59d673bb383a/numpy-2.5.0-cp314-cp314-musllinux_1_2_x86_64.whl", hash = "sha256:cc4f247a47bbf070bfd70be53ccdcf47b800af563535e7bbe172322197c30e21", size = 18411841, upload-time = "2026-06-21T20:57:21.638Z" },
+ { url = "https://files.pythonhosted.org/packages/13/dc/26edadbd812536769a82c2e9e002234e33feb5da43061d47a044f6d309b7/numpy-2.5.0-cp314-cp314-win32.whl", hash = "sha256:5dc71423499fab3f46f7a7201155ade1669ea101f2f429d332df9e72f8161731", size = 6106361, upload-time = "2026-06-21T20:57:23.844Z" },
+ { url = "https://files.pythonhosted.org/packages/f2/9e/4dd1459282229a72d92dece2ae9138e5cac94a72263a7ceb48f37434c925/numpy-2.5.0-cp314-cp314-win_amd64.whl", hash = "sha256:ebb81d9d5443e0309d6c54894c3fbed74ad7da0714352a67b6d773cd189eae73", size = 12551749, upload-time = "2026-06-21T20:57:25.945Z" },
+ { url = "https://files.pythonhosted.org/packages/05/a7/6bc6384c080b86c7f6c85c5bc5b540b24f4f679cd144791d99574e90d462/numpy-2.5.0-cp314-cp314-win_arm64.whl", hash = "sha256:3b94d0d0deceebfad3e67ae5c0e5eb87371e8f7a0581cd04a779928c2450cf1e", size = 10617072, upload-time = "2026-06-21T20:57:28.175Z" },
+ { url = "https://files.pythonhosted.org/packages/86/6b/4a2b71d66ada5608ae02b63f150dfad520f6940721cb7f029ad270befc0e/numpy-2.5.0-cp314-cp314t-macosx_11_0_arm64.whl", hash = "sha256:22f3d43e362d650bc39db1f17851302874a148ca95ba6981c1dfb5fa6862f35b", size = 11881067, upload-time = "2026-06-21T20:57:30.104Z" },
+ { url = "https://files.pythonhosted.org/packages/dc/b2/d365eb40a20efb49d67e9feb90494ed8511282ee1f5fa16006675c65397d/numpy-2.5.0-cp314-cp314t-macosx_14_0_arm64.whl", hash = "sha256:243563efb4cd7528a264567e9fd206c87826457322521d06206a00bfa316c927", size = 5440290, upload-time = "2026-06-21T20:57:32.193Z" },
+ { url = "https://files.pythonhosted.org/packages/fa/5e/e9c03188de5f9b767e46a8fe988bcfd3efad066a4a3fda8b9cb11a93f895/numpy-2.5.0-cp314-cp314t-macosx_14_0_x86_64.whl", hash = "sha256:84881d825ca75249b189bbee875fcfe3238aa5c479e6100893cda566e8e86826", size = 6748371, upload-time = "2026-06-21T20:57:33.933Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/1d/68c186a38a5027bae2c4ddd5ea681fdaf8b4d30fb7301def6d8ad270390f/numpy-2.5.0-cp314-cp314t-manylinux_2_27_aarch64.manylinux_2_28_aarch64.whl", hash = "sha256:cda12aa4779d42b8771180aba759c96f527d43446d8f380ab59e2b35e8489efd", size = 15214643, upload-time = "2026-06-21T20:57:35.677Z" },
+ { url = "https://files.pythonhosted.org/packages/8c/67/73f67b7c7e20635baae9c4c3ead4ae7326a005900297a6110971abd62eb5/numpy-2.5.0-cp314-cp314t-manylinux_2_27_x86_64.manylinux_2_28_x86_64.whl", hash = "sha256:1c0121101093d2bd74981b10f8837d78e794a8ff57834eb27179f49e1ba11ac6", size = 16690128, upload-time = "2026-06-21T20:57:38.159Z" },
+ { url = "https://files.pythonhosted.org/packages/eb/05/d4c1fb0c46d02a27d6b2b8b319a78c90937acec8631c1641874670b31e6f/numpy-2.5.0-cp314-cp314t-musllinux_1_2_aarch64.whl", hash = "sha256:d371c92cfa09da00022f501ab67fafaea813d752eb30ac44336d45b1e5b0268a", size = 16577902, upload-time = "2026-06-21T20:57:40.447Z" },
+ { url = "https://files.pythonhosted.org/packages/9e/1d/771c797d50fa26e4888989cccf1d50ee51f530d4e455ad2692dcb64fa711/numpy-2.5.0-cp314-cp314t-musllinux_1_2_x86_64.whl", hash = "sha256:9990713e9c38154c6861e7547f1e3fc7a87e75ff09bab24ef1cc81d81c2835e9", size = 18452814, upload-time = "2026-06-21T20:57:42.875Z" },
+ { url = "https://files.pythonhosted.org/packages/e8/46/52fc0d2a68d7643f0f149eeea5a5d8ea2a3507056ac8afa83c9212606e8b/numpy-2.5.0-cp314-cp314t-win32.whl", hash = "sha256:edadfbd4794b1086c0d822f81863e8a68fc129d132fd0bb9e31e955d7fbbbdb7", size = 6253168, upload-time = "2026-06-21T20:57:45.101Z" },
+ { url = "https://files.pythonhosted.org/packages/2a/be/6c8d1118b5f13b2881dc095d5b345de19c6638b8959c17409b6eff84c8aa/numpy-2.5.0-cp314-cp314t-win_amd64.whl", hash = "sha256:f7e5fa4382967ae6548bd2f174219afb908e294b0d5f625af01166edd5f7d9aa", size = 12736286, upload-time = "2026-06-21T20:57:46.935Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/6a/d3a169aaf8536cf228d56a09e04bcb713a2fe4410d4e2105b9419b5a9c89/numpy-2.5.0-cp314-cp314t-win_arm64.whl", hash = "sha256:016623417bb330d719d579daf2d6b9a01ddc52e41a9ed61a47f39fde46dcd865", size = 10686451, upload-time = "2026-06-21T20:57:49.313Z" },
+]
+
+[[package]]
+name = "opencv-python"
+version = "4.13.0.92"
+source = { registry = "https://pypi.org/simple" }
+dependencies = [
+ { name = "numpy" },
+]
+wheels = [
+ { url = "https://files.pythonhosted.org/packages/fc/6f/5a28fef4c4a382be06afe3938c64cc168223016fa520c5abaf37e8862aa5/opencv_python-4.13.0.92-cp37-abi3-macosx_13_0_arm64.whl", hash = "sha256:caf60c071ec391ba51ed00a4a920f996d0b64e3e46068aac1f646b5de0326a19", size = 46247052, upload-time = "2026-02-05T07:01:25.046Z" },
+ { url = "https://files.pythonhosted.org/packages/08/ac/6c98c44c650b8114a0fb901691351cfb3956d502e8e9b5cd27f4ee7fbf2f/opencv_python-4.13.0.92-cp37-abi3-macosx_14_0_x86_64.whl", hash = "sha256:5868a8c028a0b37561579bfb8ac1875babdc69546d236249fff296a8c010ccf9", size = 32568781, upload-time = "2026-02-05T07:01:41.379Z" },
+ { url = "https://files.pythonhosted.org/packages/3e/51/82fed528b45173bf629fa44effb76dff8bc9f4eeaee759038362dfa60237/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_aarch64.manylinux_2_17_aarch64.whl", hash = "sha256:0bc2596e68f972ca452d80f444bc404e08807d021fbba40df26b61b18e01838a", size = 47685527, upload-time = "2026-02-05T06:59:11.24Z" },
+ { url = "https://files.pythonhosted.org/packages/db/07/90b34a8e2cf9c50fe8ed25cac9011cde0676b4d9d9c973751ac7616223a2/opencv_python-4.13.0.92-cp37-abi3-manylinux2014_x86_64.manylinux_2_17_x86_64.whl", hash = "sha256:402033cddf9d294693094de5ef532339f14ce821da3ad7df7c9f6e8316da32cf", size = 70460872, upload-time = "2026-02-05T06:59:19.162Z" },
+ { url = "https://files.pythonhosted.org/packages/02/6d/7a9cc719b3eaf4377b9c2e3edeb7ed3a81de41f96421510c0a169ca3cfd4/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_aarch64.whl", hash = "sha256:bccaabf9eb7f897ca61880ce2869dcd9b25b72129c28478e7f2a5e8dee945616", size = 46708208, upload-time = "2026-02-05T06:59:15.419Z" },
+ { url = "https://files.pythonhosted.org/packages/fd/55/b3b49a1b97aabcfbbd6c7326df9cb0b6fa0c0aefa8e89d500939e04aa229/opencv_python-4.13.0.92-cp37-abi3-manylinux_2_28_x86_64.whl", hash = "sha256:620d602b8f7d8b8dab5f4b99c6eb353e78d3fb8b0f53db1bd258bb1aa001c1d5", size = 72927042, upload-time = "2026-02-05T06:59:23.389Z" },
+ { url = "https://files.pythonhosted.org/packages/fb/17/de5458312bcb07ddf434d7bfcb24bb52c59635ad58c6e7c751b48949b009/opencv_python-4.13.0.92-cp37-abi3-win32.whl", hash = "sha256:372fe164a3148ac1ca51e5f3ad0541a4a276452273f503441d718fab9c5e5f59", size = 30932638, upload-time = "2026-02-05T07:02:14.98Z" },
+ { url = "https://files.pythonhosted.org/packages/e9/a5/1be1516390333ff9be3a9cb648c9f33df79d5096e5884b5df71a588af463/opencv_python-4.13.0.92-cp37-abi3-win_amd64.whl", hash = "sha256:423d934c9fafb91aad38edf26efb46da91ffbc05f3f59c4b0c72e699720706f5", size = 40212062, upload-time = "2026-02-05T07:02:12.724Z" },
+]
+
+[[package]]
name = "playlist-generator"
version = "0.1.0"
source = { virtual = "." }
dependencies = [
{ name = "boto3" },
{ name = "json5" },
+ { name = "numpy" },
+ { name = "opencv-python" },
{ name = "python-dotenv" },
{ name = "requests" },
{ name = "yt-dlp" },
@@ -139,6 +199,8 @@ dependencies = [
requires-dist = [
{ name = "boto3", specifier = ">=1.43.22" },
{ name = "json5", specifier = ">=0.15.0" },
+ { name = "numpy", specifier = ">=2.5.0" },
+ { name = "opencv-python", specifier = ">=4.13.0.92" },
{ name = "python-dotenv", specifier = ">=1.2.2" },
{ name = "requests", specifier = ">=2.34.2" },
{ name = "yt-dlp", specifier = ">=2026.3.17" },
diff --git a/server/index.ts b/server/index.ts
index cdc57d5..5799881 100644
--- a/server/index.ts
+++ b/server/index.ts
@@ -15,5 +15,14 @@ const SERVER_PORT = process.env.SERVER_PORT || 3001;
app.use(dailyRouter);
app.use(selectRouter);
+const enableDailyMusicVideo = process.env.ENABLE_DAILY_MUSIC_VIDEO === "true";
+
+app.route("/info")
+ .get((_, res) => {
+ res.json({
+ dailyMusic: true, // for now assume daily heardle is always playable (bare minimum for instance)
+ dailyMV: enableDailyMusicVideo, // signify to worker whether daily MV guessing should be ran
+ });
+ });
app.listen(SERVER_PORT, () => console.log(`Server running on :${SERVER_PORT}`));
diff --git a/src/app.tsx b/src/app.tsx
index 044cbfc..163d769 100644
--- a/src/app.tsx
+++ b/src/app.tsx
@@ -4,6 +4,7 @@ import { BrowserRouter, Routes, Route } from "react-router-dom";
import { LandingPage } from "./pages/LandingPage";
import { DailyPage } from "./pages/DailyPage";
import { UnlimitedPage } from "./pages/UnlimitedPage";
+import { MVPage } from "./pages/MVPage";
function App() {
return (
@@ -11,6 +12,7 @@ function App() {
<Routes>
<Route path="/" element={<LandingPage />} />
<Route path="/daily" element={<DailyPage />} />
+ <Route path="/mv" element={<MVPage />} />
<Route path="/unlimited" element={<UnlimitedPage />} />
</Routes>
</BrowserRouter>
diff --git a/src/components/Game/index.tsx b/src/components/Game/index.tsx
index 8afa516..8e16d46 100644
--- a/src/components/Game/index.tsx
+++ b/src/components/Game/index.tsx
@@ -3,8 +3,9 @@ import React from "react";
import { GuessType } from "../../types/guess";
import { Song } from "../../types/song";
import { playTimes } from "../../constants";
+import { musicVideos } from "../../../server/data/mvs";
-import { Button, Guess, YTPlayer, Search, Result, Player } from "../";
+import { Button, Guess, YTPlayer, Search, Result, Player, MVPlayer } from "../";
import * as Styled from "./index.styled";
@@ -17,7 +18,7 @@ interface Props {
setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>;
skip: () => void;
guess: () => void;
- mode?: "daily" | "unlimited";
+ mode?: "daily" | "unlimited" | "dailyMV";
onPlayAgain?: () => void;
isSubmitting?: boolean;
}
@@ -39,27 +40,36 @@ export function Game({
onPlayAgain,
isSubmitting = false,
}: Props) {
- const recentFinishedPlay = localStorage.getItem("recentFinishedPlay");
+ const recentFinishedPlay =
+ mode === "dailyMV"
+ ? localStorage.getItem("recentFinishedPlayMV")
+ : localStorage.getItem("recentFinishedPlay");
const hasFinishedCurrentRound = didGuess || currentTry >= guesses.length;
const hasFinishedResponseDaily =
- mode === "daily" && !!dailyDate && recentFinishedPlay === dailyDate;
- const isGameOver = hasFinishedCurrentRound || hasFinishedResponseDaily;
+ (mode === "daily" || mode === "dailyMV") &&
+ !!dailyDate &&
+ recentFinishedPlay === dailyDate;
const isBlocked =
- mode === "daily" &&
+ (mode === "daily" || mode === "dailyMV") &&
!!dailyDate &&
!hasFinishedResponseDaily &&
new Date(getUtcDate()) > new Date(dailyDate);
const sessionDate = dailyDate ?? getUtcDate();
React.useEffect(() => {
- if (mode !== "daily") return;
+ if (mode !== "daily" && mode !== "dailyMV") return;
if (!hasFinishedCurrentRound) return;
- localStorage.setItem("recentFinishedPlay", sessionDate);
- const historicalPlayData = localStorage.getItem("historicalPlayData");
+ const storageKey =
+ mode === "dailyMV" ? "recentFinishedPlayMV" : "recentFinishedPlay";
+ localStorage.setItem(storageKey, sessionDate);
+
+ const historicalKey =
+ mode === "dailyMV" ? "historicalPlayDataMV" : "historicalPlayData";
+ const historicalPlayData = localStorage.getItem(historicalKey);
if (historicalPlayData === null) {
localStorage.setItem(
- "historicalPlayData",
+ historicalKey,
JSON.stringify({
sessionDates: [sessionDate],
guesses: [currentTry],
@@ -70,7 +80,7 @@ export function Game({
const parsedData = JSON.parse(historicalPlayData);
if (parsedData.sessionDates.includes(sessionDate)) return;
localStorage.setItem(
- "historicalPlayData",
+ historicalKey,
JSON.stringify({
sessionDates: [...parsedData.sessionDates, sessionDate],
guesses: [...parsedData.guesses, currentTry],
@@ -81,17 +91,23 @@ export function Game({
}, [mode, hasFinishedCurrentRound, sessionDate, currentTry, didGuess]);
if (isBlocked) {
- return <h1>Daily MIXX is not available yet. Check back soon!</h1>;
+ return (
+ <h1>
+ {mode === "dailyMV"
+ ? "Daily MV is not available yet. Check back soon!"
+ : "Daily MIXX is not available yet. Check back soon!"}
+ </h1>
+ );
}
- if (isGameOver) {
+ if (hasFinishedCurrentRound || hasFinishedResponseDaily) {
return (
<Result
didGuess={didGuess}
currentTry={currentTry}
todaysSolution={todaysSolution}
guesses={guesses}
- mode={mode}
+ mode={mode === "dailyMV" ? "daily" : mode}
sessionDate={sessionDate}
onPlayAgain={onPlayAgain}
/>
@@ -105,17 +121,25 @@ export function Game({
))}
{mode === "unlimited" ? (
<YTPlayer id={todaysSolution.youtubeId} currentTry={currentTry} />
+ ) : mode === "dailyMV" ? (
+ <MVPlayer currentTry={currentTry} date={sessionDate} />
) : (
<Player currentTry={currentTry} />
)}
- <Search currentTry={currentTry} setSelectedSong={setSelectedSong} />
+ <Search
+ currentTry={currentTry}
+ setSelectedSong={setSelectedSong}
+ songs={mode === "dailyMV" ? musicVideos : undefined}
+ />
<Styled.Buttons>
<Button onClick={skip}>
{isSubmitting
? "Skipping..."
: currentTry === 5
? "Give Up"
+ : mode === "dailyMV"
+ ? "Skip +1 frame"
: `Skip +${playTimes[currentTry] / 1000}s`}
</Button>
<Button variant="green" onClick={guess}>
diff --git a/src/components/InfoPopUp/index.tsx b/src/components/InfoPopUp/index.tsx
index ba9f233..72568aa 100644
--- a/src/components/InfoPopUp/index.tsx
+++ b/src/components/InfoPopUp/index.tsx
@@ -4,9 +4,14 @@ import * as Styled from "./index.styled";
interface Props {
onClose: () => void;
+ gameMode: string;
}
-export function InfoPopUp({ onClose }: Props) {
+export function InfoPopUp({ onClose, gameMode }: Props) {
+ let firstLine = "Listen to the audio clip, then find the correct song in the list."
+ if (gameMode === "dailyMV") {
+ firstLine = "Find the correct song in the list based on photos from the music video.";
+ }
return (
<Styled.Container>
<Styled.PopUp>
@@ -14,7 +19,7 @@ export function InfoPopUp({ onClose }: Props) {
<Styled.Spacer />
<Styled.Section>
<p>
- Listen to the audio clip, then find the correct song in the list.
+ {firstLine}
</p>
</Styled.Section>
<Styled.Section>
diff --git a/src/components/MVPlayer/index.styled.ts b/src/components/MVPlayer/index.styled.ts
new file mode 100644
index 0000000..ca8a06e
--- /dev/null
+++ b/src/components/MVPlayer/index.styled.ts
@@ -0,0 +1,73 @@
+import styled from "styled-components";
+
+export const Frame = styled.div`
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ gap: 12px;
+ margin: 16px 0;
+`;
+
+export const Images = styled.div`
+ display: flex;
+ flex-wrap: wrap;
+ justify-content: center;
+ gap: 8px;
+ width: 100%;
+`;
+
+export const ImageSlot = styled.div<{ $revealed: boolean }>`
+ position: relative;
+ width: 200px;
+ height: 113px;
+ background-color: var(--cl-gray-2);
+ border: 1px solid ${({ theme }) => theme.border};
+ overflow: hidden;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+ display: block;
+ }
+`;
+
+export const Placeholder = styled.p`
+ margin: 0;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.7rem;
+ color: var(--cl-gray-5);
+ text-align: center;
+ padding: 0 8px;
+`;
+
+export const Hint = styled.p`
+ margin: 0;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.75rem;
+ color: var(--cl-gray-6);
+`;
+
+export const Overlay = styled.div`
+ position: fixed;
+ top: 0;
+ left: 0;
+ right: 0;
+ bottom: 0;
+ background-color: rgba(0, 0, 0, 0.85);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ z-index: 1000;
+ cursor: zoom-out;
+`;
+
+export const LightboxImage = styled.img`
+ max-width: 90vw;
+ max-height: 90vh;
+ object-fit: contain;
+ cursor: default;
+`;
diff --git a/src/components/MVPlayer/index.tsx b/src/components/MVPlayer/index.tsx
new file mode 100644
index 0000000..a1141ab
--- /dev/null
+++ b/src/components/MVPlayer/index.tsx
@@ -0,0 +1,85 @@
+import React from "react";
+import * as Styled from "./index.styled";
+
+interface Props {
+ currentTry: number;
+ date: string;
+}
+
+const MV_CDN_URL = import.meta.env.VITE_CDN_URL || "";
+
+function getRevealedCount(currentTry: number): number {
+ if (currentTry >= 6) return 3;
+ if (currentTry >= 3) return 2;
+ return 1;
+}
+
+export function MVPlayer({ currentTry, date }: Props) {
+ const revealed = getRevealedCount(currentTry);
+ const [errored, setErrored] = React.useState<Set<number>>(new Set());
+
+ const [activeImage, setActiveImage] = React.useState<string | null>(null);
+
+ const markError = React.useCallback((index: number) => {
+ setErrored((prev) => {
+ const next = new Set(prev);
+ next.add(index);
+ return next;
+ });
+ }, []);
+
+ React.useEffect(() => {
+ setErrored(new Set());
+ setActiveImage(null);
+ }, [date]);
+
+ return (
+ <Styled.Frame>
+ <Styled.Images>
+ {Array.from({ length: 3 }, (_, i) => {
+ const index = i + 1;
+ const isRevealed = index <= revealed;
+ const src = `${MV_CDN_URL}/k-heardle-mvs/${date}/${index}.jpg`;
+ const hasError = errored.has(index);
+
+ return (
+ <Styled.ImageSlot key={index} $revealed={isRevealed}>
+ {isRevealed && !hasError ? (
+ <img
+ src={src}
+ alt={`MV frame ${index}`}
+ onError={() => markError(index)}
+ onClick={() => setActiveImage(src)}
+ style={{ cursor: "zoom-in" }}
+ />
+ ) : isRevealed && hasError ? (
+ <Styled.Placeholder>
+ Frame {index} not available yet.
+ </Styled.Placeholder>
+ ) : (
+ <Styled.Placeholder>?</Styled.Placeholder>
+ )}
+ </Styled.ImageSlot>
+ );
+ })}
+ </Styled.Images>
+
+ <Styled.Hint>
+ {revealed < 3
+ ? `More frames unlock as you guess. (${revealed}/3 revealed)`
+ : "All frames revealed."}
+ </Styled.Hint>
+
+ {/* Lightbox */}
+ {activeImage && (
+ <Styled.Overlay onClick={() => setActiveImage(null)}>
+ <Styled.LightboxImage
+ src={activeImage}
+ alt="Full size MV frame"
+ onClick={(e) => e.stopPropagation()}
+ />
+ </Styled.Overlay>
+ )}
+ </Styled.Frame>
+ );
+}
diff --git a/src/components/Search/index.tsx b/src/components/Search/index.tsx
index 4d1ae83..72ce8a1 100644
--- a/src/components/Search/index.tsx
+++ b/src/components/Search/index.tsx
@@ -8,9 +8,11 @@ import * as Styled from "./index.styled";
interface Props {
currentTry: number;
setSelectedSong: React.Dispatch<React.SetStateAction<Song | undefined>>;
+ /** Optional override of the song pool to search within. */
+ songs?: Song[];
}
-export function Search({ currentTry, setSelectedSong }: Props) {
+export function Search({ currentTry, setSelectedSong, songs }: Props) {
const [value, setValue] = React.useState("");
const [results, setResults] = React.useState<Song[]>([]);
@@ -20,8 +22,8 @@ export function Search({ currentTry, setSelectedSong }: Props) {
return;
}
- setResults(searchSong(value));
- }, [value]);
+ setResults(searchSong(value, songs));
+ }, [value, songs]);
React.useEffect(() => {
setValue("");
diff --git a/src/components/index.ts b/src/components/index.ts
index 4264a1c..d716030 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 { MVPlayer } from "./MVPlayer";
export { Player as YTPlayer } from "./YTPlayer";
export { Player } from "./Player";
export { Result } from "./Result";
diff --git a/src/helpers/fetchSolution.ts b/src/helpers/fetchSolution.ts
index d877d8f..b7895bb 100644
--- a/src/helpers/fetchSolution.ts
+++ b/src/helpers/fetchSolution.ts
@@ -77,6 +77,20 @@ export async function getDailySolution(): Promise<DailySolution> {
};
}
+export async function getDailyMVSolution(): Promise<DailySolution> {
+ const solutionData = await fetch(`${API_URL}/todayMV`);
+ if (!solutionData.ok) {
+ throw new Error(`Failed to fetch MV solution: ${solutionData.statusText}`);
+ }
+ const { data, date, sessionToken, initialSig } = await solutionData.json();
+ return {
+ date,
+ sessionToken,
+ initialSig,
+ song: decryptResponse(data, date),
+ };
+}
+
export async function submitDailyGuess(
payload: SubmitDailyGuessRequest
): Promise<SubmitDailyGuessResponse> {
@@ -95,6 +109,24 @@ export async function submitDailyGuess(
return (await response.json()) as SubmitDailyGuessResponse;
}
+export async function submitDailyMVGuess(
+ payload: SubmitDailyGuessRequest
+): Promise<SubmitDailyGuessResponse> {
+ const response = await fetch(`${API_URL}/guessMV`, {
+ method: "POST",
+ headers: {
+ "Content-Type": "application/json",
+ },
+ body: JSON.stringify(payload),
+ });
+
+ if (!response.ok) {
+ throw new Error(`Failed to submit MV guess: ${response.statusText}`);
+ }
+
+ return (await response.json()) as SubmitDailyGuessResponse;
+}
+
export async function getSelectSolution(): Promise<Song> {
const solutionData = await fetch(`${API_URL}/select`);
if (!solutionData.ok) {
diff --git a/src/helpers/searchSong.ts b/src/helpers/searchSong.ts
index 62091b6..3bbecad 100644
--- a/src/helpers/searchSong.ts
+++ b/src/helpers/searchSong.ts
@@ -5,10 +5,11 @@ function fuzzyMatch(input: string): string {
return input.toLowerCase().replace(/[^0-9a-z ]/gi, "");
}
-export function searchSong(searchTerm: string): Song[] {
+export function searchSong(searchTerm: string, pool?: Song[]): Song[] {
const normalizedSearch = fuzzyMatch(searchTerm);
+ const source = pool ?? songs; //if no pool is provided, use the default k-heardle songs list
- return songs
+ return source
.filter((song: Song) => {
const songName = fuzzyMatch(song.name);
const songArtist = fuzzyMatch(song.artist);
diff --git a/src/hooks/useGameState.ts b/src/hooks/useGameState.ts
index c6bf6f7..0cd448e 100644
--- a/src/hooks/useGameState.ts
+++ b/src/hooks/useGameState.ts
@@ -1,7 +1,13 @@
import React from "react";
import { Song } from "../types/song";
import { GuessState, GuessType } from "../types/guess";
-import { DailyGameState, submitDailyGuess } from "../helpers/fetchSolution";
+import {
+ DailyGameState,
+ submitDailyGuess,
+ submitDailyMVGuess,
+} from "../helpers/fetchSolution";
+
+export type GameMode = "daily" | "dailyMV";
interface UseGameStateOptions {
solution: Song | null;
@@ -9,6 +15,7 @@ interface UseGameStateOptions {
sessionDate?: string;
sessionToken?: string;
initialSig?: string;
+ mode?: GameMode;
}
const STATE_VERSION = 2;
@@ -21,6 +28,11 @@ interface PersistedStatsV2 extends DailyGameState {
type PersistedStats = PersistedStatsV2;
+const STORAGE_KEY_BY_MODE: Record<GameMode, string> = {
+ daily: "stats",
+ dailyMV: "statsMV",
+};
+
const initialGuess: GuessType = {
song: undefined,
state: undefined,
@@ -38,9 +50,9 @@ const normalizeAnsweredGuesses = (guesses: unknown): GuessType[] => {
return (guesses as GuessType[]).filter(isAnsweredGuess).slice(0, 6);
};
-function loadStats(): PersistedStats | null {
+function loadStats(mode: GameMode): PersistedStats | null {
try {
- const raw = localStorage.getItem("stats");
+ const raw = localStorage.getItem(STORAGE_KEY_BY_MODE[mode]);
if (!raw) return null;
const parsed = JSON.parse(raw);
@@ -59,7 +71,7 @@ function loadStats(): PersistedStats | null {
typeof parsed.version === "number" && parsed.version < STATE_VERSION;
if (isLegacyV0 || isLegacyVersion) {
- localStorage.clear();
+ localStorage.removeItem(STORAGE_KEY_BY_MODE[mode]);
return null;
}
@@ -81,7 +93,7 @@ function loadStats(): PersistedStats | null {
typeof parsed.sessionToken === "string" ? parsed.sessionToken : "",
};
} catch {
- localStorage.removeItem("stats");
+ localStorage.removeItem(STORAGE_KEY_BY_MODE[mode]);
return null;
}
}
@@ -92,6 +104,7 @@ export function useGameState({
sessionDate,
sessionToken,
initialSig,
+ mode = "daily",
}: UseGameStateOptions) {
const [guesses, setGuesses] = React.useState<GuessType[]>(makeEmptyGuesses());
const [currentTry, setCurrentTry] = React.useState(0);
@@ -100,9 +113,11 @@ export function useGameState({
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [stats, setStats] = React.useState<PersistedStats | null>(() =>
- loadStats()
+ loadStats(mode)
);
+ const submitGuessFn = mode === "dailyMV" ? submitDailyMVGuess : submitDailyGuess;
+
const hydratedRef = React.useRef(false);
React.useEffect(() => {
@@ -169,12 +184,12 @@ export function useGameState({
if (!hydratedRef.current) return;
if (!stats) {
- localStorage.removeItem("stats");
+ localStorage.removeItem(STORAGE_KEY_BY_MODE[mode]);
return;
}
- localStorage.setItem("stats", JSON.stringify(stats));
- }, [stats, persist]);
+ localStorage.setItem(STORAGE_KEY_BY_MODE[mode], JSON.stringify(stats));
+ }, [stats, persist, mode]);
const applyServerState = React.useCallback(
(next: DailyGameState, sig: string) => {
@@ -216,7 +231,7 @@ export function useGameState({
setIsSubmitting(true);
try {
- const res = await submitDailyGuess({
+ const res = await submitGuessFn({
sessionToken,
sig: stats.sig,
state: {
@@ -255,6 +270,7 @@ export function useGameState({
stats,
isSubmitting,
applyServerState,
+ submitGuessFn,
]);
const guess = React.useCallback(async () => {
@@ -266,7 +282,7 @@ export function useGameState({
setIsSubmitting(true);
try {
- const res = await submitDailyGuess({
+ const res = await submitGuessFn({
sessionToken,
sig: stats.sig,
state: {
@@ -321,6 +337,7 @@ export function useGameState({
stats,
isSubmitting,
applyServerState,
+ submitGuessFn,
]);
const reset = React.useCallback(() => {
diff --git a/src/pages/DailyPage.tsx b/src/pages/DailyPage.tsx
index 17efaeb..5b39701 100644
--- a/src/pages/DailyPage.tsx
+++ b/src/pages/DailyPage.tsx
@@ -99,7 +99,7 @@ export function DailyPage() {
return (
<main>
<Header openInfoPopUp={openInfoPopUp} />
- {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />}
+ {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} gameMode="daily"/>}
<Styled.Container>
<Game
guesses={guesses}
diff --git a/src/pages/LandingPage.tsx b/src/pages/LandingPage.tsx
index e1d29cd..40e818a 100644
--- a/src/pages/LandingPage.tsx
+++ b/src/pages/LandingPage.tsx
@@ -1,92 +1,155 @@
-import React from "react";
import { useNavigate } from "react-router-dom";
import styled from "styled-components";
import { appName } from "../constants";
+const getColor = (variant?: "green" | "purple" | "cyan") => {
+ switch (variant) {
+ case "purple":
+ return "var(--cl-purple, #a855f7)";
+ case "cyan":
+ return "var(--cl-cyan-6)";
+ default:
+ return "var(--cl-green-6)";
+ }
+};
+
const Container = styled.div`
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
- min-height: 60vh;
- gap: 24px;
+ min-height: 70vh;
+ padding: 24px;
+ gap: 14px;
`;
const Title = styled.h1`
font-family: "Roboto Mono", monospace;
- font-size: 2rem;
+ font-size: clamp(1.5rem, 3vw, 2rem);
font-weight: 700;
letter-spacing: 0.1em;
text-transform: uppercase;
color: var(--cl-white);
+ text-align: center;
+ margin: 0;
`;
const Subtitle = styled.p`
font-family: "Roboto Mono", monospace;
font-size: 0.9rem;
color: var(--cl-gray-6);
- margin: 0;
+ margin: 4px 0 0;
+ text-align: center;
+`;
+
+const ModeGroups = styled.div`
+ display: flex;
+ flex-direction: column;
+ gap: 22px;
+ width: 100%;
+ max-width: 600px;
`;
const ButtonGroup = styled.div`
display: flex;
gap: 16px;
- margin-top: 16px;
+ justify-content: center;
+ flex-wrap: wrap;
@media (max-width: 480px) {
flex-direction: column;
- width: 100%;
- padding: 0 24px;
+ align-items: stretch;
}
`;
-const ModeButton = styled.button<{ variant?: "green" | "purple" }>`
+const GroupLabel = styled.span`
+ display: block;
+ font-family: "Roboto Mono", monospace;
+ font-size: 0.75rem;
+ font-weight: 600;
+ letter-spacing: 0.1em;
+ text-transform: uppercase;
+ color: var(--cl-gray-6);
+ text-align: center;
+ margin-bottom: 10px;
+`;
+
+const ModeButton = styled.button<{ variant?: "green" | "purple" | "cyan" }>`
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)"};
+ padding: 16px 28px;
+ min-width: 180px;
+
+ border: 2px solid ${({ variant }) => getColor(variant)};
background: transparent;
- color: ${({ variant }) =>
- variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"};
+ color: ${({ variant }) => getColor(variant)};
+
cursor: pointer;
transition: all 0.15s ease;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ justify-content: center;
+ text-align: center;
+
&:hover {
- background: ${({ variant }) =>
- variant === "purple" ? "var(--cl-purple, #a855f7)" : "var(--cl-green-6)"};
+ background: ${({ variant }) => getColor(variant)};
color: var(--cl-black, #000);
}
+
+ &:focus-visible {
+ outline: 3px solid ${({ variant }) => getColor(variant)};
+ outline-offset: 3px;
+ }
+
+ @media (max-width: 480px) {
+ width: 100%;
+ min-width: unset;
+ }
`;
const ModeDescription = styled.span`
- display: block;
- font-size: 0.7rem;
+ font-size: 0.72rem;
font-weight: 400;
color: var(--cl-gray-6);
- margin-top: 4px;
+ margin-top: 6px;
`;
+
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>
+ <Subtitle>a kpop music guessing game</Subtitle>
+ <ModeGroups>
+ <div>
+ <GroupLabel>Song Guessing</GroupLabel>
+ <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>
+ </div>
+ <div>
+ <GroupLabel>Music Video Guessing</GroupLabel>
+ <ButtonGroup>
+ <ModeButton variant="cyan" onClick={() => navigate("/mv")}>
+ Daily MV
+ <ModeDescription>Guess the MV from frames</ModeDescription>
+ </ModeButton>
+ </ButtonGroup>
+ </div>
+ </ModeGroups>
</Container>
);
}
diff --git a/src/pages/MVPage.tsx b/src/pages/MVPage.tsx
new file mode 100644
index 0000000..c135918
--- /dev/null
+++ b/src/pages/MVPage.tsx
@@ -0,0 +1,76 @@
+import React from "react";
+
+import { DailySolution, getDailyMVSolution } from "../helpers/fetchSolution";
+import { useGameState } from "../hooks/useGameState";
+
+import { Header, InfoPopUp, Game, Footer } from "../components";
+
+import * as Styled from "../app.styled";
+
+export function MVPage() {
+ const [todaysSolution, setTodaysSolution] =
+ React.useState<DailySolution | null>(null);
+
+ const firstRun = localStorage.getItem("firstRun") === null;
+
+ React.useEffect(() => {
+ getDailyMVSolution().then((solution) => setTodaysSolution(solution));
+ }, []);
+
+ const {
+ guesses,
+ currentTry,
+ setSelectedSong,
+ didGuess,
+ skip,
+ guess,
+ isSubmitting,
+ } = useGameState({
+ solution: todaysSolution?.song ?? null,
+ persist: true,
+ sessionDate: todaysSolution?.date,
+ sessionToken: todaysSolution?.sessionToken,
+ initialSig: todaysSolution?.initialSig,
+ mode: "dailyMV",
+ });
+
+ 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} gameMode="dailyMV" />}
+ <Styled.Container>
+ <Game
+ guesses={guesses}
+ didGuess={didGuess}
+ todaysSolution={todaysSolution.song}
+ dailyDate={todaysSolution.date}
+ currentTry={currentTry}
+ setSelectedSong={setSelectedSong}
+ skip={skip}
+ guess={guess}
+ mode="dailyMV"
+ isSubmitting={isSubmitting}
+ />
+ </Styled.Container>
+ <Footer />
+ </main>
+ );
+}
diff --git a/src/pages/UnlimitedPage.tsx b/src/pages/UnlimitedPage.tsx
index 761d2b9..23a4daf 100644
--- a/src/pages/UnlimitedPage.tsx
+++ b/src/pages/UnlimitedPage.tsx
@@ -58,7 +58,7 @@ export function UnlimitedPage() {
return (
<main>
<Header openInfoPopUp={openInfoPopUp} />
- {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} />}
+ {isInfoPopUpOpen && <InfoPopUp onClose={closeInfoPopUp} gameMode="unlimited" />}
<Styled.Container>
<Game
guesses={guesses}
send patches to the email below
yukais@pinapelz.com
include the subject [PATCH repo_name]
pinapelz.com
homepage