Basic functionality is here with a crappy ai generated frontend.
This commit is contained in:
commit
e9fdc05c2d
14 changed files with 1600 additions and 0 deletions
1
.gitattributes
vendored
Normal file
1
.gitattributes
vendored
Normal file
|
|
@ -0,0 +1 @@
|
||||||
|
dist/* linguist-vendored
|
||||||
144
.gitignore
vendored
Normal file
144
.gitignore
vendored
Normal file
|
|
@ -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
|
||||||
14
.helix/languages.toml
Normal file
14
.helix/languages.toml
Normal file
|
|
@ -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"]
|
||||||
10
deno.json
Normal file
10
deno.json
Normal file
|
|
@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
406
deno.lock
generated
Normal file
406
deno.lock
generated
Normal file
|
|
@ -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"
|
||||||
|
]
|
||||||
|
}
|
||||||
|
}
|
||||||
255
extension/spotiqueue.js
Normal file
255
extension/spotiqueue.js
Normal file
|
|
@ -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",
|
||||||
|
`<p>hi</p>`, // 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();
|
||||||
|
})();
|
||||||
30
src/commandBuilder.ts
Normal file
30
src/commandBuilder.ts
Normal file
|
|
@ -0,0 +1,30 @@
|
||||||
|
export type BaseCommand = {
|
||||||
|
c: string,
|
||||||
|
// deno-lint-ignore no-explicit-any
|
||||||
|
d: any | undefined
|
||||||
|
}
|
||||||
|
export type Command<D> = {
|
||||||
|
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
|
||||||
|
|
||||||
|
}
|
||||||
53
src/heartbeat.ts
Normal file
53
src/heartbeat.ts
Normal file
|
|
@ -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<string>) {
|
||||||
|
const text = msg.data;
|
||||||
|
const parsed = parseCommand(text);
|
||||||
|
|
||||||
|
if (parsed === null || parsed.c !== "pong") return;
|
||||||
|
|
||||||
|
this.setPong(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
43
src/main.ts
Normal file
43
src/main.ts
Normal file
|
|
@ -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")
|
||||||
|
})
|
||||||
94
src/playerManager.ts
Normal file
94
src/playerManager.ts
Normal file
|
|
@ -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;
|
||||||
|
}}
|
||||||
3
src/queue.ts
Normal file
3
src/queue.ts
Normal file
|
|
@ -0,0 +1,3 @@
|
||||||
|
export class Queue {
|
||||||
|
private userCount = 0;
|
||||||
|
}
|
||||||
82
src/spotify.ts
Normal file
82
src/spotify.ts
Normal file
|
|
@ -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<Song[]>) => 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<Song[]> {
|
||||||
|
return new Promise<Song[]>((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<string>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
113
src/user.ts
Normal file
113
src/user.ts
Normal file
|
|
@ -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<string>) {
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
}
|
||||||
352
static/index.html
Normal file
352
static/index.html
Normal file
|
|
@ -0,0 +1,352 @@
|
||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<title>Spotify Queue</title>
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1">
|
||||||
|
<style>
|
||||||
|
* {
|
||||||
|
box-sizing: border-box;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
font-family: system-ui, sans-serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background: #121212;
|
||||||
|
color: #fff;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100vh;
|
||||||
|
}
|
||||||
|
|
||||||
|
header {
|
||||||
|
background: #1f1f1f;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
header h1 {
|
||||||
|
font-size: 1.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
main {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
footer {
|
||||||
|
background: #1f1f1f;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text],
|
||||||
|
button {
|
||||||
|
padding: 0.5rem 0.75rem;
|
||||||
|
border: none;
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type=text] {
|
||||||
|
flex: 1;
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
button {
|
||||||
|
background: #1db954;
|
||||||
|
color: #000;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
button:disabled {
|
||||||
|
background: #555;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 6px;
|
||||||
|
background: #1a1a1a;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
cursor: grab;
|
||||||
|
touch-action: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song img {
|
||||||
|
width: 48px;
|
||||||
|
height: 48px;
|
||||||
|
border-radius: 4px;
|
||||||
|
object-fit: cover;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song div {
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song .title {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song .artist {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
.song .user {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
opacity: 0.6;
|
||||||
|
margin-top: 0.2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button {
|
||||||
|
background: #2a2a2a;
|
||||||
|
color: #fff;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tabs button.active {
|
||||||
|
background: #1db954;
|
||||||
|
color: #000;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.login {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
max-width: 300px;
|
||||||
|
margin: auto;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
|
</head>
|
||||||
|
|
||||||
|
<body>
|
||||||
|
<div id="loginScreen" class="login">
|
||||||
|
<h2>Enter your name</h2>
|
||||||
|
<input id="usernameInput" type="text" placeholder="Username" />
|
||||||
|
<button onclick="login()">Join</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="app" class="hidden">
|
||||||
|
<header>
|
||||||
|
<h1>Spotify Queue</h1>
|
||||||
|
<button onclick="logout()">Logout</button>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="tabs">
|
||||||
|
<button id="tabMy" class="active" onclick="switchTab('my')">My Queue</button>
|
||||||
|
<button id="tabMerged" onclick="switchTab('merged')">Merged Queue</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<main>
|
||||||
|
<div id="myView">
|
||||||
|
<div style="display:flex; gap:0.5rem; margin-bottom:1rem;">
|
||||||
|
<input id="searchInput" type="text" placeholder="Search for songs…" />
|
||||||
|
<button onclick="search()">Search</button>
|
||||||
|
</div>
|
||||||
|
<div id="searchResults"></div>
|
||||||
|
<h3 style="margin-top:1rem;">My Queue</h3>
|
||||||
|
<div id="myQueue"></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div id="mergedView" class="hidden">
|
||||||
|
<h3>Merged Queue</h3>
|
||||||
|
<div id="mergedQueue"></div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
|
||||||
|
<!-- <footer> -->
|
||||||
|
<!-- <input id="searchFooter" type="text" placeholder="Search…" /> -->
|
||||||
|
<!-- <button onclick="searchFooter()">Search</button> -->
|
||||||
|
<!-- </footer> -->
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<script>
|
||||||
|
let ws;
|
||||||
|
let username = '';
|
||||||
|
let myQueue = [];
|
||||||
|
let mergedQueue = [];
|
||||||
|
let searchResults = [];
|
||||||
|
let dragged = null;
|
||||||
|
|
||||||
|
function connect() {
|
||||||
|
ws = new WebSocket(`ws://${location.host}/ws/user`);
|
||||||
|
|
||||||
|
ws.onopen = () => {
|
||||||
|
ws.send(JSON.stringify({c: 'login', d: {name: username}}));
|
||||||
|
ws.send(JSON.stringify({c: 'getqueue'}));
|
||||||
|
};
|
||||||
|
|
||||||
|
ws.onmessage = (e) => {
|
||||||
|
const msg = JSON.parse(e.data);
|
||||||
|
if (msg.c === 'ping') {ws.send(JSON.stringify({c: 'pong'})); return;}
|
||||||
|
if (msg.c === 'search') {
|
||||||
|
searchResults = msg.d.songs || [];
|
||||||
|
renderSearch();
|
||||||
|
}
|
||||||
|
if (msg.c === 'getqueue') {
|
||||||
|
myQueue = msg.d.queue || [];
|
||||||
|
renderMyQueue();
|
||||||
|
}
|
||||||
|
if (msg.c === 'updatemergedqueue') {
|
||||||
|
console.log(msg)
|
||||||
|
mergedQueue = msg.d.queue || [];
|
||||||
|
if (document.getElementById('tabMerged').classList.contains('active')) renderMerged();
|
||||||
|
}
|
||||||
|
if (msg.c === "logout") {
|
||||||
|
location.reload();
|
||||||
|
}
|
||||||
|
};
|
||||||
|
ws.onclose = () => setTimeout(connect, 3000);
|
||||||
|
}
|
||||||
|
|
||||||
|
function login() {
|
||||||
|
const name = document.getElementById('usernameInput').value.trim();
|
||||||
|
if (!name) return;
|
||||||
|
|
||||||
|
username = name;
|
||||||
|
document.getElementById('loginScreen').style.display = 'none';
|
||||||
|
document.getElementById('app').classList.remove('hidden');
|
||||||
|
|
||||||
|
connect();
|
||||||
|
}
|
||||||
|
|
||||||
|
function logout() {
|
||||||
|
username = '';
|
||||||
|
if (ws) ws.close();
|
||||||
|
document.getElementById('app').classList.add('hidden');
|
||||||
|
document.getElementById('loginScreen').style.display = 'flex';
|
||||||
|
}
|
||||||
|
|
||||||
|
function switchTab(tab) {
|
||||||
|
document.getElementById('tabMy').classList.toggle('active', tab === 'my');
|
||||||
|
document.getElementById('tabMerged').classList.toggle('active', tab === 'merged');
|
||||||
|
document.getElementById('myView').classList.toggle('hidden', tab !== 'my');
|
||||||
|
document.getElementById('mergedView').classList.toggle('hidden', tab !== 'merged');
|
||||||
|
if (tab === 'merged') renderMerged();
|
||||||
|
}
|
||||||
|
|
||||||
|
function search() {
|
||||||
|
const q = document.getElementById('searchInput').value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
ws.send(JSON.stringify({c: 'search', d: {query: q}}));
|
||||||
|
}
|
||||||
|
function searchFooter() {
|
||||||
|
const q = document.getElementById('searchFooter').value.trim();
|
||||||
|
if (!q) return;
|
||||||
|
document.getElementById('searchInput').value = q;
|
||||||
|
search();
|
||||||
|
switchTab('my');
|
||||||
|
}
|
||||||
|
|
||||||
|
function addSong(song) {
|
||||||
|
myQueue.push(song);
|
||||||
|
ws.send(JSON.stringify({c: 'queuesong', d: {song}}));
|
||||||
|
ws.send(JSON.stringify({c: 'getqueue'}));
|
||||||
|
}
|
||||||
|
|
||||||
|
function removeSong(idx) {
|
||||||
|
myQueue.splice(idx, 1);
|
||||||
|
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
|
||||||
|
renderMyQueue();
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderSearch() {
|
||||||
|
const el = document.getElementById('searchResults');
|
||||||
|
el.innerHTML = '';
|
||||||
|
searchResults.forEach(s => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'song';
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
|
||||||
|
<div>
|
||||||
|
<div class="title">${s.name}</div>
|
||||||
|
<div class="artist">${s.artists.join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
<button onclick='addSong(${JSON.stringify(s)})'>+</button>
|
||||||
|
`;
|
||||||
|
el.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMyQueue() {
|
||||||
|
const el = document.getElementById('myQueue');
|
||||||
|
el.innerHTML = '';
|
||||||
|
myQueue.forEach((s, i) => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'song';
|
||||||
|
div.draggable = true;
|
||||||
|
div.dataset.idx = i;
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${s.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
|
||||||
|
<div>
|
||||||
|
<div class="title">${s.name}</div>
|
||||||
|
<div class="artist">${s.artists.join(', ')}</div>
|
||||||
|
</div>
|
||||||
|
<button onclick="removeSong(${i})">✕</button>
|
||||||
|
`;
|
||||||
|
div.ondragstart = e => {dragged = +e.target.dataset.idx; e.target.classList.add('dragging');};
|
||||||
|
div.ondragend = e => e.target.classList.remove('dragging');
|
||||||
|
div.ondragover = e => e.preventDefault();
|
||||||
|
div.ondrop = e => {
|
||||||
|
e.preventDefault();
|
||||||
|
const to = +e.currentTarget.dataset.idx;
|
||||||
|
if (dragged === to) return;
|
||||||
|
const temp = myQueue[dragged];
|
||||||
|
myQueue.splice(dragged, 1);
|
||||||
|
myQueue.splice(to, 0, temp);
|
||||||
|
ws.send(JSON.stringify({c: 'setqueue', d: {queue: myQueue}}));
|
||||||
|
renderMyQueue();
|
||||||
|
};
|
||||||
|
el.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function renderMerged() {
|
||||||
|
const el = document.getElementById('mergedQueue');
|
||||||
|
el.innerHTML = '';
|
||||||
|
mergedQueue.forEach(item => {
|
||||||
|
const div = document.createElement('div');
|
||||||
|
div.className = 'song';
|
||||||
|
div.innerHTML = `
|
||||||
|
<img src="${item.song.album.coverUrl || 'data:image/gif;base64,R0lGODlhAQABAIAAAAAAAP///yH5BAEAAAAALAAAAAABAAEAAAIBRAA7'}" alt="">
|
||||||
|
<div>
|
||||||
|
<div class="title">${item.song.name}</div>
|
||||||
|
<div class="artist">${item.song.artists.join(', ')}</div>
|
||||||
|
<div class="user">Added by ${item.user}</div>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
el.appendChild(div);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
function escSong(s) {
|
||||||
|
return encodeURIComponent(JSON.stringify(s).replace(/'/g, "\\'"));
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
</body>
|
||||||
|
|
||||||
|
</html>
|
||||||
Loading…
Add table
Add a link
Reference in a new issue