From e9fdc05c2d127ce5acf5858ac40fca65162de19c Mon Sep 17 00:00:00 2001 From: Jurn Wubben Date: Sun, 1 Feb 2026 17:07:39 +0100 Subject: [PATCH] Basic functionality is here with a crappy ai generated frontend. --- .gitattributes | 1 + .gitignore | 144 ++++++++++++++ .helix/languages.toml | 14 ++ deno.json | 10 + deno.lock | 406 ++++++++++++++++++++++++++++++++++++++++ extension/spotiqueue.js | 255 +++++++++++++++++++++++++ src/commandBuilder.ts | 30 +++ src/heartbeat.ts | 53 ++++++ src/main.ts | 43 +++++ src/playerManager.ts | 94 ++++++++++ src/queue.ts | 3 + src/spotify.ts | 82 ++++++++ src/user.ts | 113 +++++++++++ static/index.html | 352 ++++++++++++++++++++++++++++++++++ 14 files changed, 1600 insertions(+) create mode 100644 .gitattributes create mode 100644 .gitignore create mode 100644 .helix/languages.toml create mode 100644 deno.json create mode 100644 deno.lock create mode 100644 extension/spotiqueue.js create mode 100644 src/commandBuilder.ts create mode 100644 src/heartbeat.ts create mode 100644 src/main.ts create mode 100644 src/playerManager.ts create mode 100644 src/queue.ts create mode 100644 src/spotify.ts create mode 100644 src/user.ts create mode 100644 static/index.html diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..46c9a9b --- /dev/null +++ b/.gitattributes @@ -0,0 +1 @@ +dist/* linguist-vendored \ No newline at end of file diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..ce2d6ee --- /dev/null +++ b/.gitignore @@ -0,0 +1,144 @@ + +# Created by https://www.toptal.com/developers/gitignore/api/node +# Edit at https://www.toptal.com/developers/gitignore?templates=node + +### Node ### +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +lerna-debug.log* +.pnpm-debug.log* + +# Diagnostic reports (https://nodejs.org/api/report.html) +report.[0-9]*.[0-9]*.[0-9]*.[0-9]*.json + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Directory for instrumented libs generated by jscoverage/JSCover +lib-cov + +# Coverage directory used by tools like istanbul +coverage +*.lcov + +# nyc test coverage +.nyc_output + +# Grunt intermediate storage (https://gruntjs.com/creating-plugins#storing-task-files) +.grunt + +# Bower dependency directory (https://bower.io/) +bower_components + +# node-waf configuration +.lock-wscript + +# Compiled binary addons (https://nodejs.org/api/addons.html) +build/Release + +# Dependency directories +node_modules/ +jspm_packages/ + +# Snowpack dependency directory (https://snowpack.dev/) +web_modules/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Optional stylelint cache +.stylelintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# dotenv environment variable files +.env +.env.development.local +.env.test.local +.env.production.local +.env.local + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next +out + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ +# Comment in the public line in if your project uses Gatsby and not Next.js +# https://nextjs.org/blog/next-9-1#public-directory-support +# public + +# vuepress build output +.vuepress/dist + +# vuepress v2.x temp and cache directory +.temp + +# Docusaurus cache and generated files +.docusaurus + +# Serverless directories +.serverless/ + +# FuseBox cache +.fusebox/ + +# DynamoDB Local files +.dynamodb/ + +# TernJS port file +.tern-port + +# Stores VSCode versions used for testing VSCode extensions +.vscode-test + +# yarn v2 +.yarn/cache +.yarn/unplugged +.yarn/build-state.yml +.yarn/install-state.gz +.pnp.* + +### Node Patch ### +# Serverless Webpack directories +.webpack/ + +# Optional stylelint cache + +# SvelteKit build / generate output +.svelte-kit + +# End of https://www.toptal.com/developers/gitignore/api/node diff --git a/.helix/languages.toml b/.helix/languages.toml new file mode 100644 index 0000000..bbe82d5 --- /dev/null +++ b/.helix/languages.toml @@ -0,0 +1,14 @@ +[language-server.deno-language-server] +command = "deno" +args = ["lsp"] +required-root-patterns = ["deno.json", "deno.jsonc"] + +[[language]] +name = "typescript" +file-types = ["ts", "tsx"] +language-servers = ["deno-language-server"] + +[[language]] +name = "javascript" +file-types = ["js", "jsx"] +language-servers = ["deno-language-server"] diff --git a/deno.json b/deno.json new file mode 100644 index 0000000..cfcf338 --- /dev/null +++ b/deno.json @@ -0,0 +1,10 @@ +{ + "tasks": { + "dev": "deno run --watch -A src/main.ts" + }, + "imports": { + "@std/assert": "jsr:@std/assert@1", + "@std/http": "jsr:@std/http@^1.0.24", + "typia": "npm:typia@^11.0.3" + } +} diff --git a/deno.lock b/deno.lock new file mode 100644 index 0000000..b4b4fb7 --- /dev/null +++ b/deno.lock @@ -0,0 +1,406 @@ +{ + "version": "5", + "specifiers": { + "jsr:@std/assert@1": "1.0.18", + "jsr:@std/cli@^1.0.27": "1.0.27", + "jsr:@std/encoding@^1.0.10": "1.0.10", + "jsr:@std/fmt@^1.0.9": "1.0.9", + "jsr:@std/fs@^1.0.22": "1.0.22", + "jsr:@std/html@^1.0.5": "1.0.5", + "jsr:@std/http@^1.0.24": "1.0.24", + "jsr:@std/internal@^1.0.12": "1.0.12", + "jsr:@std/media-types@^1.1.0": "1.1.0", + "jsr:@std/net@^1.0.6": "1.0.6", + "jsr:@std/path@^1.1.4": "1.1.4", + "jsr:@std/streams@^1.0.17": "1.0.17", + "npm:typia@^11.0.3": "11.0.3_typescript@5.9.3" + }, + "jsr": { + "@std/assert@1.0.18": { + "integrity": "270245e9c2c13b446286de475131dc688ca9abcd94fc5db41d43a219b34d1c78", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/cli@1.0.27": { + "integrity": "eba97edd0891871a7410e835dd94b3c260c709cca5983df2689c25a71fbe04de" + }, + "@std/encoding@1.0.10": { + "integrity": "8783c6384a2d13abd5e9e87a7ae0520a30e9f56aeeaa3bdf910a3eaaf5c811a1" + }, + "@std/fmt@1.0.9": { + "integrity": "2487343e8899fb2be5d0e3d35013e54477ada198854e52dd05ed0422eddcabe0" + }, + "@std/fs@1.0.22": { + "integrity": "de0f277a58a867147a8a01bc1b181d0dfa80bfddba8c9cf2bacd6747bcec9308" + }, + "@std/html@1.0.5": { + "integrity": "4e2d693f474cae8c16a920fa5e15a3b72267b94b84667f11a50c6dd1cb18d35e" + }, + "@std/http@1.0.24": { + "integrity": "4dd59afd7cfd6e2e96e175b67a5a829b449ae55f08575721ec691e5d85d886d4", + "dependencies": [ + "jsr:@std/cli", + "jsr:@std/encoding", + "jsr:@std/fmt", + "jsr:@std/fs", + "jsr:@std/html", + "jsr:@std/media-types", + "jsr:@std/net", + "jsr:@std/path", + "jsr:@std/streams" + ] + }, + "@std/internal@1.0.12": { + "integrity": "972a634fd5bc34b242024402972cd5143eac68d8dffaca5eaa4dba30ce17b027" + }, + "@std/media-types@1.1.0": { + "integrity": "c9d093f0c05c3512932b330e3cc1fe1d627b301db33a4c2c2185c02471d6eaa4" + }, + "@std/net@1.0.6": { + "integrity": "110735f93e95bb9feb95790a8b1d1bf69ec0dc74f3f97a00a76ea5efea25500c" + }, + "@std/path@1.1.4": { + "integrity": "1d2d43f39efb1b42f0b1882a25486647cb851481862dc7313390b2bb044314b5", + "dependencies": [ + "jsr:@std/internal" + ] + }, + "@std/streams@1.0.17": { + "integrity": "7859f3d9deed83cf4b41f19223d4a67661b3d3819e9fc117698f493bf5992140" + } + }, + "npm": { + "@inquirer/external-editor@1.0.3": { + "integrity": "sha512-RWbSrDiYmO4LbejWY7ttpxczuwQyZLBUyygsA9Nsv95hpzUWwnNTVQmAq3xuh7vNwCp07UTmE5i11XAEExx4RA==", + "dependencies": [ + "chardet", + "iconv-lite" + ] + }, + "@samchon/openapi@6.0.1": { + "integrity": "sha512-+nkznmCf/6YavoVkvWg60YoC0UbXY/oK9uMZReyrFcIcXecf+YoWmOLUg+TlgHi+h+6DPgRy6zRkZfiRd3uRnA==" + }, + "@standard-schema/spec@1.1.0": { + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==" + }, + "ansi-escapes@4.3.2": { + "integrity": "sha512-gKXj5ALrKWQLsYG9jlTRmR/xKluxHV+Z9QEwNIgCfM1/uwPMCuzVVnh5mwTd+OuBZcwSIMbqssNWRm1lE51QaQ==", + "dependencies": [ + "type-fest" + ] + }, + "ansi-regex@5.0.1": { + "integrity": "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==" + }, + "ansi-styles@4.3.0": { + "integrity": "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==", + "dependencies": [ + "color-convert" + ] + }, + "array-timsort@1.0.3": { + "integrity": "sha512-/+3GRL7dDAGEfM6TseQk/U+mi18TU2Ms9I3UlLdUMhz2hbvGNTKdj9xniwXfUqgYhHxRx0+8UnKkvlNwVU+cWQ==" + }, + "base64-js@1.5.1": { + "integrity": "sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==" + }, + "bl@4.1.0": { + "integrity": "sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==", + "dependencies": [ + "buffer", + "inherits", + "readable-stream" + ] + }, + "buffer@5.7.1": { + "integrity": "sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==", + "dependencies": [ + "base64-js", + "ieee754" + ] + }, + "chalk@4.1.2": { + "integrity": "sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==", + "dependencies": [ + "ansi-styles", + "supports-color" + ] + }, + "chardet@2.1.1": { + "integrity": "sha512-PsezH1rqdV9VvyNhxxOW32/d75r01NY7TQCmOqomRo15ZSOKbpTFVsfjghxo6JloQUCGnH4k1LGu0R4yCLlWQQ==" + }, + "cli-cursor@3.1.0": { + "integrity": "sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==", + "dependencies": [ + "restore-cursor" + ] + }, + "cli-spinners@2.9.2": { + "integrity": "sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==" + }, + "cli-width@3.0.0": { + "integrity": "sha512-FxqpkPPwu1HjuN93Omfm4h8uIanXofW0RxVEW3k5RKx+mJJYSthzNhp32Kzxxy3YAEZ/Dc/EWN1vZRY0+kOhbw==" + }, + "clone@1.0.4": { + "integrity": "sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==" + }, + "color-convert@2.0.1": { + "integrity": "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==", + "dependencies": [ + "color-name" + ] + }, + "color-name@1.1.4": { + "integrity": "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==" + }, + "commander@10.0.1": { + "integrity": "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug==" + }, + "comment-json@4.5.1": { + "integrity": "sha512-taEtr3ozUmOB7it68Jll7s0Pwm+aoiHyXKrEC8SEodL4rNpdfDLqa7PfBlrgFoCNNdR8ImL+muti5IGvktJAAg==", + "dependencies": [ + "array-timsort", + "core-util-is", + "esprima" + ] + }, + "core-util-is@1.0.3": { + "integrity": "sha512-ZQBvi1DcpJ4GDqanjucZ2Hj3wEO5pZDS89BWbkcrvdxksJorwUDDZamX9ldFkp9aw2lmBDLgkObEA4DWNJ9FYQ==" + }, + "defaults@1.0.4": { + "integrity": "sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==", + "dependencies": [ + "clone" + ] + }, + "drange@1.1.1": { + "integrity": "sha512-pYxfDYpued//QpnLIm4Avk7rsNtAtQkUES2cwAYSvD/wd2pKD71gN2Ebj3e7klzXwjocvE8c5vx/1fxwpqmSxA==" + }, + "emoji-regex@8.0.0": { + "integrity": "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==" + }, + "escape-string-regexp@1.0.5": { + "integrity": "sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==" + }, + "esprima@4.0.1": { + "integrity": "sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==", + "bin": true + }, + "figures@3.2.0": { + "integrity": "sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==", + "dependencies": [ + "escape-string-regexp" + ] + }, + "has-flag@4.0.0": { + "integrity": "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==" + }, + "iconv-lite@0.7.2": { + "integrity": "sha512-im9DjEDQ55s9fL4EYzOAv0yMqmMBSZp6G0VvFyTMPKWxiSBHUj9NW/qqLmXUwXrrM7AvqSlTCfvqRb0cM8yYqw==", + "dependencies": [ + "safer-buffer" + ] + }, + "ieee754@1.2.1": { + "integrity": "sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==" + }, + "inherits@2.0.4": { + "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" + }, + "inquirer@8.2.7": { + "integrity": "sha512-UjOaSel/iddGZJ5xP/Eixh6dY1XghiBw4XK13rCCIJcJfyhhoul/7KhLLUGtebEj6GDYM6Vnx/mVsjx2L/mFIA==", + "dependencies": [ + "@inquirer/external-editor", + "ansi-escapes", + "chalk", + "cli-cursor", + "cli-width", + "figures", + "lodash", + "mute-stream", + "ora", + "run-async", + "rxjs", + "string-width", + "strip-ansi", + "through", + "wrap-ansi" + ] + }, + "is-fullwidth-code-point@3.0.0": { + "integrity": "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==" + }, + "is-interactive@1.0.0": { + "integrity": "sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==" + }, + "is-unicode-supported@0.1.0": { + "integrity": "sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==" + }, + "lodash@4.17.23": { + "integrity": "sha512-LgVTMpQtIopCi79SJeDiP0TfWi5CNEc/L/aRdTh3yIvmZXTnheWpKjSZhnvMl8iXbC1tFg9gdHHDMLoV7CnG+w==" + }, + "log-symbols@4.1.0": { + "integrity": "sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==", + "dependencies": [ + "chalk", + "is-unicode-supported" + ] + }, + "mimic-fn@2.1.0": { + "integrity": "sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==" + }, + "mute-stream@0.0.8": { + "integrity": "sha512-nnbWWOkoWyUsTjKrhgD0dcz22mdkSnpYqbEjIm2nhwhuxlSkpywJmBo8h0ZqJdkp73mb90SssHkN4rsRaBAfAA==" + }, + "onetime@5.1.2": { + "integrity": "sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==", + "dependencies": [ + "mimic-fn" + ] + }, + "ora@5.4.1": { + "integrity": "sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==", + "dependencies": [ + "bl", + "chalk", + "cli-cursor", + "cli-spinners", + "is-interactive", + "is-unicode-supported", + "log-symbols", + "strip-ansi", + "wcwidth" + ] + }, + "package-manager-detector@0.2.11": { + "integrity": "sha512-BEnLolu+yuz22S56CU1SUKq3XC3PkwD5wv4ikR4MfGvnRVcmzXR9DwSlW2fEamyTPyXHomBJRzgapeuBvRNzJQ==", + "dependencies": [ + "quansync" + ] + }, + "quansync@0.2.11": { + "integrity": "sha512-AifT7QEbW9Nri4tAwR5M/uzpBuqfZf+zwaEM/QkzEjj7NBuFD2rBuy0K3dE+8wltbezDV7JMA0WfnCPYRSYbXA==" + }, + "randexp@0.5.3": { + "integrity": "sha512-U+5l2KrcMNOUPYvazA3h5ekF80FHTUG+87SEAmHZmolh1M+i/WyTCxVzmi+tidIa1tM4BSe8g2Y/D3loWDjj+w==", + "dependencies": [ + "drange", + "ret" + ] + }, + "readable-stream@3.6.2": { + "integrity": "sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==", + "dependencies": [ + "inherits", + "string_decoder", + "util-deprecate" + ] + }, + "restore-cursor@3.1.0": { + "integrity": "sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==", + "dependencies": [ + "onetime", + "signal-exit" + ] + }, + "ret@0.2.2": { + "integrity": "sha512-M0b3YWQs7R3Z917WRQy1HHA7Ba7D8hvZg6UE5mLykJxQVE2ju0IXbGlaHPPlkY+WN7wFP+wUMXmBFA0aV6vYGQ==" + }, + "run-async@2.4.1": { + "integrity": "sha512-tvVnVv01b8c1RrA6Ep7JkStj85Guv/YrMcwqYQnwjsAS2cTmmPGBBjAjpCW7RrSodNSoE2/qg9O4bceNvUuDgQ==" + }, + "rxjs@7.8.2": { + "integrity": "sha512-dhKf903U/PQZY6boNNtAGdWbG85WAbjT/1xYoZIC7FAY0yWapOBQVsVrDl58W86//e1VpMNBtRV4MaXfdMySFA==", + "dependencies": [ + "tslib" + ] + }, + "safe-buffer@5.2.1": { + "integrity": "sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==" + }, + "safer-buffer@2.1.2": { + "integrity": "sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==" + }, + "signal-exit@3.0.7": { + "integrity": "sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==" + }, + "string-width@4.2.3": { + "integrity": "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==", + "dependencies": [ + "emoji-regex", + "is-fullwidth-code-point", + "strip-ansi" + ] + }, + "string_decoder@1.3.0": { + "integrity": "sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==", + "dependencies": [ + "safe-buffer" + ] + }, + "strip-ansi@6.0.1": { + "integrity": "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==", + "dependencies": [ + "ansi-regex" + ] + }, + "supports-color@7.2.0": { + "integrity": "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==", + "dependencies": [ + "has-flag" + ] + }, + "through@2.3.8": { + "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" + }, + "tslib@2.8.1": { + "integrity": "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==" + }, + "type-fest@0.21.3": { + "integrity": "sha512-t0rzBq87m3fVcduHDUFhKmyyX+9eo6WQjZvf51Ea/M0Q7+T374Jp1aUiyUl0GKxp8M/OETVHSDvmkyPgvX+X2w==" + }, + "typescript@5.9.3": { + "integrity": "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw==", + "bin": true + }, + "typia@11.0.3_typescript@5.9.3": { + "integrity": "sha512-L7x7WzOCpFyNCauWl6VYJVEG9EHZi5EPNBRzxTO1luaLCd6WEDf+xrJNT+hMZ8U+0X7hCsR1EUpi29LdHhvCvA==", + "dependencies": [ + "@samchon/openapi", + "@standard-schema/spec", + "commander", + "comment-json", + "inquirer", + "package-manager-detector", + "randexp", + "typescript" + ], + "bin": true + }, + "util-deprecate@1.0.2": { + "integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==" + }, + "wcwidth@1.0.1": { + "integrity": "sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==", + "dependencies": [ + "defaults" + ] + }, + "wrap-ansi@6.2.0": { + "integrity": "sha512-r6lPcBGxZXlIcymEu7InxDMhdW0KDxpLgoFLcguasxCaJ/SOIZwINatK9KY/tf+ZrlywOKU0UDj3ATXUBfxJXA==", + "dependencies": [ + "ansi-styles", + "string-width", + "strip-ansi" + ] + } + }, + "workspace": { + "dependencies": [ + "jsr:@std/assert@1", + "jsr:@std/http@^1.0.24", + "npm:typia@^11.0.3" + ] + } +} diff --git a/extension/spotiqueue.js b/extension/spotiqueue.js new file mode 100644 index 0000000..a91bbe2 --- /dev/null +++ b/extension/spotiqueue.js @@ -0,0 +1,255 @@ +const DEFAULT_WS_URL = "ws://localhost:8000/ws/spotify"; +const DEFAULT_RECONNECT_MS = 1000; + +class SpotiQueue { + constructor(wsUrl = DEFAULT_WS_URL, reconnectMs = DEFAULT_RECONNECT_MS) { + this.wsUrl = wsUrl; + this.reconnectMs = reconnectMs; + + this.socket = null; + this.reconnectInterval = null; + this.closing = false; + + this.startedPlaying = false; + this.button = null; + + this._onOpen = this._onOpen.bind(this); + this._onMessage = this._onMessage.bind(this); + this._onError = this._onError.bind(this); + this._onClose = this._onClose.bind(this); + } + + parseCommand(commandStr) { + try { + const parsed = JSON.parse(commandStr); + + if ( + typeof parsed === "object" && + parsed !== null && + typeof parsed.c === "string" && + (!("d" in parsed) || typeof parsed.d === "object") + ) { + return parsed; + } + return null; + } catch { + return null; + } + } + + connect() { + console.log("[SpotiQueue] Trying to connect to server..."); + + if ( + this.socket && + (this.socket.readyState === WebSocket.OPEN || + this.socket.readyState === WebSocket.CONNECTING) + ) { + return; + } + + this.socket = new WebSocket(this.wsUrl); + + this.socket.addEventListener("open", this._onOpen); + this.socket.addEventListener("message", this._onMessage); + this.socket.addEventListener("error", this._onError); + this.socket.addEventListener("close", this._onClose); + } + + stop() { + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + } + + if (this.socket) { + this.closing = true; + try { + this.socket.close(); + } catch (_e) { + // empty + } + this.socket = null; + } + + console.log("[SpotiQueue] Disconnecting from server, Bye!"); + } + + send(objOrString) { + if (!this.socket || this.socket.readyState !== WebSocket.OPEN) return; + const payload = (typeof objOrString === "string") + ? objOrString + : JSON.stringify(objOrString); + try { + this.socket.send(payload); + } catch (err) { + console.error("[SpotiQueue] Failed to send:", err); + } + } + + _onOpen() { + if (this.reconnectInterval) { + clearInterval(this.reconnectInterval); + this.reconnectInterval = null; + } + + if (this.button) this.button.style.color = "#1DB954"; + console.log("[SpotiQueue] Connected to server!"); + } + + async _onMessage(event) { + let data; + try { + data = JSON.parse(event.data); + } catch { + console.warn("[SpotiQueue] Received non-JSON message"); + return; + } + + if (data.c === "ping") { + this.send({ c: "pong" }); + return; + } + + if (data.c === "next_song" && data.d && data.d.song) { + try { + Spicetify.Player.playUri(data.d.song); + Spicetify.Player.setRepeat(false); + this.startedPlaying = true; + console.log("[SpotiQueue] New song received!"); + } catch (err) { + console.error("[SpotiQueue] Error playing received song:", err); + } + } + + if (data.c === "search" && data.d && data.d.query && data.d.id) { + const { query, id } = data.d; + let songs = []; + + console.log("[SpotiQueue] Searching for", query); + + try { + const { searchDesktop } = Spicetify.GraphQL.Definitions; + const { data } = await Spicetify.GraphQL.Request(searchDesktop, { + searchTerm: query, + limit: 10, + offset: 0, + numberOfTopResults: 10, + includeAudiobooks: false, + includePreReleases: false, + IncludeArtistHasConcertsField: false, + includeAuthors: true, + }); + + console.log(data) + songs = data.searchV2.tracksV2.items.reduce((o, c) => { + const item = c.item.data; + + if (!item.playability.playable) return o; + + const album = item.albumOfTrack; + o.push({ + name: item.name, + uri: item.uri, + artists: item.artists.items.map(v => v.profile?.name), + album: { + name: album.name, + coverUrl: album.coverArt.sources.sort((a, b) => + b.height - a.height + ).map((v) => + v.url + )[0], + }, + }); + + return o; + }, []); + } catch (e) { + console.log("[SpotiQueue] Search error", e); + // _ + } + console.log("[SpotiQueue] Found", songs); + + this.send({ + "c": "search", + "d": { + "id": id, + "results": songs, + }, + }); + } + } + _onError() { + try { + if (this.socket) this.socket.close(); + } catch { + // ignore + } + } + + _onClose() { + console.log( + "[SpotiQueue] closed connection", + this.reconnectInterval, + this.closing, + ); + if (!this.reconnectInterval && !this.closing) { + this.reconnectInterval = setInterval( + () => this.connect(), + this.reconnectMs, + ); + } + + if (this.button) this.button.style.color = ""; + this.closing = false; + } + + initUi() { + const btn = new Spicetify.Topbar.Button( + "SpotiQueue", + `

hi

`, // SVG icon or markup + () => { + if (!this.socket) { + if (Spicetify.GraphQL.Definitions.searchDesktop === undefined) { + Spicetify.showNotification("Please search something (e.g a song) before trying to use SpotiQueue. This will load required dependencies.") + return + } + + this.connect(); + this.button.innerText = "bye"; + } else { + this.stop(); + this.button.innerText = "hi"; + } + }, + ); + this.button = btn.button; + + Spicetify.Player.addEventListener("songchange", (info) => { + console.log(info); + if ( + this.socket && this.socket.readyState === WebSocket.OPEN && + !this.startedPlaying + ) { + console.log("[SpotiQueue] Requesting new song..."); + this.send({ c: "next_song" }); + Spicetify.Player.pause(); + } + this.startedPlaying = false; + }); + } +} + +(function init() { + if ( + !Spicetify.Player || !Spicetify.Platform || !Spicetify.GraphQL || + !Spicetify.GraphQL.Request || !Spicetify.GraphQL.Definitions + ) { + setTimeout(init, 100); + console.log("[SpotiQueue] loading extension... "); + return; + } + + const client = new SpotiQueue(); + client.initUi(); +})(); diff --git a/src/commandBuilder.ts b/src/commandBuilder.ts new file mode 100644 index 0000000..f8273ec --- /dev/null +++ b/src/commandBuilder.ts @@ -0,0 +1,30 @@ +export type BaseCommand = { + c: string, + // deno-lint-ignore no-explicit-any + d: any | undefined +} +export type Command = { + c: string + d: D +} + +// deno-lint-ignore no-explicit-any +export function buildCommand(command: string, data?: any): string { + return JSON.stringify({c: command, d: data}) +} +export function buildError(error: number, info?: string): string { + return JSON.stringify({e: error, info}) +} +export function parseCommand(command: string): BaseCommand | null { + let parsed: BaseCommand = {"c": "parse_error", "d": undefined}; + try { + parsed = JSON.parse(command); + if (!("c" in parsed) || !(typeof(parsed.d) === "object" || typeof(parsed.d) === "undefined")) + return null + } catch { + return null; + } + + return parsed as BaseCommand + +} diff --git a/src/heartbeat.ts b/src/heartbeat.ts new file mode 100644 index 0000000..f261d09 --- /dev/null +++ b/src/heartbeat.ts @@ -0,0 +1,53 @@ +import { buildError, parseCommand } from "./commandBuilder.ts"; + +const HEARTBEAT_INTERVAL = 30_000; +const HEARTBEAT_TIMEOUT = 5_000; +const S_PING = JSON.stringify({ "c": "ping" }); + +export class HeartbeatWS { + heartbeatTimer: number | undefined; + pongTimer: number | undefined; + ws: WebSocket; + + constructor(socket: WebSocket) { + this.ws = socket; + socket.addEventListener("open", (_) => this.setHeartBeat()); + socket.addEventListener("close", _ => this.clearTimers()); + socket.addEventListener("error", _ => this.clearTimers()); + socket.addEventListener("message", (msg) => this.onMessage(msg)); + } + + + private setPong(state: boolean) { + if (state) { + clearTimeout(this.pongTimer); + return; + } + + this.ws.send(S_PING); + this.pongTimer = setTimeout(() => { + this.ws.send(buildError(1, "Pong missed.")); + this.ws.close(); + }, HEARTBEAT_TIMEOUT); + } + private setHeartBeat() { + this.setPong(false); + this.heartbeatTimer = setInterval(() => { + this.setPong(false); + }, HEARTBEAT_INTERVAL); + } + private clearTimers() { + clearTimeout(this.pongTimer); + clearTimeout(this.heartbeatTimer); + } + + private onMessage(msg: MessageEvent) { + const text = msg.data; + const parsed = parseCommand(text); + + if (parsed === null || parsed.c !== "pong") return; + + this.setPong(true); + } +} + diff --git a/src/main.ts b/src/main.ts new file mode 100644 index 0000000..e82b581 --- /dev/null +++ b/src/main.ts @@ -0,0 +1,43 @@ +import { SpotifyWS } from "./spotify.ts" +import { HeartbeatWS } from "./heartbeat.ts"; +import { PlayerManager } from "./playerManager.ts"; +import { serveFile } from "@std/http/file-server"; +import { UserWS } from "./user.ts"; + +const USER_PATTERN = new URLPattern({pathname: "/ws/user"}) +const SPOTIFY_PATTERN = new URLPattern({pathname: "/ws/spotify"}) +const song = "spotify:track:2VxJVGiTsK6UyrxPJJ2lR9"; + +function commonWs(req: Request) { + const { socket, response } = Deno.upgradeWebSocket(req); + return { socket, response }; +} + +declare global { + var spotify: SpotifyWS | undefined; + var playerManager: PlayerManager +} +globalThis.spotify = undefined; +globalThis.playerManager = new PlayerManager() + +Deno.serve((req) => { + if (USER_PATTERN.exec(req.url)) { + const { socket, response } = commonWs(req); + new HeartbeatWS(socket); + new UserWS(socket) + + return response + } else if (SPOTIFY_PATTERN.exec(req.url)) { + const { socket, response } = commonWs(req); + + const spState = spotify?.ws.readyState; + if (spState !== undefined && spState !== WebSocket.CLOSED) spotify?.ws.close(); + + spotify = new SpotifyWS(socket, () => song); + new HeartbeatWS(socket); + + return response + } + + return serveFile(req, "./static/index.html") +}) diff --git a/src/playerManager.ts b/src/playerManager.ts new file mode 100644 index 0000000..682fe26 --- /dev/null +++ b/src/playerManager.ts @@ -0,0 +1,94 @@ +import { Song } from "./spotify.ts"; +import { UserWS } from "./user.ts"; + +export type UserQueue = { + queue: Song[]; + userWS: UserWS; +}; +export type MergedQueue = { user: string; song: Song }[]; + +export class PlayerManager { + private userQueue: { [x: string]: UserQueue } = {}; + private nextUserIndex = 0; + public mergedQueue: MergedQueue = []; + + public login(name: string, userWS: UserWS): UserQueue { + if (name in this.userQueue) { + const user = this.userQueue[name]; + + user.userWS.disconnect(); + user.userWS = userWS; + + return user; + } + + this.userQueue[name] = { + queue: [], + userWS, + }; + + return this.userQueue[name]; + } + public logout(name: string) { + if (!(name in this.userQueue)) return; + + const userQueue = this.userQueue[name]; + userQueue.userWS.disconnect(); + + delete this.userQueue[name]; + } + public getNext(): Song | null { + const song = this.mergedQueue.shift(); + if (song === undefined) return null; + + if (song.user in this.userQueue) { + const user = this.userQueue[song.user]; + const users = Object.keys(this.userQueue); + this.nextUserIndex = (this.nextUserIndex + 1) % users.length; + + user.queue.shift(); + user.userWS.updateQueue(); + } + + this.broadcastMergedQueue(); + return song.song; + } + + public updateMergedQueue() { + this.generateMergedQueue(); + this.broadcastMergedQueue(); + } + private broadcastMergedQueue() { + for (const user of Object.values(this.userQueue)) { + user.userWS.broadcastQueue(this.mergedQueue); + } + } + + private generateMergedQueue() { + const users = Object.keys(this.userQueue); + if (!users.length) { + this.mergedQueue = []; + this.nextUserIndex = 0; + return; + } + + this.nextUserIndex = this.nextUserIndex % users.length; + const out: MergedQueue = []; + let idx = 0; + + while (true) { + let added = 0; + for (let i = 0; i < users.length; i++) { + const u = users[(this.nextUserIndex + i) % users.length]; + const list = this.userQueue[u].queue; + if (idx < list.length) { + out.push({ user: u, song: list[idx] }); + added++; + } + } + if (!added) break; + idx++; + } + + this.mergedQueue = out; + }} diff --git a/src/queue.ts b/src/queue.ts new file mode 100644 index 0000000..7a20c17 --- /dev/null +++ b/src/queue.ts @@ -0,0 +1,3 @@ +export class Queue { + private userCount = 0; +} diff --git a/src/spotify.ts b/src/spotify.ts new file mode 100644 index 0000000..d224219 --- /dev/null +++ b/src/spotify.ts @@ -0,0 +1,82 @@ +import { buildCommand, type Command, type BaseCommand, parseCommand } from "./commandBuilder.ts"; + +type GetSong = () => string | null; +export type Song = { + name: string; + uri: string; + artists: string[]; + album: { + name: string; + coverUrl: string | undefined; + }; +}; + +export class SpotifyWS { + public ws: WebSocket; + private queryList: { + [x: string]: (value: Song[] | PromiseLike) => void; + } = {}; + + constructor(ws: WebSocket) { + this.ws = ws; + + this.onMessage = this.onMessage.bind(this); + this.onClose = this.onClose.bind(this); + this.ws.addEventListener("message", this.onMessage); + this.ws.addEventListener("close", this.onClose); + } + + public search(query: string): Promise { + return new Promise((resolve, reject) => { + if (this.ws.readyState != this.ws.OPEN) reject("WS Isn't connected."); + + const id = crypto.randomUUID(); + this.send(buildCommand("search", { query, id })); + this.queryList[id] = resolve; + + setTimeout(() => { + reject("Timeout."); + delete this.queryList[id]; + }, 3000); + }); + } + + public sendSong(uri?: string) { + const song = uri ?? playerManager.getNext()?.uri; + if (!song) return; + + this.send(buildCommand("next_song", { song })); + } + + private send(data: string) { + if (this.ws.readyState != this.ws.OPEN) return; + this.ws.send(data); + } + + private onClose() { + globalThis.spotify = undefined; + } + private onMessage(msg: MessageEvent) { + const text = msg.data; + const parsed = parseCommand(text); + + if (parsed === null) return; + + switch (parsed.c) { + case "next_song": + this.sendSong(); + break; + + case "search": { + if (!parsed.d || !parsed.d.id || !parsed.d.results) break; + const cmd = parsed as Command<{id: string, results: Song[]}> + + const { id, results } = cmd.d; + if (!(id in this.queryList)) break; + + this.queryList[id](results); + break; + } + } + } +} diff --git a/src/user.ts b/src/user.ts new file mode 100644 index 0000000..47448fc --- /dev/null +++ b/src/user.ts @@ -0,0 +1,113 @@ +import { buildCommand, buildError, Command, parseCommand } from "./commandBuilder.ts"; +import { MergedQueue, UserQueue } from "./playerManager.ts"; +import { Song } from "./spotify.ts"; + +export class UserWS { + private ws: WebSocket; + + private loggedIn: boolean = false; + private name: string = ""; + private playerQueue: UserQueue | undefined; + + constructor(ws: WebSocket) { + this.ws = ws; + + this.onMessage = this.onMessage.bind(this); + this.onClose = this.onClose.bind(this); + ws.addEventListener("message", this.onMessage); + ws.addEventListener("close", this.onClose); + } + + public disconnect() { + this.loggedIn = false; + this.ws.send(buildCommand("logout")) + this.ws.close(); + } + public broadcastQueue(queue: MergedQueue) { + this.ws.send(buildCommand("updatemergedqueue", {queue})) + } + public updateQueue() { + const queue = this.playerQueue?.queue; + if (!queue) return; + + this.ws.send(buildCommand("getqueue", {queue})) + } + + private spotifyConnected(): boolean { + return spotify === undefined; + } + private login(name: string) { + this.name = name; + this.loggedIn = true; + + this.playerQueue = playerManager.login(name, this); + } + private async onMessage(msg: MessageEvent) { + const command = parseCommand(msg.data); + if (command === null || (command.c != "login" && !this.loggedIn)) { + console.log("User not logged in but sent", command) + return + }; + + switch (command.c) { + case "login": { + if (command.d === undefined || typeof(command.d.name) !== "string") { + this.ws.send(buildError(0)) + return + }; + + const cmd = command as Command<{name: string}>; + const name = cmd.d.name.trim(); + + if (name === "") { + this.ws.send(buildError(0)) + return + } + + this.login(command.d.name); + break; + }; + + case "search": { + if (command.d === undefined || typeof(command.d.query) !== "string") return + const cmd = command as Command<{query: string}>; + + + const songs = await spotify?.search(cmd.d.query) + if (songs === undefined) { + this.ws.send(buildCommand("search", {songs: [], connected: false})) + } else { + this.ws.send(buildCommand("search", {songs, connected: true})) + } + + break + } + case "getqueue": { + if (!this.playerQueue) return; + + const queue = this.playerQueue.queue; + this.ws.send(buildCommand("getqueue", {queue})) + + break + } + case "setqueue": { + if (command.d === undefined || typeof(command.d.queue) !== "object" || !this.playerQueue) return + const cmd = command as Command<{queue: Song[]}> + + this.playerQueue.queue = cmd.d.queue; + playerManager.updateMergedQueue(); + break + } + case "queuesong": { + if (command.d === undefined || typeof(command.d.song) !== "object" || !this.playerQueue) return + const cmd = command as Command<{song: Song}> + + this.playerQueue.queue.push(cmd.d.song); + playerManager.updateMergedQueue(); + } + } + } + private onClose() { + this.loggedIn = false; + } +} diff --git a/static/index.html b/static/index.html new file mode 100644 index 0000000..bca5d1a --- /dev/null +++ b/static/index.html @@ -0,0 +1,352 @@ + + + + + + Spotify Queue + + + + + + + + + + + + + \ No newline at end of file