- Update API
- Add Chart Preview - Add Drum Type dropdown when the "drums" instrument is selected - Add Min/Max Year to advanced search - Add Track Hash to advanced search - Add "Download Video Backgrounds" setting - Updated and improved detected chart issues
@@ -30,7 +30,7 @@
|
||||
"accessibility": "explicit"
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/no-use-before-define": ["error", { "typedefs": false, "functions": false }],
|
||||
"@typescript-eslint/no-use-before-define": ["error", { "typedefs": false, "functions": false, "classes": false }],
|
||||
"@typescript-eslint/no-shadow": ["off"],
|
||||
"@typescript-eslint/member-ordering": ["error", { "default": ["field", "public-constructor", "constructor", "method"] }],
|
||||
"@typescript-eslint/member-delimiter-style": ["error", { "multiline": { "delimiter": "none", "requireLast": true } }],
|
||||
@@ -43,7 +43,8 @@
|
||||
"error",
|
||||
{
|
||||
"ignorePattern": "^import |^export \\{(.*?)\\}|^\\s*@inject\\(",
|
||||
"code": 140
|
||||
"tabWidth": 2,
|
||||
"code": 150
|
||||
}
|
||||
],
|
||||
"@typescript-eslint/naming-convention": [
|
||||
|
||||
@@ -40,12 +40,14 @@
|
||||
"electron-unhandled": "^5.0.0",
|
||||
"electron-updater": "^6.2.1",
|
||||
"electron-window-state": "^5.0.3",
|
||||
"eventemitter3": "^5.0.1",
|
||||
"fs-extra": "^11.2.0",
|
||||
"lodash": "^4.17.21",
|
||||
"parse-sng": "^4.0.1",
|
||||
"rxjs": "~7.8.1",
|
||||
"sanitize-filename": "^1.6.3",
|
||||
"scan-chart": "^3.4.1",
|
||||
"scan-chart": "^4.1.3",
|
||||
"three": "^0.166.1",
|
||||
"tslib": "^2.6.3",
|
||||
"zod": "^3.23.8",
|
||||
"zone.js": "~0.14.2"
|
||||
@@ -64,6 +66,7 @@
|
||||
"@types/fs-extra": "^11.0.4",
|
||||
"@types/lodash": "^4.17.6",
|
||||
"@types/node": "^18.16.0",
|
||||
"@types/three": "^0.166.0",
|
||||
"@typescript-eslint/eslint-plugin": "^7.16.0",
|
||||
"@typescript-eslint/parser": "^7.16.0",
|
||||
"concurrently": "^8.2.2",
|
||||
|
||||
329
pnpm-lock.yaml
generated
@@ -53,6 +53,9 @@ importers:
|
||||
electron-window-state:
|
||||
specifier: ^5.0.3
|
||||
version: 5.0.3
|
||||
eventemitter3:
|
||||
specifier: ^5.0.1
|
||||
version: 5.0.1
|
||||
fs-extra:
|
||||
specifier: ^11.2.0
|
||||
version: 11.2.0
|
||||
@@ -69,8 +72,11 @@ importers:
|
||||
specifier: ^1.6.3
|
||||
version: 1.6.3
|
||||
scan-chart:
|
||||
specifier: ^3.4.1
|
||||
version: 3.4.1
|
||||
specifier: ^4.1.3
|
||||
version: 4.1.3
|
||||
three:
|
||||
specifier: ^0.166.1
|
||||
version: 0.166.1
|
||||
tslib:
|
||||
specifier: ^2.6.3
|
||||
version: 2.6.3
|
||||
@@ -120,6 +126,9 @@ importers:
|
||||
'@types/node':
|
||||
specifier: ^18.16.0
|
||||
version: 18.16.0
|
||||
'@types/three':
|
||||
specifier: ^0.166.0
|
||||
version: 0.166.0
|
||||
'@typescript-eslint/eslint-plugin':
|
||||
specifier: ^7.16.0
|
||||
version: 7.16.0(@typescript-eslint/parser@7.16.0(eslint@8.57.0)(typescript@5.4.5))(eslint@8.57.0)(typescript@5.4.5)
|
||||
@@ -916,6 +925,10 @@ packages:
|
||||
cpu: [x64]
|
||||
os: [win32]
|
||||
|
||||
'@noble/hashes@1.4.0':
|
||||
resolution: {integrity: sha512-V1JJ1WTRUqHHrOSh597hURcMqVKVGL/ea3kv0gSnEdsEZ0/+VyPghM1lMNGc00z7CIQorSvbKpuJkxvuHbvdbg==}
|
||||
engines: {node: '>= 16'}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==}
|
||||
engines: {node: '>= 8'}
|
||||
@@ -1176,6 +1189,9 @@ packages:
|
||||
resolution: {integrity: sha512-92F7/SFyufn4DXsha9+QfKnN03JGqtMFMXgSHbZOo8JG59WkTni7UzAouNQDf7AuP9OAMxVOPQcqG3sB7w+kkg==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
||||
'@tweenjs/tween.js@23.1.2':
|
||||
resolution: {integrity: sha512-kMCNaZCJugWI86xiEHaY338CU5JpD0B97p1j1IKNn/Zto8PgACjQx0UxbHjmOcLl/dDOBnItwD07KmCs75pxtQ==}
|
||||
|
||||
'@types/cacheable-request@6.0.3':
|
||||
resolution: {integrity: sha512-IQ3EbTzGxIigb1I3qPZc1rWJnH0BmSKv5QYTalEwweFvyBDLSAe24zP0le/hyi7ecGfZVlIVAg4BZqb8WBwKqw==}
|
||||
|
||||
@@ -1230,9 +1246,18 @@ packages:
|
||||
'@types/responselike@1.0.3':
|
||||
resolution: {integrity: sha512-H/+L+UkTV33uf49PH5pCAUBVPNj2nDBXTN+qS1dOwyyg24l3CcicicCA7ca+HMvJBZcFgl5r8e+RR6elsb4Lyw==}
|
||||
|
||||
'@types/stats.js@0.17.3':
|
||||
resolution: {integrity: sha512-pXNfAD3KHOdif9EQXZ9deK82HVNaXP5ZIF5RP2QG6OQFNTaY2YIetfrE9t528vEreGQvEPRDDc8muaoYeK0SxQ==}
|
||||
|
||||
'@types/three@0.166.0':
|
||||
resolution: {integrity: sha512-FHMnpcdhdbdOOIYbfkTkUVpYMW53odxbTRwd0/xJpYnTzEsjnVnondGAvHZb4z06UW0vo6WPVuvH0/9qrxKx7g==}
|
||||
|
||||
'@types/verror@1.10.9':
|
||||
resolution: {integrity: sha512-MLx9Z+9lGzwEuW16ubGeNkpBDE84RpB/NyGgg6z2BTpWzKkGU451cAY3UkUzZEp72RHF585oJ3V8JVNqIplcAQ==}
|
||||
|
||||
'@types/webxr@0.5.19':
|
||||
resolution: {integrity: sha512-4hxA+NwohSgImdTSlPXEqDqqFktNgmTXQ05ff1uWam05tNGroCMp4G+4XVl6qWm1p7GQ/9oD41kAYsSssF6Mzw==}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
resolution: {integrity: sha512-oJoftv0LSuaDZE3Le4DbKX+KS9G36NzOeSap90UIK0yMA/NhKJhqlSGtNDORNRaIbQfzjXDrQa0ytJ6mNRGz/Q==}
|
||||
|
||||
@@ -1541,9 +1566,6 @@ packages:
|
||||
axobject-query@4.0.0:
|
||||
resolution: {integrity: sha512-+60uv1hiVFhHZeO+Lz0RYzsVHy5Wr1ayX0mwda9KPDVLNJgZ1T9Ny7VmFbLDzxsH0D87I86vgj3gFrjTJUYznw==}
|
||||
|
||||
b4a@1.6.4:
|
||||
resolution: {integrity: sha512-fpWrvyVHEKyeEvbKZTVOeZF3VSKKWtJxFIxX/jaVPf+cLbGUSitjb49pHLqPV2BUNNZ0LcoeEGfE/YCpyDYHIw==}
|
||||
|
||||
balanced-match@1.0.2:
|
||||
resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==}
|
||||
|
||||
@@ -1663,9 +1685,6 @@ packages:
|
||||
chardet@0.7.0:
|
||||
resolution: {integrity: sha512-mT8iDcrh03qDGRRmoA2hmBJnxpllMR+0/0qlzjqZES6NdiWDcZkCNAk4rPFZ9Q85r27unkiNNg8ZOiwZXBHwcA==}
|
||||
|
||||
charset-detector@0.0.2:
|
||||
resolution: {integrity: sha512-aZBFdf9aE168W7w3t9JNC0w9hZdTKjtq1hsrfbziJA4DrNI22Mfrc74gw3aADIA9bCQV2IpnbjyQcDHa3qM4rg==}
|
||||
|
||||
chokidar@3.5.3:
|
||||
resolution: {integrity: sha512-Dr3sfKRP6oTcjf2JmUmFJfeVMvXBdegxB0iVQ5eb2V10uFJUCAS8OByZdVAyVb8xXNz3GjjTgj9kLWsZTqE6kw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
@@ -1674,9 +1693,6 @@ packages:
|
||||
resolution: {integrity: sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==}
|
||||
engines: {node: '>= 8.10.0'}
|
||||
|
||||
chownr@1.1.4:
|
||||
resolution: {integrity: sha512-jJ0bqzaylmJtVnNgzTeSOs8DPavpbYgEr/b0YL8/2GO3xJEhInFmhKMUnEJQjZumK7KXGFhUy89PrsJWlakBVg==}
|
||||
|
||||
chownr@2.0.0:
|
||||
resolution: {integrity: sha512-bIomtDF5KGpdogkLd9VspvFzk9KfpyyGlS8YFVZl7TGPBHL5snIOnxeshwVgPteQ9b4Eydl+pVbIyE1DcvCWgQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -1740,13 +1756,6 @@ packages:
|
||||
color-name@1.1.4:
|
||||
resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==}
|
||||
|
||||
color-string@1.9.1:
|
||||
resolution: {integrity: sha512-shrVawQFojnZv6xM40anx4CkoDP+fZsw/ZerEMsW/pyzsRbElpsL/DBVW7q3ExxwusdNXI3lXpuhEZkzs8p5Eg==}
|
||||
|
||||
color@4.2.3:
|
||||
resolution: {integrity: sha512-1rXeuUUiGGrykh+CeBdu5Ie7OJwinCgQY0bc7GCRxy5xVHy+moaqkpL/jqQq0MtQOeYcrqEz4abc5f0KtU7W4A==}
|
||||
engines: {node: '>=12.5.0'}
|
||||
|
||||
combined-stream@1.0.8:
|
||||
resolution: {integrity: sha512-FQN4MRfuJeHf7cBbBMJFXhKSDq+2kAArBlmRBvcvFE5BB1HZKXtSFASDhdlz9zOYwxh8lDdnvmMOe/+5cdoEdg==}
|
||||
engines: {node: '>= 0.8'}
|
||||
@@ -1878,10 +1887,6 @@ packages:
|
||||
resolution: {integrity: sha512-aW35yZM6Bb/4oJlZncMH2LCoZtJXTRxES17vE3hoRiowU2kWHaJKFkSBDnDR+cm9J+9QhXmREyIfv0pji9ejCQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
deep-extend@0.6.0:
|
||||
resolution: {integrity: sha512-LOHxIOaPYdHlJRtCQfDIVZtfw/ufM8+rVj649RIHzcm/vGwQRXFt6OPqIFWsm2XEMrNIEtWR64sY1LEKD2vAOA==}
|
||||
engines: {node: '>=4.0.0'}
|
||||
|
||||
deep-is@0.1.4:
|
||||
resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==}
|
||||
|
||||
@@ -2226,13 +2231,8 @@ packages:
|
||||
eventemitter3@5.0.1:
|
||||
resolution: {integrity: sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==}
|
||||
|
||||
events@3.3.0:
|
||||
resolution: {integrity: sha512-mQw+2fkQbALzQ7V0MY0IqdnXNOeTtP4r0lN9z7AAawCXgqea7bDii20AYrIBrFd/Hx0M2Ocz6S111CaFkUcb0Q==}
|
||||
engines: {node: '>=0.8.x'}
|
||||
|
||||
expand-template@2.0.3:
|
||||
resolution: {integrity: sha512-XYfuKMvj4O35f/pOXLObndIRvyQ+/+6AhODh+OKWj9S9498pHHn/IMszH+gt0fBCRWMNfk1ZSp5x3AifmnI2vg==}
|
||||
engines: {node: '>=6'}
|
||||
exifreader@4.23.3:
|
||||
resolution: {integrity: sha512-/Ii4jiNp/5BXdKOiWXZYrWmZFn/ANu3bMVGO7GFQufao5M52/fK2OsAPMH34PL4S79z1eZBzAoaYyBXit0zzVA==}
|
||||
|
||||
exponential-backoff@3.1.1:
|
||||
resolution: {integrity: sha512-dX7e/LHVJ6W3DE1MHWi9S1EYzDESENfLrYohG2G++ovZrYOkm4Knwa0mc1cn84xJOR4KEU0WSchhLbd0UklbHw==}
|
||||
@@ -2256,9 +2256,6 @@ packages:
|
||||
fast-diff@1.3.0:
|
||||
resolution: {integrity: sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==}
|
||||
|
||||
fast-fifo@1.3.2:
|
||||
resolution: {integrity: sha512-/d9sfos4yxzpwkDkuN7k2SqFKtYNmCTzgfEpz82x34IM9/zc8KGxQoXg1liNC/izpRM/MBdt44Nmx41ZWqk+FQ==}
|
||||
|
||||
fast-glob@3.3.2:
|
||||
resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==}
|
||||
engines: {node: '>=8.6.0'}
|
||||
@@ -2278,6 +2275,9 @@ packages:
|
||||
fd-slicer@1.1.0:
|
||||
resolution: {integrity: sha512-cE1qsB/VwyQozZ+q1dGxR8LBYNZeofhEdUNGSMbQD3Gw2lAzX9Zb3uIU6Ebc/Fmyjo9AWWfnn0AUCHqtevs/8g==}
|
||||
|
||||
fflate@0.8.2:
|
||||
resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==}
|
||||
|
||||
figures@3.2.0:
|
||||
resolution: {integrity: sha512-yaduQFRKLXYOGgEn6AZau90j3ggSOyiqXU0F9JZfeXYhNa+Jk4X+s45A2zg5jns87GAFa34BBm2kXw4XpNcbdg==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -2400,9 +2400,6 @@ packages:
|
||||
resolution: {integrity: sha512-2EmdH1YvIQiZpltCNgkuiUnyukzxM/R6NDJX31Ke3BG1Nq5b0S2PhX59UKi9vZpPDQVdqn+1IcaAwnzTT5vCjw==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
github-from-package@0.0.0:
|
||||
resolution: {integrity: sha512-SyHy3T1v2NUXn29OsWdxmK6RwHD+vkj3v8en8AOBZ1wBQ/hCAQ5bAQTD02kW4W9tUp/3Qh6J8r9EvntiyCmOOw==}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==}
|
||||
engines: {node: '>= 6'}
|
||||
@@ -2581,9 +2578,6 @@ packages:
|
||||
inherits@2.0.4:
|
||||
resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==}
|
||||
|
||||
ini@1.3.8:
|
||||
resolution: {integrity: sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew==}
|
||||
|
||||
ini@4.1.2:
|
||||
resolution: {integrity: sha512-AMB1mvwR1pyBFY/nSevUX6y8nJWS63/SzUKD3JyQn97s4xgIdgQPT75IRouIiBAN4yLQBUShNYVW0+UG25daCw==}
|
||||
engines: {node: ^14.17.0 || ^16.13.0 || >=18.0.0}
|
||||
@@ -2602,9 +2596,6 @@ packages:
|
||||
is-array-buffer@3.0.2:
|
||||
resolution: {integrity: sha512-y+FyyR/w8vfIRq4eQcM1EYgSTnmHXPqaF+IgzgraytCFq5Xh8lllDVmAZolPJiZttZLeFSINPYMaEJ7/vWUa1w==}
|
||||
|
||||
is-arrayish@0.3.2:
|
||||
resolution: {integrity: sha512-eVRqCvVlZbuw3GrM63ovNSNAeA1K16kaR/LRY/92w0zxQ5/1YzwblUX652i4Xs9RwAGjW9d9y6X88t8OaAJfWQ==}
|
||||
|
||||
is-bigint@1.0.4:
|
||||
resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==}
|
||||
|
||||
@@ -2743,6 +2734,9 @@ packages:
|
||||
resolution: {integrity: sha512-gFqAIbuKyyso/3G2qhiO2OM6shY6EPP/R0+mkDbyspxKazh8BXDC5FiFsUjlczgdNz/vfra0da2y+aHrusLG/Q==}
|
||||
hasBin: true
|
||||
|
||||
js-md5@0.8.3:
|
||||
resolution: {integrity: sha512-qR0HB5uP6wCuRMrWPTrkMaev7MJZwJuuw4fnwAzRgP4J4/F8RwtodOKpGp4XpqsLBFzzgqIO42efFAyz2Et6KQ==}
|
||||
|
||||
js-tokens@4.0.0:
|
||||
resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==}
|
||||
|
||||
@@ -2919,17 +2913,15 @@ packages:
|
||||
resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
meshoptimizer@0.18.1:
|
||||
resolution: {integrity: sha512-ZhoIoL7TNV4s5B6+rx5mC//fw8/POGyNxS/DZyCJeiZ12ScLfVwRE/GfsxwiTkMYYD5DmK2/JXnEVXqL4rF+Sw==}
|
||||
|
||||
micromatch@4.0.5:
|
||||
resolution: {integrity: sha512-DMy+ERcEW2q8Z2Po+WNXuw3c5YaUSFjAO5GsJqfEl7UjvtIuFKO6ZrKvcItdy98dwFI2N1tg3zNIdKaQT+aNdA==}
|
||||
engines: {node: '>=8.6'}
|
||||
|
||||
midievents@2.0.0:
|
||||
resolution: {integrity: sha512-8ABFjr+IKj1ZMAERj0jo99v/ifXU6kCbx9QKj4lM5GccaU81FPk+OVgt3FptGF9zcEsGJY74pl6juRkXDQJs+A==}
|
||||
engines: {node: '>=6.9.5'}
|
||||
|
||||
midifile@2.0.0:
|
||||
resolution: {integrity: sha512-JQw8RzJ3auOKR/fWIgt6gqKXKrPdq/AZjToQroqTjL0heBa7Ra0EvzRf/y/RDAC/UGMs6eIwB7jLS3nc9DHxZQ==}
|
||||
engines: {node: '>=6.9.5'}
|
||||
midi-file@1.2.4:
|
||||
resolution: {integrity: sha512-B5SnBC6i2bwJIXTY9MElIydJwAmnKx+r5eJ1jknTLetzLflEl0GWveuBB6ACrQpecSRkOB6fhTx1PwXk2BVxnA==}
|
||||
|
||||
mime-db@1.52.0:
|
||||
resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==}
|
||||
@@ -3010,9 +3002,6 @@ packages:
|
||||
resolution: {integrity: sha512-bAxsR8BVfj60DWXHE3u30oHzfl4G7khkSuPW+qvpd7jFRHm7dLxOjUk1EHACJ/hxLY8phGJ0YhYHZo7jil7Qdg==}
|
||||
engines: {node: '>= 8'}
|
||||
|
||||
mkdirp-classic@0.5.3:
|
||||
resolution: {integrity: sha512-gKLcREMhtuZRwRAfqP3RFW+TK4JqApVBtOIftVgjuABpAtpxhPGaDcfvbhNvD0B8iD1oUr/txX35NjcaY6Ns/A==}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
resolution: {integrity: sha512-FP+p8RB8OWpF3YZBCrP5gtADmtXApB5AMLn+vdyA+PyxCjrCs00mjyUozssO33cwDeT3wNGdLxJ5M//YqtHAJw==}
|
||||
hasBin: true
|
||||
@@ -3051,9 +3040,6 @@ packages:
|
||||
engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1}
|
||||
hasBin: true
|
||||
|
||||
napi-build-utils@1.0.2:
|
||||
resolution: {integrity: sha512-ONmRUqK7zj7DWX0D9ADe03wbwOBZxNAfF20PlGfCWQcD3+/MakShIHrMqx9YwPTfxDdF1zLeL+RGZiR9kGMLdg==}
|
||||
|
||||
natural-compare@1.4.0:
|
||||
resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==}
|
||||
|
||||
@@ -3065,10 +3051,6 @@ packages:
|
||||
resolution: {integrity: sha512-px/KnJAJZf5RuBGcfD+Sp2pAKq0ytz8j+1NehvgIGFkvtvFrDM3T8E4x/JJODXK9WZow8RRGrbA9QQ3hs+pDhA==}
|
||||
os: ['!win32']
|
||||
|
||||
node-abi@3.52.0:
|
||||
resolution: {integrity: sha512-JJ98b02z16ILv7859irtXn4oUaFWADtvkzy2c0IAatNVX2Mc9Yoh8z6hZInn3QwvMEYhHuQloYi+TTQy67SIdQ==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
node-addon-api@1.7.2:
|
||||
resolution: {integrity: sha512-ibPK3iA+vaY1eEjESkQkM0BbCqFOaZMiXRTtdB0u7b4djtY6JnsjvPdUHVMg6xQt3B8fpTTWHI9A+ADjM9frzg==}
|
||||
|
||||
@@ -3263,9 +3245,6 @@ packages:
|
||||
resolution: {integrity: sha512-TDT4HqzUiTMO1wJRwg/t/hYk8Wdp3iF/ToMIlAoVQfL1Xs/sTxq1dKWSMjMbQmIarfWKymOyly40+zmPHXMqCA==}
|
||||
engines: {node: '>= 18'}
|
||||
|
||||
parse-sng@3.1.2:
|
||||
resolution: {integrity: sha512-gAFsAJ6yaU2uH8UeSRC+EYFAN/urm/ICRJSLSwP1sqVBEF/VhN0XAxNNFTeLGsgdv1V2gx6pLA8sS9M6YX/FCA==}
|
||||
|
||||
parse-sng@4.0.1:
|
||||
resolution: {integrity: sha512-AoHeWhylK0bhkn/8W8Mxd/xqSr9B4im8qlMbgv9+Vd6YmUelKQ2MBvo2K1Jyex000qfjH6Nv76eKPwGj/h3e2A==}
|
||||
|
||||
@@ -3374,11 +3353,6 @@ packages:
|
||||
resolution: {integrity: sha512-0vzE+lAiG7hZl1/9I8yzKLx3aR9Xbof3fBHKunvMfOCYAtMhrsnccJY2iTURb9EZd5+pLuiNV9/c/GZJOHsgIw==}
|
||||
engines: {node: ^10 || ^12 || >=14}
|
||||
|
||||
prebuild-install@7.1.1:
|
||||
resolution: {integrity: sha512-jAXscXWMcCK8GgCoHOfIr0ODh5ai8mj63L2nWrjuAgXE6tDyYGnx4/8o/rCgU+B4JSyZBKbeZqzhtwtC3ovxjw==}
|
||||
engines: {node: '>=10'}
|
||||
hasBin: true
|
||||
|
||||
prelude-ls@1.2.1:
|
||||
resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -3451,17 +3425,10 @@ packages:
|
||||
queue-microtask@1.2.3:
|
||||
resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==}
|
||||
|
||||
queue-tick@1.0.1:
|
||||
resolution: {integrity: sha512-kJt5qhMxoszgU/62PLP1CJytzd2NKetjSRnyuj31fDd3Rlcz3fzlFdFLD1SItunPwyqEOkca6GbV612BWfaBag==}
|
||||
|
||||
quick-lru@5.1.1:
|
||||
resolution: {integrity: sha512-WuyALRjWPDGtt/wzJiadO5AXY+8hZ80hVpe6MyivgraREW751X3SbhRvG3eLKOYN+8VEvqLcf3wdnt44Z4S4SA==}
|
||||
engines: {node: '>=10'}
|
||||
|
||||
rc@1.2.8:
|
||||
resolution: {integrity: sha512-y3bGgqKj3QBdxLbLkomlohkvsA8gdAiUQlSBJnBhfn+BPxg4bc62d8TcBW15wavDfgexCgccckhcZvywyQYPOw==}
|
||||
hasBin: true
|
||||
|
||||
react-is@18.2.0:
|
||||
resolution: {integrity: sha512-xWGDIW6x921xtzPkhiULtthJHoJvBbF3q26fzloPCK0hsvxtPVelvftw3zjbHWSkR2km9Z+4uxbDDK/6Zw9B8w==}
|
||||
|
||||
@@ -3533,6 +3500,9 @@ packages:
|
||||
resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==}
|
||||
engines: {iojs: '>=1.0.0', node: '>=0.10.0'}
|
||||
|
||||
rfc4648@1.5.3:
|
||||
resolution: {integrity: sha512-MjOWxM065+WswwnmNONOT+bD1nXzY9Km6u3kzvnx8F8/HXGZdz3T6e6vZJ8Q/RIMUSp/nxqjH3GwvJDy8ijeQQ==}
|
||||
|
||||
rimraf@3.0.2:
|
||||
resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==}
|
||||
deprecated: Rimraf versions prior to v4 are no longer supported
|
||||
@@ -3584,8 +3554,8 @@ packages:
|
||||
sax@1.3.0:
|
||||
resolution: {integrity: sha512-0s+oAmw9zLl1V1cS9BtZN7JAd0cW5e0QH4W3LWEK6a4LaLEA2OTpGYWDY+6XasBLtz6wkm3u1xRw95mRuJ59WA==}
|
||||
|
||||
scan-chart@3.4.1:
|
||||
resolution: {integrity: sha512-nr93PLeoZSz9ZzC/RrJ5aLrmLdLaw5Ipgsg+QreeFZjppohhivSa3WFLwXcIDu4fd8l5aa1Ch/6hZiQ6eBBnEw==}
|
||||
scan-chart@4.1.3:
|
||||
resolution: {integrity: sha512-fVp0oEwpxMwrQ/7kIlKTsh4k88dqjxMo6tQwrci+G6i/P5IcI+N/tB9HGJwIYdyrqU3RPBkzmDD6hxwVe/R8+w==}
|
||||
|
||||
semver-compare@1.0.0:
|
||||
resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==}
|
||||
@@ -3620,10 +3590,6 @@ packages:
|
||||
resolution: {integrity: sha512-tMNCiqYVkXIZgc2Hnoy2IvC/f8ezc5koaRFkCjrpWzGpCd3qbZXPzVy9MAZzK1ch/X0jvSkojys3oqJN0qCmdA==}
|
||||
engines: {node: '>= 0.4'}
|
||||
|
||||
sharp@0.32.6:
|
||||
resolution: {integrity: sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w==}
|
||||
engines: {node: '>=14.15.0'}
|
||||
|
||||
shebang-command@2.0.0:
|
||||
resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3649,15 +3615,6 @@ packages:
|
||||
resolution: {integrity: sha512-8G+/XDU8wNsJOQS5ysDVO0Etg9/2uA5gR9l4ZwijjlwxBcrU6RPfwi2+jJmbP+Ap1Hlp/nVAaEO4Fj22/SL2gQ==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
||||
simple-concat@1.0.1:
|
||||
resolution: {integrity: sha512-cSFtAPtRhljv69IK0hTVZQ+OfE9nePi/rtJmw5UjHeVyVroEqJXP1sFztKUy1qU+xvz3u/sfYJLa947b7nAN2Q==}
|
||||
|
||||
simple-get@4.0.1:
|
||||
resolution: {integrity: sha512-brv7p5WgH0jmQJr1ZDDfKDOSeWWg+OVypG99A/5vYGPqJ6pxiaHLy8nxtFjBA7oMa01ebA9gfh1uMCFqOuXxvA==}
|
||||
|
||||
simple-swizzle@0.2.2:
|
||||
resolution: {integrity: sha512-JA//kQgZtbuY83m+xT+tXJkmJncGMTFT+C+g2h2R9uxkYIrE2yy9sgmcLhCnw57/WSD+Eh3J97FPEDFnbXnDUg==}
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
resolution: {integrity: sha512-a2B9Y0KlNXl9u/vsW6sTIu9vGEpfKu2wRV6l1H3XEas/0gUIzGzBoP/IouTcUQbm9JWZLH3COxyn03TYlFax6w==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3736,9 +3693,6 @@ packages:
|
||||
resolution: {tarball: https://codeload.github.com/Geomitron/stream-audio-fingerprint/tar.gz/197e8a7ff60165b18bf1debc23dab4814996a20d}
|
||||
version: 1.0.4
|
||||
|
||||
streamx@2.15.5:
|
||||
resolution: {integrity: sha512-9thPGMkKC2GctCzyCUjME3yR03x2xNo0GPKGkRw2UMYN+gqWa9uqpyNWhmsNCutU5zHmkUum0LsCRQTXUgUCAg==}
|
||||
|
||||
string-width@4.2.3:
|
||||
resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3779,10 +3733,6 @@ packages:
|
||||
resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==}
|
||||
engines: {node: '>=4'}
|
||||
|
||||
strip-json-comments@2.0.1:
|
||||
resolution: {integrity: sha512-4gB8na07fecVVkOI6Rs4e7T6NOTki5EmL7TUduTs6bu3EdnSycntVJ4re8kgZA+wx9IueI2Y11bfbgwtzuE0KQ==}
|
||||
engines: {node: '>=0.10.0'}
|
||||
|
||||
strip-json-comments@3.1.1:
|
||||
resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==}
|
||||
engines: {node: '>=8'}
|
||||
@@ -3844,19 +3794,10 @@ packages:
|
||||
engines: {node: '>=14.0.0'}
|
||||
hasBin: true
|
||||
|
||||
tar-fs@2.1.1:
|
||||
resolution: {integrity: sha512-V0r2Y9scmbDRLCNex/+hYzvp/zyYjvFbHPNgVTKfQvVrb6guiE/fxP+XblDNR011utopbkex2nM4dHNV6GDsng==}
|
||||
|
||||
tar-fs@3.0.4:
|
||||
resolution: {integrity: sha512-5AFQU8b9qLfZCX9zp2duONhPmZv0hGYiBPJsyUdqMjzq/mqVpy/rEUSeHk1+YitmxugaptgBh5oDGU3VsAJq4w==}
|
||||
|
||||
tar-stream@2.2.0:
|
||||
resolution: {integrity: sha512-ujeqbceABgwMZxEJnk2HDY2DlnUZ+9oEcb1KzTVfYHio0UE6dG71n60d8D2I4qNvleWrrXpmjpt7vZeF1LnMZQ==}
|
||||
engines: {node: '>=6'}
|
||||
|
||||
tar-stream@3.1.6:
|
||||
resolution: {integrity: sha512-B/UyjYwPpMBv+PaFSWAmtYjwdrlEaZQEhMIBFNC5oEG8lpiW8XjcSdmEaClj28ArfKScKHs2nshz3k2le6crsg==}
|
||||
|
||||
tar@6.2.0:
|
||||
resolution: {integrity: sha512-/Wo7DcT0u5HUV486xg675HtjNd3BXZ6xDbzsCUZPt5iw8bTQ63bP0Raut3mvro9u+CUyq7YQd8Cx55fsZXxqLQ==}
|
||||
engines: {node: '>=10'}
|
||||
@@ -3874,6 +3815,9 @@ packages:
|
||||
thenify@3.3.1:
|
||||
resolution: {integrity: sha512-RVZSIV5IG10Hk3enotrhvz0T9em6cyHBLkH/YAZuKqd8hRkKhSfCGIcP2KUY0EPxndzANBmNllzWPwak+bheSw==}
|
||||
|
||||
three@0.166.1:
|
||||
resolution: {integrity: sha512-LtuafkKHHzm61AQA1be2MAYIw1IjmhOUxhBa0prrLpEMWbV7ijvxCRHjSgHPGp2493wLBzwKV46tA9nivLEgKg==}
|
||||
|
||||
through@2.3.8:
|
||||
resolution: {integrity: sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==}
|
||||
|
||||
@@ -3939,9 +3883,6 @@ packages:
|
||||
resolution: {integrity: sha512-GwIJau9XaA8nLVbUXsN3IlFi7WmQ48gBUrl3FTkkL/XLu/POhBzfmX9hd33FNMX1qAsfl6ozO1iMmW9NC8YniA==}
|
||||
engines: {node: ^16.14.0 || >=18.0.0}
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
resolution: {integrity: sha512-McnNiV1l8RYeY8tBgEpuodCC1mLUdbSN+CYBL7kJsJNInOP8UjDDEwdk6Mw60vdLLrr5NHKZhMAOSrR2NZuQ+w==}
|
||||
|
||||
type-check@0.4.0:
|
||||
resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==}
|
||||
engines: {node: '>= 0.8.0'}
|
||||
@@ -4025,10 +3966,6 @@ packages:
|
||||
uri-js@4.4.1:
|
||||
resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==}
|
||||
|
||||
utf-8@2.0.0:
|
||||
resolution: {integrity: sha512-DItg/Z20ltBzugPrb8Mx1oN0F8CqN5bD38T57YM/pF/GOzUsNVXiellI0PbJPq3e1Z7BEDNoWP1H1+4n7g54Cg==}
|
||||
engines: {node: '>=6.9.5'}
|
||||
|
||||
utf8-byte-length@1.0.4:
|
||||
resolution: {integrity: sha512-4+wkEYLBbWxqTahEsWrhxepcoVOJ+1z5PGIjPZxRkytcdSUaNjIjBM7Xn8E+pdSuV7SzvWovBFA54FO0JSoqhA==}
|
||||
|
||||
@@ -4923,6 +4860,8 @@ snapshots:
|
||||
'@msgpackr-extract/msgpackr-extract-win32-x64@3.0.3':
|
||||
optional: true
|
||||
|
||||
'@noble/hashes@1.4.0': {}
|
||||
|
||||
'@nodelib/fs.scandir@2.1.5':
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -5167,6 +5106,8 @@ snapshots:
|
||||
'@tufjs/canonical-json': 2.0.0
|
||||
minimatch: 9.0.5
|
||||
|
||||
'@tweenjs/tween.js@23.1.2': {}
|
||||
|
||||
'@types/cacheable-request@6.0.3':
|
||||
dependencies:
|
||||
'@types/http-cache-semantics': 4.0.4
|
||||
@@ -5230,9 +5171,21 @@ snapshots:
|
||||
dependencies:
|
||||
'@types/node': 18.16.0
|
||||
|
||||
'@types/stats.js@0.17.3': {}
|
||||
|
||||
'@types/three@0.166.0':
|
||||
dependencies:
|
||||
'@tweenjs/tween.js': 23.1.2
|
||||
'@types/stats.js': 0.17.3
|
||||
'@types/webxr': 0.5.19
|
||||
fflate: 0.8.2
|
||||
meshoptimizer: 0.18.1
|
||||
|
||||
'@types/verror@1.10.9':
|
||||
optional: true
|
||||
|
||||
'@types/webxr@0.5.19': {}
|
||||
|
||||
'@types/yauzl@2.10.3':
|
||||
dependencies:
|
||||
'@types/node': 18.16.0
|
||||
@@ -5620,8 +5573,6 @@ snapshots:
|
||||
dependencies:
|
||||
dequal: 2.0.3
|
||||
|
||||
b4a@1.6.4: {}
|
||||
|
||||
balanced-match@1.0.2: {}
|
||||
|
||||
base64-js@1.5.1: {}
|
||||
@@ -5778,8 +5729,6 @@ snapshots:
|
||||
|
||||
chardet@0.7.0: {}
|
||||
|
||||
charset-detector@0.0.2: {}
|
||||
|
||||
chokidar@3.5.3:
|
||||
dependencies:
|
||||
anymatch: 3.1.3
|
||||
@@ -5804,8 +5753,6 @@ snapshots:
|
||||
optionalDependencies:
|
||||
fsevents: 2.3.3
|
||||
|
||||
chownr@1.1.4: {}
|
||||
|
||||
chownr@2.0.0: {}
|
||||
|
||||
chromium-pickle-js@0.2.0: {}
|
||||
@@ -5858,16 +5805,6 @@ snapshots:
|
||||
|
||||
color-name@1.1.4: {}
|
||||
|
||||
color-string@1.9.1:
|
||||
dependencies:
|
||||
color-name: 1.1.4
|
||||
simple-swizzle: 0.2.2
|
||||
|
||||
color@4.2.3:
|
||||
dependencies:
|
||||
color-convert: 2.0.1
|
||||
color-string: 1.9.1
|
||||
|
||||
combined-stream@1.0.8:
|
||||
dependencies:
|
||||
delayed-stream: 1.0.0
|
||||
@@ -5996,8 +5933,6 @@ snapshots:
|
||||
dependencies:
|
||||
mimic-response: 3.1.0
|
||||
|
||||
deep-extend@0.6.0: {}
|
||||
|
||||
deep-is@0.1.4: {}
|
||||
|
||||
defaults@1.0.4:
|
||||
@@ -6503,9 +6438,9 @@ snapshots:
|
||||
|
||||
eventemitter3@5.0.1: {}
|
||||
|
||||
events@3.3.0: {}
|
||||
|
||||
expand-template@2.0.3: {}
|
||||
exifreader@4.23.3:
|
||||
optionalDependencies:
|
||||
'@xmldom/xmldom': 0.8.10
|
||||
|
||||
exponential-backoff@3.1.1: {}
|
||||
|
||||
@@ -6532,8 +6467,6 @@ snapshots:
|
||||
|
||||
fast-diff@1.3.0: {}
|
||||
|
||||
fast-fifo@1.3.2: {}
|
||||
|
||||
fast-glob@3.3.2:
|
||||
dependencies:
|
||||
'@nodelib/fs.stat': 2.0.5
|
||||
@@ -6556,6 +6489,8 @@ snapshots:
|
||||
dependencies:
|
||||
pend: 1.2.0
|
||||
|
||||
fflate@0.8.2: {}
|
||||
|
||||
figures@3.2.0:
|
||||
dependencies:
|
||||
escape-string-regexp: 1.0.5
|
||||
@@ -6685,8 +6620,6 @@ snapshots:
|
||||
call-bind: 1.0.7
|
||||
get-intrinsic: 1.2.4
|
||||
|
||||
github-from-package@0.0.0: {}
|
||||
|
||||
glob-parent@5.1.2:
|
||||
dependencies:
|
||||
is-glob: 4.0.3
|
||||
@@ -6895,8 +6828,6 @@ snapshots:
|
||||
|
||||
inherits@2.0.4: {}
|
||||
|
||||
ini@1.3.8: {}
|
||||
|
||||
ini@4.1.2: {}
|
||||
|
||||
inquirer@9.2.22:
|
||||
@@ -6931,8 +6862,6 @@ snapshots:
|
||||
get-intrinsic: 1.2.4
|
||||
is-typed-array: 1.1.12
|
||||
|
||||
is-arrayish@0.3.2: {}
|
||||
|
||||
is-bigint@1.0.4:
|
||||
dependencies:
|
||||
has-bigints: 1.0.2
|
||||
@@ -7051,6 +6980,8 @@ snapshots:
|
||||
|
||||
jiti@1.21.0: {}
|
||||
|
||||
js-md5@0.8.3: {}
|
||||
|
||||
js-tokens@4.0.0: {}
|
||||
|
||||
js-yaml@3.14.1:
|
||||
@@ -7231,17 +7162,14 @@ snapshots:
|
||||
|
||||
merge2@1.4.1: {}
|
||||
|
||||
meshoptimizer@0.18.1: {}
|
||||
|
||||
micromatch@4.0.5:
|
||||
dependencies:
|
||||
braces: 3.0.2
|
||||
picomatch: 2.3.1
|
||||
|
||||
midievents@2.0.0: {}
|
||||
|
||||
midifile@2.0.0:
|
||||
dependencies:
|
||||
midievents: 2.0.0
|
||||
utf-8: 2.0.0
|
||||
midi-file@1.2.4: {}
|
||||
|
||||
mime-db@1.52.0: {}
|
||||
|
||||
@@ -7312,8 +7240,6 @@ snapshots:
|
||||
minipass: 3.3.6
|
||||
yallist: 4.0.0
|
||||
|
||||
mkdirp-classic@0.5.3: {}
|
||||
|
||||
mkdirp@0.5.6:
|
||||
dependencies:
|
||||
minimist: 1.2.8
|
||||
@@ -7352,8 +7278,6 @@ snapshots:
|
||||
|
||||
nanoid@3.3.7: {}
|
||||
|
||||
napi-build-utils@1.0.2: {}
|
||||
|
||||
natural-compare@1.4.0: {}
|
||||
|
||||
negotiator@0.6.3: {}
|
||||
@@ -7364,10 +7288,6 @@ snapshots:
|
||||
node-gyp-build: 4.7.0
|
||||
optional: true
|
||||
|
||||
node-abi@3.52.0:
|
||||
dependencies:
|
||||
semver: 7.6.2
|
||||
|
||||
node-addon-api@1.7.2:
|
||||
optional: true
|
||||
|
||||
@@ -7668,11 +7588,6 @@ snapshots:
|
||||
es-module-lexer: 1.5.4
|
||||
slashes: 3.0.12
|
||||
|
||||
parse-sng@3.1.2:
|
||||
dependencies:
|
||||
binary-parser: 2.2.1
|
||||
events: 3.3.0
|
||||
|
||||
parse-sng@4.0.1:
|
||||
dependencies:
|
||||
binary-parser: 2.2.1
|
||||
@@ -7768,21 +7683,6 @@ snapshots:
|
||||
picocolors: 1.0.1
|
||||
source-map-js: 1.2.0
|
||||
|
||||
prebuild-install@7.1.1:
|
||||
dependencies:
|
||||
detect-libc: 2.0.2
|
||||
expand-template: 2.0.3
|
||||
github-from-package: 0.0.0
|
||||
minimist: 1.2.8
|
||||
mkdirp-classic: 0.5.3
|
||||
napi-build-utils: 1.0.2
|
||||
node-abi: 3.52.0
|
||||
pump: 3.0.0
|
||||
rc: 1.2.8
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 2.1.1
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
prelude-ls@1.2.1: {}
|
||||
|
||||
prettier-eslint@16.3.0:
|
||||
@@ -7842,17 +7742,8 @@ snapshots:
|
||||
|
||||
queue-microtask@1.2.3: {}
|
||||
|
||||
queue-tick@1.0.1: {}
|
||||
|
||||
quick-lru@5.1.1: {}
|
||||
|
||||
rc@1.2.8:
|
||||
dependencies:
|
||||
deep-extend: 0.6.0
|
||||
ini: 1.3.8
|
||||
minimist: 1.2.8
|
||||
strip-json-comments: 2.0.1
|
||||
|
||||
react-is@18.2.0: {}
|
||||
|
||||
read-cache@1.0.0:
|
||||
@@ -7931,6 +7822,8 @@ snapshots:
|
||||
|
||||
reusify@1.0.4: {}
|
||||
|
||||
rfc4648@1.5.3: {}
|
||||
|
||||
rimraf@3.0.2:
|
||||
dependencies:
|
||||
glob: 7.2.3
|
||||
@@ -8008,17 +7901,16 @@ snapshots:
|
||||
|
||||
sax@1.3.0: {}
|
||||
|
||||
scan-chart@3.4.1:
|
||||
scan-chart@4.1.3:
|
||||
dependencies:
|
||||
'@noble/hashes': 1.4.0
|
||||
bottleneck: 2.19.5
|
||||
charset-detector: 0.0.2
|
||||
exifreader: 4.23.3
|
||||
fluent-ffmpeg: 2.1.2
|
||||
js-md5: 0.8.3
|
||||
lodash: 4.17.21
|
||||
midievents: 2.0.0
|
||||
midifile: 2.0.0
|
||||
parse-sng: 3.1.2
|
||||
sanitize-filename: 1.6.3
|
||||
sharp: 0.32.6
|
||||
midi-file: 1.2.4
|
||||
rfc4648: 1.5.3
|
||||
stream-audio-fingerprint: https://codeload.github.com/Geomitron/stream-audio-fingerprint/tar.gz/197e8a7ff60165b18bf1debc23dab4814996a20d
|
||||
workerpool: 6.5.1
|
||||
|
||||
@@ -8057,17 +7949,6 @@ snapshots:
|
||||
functions-have-names: 1.2.3
|
||||
has-property-descriptors: 1.0.2
|
||||
|
||||
sharp@0.32.6:
|
||||
dependencies:
|
||||
color: 4.2.3
|
||||
detect-libc: 2.0.2
|
||||
node-addon-api: 6.1.0
|
||||
prebuild-install: 7.1.1
|
||||
semver: 7.6.2
|
||||
simple-get: 4.0.1
|
||||
tar-fs: 3.0.4
|
||||
tunnel-agent: 0.6.0
|
||||
|
||||
shebang-command@2.0.0:
|
||||
dependencies:
|
||||
shebang-regex: 3.0.0
|
||||
@@ -8097,18 +7978,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
simple-concat@1.0.1: {}
|
||||
|
||||
simple-get@4.0.1:
|
||||
dependencies:
|
||||
decompress-response: 6.0.0
|
||||
once: 1.4.0
|
||||
simple-concat: 1.0.1
|
||||
|
||||
simple-swizzle@0.2.2:
|
||||
dependencies:
|
||||
is-arrayish: 0.3.2
|
||||
|
||||
simple-update-notifier@2.0.0:
|
||||
dependencies:
|
||||
semver: 7.6.2
|
||||
@@ -8186,11 +8055,6 @@ snapshots:
|
||||
dependencies:
|
||||
dsp.js: https://codeload.github.com/corbanbrook/dsp.js/tar.gz/219600bb0346ee9a00686c9875c81123e2d8780e
|
||||
|
||||
streamx@2.15.5:
|
||||
dependencies:
|
||||
fast-fifo: 1.3.2
|
||||
queue-tick: 1.0.1
|
||||
|
||||
string-width@4.2.3:
|
||||
dependencies:
|
||||
emoji-regex: 8.0.0
|
||||
@@ -8243,8 +8107,6 @@ snapshots:
|
||||
|
||||
strip-bom@3.0.0: {}
|
||||
|
||||
strip-json-comments@2.0.1: {}
|
||||
|
||||
strip-json-comments@3.1.1: {}
|
||||
|
||||
strong-log-transformer@2.1.0:
|
||||
@@ -8328,19 +8190,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- ts-node
|
||||
|
||||
tar-fs@2.1.1:
|
||||
dependencies:
|
||||
chownr: 1.1.4
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.0
|
||||
tar-stream: 2.2.0
|
||||
|
||||
tar-fs@3.0.4:
|
||||
dependencies:
|
||||
mkdirp-classic: 0.5.3
|
||||
pump: 3.0.0
|
||||
tar-stream: 3.1.6
|
||||
|
||||
tar-stream@2.2.0:
|
||||
dependencies:
|
||||
bl: 4.1.0
|
||||
@@ -8349,12 +8198,6 @@ snapshots:
|
||||
inherits: 2.0.4
|
||||
readable-stream: 3.6.2
|
||||
|
||||
tar-stream@3.1.6:
|
||||
dependencies:
|
||||
b4a: 1.6.4
|
||||
fast-fifo: 1.3.2
|
||||
streamx: 2.15.5
|
||||
|
||||
tar@6.2.0:
|
||||
dependencies:
|
||||
chownr: 2.0.0
|
||||
@@ -8379,6 +8222,8 @@ snapshots:
|
||||
dependencies:
|
||||
any-promise: 1.3.0
|
||||
|
||||
three@0.166.1: {}
|
||||
|
||||
through@2.3.8: {}
|
||||
|
||||
tiny-typed-emitter@2.1.0: {}
|
||||
@@ -8444,10 +8289,6 @@ snapshots:
|
||||
transitivePeerDependencies:
|
||||
- supports-color
|
||||
|
||||
tunnel-agent@0.6.0:
|
||||
dependencies:
|
||||
safe-buffer: 5.2.1
|
||||
|
||||
type-check@0.4.0:
|
||||
dependencies:
|
||||
prelude-ls: 1.2.1
|
||||
@@ -8527,8 +8368,6 @@ snapshots:
|
||||
dependencies:
|
||||
punycode: 2.3.1
|
||||
|
||||
utf-8@2.0.0: {}
|
||||
|
||||
utf8-byte-length@1.0.4: {}
|
||||
|
||||
util-deprecate@1.0.2: {}
|
||||
|
||||
@@ -5,7 +5,6 @@ import { BrowseComponent } from './components/browse/browse.component'
|
||||
import { SettingsComponent } from './components/settings/settings.component'
|
||||
import { TabPersistStrategy } from './core/tab-persist.strategy'
|
||||
|
||||
// TODO: replace these with the correct components
|
||||
const routes: Routes = [
|
||||
{ path: 'browse', component: BrowseComponent, data: { shouldReuse: true } },
|
||||
{ path: 'library', redirectTo: '/browse' },
|
||||
|
||||
@@ -8,6 +8,7 @@ import { AppComponent } from './app.component'
|
||||
import { BrowseComponent } from './components/browse/browse.component'
|
||||
import { ChartSidebarInstrumentComponent } from './components/browse/chart-sidebar/chart-sidebar-instrument/chart-sidebar-instrument.component'
|
||||
import { ChartSidebarMenutComponent } from './components/browse/chart-sidebar/chart-sidebar-menu/chart-sidebar-menu.component'
|
||||
import { ChartSidebarPreviewComponent } from './components/browse/chart-sidebar/chart-sidebar-preview/chart-sidebar-preview.component'
|
||||
import { ChartSidebarComponent } from './components/browse/chart-sidebar/chart-sidebar.component'
|
||||
import { ResultTableRowComponent } from './components/browse/result-table/result-table-row/result-table-row.component'
|
||||
import { ResultTableComponent } from './components/browse/result-table/result-table.component'
|
||||
@@ -29,6 +30,7 @@ import { RemoveStyleTagsPipe } from './core/pipes/remove-style-tags.pipe'
|
||||
ChartSidebarComponent,
|
||||
ChartSidebarInstrumentComponent,
|
||||
ChartSidebarMenutComponent,
|
||||
ChartSidebarPreviewComponent,
|
||||
ResultTableRowComponent,
|
||||
DownloadsModalComponent,
|
||||
RemoveStyleTagsPipe,
|
||||
|
||||
@@ -1,5 +1,5 @@
|
||||
<div class="flex gap-2 items-center">
|
||||
<img class="w-11 h-11" src="assets/images/instruments/{{ instrument }}.png" />
|
||||
<img class="w-11 h-11" src="https://static.enchor.us/instrument-{{ instrument }}.png" />
|
||||
<div class="leading-4">
|
||||
<span>Diff: {{ getDiff() }}</span>
|
||||
<div>
|
||||
|
||||
@@ -162,9 +162,9 @@
|
||||
</td>
|
||||
<td>
|
||||
<div class="flex flex-nowrap items-center">
|
||||
{{ version.chartMd5.substring(0, 7) }}
|
||||
{{ version.chartHash.substring(0, 7) }}
|
||||
<div class="tooltip tooltip-accent" data-tip="Copy full hash">
|
||||
<button class="btn btn-circle btn-ghost btn-xs" (click)="copyHash(version.chartMd5)">
|
||||
<button class="btn btn-circle btn-ghost btn-xs" (click)="copyHash(version.chartHash)">
|
||||
<i class="bi bi-copy text-xs"></i>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
@@ -71,13 +71,21 @@ export class ChartSidebarMenutComponent implements OnInit {
|
||||
breadcrumbs.push({ name: version.driveFileName!, link: driveLink(version.driveFileId) })
|
||||
|
||||
if (version.driveChartIsPack) {
|
||||
breadcrumbs.push({ name: this.joinPaths(version.archivePath!, version.chartFileName ?? ''), link: null })
|
||||
breadcrumbs.push({ name: this.removeFirstPathSegment(version.internalPath), link: null })
|
||||
}
|
||||
}
|
||||
|
||||
return breadcrumbs
|
||||
}
|
||||
|
||||
private removeFirstPathSegment(path: string) {
|
||||
const segments = path.split('/').filter(p => p.length > 0)
|
||||
if (segments.length > 1) {
|
||||
return segments.slice(1).join('/')
|
||||
}
|
||||
return path
|
||||
}
|
||||
|
||||
isFalseReportOption() {
|
||||
switch (this.reportOption.value) {
|
||||
case 'No notes / chart ends immediately': return true
|
||||
@@ -91,12 +99,6 @@ export class ChartSidebarMenutComponent implements OnInit {
|
||||
window.electron.emit.openUrl(url)
|
||||
}
|
||||
|
||||
joinPaths(...args: string[]) {
|
||||
return args.join('/')
|
||||
.replace(/\/+/g, '/')
|
||||
.replace(/^\/|\/$/g, '')
|
||||
}
|
||||
|
||||
copyLink(hash: string) {
|
||||
navigator.clipboard.writeText(`https://enchor.us/?hash=${hash}`)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,41 @@
|
||||
<div class="h-full w-full flex flex-col pt-3">
|
||||
<div #previewDiv class="flex-1 w-full bg-black" (click)="togglePlaying()"></div>
|
||||
<div class="join flex">
|
||||
<button class="btn join-item btn-neutral btn-sm rounded-none px-2" (click)="togglePlaying()">
|
||||
@switch (playState) {
|
||||
@case ('end') {
|
||||
<i class="bi bi-arrow-counterclockwise text-xl text-neutral-content"></i>
|
||||
}
|
||||
@case ('paused') {
|
||||
<i class="bi bi-play text-xl text-neutral-content"></i>
|
||||
}
|
||||
@case ('loading') {
|
||||
<span class="loading loading-spinner loading-sm self-center"></span>
|
||||
}
|
||||
@case ('play') {
|
||||
<i class="bi bi-pause text-xl text-neutral-content"></i>
|
||||
}
|
||||
}
|
||||
</button>
|
||||
<div class="dropdown dropdown-top dropdown-hover join-item">
|
||||
<button tabindex="0" class="btn btn-neutral btn-sm rounded-none px-2" (click)="toggleMuted()">
|
||||
@if (volumeBar.value === 0) {
|
||||
<i class="bi bi-volume-mute text-xl text-neutral-content"></i>
|
||||
} @else if (volumeBar.value <= 50) {
|
||||
<i class="bi bi-volume-down text-xl text-neutral-content"></i>
|
||||
} @else {
|
||||
<i class="bi bi-volume-up text-xl text-neutral-content"></i>
|
||||
}
|
||||
</button>
|
||||
<ul tabindex="0" class="menu dropdown-content z-[1] ml-9 w-28 !origin-bottom-left -rotate-90 bg-base-100 p-2 shadow">
|
||||
<input type="range" min="0" max="100" class="range range-xs" [formControl]="volumeBar" />
|
||||
</ul>
|
||||
</div>
|
||||
@if (chartPreview) {
|
||||
<button class="btn join-item btn-neutral no-animation btn-sm rounded-none px-1">{{ timestampText }}</button>
|
||||
}
|
||||
<button class="btn join-item btn-neutral no-animation btn-sm flex-grow rounded-none px-2">
|
||||
<input type="range" min="0" max="100" class="range range-primary range-xs" [formControl]="seekBar" />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,280 @@
|
||||
import { ChangeDetectorRef, Component, ElementRef, HostBinding, Input, OnDestroy, OnInit, ViewChild } from '@angular/core'
|
||||
import { FormControl } from '@angular/forms'
|
||||
|
||||
import { chain, sortBy } from 'lodash'
|
||||
import { SngHeader, SngStream } from 'parse-sng'
|
||||
import { from, switchMap, throttleTime } from 'rxjs'
|
||||
import { Difficulty, getInstrumentType, Instrument, parseChartFile } from 'scan-chart'
|
||||
import { SettingsService } from 'src-angular/app/core/services/settings.service.js'
|
||||
import { ChartData } from 'src-shared/interfaces/search.interface.js'
|
||||
import { getBasename, getExtension, hasAudioExtension, hasAudioName, hasChartExtension, hasChartName, hasVideoName, msToRoughTime } from 'src-shared/UtilFunctions.js'
|
||||
|
||||
import { ChartPreview } from './render.js'
|
||||
|
||||
@Component({
|
||||
selector: 'app-chart-sidebar-preview',
|
||||
templateUrl: './chart-sidebar-preview.component.html',
|
||||
})
|
||||
export class ChartSidebarPreviewComponent implements OnInit, OnDestroy {
|
||||
@HostBinding('class.h-full') height = true
|
||||
@ViewChild('previewDiv') previewDiv: ElementRef<HTMLDivElement>
|
||||
|
||||
@Input() selectedChart: ChartData
|
||||
@Input() instrument: Instrument
|
||||
@Input() difficulty: Difficulty
|
||||
|
||||
private lastVolume: number | null = null
|
||||
public isMuted = true
|
||||
public playState: 'paused' | 'loading' | 'play' | 'end' = 'paused'
|
||||
public chartPreview: ChartPreview | null = null
|
||||
|
||||
private parsedChart: ReturnType<typeof parseChartFile> | null = null
|
||||
private textures: Awaited<ReturnType<typeof ChartPreview.loadTextures>> | null = null
|
||||
private audioFiles: Uint8Array[] | null = null
|
||||
|
||||
public seekBar: FormControl<number>
|
||||
public volumeBar: FormControl<number>
|
||||
public timestampUpdateInterval: NodeJS.Timer
|
||||
public timestampText: string = ''
|
||||
|
||||
constructor(
|
||||
private cdr: ChangeDetectorRef,
|
||||
private settingsService: SettingsService,
|
||||
) { }
|
||||
ngOnInit() {
|
||||
this.seekBar = new FormControl<number>(0, { nonNullable: true })
|
||||
this.seekBar.valueChanges
|
||||
.pipe(
|
||||
throttleTime(30, undefined, { leading: true, trailing: true }),
|
||||
switchMap(progress =>
|
||||
from(
|
||||
(async () => {
|
||||
this.playState = 'loading'
|
||||
await this.chartPreview?.seek(progress / 100)
|
||||
this.playState = 'paused'
|
||||
})(),
|
||||
),
|
||||
),
|
||||
)
|
||||
.subscribe()
|
||||
this.volumeBar = new FormControl<number>(this.settingsService.volume, { nonNullable: true })
|
||||
this.isMuted = this.settingsService.volume === 0
|
||||
this.volumeBar.valueChanges.subscribe(volume => {
|
||||
this.settingsService.volume = volume
|
||||
if (this.chartPreview) {
|
||||
this.chartPreview.volume = volume / 100
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
ngOnDestroy() {
|
||||
this.endChartPreview()
|
||||
}
|
||||
|
||||
private spaceListener = (event: KeyboardEvent) => {
|
||||
if (event.code === 'Space') {
|
||||
this.togglePlaying()
|
||||
event.preventDefault()
|
||||
}
|
||||
}
|
||||
async resetChartPreview(checkInstrumentType = true) {
|
||||
if (this.parsedChart && this.textures && this.audioFiles) {
|
||||
this.playState = 'loading'
|
||||
if (checkInstrumentType && this.chartPreview?.instrumentType !== getInstrumentType(this.instrument)) {
|
||||
this.textures = await ChartPreview.loadTextures(getInstrumentType(this.instrument))
|
||||
}
|
||||
this.chartPreview?.dispose()
|
||||
this.chartPreview = await ChartPreview.create(
|
||||
this.parsedChart,
|
||||
this.textures,
|
||||
this.audioFiles,
|
||||
this.instrument,
|
||||
this.difficulty,
|
||||
(this.selectedChart.delay ?? 0) + (this.selectedChart.chart_offset ?? 0) * 1000,
|
||||
this.selectedChart.song_length ?? 5 * 60 * 1000, // TODO: have a better way to detect the audio length?
|
||||
this.previewDiv.nativeElement,
|
||||
)
|
||||
this.chartPreview.on('progress', percentComplete => {
|
||||
this.seekBar.setValue(percentComplete * 100, { emitEvent: false })
|
||||
})
|
||||
this.chartPreview.on('end', async () => {
|
||||
await this.chartPreview!.togglePaused()
|
||||
this.playState = 'end'
|
||||
this.cdr.detectChanges()
|
||||
})
|
||||
this.chartPreview.volume = this.volumeBar.value / 100
|
||||
await this.chartPreview.seek(this.seekBar.value / 100)
|
||||
document.addEventListener('keydown', this.spaceListener)
|
||||
this.timestampUpdateInterval = setInterval(
|
||||
() => (this.timestampText = msToRoughTime(this.chartPreview!.chartCurrentTimeMs) + ' / ' + msToRoughTime(this.chartPreview!.chartEndTimeMs)),
|
||||
100,
|
||||
)
|
||||
this.playState = 'paused'
|
||||
}
|
||||
}
|
||||
endChartPreview() {
|
||||
this.previewDiv.nativeElement.firstChild?.remove()
|
||||
this.chartPreview?.dispose()
|
||||
this.chartPreview = null
|
||||
this.parsedChart = null
|
||||
this.textures = null
|
||||
this.audioFiles = null
|
||||
this.playState = 'paused'
|
||||
this.seekBar.setValue(0, { emitEvent: false })
|
||||
document.removeEventListener('keydown', this.spaceListener)
|
||||
clearInterval(this.timestampUpdateInterval)
|
||||
}
|
||||
|
||||
async togglePlaying() {
|
||||
if (this.playState === 'end') {
|
||||
await this.chartPreview!.seek(0)
|
||||
this.playState = 'paused'
|
||||
}
|
||||
if (this.playState === 'paused') {
|
||||
this.playState = 'loading'
|
||||
if (this.chartPreview === null) {
|
||||
const filesPromise = getChartFiles(this.selectedChart)
|
||||
const [parsedChart, textures, audioFiles] = await Promise.all([
|
||||
(async () => {
|
||||
const { chartData, format } = findChartData(await filesPromise)
|
||||
const iniChartModifiers = Object.assign(
|
||||
{
|
||||
song_length: 0,
|
||||
hopo_frequency: 0,
|
||||
eighthnote_hopo: false,
|
||||
multiplier_note: 0,
|
||||
sustain_cutoff_threshold: -1,
|
||||
chord_snap_threshold: 0,
|
||||
five_lane_drums: false,
|
||||
pro_drums: false,
|
||||
},
|
||||
this.selectedChart,
|
||||
)
|
||||
return parseChartFile(chartData, format, iniChartModifiers)
|
||||
})(),
|
||||
ChartPreview.loadTextures(getInstrumentType(this.instrument)),
|
||||
(async () => findAudioData(await filesPromise))(),
|
||||
])
|
||||
this.parsedChart = parsedChart
|
||||
this.textures = textures
|
||||
this.audioFiles = audioFiles
|
||||
await this.resetChartPreview(false)
|
||||
}
|
||||
await this.chartPreview!.togglePaused()
|
||||
this.playState = 'play'
|
||||
} else if (this.playState === 'play') {
|
||||
this.playState = 'loading'
|
||||
await this.chartPreview!.togglePaused()
|
||||
this.playState = 'paused'
|
||||
}
|
||||
}
|
||||
|
||||
toggleMuted() {
|
||||
this.isMuted = !this.isMuted
|
||||
if (this.isMuted) {
|
||||
this.lastVolume = this.volumeBar.value
|
||||
this.volumeBar.setValue(0)
|
||||
} else {
|
||||
this.volumeBar.setValue(this.lastVolume ?? 50)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
async function getChartFiles(chartData: ChartData) {
|
||||
const chartUrl = `https://files.enchor.us/${chartData.md5 + (chartData.hasVideoBackground ? '_novideo' : '')}.sng`
|
||||
const sngResponse = await fetch(chartUrl, { mode: 'cors', referrerPolicy: 'no-referrer' })
|
||||
if (!sngResponse.ok) {
|
||||
throw new Error('Failed to fetch the .sng file')
|
||||
}
|
||||
|
||||
const sngStream = new SngStream(sngResponse.body!, { generateSongIni: true })
|
||||
|
||||
let header: SngHeader
|
||||
sngStream.on('header', h => (header = h))
|
||||
const isFileTruncated = (fileName: string) => {
|
||||
const MAX_FILE_MIB = 2048
|
||||
const MAX_FILES_MIB = 5000
|
||||
const sortedFiles = sortBy(header.fileMeta, f => f.contentsLen)
|
||||
let usedSizeMib = 0
|
||||
for (const sortedFile of sortedFiles) {
|
||||
usedSizeMib += Number(sortedFile.contentsLen / BigInt(1024) / BigInt(1024))
|
||||
if (sortedFile.filename === fileName) {
|
||||
return usedSizeMib > MAX_FILES_MIB || sortedFile.contentsLen / BigInt(1024) / BigInt(1024) >= MAX_FILE_MIB
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const files: { fileName: string; data: Uint8Array }[] = []
|
||||
|
||||
return await new Promise<{ fileName: string; data: Uint8Array }[]>((resolve, reject) => {
|
||||
sngStream.on('file', async (fileName: string, fileStream: ReadableStream<Uint8Array>, nextFile) => {
|
||||
const matchingFileMeta = header.fileMeta.find(f => f.filename === fileName)
|
||||
if (hasVideoName(fileName) || isFileTruncated(fileName) || !matchingFileMeta) {
|
||||
const reader = fileStream.getReader()
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const result = await reader.read()
|
||||
if (result.done) {
|
||||
break
|
||||
}
|
||||
}
|
||||
} else {
|
||||
const data = new Uint8Array(Number(matchingFileMeta.contentsLen))
|
||||
let offset = 0
|
||||
let readCount = 0
|
||||
const reader = fileStream.getReader()
|
||||
// eslint-disable-next-line no-constant-condition
|
||||
while (true) {
|
||||
const result = await reader.read()
|
||||
if (result.done) {
|
||||
break
|
||||
}
|
||||
readCount++
|
||||
if (readCount % 5 === 0) {
|
||||
await new Promise<void>(resolve => setTimeout(resolve, 2))
|
||||
} // Allow other processing to happen
|
||||
data.set(result.value, offset)
|
||||
offset += result.value.length
|
||||
}
|
||||
|
||||
files.push({ fileName, data })
|
||||
}
|
||||
|
||||
if (nextFile) {
|
||||
nextFile()
|
||||
} else {
|
||||
resolve(files)
|
||||
}
|
||||
})
|
||||
|
||||
sngStream.on('error', err => reject(err))
|
||||
sngStream.start()
|
||||
})
|
||||
}
|
||||
|
||||
function findChartData(files: { fileName: string; data: Uint8Array }[]) {
|
||||
const chartFiles = chain(files)
|
||||
.filter(f => hasChartExtension(f.fileName))
|
||||
.orderBy([f => hasChartName(f.fileName), f => getExtension(f.fileName).toLowerCase() === 'mid'], ['desc', 'desc'])
|
||||
.value()
|
||||
|
||||
return {
|
||||
chartData: chartFiles[0].data,
|
||||
format: (getExtension(chartFiles[0].fileName).toLowerCase() === 'mid' ? 'mid' : 'chart') as 'mid' | 'chart',
|
||||
}
|
||||
}
|
||||
function findAudioData(files: { fileName: string; data: Uint8Array }[]) {
|
||||
const audioData: Uint8Array[] = []
|
||||
|
||||
for (const file of files) {
|
||||
if (hasAudioExtension(file.fileName)) {
|
||||
if (hasAudioName(file.fileName)) {
|
||||
if (!['preview', 'crowd'].includes(getBasename(file.fileName).toLowerCase())) {
|
||||
audioData.push(file.data)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return audioData
|
||||
}
|
||||
@@ -0,0 +1,942 @@
|
||||
import { EventEmitter } from 'eventemitter3'
|
||||
import _ from 'lodash'
|
||||
import { Difficulty, getInstrumentType, Instrument, InstrumentType, instrumentTypes, NoteEvent, noteFlags, NoteType, noteTypes, parseChartFile } from 'scan-chart'
|
||||
import { interpolate } from 'src-shared/UtilFunctions'
|
||||
import * as THREE from 'three'
|
||||
|
||||
type ParsedChart = ReturnType<typeof parseChartFile>
|
||||
|
||||
const HIGHWAY_DURATION_MS = 1500
|
||||
const SCALE = 0.105
|
||||
const NOTE_SPAN_WIDTH = 0.95
|
||||
// Sprite for 6-fret barre notes are the only sprites without a dedicated NoteType
|
||||
type BARRE_TYPES = typeof BARRE1_TYPE | typeof BARRE2_TYPE | typeof BARRE3_TYPE
|
||||
const BARRE1_TYPE = 99991
|
||||
const BARRE2_TYPE = 99992
|
||||
const BARRE3_TYPE = 99993
|
||||
const barreTypes = [BARRE1_TYPE, BARRE2_TYPE, BARRE3_TYPE] as const
|
||||
// Sprites for star power versions are the only sprites without a dedicated NoteFlag
|
||||
const SP_FLAG = 2147483648
|
||||
|
||||
interface ChartPreviewEvents {
|
||||
progress: (percentComplete: number) => void
|
||||
end: () => void
|
||||
}
|
||||
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
const AudioContext = window.AudioContext || (window as any).webkitAudioContext
|
||||
|
||||
/**
|
||||
* Renders a chart preview of `parsedChart` inside `divContainer`, and plays `audioFiles` in sync with the render.
|
||||
*/
|
||||
export class ChartPreview {
|
||||
private eventEmitter = new EventEmitter<ChartPreviewEvents>()
|
||||
|
||||
public instrumentType: InstrumentType
|
||||
private paused = true
|
||||
private scene = new THREE.Scene()
|
||||
private highwayTexture: THREE.Texture
|
||||
private camera: ChartCamera
|
||||
private renderer: ChartRenderer
|
||||
private audioManager: AudioManager | SilentAudioManager
|
||||
private notesManager: NotesManager
|
||||
static loadTextures = loadTextures
|
||||
|
||||
private constructor() { }
|
||||
|
||||
/**
|
||||
* Available events:
|
||||
* - `progress`: called every frame during playback.
|
||||
* - `end`: called when the chart preview ends.
|
||||
*/
|
||||
on<T extends keyof ChartPreviewEvents>(event: T, listener: ChartPreviewEvents[T]) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
this.eventEmitter.on(event, listener as any)
|
||||
}
|
||||
|
||||
/**
|
||||
* @param chartDataPromise The `Uint8Array[]` of the audio files to be played, and the `ParsedChart` containing the notes to preview.
|
||||
* @param instrument The instrument to play.
|
||||
* @param difficulty The difficulty to play.
|
||||
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
||||
* @param audioLengthMs The length of the longest audio file stem.
|
||||
* @param divContainer The <div> element where the preview should be rendered.
|
||||
*
|
||||
* Will throw an exception if textures fail to load or if `audioFilesPromise` rejects.
|
||||
*/
|
||||
static async create(
|
||||
parsedChart: ParsedChart,
|
||||
textures: Awaited<ReturnType<typeof loadTextures>>,
|
||||
audioFiles: Uint8Array[],
|
||||
instrument: Instrument,
|
||||
difficulty: Difficulty,
|
||||
startDelayMs: number,
|
||||
audioLengthMs: number,
|
||||
divContainer: HTMLDivElement,
|
||||
) {
|
||||
const chartPreview = new ChartPreview()
|
||||
chartPreview.instrumentType = getInstrumentType(instrument)
|
||||
chartPreview.highwayTexture = textures.highwayTexture
|
||||
chartPreview.camera = new ChartCamera(divContainer)
|
||||
chartPreview.renderer = new ChartRenderer(divContainer)
|
||||
chartPreview.audioManager = await (AudioContext ?
|
||||
AudioManager.create(audioFiles, startDelayMs, audioLengthMs)
|
||||
: SilentAudioManager.create(startDelayMs, audioLengthMs))
|
||||
chartPreview.audioManager.on('end', () => chartPreview.eventEmitter.emit('end'))
|
||||
chartPreview.notesManager = new NotesManager(parsedChart, instrument, difficulty, chartPreview.scene, textures.noteTextures)
|
||||
|
||||
chartPreview.addHighwayToScene(textures.highwayTexture)
|
||||
chartPreview.addStrikelineToScene(textures.strikelineTexture)
|
||||
divContainer.firstChild?.remove()
|
||||
divContainer.appendChild(chartPreview.renderer.domElement)
|
||||
|
||||
return chartPreview
|
||||
}
|
||||
|
||||
async togglePaused() {
|
||||
if (this.paused) {
|
||||
await this.audioManager.play()
|
||||
this.renderer.setAnimationLoop(() => this.animateFrame())
|
||||
} else {
|
||||
await this.audioManager.pause()
|
||||
this.renderer.setAnimationLoop(null)
|
||||
}
|
||||
this.paused = !this.paused
|
||||
}
|
||||
|
||||
/**
|
||||
* Moves the playback time to `percentComplete` of the way through the preview.
|
||||
* @param percentComplete A number between 0 and 1 (inclusive)
|
||||
*/
|
||||
async seek(percentComplete: number) {
|
||||
await this.audioManager.seek(percentComplete)
|
||||
this.animateFrame(false)
|
||||
this.renderer.setAnimationLoop(null)
|
||||
this.paused = true
|
||||
}
|
||||
|
||||
/** `volume` is a number between 0 and 1 (inclusive). May be `null` if audio isn't loaded. */
|
||||
set volume(volume: number | null) {
|
||||
this.audioManager.volume = volume
|
||||
}
|
||||
/** `volume` is a number between 0 and 1 (inclusive). May be `null` if audio isn't loaded. */
|
||||
get volume() {
|
||||
return this.audioManager.volume
|
||||
}
|
||||
|
||||
get chartCurrentTimeMs() {
|
||||
return this.audioManager.chartCurrentTimeMs
|
||||
}
|
||||
get chartEndTimeMs() {
|
||||
return this.audioManager.chartEndTimeMs
|
||||
}
|
||||
|
||||
/*
|
||||
* Should be called when discarding the preview, and the preview should not be used after this is called.
|
||||
*/
|
||||
dispose() {
|
||||
this.eventEmitter.removeAllListeners()
|
||||
this.camera.dispose()
|
||||
this.renderer.setAnimationLoop(null)
|
||||
this.renderer.renderLists.dispose()
|
||||
this.renderer.dispose()
|
||||
this.renderer.forceContextLoss()
|
||||
this.audioManager.closeAudio()
|
||||
}
|
||||
|
||||
private addHighwayToScene(highwayTexture: THREE.Texture) {
|
||||
const mat = new THREE.MeshBasicMaterial({ map: highwayTexture })
|
||||
|
||||
const geometry = new THREE.PlaneGeometry(
|
||||
this.instrumentType === instrumentTypes.drums ? 0.9
|
||||
: this.instrumentType === instrumentTypes.sixFret ? 0.7
|
||||
: 1,
|
||||
2,
|
||||
)
|
||||
const plane = new THREE.Mesh(geometry, mat)
|
||||
plane.position.y = -0.1
|
||||
plane.renderOrder = 1
|
||||
|
||||
this.scene.add(plane)
|
||||
}
|
||||
|
||||
private addStrikelineToScene(strikelineTexture: THREE.Texture) {
|
||||
const material = new THREE.SpriteMaterial({
|
||||
map: strikelineTexture,
|
||||
sizeAttenuation: true,
|
||||
transparent: true,
|
||||
depthTest: false,
|
||||
})
|
||||
const aspectRatio = strikelineTexture.image.width / strikelineTexture.image.height
|
||||
const scale = this.instrumentType === instrumentTypes.sixFret ? 0.141 : 0.19
|
||||
const sprite = new THREE.Sprite(material)
|
||||
if (aspectRatio > 1) {
|
||||
// Texture is wider than it is tall
|
||||
sprite.scale.set(aspectRatio * scale, 1 * scale, 1)
|
||||
} else {
|
||||
// Texture is taller than it is wide or square
|
||||
sprite.scale.set(1 * scale, (1 / aspectRatio) * scale, 1)
|
||||
}
|
||||
sprite.position.y = -1
|
||||
sprite.renderOrder = 3
|
||||
|
||||
this.scene.add(sprite)
|
||||
}
|
||||
|
||||
private animateFrame(emit = true) {
|
||||
this.notesManager.updateDisplayedNotes(this.audioManager.chartCurrentTimeMs)
|
||||
|
||||
// Shift highway position
|
||||
const scrollPosition = -0.9 * (this.audioManager.chartCurrentTimeMs / 1000) * (HIGHWAY_DURATION_MS / 1000)
|
||||
this.highwayTexture.offset.y = -1 * scrollPosition
|
||||
// Y position goes from -0.1 to 2-0.1
|
||||
|
||||
this.renderer.render(this.scene, this.camera)
|
||||
|
||||
if (emit) {
|
||||
this.eventEmitter.emit('progress', this.audioManager.chartCurrentTimeMs / this.audioManager.chartEndTimeMs)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class ChartCamera extends THREE.PerspectiveCamera {
|
||||
constructor(private divContainer: HTMLDivElement) {
|
||||
super(90, 1 / 1, 0.01, 10)
|
||||
this.position.z = 0.8
|
||||
this.position.y = -1.3
|
||||
this.rotation.x = THREE.MathUtils.degToRad(60)
|
||||
this.onResize()
|
||||
window.addEventListener('resize', this.resizeListener)
|
||||
}
|
||||
|
||||
private resizeListener = () => this.onResize()
|
||||
private onResize() {
|
||||
const width = this.divContainer.offsetWidth ?? window.innerWidth
|
||||
const height = this.divContainer.offsetHeight ?? window.innerHeight
|
||||
this.aspect = width / height
|
||||
this.updateProjectionMatrix()
|
||||
}
|
||||
|
||||
dispose() {
|
||||
window.removeEventListener('resize', this.resizeListener)
|
||||
this.clear()
|
||||
}
|
||||
}
|
||||
|
||||
class ChartRenderer extends THREE.WebGLRenderer {
|
||||
constructor(private divContainer: HTMLDivElement) {
|
||||
super({
|
||||
antialias: true,
|
||||
})
|
||||
this.localClippingEnabled = true
|
||||
this.outputColorSpace = THREE.LinearSRGBColorSpace
|
||||
this.onResize()
|
||||
window.addEventListener('resize', this.resizeListener)
|
||||
}
|
||||
|
||||
private resizeListener = () => this.onResize()
|
||||
private onResize() {
|
||||
const width = this.divContainer.offsetWidth ?? window.innerWidth
|
||||
const height = this.divContainer.offsetHeight ?? window.innerHeight
|
||||
this.setSize(width, height)
|
||||
}
|
||||
|
||||
override dispose() {
|
||||
window.removeEventListener('resize', this.resizeListener)
|
||||
super.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
interface AudioManagerEvents {
|
||||
end: () => void
|
||||
}
|
||||
class AudioManager {
|
||||
private eventEmitter = new EventEmitter()
|
||||
|
||||
private audioCtx = new AudioContext()
|
||||
private gainNode: GainNode | null = null
|
||||
private _volume = 0.5
|
||||
private lastSeekChartTimeMs = 0
|
||||
private lastAudioCtxCurrentTime = 0 // Necessary because audioCtx.currentTime doesn't reset to 0 on seek
|
||||
|
||||
private constructor(
|
||||
private audioFiles: Uint8Array[],
|
||||
private startDelayMs: number,
|
||||
private audioLengthMs: number,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* @param audioFiles The `Uint8Array[]` of the audio files to be played.
|
||||
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
||||
* @param audioLengthMs The length of the longest audio file stem.
|
||||
*/
|
||||
static async create(audioFiles: Uint8Array[], startDelayMs: number, audioLengthMs: number) {
|
||||
const audioManager = new AudioManager(audioFiles, startDelayMs, audioLengthMs)
|
||||
await audioManager.initAudio()
|
||||
return audioManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Available events:
|
||||
* - `end`: called when the audio playback ends.
|
||||
*/
|
||||
on<T extends keyof AudioManagerEvents>(event: T, listener: AudioManagerEvents[T]) {
|
||||
this.eventEmitter.on(event, listener)
|
||||
}
|
||||
|
||||
/** `volume` is a number between 0 and 1 (inclusive) */
|
||||
set volume(volume: number) {
|
||||
this._volume = volume * volume
|
||||
if (this.gainNode) {
|
||||
this.gainNode.gain.value = this._volume
|
||||
}
|
||||
}
|
||||
/** `volume` is a number between 0 and 1 (inclusive) */
|
||||
get volume() {
|
||||
return Math.sqrt(this._volume)
|
||||
}
|
||||
|
||||
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
||||
get chartCurrentTimeMs() {
|
||||
const isPaused = this.audioCtx.state === 'suspended'
|
||||
// outputLatency is not implemented in safari
|
||||
const audioLatency = (this.audioCtx.baseLatency + (this.audioCtx.outputLatency || 0)) * 1000
|
||||
const audioTimeSinceLastSeekMs = (this.audioCtx.currentTime - this.lastAudioCtxCurrentTime) * 1000
|
||||
// Note: when paused, the queued audio during the latency period is skipped and never heard.
|
||||
// The solution here is to represent that visually by jumping ahead slightly by ignoring latency when paused.
|
||||
// If this is a more significant problem, it can be fixed by seeking backward by `audioLatency`.
|
||||
return this.lastSeekChartTimeMs + audioTimeSinceLastSeekMs - (isPaused ? 0 : audioLatency)
|
||||
}
|
||||
|
||||
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
||||
get chartEndTimeMs() {
|
||||
return Math.max(this.startDelayMs + this.audioLengthMs, 0)
|
||||
}
|
||||
|
||||
async play() {
|
||||
if (this.audioCtx.state === 'suspended') {
|
||||
if (this.gainNode === null) {
|
||||
await this.initAudio()
|
||||
}
|
||||
await this.audioCtx.resume()
|
||||
}
|
||||
}
|
||||
async pause() {
|
||||
if (this.audioCtx.state === 'running') {
|
||||
await this.audioCtx.suspend()
|
||||
}
|
||||
}
|
||||
|
||||
closeAudio() {
|
||||
this.eventEmitter.removeAllListeners()
|
||||
this.gainNode?.disconnect()
|
||||
this.audioCtx.close()
|
||||
}
|
||||
|
||||
async initAudio() {
|
||||
const audioBuffers = await Promise.all(
|
||||
// Must be recreated on each seek because seek is not supported by the Web Audio API
|
||||
// TODO: use audio-decode library instead if this fails
|
||||
this.audioFiles.map(file => this.audioCtx.decodeAudioData(file.slice(0).buffer)),
|
||||
)
|
||||
|
||||
this.gainNode = this.audioCtx.createGain()
|
||||
this.gainNode.gain.value = this._volume
|
||||
this.gainNode.connect(this.audioCtx.destination)
|
||||
|
||||
let endedCount = 0
|
||||
const audioStartOffsetSeconds = (this.lastSeekChartTimeMs - this.startDelayMs) / 1000
|
||||
for (const audioBuffer of audioBuffers) {
|
||||
const source = this.audioCtx.createBufferSource()
|
||||
source.buffer = audioBuffer
|
||||
source.onended = () => {
|
||||
endedCount++
|
||||
if (endedCount === audioBuffers.length) {
|
||||
this.eventEmitter.emit('end')
|
||||
}
|
||||
}
|
||||
source.connect(this.gainNode!)
|
||||
const when = Math.abs(Math.min(audioStartOffsetSeconds, 0))
|
||||
const offset = Math.max(audioStartOffsetSeconds, 0)
|
||||
source.start(when, offset)
|
||||
}
|
||||
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime
|
||||
this.pause()
|
||||
}
|
||||
|
||||
/**
|
||||
* @param percentComplete The progress between the start and end of the preview.
|
||||
*/
|
||||
async seek(percentComplete: number) {
|
||||
await this.audioCtx.suspend()
|
||||
this.gainNode?.disconnect()
|
||||
this.gainNode = null
|
||||
const chartSeekTimeMs = percentComplete * this.chartEndTimeMs
|
||||
this.lastSeekChartTimeMs = chartSeekTimeMs
|
||||
this.lastAudioCtxCurrentTime = this.audioCtx.currentTime
|
||||
}
|
||||
}
|
||||
|
||||
/** Used if window.AudioContext || window.webkitAudioContext is undefined. */
|
||||
class SilentAudioManager {
|
||||
private eventEmitter = new EventEmitter()
|
||||
private endEventTimeout: NodeJS.Timeout | null = null
|
||||
|
||||
private isPaused = true
|
||||
private lastResumeChartTimeMs: number
|
||||
private lastResumeClockTimeMs: number
|
||||
|
||||
private constructor(
|
||||
private startDelayMs: number,
|
||||
private audioLengthMs: number,
|
||||
) { }
|
||||
|
||||
/**
|
||||
* @param audioFiles The `ArrayBuffer[]` of the audio files to be played.
|
||||
* @param startDelayMs The amount of time to delay the start of the audio. (can be negative)
|
||||
* @param audioLengthMs The length of the longest audio file stem.
|
||||
*/
|
||||
static async create(startDelayMs: number, audioLengthMs: number) {
|
||||
const audioManager = new SilentAudioManager(startDelayMs, audioLengthMs)
|
||||
await audioManager.seek(0)
|
||||
return audioManager
|
||||
}
|
||||
|
||||
/**
|
||||
* Available events:
|
||||
* - `end`: called when the playback ends.
|
||||
*/
|
||||
on<T extends keyof AudioManagerEvents>(event: T, listener: AudioManagerEvents[T]) {
|
||||
this.eventEmitter.on(event, listener)
|
||||
}
|
||||
|
||||
/** `volume` is invalid for silent playback. */
|
||||
set volume(_null: number | null) {
|
||||
return
|
||||
}
|
||||
/** `volume` is invalid for silent playback. */
|
||||
get volume() {
|
||||
return null
|
||||
}
|
||||
|
||||
/** Nonnegative number of milliseconds representing time elapsed since the chart preview start. */
|
||||
get chartCurrentTimeMs() {
|
||||
if (this.isPaused) {
|
||||
return this.lastResumeChartTimeMs
|
||||
} else {
|
||||
return this.lastResumeChartTimeMs + performance.now() - this.lastResumeClockTimeMs
|
||||
}
|
||||
}
|
||||
/** Nonnegative number of milliseconds representing when the audio ends (and when the chart preview ends). */
|
||||
get chartEndTimeMs() {
|
||||
return Math.max(this.startDelayMs + this.audioLengthMs, 0)
|
||||
}
|
||||
|
||||
async play() {
|
||||
if (this.lastResumeChartTimeMs >= this.chartEndTimeMs - 2) {
|
||||
this.lastResumeChartTimeMs = 0 // Restart at the end
|
||||
}
|
||||
this.lastResumeClockTimeMs = performance.now()
|
||||
this.endEventTimeout = setTimeout(() => {
|
||||
this.pause()
|
||||
this.eventEmitter.emit('end')
|
||||
}, this.chartEndTimeMs - this.lastResumeChartTimeMs)
|
||||
this.isPaused = false
|
||||
}
|
||||
|
||||
async pause() {
|
||||
this.lastResumeChartTimeMs = this.chartCurrentTimeMs
|
||||
if (this.endEventTimeout) {
|
||||
clearTimeout(this.endEventTimeout)
|
||||
}
|
||||
this.isPaused = true
|
||||
}
|
||||
|
||||
closeAudio() {
|
||||
this.eventEmitter.removeAllListeners()
|
||||
if (this.endEventTimeout) {
|
||||
clearTimeout(this.endEventTimeout)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* @param percentComplete The progress between the start and end of the preview.
|
||||
*/
|
||||
async seek(percentComplete: number) {
|
||||
this.lastResumeChartTimeMs = this.chartEndTimeMs * percentComplete
|
||||
|
||||
if (!this.isPaused) {
|
||||
this.play()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Handles adding/removing/moving the notes in `scene` at the given `chartCurrentTimeMs` value.
|
||||
* TODO: Potential optimization: use InstancedMesh and a custom shader to render multiple sprites in a single draw call
|
||||
*/
|
||||
class NotesManager {
|
||||
private noteMaterials = new Map<NoteType | BARRE_TYPES, Map<number, THREE.SpriteMaterial>>()
|
||||
private clippingPlanes = [new THREE.Plane(new THREE.Vector3(0, 1, 0), 1), new THREE.Plane(new THREE.Vector3(0, -1, 0), 0.9)]
|
||||
|
||||
private instrumentType: InstrumentType
|
||||
private noteEvents: NoteEvent[]
|
||||
private notes: EventSequence<ParsedChart['trackData'][number]['noteEventGroups'][number][number]>
|
||||
private soloSections: EventSequence<ParsedChart['trackData'][number]['soloSections'][number]>
|
||||
private flexLanes: EventSequence<ParsedChart['trackData'][number]['flexLanes'][number]>
|
||||
private drumFreestyleSections: EventSequence<ParsedChart['trackData'][number]['drumFreestyleSections'][number]>
|
||||
|
||||
private noteGroups = new Map<number, THREE.Group<THREE.Object3DEventMap>>()
|
||||
|
||||
constructor(
|
||||
private chartData: ParsedChart,
|
||||
private instrument: Instrument,
|
||||
private difficulty: Difficulty,
|
||||
private scene: THREE.Scene,
|
||||
noteTextures: Map<NoteType | BARRE_TYPES, Map<number, THREE.Texture>>,
|
||||
) {
|
||||
adjustParsedChart(chartData, instrument, difficulty)
|
||||
_.values(noteTypes).forEach(noteType => this.noteMaterials.set(noteType, new Map()))
|
||||
barreTypes.forEach(barreType => this.noteMaterials.set(barreType, new Map()))
|
||||
noteTextures.forEach((flagTextures, noteType) => {
|
||||
flagTextures.forEach((texture, noteFlags) => {
|
||||
this.noteMaterials.get(noteType)!.set(noteFlags, new THREE.SpriteMaterial({ map: texture }))
|
||||
})
|
||||
})
|
||||
const track = chartData.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty)!
|
||||
|
||||
this.instrumentType = getInstrumentType(instrument)
|
||||
this.noteEvents = _.flatten(track.noteEventGroups)
|
||||
this.notes = new EventSequence(this.noteEvents)
|
||||
this.soloSections = new EventSequence(track.soloSections)
|
||||
this.flexLanes = new EventSequence(track.flexLanes)
|
||||
this.drumFreestyleSections = new EventSequence(track.drumFreestyleSections)
|
||||
}
|
||||
|
||||
updateDisplayedNotes(chartCurrentTimeMs: number) {
|
||||
const noteStartIndex = this.notes.getEarliestActiveEventIndex(chartCurrentTimeMs)
|
||||
// TODO: render beat lines
|
||||
// TODO: const renderedSoloSections = this.soloSections.getEventRange(chartCurrentTimeMs, chartCurrentTimeMs + 1)
|
||||
// TODO: const renderedDrumRollLanes = this.drumRollLanes.getEventRange(chartCurrentTimeMs, renderEndTimeMs)
|
||||
// TODO: const renderedDrumFreestyleSections = this.drumFreestyleSections.getEventRange(chartCurrentTimeMs, renderEndTimeMs)
|
||||
|
||||
const renderEndTimeMs = chartCurrentTimeMs + HIGHWAY_DURATION_MS
|
||||
let maxNoteEventIndex = noteStartIndex - 1
|
||||
for (const [noteEventIndex, sprite] of this.noteGroups) {
|
||||
if (noteEventIndex < noteStartIndex || this.noteEvents[noteEventIndex].msTime > renderEndTimeMs) {
|
||||
this.scene.remove(sprite)
|
||||
this.noteGroups.delete(noteEventIndex)
|
||||
} else {
|
||||
// TODO: update animation frame (.webp or sprite sheet?)
|
||||
sprite.position.y = interpolate(this.noteEvents[noteEventIndex].msTime, chartCurrentTimeMs, renderEndTimeMs, -1, 1)
|
||||
if (noteEventIndex > maxNoteEventIndex) {
|
||||
maxNoteEventIndex = noteEventIndex
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
for (let i = maxNoteEventIndex + 1; this.noteEvents[i] && this.noteEvents[i].msTime < renderEndTimeMs; i++) {
|
||||
const note = this.noteEvents[i]
|
||||
const noteGroup = new THREE.Group()
|
||||
const scale =
|
||||
note.type === noteTypes.kick ? 0.045
|
||||
: note.type === noteTypes.open && this.instrumentType === instrumentTypes.sixFret ? 0.04
|
||||
: SCALE
|
||||
const sprite = new THREE.Sprite(this.noteMaterials.get(note.type)!.get(note.flags)!)
|
||||
noteGroup.add(sprite)
|
||||
sprite.center = new THREE.Vector2(note.type === noteTypes.kick ? 0.62 : 0.5, note.type === noteTypes.kick ? -0.5 : 0)
|
||||
const aspectRatio = sprite.material.map!.image.width / sprite.material.map!.image.height
|
||||
sprite.scale.set(scale * aspectRatio, scale, scale)
|
||||
noteGroup.position.x = calculateNoteXOffset(this.instrumentType, note.type)
|
||||
noteGroup.position.y = interpolate(note.msTime, chartCurrentTimeMs, renderEndTimeMs, -1, 1)
|
||||
noteGroup.position.z = 0
|
||||
sprite.material.clippingPlanes = this.clippingPlanes
|
||||
sprite.material.depthTest = false
|
||||
sprite.material.transparent = true
|
||||
sprite.renderOrder = note.type === noteTypes.kick ? 1 : 4
|
||||
|
||||
if (note.msLength > 0) {
|
||||
const mat = new THREE.MeshBasicMaterial({
|
||||
color: calculateColor(note.type),
|
||||
side: THREE.DoubleSide,
|
||||
})
|
||||
|
||||
mat.clippingPlanes = this.clippingPlanes
|
||||
mat.depthTest = false
|
||||
mat.transparent = true
|
||||
const geometry = new THREE.PlaneGeometry(SCALE * (note.type === noteTypes.open ? 5 : 0.3), 2 * (note.msLength / HIGHWAY_DURATION_MS))
|
||||
const plane = new THREE.Mesh(geometry, mat)
|
||||
plane.position.y = 0.03 + note.msLength / HIGHWAY_DURATION_MS
|
||||
plane.renderOrder = 2
|
||||
|
||||
noteGroup.add(plane)
|
||||
}
|
||||
|
||||
this.noteGroups.set(i, noteGroup)
|
||||
this.scene.add(noteGroup)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class EventSequence<T extends { msTime: number; msLength: number; type?: NoteType }> {
|
||||
/** Contains the closest events before msTime, grouped by type */
|
||||
private lastPrecedingEventIndexesOfType = new Map<NoteType | undefined, number>()
|
||||
private lastPrecedingEventIndex = -1
|
||||
|
||||
/** Assumes `events` are already sorted in `msTime` order. */
|
||||
constructor(private events: T[]) { }
|
||||
|
||||
getEarliestActiveEventIndex(startMs: number) {
|
||||
if (this.lastPrecedingEventIndex !== -1 && startMs < this.events[this.lastPrecedingEventIndex].msTime) {
|
||||
this.lastPrecedingEventIndexesOfType = new Map<NoteType | undefined, number>()
|
||||
this.lastPrecedingEventIndex = -1
|
||||
}
|
||||
while (this.events[this.lastPrecedingEventIndex + 1] && this.events[this.lastPrecedingEventIndex + 1].msTime < startMs) {
|
||||
this.lastPrecedingEventIndexesOfType.set(this.events[this.lastPrecedingEventIndex + 1].type, this.lastPrecedingEventIndex + 1)
|
||||
this.lastPrecedingEventIndex++
|
||||
}
|
||||
|
||||
let earliestActiveEventIndex: number | null = null
|
||||
for (const [, index] of this.lastPrecedingEventIndexesOfType) {
|
||||
if (this.events[index].msTime + this.events[index].msLength > startMs) {
|
||||
if (earliestActiveEventIndex === null || earliestActiveEventIndex > index) {
|
||||
earliestActiveEventIndex = index
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return earliestActiveEventIndex === null ? this.lastPrecedingEventIndex + 1 : earliestActiveEventIndex
|
||||
}
|
||||
}
|
||||
|
||||
async function loadTextures(instrumentType: InstrumentType) {
|
||||
const textureLoader = new THREE.TextureLoader()
|
||||
const load = (path: string) => textureLoader.loadAsync('https://static.enchor.us/' + path)
|
||||
|
||||
const [highwayTexture, strikelineTexture, noteTextures] = await Promise.all([
|
||||
(async () => {
|
||||
const texture = await load('preview-highway.png')
|
||||
|
||||
texture.wrapS = THREE.RepeatWrapping
|
||||
texture.wrapT = THREE.RepeatWrapping
|
||||
|
||||
texture.repeat.set(1, 2)
|
||||
return texture
|
||||
})(),
|
||||
(async () => {
|
||||
switch (instrumentType) {
|
||||
case instrumentTypes.drums:
|
||||
return await load('preview-drums-strikeline.png')
|
||||
case instrumentTypes.sixFret:
|
||||
return await load('preview-6fret-strikeline.png')
|
||||
case instrumentTypes.fiveFret:
|
||||
return await load('preview-5fret-strikeline.png')
|
||||
}
|
||||
})(),
|
||||
(async () => {
|
||||
const texturePromises: { type: NoteType | BARRE_TYPES; flags: number; texture: Promise<THREE.Texture> }[] = []
|
||||
const addTexture = (type: NoteType | BARRE_TYPES, flags: number, path: string) => {
|
||||
const texture = textureLoader.loadAsync(`https://static.enchor.us/preview-${path}.webp`)
|
||||
texturePromises.push({ type, flags, texture })
|
||||
return texture
|
||||
}
|
||||
const reuseTexture = (type: NoteType | BARRE_TYPES, flags: number, texture: Promise<THREE.Texture>) => {
|
||||
texturePromises.push({ type, flags, texture })
|
||||
}
|
||||
|
||||
// TODO: use VideoTexture and make all note extensions consistent
|
||||
// https://stackoverflow.com/questions/18383470/using-video-as-texture-with-three-js/77077409#77077409
|
||||
if (instrumentType === instrumentTypes.drums) {
|
||||
const colors = new Map([
|
||||
[noteTypes.redDrum, 'red'],
|
||||
[noteTypes.yellowDrum, 'yellow'],
|
||||
[noteTypes.blueDrum, 'blue'],
|
||||
[noteTypes.greenDrum, 'green'],
|
||||
])
|
||||
const dynamicFlags = new Map([
|
||||
[noteFlags.none, ''],
|
||||
[noteFlags.ghost, '-ghost'],
|
||||
[noteFlags.accent, '-accent'],
|
||||
])
|
||||
const spFlags = new Map([
|
||||
[noteFlags.none, ''],
|
||||
[SP_FLAG, '-sp'],
|
||||
])
|
||||
|
||||
addTexture(noteTypes.kick, noteFlags.none, 'drums-kick')
|
||||
addTexture(noteTypes.kick, noteFlags.doubleKick, 'drums-kick')
|
||||
addTexture(noteTypes.kick, noteFlags.none | SP_FLAG, 'drums-kick-sp')
|
||||
addTexture(noteTypes.kick, noteFlags.doubleKick | SP_FLAG, 'drums-kick-sp')
|
||||
for (const [colorKey, colorName] of colors) {
|
||||
for (const [dynamicFlagKey, dynamicFlagName] of dynamicFlags) {
|
||||
for (const [spFlagKey, spFlagName] of spFlags) {
|
||||
addTexture(colorKey, spFlagKey | dynamicFlagKey | noteFlags.tom, `drums-${colorName}-tom${dynamicFlagName}${spFlagName}`)
|
||||
if (colorKey !== noteTypes.redDrum) {
|
||||
addTexture(colorKey, spFlagKey | dynamicFlagKey | noteFlags.cymbal, `drums-${colorName}-cymbal${dynamicFlagName}${spFlagName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (instrumentType === instrumentTypes.sixFret) {
|
||||
const lanes = new Map<NoteType | BARRE_TYPES, string>([
|
||||
[noteTypes.open, 'open'],
|
||||
[noteTypes.black1, 'black'],
|
||||
[noteTypes.white1, 'white'],
|
||||
[BARRE1_TYPE, 'barre'],
|
||||
])
|
||||
const modifiers = new Map([
|
||||
[noteFlags.strum, '-strum'],
|
||||
[noteFlags.hopo, '-hopo'],
|
||||
[noteFlags.tap, '-tap'],
|
||||
])
|
||||
const spFlags = new Map([
|
||||
[noteFlags.none, ''],
|
||||
[SP_FLAG, '-sp'],
|
||||
])
|
||||
|
||||
for (const [laneKey, laneName] of lanes) {
|
||||
for (const [modifierKey, modifierName] of modifiers) {
|
||||
for (const [spFlagKey, spFlagName] of spFlags) {
|
||||
const texturePromise = addTexture(laneKey, modifierKey | spFlagKey, `6fret-${laneName}${modifierName}${spFlagName}`)
|
||||
|
||||
// Same texture used for all three lanes
|
||||
if (laneKey === noteTypes.black1) {
|
||||
reuseTexture(noteTypes.black2, modifierKey | spFlagKey, texturePromise)
|
||||
reuseTexture(noteTypes.black3, modifierKey | spFlagKey, texturePromise)
|
||||
} else if (laneKey === noteTypes.white1) {
|
||||
reuseTexture(noteTypes.white2, modifierKey | spFlagKey, texturePromise)
|
||||
reuseTexture(noteTypes.white3, modifierKey | spFlagKey, texturePromise)
|
||||
} else if (laneKey === BARRE1_TYPE) {
|
||||
reuseTexture(BARRE2_TYPE, modifierKey | spFlagKey, texturePromise)
|
||||
reuseTexture(BARRE3_TYPE, modifierKey | spFlagKey, texturePromise)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} else if (instrumentType === instrumentTypes.fiveFret) {
|
||||
const lanes = new Map([
|
||||
[noteTypes.open, 'open'],
|
||||
[noteTypes.green, 'green'],
|
||||
[noteTypes.red, 'red'],
|
||||
[noteTypes.yellow, 'yellow'],
|
||||
[noteTypes.blue, 'blue'],
|
||||
[noteTypes.orange, 'orange'],
|
||||
])
|
||||
const modifiers = new Map([
|
||||
[noteFlags.strum, '-strum'],
|
||||
[noteFlags.hopo, '-hopo'],
|
||||
[noteFlags.tap, '-tap'],
|
||||
])
|
||||
const spFlags = new Map([
|
||||
[noteFlags.none, ''],
|
||||
[SP_FLAG, '-sp'],
|
||||
])
|
||||
|
||||
for (const [laneKey, laneName] of lanes) {
|
||||
for (let [modifierKey, modifierName] of modifiers) {
|
||||
for (const [spFlagKey, spFlagName] of spFlags) {
|
||||
if (laneKey === noteTypes.open && modifierKey === noteFlags.tap) {
|
||||
modifierName = '-hopo'
|
||||
modifierKey = noteFlags.hopo
|
||||
}
|
||||
addTexture(laneKey, modifierKey | spFlagKey, `5fret-${laneName}${modifierName}${spFlagName}`)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const textures = await Promise.all(texturePromises.map(async t => ({ type: t.type, flags: t.flags, texture: await t.texture })))
|
||||
|
||||
const textureMap = new Map<NoteType | BARRE_TYPES, Map<number, THREE.Texture>>()
|
||||
_.values(noteTypes).forEach(noteType => textureMap.set(noteType, new Map()))
|
||||
barreTypes.forEach(barreType => textureMap.set(barreType, new Map()))
|
||||
for (const texture of textures) {
|
||||
textureMap.get(texture.type)!.set(texture.flags, texture.texture)
|
||||
}
|
||||
|
||||
return textureMap
|
||||
})(),
|
||||
])
|
||||
|
||||
return {
|
||||
highwayTexture,
|
||||
strikelineTexture,
|
||||
noteTextures,
|
||||
}
|
||||
}
|
||||
|
||||
function adjustParsedChart(parsedChart: ParsedChart, instrument: Instrument, difficulty: Difficulty) {
|
||||
const track = parsedChart.trackData.find(t => t.instrument === instrument && t.difficulty === difficulty)!
|
||||
const starPower = track.starPowerSections
|
||||
|
||||
if (starPower.length > 0) {
|
||||
let starPowerIndex = 0
|
||||
for (const noteGroup of track.noteEventGroups) {
|
||||
while (starPowerIndex < starPower.length && starPower[starPowerIndex].tick + starPower[starPowerIndex].length < noteGroup[0].tick) {
|
||||
starPowerIndex++
|
||||
}
|
||||
if (starPowerIndex === starPower.length) {
|
||||
break
|
||||
}
|
||||
if (
|
||||
noteGroup[0].tick >= starPower[starPowerIndex].tick &&
|
||||
noteGroup[0].tick < starPower[starPowerIndex].tick + starPower[starPowerIndex].length
|
||||
) {
|
||||
for (const note of noteGroup) {
|
||||
note.flags |= SP_FLAG
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (getInstrumentType(instrument) === instrumentTypes.sixFret) {
|
||||
for (const noteGroup of track.noteEventGroups) {
|
||||
let oneCount = 0
|
||||
let twoCount = 0
|
||||
let threeCount = 0
|
||||
for (const note of noteGroup) {
|
||||
switch (note.type) {
|
||||
case noteTypes.black1:
|
||||
case noteTypes.white1:
|
||||
oneCount++
|
||||
break
|
||||
case noteTypes.black2:
|
||||
case noteTypes.white2:
|
||||
twoCount++
|
||||
break
|
||||
case noteTypes.black3:
|
||||
case noteTypes.white3:
|
||||
threeCount++
|
||||
break
|
||||
}
|
||||
}
|
||||
if (oneCount > 1) {
|
||||
const removed = _.remove(noteGroup, n => n.type === noteTypes.black1 || n.type === noteTypes.white1)
|
||||
removed[0].type = BARRE1_TYPE as NoteType
|
||||
noteGroup.push(removed[0])
|
||||
}
|
||||
if (twoCount > 1) {
|
||||
const removed = _.remove(noteGroup, n => n.type === noteTypes.black2 || n.type === noteTypes.white2)
|
||||
removed[0].type = BARRE2_TYPE as NoteType
|
||||
noteGroup.push(removed[0])
|
||||
}
|
||||
if (threeCount > 1) {
|
||||
const removed = _.remove(noteGroup, n => n.type === noteTypes.black3 || n.type === noteTypes.white3)
|
||||
removed[0].type = BARRE3_TYPE as NoteType
|
||||
noteGroup.push(removed[0])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return parsedChart
|
||||
}
|
||||
|
||||
// TODO: Consider doing document.createElement('video') instead, and use webp
|
||||
// class SpriteSheetTexture extends THREE.CanvasTexture {
|
||||
// private timer: NodeJS.Timer
|
||||
// private currentFrameIndex = 0
|
||||
// private canvas: HTMLCanvasElement
|
||||
// private ctx: CanvasRenderingContext2D
|
||||
// private img = new Image()
|
||||
|
||||
// constructor(
|
||||
// imageUrl: string,
|
||||
// private framesX: number,
|
||||
// framesY: number,
|
||||
// private endFrame = framesX * framesY,
|
||||
// ) {
|
||||
// const canvas = document.createElement('canvas')
|
||||
// super(canvas)
|
||||
// this.canvas = canvas
|
||||
// this.ctx = canvas.getContext('2d')!
|
||||
|
||||
// this.img.src = imageUrl
|
||||
// this.img.onload = () => {
|
||||
// canvas.width = this.img.width / framesX
|
||||
// canvas.height = this.img.height / framesY
|
||||
// this.timer = setInterval(() => this.nextFrame(), 16.67)
|
||||
// }
|
||||
// }
|
||||
|
||||
// nextFrame() {
|
||||
// this.currentFrameIndex++
|
||||
|
||||
// if (this.currentFrameIndex >= this.endFrame) {
|
||||
// this.currentFrameIndex = 0
|
||||
// }
|
||||
|
||||
// const x = (this.currentFrameIndex % this.framesX) * this.canvas.width
|
||||
// const y = ((this.currentFrameIndex / this.framesX) | 0) * this.canvas.height
|
||||
|
||||
// this.ctx.clearRect(0, 0, this.canvas.width, this.canvas.height)
|
||||
// this.ctx.drawImage(this.img, x, y, this.canvas.width, this.canvas.height, 0, 0, this.canvas.width, this.canvas.height)
|
||||
|
||||
// this.needsUpdate = true
|
||||
// }
|
||||
// }
|
||||
|
||||
function calculateNoteXOffset(instrumentType: InstrumentType, noteType: NoteType) {
|
||||
const lane = calculateLane(noteType)
|
||||
const leftOffset =
|
||||
instrumentType === instrumentTypes.drums ? 0.135
|
||||
: instrumentType === instrumentTypes.sixFret && noteType !== noteTypes.open ? 0.2
|
||||
: instrumentType === instrumentTypes.sixFret && noteType === noteTypes.open ? 0.035
|
||||
: 0.035
|
||||
|
||||
return leftOffset + -(NOTE_SPAN_WIDTH / 2) + SCALE + ((NOTE_SPAN_WIDTH - SCALE) / 5) * lane
|
||||
}
|
||||
|
||||
function calculateLane(noteType: NoteType) {
|
||||
switch (noteType) {
|
||||
case noteTypes.green:
|
||||
case noteTypes.redDrum:
|
||||
case noteTypes.black1:
|
||||
case noteTypes.white1:
|
||||
case BARRE1_TYPE as NoteType:
|
||||
return 0
|
||||
case noteTypes.red:
|
||||
case noteTypes.yellowDrum:
|
||||
case noteTypes.black2:
|
||||
case noteTypes.white2:
|
||||
case BARRE2_TYPE as NoteType:
|
||||
return 1
|
||||
case noteTypes.yellow:
|
||||
case noteTypes.blueDrum:
|
||||
case noteTypes.open:
|
||||
case noteTypes.kick:
|
||||
case noteTypes.black3:
|
||||
case noteTypes.white3:
|
||||
case BARRE3_TYPE as NoteType:
|
||||
return 2
|
||||
case noteTypes.blue:
|
||||
case noteTypes.greenDrum:
|
||||
return 3
|
||||
case noteTypes.orange:
|
||||
return 4
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
|
||||
function calculateColor(noteType: NoteType) {
|
||||
switch (noteType) {
|
||||
case noteTypes.green:
|
||||
case noteTypes.greenDrum:
|
||||
return '#01B11A'
|
||||
case noteTypes.red:
|
||||
case noteTypes.redDrum:
|
||||
return '#DD2214'
|
||||
case noteTypes.yellow:
|
||||
case noteTypes.yellowDrum:
|
||||
return '#DEEB52'
|
||||
case noteTypes.blue:
|
||||
case noteTypes.blueDrum:
|
||||
return '#006CAF'
|
||||
case noteTypes.open:
|
||||
return '#8A0BB5'
|
||||
case noteTypes.orange:
|
||||
return '#F8B272'
|
||||
default:
|
||||
return '#FFFFFF'
|
||||
}
|
||||
}
|
||||
@@ -28,7 +28,6 @@
|
||||
class="flex flex-1 p-2 gap-1 justify-between overflow-x-hidden overflow-y-auto scrollbar scrollbar-w-2 scrollbar-h-2 scrollbar-track-base-300 scrollbar-thumb-neutral scrollbar-thumb-rounded-full">
|
||||
<div class="flex flex-1 flex-col gap-1">
|
||||
<div>
|
||||
<!-- TODO: Change this to a dropdown if there is more than one chart -->
|
||||
<b>Charter: </b>
|
||||
<a class="link link-hover" (click)="onSourceLinkClicked()">{{ selectedChart.charter | removeStyleTags }}</a>
|
||||
</div>
|
||||
@@ -50,25 +49,25 @@
|
||||
@if (metadataIssues.length > 0) {
|
||||
<div class="menu-title">Metadata Issues Found:</div>
|
||||
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
|
||||
<li *ngFor="let issue of metadataIssues" class="list-item">{{ getMetadataIssueText(issue) }}</li>
|
||||
<li *ngFor="let issue of metadataIssues" class="list-item">{{ issue.description }}</li>
|
||||
</ul>
|
||||
}
|
||||
@if (folderIssues.length > 0) {
|
||||
<div class="menu-title">Chart Folder Issues Found:</div>
|
||||
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
|
||||
<li *ngFor="let issue of folderIssues" class="list-item">{{ getFolderIssueText(issue) }}</li>
|
||||
<li *ngFor="let issue of folderIssues" class="list-item">{{ issue.description }}</li>
|
||||
</ul>
|
||||
}
|
||||
@if (chartIssues.length > 0) {
|
||||
@if (globalChartIssues.length > 0) {
|
||||
<div class="menu-title">Chart Issues Found:</div>
|
||||
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
|
||||
<li *ngFor="let issue of chartIssues" class="list-item">{{ getChartIssueText(issue) }}</li>
|
||||
<li *ngFor="let issue of globalChartIssues" class="list-item">{{ issue.description }}</li>
|
||||
</ul>
|
||||
}
|
||||
@for (trackIssues of trackIssuesGroups; track $index) {
|
||||
<div class="menu-title">{{ trackIssues.groupName }}</div>
|
||||
<ul class="list-disc ml-9 min-w-[246px] max-w-[min(26.1vw,444px)]">
|
||||
<li *ngFor="let issue of trackIssues.issues" class="list-item">{{ getTrackIssueText(issue) }}</li>
|
||||
<li *ngFor="let issue of trackIssues.issues" class="list-item">{{ issue }}</li>
|
||||
</ul>
|
||||
}
|
||||
</div>
|
||||
@@ -118,6 +117,27 @@
|
||||
<p class="font-bold whitespace-nowrap">Average NPS: {{ averageNps || 'N/A' }}</p>
|
||||
<p class="font-bold whitespace-nowrap">Maximum NPS: {{ maximumNps }}</p>
|
||||
<p class="font-bold whitespace-nowrap">Note Count: {{ noteCount }}</p>
|
||||
<button class="btn btn-sm btn-neutral my-1 max-w-fit" (click)="previewModal.showModal()">
|
||||
<i class="bi bi-play text-lg text-neutral-content"></i>
|
||||
Chart Preview
|
||||
</button>
|
||||
<dialog #previewModal class="modal" (close)="chartPreview.endChartPreview()">
|
||||
<div class="modal-box bg-base-100 text-base-content flex flex-col gap-2 h-[50vh] w-[80vw] max-w-full max-h-full">
|
||||
<form method="dialog">
|
||||
<button class="btn btn-sm btn-circle btn-ghost absolute right-2 top-2">
|
||||
<i class="bi bi-x-lg text-lg"></i>
|
||||
</button>
|
||||
</form>
|
||||
<app-chart-sidebar-preview
|
||||
#chartPreview
|
||||
[selectedChart]="selectedChart"
|
||||
[instrument]="instrumentDropdown.value"
|
||||
[difficulty]="difficultyDropdown.value" />
|
||||
</div>
|
||||
<form method="dialog" class="modal-backdrop">
|
||||
<button>close</button>
|
||||
</form>
|
||||
</dialog>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -2,7 +2,7 @@ import { Component, ElementRef, HostBinding, OnInit, Renderer2, ViewChild } from
|
||||
import { FormControl } from '@angular/forms'
|
||||
|
||||
import _ from 'lodash'
|
||||
import { ChartIssueType, Difficulty, FolderIssueType, Instrument, MetadataIssueType, NoteIssueType, TrackIssueType } from 'scan-chart'
|
||||
import { Difficulty, Instrument, NotesData } from 'scan-chart'
|
||||
import { DownloadService } from 'src-angular/app/core/services/download.service'
|
||||
import { SearchService } from 'src-angular/app/core/services/search.service'
|
||||
import { SettingsService } from 'src-angular/app/core/services/settings.service'
|
||||
@@ -34,8 +34,8 @@ export class ChartSidebarComponent implements OnInit {
|
||||
selectedChart: ChartData | null = null
|
||||
charts: ChartData[][] | null = null
|
||||
|
||||
public instrumentDropdown: FormControl<Instrument | null>
|
||||
public difficultyDropdown: FormControl<Difficulty | null>
|
||||
public instrumentDropdown: FormControl<Instrument>
|
||||
public difficultyDropdown: FormControl<Difficulty>
|
||||
|
||||
constructor(
|
||||
private renderer: Renderer2,
|
||||
@@ -49,21 +49,21 @@ export class ChartSidebarComponent implements OnInit {
|
||||
this.charts = null
|
||||
this.selectedChart = null
|
||||
})
|
||||
this.instrumentDropdown = new FormControl<Instrument | null>(this.defaultInstrument)
|
||||
this.instrumentDropdown = new FormControl<Instrument>(this.defaultInstrument, { nonNullable: true })
|
||||
this.searchService.instrument.valueChanges.subscribe(instrument => {
|
||||
if (this.instruments.some(i => i === instrument)) {
|
||||
this.instrumentDropdown.setValue(instrument)
|
||||
this.instrumentDropdown.setValue(instrument!)
|
||||
}
|
||||
})
|
||||
this.instrumentDropdown.valueChanges.subscribe(() => {
|
||||
if (!this.difficulties.some(d => d === this.difficultyDropdown.value)) {
|
||||
this.difficultyDropdown.setValue(this.defaultDifficulty)
|
||||
this.difficultyDropdown.setValue(this.defaultDifficulty, { emitEvent: false })
|
||||
}
|
||||
})
|
||||
this.difficultyDropdown = new FormControl<Difficulty | null>(this.defaultDifficulty)
|
||||
this.difficultyDropdown = new FormControl<Difficulty>(this.defaultDifficulty, { nonNullable: true })
|
||||
this.searchService.difficulty.valueChanges.subscribe(difficulty => {
|
||||
if (this.difficulties.some(d => d === difficulty)) {
|
||||
this.difficultyDropdown.setValue(difficulty)
|
||||
this.difficultyDropdown.setValue(difficulty!)
|
||||
}
|
||||
})
|
||||
}
|
||||
@@ -91,7 +91,9 @@ export class ChartSidebarComponent implements OnInit {
|
||||
}
|
||||
|
||||
public get extraLengthSeconds() {
|
||||
return _.round((this.selectedChart!.notesData.length - this.selectedChart!.notesData.effectiveLength) / 1000, 1)
|
||||
return this.selectedChart!.song_length ?
|
||||
_.round((this.selectedChart!.song_length - this.selectedChart!.notesData.effectiveLength) / 1000, 1)
|
||||
: _.round(this.selectedChart!.notesData.effectiveLength / 1000, 1)
|
||||
}
|
||||
|
||||
public get hasIssues() {
|
||||
@@ -99,96 +101,68 @@ export class ChartSidebarComponent implements OnInit {
|
||||
}
|
||||
|
||||
public get metadataIssues() {
|
||||
return this.selectedChart!.metadataIssues
|
||||
}
|
||||
public getMetadataIssueText(issue: MetadataIssueType) {
|
||||
switch (issue) {
|
||||
case 'noName': return 'Chart has no name'
|
||||
case 'noArtist': return 'Chart has no artist'
|
||||
case 'noAlbum': return 'Chart has no album'
|
||||
case 'noGenre': return 'Chart has no genre'
|
||||
case 'noYear': return 'Chart has no year'
|
||||
case 'noCharter': return 'Chart has no charter'
|
||||
case 'missingInstrumentDiff': return 'Metadata is missing an instrument intensity rating'
|
||||
case 'extraInstrumentDiff': return 'Metadata contains an instrument intensity rating for an uncharted instrument'
|
||||
case 'nonzeroDelay': return 'Chart uses "delay" for the audio offset'
|
||||
case 'drumsSetTo4And5Lane': return 'It is unclear if the drums chart is intended to be 4-lane or 5-lane'
|
||||
case 'nonzeroOffset': return 'Chart uses "delay" for the audio offset'
|
||||
}
|
||||
return this.selectedChart!.metadataIssues.filter(i => !['extraValue'].includes(i.metadataIssue))
|
||||
}
|
||||
public get folderIssues() {
|
||||
return _.chain(this.selectedChart!.folderIssues)
|
||||
.filter(i => !['albumArtSize', 'invalidIni', 'multipleVideo', 'badIniLine'].includes(i.folderIssue))
|
||||
.map(i => i.folderIssue)
|
||||
.uniq()
|
||||
.uniqBy(i => i.folderIssue)
|
||||
.value()
|
||||
}
|
||||
public getFolderIssueText(folderIssue: FolderIssueType) {
|
||||
switch (folderIssue) {
|
||||
case 'noMetadata': return `Metadata file is missing`
|
||||
case 'invalidMetadata': return `Metadata file is invalid`
|
||||
case 'multipleIniFiles': return `Multiple metadata files`
|
||||
case 'noAlbumArt': return `Album art is missing`
|
||||
case 'badAlbumArt': return `Album art is invalid`
|
||||
case 'multipleAlbumArt': return `Multiple album art files`
|
||||
case 'noAudio': return `Audio file is missing`
|
||||
case 'invalidAudio': return `Audio file is invalid`
|
||||
case 'badAudio': return `Audio file is invalid`
|
||||
case 'multipleAudio': return `Audio file is invalid`
|
||||
case 'noChart': return `Notes file is missing`
|
||||
case 'invalidChart': return `Notes file is invalid`
|
||||
case 'badChart': return `Notes file is invalid`
|
||||
case 'multipleChart': return `Multiple notes files`
|
||||
case 'badVideo': return `Video background won't work on Linux`
|
||||
}
|
||||
}
|
||||
|
||||
public get chartIssues() {
|
||||
return this.selectedChart!.notesData?.chartIssues.filter(i => i !== 'isDefaultBPM')
|
||||
}
|
||||
public getChartIssueText(issue: ChartIssueType) {
|
||||
switch (issue) {
|
||||
case 'noResolution': return 'No resolution in chart file'
|
||||
case 'noSyncTrackSection': return 'No tempo map in chart file'
|
||||
case 'noNotes': return 'No notes in chart file'
|
||||
case 'noExpert': return 'Expert is not charted'
|
||||
case 'misalignedTimeSignatures': return 'Broken time signatures'
|
||||
case 'noSections': return 'No sections'
|
||||
}
|
||||
public get globalChartIssues() {
|
||||
return _.chain(this.selectedChart!.notesData.chartIssues)
|
||||
.filter(i => i.instrument === null)
|
||||
.filter(i => i.noteIssue !== 'isDefaultBPM')
|
||||
.value()
|
||||
}
|
||||
|
||||
public get trackIssuesGroups() {
|
||||
return _.chain([
|
||||
...this.selectedChart!.notesData.trackIssues.map(i => ({ ...i, issues: i.trackIssues })),
|
||||
...this.selectedChart!.notesData.noteIssues.map(i => ({ ...i, issues: i.noteIssues.map(ni => ni.issueType) })),
|
||||
])
|
||||
.sortBy(g => instruments.indexOf(g.instrument), g => difficulties.indexOf(g.difficulty))
|
||||
.groupBy(g => `${_.capitalize(g.instrument)} - ${_.capitalize(g.difficulty)} Issues Found:`)
|
||||
return _.chain(this.selectedChart!.notesData.chartIssues)
|
||||
.filter(g => g.instrument !== null)
|
||||
.sortBy(
|
||||
g => instruments.indexOf(g.instrument!),
|
||||
g => difficulties.indexOf(g.difficulty || '(all difficulties)' as Difficulty),
|
||||
)
|
||||
.groupBy(
|
||||
g => `${_.capitalize(g.instrument!)
|
||||
} - ${_.capitalize(g.difficulty || '(all difficulties)' as Difficulty)} Issues Found:`
|
||||
)
|
||||
.toPairs()
|
||||
.map(([groupName, group]) => ({
|
||||
groupName,
|
||||
issues: _.chain(group)
|
||||
.flatMap(g => g.issues)
|
||||
.filter(i => i !== 'babySustain' && i !== 'noNotesOnNonemptyTrack')
|
||||
.uniq()
|
||||
.filter(
|
||||
i => !['badEndEvent', 'emptyStarPower', 'emptySoloSection', 'emptyFlexLane', 'babySustain'].includes(i.noteIssue)
|
||||
)
|
||||
.groupBy(i => i.noteIssue)
|
||||
.values()
|
||||
.map(issueGroup => this.getTrackIssueText(issueGroup))
|
||||
.value(),
|
||||
}))
|
||||
.filter(g => g.issues.length > 0)
|
||||
.value()
|
||||
}
|
||||
|
||||
public getTrackIssueText(issue: NoteIssueType | TrackIssueType) {
|
||||
switch (issue) {
|
||||
case 'babySustain': return 'Has baby sustains'
|
||||
case 'badSustainGap': return 'Has sustain gaps that are too small'
|
||||
case 'brokenNote': return 'Has broken notes'
|
||||
case 'difficultyForbiddenNote': return 'Has notes not allowed on this difficulty'
|
||||
case 'fiveNoteChord': return 'Has five-note chords'
|
||||
case 'noDrumActivationLanes': return 'Has no activation lanes'
|
||||
case 'has4And5LaneFeatures': return 'Has a mix of 4 and 5 lane features on the drum chart'
|
||||
case 'noStarPower': return 'Has no star power'
|
||||
case 'smallLeadingSilence': return 'Leading silence is too small'
|
||||
case 'threeNoteDrumChord': return 'Has three-note drum chords'
|
||||
private getTrackIssueText(issueGroup: NotesData['chartIssues']) {
|
||||
const one = issueGroup.length === 1
|
||||
const len = issueGroup.length
|
||||
switch (issueGroup[0].noteIssue) {
|
||||
case 'badStarPower':
|
||||
return `There ${one ? 'is' : 'are'} ${len} ignored star power event${one ? '' : 's'
|
||||
} due to the .ini "multiplier_note" setting.`
|
||||
case 'difficultyForbiddenNote':
|
||||
return `There ${one ? 'is' : 'are'} ${len} note${one ? '' : 's'
|
||||
} that ${one ? 'is' : 'are'}n't allowed on this track's difficulty.`
|
||||
case 'invalidChord':
|
||||
return `There ${one ? 'is' : 'are'} ${len} chord${one ? '' : 's'
|
||||
} that ${one ? 'is' : 'are'}n't allowed for this instrument type.`
|
||||
case 'brokenNote':
|
||||
return `There ${one ? 'is' : 'are'} ${len} broken note${one ? '' : 's'} on this track.`
|
||||
case 'badSustainGap':
|
||||
return one ? 'There is 1 note that has a sustain gap that is too small.' : `There are ${len
|
||||
} notes that have sustain gaps that are too small.`
|
||||
default:
|
||||
return issueGroup[0].description
|
||||
}
|
||||
}
|
||||
|
||||
@@ -203,7 +177,7 @@ export class ChartSidebarComponent implements OnInit {
|
||||
showGuitarlikeProperties ? { value: notesData.hasTapNotes, text: 'Tap Notes' } : null,
|
||||
showGuitarlikeProperties ? { value: notesData.hasOpenNotes, text: 'Open Notes' } : null,
|
||||
showDrumlikeProperties ? { value: notesData.has2xKick, text: '2x Kick' } : null,
|
||||
showDrumlikeProperties ? { value: notesData.hasRollLanes, text: 'Roll Lanes' } : null,
|
||||
showDrumlikeProperties ? { value: notesData.hasFlexLanes, text: 'Roll Lanes' } : null,
|
||||
{ value: this.selectedChart!.hasVideoBackground, text: 'Video Background' },
|
||||
])
|
||||
}
|
||||
@@ -267,7 +241,7 @@ export class ChartSidebarComponent implements OnInit {
|
||||
}
|
||||
}
|
||||
|
||||
private currentTrackFilter = (track: { instrument: Instrument; difficulty: Difficulty }) => {
|
||||
private currentTrackFilter = (track: { instrument: Instrument | null; difficulty: Difficulty | null }) => {
|
||||
return track.instrument === this.instrumentDropdown.value && track.difficulty === this.difficultyDropdown.value
|
||||
}
|
||||
public get maximumNps() {
|
||||
|
||||
@@ -11,4 +11,3 @@
|
||||
<td *ngIf="hasColumn('charter')">{{ song[0].charter || 'Various' }}</td>
|
||||
<td *ngIf="hasColumn('length')">{{ songLength }}</td>
|
||||
<td *ngIf="hasColumn('difficulty')">{{ songDifficulty }}</td>
|
||||
<!-- TODO: "Various" will never display -->
|
||||
|
||||
@@ -18,7 +18,7 @@
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-r-none uppercase">
|
||||
@if (instrument) {
|
||||
<img class="w-8 hidden sm:block" src="assets/images/instruments/{{ instrument }}.png" />
|
||||
<img class="w-8 hidden sm:block" src="https://static.enchor.us/instrument-{{ instrument }}.png" />
|
||||
}
|
||||
{{ instrumentDisplay(instrument) }}
|
||||
</label>
|
||||
@@ -29,7 +29,7 @@
|
||||
@for (instrument of instruments; track $index) {
|
||||
<li>
|
||||
<a (click)="setInstrument(instrument, $event)">
|
||||
<img class="w-8" src="assets/images/instruments/{{ instrument }}.png" />
|
||||
<img class="w-8" src="https://static.enchor.us/instrument-{{ instrument }}.png" />
|
||||
{{ instrumentDisplay(instrument) }}
|
||||
</a>
|
||||
</li>
|
||||
@@ -38,7 +38,9 @@
|
||||
</div>
|
||||
<!-- Difficulty Dropdown -->
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none uppercase">{{ difficultyDisplay(difficulty) }}</label>
|
||||
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none uppercase" [class.rounded-r-none]="instrument === 'drums'">{{
|
||||
difficultyDisplay(difficulty)
|
||||
}}</label>
|
||||
<ul tabindex="0" class="menu dropdown-content z-[2] p-2 shadow bg-neutral text-neutral-content rounded-box w-40">
|
||||
<li>
|
||||
<a (click)="setDifficulty(null, $event)">{{ difficultyDisplay(null) }}</a>
|
||||
@@ -50,6 +52,22 @@
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
@if (instrument === 'drums') {
|
||||
<!-- Drum Type Dropdown -->
|
||||
<div class="dropdown">
|
||||
<label tabindex="0" class="btn btn-neutral rounded-btn rounded-l-none uppercase">{{ drumTypeDisplay(drumType) }}</label>
|
||||
<ul tabindex="0" class="menu dropdown-content z-[2] p-2 shadow bg-neutral text-neutral-content rounded-box w-40">
|
||||
<li>
|
||||
<a (click)="setDrumType(null, $event)">{{ drumTypeDisplay(null) }}</a>
|
||||
</li>
|
||||
@for (drumType of drumTypes; track $index) {
|
||||
<li>
|
||||
<a (click)="setDrumType(drumType, $event)">{{ drumTypeDisplay(drumType) }}</a>
|
||||
</li>
|
||||
}
|
||||
</ul>
|
||||
</div>
|
||||
}
|
||||
</div>
|
||||
<div class="flex-1 flex-grow-[3] h-0"></div>
|
||||
<!-- Advanced Search -->
|
||||
@@ -125,88 +143,117 @@
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-end">
|
||||
<table class="table table-xs">
|
||||
<tbody>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Length (minutes)</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minLength" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxLength" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
|
||||
data-tip="Also known as chart difficulty. Typically a number between 0 and 6.">
|
||||
Intensity
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minIntensity" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxIntensity" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Average NPS</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minAverageNPS" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxAverageNPS" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Max NPS</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minMaxNPS" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxMaxNPS" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
|
||||
data-tip="The date of the last time this chart was modified in Google Drive.">
|
||||
Modified After
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="date"
|
||||
min="2012-01-01"
|
||||
[max]="todayDate"
|
||||
placeholder="YYYY/MM/DD"
|
||||
class="input input-bordered join-item input-sm w-32"
|
||||
formControlName="modifiedAfter"
|
||||
(blur)="startValidation = true"
|
||||
[class.input-error]="advancedSearchForm.invalid && startValidation" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
|
||||
data-tip="The MD5 hash of the chart folder or .sng file. You can enter multiple values if they are separated by commas.">
|
||||
Hash
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="input input-bordered join-item input-sm w-32" formControlName="hash" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-col justify-between">
|
||||
<div class="flex gap-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="flex flex-wrap justify-center gap-5">
|
||||
<div class="flex flex-col gap-2 justify-end">
|
||||
<table class="table table-xs">
|
||||
<tbody>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Length (minutes)</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minLength" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxLength" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
|
||||
data-tip="Also known as chart difficulty. Typically a number between 0 and 6.">
|
||||
Intensity
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minIntensity" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxIntensity" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Average NPS</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minAverageNPS" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxAverageNPS" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Max NPS</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minMaxNPS" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxMaxNPS" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
<div class="flex flex-col gap-2 justify-end">
|
||||
<table class="table table-xs">
|
||||
<tbody>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">Year</td>
|
||||
<td>
|
||||
<div class="join">
|
||||
<input type="number" placeholder="Min" class="input input-bordered join-item input-sm w-16" formControlName="minYear" />
|
||||
<input type="number" placeholder="Max" class="input input-bordered join-item input-sm w-16" formControlName="maxYear" />
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
|
||||
data-tip="The date of the last time this chart was modified in Google Drive.">
|
||||
Modified After
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input
|
||||
type="date"
|
||||
min="2012-01-01"
|
||||
[max]="todayDate"
|
||||
placeholder="YYYY/MM/DD"
|
||||
class="input input-bordered join-item input-sm w-32"
|
||||
formControlName="modifiedAfter"
|
||||
(blur)="startValidation = true"
|
||||
[class.input-error]="advancedSearchForm.invalid && startValidation" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text underline decoration-dotted cursor-help tooltip [text-wrap:balance]"
|
||||
data-tip="The MD5 hash of the chart folder or .sng file. You can enter multiple values if they are separated by commas.">
|
||||
Hash
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="input input-bordered join-item input-sm w-32" formControlName="hash" />
|
||||
</td>
|
||||
</tr>
|
||||
<tr class="border-b-0">
|
||||
<td class="text-sm">
|
||||
<span
|
||||
class="label-text tooltip cursor-help underline decoration-dotted [text-wrap:balance]"
|
||||
data-tip="The hash of only things that impact scoring on a specific track. You can enter multiple values if they are separated by commas. (this is used by leaderboards to distinguish charts)">
|
||||
Track Hash
|
||||
</span>
|
||||
</td>
|
||||
<td>
|
||||
<input type="text" class="input join-item input-bordered input-sm w-32" formControlName="trackHash" />
|
||||
</td>
|
||||
</tr>
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-wrap justify-center gap-2">
|
||||
<div class="flex flex-col">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
@@ -247,6 +294,8 @@
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
<input
|
||||
@@ -278,27 +327,6 @@
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
<input
|
||||
#hasRollLanes
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm"
|
||||
[indeterminate]="true"
|
||||
(click)="clickCheckbox('hasRollLanes', $event)" />
|
||||
<span class="label-text" [class.text-opacity-70]="formValue('hasRollLanes') === null">
|
||||
{{ formValue('hasRollLanes') === false ? 'No ' : '' }}Roll Lanes
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
<input #has2xKick type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('has2xKick', $event)" />
|
||||
<span class="label-text" [class.text-opacity-70]="formValue('has2xKick') === null">
|
||||
{{ formValue('has2xKick') === false ? 'No ' : '' }}2x Kick
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
<input type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('hasIssues', $event)" />
|
||||
@@ -324,13 +352,36 @@
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<div class="flex flex-col">
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
<input
|
||||
#hasRollLanes
|
||||
type="checkbox"
|
||||
class="toggle toggle-sm"
|
||||
[indeterminate]="true"
|
||||
(click)="clickCheckbox('hasRollLanes', $event)" />
|
||||
<span class="label-text" [class.text-opacity-70]="formValue('hasRollLanes') === null">
|
||||
{{ formValue('hasRollLanes') === false ? 'No ' : '' }}Roll Lanes
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-control">
|
||||
<label class="label cursor-pointer justify-normal gap-2">
|
||||
<input #has2xKick type="checkbox" class="toggle toggle-sm" [indeterminate]="true" (click)="clickCheckbox('has2xKick', $event)" />
|
||||
<span class="label-text" [class.text-opacity-70]="formValue('has2xKick') === null">
|
||||
{{ formValue('has2xKick') === false ? 'No ' : '' }}2x Kick
|
||||
</span>
|
||||
</label>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary uppercase"
|
||||
[class.btn-disabled]="advancedSearchForm.invalid && startValidation"
|
||||
(click)="searchAdvanced()">
|
||||
Search{{ advancedSearchForm.invalid && startValidation ? ' ("Modified After" is invalid)' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
class="btn btn-sm btn-primary uppercase"
|
||||
[class.btn-disabled]="advancedSearchForm.invalid && startValidation"
|
||||
(click)="searchAdvanced()">
|
||||
Search{{ advancedSearchForm.invalid && startValidation ? ' ("Modified After" is invalid)' : '' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -5,7 +5,7 @@ import dayjs from 'dayjs'
|
||||
import { distinctUntilChanged, switchMap, throttleTime } from 'rxjs'
|
||||
import { Difficulty, Instrument } from 'scan-chart'
|
||||
import { SearchService } from 'src-angular/app/core/services/search.service'
|
||||
import { difficulties, difficultyDisplay, instrumentDisplay, instruments } from 'src-shared/UtilFunctions'
|
||||
import { difficulties, difficultyDisplay, drumTypeDisplay, DrumTypeName, drumTypeNames, instrumentDisplay, instruments } from 'src-shared/UtilFunctions'
|
||||
|
||||
@Component({
|
||||
selector: 'app-search-bar',
|
||||
@@ -25,8 +25,10 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
|
||||
public showAdvanced = false
|
||||
public instruments = instruments
|
||||
public difficulties = difficulties
|
||||
public drumTypes = drumTypeNames
|
||||
public instrumentDisplay = instrumentDisplay
|
||||
public difficultyDisplay = difficultyDisplay
|
||||
public drumTypeDisplay = drumTypeDisplay
|
||||
|
||||
public advancedSearchForm: ReturnType<this['getAdvancedSearchForm']>
|
||||
public startValidation = false
|
||||
@@ -90,6 +92,16 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
|
||||
}
|
||||
}
|
||||
|
||||
get drumType() {
|
||||
return this.searchService.drumType.value
|
||||
}
|
||||
setDrumType(drumType: DrumTypeName | null, event: MouseEvent) {
|
||||
this.searchService.drumType.setValue(drumType)
|
||||
if (event.target instanceof HTMLElement) {
|
||||
event.target.parentElement?.parentElement?.blur()
|
||||
}
|
||||
}
|
||||
|
||||
get todayDate() {
|
||||
return dayjs().format('YYYY-MM-DD')
|
||||
}
|
||||
@@ -164,8 +176,11 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
|
||||
maxAverageNPS: null as number | null,
|
||||
minMaxNPS: null as number | null,
|
||||
maxMaxNPS: null as number | null,
|
||||
minYear: null as number | null,
|
||||
maxYear: null as number | null,
|
||||
modifiedAfter: this.fb.nonNullable.control('', { validators: dateVaidator }),
|
||||
hash: this.fb.nonNullable.control(''),
|
||||
trackHash: this.fb.nonNullable.control(''),
|
||||
hasSoloSections: null as boolean | null,
|
||||
hasForcedNotes: null as boolean | null,
|
||||
hasOpenNotes: null as boolean | null,
|
||||
@@ -208,6 +223,7 @@ export class SearchBarComponent implements OnInit, AfterViewInit {
|
||||
this.searchService.advancedSearch({
|
||||
instrument: this.instrument,
|
||||
difficulty: this.difficulty,
|
||||
drumType: this.drumType,
|
||||
source: 'bridge' as const,
|
||||
...this.advancedSearchForm.getRawValue(),
|
||||
}).subscribe()
|
||||
|
||||
@@ -158,6 +158,12 @@
|
||||
Chart Folder
|
||||
</label>
|
||||
</div>
|
||||
<div class="flex">
|
||||
<label class="label cursor-pointer" for="downloadVideos">
|
||||
<input id="downloadVideos" type="checkbox" checked="checked" class="checkbox mr-1" [formControl]="downloadVideos" />
|
||||
Download Video Backgrounds
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-control">
|
||||
|
||||
@@ -14,6 +14,7 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
public chartFolderName: FormControl<string>
|
||||
public isSng: FormControl<boolean>
|
||||
public downloadVideos: FormControl<boolean>
|
||||
public isCompactTable: FormControl<boolean>
|
||||
|
||||
public artistColumn: FormControl<boolean>
|
||||
@@ -44,6 +45,8 @@ export class SettingsComponent implements OnInit {
|
||||
|
||||
this.isSng = new FormControl<boolean>(ss.isSng, { nonNullable: true })
|
||||
this.isSng.valueChanges.subscribe(value => settingsService.isSng = value)
|
||||
this.downloadVideos = new FormControl<boolean>(ss.downloadVideos, { nonNullable: true })
|
||||
this.downloadVideos.valueChanges.subscribe(value => settingsService.downloadVideos = value)
|
||||
this.isCompactTable = new FormControl<boolean>(settingsService.isCompactTable, { nonNullable: true })
|
||||
this.isCompactTable.valueChanges.subscribe(value => ss.isCompactTable = value)
|
||||
|
||||
@@ -91,10 +94,6 @@ export class SettingsComponent implements OnInit {
|
||||
})
|
||||
}
|
||||
|
||||
async downloadVideos(isChecked: boolean) {
|
||||
this.settingsService.downloadVideos = isChecked
|
||||
}
|
||||
|
||||
async getLibraryDirectory() {
|
||||
const result = await window.electron.invoke.showOpenDialog({
|
||||
title: 'Choose library folder',
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
<div class="navbar p-0 min-h-0 bg-base-100" style="-webkit-app-region: drag">
|
||||
<div style="-webkit-app-region: no-drag">
|
||||
<button class="btn btn-ghost rounded-none" routerLinkActive="btn-active" routerLink="/browse">Browse</button>
|
||||
<!-- TODO <a class="btn btn-ghost rounded-none" routerLinkActive="btn-active" routerLink="/library">Library</a> -->
|
||||
<button class="btn btn-ghost rounded-none flex flex-nowrap" routerLinkActive="btn-active" routerLink="/settings">
|
||||
<i *ngIf="updateAvailable === 'error'" class="bi bi-exclamation-triangle-fill text-warning"></i>
|
||||
Settings
|
||||
|
||||
@@ -85,7 +85,7 @@ export class DownloadService {
|
||||
type: 'good',
|
||||
isPath: false,
|
||||
})
|
||||
window.electron.emit.download({ action: 'add', md5: chart.md5, chart: newChart })
|
||||
window.electron.emit.download({ action: 'add', md5: chart.md5, hasVideoBackground: chart.hasVideoBackground, chart: newChart })
|
||||
}
|
||||
this.downloadCountChanges.emit(this.downloadCount)
|
||||
}
|
||||
|
||||
@@ -7,6 +7,7 @@ import { catchError, mergeMap, tap, throwError, timer } from 'rxjs'
|
||||
import { Difficulty, Instrument } from 'scan-chart'
|
||||
import { environment } from 'src-angular/environments/environment'
|
||||
import { AdvancedSearch, ChartData, SearchResult } from 'src-shared/interfaces/search.interface'
|
||||
import { DrumTypeName } from 'src-shared/UtilFunctions'
|
||||
|
||||
const resultsPerPage = 25
|
||||
|
||||
@@ -29,6 +30,7 @@ export class SearchService {
|
||||
public searchControl = new FormControl('', { nonNullable: true })
|
||||
public instrument: FormControl<Instrument | null>
|
||||
public difficulty: FormControl<Difficulty | null>
|
||||
public drumType: FormControl<DrumTypeName | null>
|
||||
|
||||
constructor(
|
||||
private http: HttpClient,
|
||||
@@ -53,6 +55,16 @@ export class SearchService {
|
||||
}
|
||||
})
|
||||
|
||||
this.drumType = new FormControl<DrumTypeName>(
|
||||
(localStorage.getItem('drumType') === 'null' ? null : localStorage.getItem('drumType')) as DrumTypeName
|
||||
)
|
||||
this.drumType.valueChanges.subscribe(drumType => {
|
||||
localStorage.setItem('drumType', `${drumType}`)
|
||||
if (this.songsResponse.page) {
|
||||
this.search(this.searchControl.value || '*').subscribe()
|
||||
}
|
||||
})
|
||||
|
||||
this.http.get<{ "name": string; "sha1": string }[]>('https://clonehero.gitlab.io/sources/icons.json').subscribe(result => {
|
||||
this.availableIcons = result.map(r => r.name)
|
||||
})
|
||||
@@ -86,6 +98,7 @@ export class SearchService {
|
||||
page: this.currentPage,
|
||||
instrument: this.instrument.value,
|
||||
difficulty: this.difficulty.value,
|
||||
drumType: this.drumType.value,
|
||||
source: 'bridge',
|
||||
}).pipe(
|
||||
catchError((err, caught) => {
|
||||
|
||||
@@ -153,6 +153,14 @@ export class SettingsService {
|
||||
this.zoomFactor = _.round(this.zoomFactor - 0.1, 3)
|
||||
}
|
||||
}
|
||||
|
||||
get volume() {
|
||||
return this.settings.volume
|
||||
}
|
||||
set volume(value: number) {
|
||||
this.settings.volume = value
|
||||
this.saveSettings()
|
||||
}
|
||||
}
|
||||
|
||||
function setThemeColors(themeColors: ThemeColors) {
|
||||
|
||||
|
Before Width: | Height: | Size: 4.2 KiB |
|
Before Width: | Height: | Size: 5.4 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 6.0 KiB |
|
Before Width: | Height: | Size: 7.1 KiB |
|
Before Width: | Height: | Size: 5.1 KiB |
|
Before Width: | Height: | Size: 2.5 KiB |
|
Before Width: | Height: | Size: 4.4 KiB |
|
Before Width: | Height: | Size: 6.9 KiB |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 389 KiB |
@@ -5,6 +5,7 @@
|
||||
export const environment = {
|
||||
production: false,
|
||||
apiUrl: 'https://api.enchor.us',
|
||||
// apiUrl: 'http://localhost:3000',
|
||||
}
|
||||
|
||||
/*
|
||||
|
||||
@@ -5,7 +5,7 @@ const downloadQueue: DownloadQueue = new DownloadQueue()
|
||||
|
||||
export async function download(data: Download) {
|
||||
switch (data.action) {
|
||||
case 'add': downloadQueue.add(data.md5, data.chart!); break
|
||||
case 'add': downloadQueue.add(data.md5, data.hasVideoBackground!, data.chart!); break
|
||||
case 'retry': downloadQueue.retry(data.md5); break
|
||||
case 'remove': downloadQueue.remove(data.md5); break
|
||||
}
|
||||
|
||||
@@ -60,6 +60,7 @@ export class ChartDownload {
|
||||
|
||||
private chartFolderPath: string
|
||||
private isSng: boolean
|
||||
private downloadVideos: boolean
|
||||
|
||||
private showProgress = _.throttle((description: string, percent: number | null = null) => {
|
||||
this.eventEmitter.emit('progress', { header: description, body: '' }, percent)
|
||||
@@ -67,6 +68,7 @@ export class ChartDownload {
|
||||
|
||||
constructor(
|
||||
public readonly md5: string,
|
||||
public readonly hasVideoBackground: boolean,
|
||||
private chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string },
|
||||
) { }
|
||||
|
||||
@@ -128,6 +130,7 @@ export class ChartDownload {
|
||||
}
|
||||
|
||||
this.isSng = settings.isSng
|
||||
this.downloadVideos = settings.downloadVideos
|
||||
this.chartFolderPath = resolveChartFolderName(settings.chartFolderName, this.chart) + (this.isSng ? '.sng' : '')
|
||||
this.showProgress('Checking for any duplicate charts...')
|
||||
const destinationPath = join(settings.libraryPath, this.chartFolderPath)
|
||||
@@ -141,7 +144,8 @@ export class ChartDownload {
|
||||
}
|
||||
|
||||
private async downloadChart() {
|
||||
const { response, abortController } = await getDownloadStream(this.md5)
|
||||
const downloadNovideoVersion = this.hasVideoBackground && !this.downloadVideos
|
||||
const { response, abortController } = await getDownloadStream(this.md5, downloadNovideoVersion)
|
||||
const fileSize = BigInt(response.headers['content-length']!)
|
||||
|
||||
if (this.isSng) {
|
||||
@@ -241,10 +245,10 @@ export class ChartDownload {
|
||||
}
|
||||
}
|
||||
|
||||
function getDownloadStream(md5: string): Promise<{ response: IncomingMessage; abortController: AbortController }> {
|
||||
function getDownloadStream(md5: string, downloadNovideoVersion: boolean): Promise<{ response: IncomingMessage; abortController: AbortController }> {
|
||||
const abortController = new AbortController()
|
||||
return new Promise((resolve, reject) => {
|
||||
const request = https.get(`https://files.enchor.us/${md5}.sng`, {
|
||||
const request = https.get(`https://files.enchor.us/${md5 + (downloadNovideoVersion ? '_novideo' : '')}.sng`, {
|
||||
agent: new https.Agent({ timeout: 30000 }),
|
||||
headers: {
|
||||
'mode': 'cors',
|
||||
|
||||
@@ -16,9 +16,13 @@ export class DownloadQueue {
|
||||
return false
|
||||
}
|
||||
|
||||
add(md5: string, chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string }) {
|
||||
add(
|
||||
md5: string,
|
||||
hasVideoBackground: boolean,
|
||||
chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string },
|
||||
) {
|
||||
if (!this.isChartInQueue(md5)) {
|
||||
const chartDownload = new ChartDownload(md5, chart)
|
||||
const chartDownload = new ChartDownload(md5, hasVideoBackground, chart)
|
||||
this.downloadQueue.push(chartDownload)
|
||||
|
||||
chartDownload.on('progress', (message, percent) => emitIpcEvent('downloadQueueUpdate', {
|
||||
|
||||
@@ -35,6 +35,7 @@ export interface Settings {
|
||||
zoomFactor: number // How much the display should be zoomed
|
||||
instrument: Instrument | null // The instrument selected by default, or `null` for "Any Instrument"
|
||||
difficulty: Difficulty | null // The difficulty selected by default, or `null` for "Any Difficulty"
|
||||
volume: number // The volume of the chart preview (0-100)
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -53,4 +54,5 @@ export const defaultSettings: Settings = {
|
||||
zoomFactor: 1,
|
||||
instrument: 'guitar',
|
||||
difficulty: null,
|
||||
volume: 50,
|
||||
}
|
||||
|
||||
@@ -61,6 +61,8 @@ export const instruments = [
|
||||
'guitar', 'guitarcoop', 'rhythm', 'bass', 'drums', 'keys', 'guitarghl', 'guitarcoopghl', 'rhythmghl', 'bassghl',
|
||||
] as const satisfies Readonly<Instrument[]>
|
||||
export const difficulties = ['expert', 'hard', 'medium', 'easy'] as const satisfies Readonly<Difficulty[]>
|
||||
export const drumTypeNames = ['fourLane', 'fourLanePro', 'fiveLane'] as const
|
||||
export type DrumTypeName = typeof drumTypeNames[number]
|
||||
|
||||
export function instrumentDisplay(instrument: Instrument | null) {
|
||||
switch (instrument) {
|
||||
@@ -101,6 +103,14 @@ export function difficultyDisplay(difficulty: Difficulty | null) {
|
||||
case null: return 'Any Difficulty'
|
||||
}
|
||||
}
|
||||
export function drumTypeDisplay(drumType: DrumTypeName | null) {
|
||||
switch (drumType) {
|
||||
case 'fourLane': return 'Four Lane'
|
||||
case 'fourLanePro': return 'Four Lane Pro'
|
||||
case 'fiveLane': return 'Five Lane'
|
||||
case null: return 'Any Drum Type'
|
||||
}
|
||||
}
|
||||
export function instrumentToDiff(instrument: Instrument | 'vocals') {
|
||||
switch (instrument) {
|
||||
case 'guitar': return 'diff_guitar'
|
||||
@@ -149,27 +159,93 @@ export function removeStyleTags(text: string) {
|
||||
}
|
||||
|
||||
export function hasIssues(chart: Pick<ChartData, 'metadataIssues' | 'folderIssues' | 'notesData'>) {
|
||||
if (chart.metadataIssues.length > 0) { return true }
|
||||
for (const folderIssue of chart.folderIssues) {
|
||||
if (!['albumArtSize', 'invalidIni', 'multipleVideo', 'badIniLine'].includes(folderIssue.folderIssue)) { return true }
|
||||
}
|
||||
for (const chartIssue of chart.notesData?.chartIssues ?? []) {
|
||||
if (chartIssue !== 'isDefaultBPM') { return true }
|
||||
}
|
||||
for (const trackIssue of chart.notesData?.trackIssues ?? []) {
|
||||
for (const ti of trackIssue.trackIssues) {
|
||||
if (ti !== 'noNotesOnNonemptyTrack') { return true }
|
||||
for (const metadataIssue of chart.metadataIssues) {
|
||||
if (!['extraValue'].includes(metadataIssue.metadataIssue)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (const noteIssue of chart.notesData?.noteIssues ?? []) {
|
||||
for (const ni of noteIssue.noteIssues) {
|
||||
if (ni.issueType !== 'babySustain') { return true }
|
||||
for (const folderIssue of chart.folderIssues) {
|
||||
if (!['albumArtSize', 'invalidIni', 'multipleVideo', 'badIniLine'].includes(folderIssue.folderIssue)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
for (const chartIssue of chart.notesData?.chartIssues ?? []) {
|
||||
if (!['isDefaultBPM', 'badEndEvent', 'emptyStarPower', 'emptySoloSection', 'emptyFlexLane', 'babySustain']
|
||||
.includes(chartIssue.noteIssue)) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
||||
return false
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns extension of a file, excluding the dot. (e.g. "song.ogg" -> "ogg")
|
||||
*/
|
||||
export function getExtension(fileName: string) {
|
||||
return _.last(fileName.split('.')) ?? ''
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns basename of a file, excluding the dot. (e.g. "song.ogg" -> "song")
|
||||
*/
|
||||
export function getBasename(fileName: string) {
|
||||
const parts = fileName.split('.')
|
||||
return parts.length > 1 ? parts.slice(0, -1).join('.') : fileName
|
||||
}
|
||||
/**
|
||||
* @returns `true` if `fileName` is a valid video fileName.
|
||||
*/
|
||||
export function hasVideoName(fileName: string) {
|
||||
return getBasename(fileName) === 'video' && ['mp4', 'avi', 'webm', 'vp8', 'ogv', 'mpeg'].includes(getExtension(fileName))
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `true` if `fileName` has a valid chart file extension.
|
||||
*/
|
||||
export function hasChartExtension(fileName: string) {
|
||||
return ['chart', 'mid'].includes(getExtension(fileName).toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `true` if `fileName` is a valid chart fileName.
|
||||
*/
|
||||
export function hasChartName(fileName: string) {
|
||||
return ['notes.chart', 'notes.mid'].includes(fileName)
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `true` if `fileName` has a valid chart audio file extension.
|
||||
*/
|
||||
export function hasAudioExtension(fileName: string) {
|
||||
return ['ogg', 'mp3', 'wav', 'opus'].includes(getExtension(fileName).toLowerCase())
|
||||
}
|
||||
|
||||
/**
|
||||
* @returns `true` if `fileName` has a valid chart audio fileName.
|
||||
*/
|
||||
export function hasAudioName(fileName: string) {
|
||||
return (
|
||||
[
|
||||
'song',
|
||||
'guitar',
|
||||
'bass',
|
||||
'rhythm',
|
||||
'keys',
|
||||
'vocals',
|
||||
'vocals_1',
|
||||
'vocals_2',
|
||||
'drums',
|
||||
'drums_1',
|
||||
'drums_2',
|
||||
'drums_3',
|
||||
'drums_4',
|
||||
'crowd',
|
||||
'preview',
|
||||
].includes(getBasename(fileName)) && ['ogg', 'mp3', 'wav', 'opus'].includes(getExtension(fileName))
|
||||
)
|
||||
}
|
||||
|
||||
export function resolveChartFolderName(
|
||||
chartFolderName: string,
|
||||
chart: { name: string; artist: string; album: string; genre: string; year: string; charter: string },
|
||||
|
||||
@@ -6,6 +6,7 @@ export interface Download {
|
||||
action: 'add' | 'remove' | 'retry'
|
||||
md5: string
|
||||
// Should be defined if action === 'add'
|
||||
hasVideoBackground?: boolean
|
||||
chart?: { name: string; artist: string; album: string; genre: string; year: string; charter: string }
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { EventType, FolderIssueType, Instrument, MetadataIssueType, NotesData } from 'scan-chart'
|
||||
import { FolderIssueType, Instrument, NotesData, ScannedChart } from 'scan-chart'
|
||||
import { z } from 'zod'
|
||||
|
||||
import { difficulties, instruments, Overwrite } from '../UtilFunctions'
|
||||
import { difficulties, drumTypeNames, instruments } from '../UtilFunctions.js'
|
||||
|
||||
export const sources = ['website', 'bridge'] as const
|
||||
|
||||
@@ -10,6 +10,7 @@ export const GeneralSearchSchema = z.object({
|
||||
page: z.number().positive(),
|
||||
instrument: z.enum(instruments).nullable(),
|
||||
difficulty: z.enum(difficulties).nullable(),
|
||||
drumType: z.enum(drumTypeNames).nullable(),
|
||||
source: z.enum(sources).optional(),
|
||||
})
|
||||
export type GeneralSearch = z.infer<typeof GeneralSearchSchema>
|
||||
@@ -25,6 +26,7 @@ export const AdvancedSearchSchema = z.object({
|
||||
return true
|
||||
}, { message: 'Invalid instrument list' }).nullable(),
|
||||
difficulty: z.enum(difficulties).nullable(),
|
||||
drumType: z.enum(drumTypeNames).nullable(),
|
||||
source: z.enum(sources).optional(),
|
||||
name: z.object({ value: z.string(), exact: z.boolean(), exclude: z.boolean() }),
|
||||
artist: z.object({ value: z.string(), exact: z.boolean(), exclude: z.boolean() }),
|
||||
@@ -99,14 +101,6 @@ export const ReportSchema = z.object({
|
||||
})
|
||||
export type Report = z.infer<typeof ReportSchema>
|
||||
|
||||
export type NoteStringNotesData = Overwrite<NotesData, {
|
||||
maxNps: {
|
||||
notes: {
|
||||
type: keyof typeof EventType
|
||||
}[]
|
||||
}[]
|
||||
}>
|
||||
|
||||
export interface FolderIssue {
|
||||
folderIssue: FolderIssueType
|
||||
description: string
|
||||
@@ -147,8 +141,11 @@ export interface SearchResult {
|
||||
albumArtMd5: string | null
|
||||
/** The MD5 hash of the chart folder or .sng file. */
|
||||
md5: string
|
||||
/** The MD5 hash of just the .chart or .mid file. */
|
||||
chartMd5: string
|
||||
/**
|
||||
* A blake3 hash of just the chart file and the .ini modifiers that impact chart parsing.
|
||||
* If this changes, the in-game score is reset.
|
||||
*/
|
||||
chartHash: string
|
||||
/**
|
||||
* Different versions of the same chart have the same `versionGroupId`.
|
||||
* All charts in a version group have this set to the smallest `id` in the group.
|
||||
@@ -206,6 +203,17 @@ export interface SearchResult {
|
||||
eighthnote_hopo: boolean | null
|
||||
/** Overrides the .mid note number for Star Power on 5-Fret Guitar. Valid values are 103 and 116. Only applies to .mid charts. */
|
||||
multiplier_note: number | null
|
||||
/**
|
||||
* For .mid charts, setting this causes any sustains not larger than the threshold (in number of ticks) to be reduced to length 0.
|
||||
* By default, this happens to .mid sustains shorter than 1/12 step.
|
||||
*/
|
||||
sustain_cutoff_threshold?: number
|
||||
/**
|
||||
* Notes at or closer than this threshold (in number of ticks) will be merged into a chord.
|
||||
* All note and modifier ticks are set to the tick of the earliest merged note.
|
||||
* All note sustains are set to the length of the shortest merged note.
|
||||
*/
|
||||
chord_snap_threshold?: number
|
||||
/**
|
||||
* The amount of time that should be skipped from the beginning of the video background in milliseconds.
|
||||
* A negative value will delay the start of the video by that many milliseconds.
|
||||
@@ -218,11 +226,11 @@ export interface SearchResult {
|
||||
/** `true` if the chart's end events should be used to end the chart early. Only applies to .mid charts. */
|
||||
end_events: boolean | null
|
||||
/** Data describing properties of the .chart or .mid file. `undefined` if the .chart or .mid file couldn't be parsed. */
|
||||
notesData: NoteStringNotesData
|
||||
notesData: NotesData
|
||||
/** Issues with the chart files. */
|
||||
folderIssues: FolderIssue[]
|
||||
folderIssues: ScannedChart['folderIssues']
|
||||
/** Issues with the chart's metadata. */
|
||||
metadataIssues: MetadataIssueType[]
|
||||
metadataIssues: ScannedChart['metadataIssues']
|
||||
/** `true` if the chart has a video background. */
|
||||
hasVideoBackground: boolean
|
||||
/** The date of the last time this chart was modified in Google Drive. */
|
||||
@@ -249,16 +257,14 @@ export interface SearchResult {
|
||||
/** If there is more than one chart contained inside this `DriveChart`. */
|
||||
driveChartIsPack: boolean
|
||||
/**
|
||||
* A string containing the relative path from the `DriveChart`'s archive to the chart inside the archive.
|
||||
* A string containing the relative path from the driveChart's root to the chart inside it.
|
||||
*
|
||||
* Doesn't contain the archive name, the chart file name (for file charts), or leading/trailing slashes.
|
||||
* This starts with the archive name if the driveChart is an archive.
|
||||
*
|
||||
* An empty string if the `DriveChart` is not an archive.
|
||||
* This ends with the name of the .sng file if this is an .sng file.
|
||||
*
|
||||
* This is an empty string if the driveChart is not an archive or an .sng file.
|
||||
*/
|
||||
archivePath: string
|
||||
/**
|
||||
* The name of the .sng file. `null` if the chart is not a .sng file.
|
||||
*/
|
||||
chartFileName: string | null
|
||||
internalPath: string
|
||||
}[]
|
||||
}
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { z } from 'zod'
|
||||
|
||||
import { difficulties, instruments } from './UtilFunctions'
|
||||
import { difficulties, drumTypeNames, instruments } from './UtilFunctions.js'
|
||||
|
||||
export const GeneralSearchSchema = z.object({
|
||||
search: z.string(),
|
||||
@@ -8,14 +8,17 @@ export const GeneralSearchSchema = z.object({
|
||||
page: z.number().positive(),
|
||||
instrument: z.enum(instruments).nullable(),
|
||||
difficulty: z.enum(difficulties).nullable(),
|
||||
drumType: z.enum(drumTypeNames).nullable(),
|
||||
})
|
||||
export type GeneralSearch = z.infer<typeof GeneralSearchSchema>
|
||||
|
||||
const md5Validator = z.string().regex(/^[a-f0-9]{32}$/, 'Invalid MD5 hash')
|
||||
const blakeValidator = z.string().regex(/^[A-Za-z0-9-_]+={0,2}$/, 'Invalid hash')
|
||||
|
||||
export const AdvancedSearchSchema = z.object({
|
||||
instrument: z.enum(instruments).nullable(),
|
||||
difficulty: z.enum(difficulties).nullable(),
|
||||
drumType: z.enum(drumTypeNames).nullable(),
|
||||
name: z.object({ value: z.string(), exact: z.boolean(), exclude: z.boolean() }),
|
||||
artist: z.object({ value: z.string(), exact: z.boolean(), exclude: z.boolean() }),
|
||||
album: z.object({ value: z.string(), exact: z.boolean(), exclude: z.boolean() }),
|
||||
@@ -30,6 +33,8 @@ export const AdvancedSearchSchema = z.object({
|
||||
maxAverageNPS: z.number().nullable(),
|
||||
minMaxNPS: z.number().nullable(),
|
||||
maxMaxNPS: z.number().nullable(),
|
||||
minYear: z.number().nullable(),
|
||||
maxYear: z.number().nullable(),
|
||||
modifiedAfter: z.string().regex(/^\d+-\d{2}-\d{2}$/, 'Invalid date').or(z.coerce.date()).or(z.literal('')).nullable(),
|
||||
hash: z.string().transform(data =>
|
||||
data === '' || data.split(',').every(hash => md5Validator.safeParse(hash).success) ? data : 'invalid'
|
||||
@@ -37,6 +42,9 @@ export const AdvancedSearchSchema = z.object({
|
||||
chartHash: z.string().transform(data =>
|
||||
data === '' || data.split(',').every(hash => md5Validator.safeParse(hash).success) ? data : 'invalid'
|
||||
).nullable().optional(),
|
||||
trackHash: z.string().transform(data =>
|
||||
data === '' || data.split(',').every(hash => blakeValidator.safeParse(hash).success) ? data : 'invalid'
|
||||
).nullable().optional(),
|
||||
hasSoloSections: z.boolean().nullable(),
|
||||
hasForcedNotes: z.boolean().nullable(),
|
||||
hasOpenNotes: z.boolean().nullable(),
|
||||
@@ -70,6 +78,8 @@ export const advancedSearchNumberProperties = [
|
||||
'maxAverageNPS',
|
||||
'minMaxNPS',
|
||||
'maxMaxNPS',
|
||||
'minYear',
|
||||
'maxYear',
|
||||
] as const
|
||||
|
||||
export const advancedSearchBooleanProperties = [
|
||||
|
||||