mirror of
https://github.com/SoPat712/maisie-heardle.git
synced 2026-08-19 12:12:30 -04:00
fix(game): harden playback, persistence, and accessibility
This commit is contained in:
@@ -43,8 +43,9 @@ See it in action:
|
||||
- ❌ **Skips & Wrong Guesses**: Skip to unlock the next snippet; wrong guesses also unlock more audio.
|
||||
- ⏳ **Countdown**: Live countdown timer until the next daily track.
|
||||
- 🌙 **Dark Mode**: Auto‑detects system preference and can be toggled manually.
|
||||
- 💾 **Privacy First**: No analytics or personal data are stored. The app maintains only an
|
||||
anonymous, approximate aggregate of completed games.
|
||||
- 💾 **Privacy First**: No analytics or personal data are stored. Progress, settings, and stats
|
||||
stay in your browser; completed games only increment an anonymous aggregate counter. Audio is
|
||||
provided by SoundCloud.
|
||||
|
||||
---
|
||||
|
||||
@@ -67,8 +68,8 @@ See it in action:
|
||||
### Installation
|
||||
|
||||
```bash
|
||||
git clone https://github.com/joshpatra/maisie-peters-heardle.git
|
||||
cd maisie-peters-heardle
|
||||
git clone https://github.com/SoPat712/maisie-heardle.git
|
||||
cd maisie-heardle
|
||||
npm install
|
||||
```
|
||||
|
||||
@@ -78,7 +79,7 @@ npm install
|
||||
npm run dev -- --open
|
||||
```
|
||||
|
||||
Visit `http://localhost:5173` in your browser. The game will load the SoundCloud widget and pre‑warm the daily snippet.
|
||||
Visit `http://localhost:5173` in your browser. The game loads the daily track through the SoundCloud widget.
|
||||
|
||||
### Building for Production
|
||||
|
||||
|
||||
Generated
+144
-77
@@ -850,16 +850,16 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@netlify/otel": {
|
||||
"version": "6.0.3",
|
||||
"resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.3.tgz",
|
||||
"integrity": "sha512-NIjIjB/aItiXKB6+wzSwfSyYqbNsVSzzlEPryxxTC5ZJiYSYSm82wAOcQ+9VdiufQyZO5t8jzHVPULo7a9L9sQ==",
|
||||
"version": "6.0.5",
|
||||
"resolved": "https://registry.npmjs.org/@netlify/otel/-/otel-6.0.5.tgz",
|
||||
"integrity": "sha512-FUnQsG+xp5gljmHZgrBgGLYKmKYQlGvrao6gtKenJSPCYTMzsYB9PbFfjIoEvwS+o8DxuWDrHlRDDGU3o9ObhQ==",
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "1.9.0",
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/instrumentation": "^0.217.0",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-node": "2.7.1"
|
||||
"@opentelemetry/api": "1.9.1",
|
||||
"@opentelemetry/core": "2.8.0",
|
||||
"@opentelemetry/instrumentation": "^0.220.0",
|
||||
"@opentelemetry/resources": "2.9.0",
|
||||
"@opentelemetry/sdk-trace-node": "2.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.14.0 || >=20.6.1"
|
||||
@@ -875,18 +875,18 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api": {
|
||||
"version": "1.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.0.tgz",
|
||||
"integrity": "sha512-3giAOQvZiH5F9bMlMiv8+GSPMeqg0dbaeo58/0SlA9sxSqZhnUtxzX9/2FzyhS9sWQf5S0GJE0AKBrFqjpeYcg==",
|
||||
"version": "1.9.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api/-/api-1.9.1.tgz",
|
||||
"integrity": "sha512-gLyJlPHPZYdAk1JENA9LeHejZe1Ti77/pTeFm/nMXmQH/HFZlcS/O2XJB+L8fkbrNSqhdtlvjBVjxwUYanNH5Q==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=8.0.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/api-logs": {
|
||||
"version": "0.217.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.217.0.tgz",
|
||||
"integrity": "sha512-Cdq0jW2lknrNfrAm92MyEAvpe2cRsKjdnQLHUL6xRA4IVUnsWx6P65E7NcUO0Y+L4w1Aee5iV8FvjSwd+lrs9A==",
|
||||
"version": "0.220.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/api-logs/-/api-logs-0.220.0.tgz",
|
||||
"integrity": "sha512-CmVa4ImJ+ynfrPMNaAXHET6Bhb44SwzmfyVJFq9ni2jgXJR/l7C6gfVFddNmHP+ZOkP9cf4f9DBe68qVLTHc9w==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api": "^1.3.0"
|
||||
@@ -896,9 +896,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/context-async-hooks": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.7.1.tgz",
|
||||
"integrity": "sha512-OPFBYuXEn1E4ja3Y6eeA7O+ZnLBNcXTV5Cgsn1VaqBZ6hC5FnpZPLBNme1LJY8ZtF4aOujPKFoeWN4ik487KuQ==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/context-async-hooks/-/context-async-hooks-2.9.0.tgz",
|
||||
"integrity": "sha512-OQ0vzvbZBiUhjqLnUaoNfYmP8553Crr3aggB4y0ZUi815mZ7idpdJXQmoKdeBKJelYttoBlLSSHubmyw3wvX4w==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
@@ -908,9 +908,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/core": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.7.1.tgz",
|
||||
"integrity": "sha512-QAqIj32AtK6+pEVNG7EOVxHdE06RP+FM5qpiEJ4RtDcFIqKUZHYhl7/7UY5efhwmwNAg7j8QbJVBLxMerc0+gw==",
|
||||
"version": "2.8.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.8.0.tgz",
|
||||
"integrity": "sha512-hd1Lfh8p545nNz+jq1Ejfz+Mn1hyLuxYn1YzTfFNrxr8urEWMNQLPf1Th8kjOH+HxwawCrtgBp8JpBUR4ZSgww==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
@@ -923,12 +923,12 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/instrumentation": {
|
||||
"version": "0.217.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.217.0.tgz",
|
||||
"integrity": "sha512-24ucQMjz7Y34Kw3trbxL2ZrssbtgWnR+Clpaa+YdeWuuyH3Cvk23Q03PcQvqiZrDvt8AmQmjgg9v6Y9PHoxG7w==",
|
||||
"version": "0.220.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/instrumentation/-/instrumentation-0.220.0.tgz",
|
||||
"integrity": "sha512-xQx3E2WxP1mDvKzxLxX+CTCtNLa560YJZ3087qYHerl2YmiKpv7AH+dAy7vmx+eVrZ5BwhfWUAVoKOoxCNHcpw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/api-logs": "0.217.0",
|
||||
"@opentelemetry/api-logs": "0.220.0",
|
||||
"import-in-the-middle": "^3.0.0",
|
||||
"require-in-the-middle": "^8.0.0"
|
||||
},
|
||||
@@ -940,12 +940,44 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.7.1.tgz",
|
||||
"integrity": "sha512-DeT6KKolmC4e/dRQvMQ/RwlnzhaqeiFOXY5ngoOPJ07GgVVKxZOg9EcrNZb5aTzUn+iCrJldAgOfQm1O/QfPAQ==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/resources/-/resources-2.9.0.tgz",
|
||||
"integrity": "sha512-jyA5MBLQ+Dkl3+JsZkUoUvL7yHvU64kLsvpXKarWm6347Sl1t1bXFTFykUePNpT5WH5pm9a2Qtt03iIYQhZ1Fg==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/core": "2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/resources/node_modules/@opentelemetry/core": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
|
||||
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace/-/sdk-trace-2.9.0.tgz",
|
||||
"integrity": "sha512-sGA19HvtrrSKYsseHphluH6j3p6Xa3fqc7c7y8f/7mYWejc1lyDFcpSdD1kYa50HCLUeEo4zA5bW0pniaPszuw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.9.0",
|
||||
"@opentelemetry/resources": "2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -956,13 +988,14 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-base": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.7.1.tgz",
|
||||
"integrity": "sha512-NAYIlsF8MPUsKqJMiDQJTMPOmlbawC1Iz/omMLygZ1C9am8fTKYjTaI+OZM+WTY3t3Glo0wnOg/6/pac6RGPPw==",
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-base/-/sdk-trace-base-2.9.0.tgz",
|
||||
"integrity": "sha512-cp9zmTl62R8PJrpvFcmc8N2JQU/xfa0S+61q511Nji+QxCfZ8Ifvg7H27G8cANe4crg4RTrWsVvanHiXjSp6ag==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/resources": "2.7.1",
|
||||
"@opentelemetry/core": "2.9.0",
|
||||
"@opentelemetry/resources": "2.9.0",
|
||||
"@opentelemetry/sdk-trace": "2.9.0",
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
@@ -972,15 +1005,60 @@
|
||||
"@opentelemetry/api": ">=1.3.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-node": {
|
||||
"version": "2.7.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.7.1.tgz",
|
||||
"integrity": "sha512-pCpQxU68lV+I9s9svqMyVu5iHdDDUnqUpSxqwyCU8A9ejEsSnMPCbearwsUO4yk08ZJzAIUCFuReMdVQvHrdvg==",
|
||||
"node_modules/@opentelemetry/sdk-trace-base/node_modules/@opentelemetry/core": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
|
||||
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/context-async-hooks": "2.7.1",
|
||||
"@opentelemetry/core": "2.7.1",
|
||||
"@opentelemetry/sdk-trace-base": "2.7.1"
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-node": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/sdk-trace-node/-/sdk-trace-node-2.9.0.tgz",
|
||||
"integrity": "sha512-ec9a7ps37huy5itYk0MalaZdSLlM6AXWp/FhtEjgMpp5leEGojBDvAl/UWttQnkMZOvFHKzRESn8TD3yKTF5nQ==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/context-async-hooks": "2.9.0",
|
||||
"@opentelemetry/core": "2.9.0",
|
||||
"@opentelemetry/sdk-trace-base": "2.9.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace-node/node_modules/@opentelemetry/core": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
|
||||
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@opentelemetry/api": ">=1.0.0 <1.10.0"
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/sdk-trace/node_modules/@opentelemetry/core": {
|
||||
"version": "2.9.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/core/-/core-2.9.0.tgz",
|
||||
"integrity": "sha512-m2nckMT80NnmjTYSPjJQObBJ+8dgkoajEOUbznL8AHZ3T3yHRk2P7gI1PhEBc1+lOnrYE9UWrWHqJDsmqjmNbw==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@opentelemetry/semantic-conventions": "^1.29.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": "^18.19.0 || >=20.6.0"
|
||||
@@ -990,9 +1068,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@opentelemetry/semantic-conventions": {
|
||||
"version": "1.41.1",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.41.1.tgz",
|
||||
"integrity": "sha512-/UhIkaZgPutTFmQ7RnIJGgDXZmtEJ7Dvi86xNTFWcnRxVRNk/aotsqDJYeEvDP+FSMB2SdW+pQzNMcWP0rwuNA==",
|
||||
"version": "1.43.0",
|
||||
"resolved": "https://registry.npmjs.org/@opentelemetry/semantic-conventions/-/semantic-conventions-1.43.0.tgz",
|
||||
"integrity": "sha512-eSYWTm620tTk45EKSedaUL8MFYI8hW164hIXsgIHyxu3VobUB3fFCu5t0hQby6OoWRPsG1KkKUG2M5UadiLiVg==",
|
||||
"license": "Apache-2.0",
|
||||
"engines": {
|
||||
"node": ">=14"
|
||||
@@ -1392,9 +1470,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@sveltejs/kit": {
|
||||
"version": "2.70.1",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.1.tgz",
|
||||
"integrity": "sha512-nY9SPHGOZro3doud9vZXDBwl9tCZIouuJztjgSHs6PAIrv9M/z5O7eOhPV5xU7CgVHA976Jwu3BA1hIFvXztkA==",
|
||||
"version": "2.70.2",
|
||||
"resolved": "https://registry.npmjs.org/@sveltejs/kit/-/kit-2.70.2.tgz",
|
||||
"integrity": "sha512-RzRoRpuR2KXqc5yMO0akQHDZeT4AslOlznGITURsqHaVbtyYP4Wn3eE3gxj9JcDyNYO0crkxhdwFHc+2vkVm6w==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2007,9 +2085,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@typescript-eslint/typescript-estree/node_modules/brace-expansion": {
|
||||
"version": "2.1.2",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.2.tgz",
|
||||
"integrity": "sha512-w5JZcKgdhDOgOwm8H+KgbosopHMuGcl6qbulwjtz3SM7I7P3yW1eAjzMPLrIE+NQ9vjgANKHWeMHnrT0OXW1oA==",
|
||||
"version": "2.1.4",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.1.4.tgz",
|
||||
"integrity": "sha512-hGfVzPxthbf3+2yjg/RBs60cB0FhqBS/zvdV/4wn4/BmN0bNMMHPc4V/BbFieqf1TKAGGAHnY4eSjajCl0f2Xg==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2281,15 +2359,6 @@
|
||||
"node": ">=0.4.0"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-import-attributes": {
|
||||
"version": "1.9.5",
|
||||
"resolved": "https://registry.npmjs.org/acorn-import-attributes/-/acorn-import-attributes-1.9.5.tgz",
|
||||
"integrity": "sha512-n02Vykv5uA3eHGM/Z2dQrcD56kL8TyDb2p1+0P83PClMnC/nc+anbQRhIOWnSq4Ke/KvDPrY3C9hDtC/A3eHnQ==",
|
||||
"license": "MIT",
|
||||
"peerDependencies": {
|
||||
"acorn": "^8"
|
||||
}
|
||||
},
|
||||
"node_modules/acorn-jsx": {
|
||||
"version": "5.3.2",
|
||||
"resolved": "https://registry.npmjs.org/acorn-jsx/-/acorn-jsx-5.3.2.tgz",
|
||||
@@ -2395,9 +2464,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/brace-expansion": {
|
||||
"version": "1.1.16",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.16.tgz",
|
||||
"integrity": "sha512-IDw48K2/2kRkg9LdJxurvq3lV3aBgq0REY89duEqFRthjlPdXHKMj7EnQOXVckxzgisinf3nHfrcE2FufFLXMw==",
|
||||
"version": "1.1.18",
|
||||
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-1.1.18.tgz",
|
||||
"integrity": "sha512-Edep/X9fGqVNmzKBVsDYIOtD+z1tuezV70LBjdCst9Tqu76lsnvRiZ6oTic1n+/BIwX6QDGAO94PN4N2SADvtw==",
|
||||
"dev": true,
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
@@ -2681,7 +2750,6 @@
|
||||
"version": "2.3.1",
|
||||
"resolved": "https://registry.npmjs.org/es-module-lexer/-/es-module-lexer-2.3.1.tgz",
|
||||
"integrity": "sha512-shc1dbU90Yl/xq1QrC7QRtfcwURZuVRfPhZbDoldJ1cn1gzDvBaBWlv0eFolj5+0znnPJz5TXLxsN77X/12KTA==",
|
||||
"dev": true,
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/esbuild": {
|
||||
@@ -3174,14 +3242,13 @@
|
||||
}
|
||||
},
|
||||
"node_modules/import-in-the-middle": {
|
||||
"version": "3.2.0",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.2.0.tgz",
|
||||
"integrity": "sha512-vR2B6HKIhaBjcZr2bLpFiJ1VbzOlRQ7aby4/gw5WPIzToLjqpfWw3VJ4sk1uDchoOODEirvO2jyrSPtUSL5CrQ==",
|
||||
"version": "3.3.3",
|
||||
"resolved": "https://registry.npmjs.org/import-in-the-middle/-/import-in-the-middle-3.3.3.tgz",
|
||||
"integrity": "sha512-AiohS3H80sXO6owEltjGX+glb7qXaDhBoJb9XcQVH4UI207xu/bDLUcadVKp7Qe576reg9yr/PXZjV5qx8gfbA==",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"acorn": "^8.15.0",
|
||||
"acorn-import-attributes": "^1.9.5",
|
||||
"cjs-module-lexer": "^2.2.0",
|
||||
"es-module-lexer": "^2.2.0",
|
||||
"module-details-from-path": "^1.0.4"
|
||||
},
|
||||
"engines": {
|
||||
@@ -3703,9 +3770,9 @@
|
||||
"license": "MIT"
|
||||
},
|
||||
"node_modules/nanoid": {
|
||||
"version": "3.3.15",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.15.tgz",
|
||||
"integrity": "sha512-y7Wygv/7mEOvxTuEQDB8StXdMRBWf1kR/tlhAzBRUFkB2jfcLOAxO/SHmOO2zgz1pVgK29/kyupn059/bCHdjA==",
|
||||
"version": "3.3.16",
|
||||
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.16.tgz",
|
||||
"integrity": "sha512-bzlKTyNJ7+LdGIIwy8ijFpIqEQIvafahV7eYykJ8Cvh42EdJeODoJ6gUJXpQJvej1BddH8OqTXZNE/KfbWAu8Q==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3862,9 +3929,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/postcss": {
|
||||
"version": "8.5.16",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.16.tgz",
|
||||
"integrity": "sha512-vuwillviilfKZsg0VGj5R/YwwcHx4SLsIOI/7K6mQkWx+l5cUHTjj5g0AasTBcyXsbfTgrwsUNmVUb5xVwyPwg==",
|
||||
"version": "8.5.25",
|
||||
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.5.25.tgz",
|
||||
"integrity": "sha512-DTPx3RWSSnWyzLxQnlH0rJP+EW5ekl16ZU4/psbIhA0e53kJfdgaN5vKM+xP7yJtXVu+nfdVFmlgFDEKAe4Pyw==",
|
||||
"dev": true,
|
||||
"funding": [
|
||||
{
|
||||
@@ -3882,7 +3949,7 @@
|
||||
],
|
||||
"license": "MIT",
|
||||
"dependencies": {
|
||||
"nanoid": "^3.3.12",
|
||||
"nanoid": "^3.3.16",
|
||||
"picocolors": "^1.1.1",
|
||||
"source-map-js": "^1.2.1"
|
||||
},
|
||||
@@ -4473,9 +4540,9 @@
|
||||
}
|
||||
},
|
||||
"node_modules/tar": {
|
||||
"version": "7.5.19",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.19.tgz",
|
||||
"integrity": "sha512-4LeEWl96twnS2Q7Bz4MGqgazLqO+hJN63GZxXoIqh1T3VweYD997gbU1ItNsQafqqXTXd5WFyFdReLtwvRBNiw==",
|
||||
"version": "7.5.22",
|
||||
"resolved": "https://registry.npmjs.org/tar/-/tar-7.5.22.tgz",
|
||||
"integrity": "sha512-MFO/QzvtAOmJbkhOaCTvbGcFN9L9b+JunIsDwaKljSOdcLMea3NJ1k9Usz/rjdfSXTq4dfzfeS7W4p4YOAAHeA==",
|
||||
"dev": true,
|
||||
"license": "BlueOak-1.0.0",
|
||||
"dependencies": {
|
||||
|
||||
+1
-7
@@ -1,11 +1,5 @@
|
||||
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@400;600;800&display=swap');
|
||||
@import 'tailwindcss';
|
||||
/* load any custom fonts here, e.g.: */
|
||||
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
html {
|
||||
font-family: 'Inter', sans-serif;
|
||||
font-family: ui-sans-serif, system-ui, sans-serif;
|
||||
}
|
||||
|
||||
Vendored
-11
@@ -1,5 +1,3 @@
|
||||
// See https://svelte.dev/docs/kit/types#app.d.ts
|
||||
// for information about these interfaces
|
||||
declare global {
|
||||
type SoundCloudSound = {
|
||||
artwork_url?: string;
|
||||
@@ -15,7 +13,6 @@ declare global {
|
||||
getDuration: (callback: (duration: number) => void) => void;
|
||||
getCurrentSound: (callback: (sound: SoundCloudSound) => void) => void;
|
||||
getPosition: (callback: (position: number) => void) => void;
|
||||
isPaused: (callback: (paused: boolean) => void) => void;
|
||||
play: () => void;
|
||||
pause: () => void;
|
||||
seekTo: (milliseconds: number) => void;
|
||||
@@ -31,14 +28,6 @@ declare global {
|
||||
interface Window {
|
||||
SC?: SoundCloudApi;
|
||||
}
|
||||
|
||||
namespace App {
|
||||
// interface Error {}
|
||||
// interface Locals {}
|
||||
// interface PageData {}
|
||||
// interface PageState {}
|
||||
// interface Platform {}
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
|
||||
@@ -4,8 +4,6 @@
|
||||
<meta charset="utf-8" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||
%sveltekit.head%
|
||||
<!-- SoundCloud Widget API -->
|
||||
<script src="https://w.soundcloud.com/player/api.js"></script>
|
||||
</head>
|
||||
<body data-sveltekit-preload-data="hover">
|
||||
<div style="display: contents">%sveltekit.body%</div>
|
||||
|
||||
+311
-286
@@ -20,10 +20,17 @@
|
||||
tracks,
|
||||
type Track
|
||||
} from '$lib/tracks';
|
||||
import { calculateNextStats, createEmptyStats, type Stats } from '$lib/game-state';
|
||||
import {
|
||||
calculateNextStats,
|
||||
createEmptyStats,
|
||||
parseSavedGame,
|
||||
sanitizeStats,
|
||||
type AttemptInfo,
|
||||
type SavedGame,
|
||||
type Stats
|
||||
} from '$lib/game-state';
|
||||
|
||||
const COLORS = {
|
||||
background: '#ffffff',
|
||||
panel: '#ffffff',
|
||||
text: '#171717',
|
||||
muted: '#5f6268',
|
||||
@@ -38,6 +45,7 @@
|
||||
const currentTrack = getDailyTrack(todayKey);
|
||||
const storageKey = `maisie-heardle:game:${todayKey}:${currentTrack.slug}`;
|
||||
const statsKey = 'maisie-heardle:stats:v1';
|
||||
const themeKey = 'maisie-heardle:theme';
|
||||
|
||||
const SEGMENT_INCREMENTS = [2, 1, 2, 3, 4, 5];
|
||||
let total = 0;
|
||||
@@ -52,19 +60,9 @@
|
||||
const SEEK_FLICK_TURNS = 3;
|
||||
const SEEK_FLICK_DURATION = 850;
|
||||
|
||||
type AttemptInfo = { status: 'skip' | 'wrong' | 'correct'; title?: string };
|
||||
type SavedGame = {
|
||||
attemptInfos: AttemptInfo[];
|
||||
attemptCount: number;
|
||||
gameOver: boolean;
|
||||
message: string;
|
||||
won: boolean;
|
||||
statsRecorded: boolean;
|
||||
};
|
||||
let attemptInfos: AttemptInfo[] = [];
|
||||
let attemptCount = 0;
|
||||
let gameOver = false;
|
||||
let message = '';
|
||||
let won = false;
|
||||
let statsRecorded = false;
|
||||
let stats: Stats = createEmptyStats(maxAttempts);
|
||||
@@ -76,8 +74,14 @@
|
||||
let loading = true;
|
||||
let widgetError = '';
|
||||
let widgetReadyTimeout: ReturnType<typeof setTimeout>;
|
||||
let resumePlaybackTimeout: ReturnType<typeof setTimeout>;
|
||||
let warmupPlayTimeout: ReturnType<typeof setTimeout>;
|
||||
let warmupPauseTimeout: ReturnType<typeof setTimeout>;
|
||||
let warmupSeekTimeout: ReturnType<typeof setTimeout>;
|
||||
let artworkUrl = '';
|
||||
let isPlaying = false;
|
||||
let hasPlayed = false;
|
||||
let isWarmingUp = false;
|
||||
let currentPosition = 0;
|
||||
let progressInterval: ReturnType<typeof setInterval>;
|
||||
let fullDuration = 0;
|
||||
@@ -91,14 +95,16 @@
|
||||
let vinylSeekAnimation: Animation | undefined;
|
||||
let isVinylSeekAnimating = false;
|
||||
let seekPointerId: number | undefined;
|
||||
let seekStartPosition = 0;
|
||||
let waveformHeights = [4, 4, 4, 4, 4, 4, 4];
|
||||
let waveformInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
let isWarmingUp = false;
|
||||
let showHowTo = false;
|
||||
let showInfo = false;
|
||||
let globalGamesPlayed: number | null = null;
|
||||
let darkMode = false;
|
||||
let themeOverride: 'dark' | 'light' | null = null;
|
||||
let reducedMotion = false;
|
||||
let userInput = '';
|
||||
let suggestions: Track[] = [];
|
||||
let selectedTrack: Track | null = null;
|
||||
@@ -112,8 +118,7 @@
|
||||
|
||||
let timeLeft = '';
|
||||
let countdownInterval: ReturnType<typeof setInterval>;
|
||||
let attemptHistoryEl: HTMLDivElement;
|
||||
let attemptPanelEl: HTMLElement;
|
||||
let attemptHistoryEl: HTMLOListElement;
|
||||
let resultPanelEl: HTMLElement;
|
||||
let gameGridEl: HTMLDivElement;
|
||||
let gameScrollEl: HTMLDivElement;
|
||||
@@ -123,9 +128,9 @@
|
||||
let layoutObserver: ResizeObserver | undefined;
|
||||
let middleScrollable = false;
|
||||
let gameScrollFill = false;
|
||||
let destroyed = false;
|
||||
|
||||
const boundaries = segmentDurations.map((ms) => ms / 1000).slice(0, -1);
|
||||
$: activeLimit = gameOver ? fullDuration || TOTAL_MS : segmentDurations[attemptCount] || TOTAL_MS;
|
||||
$: progressDuration = gameOver ? fullDuration || TOTAL_MS : TOTAL_MS;
|
||||
$: fillPercent = Math.min((currentPosition / progressDuration) * 100, 100);
|
||||
$: unlockedSeconds = Math.round((segmentDurations[attemptCount] || TOTAL_MS) / 1000);
|
||||
@@ -140,6 +145,11 @@
|
||||
);
|
||||
$: canSubmit = Boolean(selectedTrack || exactMatch) && !gameOver && !loading && !widgetError;
|
||||
$: resultLabel = won ? `${attemptCount}/${maxAttempts}` : `X/${maxAttempts}`;
|
||||
$: resultMessage = gameOver
|
||||
? won
|
||||
? `Correct. It was “${currentTrack.title}.”`
|
||||
: `Out of tries. It was “${currentTrack.title}.”`
|
||||
: '';
|
||||
$: suggestions =
|
||||
userInput && !selectedTrack
|
||||
? tracks
|
||||
@@ -154,20 +164,18 @@
|
||||
$: if (hydrated) {
|
||||
saveGame({
|
||||
attemptInfos,
|
||||
attemptCount,
|
||||
gameOver,
|
||||
message,
|
||||
won,
|
||||
statsRecorded
|
||||
});
|
||||
}
|
||||
|
||||
$: if (typeof window !== 'undefined' && !isVinylSeekAnimating) {
|
||||
handlePlayStateChange(isPlaying);
|
||||
handlePlayStateChange(isPlaying, reducedMotion, gameOver);
|
||||
}
|
||||
|
||||
$: if (typeof window !== 'undefined') {
|
||||
driveWaveform(isPlaying);
|
||||
driveWaveform(isPlaying && !reducedMotion);
|
||||
}
|
||||
|
||||
$: if (hydrated) {
|
||||
@@ -211,11 +219,76 @@
|
||||
}
|
||||
}
|
||||
|
||||
function readStorage(key: string) {
|
||||
try {
|
||||
return localStorage.getItem(key);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function writeStorage(key: string, value: string) {
|
||||
try {
|
||||
localStorage.setItem(key, value);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function removeStorage(key: string) {
|
||||
try {
|
||||
localStorage.removeItem(key);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
function toggleTheme() {
|
||||
darkMode = !darkMode;
|
||||
themeOverride = darkMode ? 'dark' : 'light';
|
||||
writeStorage(themeKey, themeOverride);
|
||||
}
|
||||
|
||||
function handleDialogKeydown(event: KeyboardEvent) {
|
||||
if (!showHowTo && !showInfo) return;
|
||||
if (event.key === 'Escape') {
|
||||
closeDialog();
|
||||
return;
|
||||
}
|
||||
if (event.key !== 'Tab' || !dialogEl) return;
|
||||
|
||||
const focusable = Array.from(
|
||||
dialogEl.querySelectorAll<HTMLElement>('a[href], button:not([disabled]), [tabindex="0"]')
|
||||
);
|
||||
if (!focusable.length) {
|
||||
event.preventDefault();
|
||||
return;
|
||||
}
|
||||
|
||||
const first = focusable[0];
|
||||
const last = focusable.at(-1)!;
|
||||
if (
|
||||
event.shiftKey &&
|
||||
(document.activeElement === first || document.activeElement === dialogEl)
|
||||
) {
|
||||
event.preventDefault();
|
||||
last.focus();
|
||||
} else if (!event.shiftKey && document.activeElement === last) {
|
||||
event.preventDefault();
|
||||
first.focus();
|
||||
}
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
darkMode = window.matchMedia('(prefers-color-scheme: dark)').matches;
|
||||
const colorPreference = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const motionPreference = window.matchMedia('(prefers-reduced-motion: reduce)');
|
||||
const savedTheme = readStorage(themeKey);
|
||||
themeOverride = savedTheme === 'dark' || savedTheme === 'light' ? savedTheme : null;
|
||||
darkMode = themeOverride ? themeOverride === 'dark' : colorPreference.matches;
|
||||
reducedMotion = motionPreference.matches;
|
||||
stats = loadStats();
|
||||
loadSavedGame();
|
||||
const savedVol = localStorage.getItem('heardle-volume');
|
||||
const savedVol = readStorage('heardle-volume');
|
||||
if (savedVol !== null) {
|
||||
const parsedVolume = Number.parseInt(savedVol, 10);
|
||||
if (Number.isFinite(parsedVolume)) {
|
||||
@@ -224,9 +297,12 @@
|
||||
}
|
||||
hydrated = true;
|
||||
|
||||
const colorPreference = window.matchMedia('(prefers-color-scheme: dark)');
|
||||
const onPreferenceChange = (event: MediaQueryListEvent) => (darkMode = event.matches);
|
||||
const onPreferenceChange = (event: MediaQueryListEvent) => {
|
||||
if (!themeOverride) darkMode = event.matches;
|
||||
};
|
||||
const onMotionChange = (event: MediaQueryListEvent) => (reducedMotion = event.matches);
|
||||
colorPreference.addEventListener('change', onPreferenceChange);
|
||||
motionPreference.addEventListener('change', onMotionChange);
|
||||
|
||||
updateTime();
|
||||
countdownInterval = setInterval(updateTime, 1000);
|
||||
@@ -238,17 +314,15 @@
|
||||
layoutObserver = new ResizeObserver(() => updateGameLayout());
|
||||
}
|
||||
const onResize = () => updateGameLayout();
|
||||
const onKeydown = (event: KeyboardEvent) => {
|
||||
if (event.key === 'Escape' && (showHowTo || showInfo)) closeDialog();
|
||||
};
|
||||
window.addEventListener('resize', onResize);
|
||||
window.addEventListener('keydown', onKeydown);
|
||||
window.addEventListener('keydown', handleDialogKeydown);
|
||||
|
||||
return () => {
|
||||
colorPreference.removeEventListener('change', onPreferenceChange);
|
||||
motionPreference.removeEventListener('change', onMotionChange);
|
||||
layoutObserver?.disconnect();
|
||||
window.removeEventListener('resize', onResize);
|
||||
window.removeEventListener('keydown', onKeydown);
|
||||
window.removeEventListener('keydown', handleDialogKeydown);
|
||||
};
|
||||
});
|
||||
|
||||
@@ -273,6 +347,7 @@
|
||||
async function loadGlobalGamesPlayed() {
|
||||
try {
|
||||
const res = await fetch('/api/stats/games-played', { cache: 'no-store' });
|
||||
if (destroyed) return;
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { count?: number };
|
||||
globalGamesPlayed = data.count ?? 0;
|
||||
@@ -287,7 +362,7 @@
|
||||
|
||||
async function recordGlobalGameFinish() {
|
||||
const countedKey = `maisie-heardle:global-finish-counted:${todayKey}`;
|
||||
if (localStorage.getItem(countedKey)) {
|
||||
if (readStorage(countedKey)) {
|
||||
await loadGlobalGamesPlayed();
|
||||
return;
|
||||
}
|
||||
@@ -297,10 +372,11 @@
|
||||
method: 'POST',
|
||||
cache: 'no-store'
|
||||
});
|
||||
if (destroyed) return;
|
||||
if (res.ok) {
|
||||
const data = (await res.json()) as { count?: number };
|
||||
globalGamesPlayed = data.count ?? 0;
|
||||
localStorage.setItem(countedKey, 'true');
|
||||
writeStorage(countedKey, 'true');
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to record global games played counter:', err);
|
||||
@@ -312,6 +388,7 @@
|
||||
const response = await fetch(
|
||||
`/api/soundcloud/oembed?url=${encodeURIComponent(currentTrack.url)}`
|
||||
);
|
||||
if (destroyed) return;
|
||||
if (response.ok) {
|
||||
const data = (await response.json()) as { thumbnailUrl?: string };
|
||||
if (data.thumbnailUrl) {
|
||||
@@ -326,6 +403,7 @@
|
||||
async function initializeWidget() {
|
||||
try {
|
||||
await loadSoundCloudApi();
|
||||
if (destroyed) return;
|
||||
const soundcloud = window.SC;
|
||||
if (!soundcloud) throw new Error('SoundCloud player API did not initialize.');
|
||||
|
||||
@@ -338,6 +416,7 @@
|
||||
widgetError = 'SoundCloud took too long to load. Please reload and try again.';
|
||||
}, 15_000);
|
||||
} catch (error) {
|
||||
if (destroyed) return;
|
||||
loading = false;
|
||||
widgetError =
|
||||
error instanceof Error
|
||||
@@ -347,12 +426,21 @@
|
||||
}
|
||||
|
||||
onDestroy(() => {
|
||||
destroyed = true;
|
||||
stopAllTimers();
|
||||
clearInterval(countdownInterval);
|
||||
clearTimeout(widgetReadyTimeout);
|
||||
clearTimeout(resumePlaybackTimeout);
|
||||
stopWidgetWarmup();
|
||||
cancelVinylSeekAnimation();
|
||||
if (widget?.unbind && window.SC?.Widget?.Events) {
|
||||
Object.values(window.SC.Widget.Events).forEach((eventName) => widget.unbind(eventName));
|
||||
Object.values(window.SC.Widget.Events).forEach((eventName) => {
|
||||
try {
|
||||
widget.unbind(eventName);
|
||||
} catch {
|
||||
return;
|
||||
}
|
||||
});
|
||||
}
|
||||
if (typeof cancelAnimationFrame !== 'undefined') {
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
@@ -363,11 +451,9 @@
|
||||
function driveWaveform(playing: boolean) {
|
||||
clearInterval(waveformInterval);
|
||||
if (playing) {
|
||||
// Seed with an initial burst
|
||||
waveformHeights = waveformHeights.map(() => 4 + Math.random() * 24);
|
||||
waveformInterval = setInterval(() => {
|
||||
waveformHeights = waveformHeights.map((prev) => {
|
||||
// Organic: weighted blend of previous height + new random target
|
||||
const target = 3 + Math.random() * 28;
|
||||
const inertia = 0.3 + Math.random() * 0.35;
|
||||
return prev * inertia + target * (1 - inertia);
|
||||
@@ -381,15 +467,21 @@
|
||||
function handleVolumeChange(e: Event) {
|
||||
const target = e.target as HTMLInputElement;
|
||||
volume = parseInt(target.value);
|
||||
if (typeof window !== 'undefined') {
|
||||
localStorage.setItem('heardle-volume', volume.toString());
|
||||
}
|
||||
writeStorage('heardle-volume', volume.toString());
|
||||
if (widget && widget.setVolume) {
|
||||
widget.setVolume(volume);
|
||||
}
|
||||
}
|
||||
|
||||
function handlePlayStateChange(playing: boolean) {
|
||||
function handlePlayStateChange(playing: boolean, shouldReduceMotion: boolean, ended: boolean) {
|
||||
if (shouldReduceMotion) {
|
||||
cancelVinylSeekAnimation();
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
vinylSpinTransition = 'none';
|
||||
setVinylRotation(0);
|
||||
return;
|
||||
}
|
||||
|
||||
if (playing) {
|
||||
cancelVinylSeekAnimation();
|
||||
lastFrameTime = 0;
|
||||
@@ -401,7 +493,7 @@
|
||||
|
||||
cancelAnimationFrame(animationFrameId);
|
||||
|
||||
if (gameOver) {
|
||||
if (ended) {
|
||||
vinylSpinTransition = 'none';
|
||||
return;
|
||||
}
|
||||
@@ -462,41 +554,35 @@
|
||||
const events = soundcloud.Widget.Events;
|
||||
|
||||
widget.bind(events.READY, () => {
|
||||
if (destroyed) return;
|
||||
clearTimeout(widgetReadyTimeout);
|
||||
widget.setVolume(volume);
|
||||
widget.getDuration((duration: number) => {
|
||||
if (destroyed) return;
|
||||
if (!Number.isFinite(duration) || duration <= 0) {
|
||||
loading = false;
|
||||
widgetError = 'SoundCloud did not return a playable song duration.';
|
||||
return;
|
||||
}
|
||||
fullDuration = duration;
|
||||
warmUpWidget();
|
||||
});
|
||||
widget.getCurrentSound((sound: { artwork_url?: string }) => {
|
||||
if (destroyed) return;
|
||||
if (sound?.artwork_url) {
|
||||
artworkUrl = sound.artwork_url;
|
||||
}
|
||||
});
|
||||
|
||||
isWarmingUp = true;
|
||||
setTimeout(() => {
|
||||
widget.play();
|
||||
setTimeout(() => {
|
||||
widget.pause();
|
||||
setTimeout(() => {
|
||||
widget.seekTo(0);
|
||||
loading = false;
|
||||
widgetReady = true;
|
||||
isPlaying = false;
|
||||
currentPosition = 0;
|
||||
isWarmingUp = false;
|
||||
}, 200);
|
||||
}, 300);
|
||||
}, 500);
|
||||
});
|
||||
|
||||
widget.bind(events.PLAY, () => {
|
||||
if (!isWarmingUp && !isPlaying) startPolling();
|
||||
hasPlayed = true;
|
||||
if (isWarmingUp) return;
|
||||
if (!isPlaying) startPolling();
|
||||
});
|
||||
|
||||
widget.bind(events.PAUSE, () => {
|
||||
if (isWarmingUp) return;
|
||||
|
||||
if (isPlaying) stopAllTimers();
|
||||
});
|
||||
|
||||
@@ -521,6 +607,8 @@
|
||||
|
||||
if (events.ERROR) {
|
||||
widget.bind(events.ERROR, () => {
|
||||
clearTimeout(widgetReadyTimeout);
|
||||
stopWidgetWarmup();
|
||||
stopAllTimers();
|
||||
loading = false;
|
||||
widgetError = 'SoundCloud could not play today’s song. Try reloading the page.';
|
||||
@@ -528,6 +616,36 @@
|
||||
}
|
||||
}
|
||||
|
||||
function warmUpWidget() {
|
||||
isWarmingUp = true;
|
||||
widget.setVolume(0);
|
||||
warmupPlayTimeout = setTimeout(() => {
|
||||
if (destroyed) return;
|
||||
widget.play();
|
||||
warmupPauseTimeout = setTimeout(() => {
|
||||
if (destroyed) return;
|
||||
widget.pause();
|
||||
warmupSeekTimeout = setTimeout(() => {
|
||||
if (destroyed) return;
|
||||
widget.seekTo(0);
|
||||
widget.setVolume(volume);
|
||||
loading = false;
|
||||
widgetReady = true;
|
||||
isPlaying = false;
|
||||
currentPosition = 0;
|
||||
isWarmingUp = false;
|
||||
}, 200);
|
||||
}, 300);
|
||||
}, 500);
|
||||
}
|
||||
|
||||
function stopWidgetWarmup() {
|
||||
clearTimeout(warmupPlayTimeout);
|
||||
clearTimeout(warmupPauseTimeout);
|
||||
clearTimeout(warmupSeekTimeout);
|
||||
isWarmingUp = false;
|
||||
}
|
||||
|
||||
function updateTime() {
|
||||
if (getLocalDateKey() !== todayKey) {
|
||||
window.location.reload();
|
||||
@@ -553,7 +671,7 @@
|
||||
progressInterval = setInterval(() => {
|
||||
if (!widget) return;
|
||||
widget.getPosition((position: number) => {
|
||||
currentPosition = position;
|
||||
if (!destroyed) currentPosition = position;
|
||||
});
|
||||
}, 100);
|
||||
}
|
||||
@@ -563,37 +681,21 @@
|
||||
clearInterval(progressInterval);
|
||||
}
|
||||
|
||||
function ensurePlayState() {
|
||||
if (!widget) return;
|
||||
widget.isPaused((paused: boolean) => {
|
||||
if (!paused && !isPlaying) {
|
||||
startPolling();
|
||||
} else if (paused && isPlaying) {
|
||||
stopAllTimers();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
function playSegment(seekToStart = true) {
|
||||
if (!widgetReady || loading || widgetError) return;
|
||||
stopAllTimers();
|
||||
if (seekToStart) {
|
||||
if (seekToStart && hasPlayed) {
|
||||
currentPosition = 0;
|
||||
widget.seekTo(0);
|
||||
}
|
||||
widget.play();
|
||||
startPolling();
|
||||
hasPlayed = true;
|
||||
}
|
||||
|
||||
function togglePlayPause() {
|
||||
if (!widgetReady || loading || widgetError) return;
|
||||
ensurePlayState();
|
||||
|
||||
if (isPlaying) {
|
||||
widget.pause();
|
||||
} else {
|
||||
playSegment(!gameOver);
|
||||
}
|
||||
if (isPlaying) widget.pause();
|
||||
else playSegment(!gameOver);
|
||||
}
|
||||
|
||||
function cancelVinylSeekAnimation() {
|
||||
@@ -616,6 +718,7 @@
|
||||
}
|
||||
|
||||
function spinVinylDuringScrub(deltaPosition: number, fallbackDirection = 1) {
|
||||
if (reducedMotion) return;
|
||||
if (Math.abs(deltaPosition) < 16 && Math.abs(fallbackDirection) < 1) return;
|
||||
cancelVinylSeekAnimation();
|
||||
|
||||
@@ -626,6 +729,12 @@
|
||||
}
|
||||
|
||||
function animateVinylSeekToPosition(targetPosition: number, fromPosition: number) {
|
||||
if (reducedMotion) {
|
||||
cancelVinylSeekAnimation();
|
||||
setVinylRotation(0);
|
||||
return;
|
||||
}
|
||||
|
||||
const fromRotation = vinylRotation;
|
||||
const deltaPosition = targetPosition - fromPosition;
|
||||
const direction = deltaPosition === 0 ? 1 : Math.sign(deltaPosition);
|
||||
@@ -717,6 +826,7 @@
|
||||
event.preventDefault();
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
seekPointerId = event.pointerId;
|
||||
seekStartPosition = currentPosition;
|
||||
element.setPointerCapture(event.pointerId);
|
||||
previewSeekPosition(event.clientX, element);
|
||||
}
|
||||
@@ -726,13 +836,28 @@
|
||||
previewSeekPosition(event.clientX, event.currentTarget as HTMLElement);
|
||||
}
|
||||
|
||||
function releaseSeekPointer(event: PointerEvent) {
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
if (element.hasPointerCapture(event.pointerId)) element.releasePointerCapture(event.pointerId);
|
||||
seekPointerId = undefined;
|
||||
}
|
||||
|
||||
function handleSeekPointerEnd(event: PointerEvent) {
|
||||
if (seekPointerId !== event.pointerId) return;
|
||||
const element = event.currentTarget as HTMLElement;
|
||||
previewSeekPosition(event.clientX, element);
|
||||
element.releasePointerCapture(event.pointerId);
|
||||
seekPointerId = undefined;
|
||||
seekToPosition(currentPosition, { animateVinyl: true });
|
||||
const nextPosition = currentPosition;
|
||||
currentPosition = seekStartPosition;
|
||||
releaseSeekPointer(event);
|
||||
seekToPosition(nextPosition);
|
||||
}
|
||||
|
||||
function handleSeekPointerCancel(event: PointerEvent) {
|
||||
if (seekPointerId !== event.pointerId) return;
|
||||
const nextPosition = currentPosition;
|
||||
currentPosition = seekStartPosition;
|
||||
releaseSeekPointer(event);
|
||||
seekToPosition(nextPosition);
|
||||
}
|
||||
|
||||
function handleSeekKeydown(event: KeyboardEvent) {
|
||||
@@ -747,37 +872,32 @@
|
||||
if (nextPosition === undefined) return;
|
||||
|
||||
event.preventDefault();
|
||||
seekToPosition(nextPosition, { animateVinyl: true });
|
||||
seekToPosition(nextPosition);
|
||||
}
|
||||
|
||||
function rewindSong() {
|
||||
if (!widgetReady || !gameOver) return;
|
||||
const wasPlaying = isPlaying;
|
||||
seekToPosition(0);
|
||||
if (wasPlaying) setTimeout(() => widget.play(), 150);
|
||||
}
|
||||
|
||||
function seekToPosition(
|
||||
position: number,
|
||||
options: { animateVinyl?: boolean; seekWidget?: boolean } = {}
|
||||
) {
|
||||
const { animateVinyl = true, seekWidget = true } = options;
|
||||
function seekToPosition(position: number, animateVinyl = true) {
|
||||
const duration = fullDuration || TOTAL_MS;
|
||||
const nextPosition = Math.min(Math.max(position, 0), duration);
|
||||
const wasPlaying = isPlaying;
|
||||
const previousPosition = currentPosition;
|
||||
currentPosition = nextPosition;
|
||||
|
||||
if (seekWidget) {
|
||||
widget.seekTo(nextPosition);
|
||||
}
|
||||
widget.seekTo(nextPosition);
|
||||
|
||||
if (gameOver) {
|
||||
syncVinylToPosition(nextPosition, animateVinyl, previousPosition);
|
||||
}
|
||||
|
||||
clearTimeout(resumePlaybackTimeout);
|
||||
if (wasPlaying) {
|
||||
setTimeout(() => widget.play(), 75);
|
||||
resumePlaybackTimeout = setTimeout(() => {
|
||||
if (!destroyed) widget.play();
|
||||
}, 75);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -827,20 +947,11 @@
|
||||
function finishGame(didWin: boolean) {
|
||||
won = didWin;
|
||||
gameOver = true;
|
||||
message = didWin
|
||||
? `Correct. It was “${currentTrack.title}.”`
|
||||
: `Out of tries. It was “${currentTrack.title}.”`;
|
||||
stopAllTimers();
|
||||
widget?.pause();
|
||||
|
||||
if (didWin) {
|
||||
currentPosition = 0;
|
||||
widget?.seekTo(0);
|
||||
syncVinylToPosition(0, false);
|
||||
} else {
|
||||
currentPosition = fullDuration || segmentDurations[attemptCount - 1] || TOTAL_MS;
|
||||
syncVinylToPosition(currentPosition, false);
|
||||
}
|
||||
currentPosition = 0;
|
||||
widget?.seekTo(0);
|
||||
setVinylRotation(0);
|
||||
|
||||
if (!statsRecorded) {
|
||||
stats = recordStats(didWin, attemptCount);
|
||||
@@ -873,11 +984,13 @@
|
||||
dialogEl?.focus();
|
||||
}
|
||||
|
||||
function closeDialog() {
|
||||
async function closeDialog() {
|
||||
const returnFocus = previouslyFocusedElement;
|
||||
showHowTo = false;
|
||||
showInfo = false;
|
||||
previouslyFocusedElement?.focus();
|
||||
previouslyFocusedElement = null;
|
||||
await tick();
|
||||
returnFocus?.focus();
|
||||
}
|
||||
|
||||
async function loadSuggestionArtwork(nextSuggestions: Track[]) {
|
||||
@@ -902,6 +1015,7 @@
|
||||
}
|
||||
})
|
||||
);
|
||||
if (destroyed) return;
|
||||
|
||||
suggestionArtwork = {
|
||||
...suggestionArtwork,
|
||||
@@ -936,7 +1050,8 @@
|
||||
|
||||
await navigator.clipboard.writeText(text);
|
||||
shareMessage = 'Copied result.';
|
||||
} catch {
|
||||
} catch (error) {
|
||||
if (error instanceof DOMException && error.name === 'AbortError') return;
|
||||
shareMessage = 'Could not share from this browser.';
|
||||
}
|
||||
}
|
||||
@@ -955,82 +1070,55 @@
|
||||
|
||||
function loadSavedGame() {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
const saved = readStorage(storageKey);
|
||||
if (!saved) return;
|
||||
|
||||
const parsed = JSON.parse(saved) as SavedGame;
|
||||
attemptInfos = Array.isArray(parsed.attemptInfos)
|
||||
? parsed.attemptInfos
|
||||
.filter(
|
||||
(attempt): attempt is AttemptInfo =>
|
||||
attempt?.status === 'skip' ||
|
||||
attempt?.status === 'wrong' ||
|
||||
attempt?.status === 'correct'
|
||||
)
|
||||
.slice(0, maxAttempts)
|
||||
: [];
|
||||
attemptCount = Math.min(
|
||||
Math.max(
|
||||
Number.isInteger(parsed.attemptCount) ? parsed.attemptCount : attemptInfos.length,
|
||||
0
|
||||
),
|
||||
maxAttempts
|
||||
);
|
||||
gameOver = Boolean(parsed.gameOver);
|
||||
message = parsed.message || '';
|
||||
won = Boolean(parsed.won);
|
||||
statsRecorded = Boolean(parsed.statsRecorded);
|
||||
if (attemptCount !== attemptInfos.length || (won && !gameOver)) {
|
||||
throw new Error('Saved game state is inconsistent.');
|
||||
const parsed = parseSavedGame(JSON.parse(saved), maxAttempts);
|
||||
if (!parsed) throw new Error('Saved game state is inconsistent.');
|
||||
if (
|
||||
parsed.won &&
|
||||
normalizeTrackTitle(parsed.attemptInfos.at(-1)?.title ?? '') !==
|
||||
normalizeTrackTitle(currentTrack.title)
|
||||
) {
|
||||
throw new Error('Saved winning answer does not match today’s track.');
|
||||
}
|
||||
|
||||
attemptInfos = parsed.attemptInfos;
|
||||
attemptCount = attemptInfos.length;
|
||||
gameOver = parsed.gameOver;
|
||||
won = parsed.won;
|
||||
statsRecorded = parsed.statsRecorded;
|
||||
if (gameOver && !statsRecorded) {
|
||||
stats = recordStats(won, attemptCount);
|
||||
statsRecorded = true;
|
||||
void recordGlobalGameFinish();
|
||||
}
|
||||
} catch {
|
||||
attemptInfos = [];
|
||||
attemptCount = 0;
|
||||
gameOver = false;
|
||||
message = '';
|
||||
won = false;
|
||||
statsRecorded = false;
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch {
|
||||
// Storage may be unavailable in privacy-restricted browser contexts.
|
||||
}
|
||||
removeStorage(storageKey);
|
||||
}
|
||||
}
|
||||
|
||||
function saveGame(game: SavedGame) {
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(game));
|
||||
} catch (error) {
|
||||
console.warn('Could not save game progress:', error);
|
||||
}
|
||||
writeStorage(storageKey, JSON.stringify(game));
|
||||
}
|
||||
|
||||
function loadStats(): Stats {
|
||||
try {
|
||||
const saved = localStorage.getItem(statsKey);
|
||||
if (!saved) throw new Error('No stats yet.');
|
||||
const parsed = JSON.parse(saved) as Stats;
|
||||
return {
|
||||
played: parsed.played || 0,
|
||||
wins: parsed.wins || 0,
|
||||
streak: parsed.streak || 0,
|
||||
maxStreak: parsed.maxStreak || 0,
|
||||
lastPlayedDate:
|
||||
typeof parsed.lastPlayedDate === 'string' ? parsed.lastPlayedDate : undefined,
|
||||
distribution: Array.from(
|
||||
{ length: maxAttempts },
|
||||
(_, index) => parsed.distribution?.[index] || 0
|
||||
)
|
||||
};
|
||||
const saved = readStorage(statsKey);
|
||||
return saved ? sanitizeStats(JSON.parse(saved), maxAttempts) : createEmptyStats(maxAttempts);
|
||||
} catch {
|
||||
return createEmptyStats(maxAttempts);
|
||||
}
|
||||
}
|
||||
|
||||
function recordStats(didWin: boolean, attempts: number) {
|
||||
const nextStats = calculateNextStats(stats, didWin, attempts, todayKey, maxAttempts);
|
||||
localStorage.setItem(statsKey, JSON.stringify(nextStats));
|
||||
const nextStats = calculateNextStats(loadStats(), didWin, attempts, todayKey, maxAttempts);
|
||||
writeStorage(statsKey, JSON.stringify(nextStats));
|
||||
return nextStats;
|
||||
}
|
||||
</script>
|
||||
@@ -1101,23 +1189,29 @@
|
||||
<div class="grid grid-cols-3 gap-3 text-center">
|
||||
<div class="rounded border p-3" style="border-color: {COLORS.primary}">
|
||||
<div class="text-2xl font-extrabold">{stats.played}</div>
|
||||
<div class="text-xs uppercase" style="color: {COLORS.muted}">Played</div>
|
||||
<div class="text-xs uppercase" style="color: {darkMode ? '#bbb' : COLORS.muted}">
|
||||
Played
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border p-3" style="border-color: {COLORS.secondary}">
|
||||
<div class="text-2xl font-extrabold">
|
||||
{stats.played ? Math.round((stats.wins / stats.played) * 100) : 0}%
|
||||
</div>
|
||||
<div class="text-xs uppercase" style="color: {COLORS.muted}">Wins</div>
|
||||
<div class="text-xs uppercase" style="color: {darkMode ? '#bbb' : COLORS.muted}">
|
||||
Wins
|
||||
</div>
|
||||
</div>
|
||||
<div class="rounded border p-3" style="border-color: {COLORS.accent}">
|
||||
<div class="text-2xl font-extrabold">{stats.streak}</div>
|
||||
<div class="text-xs uppercase" style="color: {COLORS.muted}">Streak</div>
|
||||
<div class="text-xs uppercase" style="color: {darkMode ? '#bbb' : COLORS.muted}">
|
||||
Streak
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p class="mt-5 text-sm leading-6">
|
||||
Songs belong to {ARTIST_NAME}. This app stores only your local game progress and local stats
|
||||
in this browser.
|
||||
Your progress, settings, and stats stay in this browser. Finishing a game only increments an
|
||||
anonymous aggregate counter. Audio loads from SoundCloud.
|
||||
</p>
|
||||
<p class="mt-3 text-sm">
|
||||
Next track in <strong>{timeLeft}</strong>
|
||||
@@ -1136,6 +1230,7 @@
|
||||
{/if}
|
||||
|
||||
<main
|
||||
inert={showHowTo || showInfo}
|
||||
class={`heardle-page h-[100dvh] overflow-hidden px-3 py-2 sm:px-6 sm:py-4 md:px-8 ${darkMode ? 'heardle-dark' : ''}`}
|
||||
>
|
||||
<div class="mx-auto flex h-full min-h-0 max-w-5xl flex-col">
|
||||
@@ -1155,7 +1250,7 @@
|
||||
aria-label={darkMode ? 'Use light mode' : 'Use dark mode'}
|
||||
title={darkMode ? 'Light mode' : 'Dark mode'}
|
||||
class="deck-icon-button deck-icon-button-alt"
|
||||
on:click={() => (darkMode = !darkMode)}
|
||||
on:click={toggleTheme}
|
||||
>
|
||||
<Icon src={darkMode ? Sun : Moon} class="h-5 w-5" />
|
||||
</button>
|
||||
@@ -1163,7 +1258,10 @@
|
||||
|
||||
<div class="deck-title min-w-0 text-center">
|
||||
<p class="deck-kicker">Daily listening test</p>
|
||||
<h1 class="truncate text-xl font-extrabold sm:text-2xl">Heardle - {ARTIST_NAME}</h1>
|
||||
<h1 class="truncate text-xl font-extrabold sm:text-2xl">
|
||||
<span class="sm:hidden">Maisie Heardle</span>
|
||||
<span class="hidden sm:inline">Heardle - {ARTIST_NAME}</span>
|
||||
</h1>
|
||||
<p class="text-xs">New song in {timeLeft}</p>
|
||||
</div>
|
||||
|
||||
@@ -1220,27 +1318,29 @@
|
||||
class="game-grid flex min-h-0 flex-col gap-3 sm:gap-4 lg:grid lg:grid-cols-12 lg:items-stretch lg:gap-x-6 lg:gap-y-4"
|
||||
>
|
||||
<div class="left-col order-1 flex min-h-0 w-full flex-col lg:col-span-6">
|
||||
<section
|
||||
bind:this={attemptPanelEl}
|
||||
class="attempt-panel min-h-0 w-full"
|
||||
class:attempt-panel-game-over={gameOver}
|
||||
>
|
||||
<section class="attempt-panel min-h-0 w-full" class:attempt-panel-game-over={gameOver}>
|
||||
<p class="status-strip">
|
||||
<span class="status-chip">
|
||||
{gameOver ? 'Answer revealed' : `${unlockedSeconds}s unlocked`}
|
||||
</span>
|
||||
<span class="status-note">
|
||||
{gameOver ? 'Full playback' : `${maxAttempts - attemptCount} guesses left`}
|
||||
{gameOver
|
||||
? 'Full playback'
|
||||
: `${maxAttempts - attemptCount} ${maxAttempts - attemptCount === 1 ? 'guess' : 'guesses'} left`}
|
||||
</span>
|
||||
</p>
|
||||
|
||||
<div
|
||||
<ol
|
||||
bind:this={attemptHistoryEl}
|
||||
class="attempt-history space-y-1.5 sm:space-y-2"
|
||||
class="attempt-history list-none space-y-1.5 sm:space-y-2"
|
||||
aria-label="Guess history"
|
||||
aria-live="polite"
|
||||
aria-relevant="additions"
|
||||
>
|
||||
{#each attemptInfos as info, index (`${index}-${info.status}-${info.title ?? 'skip'}`)}
|
||||
<div
|
||||
<li
|
||||
role="listitem"
|
||||
aria-label={`Try ${index + 1}: ${info.status === 'skip' ? 'Skipped' : info.title}`}
|
||||
class="attempt-row attempt-row-filled flex h-8 items-center justify-between rounded px-3 text-xs font-semibold sm:h-10 sm:text-sm md:h-11"
|
||||
style="
|
||||
--attempt-color: {info.status === 'skip'
|
||||
@@ -1261,20 +1361,20 @@
|
||||
{:else}{info.title}{/if}
|
||||
</span>
|
||||
<span class="flex-shrink-0 text-[10px] uppercase">Try {index + 1}</span>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
{#each remainingAttempts as attemptNumber (attemptNumber)}
|
||||
<div
|
||||
<li
|
||||
role="listitem"
|
||||
class="attempt-row attempt-row-empty flex h-8 items-center rounded px-3 text-xs sm:h-10 sm:text-sm md:h-11"
|
||||
>
|
||||
Attempt {attemptNumber}
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
</div>
|
||||
</ol>
|
||||
</section>
|
||||
</div>
|
||||
|
||||
<!-- Right Column: Spinning Vinyl Player (Desktop only) -->
|
||||
<div
|
||||
bind:this={vinylColEl}
|
||||
class="vinyl-col order-2 hidden min-h-0 w-full flex-col lg:col-span-6 lg:flex"
|
||||
@@ -1287,7 +1387,6 @@
|
||||
border: 1px solid var(--deck-border);
|
||||
"
|
||||
>
|
||||
<!-- Vintage Start/Stop (Play/Pause) Button (top left) -->
|
||||
<div
|
||||
class="absolute top-6 left-8 z-40 flex flex-col items-center gap-1 select-none"
|
||||
style="width: 28px;"
|
||||
@@ -1299,7 +1398,7 @@
|
||||
type="button"
|
||||
on:click={togglePlayPause}
|
||||
disabled={loading || Boolean(widgetError)}
|
||||
class="vintage-play-btn flex h-10 w-10 items-center justify-center rounded-full border border-zinc-800 bg-zinc-950 shadow-[inset_0_2px_4px_rgba(0,0,0,0.8)] focus:outline-none"
|
||||
class="vintage-play-btn flex h-10 w-10 items-center justify-center rounded-full border border-zinc-800 bg-zinc-950 shadow-[inset_0_2px_4px_rgba(0,0,0,0.8)] focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2"
|
||||
aria-label={isPlaying ? 'Pause audio' : 'Play audio'}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
@@ -1343,32 +1442,29 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Platter (clickable to toggle play/pause as a fallback) -->
|
||||
<button
|
||||
type="button"
|
||||
on:click={togglePlayPause}
|
||||
disabled={loading || Boolean(widgetError)}
|
||||
class="platter absolute z-30 flex h-[400px] w-[400px] cursor-pointer items-center justify-center rounded-full border focus:outline-none"
|
||||
class="platter absolute z-30 flex h-[400px] w-[400px] cursor-pointer items-center justify-center rounded-full border focus:outline-none focus-visible:ring-2 focus-visible:ring-sky-500 focus-visible:ring-offset-2"
|
||||
style="
|
||||
left: 40px;
|
||||
top: 20px;
|
||||
background: #0d5b84;
|
||||
box-shadow: inset 0 4px 10px rgba(0,0,0,0.35), 0 1px 3px rgba(255,255,255,0.08);
|
||||
border-color: {darkMode ? '#27272a' : '#3f3f46'};
|
||||
"
|
||||
border-color: {darkMode ? '#27272a' : '#3f3f46'};
|
||||
"
|
||||
aria-label={isPlaying ? 'Pause audio from record' : 'Play audio from record'}
|
||||
title={isPlaying ? 'Pause' : 'Play'}
|
||||
>
|
||||
<!-- Vinyl record wrapper -->
|
||||
<div
|
||||
class="relative flex h-[360px] w-[360px] items-center justify-center overflow-hidden rounded-full"
|
||||
>
|
||||
<!-- Vinyl record (spins) -->
|
||||
<div
|
||||
bind:this={vinylElement}
|
||||
class="vinyl-record absolute inset-0 flex items-center justify-center rounded-full shadow-lg"
|
||||
style="transform: {vinylTransform}; transition: {vinylSpinTransition};"
|
||||
>
|
||||
<!-- Track separators (wider gaps) -->
|
||||
<div
|
||||
class="pointer-events-none absolute inset-[36px] rounded-full border border-black/30"
|
||||
></div>
|
||||
@@ -1379,7 +1475,6 @@
|
||||
class="pointer-events-none absolute inset-[108px] rounded-full border border-black/30"
|
||||
></div>
|
||||
|
||||
<!-- Vinyl Center Label (spins with record) -->
|
||||
<div
|
||||
class="vinyl-label absolute top-1/2 left-1/2 z-10 flex h-36 w-36 -translate-x-1/2 -translate-y-1/2 items-center justify-center overflow-hidden rounded-full border-2 border-black/25"
|
||||
style="background: linear-gradient(135deg, {COLORS.primary}, {COLORS.secondary});"
|
||||
@@ -1405,13 +1500,11 @@
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Stationary light reflection overlay (sheen stays still) -->
|
||||
<div
|
||||
class="vinyl-sheen pointer-events-none absolute inset-0 z-20 rounded-full"
|
||||
></div>
|
||||
</div>
|
||||
|
||||
<!-- Spindle center peg (STATIONARY in center of platter) -->
|
||||
<div
|
||||
class="absolute top-1/2 left-1/2 z-30 flex h-5 w-5 -translate-x-1/2 -translate-y-1/2 items-center justify-center rounded-full border shadow-md"
|
||||
style="
|
||||
@@ -1419,19 +1512,16 @@
|
||||
border-color: {darkMode ? '#3f3f46' : '#a1a1aa'};
|
||||
"
|
||||
>
|
||||
<!-- Small center peg tip -->
|
||||
<div class="h-1.5 w-1.5 rounded-full bg-zinc-900"></div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<!-- Tonearm (S-shaped arm with pivot at 445,70 on a 480x440 canvas) -->
|
||||
<div class="tonearm pointer-events-none absolute inset-0 z-40 h-full w-full">
|
||||
<svg
|
||||
viewBox="0 0 480 440"
|
||||
class="h-full w-full drop-shadow-[0_8px_12px_rgba(0,0,0,0.45)]"
|
||||
>
|
||||
<defs>
|
||||
<!-- Metallic linear gradient for the chrome tonearm -->
|
||||
<linearGradient id="chrome-grad" x1="0%" y1="0%" x2="100%" y2="100%">
|
||||
<stop offset="0%" stop-color="#f3f4f6" />
|
||||
<stop offset="30%" stop-color="#9ca3af" />
|
||||
@@ -1439,7 +1529,6 @@
|
||||
<stop offset="70%" stop-color="#4b5563" />
|
||||
<stop offset="100%" stop-color="#e5e7eb" />
|
||||
</linearGradient>
|
||||
<!-- Darker metallic for pivot and weight -->
|
||||
<linearGradient id="dark-metal-grad" x1="0%" y1="0%" x2="0%" y2="100%">
|
||||
<stop offset="0%" stop-color="#71717a" />
|
||||
<stop offset="50%" stop-color="#3f3f46" />
|
||||
@@ -1447,8 +1536,6 @@
|
||||
</linearGradient>
|
||||
</defs>
|
||||
|
||||
<!-- 1. Tonearm base / Rest cradle (STATIONARY) -->
|
||||
<!-- Gimbal base -->
|
||||
<circle
|
||||
cx="445"
|
||||
cy="70"
|
||||
@@ -1458,8 +1545,6 @@
|
||||
stroke-width="2"
|
||||
/>
|
||||
<circle cx="445" cy="70" r="14" fill="#18181b" stroke="#333" stroke-width="1" />
|
||||
<!-- Arm Rest structure -->
|
||||
<!-- Post -->
|
||||
<line
|
||||
x1="452"
|
||||
y1="200"
|
||||
@@ -1469,7 +1554,6 @@
|
||||
stroke-width="3"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<!-- U-clip cradle -->
|
||||
<path
|
||||
d="M 444 200 Q 452 206 460 200"
|
||||
fill="none"
|
||||
@@ -1478,13 +1562,11 @@
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
|
||||
<!-- 2. Rotating Tonearm Assembly -->
|
||||
<g
|
||||
style="transform: rotate({isPlaying
|
||||
? '22deg'
|
||||
: '0deg'}); transform-origin: 445px 70px; transition: transform 750ms cubic-bezier(0.25, 1, 0.5, 1);"
|
||||
>
|
||||
<!-- Counterweight shaft (extends behind pivot) -->
|
||||
<line
|
||||
x1="445"
|
||||
y1="70"
|
||||
@@ -1494,7 +1576,6 @@
|
||||
stroke-width="4.5"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<!-- Counterweight dial ring -->
|
||||
<rect
|
||||
x="430"
|
||||
y="24"
|
||||
@@ -1505,7 +1586,6 @@
|
||||
stroke="#111"
|
||||
stroke-width="1.5"
|
||||
/>
|
||||
<!-- Counterweight weight block -->
|
||||
<rect
|
||||
x="432"
|
||||
y="16"
|
||||
@@ -1517,7 +1597,6 @@
|
||||
stroke-width="1"
|
||||
/>
|
||||
|
||||
<!-- Metallic 2-segment straight Tonearm tube -->
|
||||
<path
|
||||
d="M 445 70 L 456 180 L 445 235"
|
||||
stroke="url(#chrome-grad)"
|
||||
@@ -1527,7 +1606,6 @@
|
||||
stroke-linejoin="round"
|
||||
/>
|
||||
|
||||
<!-- Cartridge collar connector -->
|
||||
<circle
|
||||
cx="445"
|
||||
cy="235"
|
||||
@@ -1537,15 +1615,12 @@
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
|
||||
<!-- Headshell (Classic Technics style angled cartridge) -->
|
||||
<!-- Headshell body -->
|
||||
<path
|
||||
d="M 443 235 L 448 235 L 441 257 L 435 255 Z"
|
||||
fill="#1f2937"
|
||||
stroke="#111"
|
||||
stroke-width="0.75"
|
||||
/>
|
||||
<!-- Finger lift (curving to the right) -->
|
||||
<path
|
||||
d="M 447 241 C 454 241, 456 245, 455 249"
|
||||
fill="none"
|
||||
@@ -1553,20 +1628,17 @@
|
||||
stroke-width="1"
|
||||
stroke-linecap="round"
|
||||
/>
|
||||
<!-- Stylus body (orange accent cartridge tip) -->
|
||||
<polygon
|
||||
points="435,255 441,257 439,261 435,260"
|
||||
fill={COLORS.accent}
|
||||
stroke="#111"
|
||||
stroke-width="0.5"
|
||||
/>
|
||||
<!-- Tiny metallic stylus tip/needle -->
|
||||
<line x1="435" y1="260" x2="433" y2="262" stroke="#d1d5db" stroke-width="1" />
|
||||
</g>
|
||||
</svg>
|
||||
</div>
|
||||
|
||||
<!-- Dynamic Island Waveform (bottom right) -->
|
||||
<div
|
||||
class="pointer-events-none absolute right-8 bottom-6 z-40 flex h-10 w-24 items-center justify-center gap-1.5 select-none"
|
||||
>
|
||||
@@ -1583,7 +1655,6 @@
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<!-- Vintage Volume Fader (bottom left, vertical) -->
|
||||
<div
|
||||
class="absolute bottom-6 left-8 z-40 flex flex-col items-center gap-1 select-none"
|
||||
style="width: 28px;"
|
||||
@@ -1641,7 +1712,7 @@
|
||||
class="line-clamp-2 text-xs sm:text-sm"
|
||||
style="color: {darkMode ? '#cfc7c1' : COLORS.muted}"
|
||||
>
|
||||
{message}
|
||||
{resultMessage}
|
||||
</p>
|
||||
<a
|
||||
href={`https://song.link/${currentTrack.url}`}
|
||||
@@ -1686,7 +1757,6 @@
|
||||
|
||||
<div
|
||||
class="progress-rail relative mb-1.5 h-5 w-full overflow-hidden rounded sm:mb-2 sm:h-7"
|
||||
aria-label="Audio progress"
|
||||
>
|
||||
<div
|
||||
class="absolute top-0 left-0 h-full transition-[width] duration-100"
|
||||
@@ -1694,8 +1764,8 @@
|
||||
></div>
|
||||
<button
|
||||
type="button"
|
||||
class="seek-slider absolute inset-0 z-20 h-full w-full cursor-pointer bg-transparent focus:ring-2 focus:outline-none"
|
||||
style="--tw-ring-color: {COLORS.primary}; touch-action: none;"
|
||||
class="seek-slider absolute inset-0 z-20 h-full w-full cursor-pointer"
|
||||
style="touch-action: none;"
|
||||
aria-label="Seek finished song"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={Math.round((fullDuration || TOTAL_MS) / 1000)}
|
||||
@@ -1703,10 +1773,11 @@
|
||||
aria-valuetext={formatTime(currentPosition)}
|
||||
role="slider"
|
||||
title="Seek song"
|
||||
disabled={!widgetReady || !fullDuration}
|
||||
on:pointerdown={handleSeekPointerDown}
|
||||
on:pointermove={handleSeekPointerMove}
|
||||
on:pointerup={handleSeekPointerEnd}
|
||||
on:pointercancel={handleSeekPointerEnd}
|
||||
on:pointercancel={handleSeekPointerCancel}
|
||||
on:keydown={handleSeekKeydown}
|
||||
></button>
|
||||
</div>
|
||||
@@ -1761,7 +1832,6 @@
|
||||
</div>
|
||||
{:else}
|
||||
<div bind:this={playerWrapEl} class="player-wrap flex-shrink-0">
|
||||
<!-- Control Deck (Audio player controls & input) -->
|
||||
<section class="control-deck">
|
||||
{#if widgetError}
|
||||
<div
|
||||
@@ -1777,7 +1847,12 @@
|
||||
|
||||
<div
|
||||
class="progress-rail relative mb-1.5 h-5 w-full overflow-hidden rounded sm:mb-2 sm:h-7"
|
||||
role="progressbar"
|
||||
aria-label="Audio progress"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={TOTAL_SECONDS}
|
||||
aria-valuenow={Math.round(currentPosition / 1000)}
|
||||
aria-valuetext={formatTime(currentPosition)}
|
||||
>
|
||||
{#if !gameOver}
|
||||
{#each segmentDurations as segmentEnd, index (segmentEnd)}
|
||||
@@ -1808,25 +1883,6 @@
|
||||
></div>
|
||||
{/each}
|
||||
{/if}
|
||||
{#if gameOver}
|
||||
<button
|
||||
type="button"
|
||||
class="seek-slider absolute inset-0 z-20 h-full w-full cursor-pointer bg-transparent focus:ring-2 focus:outline-none"
|
||||
style="--tw-ring-color: {COLORS.primary}; touch-action: none;"
|
||||
aria-label="Seek finished song"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax={Math.round((fullDuration || TOTAL_MS) / 1000)}
|
||||
aria-valuenow={Math.round(currentPosition / 1000)}
|
||||
aria-valuetext={formatTime(currentPosition)}
|
||||
role="slider"
|
||||
title="Seek song"
|
||||
on:pointerdown={handleSeekPointerDown}
|
||||
on:pointermove={handleSeekPointerMove}
|
||||
on:pointerup={handleSeekPointerEnd}
|
||||
on:pointercancel={handleSeekPointerEnd}
|
||||
on:keydown={handleSeekKeydown}
|
||||
></button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div
|
||||
@@ -1838,21 +1894,6 @@
|
||||
</div>
|
||||
|
||||
<div class="mb-3 flex items-center justify-center gap-4 sm:mb-4">
|
||||
{#if gameOver}
|
||||
<button
|
||||
type="button"
|
||||
on:click={rewindSong}
|
||||
class="flex h-9 w-9 items-center justify-center rounded-full border-2 transition hover:scale-105 disabled:opacity-50 sm:h-12 sm:w-12 lg:hidden"
|
||||
style="border-color: {loading ? '#888888' : COLORS.primary}; color: {loading
|
||||
? '#888888'
|
||||
: COLORS.primary}"
|
||||
disabled={loading}
|
||||
aria-label="Restart song from beginning"
|
||||
title="Restart song"
|
||||
>
|
||||
<Icon src={ArrowPath} class="h-4 w-4 sm:h-6 sm:w-6" />
|
||||
</button>
|
||||
{/if}
|
||||
<button
|
||||
type="button"
|
||||
on:click={togglePlayPause}
|
||||
@@ -2278,10 +2319,16 @@
|
||||
box-shadow: inset 0 1px 3px rgba(0, 0, 0, 0.14);
|
||||
}
|
||||
|
||||
.progress-rail:focus-within {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.seek-slider {
|
||||
padding: 0;
|
||||
border: 0;
|
||||
margin: 0;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.share-button {
|
||||
@@ -2321,30 +2368,6 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Spinning Vinyl Record Player Styles */
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
.waveform-bar.animating {
|
||||
animation: waveform-bounce ease-in-out infinite;
|
||||
}
|
||||
|
||||
@keyframes waveform-bounce {
|
||||
0%,
|
||||
100% {
|
||||
height: 4px;
|
||||
}
|
||||
50% {
|
||||
height: 28px;
|
||||
}
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.heardle-page *,
|
||||
.heardle-page *::before,
|
||||
@@ -2356,21 +2379,25 @@
|
||||
}
|
||||
}
|
||||
|
||||
/* Vintage Vertical Volume Fader */
|
||||
.vintage-slider {
|
||||
-webkit-appearance: none;
|
||||
appearance: none;
|
||||
background: transparent;
|
||||
outline: none;
|
||||
position: absolute;
|
||||
width: 80px; /* matches fader container height */
|
||||
height: 20px; /* matches fader container width */
|
||||
width: 80px;
|
||||
height: 20px;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
/* Rotate -90deg so left(0) is bottom, right(100) is top */
|
||||
transform: rotate(-90deg);
|
||||
transform-origin: center center;
|
||||
}
|
||||
|
||||
.vintage-slider:focus-visible {
|
||||
outline: 2px solid var(--primary);
|
||||
outline-offset: 3px;
|
||||
}
|
||||
|
||||
.vintage-slider::-webkit-slider-runnable-track {
|
||||
width: 100%;
|
||||
height: 4px;
|
||||
@@ -2384,7 +2411,6 @@
|
||||
appearance: none;
|
||||
width: 10px;
|
||||
height: 18px;
|
||||
/* Gradient horizontal so line appears vertical after rotation */
|
||||
background: linear-gradient(
|
||||
90deg,
|
||||
#e4e4e7 0%,
|
||||
@@ -2426,7 +2452,6 @@
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
/* Vintage Tactile Play Button */
|
||||
.vintage-play-btn {
|
||||
cursor: pointer;
|
||||
background: #09090b;
|
||||
|
||||
@@ -1,5 +1,10 @@
|
||||
import { describe, expect, it } from 'vitest';
|
||||
import { calculateNextStats, createEmptyStats } from '$lib/game-state';
|
||||
import {
|
||||
calculateNextStats,
|
||||
createEmptyStats,
|
||||
parseSavedGame,
|
||||
sanitizeStats
|
||||
} from '$lib/game-state';
|
||||
|
||||
describe('game statistics', () => {
|
||||
it('starts a streak with a win', () => {
|
||||
@@ -33,4 +38,56 @@ describe('game statistics', () => {
|
||||
expect(losing.wins).toBe(1);
|
||||
expect(losing.distribution).toEqual([1, 0, 0, 0, 0, 0]);
|
||||
});
|
||||
|
||||
it('records at most one result per day', () => {
|
||||
const first = calculateNextStats(createEmptyStats(6), true, 1, '2026-07-20', 6);
|
||||
const duplicate = calculateNextStats(first, false, 6, '2026-07-20', 6);
|
||||
|
||||
expect(duplicate).toEqual(first);
|
||||
});
|
||||
|
||||
it('sanitizes stored statistics', () => {
|
||||
expect(
|
||||
sanitizeStats(
|
||||
{
|
||||
played: 2,
|
||||
wins: 5,
|
||||
streak: -1,
|
||||
maxStreak: '4',
|
||||
distribution: [1, '2', 3],
|
||||
lastPlayedDate: 'not-a-date'
|
||||
},
|
||||
6
|
||||
)
|
||||
).toEqual({
|
||||
played: 2,
|
||||
wins: 2,
|
||||
streak: 0,
|
||||
maxStreak: 0,
|
||||
distribution: [1, 0, 3, 0, 0, 0],
|
||||
lastPlayedDate: undefined
|
||||
});
|
||||
});
|
||||
|
||||
it('restores only consistent saved games', () => {
|
||||
expect(
|
||||
parseSavedGame(
|
||||
{
|
||||
attemptInfos: [{ status: 'skip' }, { status: 'correct', title: 'Psycho' }],
|
||||
gameOver: true,
|
||||
won: true,
|
||||
statsRecorded: true
|
||||
},
|
||||
6
|
||||
)
|
||||
).toEqual({
|
||||
attemptInfos: [{ status: 'skip' }, { status: 'correct', title: 'Psycho' }],
|
||||
gameOver: true,
|
||||
won: true,
|
||||
statsRecorded: true
|
||||
});
|
||||
expect(
|
||||
parseSavedGame({ attemptInfos: [], gameOver: true, won: false, statsRecorded: false }, 6)
|
||||
).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
+104
-1
@@ -1,4 +1,4 @@
|
||||
import { getLocalDateKey } from '$lib/tracks';
|
||||
import { getLocalDateKey, isValidDateKey } from '$lib/tracks';
|
||||
|
||||
export type Stats = {
|
||||
played: number;
|
||||
@@ -9,6 +9,29 @@ export type Stats = {
|
||||
lastPlayedDate?: string;
|
||||
};
|
||||
|
||||
export type AttemptInfo = { status: 'skip' | 'wrong' | 'correct'; title?: string };
|
||||
|
||||
export type SavedGame = {
|
||||
attemptInfos: AttemptInfo[];
|
||||
gameOver: boolean;
|
||||
won: boolean;
|
||||
statsRecorded: boolean;
|
||||
};
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value !== null && typeof value === 'object' && !Array.isArray(value)
|
||||
? (value as Record<string, unknown>)
|
||||
: null;
|
||||
}
|
||||
|
||||
function asCount(value: unknown): number {
|
||||
return Number.isSafeInteger(value) &&
|
||||
Number(value) >= 0 &&
|
||||
Number(value) < Number.MAX_SAFE_INTEGER
|
||||
? Number(value)
|
||||
: 0;
|
||||
}
|
||||
|
||||
export function createEmptyStats(maxAttempts: number): Stats {
|
||||
return {
|
||||
played: 0,
|
||||
@@ -19,6 +42,84 @@ export function createEmptyStats(maxAttempts: number): Stats {
|
||||
};
|
||||
}
|
||||
|
||||
export function sanitizeStats(value: unknown, maxAttempts: number): Stats {
|
||||
const saved = asRecord(value) ?? {};
|
||||
const played = asCount(saved.played);
|
||||
const wins = Math.min(asCount(saved.wins), played);
|
||||
const streak = Math.min(asCount(saved.streak), wins);
|
||||
const maxStreak = Math.max(streak, Math.min(asCount(saved.maxStreak), wins));
|
||||
const savedDistribution = Array.isArray(saved.distribution) ? saved.distribution : [];
|
||||
const lastPlayedDate =
|
||||
typeof saved.lastPlayedDate === 'string' && isValidDateKey(saved.lastPlayedDate)
|
||||
? saved.lastPlayedDate
|
||||
: undefined;
|
||||
|
||||
return {
|
||||
played,
|
||||
wins,
|
||||
streak,
|
||||
maxStreak,
|
||||
distribution: Array.from({ length: maxAttempts }, (_, index) =>
|
||||
asCount(savedDistribution[index])
|
||||
),
|
||||
lastPlayedDate
|
||||
};
|
||||
}
|
||||
|
||||
export function parseSavedGame(value: unknown, maxAttempts: number): SavedGame | null {
|
||||
const saved = asRecord(value);
|
||||
if (
|
||||
!saved ||
|
||||
!Array.isArray(saved.attemptInfos) ||
|
||||
typeof saved.gameOver !== 'boolean' ||
|
||||
typeof saved.won !== 'boolean' ||
|
||||
saved.attemptInfos.length > maxAttempts
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const attemptInfos: AttemptInfo[] = [];
|
||||
for (const value of saved.attemptInfos) {
|
||||
const attempt = asRecord(value);
|
||||
if (!attempt) return null;
|
||||
|
||||
if (attempt.status === 'skip') {
|
||||
attemptInfos.push({ status: 'skip' });
|
||||
continue;
|
||||
}
|
||||
|
||||
if (
|
||||
(attempt.status === 'wrong' || attempt.status === 'correct') &&
|
||||
typeof attempt.title === 'string' &&
|
||||
attempt.title.trim()
|
||||
) {
|
||||
attemptInfos.push({ status: attempt.status, title: attempt.title.trim() });
|
||||
continue;
|
||||
}
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
const correctAttempts = attemptInfos.filter((attempt) => attempt.status === 'correct');
|
||||
const won = saved.won;
|
||||
const gameOver = saved.gameOver;
|
||||
const validWin =
|
||||
gameOver && won && correctAttempts.length === 1 && attemptInfos.at(-1)?.status === 'correct';
|
||||
const validLoss =
|
||||
gameOver && !won && correctAttempts.length === 0 && attemptInfos.length === maxAttempts;
|
||||
const validActiveGame =
|
||||
!gameOver && !won && correctAttempts.length === 0 && attemptInfos.length < maxAttempts;
|
||||
|
||||
if (!validWin && !validLoss && !validActiveGame) return null;
|
||||
|
||||
return {
|
||||
attemptInfos,
|
||||
gameOver,
|
||||
won,
|
||||
statsRecorded: gameOver && saved.statsRecorded === true
|
||||
};
|
||||
}
|
||||
|
||||
export function calculateNextStats(
|
||||
stats: Stats,
|
||||
didWin: boolean,
|
||||
@@ -26,6 +127,8 @@ export function calculateNextStats(
|
||||
todayKey: string,
|
||||
maxAttempts: number
|
||||
): Stats {
|
||||
if (stats.lastPlayedDate === todayKey) return stats;
|
||||
|
||||
const previousDate = new Date(`${todayKey}T12:00:00`);
|
||||
previousDate.setDate(previousDate.getDate() - 1);
|
||||
const playedYesterday = stats.lastPlayedDate === getLocalDateKey(previousDate);
|
||||
|
||||
@@ -1,2 +0,0 @@
|
||||
// src/lib/index.ts
|
||||
export { default as HeardleGame } from './HeardleGame.svelte';
|
||||
@@ -13,6 +13,7 @@ describe('daily track selection', () => {
|
||||
|
||||
it('rejects invalid date keys', () => {
|
||||
expect(() => getDailyTrack('07/22/2026')).toThrow('Invalid date key');
|
||||
expect(() => getDailyTrack('2026-02-29')).toThrow('Invalid date key');
|
||||
});
|
||||
|
||||
it('has unique answer titles and slugs', () => {
|
||||
|
||||
+24
-3
@@ -121,12 +121,33 @@ export function getLocalDateKey(date = new Date()) {
|
||||
return `${year}-${month}-${day}`;
|
||||
}
|
||||
|
||||
export function getDailyTrack(dateKey = getLocalDateKey()) {
|
||||
function parseDateKey(dateKey: string): Date | null {
|
||||
const match = /^(\d{4})-(\d{2})-(\d{2})$/.exec(dateKey);
|
||||
if (!match) throw new Error(`Invalid date key: ${dateKey}`);
|
||||
if (!match) return null;
|
||||
|
||||
const [, year, month, day] = match;
|
||||
const dayNumber = Math.floor(Date.UTC(Number(year), Number(month) - 1, Number(day)) / 86_400_000);
|
||||
const date = new Date(0);
|
||||
date.setUTCHours(0, 0, 0, 0);
|
||||
date.setUTCFullYear(Number(year), Number(month) - 1, Number(day));
|
||||
if (
|
||||
date.getUTCFullYear() !== Number(year) ||
|
||||
date.getUTCMonth() !== Number(month) - 1 ||
|
||||
date.getUTCDate() !== Number(day)
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
return date;
|
||||
}
|
||||
|
||||
export function isValidDateKey(dateKey: string) {
|
||||
return parseDateKey(dateKey) !== null;
|
||||
}
|
||||
|
||||
export function getDailyTrack(dateKey = getLocalDateKey()) {
|
||||
const date = parseDateKey(dateKey);
|
||||
if (!date) throw new Error(`Invalid date key: ${dateKey}`);
|
||||
|
||||
const dayNumber = Math.floor(date.getTime() / 86_400_000);
|
||||
const cycle = Math.floor(dayNumber / scheduledTracks.length);
|
||||
const position =
|
||||
((dayNumber % scheduledTracks.length) + scheduledTracks.length) % scheduledTracks.length;
|
||||
|
||||
@@ -1,4 +1,3 @@
|
||||
<!-- src/routes/+layout.svelte -->
|
||||
<script lang="ts">
|
||||
import '../app.css';
|
||||
</script>
|
||||
@@ -7,7 +6,6 @@
|
||||
<title>Heardle - Maisie Peters Edition</title>
|
||||
</svelte:head>
|
||||
|
||||
<!-- Remove bg-black/text-white here so your game can supply its own background -->
|
||||
<div class="min-h-screen bg-transparent antialiased">
|
||||
<slot />
|
||||
</div>
|
||||
|
||||
@@ -1,2 +1 @@
|
||||
// src/routes/+page.ts
|
||||
export const ssr = false;
|
||||
|
||||
@@ -12,7 +12,7 @@ export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||
|
||||
try {
|
||||
const parsedUrl = new URL(trackUrl);
|
||||
if (!ALLOWED_HOSTS.has(parsedUrl.hostname)) {
|
||||
if (parsedUrl.protocol !== 'https:' || !ALLOWED_HOSTS.has(parsedUrl.hostname)) {
|
||||
return json({ error: 'Only SoundCloud URLs are supported.' }, { status: 400 });
|
||||
}
|
||||
} catch {
|
||||
@@ -23,7 +23,12 @@ export const GET: RequestHandler = async ({ fetch, url }) => {
|
||||
oembedUrl.searchParams.set('format', 'json');
|
||||
oembedUrl.searchParams.set('url', trackUrl);
|
||||
|
||||
const response = await fetch(oembedUrl);
|
||||
let response: Response;
|
||||
try {
|
||||
response = await fetch(oembedUrl, { signal: AbortSignal.timeout(8_000) });
|
||||
} catch {
|
||||
return json({ error: 'SoundCloud metadata was unavailable.' }, { status: 502 });
|
||||
}
|
||||
|
||||
if (!response.ok) {
|
||||
return json({ error: 'SoundCloud metadata was unavailable.' }, { status: response.status });
|
||||
|
||||
@@ -1,10 +0,0 @@
|
||||
/** @type {import('tailwindcss').Config} */
|
||||
module.exports = {
|
||||
content: ['./src/**/*.{html,svelte,ts,js}'],
|
||||
theme: {
|
||||
extend: {
|
||||
// put your custom colors, spacing, etc. here
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -11,9 +11,4 @@
|
||||
"strict": true,
|
||||
"moduleResolution": "bundler"
|
||||
}
|
||||
// Path aliases are handled by https://svelte.dev/docs/kit/configuration#alias
|
||||
// except $lib which is handled by https://svelte.dev/docs/kit/configuration#files
|
||||
//
|
||||
// If you want to overwrite includes/excludes, make sure to copy over the relevant includes/excludes
|
||||
// from the referenced tsconfig.json - TypeScript does not merge them in
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user