Compare commits
32 Commits
Author | SHA1 | Date |
---|---|---|
Adam Drew | 72473709cc | |
Stefano Brilli | cb0ecf25d8 | |
dependabot[bot] | 314bf9272a | |
Stefano Brilli | 21d0a3be7a | |
Stefano Brilli | 4e9860e770 | |
Stefano Brilli | 05de503bbd | |
Stefano Brilli | 4853480e66 | |
Stefano Brilli | 7ec1ad7850 | |
dependabot[bot] | a800155624 | |
dependabot[bot] | b8bdb0fdfc | |
dependabot[bot] | 08b000bed5 | |
dependabot[bot] | a911df955d | |
dependabot[bot] | c168a03001 | |
Stefano Brilli | 1bdad5d3e8 | |
dependabot[bot] | 837149e934 | |
Stefano Brilli | 94ad175c51 | |
Stefano Brilli | 77035f9599 | |
Stefano Brilli | 6d45e30eba | |
Stefano Brilli | 48af377a33 | |
Stefano Brilli | 8049ed9dff | |
Stefano Brilli | 251e0bedcc | |
Stefano Brilli | c41d69c6c5 | |
Stefano Brilli | db39e9239e | |
Stefano Brilli | c647826f58 | |
Stefano Brilli | e63ca90c38 | |
Stefano Brilli | ed96e7a770 | |
Stefano Brilli | 2542fd7bf4 | |
Stefano Brilli | 33c0b02973 | |
Stefano Brilli | 2c6c1e8b69 | |
Stefano Brilli | 039b078c6d | |
asivery | 8f8779a327 | |
asivery | 852a7129d1 |
12
README.md
12
README.md
|
@ -12,7 +12,17 @@ Requires *Chrome* or any other browser that supports both **WASM** and **WebUSB*
|
|||
_it just works ®_ ... no need to download or install any software.
|
||||
|
||||
#### 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. If you skip this step you'll likely get an *Access denied* message when trying to connect.
|
||||
To grant permission to the browser to access the USB device you'll need to add UDEV rules that grant access to a group called `plugdev`. You will also need to ensure your user is added to that `plugdev` group.
|
||||
1. Copy https://github.com/glaubitz/linux-minidisc/blob/master/netmd/etc/netmd.rules into `/etc/udev/rules.d/70-netmd.rules`
|
||||
2. Add yourself to the `plugdev` group (create the group if it doesn't exist)
|
||||
```
|
||||
$ groupadd plugdev
|
||||
$ usermod -a -G plugdev YourUserName
|
||||
```
|
||||
3. Reboot
|
||||
4. Plug-in your netmd device after loggin in.
|
||||
|
||||
Also note that you will need to use a browser packaged by your distro vendor; browsers installed via snap or flatpak wont work.
|
||||
|
||||
#### Windows 10
|
||||
The Windows USB stack requires a driver to be installed to communicate with any USB device. The bad news is that there are no official Windows 10 drivers for NetMD devices. The good news is that we don't need it!
|
||||
|
|
File diff suppressed because it is too large
Load Diff
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"name": "webmd",
|
||||
"version": "0.2.5",
|
||||
"version": "0.2.7",
|
||||
"private": true,
|
||||
"dependencies": {
|
||||
"@ffmpeg/ffmpeg": "0.6.1",
|
||||
|
@ -74,6 +74,7 @@
|
|||
"devDependencies": {
|
||||
"@types/dom-mediacapture-record": "^1.0.7",
|
||||
"@types/react-beautiful-dnd": "^13.1.1",
|
||||
"@types/wicg-mediasession": "^1.1.3",
|
||||
"async-mutex": "^0.2.6",
|
||||
"gh-pages": "^2.2.0"
|
||||
}
|
||||
|
|
|
@ -30,8 +30,8 @@
|
|||
<body>
|
||||
<script>
|
||||
try {
|
||||
if (location.protocol !== 'https:' && !location.host.match(/^localhost/gm)) {
|
||||
// Make sure we're on https, or WebUSB won't work.
|
||||
if (location.protocol !== 'file:' && location.protocol !== 'https:' && !location.host.match(/^localhost/gm)) {
|
||||
// Make sure we're on https/file, or WebUSB won't work.
|
||||
location.replace(`https:${location.href.substring(location.protocol.length)}`);
|
||||
console.log('Redirecting to https....');
|
||||
}
|
||||
|
|
|
@ -1,4 +1,4 @@
|
|||
import React, { useCallback, useRef, useEffect, useState } from 'react';
|
||||
import React, { useCallback, useRef, useEffect, useState, useMemo } from 'react';
|
||||
import clsx from 'clsx';
|
||||
|
||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
|
||||
|
@ -134,7 +134,7 @@ export const Controls = () => {
|
|||
let deviceState = deviceStatus?.state ?? null;
|
||||
let discPresent = deviceStatus?.discPresent ?? false;
|
||||
let paused = deviceStatus?.state === 'paused';
|
||||
const tracks = getSortedTracks(disc);
|
||||
const tracks = useMemo(() => getSortedTracks(disc), [disc]);
|
||||
if (!discPresent) {
|
||||
message = ``;
|
||||
} else if (deviceState === 'readingTOC') {
|
||||
|
@ -143,9 +143,7 @@ export const Controls = () => {
|
|||
message = `BLANKDISC`;
|
||||
} else if (deviceStatus && deviceStatus.track !== null && tracks[deviceStatus.track]) {
|
||||
let title = tracks[deviceStatus.track].fullWidthTitle || tracks[deviceStatus.track].title;
|
||||
message =
|
||||
(deviceStatus.track + 1).toString().padStart(3, '0') +
|
||||
(title ? ' - ' + title : '');
|
||||
message = (deviceStatus.track + 1).toString().padStart(3, '0') + (title ? ' - ' + title : '');
|
||||
}
|
||||
|
||||
const [lcdScroll, setLcdScroll] = useState(0);
|
||||
|
|
|
@ -355,7 +355,7 @@ export const ConvertDialog = (props: { files: File[] }) => {
|
|||
</DialogContent>
|
||||
<DialogActions>
|
||||
<Button onClick={handleToggleTracksOrder} className={classes.showTracksOrderBtn}>
|
||||
{`${tracksOrderVisible ? 'Hide' : 'Show'} Tracks`}
|
||||
{`${tracksOrderVisible ? 'Hide' : 'Edit'} Tracks`}
|
||||
</Button>
|
||||
<div className={classes.spacer}></div>
|
||||
<Button onClick={handleClose}>Cancel</Button>
|
||||
|
|
|
@ -328,11 +328,11 @@ export const Main = (props: {}) => {
|
|||
}
|
||||
if (deviceStatus.track !== track) {
|
||||
dispatch(control('goto', track));
|
||||
if (deviceStatus.state !== 'playing') {
|
||||
dispatch(control('play'));
|
||||
}
|
||||
dispatch(control('play'));
|
||||
} else if (deviceStatus.state === 'playing') {
|
||||
dispatch(control('pause'));
|
||||
} else {
|
||||
dispatch(control('play'));
|
||||
}
|
||||
},
|
||||
[dispatch, deviceStatus]
|
||||
|
|
|
@ -17,6 +17,8 @@ import { TopMenu } from './topmenu';
|
|||
import ChromeIconPath from '../images/chrome-icon.svg';
|
||||
import { W95Welcome } from './win95/welcome';
|
||||
import OpenInNewIcon from '@material-ui/icons/OpenInNew';
|
||||
import Snackbar from '@material-ui/core/Snackbar';
|
||||
import Alert from '@material-ui/lab/Alert';
|
||||
|
||||
const useStyles = makeStyles(theme => ({
|
||||
main: {
|
||||
|
@ -34,6 +36,9 @@ const useStyles = makeStyles(theme => ({
|
|||
spacing: {
|
||||
marginTop: theme.spacing(1),
|
||||
},
|
||||
spacing2: {
|
||||
marginTop: theme.spacing(2),
|
||||
},
|
||||
chromeLogo: {
|
||||
marginTop: theme.spacing(1),
|
||||
width: 96,
|
||||
|
@ -117,6 +122,11 @@ export const Welcome = (props: {}) => {
|
|||
>
|
||||
<FormHelperText>{pairingMessage}</FormHelperText>
|
||||
</FormControl>
|
||||
|
||||
<Alert severity="warning" className={classes.spacing2}>
|
||||
Want an updated version of Web Minidisc? Try{' '}
|
||||
<Link href="https://web.minidisc.wiki/">Web MiniDisc Pro.</Link>
|
||||
</Alert>
|
||||
</div>
|
||||
<div>
|
||||
<Typography component="h2" variant="subtitle1" align="center" className={classes.spacing}>
|
||||
|
|
|
@ -133,7 +133,7 @@ export const W95ConvertDialog = (props: {
|
|||
) : null}
|
||||
|
||||
<DialogFooter>
|
||||
<Button onClick={props.handleToggleTracksOrder}>{`${props.tracksOrderVisible ? 'Hide' : 'Show'} Tracks`}</Button>
|
||||
<Button onClick={props.handleToggleTracksOrder}>{`${props.tracksOrderVisible ? 'Hide' : 'Edit'} Tracks`}</Button>
|
||||
<div style={{ flex: '1 1 auto' }}></div>
|
||||
<FooterButton onClick={props.handleConvert}>OK</FooterButton>
|
||||
<FooterButton onClick={props.handleClose}>Cancel</FooterButton>
|
||||
|
|
|
@ -0,0 +1,37 @@
|
|||
export function createEmptyWave(time: number, sampleRate = 22050) {
|
||||
const numOfChan = 1;
|
||||
const depthInBytes = 2;
|
||||
const length = time * sampleRate * numOfChan * depthInBytes + 44;
|
||||
const buffer = new ArrayBuffer(length);
|
||||
const view = new DataView(buffer);
|
||||
|
||||
let pos = 0;
|
||||
function setUint16(data: number) {
|
||||
view.setUint16(pos, data, true);
|
||||
pos += 2;
|
||||
}
|
||||
|
||||
function setUint32(data: number) {
|
||||
view.setUint32(pos, data, true);
|
||||
pos += 4;
|
||||
}
|
||||
|
||||
// write WAVE header
|
||||
setUint32(0x46464952);
|
||||
setUint32(length - 8);
|
||||
setUint32(0x45564157);
|
||||
|
||||
setUint32(0x20746d66);
|
||||
setUint32(16);
|
||||
setUint16(1);
|
||||
setUint16(numOfChan);
|
||||
setUint32(sampleRate);
|
||||
setUint32(sampleRate * numOfChan * depthInBytes);
|
||||
setUint16(numOfChan * depthInBytes);
|
||||
setUint16(depthInBytes * 8);
|
||||
|
||||
setUint32(0x61746164);
|
||||
setUint32(length - pos - 4);
|
||||
|
||||
return new Blob([buffer], { type: 'audio/wav' });
|
||||
}
|
|
@ -18,11 +18,13 @@ import './fonts/fonts.css';
|
|||
|
||||
import { FFMpegAudioExportService } from './services/audio-export';
|
||||
import { MediaRecorderService } from './services/mediarecorder';
|
||||
import { BrowserMediaSessionService } from './services/media-session';
|
||||
|
||||
serviceRegistry.netmdService = new NetMDUSBService({ debug: true });
|
||||
serviceRegistry.netmdService = (window as any).native?.interface || new NetMDUSBService({ debug: true });
|
||||
// serviceRegistry.netmdService = new NetMDMockService(); // Uncomment to work without a device attached
|
||||
serviceRegistry.audioExportService = new FFMpegAudioExportService();
|
||||
serviceRegistry.mediaRecorderService = new MediaRecorderService();
|
||||
serviceRegistry.mediaSessionService = new BrowserMediaSessionService(store);
|
||||
|
||||
(function setupEventHandlers() {
|
||||
window.addEventListener('beforeunload', ev => {
|
||||
|
|
|
@ -17,13 +17,14 @@ import {
|
|||
askNotificationPermission,
|
||||
getGroupedTracks,
|
||||
getHalfWidthTitleLength,
|
||||
timeToSeekArgs,
|
||||
} from '../utils';
|
||||
import * as mm from 'music-metadata-browser';
|
||||
import { TitleFormatType, UploadFormat } from './convert-dialog-feature';
|
||||
import NotificationCompleteIconUrl from '../images/record-complete-notification-icon.png';
|
||||
import { assertNumber } from 'netmd-js/dist/utils';
|
||||
|
||||
export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pause', params?: unknown) {
|
||||
export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pause' | 'seek', params?: unknown) {
|
||||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||
switch (action) {
|
||||
case 'play':
|
||||
|
@ -41,10 +42,22 @@ export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pa
|
|||
case 'pause':
|
||||
await serviceRegistry.netmdService!.pause();
|
||||
break;
|
||||
case 'goto':
|
||||
case 'goto': {
|
||||
const trackNumber = assertNumber(params, 'Invalid track number for "goto" command');
|
||||
await serviceRegistry.netmdService!.gotoTrack(trackNumber);
|
||||
break;
|
||||
}
|
||||
case 'seek': {
|
||||
if (!(params instanceof Object)) {
|
||||
throw new Error('"seek" command has wrong params');
|
||||
}
|
||||
const typedParams: { trackNumber: number; time: number } = params as any;
|
||||
const trackNumber = assertNumber(typedParams.trackNumber, 'Invalid track number for "seek" command');
|
||||
const time = assertNumber(typedParams.time, 'Invalid time for "seek" command');
|
||||
const timeArgs = timeToSeekArgs(time);
|
||||
await serviceRegistry.netmdService!.gotoTime(trackNumber, timeArgs[0], timeArgs[1], timeArgs[2], timeArgs[3]);
|
||||
break;
|
||||
}
|
||||
}
|
||||
// CAVEAT: change-track might take a up to a few seconds to complete.
|
||||
// We wait 500ms and let the monitor do further updates
|
||||
|
@ -184,6 +197,7 @@ export function pair() {
|
|||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||
dispatch(appStateActions.setPairingFailed(false));
|
||||
|
||||
serviceRegistry.mediaSessionService?.init(); // no need to await
|
||||
await serviceRegistry.audioExportService!.init();
|
||||
|
||||
try {
|
||||
|
@ -436,10 +450,9 @@ async function getTrackNameFromMediaTags(file: File, titleFormat: TitleFormatTyp
|
|||
}
|
||||
}
|
||||
|
||||
export function convertAndUpload(files: File[], format: UploadFormat, titleFormat: TitleFormatType) {
|
||||
export function convertAndUpload(files: File[], requestedFormat: UploadFormat, titleFormat: TitleFormatType) {
|
||||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||
const { audioExportService, netmdService } = serviceRegistry;
|
||||
const wireformat = WireformatDict[format];
|
||||
|
||||
await netmdService?.stop();
|
||||
dispatch(batchActions([uploadDialogActions.setVisible(true), uploadDialogActions.setCancelUpload(false)]));
|
||||
|
@ -484,7 +497,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
|
|||
};
|
||||
|
||||
let conversionIterator = async function*(files: File[]) {
|
||||
let converted: Promise<{ file: File; data: ArrayBuffer }>[] = [];
|
||||
let converted: Promise<{ file: File; data: ArrayBuffer; format: Wireformat }>[] = [];
|
||||
|
||||
let i = 0;
|
||||
function convertNext() {
|
||||
|
@ -504,11 +517,12 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
|
|||
converted.push(
|
||||
new Promise(async (resolve, reject) => {
|
||||
let data: ArrayBuffer;
|
||||
let format: Wireformat;
|
||||
try {
|
||||
await audioExportService!.prepare(f);
|
||||
data = await audioExportService!.export({ format });
|
||||
({ data, format } = await audioExportService!.export({ requestedFormat }));
|
||||
convertNext();
|
||||
resolve({ file: f, data: data });
|
||||
resolve({ file: f, data: data, format: format });
|
||||
} catch (err) {
|
||||
error = err;
|
||||
errorMessage = `${f.name}: Unsupported or unrecognized format`;
|
||||
|
@ -539,7 +553,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
|
|||
break;
|
||||
}
|
||||
|
||||
const { file, data } = item;
|
||||
const { file, data, format } = item;
|
||||
|
||||
let title = file.name;
|
||||
try {
|
||||
|
@ -563,7 +577,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
|
|||
updateTrack();
|
||||
updateProgressCallback({ written: 0, encrypted: 0, total: 100 });
|
||||
try {
|
||||
await netmdService?.upload(halfWidthTitle, fullWidthTitle, data, wireformat, updateProgressCallback);
|
||||
await netmdService?.upload(halfWidthTitle, fullWidthTitle, data, format, updateProgressCallback);
|
||||
} catch (err) {
|
||||
error = err;
|
||||
errorMessage = `${file.name}: Error uploading to device. There might not be enough space left.`;
|
||||
|
|
|
@ -50,5 +50,8 @@ export const store = configureStore({
|
|||
|
||||
const initialState = Object.freeze(store.getState());
|
||||
|
||||
export type AppStore = typeof store;
|
||||
export type AppSubscribe = typeof store.subscribe;
|
||||
export type AppGetState = typeof store.getState;
|
||||
export type RootState = ReturnType<typeof store.getState>;
|
||||
export type AppDispatch = typeof store.dispatch;
|
||||
|
|
|
@ -1,6 +1,8 @@
|
|||
import { createWorker, setLogging } from '@ffmpeg/ffmpeg';
|
||||
import { AtracdencProcess } from './atracdenc-worker';
|
||||
import { getPublicPathFor } from '../utils';
|
||||
import { getAtrac3Info, getPublicPathFor } from '../utils';
|
||||
import { Wireformat } from 'netmd-js';
|
||||
import { WireformatDict } from '../redux/actions';
|
||||
const AtracdencWorker = require('worker-loader!./atracdenc-worker'); // eslint-disable-line import/no-webpack-loader-syntax
|
||||
|
||||
interface LogPayload {
|
||||
|
@ -10,7 +12,7 @@ interface LogPayload {
|
|||
|
||||
export interface AudioExportService {
|
||||
init(): Promise<void>;
|
||||
export(params: { format: string }): Promise<ArrayBuffer>;
|
||||
export(params: { requestedFormat: 'SP' | 'LP2' | 'LP4' }): Promise<{ data: ArrayBuffer; format: Wireformat }>;
|
||||
info(): Promise<{ format: string | null; input: string | null }>;
|
||||
prepare(file: File): Promise<void>;
|
||||
}
|
||||
|
@ -21,12 +23,14 @@ export class FFMpegAudioExportService implements AudioExportService {
|
|||
public loglines: { action: string; message: string }[] = [];
|
||||
public inFileName: string = ``;
|
||||
public outFileNameNoExt: string = ``;
|
||||
public inFile?: File;
|
||||
|
||||
async init() {
|
||||
setLogging(true);
|
||||
}
|
||||
|
||||
async prepare(file: File) {
|
||||
this.inFile = file;
|
||||
this.loglines = [];
|
||||
this.ffmpegProcess = createWorker({
|
||||
logger: (payload: LogPayload) => {
|
||||
|
@ -79,33 +83,45 @@ export class FFMpegAudioExportService implements AudioExportService {
|
|||
return { format, input };
|
||||
}
|
||||
|
||||
async export({ format }: { format: string }) {
|
||||
async export({ requestedFormat }: { requestedFormat: 'SP' | 'LP2' | 'LP4' | 'LP105' }) {
|
||||
let result: ArrayBuffer;
|
||||
if (format === `SP`) {
|
||||
let format: Wireformat;
|
||||
const atrac3Info = await getAtrac3Info(this.inFile!);
|
||||
if (atrac3Info) {
|
||||
format = WireformatDict[atrac3Info.mode];
|
||||
result = (await this.inFile!.arrayBuffer()).slice(atrac3Info.dataOffset);
|
||||
} else if (requestedFormat === `SP`) {
|
||||
const outFileName = `${this.outFileNameNoExt}.raw`;
|
||||
await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-ac 2 -ar 44100 -f s16be');
|
||||
let { data } = await this.ffmpegProcess.read(outFileName);
|
||||
result = data.buffer;
|
||||
format = Wireformat.pcm;
|
||||
} else {
|
||||
const outFileName = `${this.outFileNameNoExt}.wav`;
|
||||
await this.ffmpegProcess.transcode(this.inFileName, outFileName, '-f wav -ar 44100 -ac 2');
|
||||
let { data } = await this.ffmpegProcess.read(outFileName);
|
||||
let bitrate: string = `0`;
|
||||
switch (format) {
|
||||
switch (requestedFormat) {
|
||||
case `LP2`:
|
||||
bitrate = `128`;
|
||||
format = Wireformat.lp2;
|
||||
break;
|
||||
case `LP105`:
|
||||
bitrate = `102`;
|
||||
format = Wireformat.l105kbps;
|
||||
break;
|
||||
case `LP4`:
|
||||
bitrate = `64`;
|
||||
format = Wireformat.lp4;
|
||||
break;
|
||||
}
|
||||
result = await this.atracdencProcess!.encode(data.buffer, bitrate);
|
||||
}
|
||||
this.ffmpegProcess.worker.terminate();
|
||||
this.atracdencProcess!.terminate();
|
||||
return result;
|
||||
return {
|
||||
data: result,
|
||||
format,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
|
|
@ -0,0 +1,161 @@
|
|||
import { debounce, DisplayTrack, getSortedTracks, sleep } from '../utils';
|
||||
import { createEmptyWave } from '../create-empty-wave';
|
||||
import { control } from '../redux/actions';
|
||||
import { AppStore, AppSubscribe, AppGetState } from '../redux/store';
|
||||
import { Dispatch } from '@reduxjs/toolkit';
|
||||
import { Disc } from 'netmd-js';
|
||||
|
||||
export interface MediaSessionService {
|
||||
init(): Promise<void>;
|
||||
}
|
||||
|
||||
export class BrowserMediaSessionService {
|
||||
private initialized = false;
|
||||
private audioEl?: HTMLAudioElement;
|
||||
|
||||
private dispatch: Dispatch<any>; // CAVEAT: AppDispatch type doesn't have an overload for redux thunk actions
|
||||
private subscribe: AppSubscribe;
|
||||
private getState: AppGetState;
|
||||
|
||||
constructor(appStore: AppStore) {
|
||||
this.dispatch = appStore.dispatch.bind(appStore) as Dispatch<any>;
|
||||
this.subscribe = appStore.subscribe.bind(appStore);
|
||||
this.getState = appStore.getState.bind(appStore);
|
||||
}
|
||||
|
||||
async init() {
|
||||
if (this.initialized || !navigator.mediaSession) {
|
||||
return;
|
||||
}
|
||||
this.initialized = true;
|
||||
|
||||
// Audio el
|
||||
const audioEl = document.createElement('audio');
|
||||
audioEl.id = 'browser-media-session-helper';
|
||||
document.body.appendChild(audioEl);
|
||||
|
||||
audioEl.setAttribute('loop', 'true');
|
||||
audioEl.src = URL.createObjectURL(createEmptyWave(6));
|
||||
audioEl.volume = 0;
|
||||
|
||||
this.audioEl = audioEl;
|
||||
|
||||
// Blocks media session events during initialization
|
||||
navigator.mediaSession.setActionHandler('play', null);
|
||||
navigator.mediaSession.setActionHandler('previoustrack', null);
|
||||
navigator.mediaSession.setActionHandler('nexttrack', null);
|
||||
navigator.mediaSession.setActionHandler('pause', null);
|
||||
navigator.mediaSession.setActionHandler('seekto', null);
|
||||
navigator.mediaSession.metadata = null;
|
||||
|
||||
audioEl.play();
|
||||
await sleep(5000); // CAVEAT: 5secs is the minimum playing time for media info to show up
|
||||
audioEl.pause();
|
||||
|
||||
if (this.getState().main.deviceStatus?.state === 'playing') {
|
||||
// restore current state
|
||||
audioEl.play();
|
||||
}
|
||||
|
||||
console.log('MediaSession ready');
|
||||
|
||||
// Set mediaSession event handlers
|
||||
navigator.mediaSession.setActionHandler('previoustrack', () => {
|
||||
this.dispatch(control('prev'));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('nexttrack', () => {
|
||||
this.dispatch(control('next'));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('pause', () => {
|
||||
audioEl.pause();
|
||||
this.dispatch(control('pause'));
|
||||
});
|
||||
navigator.mediaSession.setActionHandler('play', () => {
|
||||
audioEl.play();
|
||||
this.dispatch(control('play'));
|
||||
});
|
||||
|
||||
const debouncedSeek = debounce((time: number, trackNumber: number) => {
|
||||
this.dispatch(control('seek', { time, trackNumber }));
|
||||
audioEl.currentTime = time;
|
||||
}, 100);
|
||||
|
||||
navigator.mediaSession.setActionHandler('seekto', details => {
|
||||
const trackNumber = this.getState().main.deviceStatus?.track ?? -1;
|
||||
if (trackNumber === -1 || details.seekTime === null) {
|
||||
return; // can't seek without knowing the track number or the seek time
|
||||
}
|
||||
debouncedSeek(details.seekTime, trackNumber);
|
||||
});
|
||||
|
||||
this.subscribe(() => {
|
||||
this.syncState();
|
||||
});
|
||||
}
|
||||
|
||||
// This will save cpu cycles when just playing music. Might replace in the future with some memoization library
|
||||
private sortedTracks: DisplayTrack[] = [];
|
||||
private sortedTracksDisc: Disc | null = null;
|
||||
getSortedTracksWithCache(disc: Disc | null) {
|
||||
if (disc !== this.sortedTracksDisc) {
|
||||
this.sortedTracks = getSortedTracks(disc);
|
||||
this.sortedTracksDisc = disc;
|
||||
}
|
||||
return this.sortedTracks;
|
||||
}
|
||||
|
||||
syncState() {
|
||||
if (!this.initialized) {
|
||||
return;
|
||||
}
|
||||
|
||||
const audioEl = this.audioEl!;
|
||||
const {
|
||||
main: { deviceStatus, disc },
|
||||
} = this.getState();
|
||||
|
||||
const isPlaying = deviceStatus?.state === 'playing';
|
||||
const currentDiscTitle = disc?.title;
|
||||
const currentTrackIndex = deviceStatus?.track ?? -1;
|
||||
const allTracks = this.getSortedTracksWithCache(disc);
|
||||
const currentTrack: DisplayTrack | undefined = allTracks[currentTrackIndex];
|
||||
const currentTrackTitle = currentTrack ? currentTrack.fullWidthTitle || currentTrack?.title : '';
|
||||
const currentTrackDurationInSecs = Math.round(currentTrack?.durationInSecs ?? -1);
|
||||
|
||||
const oldTrackTitle = navigator.mediaSession.metadata?.title;
|
||||
const oldDiscTitle = navigator.mediaSession.metadata?.album;
|
||||
|
||||
// Sync MmediaSession
|
||||
if (isPlaying && navigator.mediaSession.playbackState !== 'playing') {
|
||||
navigator.mediaSession.playbackState = 'playing';
|
||||
} else if (!isPlaying && navigator.mediaSession.playbackState !== 'paused') {
|
||||
navigator.mediaSession.playbackState = 'paused';
|
||||
}
|
||||
|
||||
// Sync MediaMetadata
|
||||
if (oldTrackTitle !== currentTrackTitle || oldDiscTitle !== currentDiscTitle) {
|
||||
navigator.mediaSession.metadata = new MediaMetadata({
|
||||
title: currentTrackTitle,
|
||||
album: currentDiscTitle,
|
||||
artwork: [
|
||||
{ src: window.location.pathname + 'MiniDisc192.png', sizes: '192x192', type: 'image/png' },
|
||||
{ src: window.location.pathname + 'MiniDisc512.png', sizes: '512x512', type: 'image/png' },
|
||||
],
|
||||
});
|
||||
}
|
||||
|
||||
// Sync audio duration.
|
||||
// CAVEAT: replacing the src may change the audioEl paused state.
|
||||
if (audioEl && audioEl.duration !== currentTrackDurationInSecs && isPlaying) {
|
||||
URL.revokeObjectURL(audioEl.src ?? '');
|
||||
audioEl.src = URL.createObjectURL(createEmptyWave(Math.max(currentTrackDurationInSecs, 1)));
|
||||
}
|
||||
|
||||
// Sync <audio> state
|
||||
if (isPlaying && audioEl.paused) {
|
||||
audioEl.play();
|
||||
} else if (!isPlaying && !audioEl.paused) {
|
||||
audioEl.pause();
|
||||
}
|
||||
}
|
||||
}
|
|
@ -13,7 +13,7 @@ class NetMDMockService implements NetMDService {
|
|||
public _discCapacity: number = 80 * 60 * 512;
|
||||
public _tracks: Track[] = [
|
||||
{
|
||||
duration: 5 * 60 * 512,
|
||||
duration: 3 * 60 * 512,
|
||||
encoding: Encoding.sp,
|
||||
index: 0,
|
||||
channel: Channels.stereo,
|
||||
|
@ -360,6 +360,11 @@ class NetMDMockService implements NetMDService {
|
|||
await sleep(500);
|
||||
}
|
||||
|
||||
async gotoTime(index: number, hour = 0, minute = 0, second = 0, frame = 0) {
|
||||
this._status.track = index;
|
||||
await sleep(500);
|
||||
}
|
||||
|
||||
async getPosition() {
|
||||
if (this._status.track === null || this._status.time === null) {
|
||||
return null;
|
||||
|
|
|
@ -58,6 +58,7 @@ export interface NetMDService {
|
|||
next(): Promise<void>;
|
||||
prev(): Promise<void>;
|
||||
gotoTrack(index: number): Promise<void>;
|
||||
gotoTime(index: number, hour: number, minute: number, second: number, frame: number): Promise<void>;
|
||||
getPosition(): Promise<number[] | null>;
|
||||
}
|
||||
|
||||
|
@ -384,6 +385,11 @@ export class NetMDUSBService implements NetMDService {
|
|||
await this.netmdInterface!.gotoTrack(index);
|
||||
}
|
||||
|
||||
@asyncMutex
|
||||
async gotoTime(index: number, h: number, m: number, s: number, f: number) {
|
||||
await this.netmdInterface!.gotoTime(index, h, m, s, f);
|
||||
}
|
||||
|
||||
@asyncMutex
|
||||
async getPosition() {
|
||||
return await this.netmdInterface!.getPosition();
|
||||
|
|
|
@ -1,11 +1,13 @@
|
|||
import { NetMDService } from './netmd';
|
||||
import { AudioExportService } from './audio-export';
|
||||
import { MediaRecorderService } from './mediarecorder';
|
||||
import { MediaSessionService } from './media-session';
|
||||
|
||||
interface ServiceRegistry {
|
||||
netmdService?: NetMDService;
|
||||
audioExportService?: AudioExportService;
|
||||
mediaRecorderService?: MediaRecorderService;
|
||||
mediaSessionService?: MediaSessionService;
|
||||
}
|
||||
|
||||
const ServiceRegistry: ServiceRegistry = {};
|
||||
|
|
160
src/utils.ts
160
src/utils.ts
|
@ -12,6 +12,17 @@ export function sleep(ms: number) {
|
|||
});
|
||||
}
|
||||
|
||||
export function debounce<T extends Function>(func: T, timeout = 300): T {
|
||||
let timer: ReturnType<typeof setTimeout>;
|
||||
const debouncedFn = (...args: any) => {
|
||||
clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
func(...args);
|
||||
}, timeout);
|
||||
};
|
||||
return (debouncedFn as any) as T;
|
||||
}
|
||||
|
||||
export async function sleepWithProgressCallback(ms: number, cb: (perc: number) => void) {
|
||||
let elapsedSecs = 1;
|
||||
let interval = setInterval(() => {
|
||||
|
@ -84,6 +95,20 @@ export function framesToSec(frames: number) {
|
|||
return frames / 512;
|
||||
}
|
||||
|
||||
export function timeToSeekArgs(timeInSecs: number): number[] {
|
||||
let value = Math.round(timeInSecs); // ignore frames
|
||||
|
||||
let s = value % 60;
|
||||
value = (value - s) / 60; // min
|
||||
|
||||
let m = value % 60;
|
||||
value = (value - m) / 60; // hour
|
||||
|
||||
let h = value;
|
||||
|
||||
return [h, m, s, 0];
|
||||
}
|
||||
|
||||
export function sanitizeTitle(title: string) {
|
||||
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
|
||||
}
|
||||
|
@ -91,6 +116,7 @@ export function sanitizeTitle(title: string) {
|
|||
export function getHalfWidthTitleLength(title: string) {
|
||||
// Some characters are written as 2 bytes
|
||||
// prettier-ignore
|
||||
// '\u309C': -1, '\uFF9F': -1, '\u309B': -1, '\uFF9E': -1 but when they become part of a multi byte character, it will sum up to 0
|
||||
const multiByteChars: { [key: string]: number } = { "ガ": 1, "ギ": 1, "グ": 1, "ゲ": 1, "ゴ": 1, "ザ": 1, "ジ": 1, "ズ": 1, "ゼ": 1, "ゾ": 1, "ダ": 1, "ヂ": 1, "ヅ": 1, "デ": 1, "ド": 1, "バ": 1, "パ": 1, "ビ": 1, "ピ": 1, "ブ": 1, "プ": 1, "ベ": 1, "ペ": 1, "ボ": 1, "ポ": 1, "ヮ": 1, "ヰ": 1, "ヱ": 1, "ヵ": 1, "ヶ": 1, "ヴ": 1, "ヽ": 1, "ヾ": 1, "が": 1, "ぎ": 1, "ぐ": 1, "げ": 1, "ご": 1, "ざ": 1, "じ": 1, "ず": 1, "ぜ": 1, "ぞ": 1, "だ": 1, "ぢ": 1, "づ": 1, "で": 1, "ど": 1, "ば": 1, "ぱ": 1, "び": 1, "ぴ": 1, "ぶ": 1, "ぷ": 1, "べ": 1, "ぺ": 1, "ぼ": 1, "ぽ": 1, "ゎ": 1, "ゐ": 1, "ゑ": 1, "ゕ": 1, "ゖ": 1, "ゔ": 1, "ゝ": 1, "ゞ": 1 };
|
||||
return (
|
||||
title.length +
|
||||
|
@ -102,8 +128,59 @@ export function getHalfWidthTitleLength(title: string) {
|
|||
}
|
||||
|
||||
export function sanitizeHalfWidthTitle(title: string) {
|
||||
enum CharType {
|
||||
normal,
|
||||
dakuten,
|
||||
handakuten,
|
||||
}
|
||||
|
||||
const handakutenPossible = 'はひふへほハヒフヘホ'.split('');
|
||||
const dakutenPossible = 'かきくけこさしすせそたちつてとカキクケコサシスセソタチツテト'.split('').concat(handakutenPossible);
|
||||
|
||||
//'Flatten' all the characters followed by the (han)dakuten character into one
|
||||
let dakutenFix = [];
|
||||
let type = CharType.normal;
|
||||
for (const char of sanitizeFullWidthTitle(title, true)
|
||||
.split('')
|
||||
.reverse()) {
|
||||
//This only works for full-width kana. It will get converted to half-width later anyway...
|
||||
switch (type) {
|
||||
case CharType.dakuten:
|
||||
if (dakutenPossible.includes(char)) {
|
||||
dakutenFix.push(String.fromCharCode(char.charCodeAt(0) + 1));
|
||||
type = CharType.normal;
|
||||
break;
|
||||
} //Else fall through
|
||||
case CharType.handakuten:
|
||||
if (handakutenPossible.includes(char)) {
|
||||
dakutenFix.push(String.fromCharCode(char.charCodeAt(0) + 2));
|
||||
type = CharType.normal;
|
||||
break;
|
||||
} //Else fall through
|
||||
case CharType.normal:
|
||||
switch (char) {
|
||||
case '\u309B':
|
||||
case '\u3099':
|
||||
case '\uFF9E':
|
||||
type = CharType.dakuten;
|
||||
break;
|
||||
case '\u309C':
|
||||
case '\u309A':
|
||||
case '\uFF9F':
|
||||
type = CharType.handakuten;
|
||||
break;
|
||||
default:
|
||||
type = CharType.normal;
|
||||
dakutenFix.push(char);
|
||||
break;
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
title = dakutenFix.reverse().join('');
|
||||
// prettier-ignore
|
||||
const mappings: { [key: string]: string } = { 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', ''': "'", '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '-': '-', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'O': 'O', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'U': 'U', 'V': 'V', 'W': 'W', 'X': 'X', 'Y': 'Y', 'Z': 'Z', '[': '[', '\': '\\', ']': ']', '^': '^', '_': '_', '`': '`', 'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'l', 'm': 'm', 'n': 'n', 'o': 'o', 'p': 'p', 'q': 'q', 'r': 'r', 's': 's', 't': 't', 'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z', '{': '{', '|': '|', '}': '}', '~': '~', '\u3000': ' ', '0': '0', '1': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
|
||||
const mappings: { [key: string]: string } = { '-': '-', 'ー': '-', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': '-', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', ''': "'", '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'O': 'O', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'U': 'U', 'V': 'V', 'W': 'W', 'X': 'X', 'Y': 'Y', 'Z': 'Z', '[': '[', '\': '\\', ']': ']', '^': '^', '_': '_', '`': '`', 'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'l', 'm': 'm', 'n': 'n', 'o': 'o', 'p': 'p', 'q': 'q', 'r': 'r', 's': 's', 't': 't', 'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z', '{': '{', '|': '|', '}': '}', '~': '~', '\u3000': ' ', '0': '0', '1': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
|
||||
const allowedHalfWidthKana: string[] = Object.values(mappings);
|
||||
|
||||
const newTitle = title
|
||||
|
@ -120,7 +197,7 @@ export function sanitizeHalfWidthTitle(title: string) {
|
|||
return newTitle;
|
||||
}
|
||||
|
||||
export function sanitizeFullWidthTitle(title: string) {
|
||||
export function sanitizeFullWidthTitle(title: string, justRemap: boolean = false) {
|
||||
// prettier-ignore
|
||||
const mappings: { [key: string]: string } = { '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', "'": ''', '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '-': '-', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'O': 'O', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'U': 'U', 'V': 'V', 'W': 'W', 'X': 'X', 'Y': 'Y', 'Z': 'Z', '[': '[', '\\': '\', ']': ']', '^': '^', '_': '_', '`': '`', 'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'l', 'm': 'm', 'n': 'n', 'o': 'o', 'p': 'p', 'q': 'q', 'r': 'r', 's': 's', 't': 't', 'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z', '{': '{', '|': '|', '}': '}', '~': '~', ' ': '\u3000', '0': '0', '1': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、' };
|
||||
|
||||
|
@ -129,6 +206,8 @@ export function sanitizeFullWidthTitle(title: string) {
|
|||
.map(n => mappings[n] ?? n)
|
||||
.join('');
|
||||
|
||||
if (justRemap) return newTitle;
|
||||
|
||||
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
|
||||
if (jconv.decode(sjisEncoded, 'SJIS') !== newTitle) return sanitizeTitle(title); // Fallback
|
||||
if (sjisEncoded.length !== title.length * 2) return sanitizeTitle(title); // Fallback (every character in the full-width title is 2 bytes)
|
||||
|
@ -148,6 +227,7 @@ export type DisplayTrack = {
|
|||
group: string | null;
|
||||
duration: string;
|
||||
encoding: string;
|
||||
durationInSecs: number;
|
||||
};
|
||||
|
||||
export function getSortedTracks(disc: Disc | null) {
|
||||
|
@ -162,6 +242,7 @@ export function getSortedTracks(disc: Disc | null) {
|
|||
group: group.title ?? null,
|
||||
encoding: EncodingName[track.encoding],
|
||||
duration: formatTimeFromFrames(track.duration, false),
|
||||
durationInSecs: track.duration / 512, // CAVEAT: 1s = 512 frames
|
||||
});
|
||||
}
|
||||
}
|
||||
|
@ -391,4 +472,79 @@ export function askNotificationPermission(): Promise<NotificationPermission> {
|
|||
}
|
||||
}
|
||||
|
||||
export async function getAtrac3Info(file: File) {
|
||||
// see: http://soundfile.sapp.org/doc/WaveFormat/
|
||||
// and: https://www.fatalerrors.org/a/detailed-explanation-of-wav-file-format.html
|
||||
|
||||
const fileData = await file.arrayBuffer();
|
||||
if (fileData.byteLength < 44) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const riffDescriptor = new Uint32Array(fileData.slice(0, 12));
|
||||
if (riffDescriptor[0] !== 0x46464952 || riffDescriptor[2] !== 0x45564157) {
|
||||
// 'RIFF' && 'WAVE'
|
||||
return null;
|
||||
}
|
||||
|
||||
// WAVE format
|
||||
const waveDescriptor = new Uint32Array(fileData.slice(12, 20));
|
||||
if (waveDescriptor[0] !== 0x20746d66) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const audioFormatAndChanneld = new Uint16Array(fileData.slice(20, 24));
|
||||
if (audioFormatAndChanneld[0] !== 0x270 || audioFormatAndChanneld[1] !== 2) {
|
||||
// 'atrac3' && 2 channels
|
||||
return null;
|
||||
}
|
||||
|
||||
const sampleRateAndByteRate = new Uint32Array(fileData.slice(24, 32));
|
||||
if (sampleRateAndByteRate[0] !== 44100) {
|
||||
// Sample rate
|
||||
return null;
|
||||
}
|
||||
const byteRate = sampleRateAndByteRate[1];
|
||||
|
||||
let mode: 'LP2' | 'LP105' | 'LP4' | null = null;
|
||||
if (byteRate > 16000) {
|
||||
mode = 'LP2';
|
||||
} else if (byteRate > 13000) {
|
||||
mode = 'LP105';
|
||||
} else if (byteRate > 8000) {
|
||||
mode = 'LP4';
|
||||
} else {
|
||||
mode = null;
|
||||
}
|
||||
|
||||
if (mode === null) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const waveBlockEndOffset = new Uint16Array(fileData.slice(36, 38));
|
||||
|
||||
let dataOffset = -1;
|
||||
|
||||
const nextBlockStartOffset = waveBlockEndOffset[0] + 38;
|
||||
const nextBlockEndOffset = nextBlockStartOffset + 8;
|
||||
const nextBlock = new Uint32Array(fileData.slice(nextBlockStartOffset, nextBlockEndOffset));
|
||||
if (nextBlock[0] === 0x61746164) {
|
||||
// data
|
||||
dataOffset = nextBlockEndOffset;
|
||||
} else if (nextBlock[0] === 0x74636166) {
|
||||
// fact
|
||||
const dataBlockLength = 8;
|
||||
dataOffset = nextBlockEndOffset + nextBlock[1] + dataBlockLength;
|
||||
}
|
||||
|
||||
if (dataOffset === -1) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return {
|
||||
mode,
|
||||
dataOffset,
|
||||
};
|
||||
}
|
||||
|
||||
declare let process: any;
|
||||
|
|
Loading…
Reference in New Issue