diff options
| -rw-r--r-- | playlist_generator/.gitignore | 3 | ||||
| -rw-r--r-- | playlist_generator/frame_generator.py | 85 | ||||
| -rw-r--r-- | playlist_generator/generate_daily.py | 68 | ||||
| -rw-r--r-- | playlist_generator/pyproject.toml | 2 | ||||
| -rw-r--r-- | playlist_generator/uv.lock | 62 | ||||
| -rw-r--r-- | server/index.ts | 9 | ||||
| -rw-r--r-- | src/app.tsx | 2 | ||||
| -rw-r--r-- | src/components/Game/index.tsx | 54 | ||||
| -rw-r--r-- | src/components/InfoPopUp/index.tsx | 9 | ||||
| -rw-r--r-- | src/components/MVPlayer/index.styled.ts | 73 | ||||
| -rw-r--r-- | src/components/MVPlayer/index.tsx | 85 | ||||
| -rw-r--r-- | src/components/Search/index.tsx | 8 | ||||
| -rw-r--r-- | src/components/index.ts | 1 | ||||
| -rw-r--r-- | src/helpers/fetchSolution.ts | 32 | ||||
| -rw-r--r-- | src/helpers/searchSong.ts | 5 | ||||
| -rw-r--r-- | src/hooks/useGameState.ts | 39 | ||||
| -rw-r--r-- | src/pages/DailyPage.tsx | 2 | ||||
| -rw-r--r-- | src/pages/LandingPage.tsx | 125 | ||||
| -rw-r--r-- | src/pages/MVPage.tsx | 76 | ||||
| -rw-r--r-- | src/pages/UnlimitedPage.tsx | 2 |
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} |
