Merge pull request #49 from cybercase/asivery_work_on_groups_and_full_width_titles

Asivery's work on groups and full width titles
This commit is contained in:
Stefano Brilli 2021-07-20 10:06:15 +02:00 committed by GitHub
commit 793422878d
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
16 changed files with 1320 additions and 258 deletions

202
package-lock.json generated
View File

@ -25,11 +25,13 @@
"@types/w3c-web-usb": "^1.0.4",
"clsx": "^1.1.1",
"husky": "^4.3.8",
"jconv": "^0.1.5",
"lint-staged": "^10.5.3",
"music-metadata-browser": "^2.2.0",
"netmd-js": "^1.4.0",
"netmd-js": "^2.0.1",
"prettier": "^1.19.1",
"react": "^16.14.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^16.14.0",
"react-dropzone": "^10.2.2",
"react-redux": "^7.2.2",
@ -43,6 +45,7 @@
},
"devDependencies": {
"@types/dom-mediacapture-record": "^1.0.7",
"@types/react-beautiful-dnd": "^13.1.1",
"async-mutex": "^0.2.6",
"gh-pages": "^2.2.0"
}
@ -1033,11 +1036,6 @@
"regenerator-runtime": "^0.13.4"
}
},
"node_modules/@babel/runtime-corejs3/node_modules/regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"node_modules/@babel/template": {
"version": "7.8.3",
"resolved": "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz",
@ -1837,11 +1835,6 @@
"node": ">= 8.3"
}
},
"node_modules/@testing-library/dom/node_modules/regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"node_modules/@testing-library/dom/node_modules/supports-color": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
@ -2057,6 +2050,15 @@
"csstype": "^3.0.2"
}
},
"node_modules/@types/react-beautiful-dnd": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz",
"integrity": "sha512-1lBBxVSutE8CQM37Jq7KvJwuA94qaEEqsx+G0dnwzG6Sfwf6JGcNeFk5jjjhJli1q2naeMZm+D/dvT/zyX4QPw==",
"dev": true,
"dependencies": {
"@types/react": "*"
}
},
"node_modules/@types/react-dom": {
"version": "16.9.10",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.10.tgz",
@ -4669,6 +4671,14 @@
"node": ">=6.0.0"
}
},
"node_modules/css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"dependencies": {
"tiny-invariant": "^1.0.6"
}
},
"node_modules/css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@ -5391,11 +5401,6 @@
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw=="
},
"node_modules/dom-helpers/node_modules/regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"node_modules/dom-serializer": {
"version": "0.2.2",
"resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-0.2.2.tgz",
@ -11384,6 +11389,11 @@
"node": ">=6"
}
},
"node_modules/memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"node_modules/memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
@ -11987,9 +11997,9 @@
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw=="
},
"node_modules/netmd-js": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/netmd-js/-/netmd-js-1.4.1.tgz",
"integrity": "sha512-8OXEO4Gh6Me3XU2igHmVbk+hn06O3N5xlM/c38w97rNQDRpCAppO8jqq+fryaZd7QZwmIxXqwwY88KIehoieAg==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/netmd-js/-/netmd-js-2.0.1.tgz",
"integrity": "sha512-9rMt+tcpqYM1/IyudyzVeOzZ+E55/+RvHf6NNOzQJbb0knySG5oaLich2NXSsFT45Wrg+c43uLMu3iOKmjW4aA==",
"dependencies": {
"browser-bunyan": "^1.5.3",
"buffer": "^5.5.0",
@ -14528,6 +14538,11 @@
"performance-now": "^2.1.0"
}
},
"node_modules/raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
},
"node_modules/randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -14626,6 +14641,35 @@
"node": ">=6"
}
},
"node_modules/react-beautiful-dnd": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
"integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==",
"dependencies": {
"@babel/runtime": "^7.9.2",
"css-box-model": "^1.2.0",
"memoize-one": "^5.1.1",
"raf-schd": "^4.0.2",
"react-redux": "^7.2.0",
"redux": "^4.0.4",
"use-memo-one": "^1.1.1"
},
"peerDependencies": {
"react": "^16.8.5 || ^17.0.0",
"react-dom": "^16.8.5 || ^17.0.0"
}
},
"node_modules/react-beautiful-dnd/node_modules/@babel/runtime": {
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz",
"integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==",
"dependencies": {
"regenerator-runtime": "^0.13.4"
},
"engines": {
"node": ">=6.9.0"
}
},
"node_modules/react-dev-utils": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.1.0.tgz",
@ -14901,11 +14945,6 @@
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"node_modules/react-redux/node_modules/regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"node_modules/react-scripts": {
"version": "3.3.1",
"resolved": "https://registry.npmjs.org/react-scripts/-/react-scripts-3.3.1.tgz",
@ -15231,9 +15270,9 @@
}
},
"node_modules/regenerator-runtime": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"node_modules/regenerator-transform": {
"version": "0.14.1",
@ -17310,6 +17349,11 @@
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
},
"node_modules/tiny-invariant": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",
"integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw=="
},
"node_modules/tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@ -17771,6 +17815,14 @@
"node": ">=0.10.0"
}
},
"node_modules/use-memo-one": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz",
"integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==",
"peerDependencies": {
"react": "^16.8.0 || ^17.0.0"
}
},
"node_modules/util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",
@ -20049,13 +20101,6 @@
"requires": {
"core-js-pure": "^3.0.0",
"regenerator-runtime": "^0.13.4"
},
"dependencies": {
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
"@babel/template": {
@ -20726,11 +20771,6 @@
"react-is": "^16.12.0"
}
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"supports-color": {
"version": "7.1.0",
"resolved": "https://registry.npmjs.org/supports-color/-/supports-color-7.1.0.tgz",
@ -20947,6 +20987,15 @@
}
}
},
"@types/react-beautiful-dnd": {
"version": "13.1.1",
"resolved": "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz",
"integrity": "sha512-1lBBxVSutE8CQM37Jq7KvJwuA94qaEEqsx+G0dnwzG6Sfwf6JGcNeFk5jjjhJli1q2naeMZm+D/dvT/zyX4QPw==",
"dev": true,
"requires": {
"@types/react": "*"
}
},
"@types/react-dom": {
"version": "16.9.10",
"resolved": "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.10.tgz",
@ -23196,6 +23245,14 @@
"postcss": "^7.0.5"
}
},
"css-box-model": {
"version": "1.2.1",
"resolved": "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz",
"integrity": "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==",
"requires": {
"tiny-invariant": "^1.0.6"
}
},
"css-color-keywords": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz",
@ -23784,11 +23841,6 @@
"version": "3.0.6",
"resolved": "https://registry.npmjs.org/csstype/-/csstype-3.0.6.tgz",
"integrity": "sha512-+ZAmfyWMT7TiIlzdqJgjMb7S4f1beorDbWbsocyK4RaiqA5RTX3K14bnBWmmA9QEM0gRdsjyyrEmcyga8Zsxmw=="
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
@ -28615,6 +28667,11 @@
"p-is-promise": "^2.0.0"
}
},
"memoize-one": {
"version": "5.2.1",
"resolved": "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz",
"integrity": "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
},
"memory-fs": {
"version": "0.4.1",
"resolved": "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz",
@ -29118,9 +29175,9 @@
"integrity": "sha512-iyam8fBuCUpWeKPGpaNMetEocMt364qkCsfL9JuhjXX6dRnguRVOfk2GZaDpPjcOKiiXCPINZC1GczQ7iTq3Zw=="
},
"netmd-js": {
"version": "1.4.1",
"resolved": "https://registry.npmjs.org/netmd-js/-/netmd-js-1.4.1.tgz",
"integrity": "sha512-8OXEO4Gh6Me3XU2igHmVbk+hn06O3N5xlM/c38w97rNQDRpCAppO8jqq+fryaZd7QZwmIxXqwwY88KIehoieAg==",
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/netmd-js/-/netmd-js-2.0.1.tgz",
"integrity": "sha512-9rMt+tcpqYM1/IyudyzVeOzZ+E55/+RvHf6NNOzQJbb0knySG5oaLich2NXSsFT45Wrg+c43uLMu3iOKmjW4aA==",
"requires": {
"browser-bunyan": "^1.5.3",
"buffer": "^5.5.0",
@ -31214,6 +31271,11 @@
"performance-now": "^2.1.0"
}
},
"raf-schd": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz",
"integrity": "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
},
"randombytes": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/randombytes/-/randombytes-2.1.0.tgz",
@ -31295,6 +31357,30 @@
"whatwg-fetch": "^3.0.0"
}
},
"react-beautiful-dnd": {
"version": "13.1.0",
"resolved": "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz",
"integrity": "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA==",
"requires": {
"@babel/runtime": "^7.9.2",
"css-box-model": "^1.2.0",
"memoize-one": "^5.1.1",
"raf-schd": "^4.0.2",
"react-redux": "^7.2.0",
"redux": "^4.0.4",
"use-memo-one": "^1.1.1"
},
"dependencies": {
"@babel/runtime": {
"version": "7.14.6",
"resolved": "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz",
"integrity": "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg==",
"requires": {
"regenerator-runtime": "^0.13.4"
}
}
}
},
"react-dev-utils": {
"version": "10.1.0",
"resolved": "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.1.0.tgz",
@ -31502,11 +31588,6 @@
"version": "16.13.1",
"resolved": "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz",
"integrity": "sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ=="
},
"regenerator-runtime": {
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
}
}
},
@ -31775,9 +31856,9 @@
}
},
"regenerator-runtime": {
"version": "0.13.3",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz",
"integrity": "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
"version": "0.13.7",
"resolved": "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz",
"integrity": "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
},
"regenerator-transform": {
"version": "0.14.1",
@ -33511,6 +33592,11 @@
"resolved": "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz",
"integrity": "sha1-QFQRqOfmM5/mTbmiNN4R3DHgK9Q="
},
"tiny-invariant": {
"version": "1.1.0",
"resolved": "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz",
"integrity": "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw=="
},
"tiny-warning": {
"version": "1.0.3",
"resolved": "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz",
@ -33884,6 +33970,12 @@
"resolved": "https://registry.npmjs.org/use/-/use-3.1.1.tgz",
"integrity": "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
},
"use-memo-one": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz",
"integrity": "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ==",
"requires": {}
},
"util": {
"version": "0.10.3",
"resolved": "https://registry.npmjs.org/util/-/util-0.10.3.tgz",

View File

@ -21,11 +21,13 @@
"@types/w3c-web-usb": "^1.0.4",
"clsx": "^1.1.1",
"husky": "^4.3.8",
"jconv": "^0.1.5",
"lint-staged": "^10.5.3",
"music-metadata-browser": "^2.2.0",
"netmd-js": "^1.4.0",
"netmd-js": "^2.0.1",
"prettier": "^1.19.1",
"react": "^16.14.0",
"react-beautiful-dnd": "^13.1.0",
"react-dom": "^16.14.0",
"react-dropzone": "^10.2.2",
"react-redux": "^7.2.2",
@ -72,6 +74,7 @@
},
"devDependencies": {
"@types/dom-mediacapture-record": "^1.0.7",
"@types/react-beautiful-dnd": "^13.1.1",
"async-mutex": "^0.2.6",
"gh-pages": "^2.2.0"
}

View File

@ -142,9 +142,10 @@ export const Controls = () => {
} else if (tracks.length === 0) {
message = `BLANKDISC`;
} else if (deviceStatus && deviceStatus.track !== null && tracks[deviceStatus.track]) {
let title = tracks[deviceStatus.track].fullWidthTitle || tracks[deviceStatus.track].title;
message =
(deviceStatus.track + 1).toString().padStart(3, '0') +
(tracks[deviceStatus.track].title ? ' - ' + tracks[deviceStatus.track].title : '');
(title ? ' - ' + title : '');
}
const [lcdScroll, setLcdScroll] = useState(0);

