Last week's work
|
@ -0,0 +1 @@
|
|||
public/
|
|
@ -0,0 +1,7 @@
|
|||
{
|
||||
"trailingComma": "es5",
|
||||
"singleQuote": true,
|
||||
"tabWidth": 4,
|
||||
"printWidth": 140,
|
||||
"parser": "typescript"
|
||||
}
|
|
@ -0,0 +1,11 @@
|
|||
{
|
||||
"editor.formatOnSave": true,
|
||||
"files.exclude": {
|
||||
"**/.git": true,
|
||||
"**/.svn": true,
|
||||
"**/.hg": true,
|
||||
"**/CVS": true,
|
||||
"**/.DS_Store": true,
|
||||
"build/": true,
|
||||
}
|
||||
}
|
63
README.md
|
@ -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 can’t 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 aren’t 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 you’re 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 don’t have to ever use `eject`. The curated feature set is suitable for small and middle deployments, and you shouldn’t feel obligated to use this feature. However we understand that this tool wouldn’t be useful if you couldn’t 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.
|
||||
|
|
|
@ -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]"
|
||||
```
|
|
@ -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
|
|
@ -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 "$@"
|
32
package.json
|
@ -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"
|
||||
}
|
||||
}
|
||||
|
|
After Width: | Height: | Size: 63 KiB |
After Width: | Height: | Size: 263 KiB |
Before Width: | Height: | Size: 3.1 KiB After Width: | Height: | Size: 1.1 KiB |
|
@ -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>
|
||||
|
|
Before Width: | Height: | Size: 5.2 KiB |
Before Width: | Height: | Size: 9.4 KiB |
|
@ -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
|
||||
}
|
||||
|
|
|
@ -0,0 +1 @@
|
|||
../node_modules/@ffmpeg/ffmpeg/dist/worker.min.js
|
38
src/App.css
|
@ -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);
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
26
src/App.tsx
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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;
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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>
|
||||
);
|
||||
};
|
|
@ -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.
|
||||
<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>
|
||||
);
|
||||
};
|
|
@ -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 |
|
@ -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 |
|
@ -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%;
|
||||
}
|
||||
|
|
|
@ -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();
|
||||
|
|
|
@ -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 |
|
@ -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);
|
||||
};
|
||||
}
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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);
|
|
@ -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;
|
|
@ -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);
|
|
@ -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
|
||||
}
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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();
|
||||
});
|
||||
}
|
||||
}
|
|
@ -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;
|
|
@ -0,0 +1,2 @@
|
|||
declare module '@ffmpeg/ffmpeg';
|
||||
declare module '@ffmpeg/ffmpeg/src/index';
|
|
@ -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;
|
|
@ -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"
|
||||
|
|