Last week's work

This commit is contained in:
Stefano Brilli 2020-03-20 18:21:53 +01:00
parent 35c4de410a
commit 7c12a9cb96
52 changed files with 17654 additions and 147 deletions

1
.prettierignore Normal file
View File

@ -0,0 +1 @@
public/

7
.prettierrc Normal file
View File

@ -0,0 +1,7 @@
{
"trailingComma": "es5",
"singleQuote": true,
"tabWidth": 4,
"printWidth": 140,
"parser": "typescript"
}

11
.vscode/settings.json vendored Normal file
View File

@ -0,0 +1,11 @@
{
"editor.formatOnSave": true,
"files.exclude": {
"**/.git": true,
"**/.svn": true,
"**/.hg": true,
"**/CVS": true,
"**/.DS_Store": true,
"build/": true,
}
}

View File

@ -1,44 +1,57 @@
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app).
# Web MiniDisc
## Available Scripts
*Brings NetMD Devices to the Web*
In the project directory, you can run:
live demo at [https://minidisc.brilli.me](https://minidisc.brilli.me).
### `yarn start`
Requires *Chrome* or any other browser that supports both **WASM** and **WebUSB**
Runs the app in the development mode.<br />
Open [http://localhost:3000](http://localhost:3000) to view it in the browser.
##### macOS
_it just works ®_ ... no need to download or install any software.
The page will reload if you make edits.<br />
You will also see any lint errors in the console.
##### Linux
Follow the instructions here [https://github.com/glaubitz/linux-minidisc/tree/master/netmd/etc](https://github.com/glaubitz/linux-minidisc/tree/master/netmd/etc) to grant your user access to the device.
### `yarn test`
##### Windows 10
There are no official Windows 10 drivers for NetMD devices, and the good news is that we don't need it!
We can just use a generic driver like the WinUSB driver to access the device.
You can find installation instruction [here](https://docs.microsoft.com/en-us/windows-hardware/drivers/usbcon/winusb-installation), but the easiest way is to use [Zadig](https://zadig.akeo.ie/).<br/> Note: you'll need to restart your browser after installation to make it see the device.
Launches the test runner in the interactive watch mode.<br />
See the section about [running tests](https://facebook.github.io/create-react-app/docs/running-tests) for more information.
### Don't know what is a MiniDisc?
### `yarn build`
- Where to start -> [https://en.wikipedia.org/wiki/MiniDisc](https://en.wikipedia.org/wiki/MiniDisc)
- Community -> [https://www.reddit.com/r/minidisc/](https://www.reddit.com/r/minidisc/)
Builds the app for production to the `build` folder.<br />
It correctly bundles React in production mode and optimizes the build for the best performance.
### How to build
The build is minified and the filenames include the hashes.<br />
Your app is ready to be deployed!
This project was bootstrapped with [Create React App](https://github.com/facebook/create-react-app), so you can run:
- `npm start` to start the development server
- `npm build` to build for production
See the section about [deployment](https://facebook.github.io/create-react-app/docs/deployment) for more information.
WASM modules are provided in the `public/` directory. However, if you wish to build those binaries yourself, instructions are provided in the `extra/` directory.
### `yarn eject`
**Note: this is a one-way operation. Once you `eject`, you cant go back!**
### How Contribute
Every contribute is welcome but, please, reach out to me before working on any PR. I've built this app mainly for personal use and I wish to keep it as light as possible in terms of features.
If you arent satisfied with the build tool and configuration choices, you can `eject` at any time. This command will remove the single build dependency from your project.
### Bugs and Issues
There might be plenty of them, for sure :) . The thing is that I've not the time to fix all of them and to make sure this app works on every browser or device.
Instead, it will copy all the configuration files and the transitive dependencies (Webpack, Babel, ESLint, etc) right into your project so you have full control over them. All of the commands except `eject` will still work, but they will point to the copied scripts so you can tweak them. At this point youre on your own.
The best way to get a bug fixed, a feature implemented, or a device supported, is to fork the project and do it for yourself. I'll try to provide support as best as I can.
You dont have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldnt feel obligated to use this feature. However we understand that this tool wouldnt be useful if you couldnt customize it when you are ready for it.
### Backstory
A few weeks ago I've found my old [MZ-N710](https://www.minidisc.org/part_Sony_MZ-N710.html) in the basement of my parents' house.
## Learn More
Determined to make it work on my modern Mac, after some googling, I found out about the [linux-minidisc](https://github.com/glaubitz/linux-minidisc) project. They've done such a great job in reversing the NetMD protocol!
You can learn more in the [Create React App documentation](https://facebook.github.io/create-react-app/docs/getting-started).
After a quick inspection to the source code, I realized the project could be easily ported to javascript (either node and the browser) using the WebUSB api, so I created [netmd-js](https://github.com/cybercase/netmd-js). Then, on top of that I've build **Web MiniDisc** to manage the music on my device without the need of downloading and installing any dedicated software.
To learn React, check out the [React documentation](https://reactjs.org/).
That's it. It was a LOT of fun :).
### Some OSS I've used
- [FFmpeg](https://www.ffmpeg.org/) and [ffmpegjs](https://github.com/ffmpegjs/FFmpeg), to read your audio files (wav, mp3, ogg, mp4, etc...).
- [Atracdenc](https://github.com/dcherednik/atracdenc/), to support atrac3 encoding (lp2, lp4 audio formats).
- [Emscripten](https://emscripten.org/), to run both FFmpeg and Atracdenc in the browser.
- [netmd-js](https://github.com/cybercase/netmd-js), to send commands to NetMD devices using Javascript.
- [linux-minidisc](https://github.com/glaubitz/linux-minidisc), to have made the netmd-js project possible.
- [material-ui](https://material-ui.com/), to build the user interface.

31
extra/BUILD_ATRACDENC.md Normal file
View File

@ -0,0 +1,31 @@
# How to build atracdenc
## required libs
```
git clone https://github.com/erikd/libsndfile #4bdd741
cd libsndfile
docker run -ti -v`pwd`:/src trzeci/emscripten-upstream sh -c bash
mkdir build
cd build
emcmake cmake .. -DBUILD_TESTING=false -DENABLE_EXTERNAL_LIBS=false -DCMAKE_INSTALL_PREFIX=./installation
emmake make
emmake install
exit
```
## Build atracdenc executable
```
git clone https://github.com/dcherednik/atracdenc.git # e16e9c6
cd atracdenc
# Copy the libsndfile library
# cp -r ../libsndfile/build/installation libsndfile
cd src
# Remove the TEST_BIG_ENDIAN block in CMakeFile.txt. WASM is little endian.
docker run -ti -v`pwd`:/src trzeci/emscripten-upstream sh -c bash
mkdir build
cd build
emcmake cmake .. -DLIBSNDFILE_INCLUDE_DIR=../../libsndfile/include/ -DSNDFILE_LIBRARY=../../libsndfile/lib/libsndfile.a
emmake make
# Build an optimized executable
`cat CMakeFiles/atracdenc.dir/link.txt` --closure 1 -Oz -s MODULARIZE=1 -s SINGLE_FILE=1 -s ALLOW_MEMORY_GROWTH=1 -s INVOKE_RUN=0 -s EXTRA_EXPORTED_RUNTIME_METHODS="[callMain, FS]"
```

6
extra/BUILD_FFMPEGJS.md Normal file
View File

@ -0,0 +1,6 @@
# How to build ffmpegjs
1. checkout https://github.com/ffmpegjs/FFmpeg
2. replace `build-js.sh` with the `build-js.sh` from this directory
3. run `build-with-docker.sh`
4. copy `dist/ffmpeg-core.js` to this project `public` folder

306
extra/build-js.sh Normal file
View File

@ -0,0 +1,306 @@
#!/bin/bash -x
set -e -o pipefail
NPROC=$(grep -c ^processor /proc/cpuinfo)
ROOT_DIR=$PWD
BUILD_DIR=$ROOT_DIR/build
build_zlib() {
cd third_party/zlib
rm -rf build zconf.h
mkdir build
cd build
emmake cmake .. \
-DCMAKE_INSTALL_PREFIX=${BUILD_DIR}
emmake make install -j${NPROC}
cd ${ROOT_DIR}
}
build_x264() {
cd third_party/x264
emconfigure ./configure \
--disable-asm \
--disable-thread \
--prefix=$BUILD_DIR
emmake make install-lib-static -j${NPROC}
cd ${ROOT_DIR}
}
configure_ffmpeg() {
emconfigure ./configure \
--nm="llvm-nm -g" \
--ar=emar \
--cc=emcc \
--cxx=em++ \
--objcc=emcc \
--dep-cc=emcc \
--prefix=$BUILD_DIR \
--extra-cflags="-I$BUILD_DIR/include" \
--extra-cxxflags="-I$BUILD_DIR/include" \
--extra-ldflags="-L$BUILD_DIR/lib" \
--enable-gpl \
--disable-pthreads \
--disable-doc \
\
--disable-stripping \
\
--disable-ffmpeg \
--disable-ffprobe \
--disable-ffplay \
\
--disable-indevs \
--disable-outdevs \
\
--disable-x86asm \
--disable-inline-asm \
--disable-bsfs \
--disable-parsers \
--enable-parser=aac \
--enable-parser=ac3 \
--enable-parser=mpegaudio \
--enable-parser=vorbis \
--enable-parser=opus \
--enable-parser=gsm \
--enable-parser=flac \
--enable-parser=dvaudio \
\
--disable-protocols \
--enable-protocol=file \
--enable-rdft \
\
--disable-demuxers \
--enable-demuxer=ffmetadata \
--enable-demuxer=oma \
--enable-demuxer=aac \
--enable-demuxer=ac3 \
--enable-demuxer=ape \
--enable-demuxer=asf \
--enable-demuxer=flac \
--enable-demuxer=mp3 \
--enable-demuxer=mpc \
--enable-demuxer=mov \
--enable-demuxer=mpc8 \
--enable-demuxer=ogg \
--enable-demuxer=tta \
--enable-demuxer=wav \
--enable-demuxer=wv \
--enable-demuxer=pcm_alaw \
--enable-demuxer=pcm_f32be \
--enable-demuxer=pcm_f32le \
--enable-demuxer=pcm_f64be \
--enable-demuxer=pcm_f64le \
--enable-demuxer=pcm_s16be \
--enable-demuxer=pcm_s16le \
--enable-demuxer=pcm_s24be \
--enable-demuxer=pcm_s24le \
--enable-demuxer=pcm_s32be \
--enable-demuxer=pcm_s32le \
--enable-demuxer=pcm_s8 \
--enable-demuxer=pcm_u16be \
--enable-demuxer=pcm_u16le \
--enable-demuxer=pcm_u24be \
--enable-demuxer=pcm_u24le \
\
--disable-muxers \
--enable-muxer=ffmetadata \
--enable-muxer=fifo \
--enable-muxer=null \
--enable-muxer=oma \
--enable-muxer=rawvideo \
--enable-muxer=wav \
--enable-muxer=pcm_alaw \
--enable-muxer=pcm_f32be \
--enable-muxer=pcm_f32le \
--enable-muxer=pcm_f64be \
--enable-muxer=pcm_f64le \
--enable-muxer=pcm_s16be \
--enable-muxer=pcm_s16le \
--enable-muxer=pcm_s24be \
--enable-muxer=pcm_s24le \
--enable-muxer=pcm_s32be \
--enable-muxer=pcm_s32le \
--enable-muxer=pcm_s8 \
--enable-muxer=pcm_u16be \
--enable-muxer=pcm_u16le \
--enable-muxer=pcm_u24be \
--enable-muxer=pcm_u24le \
\
--disable-decoders \
--enable-decoder=opus \
--enable-decoder=aac \
--enable-decoder=ac3 \
--enable-decoder=ape \
--enable-decoder=flac \
--enable-decoder=mp1 \
--enable-decoder=mp2 \
--enable-decoder=mp3 \
--enable-decoder=mpc7 \
--enable-decoder=mpc8 \
--enable-decoder=tta \
--enable-decoder=vorbis \
--enable-decoder=wavpack \
--enable-decoder=pcm_alaw \
--enable-decoder=pcm_dvd \
--enable-decoder=pcm_f32be \
--enable-decoder=pcm_f32le \
--enable-decoder=pcm_f64be \
--enable-decoder=pcm_f64le \
--enable-decoder=pcm_s16be \
--enable-decoder=pcm_s16le \
--enable-decoder=pcm_s16le_planar \
--enable-decoder=pcm_s24be \
--enable-decoder=pcm_s24le \
--enable-decoder=pcm_s32be \
--enable-decoder=pcm_s32le \
--enable-decoder=pcm_s8 \
--enable-decoder=pcm_u16be \
--enable-decoder=pcm_u16le \
--enable-decoder=pcm_u24be \
--enable-decoder=pcm_u24le \
\
--disable-encoders \
--enable-encoder=wavpack \
--enable-encoder=pcm_alaw \
--enable-encoder=pcm_f32be \
--enable-encoder=pcm_f32le \
--enable-encoder=pcm_f64be \
--enable-encoder=pcm_f64le \
--enable-encoder=pcm_s16be \
--enable-encoder=pcm_s16le \
--enable-encoder=pcm_s16le_planar \
--enable-encoder=pcm_s24be \
--enable-encoder=pcm_s24le \
--enable-encoder=pcm_s32be \
--enable-encoder=pcm_s32le \
--enable-encoder=pcm_s8 \
--enable-encoder=pcm_u16be \
--enable-encoder=pcm_u16le \
--enable-encoder=pcm_u24be \
--enable-encoder=pcm_u24le \
\
--disable-filters \
--enable-filter=acompressor \
--enable-filter=acontrast \
--enable-filter=acopy \
--enable-filter=acrossfade \
--enable-filter=acrossover \
--enable-filter=acrusher \
--enable-filter=acue \
--enable-filter=adeclick \
--enable-filter=adeclip \
--enable-filter=adelay \
--enable-filter=aintegral \
--enable-filter=aecho \
--enable-filter=aemphasis \
--enable-filter=aeval \
--enable-filter=afade \
--enable-filter=afftdn \
--enable-filter=afftfilt \
--enable-filter=afir \
--enable-filter=aformat \
--enable-filter=agate \
--enable-filter=aiir \
--enable-filter=alimiter \
--enable-filter=allpass \
--enable-filter=aloop \
--enable-filter=amerge \
--enable-filter=amix \
--enable-filter=amultiply \
--enable-filter=anequalizer \
--enable-filter=anull \
--enable-filter=apad \
--enable-filter=aphaser \
--enable-filter=apulsator \
--enable-filter=aresample \
--enable-filter=areverse \
--enable-filter=asetnsamples \
--enable-filter=asetrate \
--enable-filter=ashowinfo \
--enable-filter=astats \
--enable-filter=atempo \
--enable-filter=atrim \
--enable-filter=bandpass \
--enable-filter=bandreject \
--enable-filter=lowshelf \
--enable-filter=biquad \
--enable-filter=bs2b \
--enable-filter=channelmap \
--enable-filter=channelsplit \
--enable-filter=chorus \
--enable-filter=compand \
--enable-filter=compensationdelay \
--enable-filter=crossfeed \
--enable-filter=crystalizer \
--enable-filter=dcshift \
--enable-filter=drmeter \
--enable-filter=dynaudnorm \
--enable-filter=earwax \
--enable-filter=equalizer \
--enable-filter=extrastereo \
--enable-filter=firequalizer \
--enable-filter=flanger \
--enable-filter=haas \
--enable-filter=hdcd \
--enable-filter=headphone \
--enable-filter=highpass \
--enable-filter=join \
--enable-filter=ladspa \
--enable-filter=loudnorm \
--enable-filter=lowpass \
--enable-filter=lv2 \
--enable-filter=mcompand \
--enable-filter=pan \
--enable-filter=replaygain \
--enable-filter=resample \
--enable-filter=rubberband \
--enable-filter=sidechaincompress \
--enable-filter=sidechaingate \
--enable-filter=silencedetect \
--enable-filter=silenceremove \
--enable-filter=sofalizer \
--enable-filter=stereotools \
--enable-filter=stereowiden \
--enable-filter=superequalizer \
--enable-filter=surround \
--enable-filter=highshelf \
--enable-filter=tremolo \
--enable-filter=vibrato \
--enable-filter=volume \
--enable-filter=volumedetect \
}
make_ffmpeg() {
emmake make -j${NPROC}
}
build_ffmpegjs() {
emcc \
-I. -I./fftools -I$BUILD_DIR/include \
-Llibavcodec -Llibavdevice -Llibavfilter -Llibavformat -Llibavresample -Llibavutil -Llibpostproc -Llibswscale -Llibswresample -Llibpostproc -L${BUILD_DIR}/lib \
-Qunused-arguments -Oz \
-o $2 fftools/ffmpeg_opt.c fftools/ffmpeg_filter.c fftools/ffmpeg_hw.c fftools/cmdutils.c fftools/ffmpeg.c \
-lavdevice -lavfilter -lavformat -lavcodec -lswresample -lswscale -lavutil -lpostproc -lm -lx264 -lz \
--closure 1 \
--pre-js javascript/prepend.js \
--post-js javascript/post.js \
-s USE_SDL=2 \
-s MODULARIZE=1 \
-s SINGLE_FILE=$1 \
-s EXPORTED_FUNCTIONS="[_ffmpeg]" \
-s EXTRA_EXPORTED_RUNTIME_METHODS="[cwrap, FS, getValue, setValue]" \
-s TOTAL_MEMORY=33554432 \
-s ALLOW_MEMORY_GROWTH=1
}
main() {
build_zlib
build_x264
configure_ffmpeg
make_ffmpeg
build_ffmpegjs 1 dist/ffmpeg-core.js
build_ffmpegjs 0 dist-wasm/ffmpeg-core.js
}
main "$@"

14646
package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

View File

@ -3,6 +3,9 @@
"version": "0.1.0",
"private": true,
"dependencies": {
"@ffmpeg/ffmpeg": "^0.6.1",
"@material-ui/core": "^4.9.5",
"@reduxjs/toolkit": "^1.2.5",
"@testing-library/jest-dom": "^4.2.4",
"@testing-library/react": "^9.3.2",
"@testing-library/user-event": "^7.1.2",
@ -10,9 +13,16 @@
"@types/node": "^12.0.0",
"@types/react": "^16.9.0",
"@types/react-dom": "^16.9.0",
"@types/w3c-web-usb": "^1.0.4",
"clsx": "^1.1.0",
"husky": "^4.2.2",
"lint-staged": "^10.0.7",
"prettier": "^1.19.1",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"react-redux": "^7.2.0",
"react-scripts": "3.3.1",
"redux-batched-actions": "^0.4.1",
"typescript": "~3.7.2"
},
"scripts": {
@ -22,7 +32,10 @@
"eject": "react-scripts eject"
},
"eslintConfig": {
"extends": "react-app"
"extends": [
"react-app",
"prettier"
]
},
"browserslist": {
"production": [
@ -35,5 +48,22 @@
"last 1 firefox version",
"last 1 safari version"
]
},
"husky": {
"hooks": {
"pre-commit": "lint-staged"
}
},
"lint-staged": {
"src/**/*.{js,jsx,ts,tsx,json,scss,md}": [
"prettier --check"
]
},
"devDependencies": {
"@material-ui/icons": "^4.9.1",
"@material-ui/lab": "^4.0.0-alpha.45",
"@types/react-redux": "^7.1.7",
"react-dropzone": "^10.2.1",
"worker-loader": "^2.0.0"
}
}

BIN
public/MiniDisc192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB

BIN
public/MiniDisc512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 263 KiB

121
public/atracdenc.js Normal file

File diff suppressed because one or more lines are too long

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.1 KiB

After

Width:  |  Height:  |  Size: 1.1 KiB

189
public/ffmpeg-core.js Normal file

File diff suppressed because one or more lines are too long

View File

@ -3,13 +3,15 @@
<head>
<meta charset="utf-8" />
<link rel="icon" href="%PUBLIC_URL%/favicon.ico" />
<link rel="stylesheet" href="https://fonts.googleapis.com/css?family=Roboto:300,400,500,700&display=swap" />
<link rel="stylesheet" href="https://fonts.googleapis.com/icon?family=Material+Icons" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<meta name="theme-color" content="#000000" />
<meta
name="description"
content="Web site created using create-react-app"
content="Bring back to life your old NetMD MiniDisc player. Upload music to MiniDisc from the Browser."
/>
<link rel="apple-touch-icon" href="%PUBLIC_URL%/logo192.png" />
<link rel="apple-touch-icon" href="%PUBLIC_URL%/MiniDisc192.png" />
<!--
manifest.json provides metadata used when your web app is installed on a
user's mobile device or desktop. See https://developers.google.com/web/fundamentals/web-app-manifest/
@ -24,20 +26,23 @@
work correctly both with client-side routing and a non-root public URL.
Learn how to configure a non-root public URL by running `npm run build`.
-->
<title>React App</title>
<title>Web MiniDisc - Brings NetMD Devices to the Web</title>
</head>
<body>
<script>
try {
if (location.protocol !== 'https:' && !location.host.match(/^localhost/gm)) {
// Make sure we're on https, or WebUSB won't work.
location.replace(`https:${location.href.substring(location.protocol.length)}`);
console.log('Redirecting to https....');
}
} catch (err) {
console.log(err)
}
</script>
<noscript>You need to enable JavaScript to run this app.</noscript>
<div id="root"></div>
<!--
This HTML file is a template.
If you open it directly in the browser, you will see an empty page.
You can add webfonts, meta tags, or analytics to this file.
The build step will place the bundled scripts into the <body> tag.
To begin the development, run `npm start` or `yarn start`.
To create a production bundle, use `npm run build` or `yarn build`.
-->
<script async src="https://platform.twitter.com/widgets.js" charset="utf-8"></script>
</body>
</html>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 9.4 KiB

View File

@ -1,25 +1,28 @@
{
"short_name": "React App",
"name": "Create React App Sample",
"short_name": "Web MiniDisc",
"name": "Web MiniDisc: Brings NetMD Devices to the Web",
"description": "Upload music to NetMD MiniDisc devices",
"icons": [
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
},
{
"src": "logo192.png",
"src": "MiniDisc192.png",
"type": "image/png",
"sizes": "192x192"
},
{
"src": "logo512.png",
"src": "MiniDisc512.png",
"type": "image/png",
"sizes": "512x512"
},
{
"src": "favicon.ico",
"sizes": "64x64 32x32 24x24 16x16",
"type": "image/x-icon"
}
],
"start_url": ".",
"display": "standalone",
"theme_color": "#000000",
"background_color": "#ffffff"
"orientation": "portrait",
"background_color": "#ffffff",
"splash_pages": null
}

188
public/worker.dev.js Normal file

File diff suppressed because one or more lines are too long

1
public/worker.min.js vendored Symbolic link
View File

@ -0,0 +1 @@
../node_modules/@ffmpeg/ffmpeg/dist/worker.min.js

View File

@ -1,38 +0,0 @@
.App {
text-align: center;
}
.App-logo {
height: 40vmin;
pointer-events: none;
}
@media (prefers-reduced-motion: no-preference) {
.App-logo {
animation: App-logo-spin infinite 20s linear;
}
}
.App-header {
background-color: #282c34;
min-height: 100vh;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
font-size: calc(10px + 2vmin);
color: white;
}
.App-link {
color: #61dafb;
}
@keyframes App-logo-spin {
from {
transform: rotate(0deg);
}
to {
transform: rotate(360deg);
}
}

View File

@ -1,9 +0,0 @@
import React from 'react';
import { render } from '@testing-library/react';
import App from './App';
test('renders learn react link', () => {
const { getByText } = render(<App />);
const linkElement = getByText(/learn react/i);
expect(linkElement).toBeInTheDocument();
});

View File

@ -1,26 +0,0 @@
import React from 'react';
import logo from './logo.svg';
import './App.css';
const App = () => {
return (
<div className="App">
<header className="App-header">
<img src={logo} className="App-logo" alt="logo" />
<p>
Edit <code>src/App.tsx</code> and save to reload.
</p>
<a
className="App-link"
href="https://reactjs.org"
target="_blank"
rel="noopener noreferrer"
>
Learn React
</a>
</header>
</div>
);
}
export default App;

View File

@ -0,0 +1,107 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { useShallowEqualSelector } from '../utils';
import { actions as appActions } from '../redux/app-feature';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle';
import Slide from '@material-ui/core/Slide';
import Button from '@material-ui/core/Button';
import Link from '@material-ui/core/Link';
const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const AboutDialog = (props: {}) => {
const dispatch = useDispatch();
let visible = useShallowEqualSelector(state => state.appState.aboutDialogVisible);
const handleClose = () => {
dispatch(appActions.showAboutDialog(false));
};
return (
<Dialog
open={visible}
maxWidth={'sm'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="about-dialog-slide-title"
>
<DialogTitle id="about-dialog-slide-title">About Web MiniDisc</DialogTitle>
<DialogContent>
<DialogContentText>Web MiniDisc has been made possible by</DialogContentText>
<ul>
<li>
<Link rel="noopener noreferrer" href="https://www.ffmpeg.org/" target="_blank">
FFmpeg
</Link>{' '}
and{' '}
<Link rel="noopener noreferrer" href="https://github.com/ffmpegjs/FFmpeg" target="_blank">
ffmpegjs
</Link>
, to read your audio files (wav, mp3, ogg, mp4, etc...).
</li>
<li>
<Link rel="noopener noreferrer" href="https://github.com/dcherednik/atracdenc/" target="_blank">
Atracdenc
</Link>
, to support atrac3 encoding (lp2, lp4 audio formats).
</li>
<li>
<Link rel="noopener noreferrer" href="https://emscripten.org/" target="_blank">
Emscripten
</Link>
, to run both FFmpeg and Atracdenc in the browser.
</li>
<li>
<Link rel="noopener noreferrer" href="https://github.com/cybercase/netmd-js" target="_blank">
netmd-js
</Link>
, to send commands to NetMD devices using Javascript.
</li>
<li>
<Link rel="noopener noreferrer" href="https://github.com/glaubitz/linux-minidisc" target="_blank">
linux-minidisc
</Link>
, to make the netmd-js project possible.
</li>
<li>
<Link rel="noopener noreferrer" href="https://material-ui.com/" target="_blank">
material-ui
</Link>
, to build the user interface.
</li>
</ul>
<DialogContentText>Attribution</DialogContentText>
<ul>
<li>
MiniDisc logo from{' '}
<Link rel="noopener noreferrer" href="https://en.wikipedia.org/wiki/MiniDisc" target="_blank">
https://en.wikipedia.org/wiki/MiniDisc
</Link>
</li>
<li>
MiniDisc icon from{' '}
<Link
rel="noopener noreferrer"
href="https://www.deviantart.com/blinkybill/art/Sony-MiniDisc-Plastic-Icon-473812540"
target="_blank"
>
http://fav.me/d7u3g3g
</Link>
</li>
</ul>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

127
src/components/app.tsx Normal file
View File

@ -0,0 +1,127 @@
import React from 'react';
import { useShallowEqualSelector } from '../utils';
import { actions as appActions } from '../redux/app-feature';
import CssBaseline from '@material-ui/core/CssBaseline';
import Backdrop from '@material-ui/core/Backdrop';
import CircularProgress from '@material-ui/core/CircularProgress';
import { makeStyles, createMuiTheme, ThemeProvider } from '@material-ui/core/styles';
import { Welcome } from './welcome';
import { Main } from './main';
import Paper from '@material-ui/core/Paper';
import Typography from '@material-ui/core/Typography';
import Link from '@material-ui/core/Link';
import Box from '@material-ui/core/Box';
import Brightness2Icon from '@material-ui/icons/Brightness2';
import IconButton from '@material-ui/core/IconButton';
import { useDispatch } from 'react-redux';
const useStyles = makeStyles(theme => ({
layout: {
width: 'auto',
height: '100%',
[theme.breakpoints.up(600 + theme.spacing(2) * 2)]: {
width: 600,
marginLeft: 'auto',
marginRight: 'auto',
},
},
paper: {
position: 'relative',
display: 'flex',
flexDirection: 'column',
padding: theme.spacing(2),
height: '100%',
[theme.breakpoints.up(600 + theme.spacing(2) * 2)]: {
marginTop: theme.spacing(6),
marginBottom: theme.spacing(6),
padding: theme.spacing(3),
height: 600,
},
},
copyright: {
display: 'flex',
alignItems: 'center',
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
minidiscLogo: {
width: 48,
},
}));
const darkTheme = createMuiTheme({
palette: {
type: 'dark',
primary: {
light: '#6ec6ff',
main: '#2196f3',
dark: '#0069c0',
contrastText: '#fff',
},
},
});
const lightTheme = createMuiTheme({
palette: {
type: 'light',
},
});
const App = () => {
const classes = useStyles();
const dispatch = useDispatch();
let { mainView, loading, darkMode } = useShallowEqualSelector(state => state.appState);
return (
<React.Fragment>
<ThemeProvider theme={darkMode ? darkTheme : lightTheme}>
<CssBaseline />
<main className={classes.layout}>
<Paper className={classes.paper}>
{mainView === 'WELCOME' ? <Welcome /> : null}
{mainView === 'MAIN' ? <Main /> : null}
<Box className={classes.copyright}>
<IconButton onClick={() => dispatch(appActions.setDarkMode(!darkMode))}>
<Brightness2Icon color={darkMode ? 'secondary' : undefined} />
</IconButton>
<Typography variant="body2" color="textSecondary" style={{ marginRight: `8px` }}>
{'© '}
<Link rel="noopener noreferrer" color="inherit" target="_blank" href="https://stefano.brilli.me/">
Stefano Brilli
</Link>{' '}
{new Date().getFullYear()}
{'.'}
</Typography>
<Link
rel="noopener noreferrer"
href="https://twitter.com/share?ref_src=twsrc%5Etfw"
className="twitter-share-button"
data-via="thecybercase"
data-hashtags="MiniDisc"
data-dnt="true"
data-show-count="false"
>
Tweet
</Link>
<Box style={{ flex: '1 1 auto' }}></Box>
</Box>
</Paper>
</main>
<Backdrop className={classes.backdrop} open={loading}>
<CircularProgress color="inherit" />
</Backdrop>
</ThemeProvider>
</React.Fragment>
);
};
export default App;

View File

@ -0,0 +1,89 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { useShallowEqualSelector } from '../utils';
import { actions as convertDialogActions } from '../redux/convert-dialog-feature';
import { convertAndUpload } from '../redux/actions';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import Slide from '@material-ui/core/Slide';
import Button from '@material-ui/core/Button';
import { makeStyles } from '@material-ui/core/styles';
import FormControl from '@material-ui/core/FormControl';
import InputLabel from '@material-ui/core/InputLabel';
import Select from '@material-ui/core/Select';
import Input from '@material-ui/core/Input';
import MenuItem from '@material-ui/core/MenuItem';
const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
const useStyles = makeStyles(theme => ({
container: {
display: 'flex',
flexDirection: 'row',
},
formControl: {
minWidth: 120,
},
}));
export const ConvertDialog = (props: { files: File[] }) => {
const dispatch = useDispatch();
const classes = useStyles();
let { visible, format } = useShallowEqualSelector(state => state.convertDialog);
const handleClose = () => {
dispatch(convertDialogActions.setVisible(false));
};
const handleChange = (ev: React.ChangeEvent<{ value: unknown }>) => {
dispatch(convertDialogActions.setFormat(ev.target.value as string));
};
const handleConvert = () => {
handleClose();
dispatch(convertAndUpload(props.files, format));
};
return (
<Dialog
open={visible}
maxWidth={'xs'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="convert-dialog-slide-title"
aria-describedby="convert-dialog-slide-description"
>
<DialogTitle id="convert-dialog-slide-title">Upload Settings</DialogTitle>
<DialogContent>
<FormControl className={classes.formControl}>
<InputLabel color="secondary" id="convert-dialog-format">
Format
</InputLabel>
<Select
labelId="convert-dialog-format-label"
id="convert-dialog-format"
value={format}
color="secondary"
onChange={handleChange}
input={<Input />}
>
<MenuItem value={`SP`}>SP</MenuItem>
<MenuItem value={`LP2`}>LP2</MenuItem>
<MenuItem value={`LP4`}>LP4</MenuItem>
</Select>
</FormControl>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Cancel</Button>
<Button onClick={handleConvert}>Ok</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,46 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { useShallowEqualSelector } from '../utils';
import { actions as errorDialogActions } from '../redux/error-dialog-feature';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle';
import Slide from '@material-ui/core/Slide';
import Button from '@material-ui/core/Button';
const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const ErrorDialog = (props: {}) => {
const dispatch = useDispatch();
let { visible, error } = useShallowEqualSelector(state => state.errorDialog);
const handleClose = () => {
dispatch(errorDialogActions.setVisible(false));
};
return (
<Dialog
open={visible}
maxWidth={'sm'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="error-dialog-slide-title"
aria-describedby="error-dialog-slide-description"
>
<DialogTitle id="alert-dialog-slide-title">Error</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-slide-description">{error}</DialogContentText>
</DialogContent>
<DialogActions>
<Button onClick={handleClose}>Close</Button>
</DialogActions>
</Dialog>
);
};

289
src/components/main.tsx Normal file
View File

@ -0,0 +1,289 @@
import React, { useEffect, useCallback } from 'react';
import { useDispatch } from 'react-redux';
import clsx from 'clsx';
import { useDropzone } from 'react-dropzone';
import { listContent, deleteTracks } from '../redux/actions';
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
import { actions as convertDialogActions } from '../redux/convert-dialog-feature';
import { formatTimeFromFrames, getTracks, Encoding } from 'netmd-js';
import { useShallowEqualSelector } from '../utils';
import { lighten, makeStyles } from '@material-ui/core/styles';
import Typography from '@material-ui/core/Typography';
import Box from '@material-ui/core/Box';
import Fab from '@material-ui/core/Fab';
import AddIcon from '@material-ui/icons/Add';
import DeleteIcon from '@material-ui/icons/Delete';
import EditIcon from '@material-ui/icons/Edit';
import Backdrop from '@material-ui/core/Backdrop';
import Table from '@material-ui/core/Table';
import TableBody from '@material-ui/core/TableBody';
import TableCell from '@material-ui/core/TableCell';
import TableHead from '@material-ui/core/TableHead';
import TableRow from '@material-ui/core/TableRow';
import IconButton from '@material-ui/core/IconButton';
import Toolbar from '@material-ui/core/Toolbar';
import Tooltip from '@material-ui/core/Tooltip';
import { batchActions } from 'redux-batched-actions';
import { RenameDialog } from './rename-dialog';
import { UploadDialog } from './upload-dialog';
import { ErrorDialog } from './error-dialog';
import { ConvertDialog } from './convert-dialog';
import { AboutDialog } from './about-dialog';
import { TopMenu } from './topmenu';
import Checkbox from '@material-ui/core/Checkbox';
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
const useStyles = makeStyles(theme => ({
add: {
position: 'absolute',
bottom: theme.spacing(3),
right: theme.spacing(3),
},
main: {
overflowY: 'auto',
flex: '1 1 auto',
marginBottom: theme.spacing(3),
marginLeft: theme.spacing(-2),
marginRight: theme.spacing(-2),
outline: 'none',
},
toolbar: {
marginTop: theme.spacing(3),
marginLeft: theme.spacing(-2),
marginRight: theme.spacing(-2),
[theme.breakpoints.up(600 + theme.spacing(2) * 2)]: {
marginLeft: theme.spacing(-3),
marginRight: theme.spacing(-3),
},
},
toolbarLabel: {
flex: '1 1 100%',
},
toolbarHighlight:
theme.palette.type === 'light'
? {
color: theme.palette.secondary.main,
backgroundColor: lighten(theme.palette.secondary.light, 0.85),
}
: {
color: theme.palette.text.primary,
backgroundColor: theme.palette.secondary.dark,
},
headBox: {
display: 'flex',
justifyContent: 'space-between',
},
spacing: {
marginTop: theme.spacing(1),
},
formatBadge: {
...(BadgeImpl as any).styles(theme).badge,
...(BadgeImpl as any).styles(theme).colorPrimary,
position: 'static',
display: 'inline-flex',
border: `2px solid ${theme.palette.background.paper}`,
padding: '0 4px',
},
titleCell: {
overflow: 'hidden',
maxWidth: '40ch',
textOverflow: 'ellipsis',
// whiteSpace: 'nowrap',
},
backdrop: {
zIndex: theme.zIndex.drawer + 1,
color: '#fff',
},
}));
const EncodingName: { [k: number]: string } = {
[Encoding.sp]: 'SP',
[Encoding.lp2]: 'LP2',
[Encoding.lp4]: 'LP4',
};
export const Main = (props: {}) => {
let dispatch = useDispatch();
let disc = useShallowEqualSelector(state => state.main.disc);
let deviceName = useShallowEqualSelector(state => state.main.deviceName);
const [selected, setSelected] = React.useState<number[]>([]);
const selectedCount = selected.length;
useEffect(() => {
dispatch(listContent());
}, [dispatch]);
useEffect(() => {
setSelected([]); // Reset selection if disc changes
}, [disc]);
let [uploadedFiles, setUploadedFiles] = React.useState<File[]>([]);
const onDrop = useCallback(
(acceptedFiles: File[], rejectedFiles: File[]) => {
setUploadedFiles(acceptedFiles);
dispatch(convertDialogActions.setVisible(true));
},
[dispatch]
);
const { getRootProps, getInputProps, isDragActive, open } = useDropzone({ onDrop, accept: `audio/*`, noClick: true });
const classes = useStyles();
let tracks: { index: number; title: string; group: string; duration: string; encoding: string }[] = [];
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 ?? ``,
encoding: EncodingName[track.encoding],
duration: formatTimeFromFrames(track.duration, false),
});
}
}
}
// Action Handlers
const handleSelectClick = (event: React.MouseEvent, item: number) => {
if (selected.includes(item)) {
setSelected(selected.filter(i => i !== item));
} else {
setSelected([...selected, item]);
}
};
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
if (selected.length < tracks.length) {
setSelected(tracks.map(t => t.index));
} else {
setSelected([]);
}
};
const handleRenameDoubleClick = (event: React.MouseEvent, item: number) => {
let selectedIndex = item;
let currentName = getTracks(disc!).find(track => track.index === selectedIndex)?.title ?? '';
dispatch(
batchActions([
renameDialogActions.setVisible(true),
renameDialogActions.setCurrentName(currentName),
renameDialogActions.setIndex(selectedIndex),
])
);
};
const handleRenameActionClick = (event: React.MouseEvent) => {
handleRenameDoubleClick(event, selected[0]);
};
const handleDeleteSelected = (event: React.MouseEvent) => {
dispatch(deleteTracks(selected));
};
return (
<React.Fragment>
<Box className={classes.headBox}>
<Typography component="h1" variant="h4">
{deviceName || `Loading...`}
</Typography>
<TopMenu />
</Box>
<Typography component="h2" variant="body2">
{disc !== null
? `${formatTimeFromFrames(disc.left, false)} left of ${formatTimeFromFrames(disc.total, false)}`
: `Loading...`}
</Typography>
<Toolbar
className={clsx(classes.toolbar, {
[classes.toolbarHighlight]: selectedCount > 0,
})}
>
{selectedCount > 0 ? (
<Checkbox
indeterminate={selectedCount > 0 && selectedCount < tracks.length}
checked={selectedCount > 0}
onChange={handleSelectAllClick}
inputProps={{ 'aria-label': 'select all tracks' }}
/>
) : null}
{selectedCount > 0 ? (
<Typography className={classes.toolbarLabel} color="inherit" variant="subtitle1">
{selectedCount} selected
</Typography>
) : (
<Typography component="h3" variant="h6" className={classes.toolbarLabel}>
Content
</Typography>
)}
{selectedCount > 0 ? (
<Tooltip title="Delete">
<IconButton aria-label="delete" onClick={handleDeleteSelected}>
<DeleteIcon />
</IconButton>
</Tooltip>
) : null}
{selectedCount > 0 ? (
<Tooltip title="Rename">
<IconButton aria-label="rename" disabled={selectedCount !== 1} onClick={handleRenameActionClick}>
<EditIcon />
</IconButton>
</Tooltip>
) : null}
</Toolbar>
<Box className={classes.main} {...getRootProps()}>
<input {...getInputProps()} />
<Table size="small">
<TableHead>
<TableRow>
<TableCell>Title</TableCell>
<TableCell>Format</TableCell>
<TableCell align="right">Duration</TableCell>
</TableRow>
</TableHead>
<TableBody>
{tracks.map(track => (
<TableRow
hover
selected={selected.includes(track.index)}
key={track.index}
onDoubleClick={event => handleRenameDoubleClick(event, track.index)}
onClick={event => handleSelectClick(event, track.index)}
>
<TableCell className={classes.titleCell} title={track.title}>
{track.title || `No Title`}
</TableCell>
<TableCell>
<span className={classes.formatBadge}>{track.encoding}</span>
</TableCell>
<TableCell align="right">{track.duration}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Backdrop className={classes.backdrop} open={isDragActive}>
Drop your Music to Upload
</Backdrop>
</Box>
<Fab color="primary" aria-label="add" className={classes.add} onClick={open}>
<AddIcon />
</Fab>
<UploadDialog />
<RenameDialog />
<ErrorDialog />
<ConvertDialog files={uploadedFiles} />
<AboutDialog />
</React.Fragment>
);
};

View File

@ -0,0 +1,68 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import { useShallowEqualSelector } from '../utils';
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
import { renameTrack } from '../redux/actions';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogTitle from '@material-ui/core/DialogTitle';
import TextField from '@material-ui/core/TextField';
import Slide from '@material-ui/core/Slide';
import Button from '@material-ui/core/Button';
const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const RenameDialog = (props: {}) => {
let dispatch = useDispatch();
let renameDialogVisible = useShallowEqualSelector(state => state.renameDialog.visible);
let renameDialogTitle = useShallowEqualSelector(state => state.renameDialog.title);
let renameDialogIndex = useShallowEqualSelector(state => state.renameDialog.index);
const handleCancelRename = () => {
dispatch(renameDialogActions.setVisible(false));
};
const handleDoRename = () => {
dispatch(renameTrack({ index: renameDialogIndex, newName: renameDialogTitle }));
};
return (
<Dialog
open={renameDialogVisible}
onClose={handleCancelRename}
maxWidth={'sm'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="rename-dialog-title"
>
<DialogTitle id="rename-dialog-title">Rename Track</DialogTitle>
<DialogContent>
<TextField
autoFocus
id="name"
label="Track Name"
type="text"
fullWidth
value={renameDialogTitle}
onKeyDown={event => {
event.key === `Enter` && handleDoRename();
}}
onChange={event => {
dispatch(renameDialogActions.setCurrentName(event.target.value));
}}
/>
</DialogContent>
<DialogActions>
<Button onClick={handleCancelRename}>Cancel</Button>
<Button color={'primary'} onClick={handleDoRename}>
Rename
</Button>
</DialogActions>
</Dialog>
);
};

View File

@ -0,0 +1,89 @@
import React from 'react';
import { useDispatch } from 'react-redux';
import IconButton from '@material-ui/core/IconButton';
import Menu from '@material-ui/core/Menu';
import MenuItem from '@material-ui/core/MenuItem';
import MoreVertIcon from '@material-ui/icons/MoreVert';
import { wipeDisc, listContent } from '../redux/actions';
import { actions as appActions } from '../redux/app-feature';
import { useShallowEqualSelector } from '../utils';
import Link from '@material-ui/core/Link';
export const TopMenu = function() {
const dispatch = useDispatch();
let { mainView } = useShallowEqualSelector(state => state.appState);
const [menuAnchorEl, setMenuAnchorEl] = React.useState<null | HTMLElement>(null);
const menuOpen = Boolean(menuAnchorEl);
const handleMenuClick = (event: React.MouseEvent<HTMLElement>) => {
setMenuAnchorEl(event.currentTarget);
};
const handleMenuClose = () => {
setMenuAnchorEl(null);
};
const handleWipeDisc = () => {
dispatch(wipeDisc());
handleMenuClose();
};
const handleRefresh = () => {
dispatch(listContent());
handleMenuClose();
};
const handleExit = () => {
dispatch(appActions.setState('WELCOME'));
handleMenuClose();
};
const handleShowAbout = () => {
dispatch(appActions.showAboutDialog(true));
handleMenuClose();
};
const menuItems = [];
if (mainView === 'MAIN') {
menuItems.push(
<MenuItem key="update" onClick={handleRefresh}>
Refresh
</MenuItem>
);
menuItems.push(
<MenuItem key="wipe" onClick={handleWipeDisc}>
Wipe Disc
</MenuItem>
);
menuItems.push(
<MenuItem key="exit" onClick={handleExit}>
Exit
</MenuItem>
);
}
menuItems.push(
<MenuItem key="about" onClick={handleShowAbout}>
About
</MenuItem>
);
menuItems.push(
<MenuItem key="github" onClick={handleMenuClose}>
<Link rel="noopener noreferrer" href="https://github.com/cybercase/webminidisc" target="_blank">
Fork me on GitHub
</Link>
</MenuItem>
);
return (
<React.Fragment>
<IconButton aria-label="actions" aria-controls="actions-menu" aria-haspopup="true" onClick={handleMenuClick}>
<MoreVertIcon />
</IconButton>
<Menu id="actions-menu" anchorEl={menuAnchorEl} keepMounted open={menuOpen} onClose={handleMenuClose}>
{menuItems}
</Menu>
</React.Fragment>
);
};

View File

@ -0,0 +1,83 @@
import React from 'react';
import { useShallowEqualSelector } from '../utils';
import Dialog from '@material-ui/core/Dialog';
import DialogActions from '@material-ui/core/DialogActions';
import DialogContent from '@material-ui/core/DialogContent';
import DialogContentText from '@material-ui/core/DialogContentText';
import DialogTitle from '@material-ui/core/DialogTitle';
import Slide from '@material-ui/core/Slide';
import LinearProgress from '@material-ui/core/LinearProgress';
import Box from '@material-ui/core/Box';
import { makeStyles } from '@material-ui/core/styles';
const useStyles = makeStyles(theme => ({
progressPerc: {
marginTop: theme.spacing(1),
},
progressBar: {
marginTop: theme.spacing(3),
},
uploadLabel: {
marginTop: theme.spacing(3),
},
}));
const Transition = React.forwardRef(function Transition(props, ref) {
return <Slide direction="up" ref={ref} {...props} />;
});
export const UploadDialog = (props: {}) => {
const classes = useStyles();
let {
visible,
writtenProgress,
encryptedProgress,
totalProgress,
trackTotal,
trackCurrent,
trackConverting,
titleCurrent,
titleConverting,
} = useShallowEqualSelector(state => state.uploadDialog);
let progressValue = Math.floor((writtenProgress / totalProgress) * 100);
let bufferValue = Math.floor((encryptedProgress / totalProgress) * 100);
let convertedValue = Math.floor((trackConverting / trackTotal) * 100);
return (
<Dialog
open={visible}
maxWidth={'sm'}
fullWidth={true}
TransitionComponent={Transition as any}
aria-labelledby="alert-dialog-slide-title"
aria-describedby="alert-dialog-slide-description"
>
<DialogTitle id="alert-dialog-slide-title">Recording...</DialogTitle>
<DialogContent>
<DialogContentText id="alert-dialog-slide-description">
{convertedValue === 100 && trackConverting === trackTotal
? `Convertion completed`
: `Converting ${trackConverting + 1} of ${trackTotal}: ${titleConverting}`}
</DialogContentText>
<LinearProgress className={classes.progressBar} variant="determinate" color="primary" value={convertedValue} />
<Box className={classes.progressPerc}>{convertedValue}%</Box>
<DialogContentText id="alert-dialog-slide-description" className={classes.uploadLabel}>
Uploading {trackCurrent} of {trackTotal}: {titleCurrent}
</DialogContentText>
<LinearProgress
className={classes.progressBar}
variant="buffer"
color="secondary"
value={progressValue}
valueBuffer={bufferValue}
/>
<Box className={classes.progressPerc}>{progressValue}%</Box>
</DialogContent>
<DialogActions></DialogActions>
</Dialog>
);
};

138
src/components/welcome.tsx Normal file
View File

@ -0,0 +1,138 @@
import React, { useState } from 'react';
import { useDispatch } from 'react-redux';
import { pair } from '../redux/actions';
import { useShallowEqualSelector } from '../utils';
import { makeStyles } from '@material-ui/core/styles';
import Button from '@material-ui/core/Button';
import Typography from '@material-ui/core/Typography';
import FormControl from '@material-ui/core/FormControl';
import FormHelperText from '@material-ui/core/FormHelperText';
import Box from '@material-ui/core/Box';
import Link from '@material-ui/core/Link';
import { AboutDialog } from './about-dialog';
import { TopMenu } from './topmenu';
import ChromeIconPath from '../images/chrome-icon.svg';
const useStyles = makeStyles(theme => ({
main: {
position: 'relative',
flex: '1 1 auto',
display: 'flex',
justifyContent: 'center',
flexDirection: 'column',
alignItems: 'center',
},
button: {
marginTop: theme.spacing(3),
minWidth: 150,
},
spacing: {
marginTop: theme.spacing(1),
},
chromeLogo: {
marginTop: theme.spacing(1),
width: 96,
height: 96,
},
why: {
alignSelf: 'flex-start',
marginTop: theme.spacing(3),
},
headBox: {
display: 'flex',
justifyContent: 'space-between',
},
}));
export const Welcome = (props: {}) => {
const classes = useStyles();
const dispatch = useDispatch();
const { browserSupported, pairingFailed, pairingMessage } = useShallowEqualSelector(state => state.appState);
if (pairingMessage.toLowerCase().match(/denied/)) {
// show linux instructions
}
// Access denied.
const [showWhyUnsupported, setWhyUnsupported] = useState(false);
const handleLearnWhy = (event: React.SyntheticEvent) => {
event.preventDefault();
setWhyUnsupported(true);
};
return (
<React.Fragment>
<Box className={classes.headBox}>
<Typography component="h1" variant="h4">
Web MiniDisc
</Typography>
<TopMenu />
</Box>
<Typography component="h2" variant="body2">
Brings NetMD Devices to the Web
</Typography>
<Box className={classes.main}>
{browserSupported ? (
<React.Fragment>
<Typography component="h2" variant="subtitle1" align="center" className={classes.spacing}>
Press the button to connect to a NetMD device
</Typography>
<Button variant="contained" color="primary" onClick={() => dispatch(pair())} className={classes.button}>
Connect
</Button>
<FormControl error={true} className={classes.spacing} style={{ visibility: pairingFailed ? 'visible' : 'hidden' }}>
<FormHelperText>{pairingMessage}</FormHelperText>
</FormControl>
</React.Fragment>
) : (
<React.Fragment>
<Typography component="h2" variant="subtitle1" align="center" className={classes.spacing}>
This Web browser is not supported.&nbsp;
<Link rel="noopener noreferrer" href="#" onClick={handleLearnWhy}>
Learn Why
</Link>
</Typography>
<Link rel="noopener noreferrer" target="_blank" href="https://www.google.com/chrome/">
<img alt="Chrome Logo" src={ChromeIconPath} className={classes.chromeLogo} />
</Link>
<Typography component="h2" variant="subtitle1" align="center" className={classes.spacing}>
Try using{' '}
<Link rel="noopener noreferrer" target="_blank" href="https://www.google.com/chrome/">
Chrome
</Link>{' '}
instead
</Typography>
{showWhyUnsupported ? (
<>
<Typography component="p" variant="body2" className={classes.why}>
Web MiniDisc requires a browser that supports both{' '}
<Link rel="noopener noreferrer" target="_blank" href="https://wicg.github.io/webusb/">
WebUSB
</Link>{' '}
and{' '}
<Link rel="noopener noreferrer" target="_blank" href="https://webassembly.org/">
WebAssembly
</Link>
.
</Typography>
<ul>
<li>WebUSB is needed to control the NetMD device via the USB connection to your computer.</li>
<li>WebAssembly is used to convert the music to a MiniDisc compatible format</li>
</ul>
</>
) : null}
</React.Fragment>
)}
</Box>
<AboutDialog />
</React.Fragment>
);
};

105
src/images/chrome-icon.svg Normal file
View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="1 1 176 176">
<defs>
<circle id="a" cy="96" cx="96" r="88"/>
</defs>
<clipPath id="b">
<use width="100%" overflow="visible" xlink:href="#a" height="100%"/>
</clipPath>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<path d="m21.97 8v108h39.39l34.64-60h88v-48z" fill="#db4437"/>
<linearGradient id="c" y2="44.354" gradientUnits="userSpaceOnUse" y1="75.021" x2="81.837" x1="29.337">
<stop stop-color="#A52714" stop-opacity=".6" offset="0"/>
<stop stop-color="#A52714" stop-opacity="0" offset=".66"/>
</linearGradient>
<path d="m21.97 8v108h39.39l34.64-60h88v-48z" fill="url(#c)"/>
</g>
<path clip-path="url(#b)" fill-opacity=".15" d="m62.31 115.65l-39.83-68.31-0.58 1 39.54 67.8z" transform="translate(-7 -7)" fill="#3e2723"/>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<path d="m8 184h83.77l38.88-38.88v-29.12h-69.29l-53.36-91.52z" fill="#0f9d58"/>
<linearGradient id="d" y2="130.33" gradientUnits="userSpaceOnUse" y1="164.5" x2="52.538" x1="110.87">
<stop stop-color="#055524" stop-opacity=".4" offset="0"/>
<stop stop-color="#055524" stop-opacity="0" offset=".33"/>
</linearGradient>
<path d="m8 184h83.77l38.88-38.88v-29.12h-69.29l-53.36-91.52z" fill="url(#d)"/>
</g>
<path clip-path="url(#b)" fill-opacity=".15" d="m129.84 117.33l-0.83-0.48-38.39 67.15h1.15l38.1-66.64z" transform="translate(-7 -7)" fill="#263238"/>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<defs>
<path id="e" d="m8 184h83.77l38.88-38.88v-29.12h-69.29l-53.36-91.52z"/>
</defs>
<clipPath id="f">
<use width="100%" overflow="visible" xlink:href="#e" height="100%"/>
</clipPath>
<g clip-path="url(#f)">
<path d="m96 56l34.65 60-38.88 68h92.23v-128z" fill="#ffcd40"/>
<linearGradient id="g" y2="114.13" gradientUnits="userSpaceOnUse" y1="49.804" x2="136.55" x1="121.86">
<stop stop-color="#EA6100" stop-opacity=".3" offset="0"/>
<stop stop-color="#EA6100" stop-opacity="0" offset=".66"/>
</linearGradient>
<path d="m96 56l34.65 60-38.88 68h92.23v-128z" fill="url(#g)"/>
</g>
</g>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<path d="m96 56l34.65 60-38.88 68h92.23v-128z" fill="#ffcd40"/>
<path d="m96 56l34.65 60-38.88 68h92.23v-128z" fill="url(#g)"/>
</g>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<defs>
<path id="i" d="m96 56l34.65 60-38.88 68h92.23v-128z"/>
</defs>
<clipPath id="j">
<use width="100%" overflow="visible" xlink:href="#i" height="100%"/>
</clipPath>
<g clip-path="url(#j)">
<path d="m21.97 8v108h39.39l34.64-60h88v-48z" fill="#db4437"/>
<path d="m21.97 8v108h39.39l34.64-60h88v-48z" fill="url(#c)"/>
</g>
</g>
<radialGradient id="l" gradientUnits="userSpaceOnUse" cy="55.948" cx="668.18" gradientTransform="translate(-576)" r="84.078">
<stop stop-color="#3E2723" stop-opacity=".2" offset="0"/>
<stop stop-color="#3E2723" stop-opacity="0" offset="1"/>
</radialGradient>
<path clip-path="url(#b)" d="m96 56v20.95l78.4-20.95z" transform="translate(-7 -7)" fill="url(#l)"/>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<defs>
<path id="m" d="m21.97 8v40.34l39.39 67.66 34.64-60h88v-48z"/>
</defs>
<clipPath id="n">
<use width="100%" overflow="visible" xlink:href="#m" height="100%"/>
</clipPath>
<g clip-path="url(#n)">
<path d="m8 184h83.77l38.88-38.88v-29.12h-69.29l-53.36-91.52z" fill="#0f9d58"/>
<path d="m8 184h83.77l38.88-38.88v-29.12h-69.29l-53.36-91.52z" fill="url(#d)"/>
</g>
</g>
<radialGradient id="p" gradientUnits="userSpaceOnUse" cy="48.52" cx="597.88" gradientTransform="translate(-576)" r="78.044">
<stop stop-color="#3E2723" stop-opacity=".2" offset="0"/>
<stop stop-color="#3E2723" stop-opacity="0" offset="1"/>
</radialGradient>
<path clip-path="url(#b)" d="m21.97 48.45l57.25 57.24-17.86 10.31z" transform="translate(-7 -7)" fill="url(#p)"/>
<radialGradient id="q" gradientUnits="userSpaceOnUse" cy="96.138" cx="671.84" gradientTransform="translate(-576)" r="87.87">
<stop stop-color="#263238" stop-opacity=".2" offset="0"/>
<stop stop-color="#263238" stop-opacity="0" offset="1"/>
</radialGradient>
<path clip-path="url(#b)" d="m91.83 183.89l20.96-78.2 17.86 10.31z" transform="translate(-7 -7)" fill="url(#q)"/>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<circle cy="96" cx="96" r="40" fill="#f1f1f1"/>
<circle cy="96" cx="96" r="32" fill="#4285f4"/>
</g>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<path fill-opacity=".2" d="m96 55c-22.09 0-40 17.91-40 40v1c0-22.09 17.91-40 40-40h88v-1h-88z" fill="#3e2723"/>
<path fill-opacity=".1" d="m130.6 116c-6.92 11.94-19.81 20-34.6 20-14.8 0-27.69-8.06-34.61-20h-0.04l-53.35-91.52v1l53.36 91.52h0.04c6.92 11.94 19.81 20 34.61 20 14.79 0 27.68-8.05 34.6-20h0.05v-1h-0.06z" fill="#fff"/>
<path opacity=".1" d="m97 56c-0.17 0-0.33 0.02-0.5 0.03 21.86 0.27 39.5 18.05 39.5 39.97s-17.64 39.7-39.5 39.97c0.17 0 0.33 0.03 0.5 0.03 22.09 0 40-17.91 40-40s-17.91-40-40-40z" fill="#3e2723"/>
<path fill-opacity=".2" d="m131 117.33c3.4-5.88 5.37-12.68 5.37-19.96 0-4.22-0.66-8.28-1.87-12.09 0.95 3.42 1.5 7.01 1.5 10.73 0 7.28-1.97 14.08-5.37 19.96l0.02 0.04-38.88 68h1.16l38.09-66.64-0.02-0.04z" fill="#fff"/>
</g>
<g transform="translate(-7 -7)" clip-path="url(#b)">
<path fill-opacity=".2" d="m96 9c48.43 0 87.72 39.13 87.99 87.5 0-0.17 0.01-0.33 0.01-0.5 0-48.6-39.4-88-88-88s-88 39.4-88 88c0 0.17 0.01 0.33 0.01 0.5 0.27-48.37 39.56-87.5 87.99-87.5z" fill="#fff"/>
<path fill-opacity=".15" d="m96 183c48.43 0 87.72-39.13 87.99-87.5 0 0.17 0.01 0.33 0.01 0.5 0 48.6-39.4 88-88 88s-88-39.4-88-88c0-0.17 0.01-0.33 0.01-0.5 0.27 48.37 39.56 87.5 87.99 87.5z" fill="#3e2723"/>
</g>
<radialGradient id="r" gradientUnits="userSpaceOnUse" cy="32.014" cx="34.286" gradientTransform="translate(-7 -7)" r="176.75">
<stop stop-color="#fff" stop-opacity=".1" offset="0"/>
<stop stop-color="#fff" stop-opacity="0" offset="1"/>
</radialGradient>
<circle cy="89" cx="89" r="88" fill="url(#r)"/>
</svg>

After

Width:  |  Height:  |  Size: 6.0 KiB

View File

@ -0,0 +1,67 @@
<?xml version="1.0" encoding="utf-8"?>
<!-- Generator: Adobe Illustrator 13.0.0, SVG Export Plug-In -->
<!DOCTYPE svg PUBLIC "-//W3C//DTD SVG 1.1//EN" "http://www.w3.org/Graphics/SVG/1.1/DTD/svg11.dtd" [
<!ENTITY ns_flows "http://ns.adobe.com/Flows/1.0/">
]>
<svg version="1.1"
xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" xmlns:a="http://ns.adobe.com/AdobeSVGViewerExtensions/3.0/"
x="0px" y="0px" width="162px" height="153px" viewBox="0 -0.333 162 153" enable-background="new 0 -0.333 162 153"
xml:space="preserve">
<defs>
</defs>
<polygon opacity="0" fill-rule="evenodd" clip-rule="evenodd" fill="#FFFFFF" points="0,0 161.333,0 161.333,152.422 0,152.422 0,0
"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M34.243,36.278c0.704,1.93,0.493,3.727,1.479,8.719
c1.268,6.655,6.481,20.898,8.102,27.087h0.211h9.299c1.691-6.189,6.834-20.433,8.102-27.087c0.986-4.992,0.775-6.79,1.479-8.719
v35.807h9.299V28.957H57.278c-2.535,8.319-6.058,18.237-7.256,24.359c-1.127,5.723-1.127,8.119-1.409,11.647
c-0.352-3.528-0.281-5.924-1.479-11.647c-0.564-3.062-1.761-7.055-3.1-11.314c-1.338-4.26-2.889-8.853-4.156-13.045H24.944v43.127
h9.299V36.278L34.243,36.278z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.574,39.539V29.156h-5.496h-5.494v10.382h5.494H138.574L138.574,39.539z
M130.754,36.544v-4.459h2.324h2.396v4.459h-2.396H130.754L130.754,36.544z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M83.204,85.397h5.494h5.495V75.013h-5.495h-5.494V85.397L83.204,85.397z
M86.303,78.008h2.395h2.325v4.459h-2.325h-2.395V78.008L86.303,78.008z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M49.53,75.013h-5.495H24.944v47.853h19.091h5.495
c7.467,0,13.526-5.724,13.526-12.777v-3.261v-4.262v-7.188v-4.991v-2.529C63.056,80.804,56.997,75.013,49.53,75.013L49.53,75.013z
M53.686,86.594v1.796v21.965v1.264v2.063c0,0.732-0.634,1.332-1.41,1.332h-0.915h-2.606h-4.72h-9.651V83.732h9.651h4.72h2.606
h0.915c0.775,0,1.41,0.6,1.41,1.331V86.594L53.686,86.594z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M138.574,72.084V42.601h-5.496h-5.494v29.484h5.494H138.574L138.574,72.084z
M130.754,69.622V45.595h2.324h2.396v24.026h-2.396H130.754L130.754,69.622z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88.416,39.539V29.156h-5.424h-5.566v10.382h5.566H88.416L88.416,39.539z
M80.526,36.544v-4.459h2.466h2.254v4.459h-2.254H80.526L80.526,36.544z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M123.779,85.397V75.013h-5.494h-5.495v10.384h5.495H123.779L123.779,85.397z
M115.89,82.467v-4.459h2.396h2.323v4.459h-2.323H115.89L115.89,82.467z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M120.328,87.858c-0.705,0-1.409,0.066-2.043,0.267
c-2.537,0.865-4.369,3.129-4.369,5.724c0,7.654,0,15.375,0,23.029c0,2.594,1.832,4.856,4.369,5.724
c0.634,0.198,1.338,0.265,2.043,0.265h12.75h5.566v-9.717h-5.566h-8.594c0-5.191,0-10.382,0-15.573h8.594h5.566v-9.718h-5.566
H120.328L120.328,87.858z M135.544,90.585v3.994h-2.466h-11.764c0,7.188,0,14.377,0,21.565h11.764h2.466v3.992h-2.466h-12.961
c-0.705,0-1.339-0.266-1.832-0.599c-0.775-0.533-1.269-1.397-1.269-2.396c0-7.853,0-15.706,0-23.56c0-1,0.493-1.863,1.269-2.396
c0.493-0.4,1.127-0.602,1.832-0.602h12.961H135.544L135.544,90.585z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M140.194,7.792h-7.116h-14.793h-10.145h-4.58H88.698h-5.706h-9.017h-29.94H21.069
c-8.313,0-15.075,6.455-15.075,14.309V130.32c0,7.921,6.762,14.31,15.075,14.31h22.965h29.94h9.017h5.706h14.863h4.58h10.145h14.793
h7.116c8.313,0,15.146-6.455,15.146-14.31V22.101C155.34,14.248,148.507,7.792,140.194,7.792L140.194,7.792z M151.746,130.121
c0,6.058-5.353,11.114-11.764,11.114h-6.904h-14.793h-10.145h-4.58H88.698h-5.706h-9.017h-29.94H21.351
c-6.48,0-11.765-5.057-11.765-11.114V22.368c0-6.123,5.284-11.114,11.765-11.114h22.684h29.94h9.017h5.706h14.863h4.58h10.145
h14.793h6.904c6.411,0,11.764,4.991,11.764,11.114V130.121L151.746,130.121z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M127.653,75.013v10.384h5.425h5.566V75.013h-5.566H127.653L127.653,75.013z
M135.474,78.008v4.459h-2.396h-2.324v-4.459h2.324H135.474L135.474,78.008z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M118.285,72.084h4.508V50.72c0-3.061-1.831-5.791-4.508-7.188
c-1.269-0.6-2.605-0.932-4.086-0.932h-6.059h-4.58h-1.618c-4.72,0-8.666,3.661-8.666,8.12v21.364h10.284h0.494v-19.5h4.086h4.086
v19.5H118.285L118.285,72.084z M108.141,49.656h-4.58h-2.605v19.966h-4.507V50.787c0-2.861,2.465-5.192,5.424-5.192h1.688h4.58
h6.269c1.48,0,2.89,0.6,3.876,1.531c0.986,0.932,1.549,2.263,1.549,3.661v18.834h-1.549h-2.889V49.656H108.141L108.141,49.656z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M68.48,87.858v35.008h5.495h5.494V87.858h-5.494H68.48L68.48,87.858z
M76.37,90.986v29.284h-2.395H71.65V90.986h2.325H76.37L76.37,90.986z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M88.485,42.601h-5.493h-5.495v29.484h5.495h5.493V42.601L88.485,42.601z
M85.386,69.622h-2.394h-2.325V45.595h2.325h2.394V69.622L85.386,69.622z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.141,85.397h0.916V75.013h-0.916h-4.58h-5.493v10.384h5.493H108.141
L108.141,85.397z M101.168,82.467v-4.459h2.393h2.324v4.459h-2.324H101.168L101.168,82.467z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M79.399,85.397V75.013h-5.424h-5.566v10.384h5.566H79.399L79.399,85.397z
M71.509,82.467v-4.459h2.466h2.254v4.459h-2.254H71.509L71.509,82.467z"/>
<path fill-rule="evenodd" clip-rule="evenodd" d="M108.141,102.036c-1.128-1-2.607-1.599-4.298-1.599h-0.282H93.7v-2.861h9.861h4.58
h1.551v-9.718h-1.551h-4.58H89.543c-0.281,0-0.563,0-0.845,0c-3.099,0.467-5.494,2.996-5.494,5.99v0.267v9.915v0.201
c0,3.062,2.395,5.591,5.494,5.989c0.282,0.066,0.564,0.066,0.845,0.066h10.145v2.862h-10.99h-4.931v9.717h4.931h14.863h0.282
c1.69,0,3.17-0.599,4.298-1.53c1.269-1.132,2.113-2.729,2.113-4.458v-0.268v-9.917v-0.199
C110.254,104.698,109.409,103.099,108.141,102.036L108.141,102.036z M107.225,106.693v9.917v0.532c0,1.664-1.408,2.995-3.17,2.995
h-0.494H88.698h-1.832v-3.992h1.832h14.16v-8.654H89.402c-0.281,0-0.492,0-0.704-0.064c-1.409-0.267-2.466-1.466-2.466-2.93v-0.467
v-9.915v-0.532c0-1.398,1.057-2.597,2.466-2.929c0.212,0,0.423-0.068,0.704-0.068h14.159h3.031v3.994h-3.031H90.599v8.652h12.961
h0.494c1.762,0,3.17,1.331,3.17,2.93V106.693L107.225,106.693z"/>
</svg>

After

Width:  |  Height:  |  Size: 6.2 KiB

View File

@ -1,13 +1,10 @@
body {
margin: 0;
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', 'Roboto', 'Oxygen',
'Ubuntu', 'Cantarell', 'Fira Sans', 'Droid Sans', 'Helvetica Neue',
sans-serif;
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
body, html {
height: 100%;
width: 100%;
position: fixed;
overflow: hidden;
}
code {
font-family: source-code-pro, Menlo, Monaco, Consolas, 'Courier New',
monospace;
#root {
height: 100%;
}

View File

@ -1,12 +1,54 @@
/* eslint no-restricted-globals: 0 */
import React from 'react';
import ReactDOM from 'react-dom';
import './index.css';
import App from './App';
import { Provider } from 'react-redux';
import * as serviceWorker from './serviceWorker';
import { NetMDUSBService } from './services/netmd';
import serviceRegistry from './services/registry';
ReactDOM.render(<App />, document.getElementById('root'));
import { store } from './redux/store';
import { actions as appActions } from './redux/app-feature';
// If you want your app to work offline and load faster, you can change
// unregister() to register() below. Note this comes with some pitfalls.
// Learn more about service workers: https://bit.ly/CRA-PWA
serviceWorker.unregister();
import App from './components/app';
import './index.css';
import { FFMpegAudioExportService } from './services/audio-export';
serviceRegistry.netmdService = new NetMDUSBService();
serviceRegistry.audioExportService = new FFMpegAudioExportService();
(function setupEventHandlers() {
window.addEventListener('beforeunload', ev => {
let isUploading = store.getState().uploadDialog.visible;
if (!isUploading) {
return;
}
ev.preventDefault();
ev.returnValue = `Warning! Recording will be interrupted`;
});
if (navigator && navigator.usb) {
navigator.usb.ondisconnect = function() {
store.dispatch(appActions.setState('WELCOME'));
};
} else {
store.dispatch(appActions.setBrowserSupported(false));
}
// eslint-disable-next-line
let deferredPrompt: any;
window.addEventListener('beforeinstallprompt', (e: any) => {
e.preventDefault();
deferredPrompt = e;
});
})();
ReactDOM.render(
<Provider store={store}>
<App />
</Provider>,
document.getElementById('root')
);
// serviceWorker.unregister();
serviceWorker.register();

View File

@ -1,7 +0,0 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 841.9 595.3">
<g fill="#61DAFB">
<path d="M666.3 296.5c0-32.5-40.7-63.3-103.1-82.4 14.4-63.6 8-114.2-20.2-130.4-6.5-3.8-14.1-5.6-22.4-5.6v22.3c4.6 0 8.3.9 11.4 2.6 13.6 7.8 19.5 37.5 14.9 75.7-1.1 9.4-2.9 19.3-5.1 29.4-19.6-4.8-41-8.5-63.5-10.9-13.5-18.5-27.5-35.3-41.6-50 32.6-30.3 63.2-46.9 84-46.9V78c-27.5 0-63.5 19.6-99.9 53.6-36.4-33.8-72.4-53.2-99.9-53.2v22.3c20.7 0 51.4 16.5 84 46.6-14 14.7-28 31.4-41.3 49.9-22.6 2.4-44 6.1-63.6 11-2.3-10-4-19.7-5.2-29-4.7-38.2 1.1-67.9 14.6-75.8 3-1.8 6.9-2.6 11.5-2.6V78.5c-8.4 0-16 1.8-22.6 5.6-28.1 16.2-34.4 66.7-19.9 130.1-62.2 19.2-102.7 49.9-102.7 82.3 0 32.5 40.7 63.3 103.1 82.4-14.4 63.6-8 114.2 20.2 130.4 6.5 3.8 14.1 5.6 22.5 5.6 27.5 0 63.5-19.6 99.9-53.6 36.4 33.8 72.4 53.2 99.9 53.2 8.4 0 16-1.8 22.6-5.6 28.1-16.2 34.4-66.7 19.9-130.1 62-19.1 102.5-49.9 102.5-82.3zm-130.2-66.7c-3.7 12.9-8.3 26.2-13.5 39.5-4.1-8-8.4-16-13.1-24-4.6-8-9.5-15.8-14.4-23.4 14.2 2.1 27.9 4.7 41 7.9zm-45.8 106.5c-7.8 13.5-15.8 26.3-24.1 38.2-14.9 1.3-30 2-45.2 2-15.1 0-30.2-.7-45-1.9-8.3-11.9-16.4-24.6-24.2-38-7.6-13.1-14.5-26.4-20.8-39.8 6.2-13.4 13.2-26.8 20.7-39.9 7.8-13.5 15.8-26.3 24.1-38.2 14.9-1.3 30-2 45.2-2 15.1 0 30.2.7 45 1.9 8.3 11.9 16.4 24.6 24.2 38 7.6 13.1 14.5 26.4 20.8 39.8-6.3 13.4-13.2 26.8-20.7 39.9zm32.3-13c5.4 13.4 10 26.8 13.8 39.8-13.1 3.2-26.9 5.9-41.2 8 4.9-7.7 9.8-15.6 14.4-23.7 4.6-8 8.9-16.1 13-24.1zM421.2 430c-9.3-9.6-18.6-20.3-27.8-32 9 .4 18.2.7 27.5.7 9.4 0 18.7-.2 27.8-.7-9 11.7-18.3 22.4-27.5 32zm-74.4-58.9c-14.2-2.1-27.9-4.7-41-7.9 3.7-12.9 8.3-26.2 13.5-39.5 4.1 8 8.4 16 13.1 24 4.7 8 9.5 15.8 14.4 23.4zM420.7 163c9.3 9.6 18.6 20.3 27.8 32-9-.4-18.2-.7-27.5-.7-9.4 0-18.7.2-27.8.7 9-11.7 18.3-22.4 27.5-32zm-74 58.9c-4.9 7.7-9.8 15.6-14.4 23.7-4.6 8-8.9 16-13 24-5.4-13.4-10-26.8-13.8-39.8 13.1-3.1 26.9-5.8 41.2-7.9zm-90.5 125.2c-35.4-15.1-58.3-34.9-58.3-50.6 0-15.7 22.9-35.6 58.3-50.6 8.6-3.7 18-7 27.7-10.1 5.7 19.6 13.2 40 22.5 60.9-9.2 20.8-16.6 41.1-22.2 60.6-9.9-3.1-19.3-6.5-28-10.2zM310 490c-13.6-7.8-19.5-37.5-14.9-75.7 1.1-9.4 2.9-19.3 5.1-29.4 19.6 4.8 41 8.5 63.5 10.9 13.5 18.5 27.5 35.3 41.6 50-32.6 30.3-63.2 46.9-84 46.9-4.5-.1-8.3-1-11.3-2.7zm237.2-76.2c4.7 38.2-1.1 67.9-14.6 75.8-3 1.8-6.9 2.6-11.5 2.6-20.7 0-51.4-16.5-84-46.6 14-14.7 28-31.4 41.3-49.9 22.6-2.4 44-6.1 63.6-11 2.3 10.1 4.1 19.8 5.2 29.1zm38.5-66.7c-8.6 3.7-18 7-27.7 10.1-5.7-19.6-13.2-40-22.5-60.9 9.2-20.8 16.6-41.1 22.2-60.6 9.9 3.1 19.3 6.5 28.1 10.2 35.4 15.1 58.3 34.9 58.3 50.6-.1 15.7-23 35.6-58.4 50.6zM320.8 78.4z"/>
<circle cx="420.9" cy="296.5" r="45.7"/>
<path d="M520.5 78.1z"/>
</g>
</svg>

Before

Width:  |  Height:  |  Size: 2.6 KiB

195
src/redux/actions.ts Normal file
View File

@ -0,0 +1,195 @@
import { batchActions } from 'redux-batched-actions';
import { AppDispatch, RootState } from './store';
import { actions as uploadDialogActions } from './upload-dialog-feature';
import { actions as renameDialogActions } from './rename-dialog-feature';
import { actions as errorDialogAction } from './error-dialog-feature';
import { actions as appStateActions } from './app-feature';
import { actions as mainActions } from './main-feature';
import serviceRegistry from '../services/registry';
import { Wireformat } from 'netmd-js';
import { AnyAction } from '@reduxjs/toolkit';
export function pair() {
return async function(dispatch: AppDispatch, getState: () => RootState) {
dispatch(appStateActions.setPairingFailed(false));
await serviceRegistry.audioExportService!.init();
try {
let connected = await serviceRegistry.netmdService!.connect();
if (connected) {
dispatch(appStateActions.setState('MAIN'));
return;
}
} catch (err) {
console.error(err);
// In case of error, just log and try to pair
}
try {
let paired = await serviceRegistry.netmdService!.pair();
if (paired) {
dispatch(appStateActions.setState('MAIN'));
return;
}
dispatch(batchActions([appStateActions.setPairingMessage(`Connection Failed`), appStateActions.setPairingFailed(true)]));
} catch (err) {
console.error(err);
let message = (err as Error).message;
dispatch(batchActions([appStateActions.setPairingMessage(message), appStateActions.setPairingFailed(true)]));
}
};
}
export function listContent() {
return async function(dispatch: AppDispatch) {
// Issue loading
dispatch(appStateActions.setLoading(true));
let disc = await serviceRegistry.netmdService!.listContent();
let deviceName = await serviceRegistry.netmdService!.getDeviceName();
dispatch(batchActions([mainActions.setDisc(disc), mainActions.setDeviceName(deviceName), appStateActions.setLoading(false)]));
};
}
export function renameTrack({ index, newName }: { index: number; newName: string }) {
return async function(dispatch: AppDispatch) {
const { netmdService } = serviceRegistry;
await netmdService!.renameTrack(index, newName);
dispatch(renameDialogActions.setVisible(false));
listContent()(dispatch);
};
}
export function deleteTracks(indexes: number[]) {
return async function(dispatch: AppDispatch) {
const { netmdService } = serviceRegistry;
dispatch(appStateActions.setLoading(true));
indexes = indexes.sort();
indexes.reverse();
for (let index of indexes) {
await netmdService!.deleteTrack(index);
}
listContent()(dispatch);
};
}
export function wipeDisc() {
return async function(dispatch: AppDispatch) {
const { netmdService } = serviceRegistry;
dispatch(appStateActions.setLoading(true));
await netmdService!.wipeDisc();
listContent()(dispatch);
};
}
export const WireformatDict: { [k: string]: Wireformat } = {
SP: Wireformat.pcm,
LP2: Wireformat.lp2,
LP105: Wireformat.l105kbps,
LP4: Wireformat.lp4,
};
export function convertAndUpload(files: File[], format: string) {
return async function(dispatch: AppDispatch, getState: (state: RootState) => void) {
const { audioExportService, netmdService } = serviceRegistry;
const wireformat = WireformatDict[format];
dispatch(uploadDialogActions.setVisible(true));
const updateProgressCallback = ({ written, encrypted, total }: { written: number; encrypted: number; total: number }) => {
dispatch(uploadDialogActions.setWriteProgress({ written, encrypted, total }));
};
let trackUpdate: {
current: number;
converting: number;
total: number;
titleCurrent: string;
titleConverting: string;
} = {
current: 0,
converting: 0,
total: files.length,
titleCurrent: '',
titleConverting: '',
};
const updateTrack = () => {
dispatch(uploadDialogActions.setTrackProgress(trackUpdate));
};
let conversionIterator = async function*(files: File[]) {
let converted: Promise<{ file: File; data: ArrayBuffer }>[] = [];
let i = 0;
function convertNext() {
if (i === files.length) {
trackUpdate.converting = i;
trackUpdate.titleConverting = ``;
updateTrack();
return;
}
let f = files[i];
trackUpdate.converting = i;
trackUpdate.titleConverting = f.name;
updateTrack();
i++;
converted.push(
new Promise(async (resolve, reject) => {
let data: ArrayBuffer;
try {
await audioExportService!.prepare(f);
data = await audioExportService!.export({ format });
convertNext();
resolve({ file: f, data: data });
} catch (err) {
error = err;
errorMessage = `${f.name}: Unsupported or unrecognized format`;
reject(err);
}
})
);
}
convertNext();
let j = 0;
while (j < converted.length) {
yield await converted[j];
j++;
}
};
let error: any;
let errorMessage = ``;
let i = 1;
for await (let item of conversionIterator(files)) {
const { file, data } = item;
trackUpdate.current = i++;
trackUpdate.titleCurrent = file.name;
updateTrack();
updateProgressCallback({ written: 0, encrypted: 0, total: 100 });
try {
await netmdService?.upload(file.name, data, wireformat, updateProgressCallback);
} catch (err) {
error = err;
errorMessage = `${file.name}: Error uploading to device`;
break;
}
}
let actionToDispatch: AnyAction[] = [uploadDialogActions.setVisible(false)];
if (error) {
console.error(error);
actionToDispatch = actionToDispatch.concat([
errorDialogAction.setVisible(true),
errorDialogAction.setErrorMessage(errorMessage),
]);
}
dispatch(batchActions(actionToDispatch));
listContent()(dispatch);
};
}

57
src/redux/app-feature.ts Normal file
View File

@ -0,0 +1,57 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { enableBatching } from 'redux-batched-actions';
import { savePreference, loadPreference } from '../utils';
type Views = 'WELCOME' | 'MAIN';
export interface AppState {
mainView: Views;
loading: boolean;
pairingFailed: boolean;
pairingMessage: string;
browserSupported: boolean;
darkMode: boolean;
aboutDialogVisible: boolean;
}
const initialState: AppState = {
mainView: 'WELCOME',
loading: false,
pairingFailed: false,
pairingMessage: ``,
browserSupported: true,
darkMode: loadPreference('darkMode', false),
aboutDialogVisible: false,
};
export const slice = createSlice({
name: 'app',
initialState,
reducers: {
setState: (state, action: PayloadAction<Views>) => {
state.mainView = action.payload;
},
setLoading: (state, action: PayloadAction<boolean>) => {
state.loading = action.payload;
},
setPairingFailed: (state, action: PayloadAction<boolean>) => {
state.pairingFailed = action.payload;
},
setPairingMessage: (state, action: PayloadAction<string>) => {
state.pairingMessage = action.payload;
},
setBrowserSupported: (state, action: PayloadAction<boolean>) => {
state.browserSupported = action.payload;
},
setDarkMode: (state, action: PayloadAction<boolean>) => {
state.darkMode = action.payload;
savePreference('darkMode', state.darkMode);
},
showAboutDialog: (state, action: PayloadAction<boolean>) => {
state.aboutDialogVisible = action.payload;
},
},
});
export const { reducer, actions } = slice;
export default enableBatching(reducer);

View File

@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { enableBatching } from 'redux-batched-actions';
export interface ConvertDialogFeature {
visible: boolean;
format: string;
}
const initialState: ConvertDialogFeature = {
visible: false,
format: `LP2`,
};
const slice = createSlice({
name: 'convertDialog',
initialState,
reducers: {
setVisible: (state, action: PayloadAction<boolean>) => {
state.visible = action.payload;
},
setFormat: (state, action: PayloadAction<string>) => {
state.format = action.payload;
},
},
});
export const { actions, reducer } = slice;
export default enableBatching(reducer);

View File

@ -0,0 +1,28 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { enableBatching } from 'redux-batched-actions';
export interface ErrorDialogState {
visible: boolean;
error: string;
}
const initialState: ErrorDialogState = {
visible: false,
error: ``,
};
const slice = createSlice({
name: 'errorDialog',
initialState,
reducers: {
setVisible: (state, action: PayloadAction<boolean>) => {
state.visible = action.payload;
},
setErrorMessage: (state, action: PayloadAction<string>) => {
state.error = `${action.payload}`;
},
},
});
export const { actions, reducer } = slice;
export default enableBatching(reducer);

29
src/redux/main-feature.ts Normal file
View File

@ -0,0 +1,29 @@
import { Disc } from 'netmd-js';
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { enableBatching } from 'redux-batched-actions';
export interface MainState {
disc: Disc | null;
deviceName: string;
}
const initialState: MainState = {
disc: null,
deviceName: '',
};
export const slice = createSlice({
name: 'main',
initialState,
reducers: {
setDisc: (state, action: PayloadAction<Disc>) => {
state.disc = action.payload;
},
setDeviceName: (state, action: PayloadAction<string>) => {
state.deviceName = action.payload;
},
},
});
export const { reducer, actions } = slice;
export default enableBatching(reducer);

View File

@ -0,0 +1,33 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { enableBatching } from 'redux-batched-actions';
export interface RenameDialogState {
visible: boolean;
title: string;
index: number;
}
const initialState: RenameDialogState = {
visible: false,
title: '',
index: -1,
};
export const slice = createSlice({
name: 'renameDialog',
initialState,
reducers: {
setVisible: (state: RenameDialogState, action: PayloadAction<boolean>) => {
state.visible = action.payload;
},
setCurrentName: (state: RenameDialogState, action: PayloadAction<string>) => {
state.title = action.payload;
},
setIndex: (state: RenameDialogState, action: PayloadAction<number>) => {
state.index = action.payload;
},
},
});
export const { reducer, actions } = slice;
export default enableBatching(reducer);

22
src/redux/store.ts Normal file
View File

@ -0,0 +1,22 @@
import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit';
import uploadDialog from './upload-dialog-feature';
import renameDialog from './rename-dialog-feature';
import errorDialog from './error-dialog-feature';
import convertDialog from './convert-dialog-feature';
import appState from './app-feature';
import main from './main-feature';
export const store = configureStore({
reducer: {
renameDialog,
uploadDialog,
errorDialog,
convertDialog,
appState,
main,
},
middleware: [...getDefaultMiddleware()],
});
export type RootState = ReturnType<typeof store.getState>;
export type AppDispatch = typeof store.dispatch;

View File

@ -0,0 +1,59 @@
import { createSlice, PayloadAction } from '@reduxjs/toolkit';
import { enableBatching } from 'redux-batched-actions';
export interface LoadingDialogState {
visible: boolean;
writtenProgress: number;
encryptedProgress: number;
totalProgress: number;
trackTotal: number;
trackConverting: number;
trackCurrent: number;
titleCurrent: string;
titleConverting: string;
}
const initialState: LoadingDialogState = {
visible: false,
// Current Track Upload
writtenProgress: 0,
encryptedProgress: 0,
totalProgress: 1,
// Tracks done
trackTotal: 1,
trackConverting: 0,
trackCurrent: 0,
titleCurrent: '',
titleConverting: '',
};
export const slice = createSlice({
name: 'uploadDialog',
initialState,
reducers: {
setVisible: (state, action: PayloadAction<boolean>) => {
state.visible = action.payload;
},
setWriteProgress: (state, action: PayloadAction<{ written: number; encrypted: number; total: number }>) => {
state.encryptedProgress = action.payload.encrypted;
state.writtenProgress = action.payload.written;
state.totalProgress = action.payload.total;
},
setTrackProgress: (
state,
action: PayloadAction<{ total: number; current: number; converting: number; titleCurrent: string; titleConverting: string }>
) => {
state.trackTotal = action.payload.total;
state.trackCurrent = action.payload.current;
state.trackConverting = action.payload.converting;
state.titleCurrent = action.payload.titleCurrent;
state.titleConverting = action.payload.titleConverting;
},
},
});
export const { reducer, actions } = slice;
export default enableBatching(reducer);

View File

@ -0,0 +1,70 @@
/* eslint no-restricted-globals: 0 */
export class AtracdencProcess {
private messageCallback?: (ev: MessageEvent) => void;
constructor(public worker: Worker) {
worker.onmessage = this.handleMessage.bind(this);
}
async init() {
await new Promise<MessageEvent>(resolve => {
this.messageCallback = resolve;
this.worker.postMessage({ action: 'init' });
});
}
async encode(data: ArrayBuffer, bitrate: string) {
let eventData = await new Promise<MessageEvent>(resolve => {
this.messageCallback = resolve;
this.worker.postMessage({ action: 'encode', bitrate, data }, [data]);
});
return eventData.data.result as Uint8Array;
}
handleMessage(ev: MessageEvent) {
this.messageCallback!(ev);
this.messageCallback = undefined;
}
}
if (typeof WorkerGlobalScope !== 'undefined' && self instanceof WorkerGlobalScope) {
// Worker
let Module: any;
onmessage = async (ev: MessageEvent) => {
const { action, ...others } = ev.data;
if (action === 'init') {
self.importScripts(`atracdenc.js`);
(self as any).Module().then((m: any) => {
Module = m;
self.postMessage({ action: 'init' });
});
} else if (action === 'encode') {
const { bitrate, data } = others;
const inWavFile = `inWavFile.wav`;
const outAt3File = `outAt3File.aea`;
Module.FS.writeFile(`${inWavFile}`, new Uint8Array(data));
Module.callMain([`-e`, `atrac3`, `-i`, inWavFile, `-o`, outAt3File, `--bitrate`, bitrate]);
// Read file and trim header (96 bytes)
let fileStat = Module.FS.stat(outAt3File);
let size = fileStat.size;
let tmp = new Uint8Array(size - 96);
let outAt3FileStream = Module.FS.open(outAt3File, 'r');
Module.FS.read(outAt3FileStream, tmp, 0, tmp.length, 96);
Module.FS.close(outAt3FileStream);
let result = tmp.buffer;
self.postMessage(
{
action: 'encode',
result,
},
[result]
);
self.close();
}
};
} else {
// Main
}

View File

@ -0,0 +1,108 @@
import { createWorker, setLogging } from '@ffmpeg/ffmpeg';
import { AtracdencProcess } from './atracdenc-worker';
const AtracdencWorker = require('worker-loader!./atracdenc-worker'); // eslint-disable-line import/no-webpack-loader-syntax
interface LogPayload {
message: string;
action: string;
}
export interface AudioExportService {
init(): Promise<void>;
export(params: { format: string }): Promise<ArrayBuffer>;
info(): Promise<{ format: string | null; input: string | null }>;
prepare(file: File): Promise<void>;
}
export class FFMpegAudioExportService implements AudioExportService {
public ffmpegProcess: any;
public atracdencProcess?: AtracdencProcess;
public loglines: { action: string; message: string }[] = [];
public inFileName: string = ``;
public outFileNameNoExt: string = ``;
async init() {
setLogging(true);
}
async prepare(file: File) {
this.loglines = [];
this.ffmpegProcess = createWorker({
logger: (payload: LogPayload) => {
this.loglines.push(payload);
console.log(payload.action, payload.message);
},
corePath: '/ffmpeg-core.js',
// workerPath: '/worker.min.js',
workerPath: '/worker.dev.js',
});
await this.ffmpegProcess.load();
this.atracdencProcess = new AtracdencProcess(new AtracdencWorker());
await this.atracdencProcess.init();
let ext = file.name.split('.').slice(-1);
if (ext.length === 0) {
throw new Error(`Unrecognized file format: ${file.name}`);
}
this.inFileName = `inAudioFile.${ext[0]}`;
this.outFileNameNoExt = `outAudioFile`;
await this.ffmpegProcess.write(this.inFileName, file);
}
async info() {
await this.ffmpegProcess.transcode(this.inFileName, `${this.outFileNameNoExt}.metadata`, `-f ffmetadata`);
let audioFormatRegex = /Audio:\s(.*?),/; // Actual content
let inputFormatRegex = /Input #0,\s(.*?),/; // Container
let format: string | null = null;
let input: string | null = null;
for (let line of this.loglines) {
let match = line.message.match(audioFormatRegex);
if (match !== null) {
format = match[1];
continue;
}
match = line.message.match(inputFormatRegex);
if (match !== null) {
input = match[1];
continue;
}
if (format !== null && input !== null) {
break;
}
}
return { format, input };
}
async export({ format }: { format: string }) {
if (format === `SP`) {
const outFileName = `${this.outFileNameNoExt}.raw`;
await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-f s16be');
let { data } = await this.ffmpegProcess.read(outFileName);
return data.buffer;
} else {
const outFileName = `${this.outFileNameNoExt}.wav`;
await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-f wav');
let { data } = await this.ffmpegProcess.read(outFileName);
let bitrate: string = `0`;
switch (format) {
case `LP2`:
bitrate = `128`;
break;
case `LP105`:
bitrate = `102`;
break;
case `LP4`:
bitrate = `64`;
break;
}
let result = await this.atracdencProcess!.encode(data.buffer, bitrate);
return result;
}
}
}

95
src/services/netmd.ts Normal file
View File

@ -0,0 +1,95 @@
import { openNewDevice, NetMDInterface, Disc, listContent, openPairedDevice, Wireformat, MDTrack, download } from 'netmd-js';
import { makeGetAsyncPacketIteratorOnWorkerThread } from 'netmd-js/dist/web-encrypt-worker';
const Worker = require('worker-loader!netmd-js/dist/web-encrypt-worker.js'); // eslint-disable-line import/no-webpack-loader-syntax
export interface NetMDService {
pair(): Promise<boolean>;
connect(): Promise<boolean>;
listContent(): Promise<Disc>;
getDeviceName(): Promise<string>;
finalize(): Promise<void>;
renameTrack(index: number, newTitle: string): Promise<void>;
deleteTrack(index: number): Promise<void>;
wipeDisc(): Promise<void>;
upload(
title: string,
data: ArrayBuffer,
format: Wireformat,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
): Promise<void>;
}
export class NetMDUSBService implements NetMDService {
private netmdInterface?: NetMDInterface;
async pair() {
let iface = await openNewDevice(navigator.usb);
if (iface === null) {
return false;
}
this.netmdInterface = iface;
return true;
}
async connect() {
let iface = await openPairedDevice(navigator.usb);
if (iface === null) {
return false;
}
this.netmdInterface = iface;
return true;
}
async listContent() {
return await listContent(this.netmdInterface!);
}
async getDeviceName() {
return await this.netmdInterface!.netMd.getDeviceName();
}
async finalize() {
await this.netmdInterface!.netMd.finalize();
}
async renameTrack(index: number, newTitle: string) {
await this.netmdInterface!.setTrackTitle(index, newTitle);
}
async deleteTrack(index: number) {
await this.netmdInterface!.eraseTrack(index);
}
async wipeDisc() {
await this.netmdInterface!.eraseDisc();
}
async upload(
title: string,
data: ArrayBuffer,
format: Wireformat,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
) {
let total = data.byteLength;
let written = 0;
let encrypted = 0;
function updateProgress() {
progressCallback({ written, encrypted, total });
}
let w = new Worker();
let webWorkerAsyncPacketIterator = makeGetAsyncPacketIteratorOnWorkerThread(w, ({ encryptedBytes }) => {
encrypted = encryptedBytes;
updateProgress();
});
let mdTrack = new MDTrack(title, format, data, webWorkerAsyncPacketIterator);
await download(this.netmdInterface!, mdTrack, ({ writtenBytes }) => {
written = writtenBytes;
updateProgress();
});
}
}

11
src/services/registry.ts Normal file
View File

@ -0,0 +1,11 @@
import { NetMDService } from './netmd';
import { AudioExportService } from './audio-export';
interface ServiceRegistry {
netmdService?: NetMDService;
audioExportService?: AudioExportService;
}
const ServiceRegistry: ServiceRegistry = {};
export default ServiceRegistry;

2
src/types.d.ts vendored Normal file
View File

@ -0,0 +1,2 @@
declare module '@ffmpeg/ffmpeg';
declare module '@ffmpeg/ffmpeg/src/index';

43
src/utils.ts Normal file
View File

@ -0,0 +1,43 @@
import { useSelector, shallowEqual } from 'react-redux';
import { RootState } from './redux/store';
export function sleep(ms: number) {
return new Promise(resolve => {
setTimeout(resolve, ms);
});
}
export function useShallowEqualSelector<TState = RootState, TSelected = unknown>(selector: (state: TState) => TSelected): TSelected {
return useSelector(selector, shallowEqual);
}
export function hasWebUSB(): boolean {
return !!navigator.usb;
}
export function getWebUSB(): USB {
return navigator.usb;
}
export function debugEnabled() {
return process.env.NODE_ENV === 'development';
}
export function savePreference(key: string, value: unknown) {
localStorage.setItem(key, JSON.stringify(value));
}
export function loadPreference<T>(key: string, defaultValue: T): T {
let res = localStorage.getItem(key);
if (res === null) {
return defaultValue;
} else {
try {
return JSON.parse(res) as T;
} catch (e) {
return defaultValue;
}
}
}
declare let process: any;

View File

@ -4,7 +4,8 @@
"lib": [
"dom",
"dom.iterable",
"esnext"
"esnext",
"WebWorker",
],
"allowJs": true,
"skipLibCheck": true,
@ -17,7 +18,8 @@
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react"
"jsx": "react",
"downlevelIteration": true
},
"include": [
"src"