View File

@ -1,18 +1,37 @@
import React, { useEffect, useCallback } from 'react';
import React, { useEffect, useCallback, useState } from 'react';
import { useDispatch } from 'react-redux';
import clsx from 'clsx';
import { useDropzone } from 'react-dropzone';
import { listContent, deleteTracks, moveTrack } from '../redux/actions';
import {
DragDropContext,
Draggable,
DraggableProvided,
DropResult,
ResponderProvided,
Droppable,
DroppableProvided,
DroppableStateSnapshot,
} from 'react-beautiful-dnd';
import { listContent, deleteTracks, moveTrack, groupTracks, deleteGroup, dragDropTrack } from '../redux/actions';
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
import { actions as convertDialogActions } from '../redux/convert-dialog-feature';
import { actions as dumpDialogActions } from '../redux/dump-dialog-feature';
import { formatTimeFromFrames, getTracks } from 'netmd-js';
import { formatTimeFromFrames, Track } from 'netmd-js';
import { control } from '../redux/actions';
import { belowDesktop, forAnyDesktop, getSortedTracks, useShallowEqualSelector } from '../utils';
import {
belowDesktop,
forAnyDesktop,
getGroupedTracks,
getSortedTracks,
isSequential,
useShallowEqualSelector,
EncodingName,
} from '../utils';
import { lighten, makeStyles } from '@material-ui/core/styles';
import { fade } from '@material-ui/core/styles/colorManipulator';
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';
import Fab from '@material-ui/core/Fab';
@ -22,6 +41,8 @@ import EditIcon from '@material-ui/icons/Edit';
import Backdrop from '@material-ui/core/Backdrop';
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
import PauseIcon from '@material-ui/icons/Pause';
import FolderIcon from '@material-ui/icons/Folder';
import CreateNewFolderIcon from '@material-ui/icons/CreateNewFolder';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
@ -46,9 +67,8 @@ import { TopMenu } from './topmenu';
import Checkbox from '@material-ui/core/Checkbox';
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
import Button from '@material-ui/core/Button';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import { W95Main } from './win95/main';
import { useMemo } from 'react';
const useStyles = makeStyles(theme => ({
add: {
@ -126,7 +146,7 @@ const useStyles = makeStyles(theme => ({
indexCell: {
whiteSpace: 'nowrap',
paddingRight: 0,
width: `2ch`,
width: `16px`,
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
@ -138,7 +158,9 @@ const useStyles = makeStyles(theme => ({
},
controlButtonInTrackCommon: {
width: `16px`,
height: `16px`,
verticalAlign: 'middle',
cursor: 'pointer',
marginLeft: theme.spacing(-0.5),
},
playButtonInTrackListPlaying: {
@ -153,15 +175,17 @@ const useStyles = makeStyles(theme => ({
display: 'inline-block',
},
playButtonInTrackListNotPlaying: {
visibility: 'hidden',
width: `0px`,
},
trackRow: {
userSelect: 'none',
'&:hover': {
/* For the tracks that aren't currently playing */
'& $playButtonInTrackListNotPlaying': {
visibility: 'visible',
width: `16px`,
},
'& $trackIndex': {
width: `0px`,
display: 'none',
},
@ -174,8 +198,34 @@ const useStyles = makeStyles(theme => ({
},
},
},
inGroupTrackRow: {
'& > $indexCell': {
transform: `translateX(${theme.spacing(3)}px)`,
},
'& > $titleCell': {
transform: `translateX(${theme.spacing(3)}px)`,
},
},
deleteGroupButton: {
display: 'none',
},
groupHeadRow: {
'&:hover': {
'& svg:not($deleteGroupButton)': {
display: 'none',
},
'& $deleteGroupButton': {
display: 'inline-block',
},
},
},
hoveringOverGroup: {
backgroundColor: `${fade(theme.palette.secondary.dark, 0.4)}`,
},
trackIndex: {
display: 'inline-block',
height: '16px',
width: '16px',
},
}));
@ -187,6 +237,7 @@ export const Main = (props: {}) => {
const { vintageMode } = useShallowEqualSelector(state => state.appState);
const [selected, setSelected] = React.useState<number[]>([]);
const [lastClicked, setLastClicked] = useState(-1);
const selectedCount = selected.length;
const [moveMenuAnchorEl, setMoveMenuAnchorEl] = React.useState<null | HTMLElement>(null);
@ -207,6 +258,18 @@ export const Main = (props: {}) => {
[dispatch, selected, handleCloseMoveMenu]
);
const handleDrop = useCallback(
(result: DropResult, provided: ResponderProvided) => {
if (!result.destination) return;
let sourceList = parseInt(result.source.droppableId),
sourceIndex = result.source.index,
targetList = parseInt(result.destination.droppableId),
targetIndex = result.destination.index;
dispatch(dragDropTrack(sourceList, sourceIndex, targetList, targetIndex));
},
[dispatch]
);
const handleShowDumpDialog = useCallback(() => {
dispatch(dumpDialogActions.setVisible(true));
}, [dispatch]);
@ -234,15 +297,28 @@ export const Main = (props: {}) => {
});
const classes = useStyles();
const tracks = getSortedTracks(disc);
const tracks = useMemo(() => getSortedTracks(disc), [disc]);
const groupedTracks = useMemo(() => getGroupedTracks(disc), [disc]);
// Action Handlers
const handleSelectClick = (event: React.MouseEvent, item: number) => {
if (selected.includes(item)) {
if (event.shiftKey && selected.length && lastClicked !== -1) {
let rangeBegin = Math.min(lastClicked + 1, item),
rangeEnd = Math.max(lastClicked - 1, item);
let copy = [...selected];
for (let i = rangeBegin; i <= rangeEnd; i++) {
let index = copy.indexOf(i);
if (index === -1) copy.push(i);
else copy.splice(index, 1);
}
if (!copy.includes(item)) copy.push(item);
setSelected(copy);
} else if (selected.includes(item)) {
setSelected(selected.filter(i => i !== item));
} else {
setSelected([...selected, item]);
}
setLastClicked(item);
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
@ -253,20 +329,31 @@ export const Main = (props: {}) => {
}
};
const handleRenameDoubleClick = (event: React.MouseEvent, item: number) => {
let selectedIndex = item;
let currentName = getTracks(disc!).find(track => track.index === selectedIndex)?.title ?? '';
const handleRenameDoubleClick = (event: React.MouseEvent, index: number, renameGroup?: boolean) => {
let group, track;
if (renameGroup) {
group = groupedTracks.find(n => n.tracks[0]?.index === index);
if (!group) return;
} else {
track = tracks.find(n => n.index === index);
if (!track) return;
}
let currentName = (track ? track.title : group?.title) ?? '';
let currentFullWidthName = (track ? track.fullWidthTitle : group?.fullWidthTitle) ?? '';
dispatch(
batchActions([
renameDialogActions.setVisible(true),
renameDialogActions.setGroupIndex(group ? index : null),
renameDialogActions.setCurrentName(currentName),
renameDialogActions.setIndex(selectedIndex),
renameDialogActions.setCurrentFullWidthName(currentFullWidthName),
renameDialogActions.setIndex(track?.index ?? -1),
])
);
};
const handleRenameActionClick = (event: React.MouseEvent) => {
if(event.detail !== 1) return; //Event retriggering when hitting enter in the dialog
handleRenameDoubleClick(event, selected[0]);
};
@ -274,6 +361,14 @@ export const Main = (props: {}) => {
dispatch(deleteTracks(selected));
};
const handleGroupTracks = (event: React.MouseEvent) => {
dispatch(groupTracks(selected));
};
const handleGroupRemoval = (event: React.MouseEvent, groupBegin: number) => {
dispatch(deleteGroup(groupBegin));
};
const handlePlayTrack = async (event: React.MouseEvent, track: number) => {
if (deviceStatus?.track !== track) {
dispatch(control('goto', track));
@ -289,6 +384,13 @@ export const Main = (props: {}) => {
} else dispatch(control('play'));
};
const canGroup = useMemo(() => {
return (
tracks.filter(n => n.group === null && selected.includes(n.index)).length === selected.length &&
isSequential(selected.sort((a, b) => a - b))
);
}, [tracks, selected]);
if (vintageMode) {
const p = {
disc,
@ -324,6 +426,64 @@ export const Main = (props: {}) => {
return <W95Main {...p} />;
}
const getTrackRow = (track: Track, inGroup: boolean, additional: {}) => {
const child = (
<TableRow
{...additional}
hover
selected={selected.includes(track.index)}
onDoubleClick={event => handleRenameDoubleClick(event, track.index)}
onClick={event => handleSelectClick(event, track.index)}
className={clsx(classes.trackRow, { [classes.inGroupTrackRow]: inGroup })}
>
<TableCell className={classes.indexCell}>
{track.index === deviceStatus?.track && ['playing', 'paused'].includes(deviceStatus?.state) ? (
<span>
<PlayArrowIcon
className={clsx(classes.controlButtonInTrackCommon, classes.playButtonInTrackListPlaying, {
[classes.currentControlButton]: deviceStatus?.state === 'playing',
})}
onClick={event => {
handleCurrentClick(event);
event.stopPropagation();
}}
/>
<PauseIcon
className={clsx(classes.controlButtonInTrackCommon, classes.pauseButtonInTrackListPlaying, {
[classes.currentControlButton]: deviceStatus?.state === 'paused',
})}
onClick={event => {
handleCurrentClick(event);
event.stopPropagation();
}}
/>
</span>
) : (
<span>
<span className={classes.trackIndex}>{track.index + 1}</span>
<PlayArrowIcon
className={clsx(classes.controlButtonInTrackCommon, classes.playButtonInTrackListNotPlaying)}
onClick={event => {
handlePlayTrack(event, track.index);
event.stopPropagation();
}}
/>
</span>
)}
</TableCell>
<TableCell className={classes.titleCell} title={track.title ?? ''}>
{track.fullWidthTitle ? `${track.fullWidthTitle} / ` : ``}
{track.title || `No Title`}
</TableCell>
<TableCell align="right" className={classes.durationCell}>
<span className={classes.formatBadge}>{EncodingName[track.encoding]}</span>
<span className={classes.durationCellTime}>{formatTimeFromFrames(track.duration, false)}</span>
</TableCell>
</TableRow>
);
return child;
};
return (
<React.Fragment>
<Box className={classes.headBox}>
@ -372,40 +532,10 @@ export const Main = (props: {}) => {
</Typography>
) : (
<Typography component="h3" variant="h6" className={classes.toolbarLabel}>
{disc?.fullWidthTitle && `${disc.fullWidthTitle} / `}
{disc?.title || `Untitled Disc`}
</Typography>
)}
{selectedCount === 1 ? (
<React.Fragment>
<Tooltip title="Move to Position">
<Button aria-controls="move-menu" aria-label="Move" onClick={handleShowMoveMenu}>
Move
</Button>
</Tooltip>
<Menu
id="move-menu"
anchorEl={moveMenuAnchorEl}
open={!!moveMenuAnchorEl}
onClose={handleCloseMoveMenu}
PaperProps={{
style: {
maxHeight: 300,
},
}}
>
{Array(tracks.length)
.fill(null)
.map((_, i) => {
return (
<MenuItem key={`pos-${i}`} onClick={() => handleMoveSelectedTrack(i)}>
{i + 1}
</MenuItem>
);
})}
</Menu>
</React.Fragment>
) : null}
{selectedCount > 0 ? (
<React.Fragment>
<Tooltip title="Record from MD">
@ -424,6 +554,14 @@ export const Main = (props: {}) => {
</Tooltip>
) : null}
{selectedCount > 0 ? (
<Tooltip title={canGroup ? 'Group' : ''}>
<IconButton aria-label="group" disabled={!canGroup} onClick={handleGroupTracks}>
<CreateNewFolderIcon />
</IconButton>
</Tooltip>
) : null}
{selectedCount > 0 ? (
<Tooltip title="Rename">
<IconButton aria-label="rename" disabled={selectedCount !== 1} onClick={handleRenameActionClick}>
@ -432,7 +570,7 @@ export const Main = (props: {}) => {
</Tooltip>
) : null}
</Toolbar>
<Box className={classes.main} {...getRootProps()}>
<Box className={classes.main} {...getRootProps()} id="main">
<input {...getInputProps()} />
<Table size="small">
<TableHead>
@ -442,64 +580,82 @@ export const Main = (props: {}) => {
<TableCell align="right">Duration</TableCell>
</TableRow>
</TableHead>
<DragDropContext onDragEnd={handleDrop}>
<TableBody>
{tracks.map(track => (
{groupedTracks.map((group, index) => (
<TableRow key={`${index}`}>
<TableCell colSpan={3} style={{ padding: '0' }}>
<Table size="small">
<Droppable droppableId={`${index}`} key={`${index}`}>
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
<TableBody
{...provided.droppableProps}
ref={provided.innerRef}
className={clsx({ [classes.hoveringOverGroup]: snapshot.isDraggingOver })}
>
{group.title !== null && (
<TableRow
hover
selected={selected.includes(track.index)}
key={track.index}
onDoubleClick={event => handleRenameDoubleClick(event, track.index)}
onClick={event => handleSelectClick(event, track.index)}
className={classes.trackRow}
className={classes.groupHeadRow}
key={`${index}-head`}
onDoubleClick={event =>
handleRenameDoubleClick(event, group.tracks[0].index, true)
}
>
<TableCell className={classes.indexCell}>
{track.index === deviceStatus?.track && ['playing', 'paused'].includes(deviceStatus?.state) ? (
<span>
<PlayArrowIcon
className={clsx(classes.controlButtonInTrackCommon, classes.playButtonInTrackListPlaying, {
[classes.currentControlButton]: deviceStatus?.state === 'playing',
})}
onClick={event => {
handleCurrentClick(event);
event.stopPropagation();
}}
/>
<PauseIcon
className={clsx(classes.controlButtonInTrackCommon, classes.pauseButtonInTrackListPlaying, {
[classes.currentControlButton]: deviceStatus?.state === 'paused',
})}
onClick={event => {
handleCurrentClick(event);
event.stopPropagation();
}}
/>
</span>
) : (
<span>
<span className={classes.trackIndex}>{track.index + 1}</span>
<PlayArrowIcon
<FolderIcon className={classes.controlButtonInTrackCommon} />
<DeleteIcon
className={clsx(
classes.controlButtonInTrackCommon,
classes.playButtonInTrackListNotPlaying
classes.deleteGroupButton
)}
onClick={event => {
handlePlayTrack(event, track.index);
event.stopPropagation();
handleGroupRemoval(event, group.tracks[0].index);
}}
/>
</span>
)}
</TableCell>
<TableCell className={classes.titleCell} title={track.title}>
{track.title || `No Title`}
<TableCell className={classes.titleCell} title={group.title!}>
{group.fullWidthTitle ? `${group.fullWidthTitle} / ` : ``}
{group.title || `No Name`}
</TableCell>
<TableCell align="right" className={classes.durationCell}>
<span className={classes.formatBadge}>{track.encoding}</span>
<span className={classes.durationCellTime}>{track.duration}</span>
<span className={classes.durationCellTime}>
{formatTimeFromFrames(
group.tracks.map(n => n.duration).reduce((a, b) => a + b),
false
)}
</span>
</TableCell>
</TableRow>
)}
{group.title === null && group.tracks.length === 0 && (
<TableRow style={{ height: '1px' }} />
)}
{group.tracks.map(n => (
<Draggable
draggableId={`${group.index}-${n.index}`}
key={`${n.index - group.tracks[0].index}`}
index={n.index - group.tracks[0].index}
>
{(providedInGroup: DraggableProvided) =>
getTrackRow(n, group.title !== null, {
ref: providedInGroup.innerRef,
...providedInGroup.draggableProps,
...providedInGroup.dragHandleProps,
})
}
</Draggable>
))}
{provided.placeholder}
</TableBody>
)}
</Droppable>
</Table>
</TableCell>
</TableRow>
))}
</TableBody>
</DragDropContext>
</Table>
<Backdrop className={classes.backdrop} open={isDragActive}>
Drop your Music to Upload

View File

@ -2,8 +2,9 @@ import React, { useCallback } from 'react';
import { useDispatch } from 'react-redux';
import { useShallowEqualSelector } from '../utils';
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
import { renameTrack, renameDisc } from '../redux/actions';
import { renameTrack, renameDisc, renameGroup } from '../redux/actions';
import { makeStyles } from '@material-ui/core/styles';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
@ -21,25 +22,57 @@ const Transition = React.forwardRef(function Transition(
return <Slide direction="up" ref={ref} {...props} />;
});
const useStyles = makeStyles(theme => ({
marginUpDown: {
marginTop: theme.spacing(3),
marginBottom: theme.spacing(3),
},
}));
export const RenameDialog = (props: {}) => {
let dispatch = useDispatch();
let classes = useStyles();
let renameDialogVisible = useShallowEqualSelector(state => state.renameDialog.visible);
let renameDialogTitle = useShallowEqualSelector(state => state.renameDialog.title);
let renameDialogFullWidthTitle = useShallowEqualSelector(state => state.renameDialog.fullWidthTitle);
let renameDialogIndex = useShallowEqualSelector(state => state.renameDialog.index);
let renameDialogGroupIndex = useShallowEqualSelector(state => state.renameDialog.groupIndex);
let allowFullWidth = useShallowEqualSelector(state => state.appState.fullWidthSupport);
const what = renameDialogIndex < 0 ? `Disc` : `Track`;
const what = renameDialogGroupIndex !== null ? `Group` : renameDialogIndex < 0 ? `Disc` : `Track`;
const handleCancelRename = () => {
dispatch(renameDialogActions.setVisible(false));
};
const handleDoRename = () => {
if (renameDialogIndex < 0) {
dispatch(renameDisc({ newName: renameDialogTitle }));
if (renameDialogGroupIndex !== null) {
// Just rename the group with this range
dispatch(
renameGroup({
newName: renameDialogTitle,
newFullWidthName: renameDialogFullWidthTitle,
groupIndex: renameDialogGroupIndex,
})
);
} else if (renameDialogIndex < 0) {
dispatch(
renameDisc({
newName: renameDialogTitle,
newFullWidthName: renameDialogFullWidthTitle,
})
);
} else {
dispatch(renameTrack({ index: renameDialogIndex, newName: renameDialogTitle }));
dispatch(
renameTrack({
index: renameDialogIndex,
newName: renameDialogTitle,
newFullWidthName: renameDialogFullWidthTitle,
})
);
}
handleCancelRename(); // Close the dialog
};
const handleChange = useCallback(
@ -49,6 +82,13 @@ export const RenameDialog = (props: {}) => {
[dispatch]
);
const handleFullWidthChange = useCallback(
(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
dispatch(renameDialogActions.setCurrentFullWidthName(event.target.value.substring(0, 105)));
},
[dispatch]
);
const { vintageMode } = useShallowEqualSelector(state => state.appState);
if (vintageMode) {
const p = {
@ -86,6 +126,20 @@ export const RenameDialog = (props: {}) => {
}}
onChange={handleChange}
/>
{allowFullWidth && (
<TextField
id="fullWidthTitle"
label={`Full-Width ${what} Name`}
type="text"
fullWidth
className={classes.marginUpDown}
value={renameDialogFullWidthTitle}
onKeyDown={event => {
event.key === `Enter` && handleDoRename();
}}
onChange={handleFullWidthChange}
/>
)}
</DialogContent>
<DialogActions>
<Button onClick={handleCancelRename}>Cancel</Button>

View File

@ -5,6 +5,7 @@ import { batchActions } from 'redux-batched-actions';
import IconButton from '@material-ui/core/IconButton';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import Divider from '@material-ui/core/Divider';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import { wipeDisc, listContent } from '../redux/actions';
@ -14,6 +15,7 @@ import { useShallowEqualSelector } from '../utils';
import Link from '@material-ui/core/Link';
import ListItemIcon from '@material-ui/core/ListItemIcon';
import ListItemText from '@material-ui/core/ListItemText';
import Tooltip from '@material-ui/core/Tooltip';
import { makeStyles } from '@material-ui/core/styles';
import RefreshIcon from '@material-ui/icons/Refresh';
@ -33,14 +35,19 @@ const useStyles = makeStyles(theme => ({
listItemIcon: {
minWidth: theme.spacing(5),
},
toolTippedText: {
textDecoration: 'underline',
textDecorationStyle: 'dotted',
},
}));
export const TopMenu = function(props: { onClick?: () => void }) {
const classes = useStyles();
const dispatch = useDispatch();
let { mainView, darkMode, vintageMode } = useShallowEqualSelector(state => state.appState);
let { mainView, darkMode, vintageMode, fullWidthSupport } = useShallowEqualSelector(state => state.appState);
let discTitle = useShallowEqualSelector(state => state.main.disc?.title ?? ``);
let fullWidthDiscTitle = useShallowEqualSelector(state => state.main.disc?.fullWidthTitle ?? ``);
const githubLinkRef = React.useRef<null | HTMLAnchorElement>(null);
const helpLinkRef = React.useRef<null | HTMLAnchorElement>(null);
@ -71,6 +78,10 @@ export const TopMenu = function(props: { onClick?: () => void }) {
handleMenuClose();
}, [dispatch, handleMenuClose]);
const handleAllowFullWidth = useCallback(() => {
dispatch(appActions.setFullWidthSupport(!fullWidthSupport));
}, [dispatch, fullWidthSupport]);
const handleRefresh = useCallback(() => {
dispatch(listContent());
handleMenuClose();
@ -81,11 +92,13 @@ export const TopMenu = function(props: { onClick?: () => void }) {
batchActions([
renameDialogActions.setVisible(true),
renameDialogActions.setCurrentName(discTitle),
renameDialogActions.setGroupIndex(null),
renameDialogActions.setCurrentFullWidthName(fullWidthDiscTitle),
renameDialogActions.setIndex(-1),
])
);
handleMenuClose();
}, [dispatch, handleMenuClose, discTitle]);
}, [dispatch, handleMenuClose, discTitle, fullWidthDiscTitle]);
const handleExit = useCallback(() => {
dispatch(appActions.setMainView('WELCOME'));
@ -156,6 +169,25 @@ export const TopMenu = function(props: { onClick?: () => void }) {
</MenuItem>
);
}
if (mainView === 'MAIN') {
menuItems.push(<Divider />);
menuItems.push(
<MenuItem key="allowFullWidth" onClick={handleAllowFullWidth}>
<ListItemIcon className={classes.listItemIcon}>
{fullWidthSupport ? <ToggleOnIcon fontSize="small" /> : <ToggleOffIcon fontSize="small" />}
</ListItemIcon>
<ListItemText>
{fullWidthSupport ? `Disable ` : `Enable `}
<Tooltip
title="This advanced feature enables the use of Hiragana and Kanji alphabets. More about this in Support and FAQ."
arrow
>
<span className={classes.toolTippedText}>Full-Width Title Editing</span>
</Tooltip>
</ListItemText>
</MenuItem>
);
}
menuItems.push(
<MenuItem key="darkMode" onClick={handleDarkMode}>
<ListItemIcon className={classes.listItemIcon}>
@ -175,6 +207,9 @@ export const TopMenu = function(props: { onClick?: () => void }) {
</MenuItem>
);
}
if (mainView === 'MAIN') {
menuItems.push(<Divider />);
}
menuItems.push(
<MenuItem key="about" onClick={handleShowAbout}>
<ListItemIcon className={classes.listItemIcon}>

View File

@ -86,7 +86,8 @@ export const W95Main = (props: {
tracks: {
index: number;
title: string;
group: string;
fullWidthTitle: string;
group: string | null;
duration: string;
encoding: string;
}[];
@ -122,6 +123,7 @@ export const W95Main = (props: {
<img alt="device" src={DeviceIconUrl} style={{ marginTop: -10, marginLeft: 10 }} />
<div className={classes.toolbarItem}>
{`${props.deviceName}: (` || `Loading...`}
{props.disc?.fullWidthTitle && `${props.disc?.fullWidthTitle} / `}
{props.disc?.title || `Untitled Disc`}
{`)`}
</div>
@ -202,7 +204,7 @@ export const W95Main = (props: {
>
<TableDataCell style={{ textAlign: 'center', width: '2ch' }}>{track.index + 1}</TableDataCell>
<TableDataCell style={{ width: '80%' }}>
<div>{track.title || `No Title`}</div>
<div>{track.fullWidthTitle && `${track.fullWidthTitle} / `}{track.title || `No Title`}</div>
</TableDataCell>
<TableDataCell style={{ textAlign: 'right', width: '20%' }}>
<span>{track.encoding}</span>

View File

@ -22,7 +22,7 @@ export const W95RenameDialog = (props: {
<span>Rename {props.what}</span>
</WindowHeader>
<WindowContent>
<p style={{ marginBottom: 4 }}>Track Name:</p>
<p style={{ marginBottom: 4 }}>{props.what} Name:</p>
<TextField
style={{ marginBottom: 16 }}
value={props.renameDialogTitle}

View File

@ -9,7 +9,15 @@ import { actions as mainActions } from './main-feature';
import serviceRegistry from '../services/registry';
import { Wireformat, getTracks } from 'netmd-js';
import { AnyAction } from '@reduxjs/toolkit';
import { getAvailableCharsForTrackTitle, framesToSec, sleepWithProgressCallback, sleep, askNotificationPermission } from '../utils';
import {
getAvailableCharsForTitle,
framesToSec,
sleepWithProgressCallback,
sleep,
askNotificationPermission,
getGroupedTracks,
getHalfWidthTitleLength,
} from '../utils';
import * as mm from 'music-metadata-browser';
import { TitleFormatType, UploadFormat } from './convert-dialog-feature';
import NotificationCompleteIconUrl from '../images/record-complete-notification-icon.png';
@ -50,6 +58,128 @@ export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pa
};
}
export function renameGroup({ groupIndex, newName, newFullWidthName }: { groupIndex: number; newName: string; newFullWidthName?: string }) {
return async function(dispatch: AppDispatch, getState: () => RootState) {
await serviceRegistry!.netmdService?.renameGroup(groupIndex, newName, newFullWidthName);
listContent()(dispatch);
};
}
export function groupTracks(indexes: number[]) {
return async function(dispatch: AppDispatch) {
let begin = indexes[0];
let length = indexes[indexes.length - 1] - begin + 1;
const { netmdService } = serviceRegistry;
netmdService!.addGroup(begin, length, '');
listContent()(dispatch);
};
}
export function deleteGroup(groupBegin: number) {
return async function(dispatch: AppDispatch) {
const { netmdService } = serviceRegistry;
netmdService!.deleteGroup(groupBegin);
listContent()(dispatch);
};
}
export function dragDropTrack(sourceList: number, sourceIndex: number, targetList: number, targetIndex: number) {
// This code is here, because it would need to be duplicated in both netmd and netmd-mock.
return async function(dispatch: AppDispatch, getState: () => RootState) {
if (sourceList === targetList && sourceIndex === targetIndex) return;
dispatch(appStateActions.setLoading(true));
const groupedTracks = getGroupedTracks(await serviceRegistry.netmdService!.listContent());
// Remove the moved item from its current list
let movedItem = groupedTracks[sourceList].tracks.splice(sourceIndex, 1)[0];
let newIndex: number;
// Calculate bounds
let boundsStartList, boundsEndList, boundsStartIndex, boundsEndIndex, offset;
if (sourceList < targetList) {
boundsStartList = sourceList;
boundsStartIndex = sourceIndex;
boundsEndList = targetList;
boundsEndIndex = targetIndex;
offset = -1;
} else if (sourceList > targetList) {
boundsStartList = targetList;
boundsStartIndex = targetIndex;
boundsEndList = sourceList;
boundsEndIndex = sourceIndex;
offset = 1;
} else {
if (sourceIndex < targetIndex) {
boundsStartList = boundsEndList = sourceList;
boundsStartIndex = sourceIndex;
boundsEndIndex = targetIndex;
offset = -1;
} else {
boundsStartList = boundsEndList = targetList;
boundsStartIndex = targetIndex;
boundsEndIndex = sourceIndex;
offset = 1;
}
}
// Shift indices
for (let i = boundsStartList; i <= boundsEndList; i++) {
let startingIndex = i === boundsStartList ? boundsStartIndex : 0;
let endingIndex = i === boundsEndList ? boundsEndIndex : groupedTracks[i].tracks.length;
for (let j = startingIndex; j < endingIndex; j++) {
groupedTracks[i].tracks[j].index += offset;
}
}
// Calculate the moved track's destination index
if (targetList === 0) {
newIndex = targetIndex;
} else {
if (targetIndex === 0) {
let prevList = groupedTracks[targetList - 1];
let i = 2;
while (prevList && prevList.tracks.length === 0) {
// Skip past all the empty lists
prevList = groupedTracks[targetList - i++];
}
if (prevList) {
// If there's a previous list, make this tracks's index previous list's last item's index + 1
let lastIndexOfPrevList = prevList.tracks[prevList.tracks.length - 1].index;
newIndex = lastIndexOfPrevList + 1;
} else newIndex = 0; // Else default to index 0
} else {
newIndex = groupedTracks[targetList].tracks[0].index + targetIndex;
}
}
if (movedItem.index !== newIndex) {
await serviceRegistry!.netmdService!.moveTrack(movedItem.index, newIndex, false);
}
movedItem.index = newIndex;
groupedTracks[targetList].tracks.splice(targetIndex, 0, movedItem);
let ungrouped = [];
// Recompile the groups and update them on the player
let normalGroups = [];
for (let group of groupedTracks) {
if (group.tracks.length === 0) continue;
if (group.index === -1) ungrouped.push(...group.tracks);
else normalGroups.push(group);
}
if (ungrouped.length)
normalGroups.unshift({
index: 0,
title: null,
fullWidthTitle: null,
tracks: ungrouped,
});
await serviceRegistry.netmdService!.rewriteGroups(normalGroups);
listContent()(dispatch);
};
}
export function pair() {
return async function(dispatch: AppDispatch, getState: () => RootState) {
dispatch(appStateActions.setPairingFailed(false));
@ -86,7 +216,15 @@ export function listContent() {
return async function(dispatch: AppDispatch) {
// Issue loading
dispatch(appStateActions.setLoading(true));
let disc = await serviceRegistry.netmdService!.listContent();
let disc;
try {
disc = await serviceRegistry.netmdService!.listContent();
} catch (err) {
if (window.confirm("This disc's title seems to be corrupted, do you wish to erase it?\nNone of the tracks will be deleted.")) {
await serviceRegistry.netmdService!.wipeDiscTitleInfo();
disc = await serviceRegistry.netmdService!.listContent();
} else throw err;
}
let deviceName = await serviceRegistry.netmdService!.getDeviceName();
let deviceStatus = null;
try {
@ -105,12 +243,12 @@ export function listContent() {
};
}
export function renameTrack({ index, newName }: { index: number; newName: string }) {
export function renameTrack({ index, newName, newFullWidthName }: { index: number; newName: string; newFullWidthName?: string }) {
return async function(dispatch: AppDispatch) {
const { netmdService } = serviceRegistry;
dispatch(renameDialogActions.setVisible(false));
try {
await netmdService!.renameTrack(index, newName);
await netmdService!.renameTrack(index, newName, newFullWidthName);
} catch (err) {
console.error(err);
dispatch(batchActions([errorDialogAction.setVisible(true), errorDialogAction.setErrorMessage(`Rename failed.`)]));
@ -119,10 +257,13 @@ export function renameTrack({ index, newName }: { index: number; newName: string
};
}
export function renameDisc({ newName }: { newName: string }) {
export function renameDisc({ newName, newFullWidthName }: { newName: string; newFullWidthName?: string }) {
return async function(dispatch: AppDispatch) {
const { netmdService } = serviceRegistry;
await netmdService!.renameDisc(newName);
await netmdService!.renameDisc(
newName.replace(/\/\//g, ' /'), // Make sure the title doesn't interfere with the groups
newFullWidthName?.replace(//g, '')
);
dispatch(renameDialogActions.setVisible(false));
listContent()(dispatch);
};
@ -138,11 +279,7 @@ export function deleteTracks(indexes: number[]) {
}
const { netmdService } = serviceRegistry;
dispatch(appStateActions.setLoading(true));
indexes = indexes.sort();
indexes.reverse();
for (let index of indexes) {
await netmdService!.deleteTrack(index);
}
await netmdService!.deleteTracks(indexes);
listContent()(dispatch);
};
}
@ -387,8 +524,8 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
};
let disc = getState().main.disc;
let maxTitleLength = disc ? getAvailableCharsForTrackTitle(getTracks(disc).map(track => track.title || ``)) : -1;
maxTitleLength = Math.floor(maxTitleLength / files.length);
let useFullWidth = getState().appState.fullWidthSupport;
let availableCharacters = getAvailableCharsForTitle(disc!);
let error: any;
let errorMessage = ``;
@ -407,16 +544,22 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
console.error(err);
}
if (maxTitleLength > -1) {
title = title.substring(0, maxTitleLength);
const fixLength = (l: number) => Math.ceil(l / 7) * 7;
let halfWidthTitle = title.substr(0, Math.min(getHalfWidthTitleLength(title), availableCharacters));
availableCharacters -= fixLength(getHalfWidthTitleLength(halfWidthTitle));
let fullWidthTitle = '';
if (useFullWidth) {
fullWidthTitle = title.substr(0, Math.min(title.length * 2, availableCharacters, 210 /* limit is 105 */) / 2);
availableCharacters -= fixLength(fullWidthTitle.length * 2);
}
trackUpdate.current = i++;
trackUpdate.titleCurrent = title;
trackUpdate.titleCurrent = halfWidthTitle;
updateTrack();
updateProgressCallback({ written: 0, encrypted: 0, total: 100 });
try {
await netmdService?.upload(title, data, wireformat, updateProgressCallback);
await netmdService?.upload(halfWidthTitle, fullWidthTitle, data, wireformat, updateProgressCallback);
} catch (err) {
error = err;
errorMessage = `${file.name}: Error uploading to device. There might not be enough space left.`;

View File

@ -15,6 +15,7 @@ export interface AppState {
aboutDialogVisible: boolean;
notifyWhenFinished: boolean;
hasNotificationSupport: boolean;
fullWidthSupport: boolean;
}
export const buildInitialState = (): AppState => {
@ -29,6 +30,7 @@ export const buildInitialState = (): AppState => {
aboutDialogVisible: false,
notifyWhenFinished: loadPreference('notifyWhenFinished', false),
hasNotificationSupport: true,
fullWidthSupport: loadPreference('fullWidthSupport', false),
};
};
@ -72,6 +74,10 @@ export const slice = createSlice({
showAboutDialog: (state, action: PayloadAction<boolean>) => {
state.aboutDialogVisible = action.payload;
},
setFullWidthSupport: (state, action: PayloadAction<boolean>) => {
state.fullWidthSupport = action.payload;
savePreference('fullWidthSupport', state.fullWidthSupport);
},
},
});

View File

@ -4,13 +4,17 @@ import { enableBatching } from 'redux-batched-actions';
export interface RenameDialogState {
visible: boolean;
title: string;
fullWidthTitle: string;
index: number;
groupIndex: number | null;
}
const initialState: RenameDialogState = {
visible: false,
title: '',
fullWidthTitle: '',
index: -1,
groupIndex: null,
};
export const slice = createSlice({
@ -23,9 +27,15 @@ export const slice = createSlice({
setCurrentName: (state: RenameDialogState, action: PayloadAction<string>) => {
state.title = action.payload;
},
setCurrentFullWidthName: (state: RenameDialogState, action: PayloadAction<string>) => {
state.fullWidthTitle = action.payload;
},
setIndex: (state: RenameDialogState, action: PayloadAction<number>) => {
state.index = action.payload;
},
setGroupIndex: (state: RenameDialogState, action: PayloadAction<number | null>) => {
state.groupIndex = action.payload;
},
},
});

View File

@ -1,6 +1,6 @@
import { Track, Channels, Encoding, Wireformat, TrackFlag, DeviceStatus } from 'netmd-js';
import { Track, Channels, Encoding, Wireformat, TrackFlag, DeviceStatus, Group } from 'netmd-js';
import { NetMDService } from './netmd';
import { sleep, sanitizeTitle, asyncMutex } from '../utils';
import { sleep, sanitizeFullWidthTitle, sanitizeHalfWidthTitle, asyncMutex, recomputeGroupsAfterTrackMove, isSequential } from '../utils';
import { assert } from 'netmd-js/dist/utils';
import { Mutex } from 'async-mutex';
@ -9,6 +9,7 @@ class NetMDMockService implements NetMDService {
public mutex = new Mutex();
public _tracksTitlesMaxLength = 1700;
public _discTitle: string = 'Mock Disc';
public _fullWidthDiscTitle: string = '';
public _discCapacity: number = 80 * 60 * 512;
public _tracks: Track[] = [
{
@ -18,6 +19,7 @@ class NetMDMockService implements NetMDService {
channel: Channels.stereo,
protected: TrackFlag.unprotected,
title: 'Long name for - Mock Track 1 - by some artist -12398729837198723',
fullWidthTitle: '',
},
{
duration: 5 * 60 * 512,
@ -26,6 +28,7 @@ class NetMDMockService implements NetMDService {
channel: Channels.stereo,
protected: TrackFlag.unprotected,
title: 'Mock Track 2',
fullWidthTitle: '',
},
{
duration: 5 * 60 * 512,
@ -34,6 +37,7 @@ class NetMDMockService implements NetMDService {
channel: Channels.stereo,
protected: TrackFlag.unprotected,
title: 'Mock Track 3',
fullWidthTitle: '',
},
{
duration: 5 * 60 * 512,
@ -42,8 +46,33 @@ class NetMDMockService implements NetMDService {
channel: Channels.stereo,
protected: TrackFlag.unprotected,
title: 'Mock Track 4',
fullWidthTitle: '',
},
{
duration: 5 * 60 * 512,
encoding: Encoding.sp,
index: 4,
channel: Channels.stereo,
protected: TrackFlag.unprotected,
title: 'Mock Track 5',
fullWidthTitle: '',
},
];
public _groups: Group[] = [
{
title: null,
index: 0,
tracks: this._tracks.slice(2),
fullWidthTitle: '',
},
{
title: 'Test',
fullWidthTitle: '',
index: 0,
tracks: [this._tracks[0], this._tracks[1]],
},
];
public _status: DeviceStatus = {
discPresent: true,
track: 0,
@ -69,6 +98,20 @@ class NetMDMockService implements NetMDService {
return this._tracks.reduce((acc, track) => acc + (track.title?.length ?? 0), 0);
}
_getDisc() {
return {
title: this._discTitle,
fullWidthTitle: this._fullWidthDiscTitle,
writeProtected: false,
writable: true,
left: this._discCapacity - this._getUsed(),
used: this._getUsed(),
total: this._discCapacity,
trackCount: this._tracks.length,
groups: this._groups,
};
}
async pair() {
return true;
}
@ -80,24 +123,58 @@ class NetMDMockService implements NetMDService {
async listContent() {
// This object ends up in the state of redux and Immer will freeze it.
// That's why it's deep cloned
return JSON.parse(
JSON.stringify({
title: this._discTitle,
writeProtected: false,
writable: true,
left: this._discCapacity - this._getUsed(),
used: this._getUsed(),
total: this._discCapacity,
trackCount: this._tracks.length,
groups: [
{
index: 0,
return JSON.parse(JSON.stringify(this._getDisc()));
}
async renameGroup(groupBegin: number, newName: string, newFullWidth?: string) {
let group = this._groups.slice(1).find(n => n.index === groupBegin);
if (!group) return;
group.title = newName;
if (newFullWidth !== undefined) group.fullWidthTitle = newFullWidth;
}
async addGroup(groupBegin: number, groupLength: number, newName: string) {
let ungrouped = this._groups.find(n => n.title === null);
if (!ungrouped) return; // You can only group tracks that aren't already in a different group, if there's no such tracks, there's no point to continue
let ungroupedLengthBeforeGroup = ungrouped.tracks.length;
let thisGroupTracks = ungrouped.tracks.filter(n => n.index >= groupBegin && n.index < groupBegin + groupLength);
ungrouped.tracks = ungrouped.tracks.filter(n => !thisGroupTracks.includes(n));
if (ungroupedLengthBeforeGroup - ungrouped.tracks.length !== groupLength) {
throw new Error('A track cannot be in 2 groups!');
}
if (!isSequential(thisGroupTracks.map(n => n.index))) {
throw new Error('Invalid sequence of tracks!');
}
this._groups.push({
title: newName,
fullWidthTitle: '',
index: groupBegin,
tracks: thisGroupTracks,
});
}
async deleteGroup(groupBegin: number) {
const thisGroup = this._groups.slice(1).find(n => n.tracks[0].index === groupBegin);
if (!thisGroup) return;
let ungroupedGroup = this._groups.find(n => n.title === null);
if (!ungroupedGroup) {
ungroupedGroup = {
title: null,
tracks: this._tracks,
},
],
})
);
fullWidthTitle: null,
tracks: [],
index: 0,
};
this._groups.unshift(ungroupedGroup);
}
ungroupedGroup.tracks = ungroupedGroup.tracks.concat(thisGroup.tracks).sort((a, b) => a.index - b.index);
this._groups.splice(this._groups.indexOf(thisGroup), 1);
}
async rewriteGroups(groups: Group[]) {
this._groups = [...groups];
}
async getDeviceStatus() {
@ -110,43 +187,69 @@ class NetMDMockService implements NetMDService {
async finalize() {}
async renameTrack(index: number, newTitle: string) {
newTitle = sanitizeTitle(newTitle);
async renameTrack(index: number, newTitle: string, fullWidthTitle?: string) {
newTitle = sanitizeHalfWidthTitle(newTitle);
if (this._getTracksTitlesLength() + newTitle.length > this._tracksTitlesMaxLength) {
throw new Error(`Track's title too long`);
}
if (fullWidthTitle !== undefined) {
this._tracks[index].fullWidthTitle = sanitizeFullWidthTitle(fullWidthTitle);
}
this._tracks[index].title = newTitle;
}
async renameDisc(newName: string) {
this._discTitle = newName;
async renameDisc(newName: string, fullWidthName?: string) {
this._discTitle = sanitizeHalfWidthTitle(newName);
if (fullWidthName !== undefined) this._fullWidthDiscTitle = sanitizeFullWidthTitle(fullWidthName);
}
async deleteTrack(index: number) {
async deleteTracks(indexes: number[]) {
indexes = indexes.sort();
indexes.reverse();
for (let index of indexes) {
this._groups = recomputeGroupsAfterTrackMove(this._getDisc(), index, -1).groups;
this._tracks.splice(index, 1);
this._groups.forEach(n => (n.tracks = n.tracks.filter(n => this._tracks.includes(n))));
}
this._updateTrackIndexes();
}
async moveTrack(src: number, dst: number) {
async moveTrack(src: number, dst: number, updateGroups?: boolean) {
let t = this._tracks.splice(src, 1);
assert(t.length === 1);
this._tracks.splice(dst, 0, t[0]);
this._updateTrackIndexes();
if (updateGroups || updateGroups === undefined) this._groups = recomputeGroupsAfterTrackMove(this._getDisc(), src, dst).groups;
}
async wipeDisc() {
this._tracks = [];
}
async wipeDiscTitleInfo() {
this._groups = [
{
index: 0,
title: null,
fullWidthTitle: null,
tracks: this._tracks,
},
];
this._discTitle = '';
this._fullWidthDiscTitle = '';
}
async upload(
title: string,
fullWidthTitle: string,
data: ArrayBuffer,
format: Wireformat,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
) {
progressCallback({ written: 0, encrypted: 0, total: 100 });
title = sanitizeTitle(title);
let halfWidthTitle = sanitizeHalfWidthTitle(title);
fullWidthTitle = sanitizeFullWidthTitle(fullWidthTitle);
if (this._getTracksTitlesLength() + title.length > this._tracksTitlesMaxLength) {
throw new Error(`Track's title too long`); // Simulates reject from device
@ -160,12 +263,13 @@ class NetMDMockService implements NetMDService {
}
this._tracks.push({
title,
title: halfWidthTitle,
duration: 5 * 60 * 512,
encoding: Encoding.sp,
index: this._tracks.length,
protected: TrackFlag.unprotected,
channel: 0,
fullWidthTitle: fullWidthTitle,
});
await sleep(1000);

View File

@ -9,10 +9,19 @@ import {
download,
getDeviceStatus,
DeviceStatus,
Group,
} from 'netmd-js';
import { makeGetAsyncPacketIteratorOnWorkerThread } from 'netmd-js/dist/web-encrypt-worker';
import { Logger } from 'netmd-js/dist/logger';
import { asyncMutex, sanitizeTitle, sleep } from '../utils';
import {
asyncMutex,
sanitizeHalfWidthTitle,
sanitizeFullWidthTitle,
sleep,
isSequential,
compileDiscTitles,
recomputeGroupsAfterTrackMove,
} from '../utils';
import { Mutex } from 'async-mutex';
const Worker = require('worker-loader!netmd-js/dist/web-encrypt-worker.js'); // eslint-disable-line import/no-webpack-loader-syntax
@ -25,13 +34,19 @@ export interface NetMDService {
listContent(): Promise<Disc>;
getDeviceName(): Promise<string>;
finalize(): Promise<void>;
renameTrack(index: number, newTitle: string): Promise<void>;
renameDisc(newName: string): Promise<void>;
deleteTrack(index: number): Promise<void>;
moveTrack(src: number, dst: number): Promise<void>;
renameTrack(index: number, newTitle: string, newFullWidthTitle?: string): Promise<void>;
renameDisc(newName: string, newFullWidthName?: string): Promise<void>;
renameGroup(groupBegin: number, newTitle: string, newFullWidthTitle?: string): Promise<void>;
addGroup(groupBegin: number, groupLength: number, name: string): Promise<void>;
deleteGroup(groupIndex: number): Promise<void>;
rewriteGroups(groups: Group[]): Promise<void>;
deleteTracks(indexes: number[]): Promise<void>;
moveTrack(src: number, dst: number, updateGroups?: boolean): Promise<void>;
wipeDisc(): Promise<void>;
wipeDiscTitleInfo(): Promise<void>;
upload(
title: string,
fullWidthTitle: string,
data: ArrayBuffer,
format: Wireformat,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
@ -49,6 +64,7 @@ export interface NetMDService {
export class NetMDUSBService implements NetMDService {
private netmdInterface?: NetMDInterface;
private logger?: Logger;
private cachedContentList?: Disc;
public mutex = new Mutex();
public statusMonitorTimer: any;
@ -70,7 +86,33 @@ export class NetMDUSBService implements NetMDService {
}
}
private async writeRawTitles(titleObject: { newRawTitle: string; newRawFullWidthTitle: string } | null) {
if (titleObject === null) return;
await this.netmdInterface!.cacheTOC();
await this.netmdInterface!.setDiscTitle(sanitizeHalfWidthTitle(titleObject.newRawTitle));
await this.netmdInterface!.setDiscTitle(sanitizeFullWidthTitle(titleObject.newRawFullWidthTitle), true);
await this.netmdInterface!.syncTOC();
this.dropCachedContentList();
}
private async listContentUsingCache() {
// listContent takes a long time to execute (>3000ms), so I think caching it should speed up the app
if (!this.cachedContentList) {
console.log("There's no cached version of the TOC, caching");
this.cachedContentList = await listContent(this.netmdInterface!);
} else {
console.log("There's a cached TOC available.");
}
return JSON.parse(JSON.stringify(this.cachedContentList)) as Disc;
}
private dropCachedContentList() {
console.log('Cached TOC Dropped');
this.cachedContentList = undefined;
}
async pair() {
this.dropCachedContentList();
let iface = await openNewDevice(navigator.usb, this.logger);
if (iface === null) {
return false;
@ -80,6 +122,7 @@ export class NetMDUSBService implements NetMDService {
}
async connect() {
this.dropCachedContentList();
let iface = await openPairedDevice(navigator.usb, this.logger);
if (iface === null) {
return false;
@ -90,7 +133,8 @@ export class NetMDUSBService implements NetMDService {
@asyncMutex
async listContent() {
return await listContent(this.netmdInterface!);
this.dropCachedContentList();
return await this.listContentUsingCache();
}
@asyncMutex
@ -106,24 +150,109 @@ export class NetMDUSBService implements NetMDService {
@asyncMutex
async finalize() {
await this.netmdInterface!.netMd.finalize();
this.dropCachedContentList();
}
@asyncMutex
async renameTrack(index: number, title: string) {
// Removing non ascii chars... Sorry, I didn't implement char encoding.
title = sanitizeTitle(title);
async rewriteGroups(groups: Group[]) {
const disc = await this.listContentUsingCache();
disc.groups = groups;
await this.writeRawTitles(compileDiscTitles(disc));
}
@asyncMutex
async renameTrack(index: number, title: string, fullWidthTitle?: string) {
title = sanitizeHalfWidthTitle(title);
await this.netmdInterface!.cacheTOC();
await this.netmdInterface!.setTrackTitle(index, title);
if (fullWidthTitle !== undefined) {
await this.netmdInterface!.setTrackTitle(index, sanitizeFullWidthTitle(fullWidthTitle), true);
}
await this.netmdInterface!.syncTOC();
this.dropCachedContentList();
}
@asyncMutex
async renameDisc(newName: string) {
async renameGroup(groupBegin: number, newName: string, newFullWidthName?: string) {
const disc = await this.listContentUsingCache();
let thisGroup = disc.groups.find(n => n.tracks[0].index === groupBegin);
if (!thisGroup) return;
thisGroup.title = newName;
if (newFullWidthName !== undefined) thisGroup.fullWidthTitle = newFullWidthName;
await this.writeRawTitles(compileDiscTitles(disc));
}
@asyncMutex
async addGroup(groupBegin: number, groupLength: number, title: string) {
const disc = await this.listContentUsingCache();
let ungrouped = disc.groups.find(n => n.title === null);
if (!ungrouped) return; // You can only group tracks that aren't already in a different group, if there's no such tracks, there's no point to continue
let ungroupedLengthBeforeGroup = ungrouped.tracks.length;
let thisGroupTracks = ungrouped.tracks.filter(n => n.index >= groupBegin && n.index < groupBegin + groupLength);
ungrouped.tracks = ungrouped.tracks.filter(n => !thisGroupTracks.includes(n));
if (ungroupedLengthBeforeGroup - ungrouped.tracks.length !== groupLength) {
throw new Error('A track cannot be in 2 groups!');
}
if (!isSequential(thisGroupTracks.map(n => n.index))) {
throw new Error('Invalid sequence of tracks!');
}
disc.groups.push({
title,
fullWidthTitle: '',
index: groupBegin,
tracks: thisGroupTracks,
});
await this.writeRawTitles(compileDiscTitles(disc));
}
@asyncMutex
async deleteGroup(groupBegin: number) {
const disc = await this.listContentUsingCache();
let thisGroup = disc.groups.find(n => n.tracks[0].index === groupBegin);
if (thisGroup) disc.groups.splice(disc.groups.indexOf(thisGroup), 1);
await this.writeRawTitles(compileDiscTitles(disc));
}
@asyncMutex
async renameDisc(newName: string, newFullWidthName?: string) {
// TODO: This whole function should be moved in netmd-js
const oldName = await this.netmdInterface!.getDiscTitle();
const oldFullWidthName = await this.netmdInterface!.getDiscTitle(true);
const oldRawName = await this.netmdInterface!._getDiscTitle();
const oldRawFullWidthName = await this.netmdInterface!._getDiscTitle(true);
const hasGroups = oldRawName.indexOf('//') >= 0;
const hasFullWidthGroups = oldRawName.indexOf('') >= 0;
const hasGroupsAndTitle = oldRawName.startsWith('0;');
const hasFullWidthGroupsAndTitle = oldRawName.startsWith('');
newName = sanitizeHalfWidthTitle(newName);
newFullWidthName = newFullWidthName && sanitizeFullWidthTitle(newFullWidthName);
if (newFullWidthName !== oldFullWidthName && newFullWidthName !== undefined) {
let newFullWidthNameWithGroups;
if (hasFullWidthGroups) {
if (hasFullWidthGroupsAndTitle) {
newFullWidthNameWithGroups = oldRawFullWidthName.replace(
/^.*?/,
newFullWidthName !== '' ? `${newFullWidthName}` : ``
);
} else {
newFullWidthNameWithGroups = `${newFullWidthName}${oldRawFullWidthName}`; // Add the new title
}
} else {
newFullWidthNameWithGroups = newFullWidthName;
}
await this.netmdInterface!.cacheTOC();
await this.netmdInterface!.setDiscTitle(newFullWidthNameWithGroups, true);
await this.netmdInterface!.syncTOC();
this.dropCachedContentList();
}
if (newName === oldName) {
return;
@ -144,26 +273,50 @@ export class NetMDUSBService implements NetMDService {
await this.netmdInterface!.cacheTOC();
await this.netmdInterface!.setDiscTitle(newNameWithGroups);
await this.netmdInterface!.syncTOC();
this.dropCachedContentList();
}
@asyncMutex
async deleteTrack(index: number) {
async deleteTracks(indexes: number[]) {
indexes = indexes.sort();
indexes.reverse();
let content = await this.listContentUsingCache();
for (let index of indexes) {
content = recomputeGroupsAfterTrackMove(content, index, -1);
await this.netmdInterface!.eraseTrack(index);
await sleep(100);
}
await this.writeRawTitles(compileDiscTitles(content));
this.dropCachedContentList();
}
@asyncMutex
async wipeDisc() {
await this.netmdInterface!.eraseDisc();
this.dropCachedContentList();
}
@asyncMutex
async moveTrack(src: number, dst: number) {
async wipeDiscTitleInfo() {
await this.writeRawTitles({
newRawTitle: '',
newRawFullWidthTitle: '',
});
}
@asyncMutex
async moveTrack(src: number, dst: number, updateGroups?: boolean) {
await this.netmdInterface!.moveTrack(src, dst);
if (updateGroups === undefined || updateGroups) {
await this.writeRawTitles(compileDiscTitles(recomputeGroupsAfterTrackMove(await this.listContentUsingCache(), src, dst)));
}
this.dropCachedContentList();
}
async upload(
title: string,
fullWidthTitle: string,
data: ArrayBuffer,
format: Wireformat,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
@ -182,9 +335,9 @@ export class NetMDUSBService implements NetMDService {
updateProgress();
});
// Removing non ascii chars... Sorry, I didn't implement char encoding.
title = sanitizeTitle(title);
let mdTrack = new MDTrack(title, format, data, 0x80000, webWorkerAsyncPacketIterator);
let halfWidthTitle = sanitizeHalfWidthTitle(title);
fullWidthTitle = sanitizeFullWidthTitle(fullWidthTitle);
let mdTrack = new MDTrack(halfWidthTitle, format, data, 0x80000, fullWidthTitle, webWorkerAsyncPacketIterator);
await download(this.netmdInterface!, mdTrack, ({ writtenBytes }) => {
written = writtenBytes;
@ -192,6 +345,7 @@ export class NetMDUSBService implements NetMDService {
});
w.terminate();
this.dropCachedContentList();
}
@asyncMutex

1
src/types.d.ts vendored
View File

@ -9,3 +9,4 @@ declare module 'react95';
declare module 'react95/dist/themes/original';
declare module 'react95/dist/fonts/ms_sans_serif.woff2';
declare module 'react95/dist/fonts/ms_sans_serif_bold.woff2';
declare module 'jconv';

View File

@ -1,8 +1,10 @@
import { Disc, formatTimeFromFrames, Encoding } from 'netmd-js';
import { Disc, formatTimeFromFrames, Encoding, getTracks, Group } from 'netmd-js';
import { useSelector, shallowEqual } from 'react-redux';
import { RootState } from './redux/store';
import { Mutex } from 'async-mutex';
import { Theme } from '@material-ui/core';
import jconv from 'jconv';
import { halfWidthToFullWidthRange } from 'netmd-js/dist/utils';
export function sleep(ms: number) {
return new Promise(resolve => {
@ -49,12 +51,33 @@ export function loadPreference<T>(key: string, defaultValue: T): T {
}
}
export function getAvailableCharsForTrackTitle(trackTitles: string[]) {
const maxChars = 1700; // see https://www.minidisc.org/md_toc.html
const usedChars = trackTitles.reduce((acc, title) => {
return acc + title.length;
}, 0);
return maxChars - usedChars;
export function getAvailableCharsForTitle(disc: Disc, includeGroups?: boolean) {
const cellLimit = 255;
// see https://www.minidisc.org/md_toc.html
const fixLength = (len: number) => Math.ceil(len / 7);
let groups = disc.groups.filter(n => n.title !== null);
// Assume worst-case scenario
let fwTitle = disc.fullWidthTitle + `0;//`;
let hwTitle = disc.title + `0;//`;
if (includeGroups || includeGroups === undefined)
for (let group of groups) {
let range = `${group.tracks[0].index + 1}${group.tracks.length - 1 !== 0 &&
`-${group.tracks[group.tracks.length - 1].index + 1}`}//`;
// The order of these characters doesn't matter. It's for length only
fwTitle += group.fullWidthTitle + range;
hwTitle += group.title + range;
}
let usedCells = 0;
usedCells += fixLength(fwTitle.length * 2);
usedCells += fixLength(getHalfWidthTitleLength(hwTitle));
for (let trk of getTracks(disc)) {
usedCells += fixLength((trk.fullWidthTitle?.length ?? 0) * 2);
usedCells += fixLength(getHalfWidthTitleLength(trk.title ?? ''));
}
return Math.max(cellLimit - usedCells, 0) * 7;
}
export function framesToSec(frames: number) {
@ -65,21 +88,78 @@ export function sanitizeTitle(title: string) {
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
}
const EncodingName: { [k: number]: string } = {
export function getHalfWidthTitleLength(title: string) {
// Some characters are written as 2 bytes
// prettier-ignore
const multiByteChars: { [key: string]: number } = { "ガ": 1, "ギ": 1, "グ": 1, "ゲ": 1, "ゴ": 1, "ザ": 1, "ジ": 1, "ズ": 1, "ゼ": 1, "ゾ": 1, "ダ": 1, "ヂ": 1, "ヅ": 1, "デ": 1, "ド": 1, "バ": 1, "パ": 1, "ビ": 1, "ピ": 1, "ブ": 1, "プ": 1, "ベ": 1, "ペ": 1, "ボ": 1, "ポ": 1, "ヮ": 1, "ヰ": 1, "ヱ": 1, "ヵ": 1, "ヶ": 1, "ヴ": 1, "ヽ": 1, "ヾ": 1, "が": 1, "ぎ": 1, "ぐ": 1, "げ": 1, "ご": 1, "ざ": 1, "じ": 1, "ず": 1, "ぜ": 1, "ぞ": 1, "だ": 1, "ぢ": 1, "づ": 1, "で": 1, "ど": 1, "ば": 1, "ぱ": 1, "び": 1, "ぴ": 1, "ぶ": 1, "ぷ": 1, "べ": 1, "ぺ": 1, "ぼ": 1, "ぽ": 1, "ゎ": 1, "ゐ": 1, "ゑ": 1, "ゕ": 1, "ゖ": 1, "ゔ": 1, "ゝ": 1, "ゞ": 1 };
return (
title.length +
title
.split('')
.map(n => multiByteChars[n] ?? 0)
.reduce((a, b) => a + b, 0)
);
}
export function sanitizeHalfWidthTitle(title: string) {
// prettier-ignore
const mappings: { [key: string]: string } = { 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', '': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '': '!', '': '"', '': '#', '': '$', '': '%', '': '&', '': "'", '': '(', '': ')', '': '*', '': '+', '': ',', '': '-', '': '.', '': '/', '': ':', '': ';', '': '<', '': '=', '': '>', '': '?', '': '@', '': 'A', '': 'B', '': 'C', '': 'D', '': 'E', '': 'F', '': 'G', '': 'H', '': 'I', '': 'J', '': 'K', '': 'L', '': 'M', '': 'N', '': 'O', '': 'P', '': 'Q', '': 'R', '': 'S', '': 'T', '': 'U', '': 'V', '': 'W', '': 'X', '': 'Y', '': 'Z', '': '[', '': '\\', '': ']', '': '^', '_': '_', '': '`', '': 'a', '': 'b', '': 'c', '': 'd', '': 'e', '': 'f', '': 'g', '': 'h', '': 'i', '': 'j', '': 'k', '': 'l', '': 'm', '': 'n', '': 'o', '': 'p', '': 'q', '': 'r', '': 's', '': 't', '': 'u', '': 'v', '': 'w', '': 'x', '': 'y', '': 'z', '': '{', '': '|', '': '}', '': '~', '\u3000': ' ', '': '0', '': '1', '': '2', '': '3', '': '4', '': '5', '': '6', '': '7', '': '8', '': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
const allowedHalfWidthKana: string[] = Object.values(mappings);
const newTitle = title
.split('')
.map(n => {
if (mappings[n]) return mappings[n];
if (n.charCodeAt(0) < 0x7f || allowedHalfWidthKana.includes(n)) return n;
return ' ';
})
.join('');
// Check if the amount of characters is the same as the amount of encoded bytes (when accounting for dakuten). Otherwise the disc might end up corrupted
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
if (sjisEncoded.length !== getHalfWidthTitleLength(title)) return sanitizeTitle(title); //Fallback
return newTitle;
}
export function sanitizeFullWidthTitle(title: string) {
// prettier-ignore
const mappings: { [key: string]: string } = { '!': '', '"': '', '#': '', '$': '', '%': '', '&': '', "'": '', '(': '', ')': '', '*': '', '+': '', ',': '', '-': '', '.': '', '/': '', ':': '', ';': '', '<': '', '=': '', '>': '', '?': '', '@': '', 'A': '', 'B': '', 'C': '', 'D': '', 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': '', 'K': '', 'L': '', 'M': '', 'N': '', 'O': '', 'P': '', 'Q': '', 'R': '', 'S': '', 'T': '', 'U': '', 'V': '', 'W': '', 'X': '', 'Y': '', 'Z': '', '[': '', '\\': '', ']': '', '^': '', '_': '_', '`': '', 'a': '', 'b': '', 'c': '', 'd': '', 'e': '', 'f': '', 'g': '', 'h': '', 'i': '', 'j': '', 'k': '', 'l': '', 'm': '', 'n': '', 'o': '', 'p': '', 'q': '', 'r': '', 's': '', 't': '', 'u': '', 'v': '', 'w': '', 'x': '', 'y': '', 'z': '', '{': '', '|': '', '}': '', '~': '', ' ': '\u3000', '0': '', '1': '', '2': '', '3': '', '4': '', '5': '', '6': '', '7': '', '8': '', '9': '', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': '', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、' };
const newTitle = title
.split('')
.map(n => mappings[n] ?? n)
.join('');
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
if (jconv.decode(sjisEncoded, 'SJIS') !== newTitle) return sanitizeTitle(title); // Fallback
if (sjisEncoded.length !== title.length * 2) return sanitizeTitle(title); // Fallback (every character in the full-width title is 2 bytes)
return newTitle;
}
export const EncodingName: { [k: number]: string } = {
[Encoding.sp]: 'SP',
[Encoding.lp2]: 'LP2',
[Encoding.lp4]: 'LP4',
};
export type DisplayTrack = {
index: number;
title: string;
fullWidthTitle: string;
group: string | null;
duration: string;
encoding: string;
};
export function getSortedTracks(disc: Disc | null) {
let tracks: { index: number; title: string; group: string; duration: string; encoding: string }[] = [];
let tracks: DisplayTrack[] = [];
if (disc !== null) {
for (let group of disc.groups) {
for (let track of group.tracks) {
tracks.push({
index: track.index,
title: track.title ?? `Unknown Title`,
group: group.title ?? ``,
fullWidthTitle: track.fullWidthTitle ?? ``,
group: group.title ?? null,
encoding: EncodingName[track.encoding],
duration: formatTimeFromFrames(track.duration, false),
});
@ -90,6 +170,178 @@ export function getSortedTracks(disc: Disc | null) {
return tracks;
}
export function getGroupedTracks(disc: Disc | null) {
if (!disc) return [];
let groupedList: Group[] = [];
let ungroupedTracks = [...(disc.groups.find(n => n.title === null)?.tracks ?? [])];
let lastIndex = 0;
for (let group of disc.groups) {
if (group.title === null) continue; // Ungrouped tracks
let toCopy = group.tracks[0].index - lastIndex;
groupedList.push({
index: -1,
title: null,
fullWidthTitle: null,
tracks: toCopy === 0 ? [] : ungroupedTracks.splice(0, toCopy),
});
lastIndex = group.tracks[group.tracks.length - 1].index + 1;
groupedList.push(group);
}
groupedList.push({
index: -1,
title: null,
fullWidthTitle: null,
tracks: ungroupedTracks,
});
return groupedList;
}
export function recomputeGroupsAfterTrackMove(disc: Disc, trackIndex: number, targetIndex: number) {
// Used for moving tracks in netmd-mock and deleting
let offset = trackIndex > targetIndex ? 1 : -1;
let deleteMode = targetIndex === -1;
if (deleteMode) {
offset = -1;
targetIndex = disc.trackCount;
}
let boundsStart = Math.min(trackIndex, targetIndex);
let boundsEnd = Math.max(trackIndex, targetIndex);
let allTracks = disc.groups
.map(n => n.tracks)
.reduce((a, b) => a.concat(b), [])
.sort((a, b) => a.index - b.index)
.filter(n => !deleteMode || n.index !== trackIndex);
let groupBoundaries: {
name: string | null;
fullWidthName: string | null;
start: number;
end: number;
}[] = disc.groups
.filter(n => n.title !== null)
.map(group => ({
name: group.title,
fullWidthName: group.fullWidthTitle,
start: group.tracks[0].index,
end: group.tracks[0].index + group.tracks.length - 1,
})); // Convert to a format better for shifting
let anyChanges = false;
for (let group of groupBoundaries) {
if (group.start > boundsStart && group.start <= boundsEnd) {
group.start += offset;
anyChanges = true;
}
if (group.end >= boundsStart && group.end < boundsEnd) {
group.end += offset;
anyChanges = true;
}
}
if (!anyChanges) return disc;
let newDisc: Disc = { ...disc };
// Convert back
newDisc.groups = groupBoundaries
.map(n => ({
title: n.name,
fullWidthTitle: n.fullWidthName,
index: n.start,
tracks: allTracks.slice(n.start, n.end + 1),
}))
.filter(n => n.tracks.length > 0);
// Convert ungrouped tracks
let allGrouped = newDisc.groups.map(n => n.tracks).reduce((a, b) => a.concat(b), []);
let ungrouped = allTracks.filter(n => !allGrouped.includes(n));
// Fix all the track indexes
if (deleteMode) {
for (let i = 0; i < allTracks.length; i++) {
allTracks[i].index = i;
}
}
if (ungrouped.length) newDisc.groups.unshift({ title: null, fullWidthTitle: null, index: 0, tracks: ungrouped });
return newDisc;
}
export function compileDiscTitles(disc: Disc) {
let availableCharactersForTitle = getAvailableCharsForTitle(
{
...disc,
title: '',
fullWidthTitle: '',
},
false
);
// If the disc or any of the groups, or any track has a full-width title, provide support for them
const useFullWidth =
disc.fullWidthTitle ||
disc.groups.filter(n => !!n.fullWidthTitle).length > 0 ||
disc.groups
.map(n => n.tracks)
.reduce((a, b) => a.concat(b), [])
.filter(n => !!n.fullWidthTitle).length > 0;
const fixLength = (l: number) => Math.ceil(l / 7) * 7;
let newRawTitle = '',
newRawFullWidthTitle = '';
if (disc.title) newRawTitle = `0;${disc.title}//`;
if (useFullWidth) newRawFullWidthTitle = `${disc.fullWidthTitle}`;
for (let n of disc.groups) {
if (n.title === null || n.tracks.length === 0) continue;
let range = `${n.tracks[0].index + 1}`;
if (n.tracks.length !== 1) {
// Special case
range += `-${n.tracks[0].index + n.tracks.length}`;
}
let newRawTitleAfterGroup = newRawTitle + `${range};${n.title}//`,
newRawFullWidthTitleAfterGroup = newRawFullWidthTitle + halfWidthToFullWidthRange(range) + `${n.fullWidthTitle ?? ''}`;
let titlesLengthInTOC = fixLength(getHalfWidthTitleLength(newRawTitleAfterGroup));
if (useFullWidth) titlesLengthInTOC += fixLength(newRawFullWidthTitleAfterGroup.length * 2);
if (availableCharactersForTitle - titlesLengthInTOC < 0) break;
newRawTitle = newRawTitleAfterGroup;
newRawFullWidthTitle = newRawFullWidthTitleAfterGroup;
}
let titlesLengthInTOC = fixLength(getHalfWidthTitleLength(newRawTitle));
if (useFullWidth) titlesLengthInTOC += fixLength(newRawFullWidthTitle.length * 2); // If this check fails the titles without the groups already take too much space, don't change anything
if (availableCharactersForTitle - titlesLengthInTOC < 0) {
return null;
}
return {
newRawTitle,
newRawFullWidthTitle: useFullWidth ? newRawFullWidthTitle : '',
};
}
export function isSequential(numbers: number[]) {
if (numbers.length === 0) return true;
let last = numbers[0];
for (let num of numbers) {
if (num === last) {
++last;
} else return false;
}
return true;
}
export function asyncMutex(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
// This is meant to be used only with classes having a "mutex" instance property
const oldValue = descriptor.value;

View File

@ -877,6 +877,13 @@
dependencies:
"regenerator-runtime" "^0.13.4"
"@babel/runtime@^7.9.2":
"integrity" "sha512-/PCB2uJ7oM44tz8YhC4Z/6PeOKXp4K588f+5M3clr1M4zbqztlo0XEfJ2LEzj/FgwfgGcIdl8n7YYjTCI0BYwg=="
"resolved" "https://registry.npmjs.org/@babel/runtime/-/runtime-7.14.6.tgz"
"version" "7.14.6"
dependencies:
"regenerator-runtime" "^0.13.4"
"@babel/template@^7.4.0", "@babel/template@^7.8.3":
"integrity" "sha512-04m87AcQgAFdvuoyiQ2kgELr2tV8B4fP/xJAVUL3Yb3bkNdMedD3d0rlSQr3PegP0cms3eHjl1F7PWlvWbU8FQ=="
"resolved" "https://registry.npmjs.org/@babel/template/-/template-7.8.3.tgz"
@ -1591,6 +1598,13 @@
"resolved" "https://registry.npmjs.org/@types/q/-/q-1.5.2.tgz"
"version" "1.5.2"
"@types/react-beautiful-dnd@^13.1.1":
"integrity" "sha512-1lBBxVSutE8CQM37Jq7KvJwuA94qaEEqsx+G0dnwzG6Sfwf6JGcNeFk5jjjhJli1q2naeMZm+D/dvT/zyX4QPw=="
"resolved" "https://registry.npmjs.org/@types/react-beautiful-dnd/-/react-beautiful-dnd-13.1.1.tgz"
"version" "13.1.1"
dependencies:
"@types/react" "*"
"@types/react-dom@*", "@types/react-dom@^16.9.10":
"integrity" "sha512-ItatOrnXDMAYpv6G8UCk2VhbYVTjZT9aorLtA/OzDN9XJ2GKcfam68jutoAcILdRjsRUO8qb7AmyObF77Q8QFw=="
"resolved" "https://registry.npmjs.org/@types/react-dom/-/react-dom-16.9.10.tgz"
@ -3554,6 +3568,13 @@
dependencies:
"postcss" "^7.0.5"
"css-box-model@^1.2.0":
"integrity" "sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw=="
"resolved" "https://registry.npmjs.org/css-box-model/-/css-box-model-1.2.1.tgz"
"version" "1.2.1"
dependencies:
"tiny-invariant" "^1.0.6"
"css-color-keywords@^1.0.0":
"integrity" "sha1-/qJhbcZ2spYmhrOvjb2+GAskTgU="
"resolved" "https://registry.npmjs.org/css-color-keywords/-/css-color-keywords-1.0.0.tgz"
@ -7401,6 +7422,11 @@
"mimic-fn" "^2.0.0"
"p-is-promise" "^2.0.0"
"memoize-one@^5.1.1":
"integrity" "sha512-zYiwtZUcYyXKo/np96AGZAckk+FWWsUdJ3cHGGmld7+AhvcWmQyGCYUh1hc4Q/pkOhb65dQR/pqCyK0cOaHz4Q=="
"resolved" "https://registry.npmjs.org/memoize-one/-/memoize-one-5.2.1.tgz"
"version" "5.2.1"
"memory-fs@^0.4.1":
"integrity" "sha1-OpoguEYlI+RHz7x+i7gO1me/xVI="
"resolved" "https://registry.npmjs.org/memory-fs/-/memory-fs-0.4.1.tgz"
@ -7796,10 +7822,10 @@
"resolved" "https://registry.npmjs.org/neo-async/-/neo-async-2.6.1.tgz"
"version" "2.6.1"
"netmd-js@^1.4.0":
"integrity" "sha512-8OXEO4Gh6Me3XU2igHmVbk+hn06O3N5xlM/c38w97rNQDRpCAppO8jqq+fryaZd7QZwmIxXqwwY88KIehoieAg=="
"resolved" "https://registry.npmjs.org/netmd-js/-/netmd-js-1.4.1.tgz"
"version" "1.4.1"
"netmd-js@^2.0.1":
"integrity" "sha512-9rMt+tcpqYM1/IyudyzVeOzZ+E55/+RvHf6NNOzQJbb0knySG5oaLich2NXSsFT45Wrg+c43uLMu3iOKmjW4aA=="
"resolved" "https://registry.npmjs.org/netmd-js/-/netmd-js-2.0.1.tgz"
"version" "2.0.1"
dependencies:
"browser-bunyan" "^1.5.3"
"buffer" "^5.5.0"
@ -9583,6 +9609,11 @@
"resolved" "https://registry.npmjs.org/querystringify/-/querystringify-2.1.1.tgz"
"version" "2.1.1"
"raf-schd@^4.0.2":
"integrity" "sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ=="
"resolved" "https://registry.npmjs.org/raf-schd/-/raf-schd-4.0.3.tgz"
"version" "4.0.3"
"raf@^3.4.1":
"integrity" "sha512-Sq4CW4QhwOHE8ucn6J34MqtZCeWFP2aQSmrlroYgqAV1PjStIhJXxYuTgUIfkEk7zTLjmIjLmU5q+fbD1NnOJA=="
"resolved" "https://registry.npmjs.org/raf/-/raf-3.4.1.tgz"
@ -9642,6 +9673,19 @@
"regenerator-runtime" "^0.13.3"
"whatwg-fetch" "^3.0.0"
"react-beautiful-dnd@^13.1.0":
"integrity" "sha512-aGvblPZTJowOWUNiwd6tNfEpgkX5OxmpqxHKNW/4VmvZTNTbeiq7bA3bn5T+QSF2uibXB0D1DmJsb1aC/+3cUA=="
"resolved" "https://registry.npmjs.org/react-beautiful-dnd/-/react-beautiful-dnd-13.1.0.tgz"
"version" "13.1.0"
dependencies:
"@babel/runtime" "^7.9.2"
"css-box-model" "^1.2.0"
"memoize-one" "^5.1.1"
"raf-schd" "^4.0.2"
"react-redux" "^7.2.0"
"redux" "^4.0.4"
"use-memo-one" "^1.1.1"
"react-dev-utils@^10.1.0":
"integrity" "sha512-KmZChqxY6l+ed28IHetGrY8J9yZSvzlAHyFXduEIhQ42EBGtqftlbqQZ+dDTaC7CwNW2tuXN+66bRKE5h2HgrQ=="
"resolved" "https://registry.npmjs.org/react-dev-utils/-/react-dev-utils-10.1.0.tgz"
@ -9672,7 +9716,7 @@
"strip-ansi" "6.0.0"
"text-table" "0.2.0"
"react-dom@^16.14.0":
"react-dom@^16.14.0", "react-dom@^16.8.5 || ^17.0.0":
"integrity" "sha512-1gCeQXDLoIqMgqD3IO2Ah9bnf0w9kzhwN5q4FGnHZ67hBm9yePzB5JJAIQCc8x3pFnNlwFq4RidZggNAAkzWWw=="
"resolved" "https://registry.npmjs.org/react-dom/-/react-dom-16.14.0.tgz"
"version" "16.14.0"
@ -9706,7 +9750,7 @@
"resolved" "https://registry.npmjs.org/react-is/-/react-is-16.13.1.tgz"
"version" "16.13.1"
"react-redux@^7.2.2":
"react-redux@^7.2.0", "react-redux@^7.2.2":
"integrity" "sha512-ZhAmQ1lrK+Pyi0ZXNMUZuYxYAZd59wFuVDGUt536kSGdD0ya9Q7BfsE95E3TsFLE3kOSFp5m6G5qbatE+Ic1+w=="
"resolved" "https://registry.npmjs.org/react-redux/-/react-redux-7.2.3.tgz"
"version" "7.2.3"
@ -9788,7 +9832,7 @@
"loose-envify" "^1.4.0"
"prop-types" "^15.6.2"
"react@^16.14.0", "react@^16.8.3 || ^17":
"react@^16.14.0", "react@^16.8.0 || ^17.0.0", "react@^16.8.3 || ^17", "react@^16.8.5 || ^17.0.0":
"integrity" "sha512-0X2CImDkJGApiAlcf0ODKIneSwBPhqJawOa5wCtKbu7ZECrmS26NvtSILynQ66cgkT/RJ4LidJOc3bUESwmU8g=="
"resolved" "https://registry.npmjs.org/react/-/react-16.14.0.tgz"
"version" "16.14.0"
@ -10045,7 +10089,7 @@
"resolved" "https://registry.npmjs.org/redux-thunk/-/redux-thunk-2.3.0.tgz"
"version" "2.3.0"
"redux@^2.0.0 || ^3.0.0 || ^4.0.0-0", "redux@^4.0.0":
"redux@^2.0.0 || ^3.0.0 || ^4.0.0-0", "redux@^4.0.0", "redux@^4.0.4":
"integrity" "sha512-VSz1uMAH24DM6MF72vcojpYPtrTUu3ByVWfPL1nPfVRb5mZVTve5GnNCUV53QM/BZ66xfWrm0CTWoM+Xlz8V1w=="
"resolved" "https://registry.npmjs.org/redux/-/redux-4.0.5.tgz"
"version" "4.0.5"
@ -10070,12 +10114,7 @@
"resolved" "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.11.1.tgz"
"version" "0.11.1"
"regenerator-runtime@^0.13.2", "regenerator-runtime@^0.13.3":
"integrity" "sha512-naKIZz2GQ8JWh///G7L3X6LaQUAMp2lvb1rvwwsURe/VXwD6VMfr+/1NuNw3ag8v2kY1aQ/go5SNn79O9JU7yw=="
"resolved" "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.3.tgz"
"version" "0.13.3"
"regenerator-runtime@^0.13.4":
"regenerator-runtime@^0.13.2", "regenerator-runtime@^0.13.3", "regenerator-runtime@^0.13.4":
"integrity" "sha512-a54FxoJDIr27pgf7IgeQGxmqUNYrcV338lf/6gH456HZ/PhX+5BcwHXG9ajESmwe6WRO0tAzRUrRmNONWgkrew=="
"resolved" "https://registry.npmjs.org/regenerator-runtime/-/regenerator-runtime-0.13.7.tgz"
"version" "0.13.7"
@ -11486,6 +11525,11 @@
"resolved" "https://registry.npmjs.org/timsort/-/timsort-0.3.0.tgz"
"version" "0.3.0"
"tiny-invariant@^1.0.6":
"integrity" "sha512-ytxQvrb1cPc9WBEI/HSeYYoGD0kWnGEOR8RY6KomWLBVhqz0RgTwVO9dLrGz7dC+nN9llyI7OKAgRq8Vq4ZBSw=="
"resolved" "https://registry.npmjs.org/tiny-invariant/-/tiny-invariant-1.1.0.tgz"
"version" "1.1.0"
"tiny-warning@^1.0.2":
"integrity" "sha512-lBN9zLN/oAf68o3zNXYrdCt1kP8WsiGW8Oo2ka41b2IM5JL/S1CTyX1rW0mb/zSuJun0ZUrDxx4sqvYS2FWzPA=="
"resolved" "https://registry.npmjs.org/tiny-warning/-/tiny-warning-1.0.3.tgz"
@ -11807,6 +11851,11 @@
"nan" "2.13.2"
"prebuild-install" "^5.3.3"
"use-memo-one@^1.1.1":
"integrity" "sha512-u2qFKtxLsia/r8qG0ZKkbytbztzRb317XCkT7yP8wxL0tZ/CzK2G+WWie5vWvpyeP7+YoPIwbJoIHJ4Ba4k0oQ=="
"resolved" "https://registry.npmjs.org/use-memo-one/-/use-memo-one-1.1.2.tgz"
"version" "1.1.2"
"use@^3.1.0":
"integrity" "sha512-cwESVXlO3url9YWlFW/TA9cshCEhtu7IKJ/p5soJ/gGpj7vbvFrAY/eIioQ6Dw23KjZhYgiIo8HOs1nQ2vr/oQ=="
"resolved" "https://registry.npmjs.org/use/-/use-3.1.1.tgz"