Merge pull request #48 from asivery/master
Asivery's work on groups and full width titles
This commit is contained in:
commit
3ff45e7350
|
@ -21,11 +21,13 @@
|
||||||
"@types/w3c-web-usb": "^1.0.4",
|
"@types/w3c-web-usb": "^1.0.4",
|
||||||
"clsx": "^1.1.1",
|
"clsx": "^1.1.1",
|
||||||
"husky": "^4.3.8",
|
"husky": "^4.3.8",
|
||||||
|
"jconv": "^0.1.5",
|
||||||
"lint-staged": "^10.5.3",
|
"lint-staged": "^10.5.3",
|
||||||
"music-metadata-browser": "^2.2.0",
|
"music-metadata-browser": "^2.2.0",
|
||||||
"netmd-js": "^1.4.0",
|
"netmd-js": "^1.4.0",
|
||||||
"prettier": "^1.19.1",
|
"prettier": "^1.19.1",
|
||||||
"react": "^16.14.0",
|
"react": "^16.14.0",
|
||||||
|
"react-beautiful-dnd": "^13.1.0",
|
||||||
"react-dom": "^16.14.0",
|
"react-dom": "^16.14.0",
|
||||||
"react-dropzone": "^10.2.2",
|
"react-dropzone": "^10.2.2",
|
||||||
"react-redux": "^7.2.2",
|
"react-redux": "^7.2.2",
|
||||||
|
@ -72,6 +74,7 @@
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/dom-mediacapture-record": "^1.0.7",
|
"@types/dom-mediacapture-record": "^1.0.7",
|
||||||
|
"@types/react-beautiful-dnd": "^13.1.1",
|
||||||
"async-mutex": "^0.2.6",
|
"async-mutex": "^0.2.6",
|
||||||
"gh-pages": "^2.2.0"
|
"gh-pages": "^2.2.0"
|
||||||
}
|
}
|
||||||
|
|
|
@ -142,9 +142,10 @@ export const Controls = () => {
|
||||||
} else if (tracks.length === 0) {
|
} else if (tracks.length === 0) {
|
||||||
message = `BLANKDISC`;
|
message = `BLANKDISC`;
|
||||||
} else if (deviceStatus && deviceStatus.track !== null && tracks[deviceStatus.track]) {
|
} else if (deviceStatus && deviceStatus.track !== null && tracks[deviceStatus.track]) {
|
||||||
|
let title = tracks[deviceStatus.track].fullWidthTitle || tracks[deviceStatus.track].title;
|
||||||
message =
|
message =
|
||||||
(deviceStatus.track + 1).toString().padStart(3, '0') +
|
(deviceStatus.track + 1).toString().padStart(3, '0') +
|
||||||
(tracks[deviceStatus.track].title ? ' - ' + tracks[deviceStatus.track].title : '');
|
(title ? ' - ' + title : '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const [lcdScroll, setLcdScroll] = useState(0);
|
const [lcdScroll, setLcdScroll] = useState(0);
|
||||||
|
|
|
@ -1,18 +1,44 @@
|
||||||
import React, { useEffect, useCallback } from 'react';
|
import React, { useEffect, useCallback, useState } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import clsx from 'clsx';
|
import clsx from 'clsx';
|
||||||
import { useDropzone } from 'react-dropzone';
|
import { useDropzone } from 'react-dropzone';
|
||||||
import { listContent, deleteTracks, moveTrack } from '../redux/actions';
|
import {
|
||||||
|
DragDropContext,
|
||||||
|
Draggable,
|
||||||
|
DraggableProvided,
|
||||||
|
DropResult,
|
||||||
|
ResponderProvided,
|
||||||
|
Droppable,
|
||||||
|
DroppableProvided,
|
||||||
|
DroppableStateSnapshot,
|
||||||
|
} from 'react-beautiful-dnd';
|
||||||
|
import {
|
||||||
|
listContent,
|
||||||
|
deleteTracks,
|
||||||
|
moveTrack,
|
||||||
|
groupTracks,
|
||||||
|
deleteGroup,
|
||||||
|
dragDropTrack,
|
||||||
|
} from '../redux/actions';
|
||||||
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
|
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
|
||||||
import { actions as convertDialogActions } from '../redux/convert-dialog-feature';
|
import { actions as convertDialogActions } from '../redux/convert-dialog-feature';
|
||||||
import { actions as dumpDialogActions } from '../redux/dump-dialog-feature';
|
import { actions as dumpDialogActions } from '../redux/dump-dialog-feature';
|
||||||
|
|
||||||
import { formatTimeFromFrames, getTracks } from 'netmd-js';
|
import { formatTimeFromFrames, Track } from 'netmd-js';
|
||||||
import { control } from '../redux/actions';
|
import { control } from '../redux/actions';
|
||||||
|
|
||||||
import { belowDesktop, forAnyDesktop, getSortedTracks, useShallowEqualSelector } from '../utils';
|
import {
|
||||||
|
belowDesktop,
|
||||||
|
forAnyDesktop,
|
||||||
|
getGroupedTracks,
|
||||||
|
getSortedTracks,
|
||||||
|
isSequential,
|
||||||
|
useShallowEqualSelector,
|
||||||
|
EncodingName,
|
||||||
|
} from '../utils';
|
||||||
|
|
||||||
import { lighten, makeStyles } from '@material-ui/core/styles';
|
import { lighten, makeStyles } from '@material-ui/core/styles';
|
||||||
|
import { fade } from '@material-ui/core/styles/colorManipulator';
|
||||||
import Typography from '@material-ui/core/Typography';
|
import Typography from '@material-ui/core/Typography';
|
||||||
import Box from '@material-ui/core/Box';
|
import Box from '@material-ui/core/Box';
|
||||||
import Fab from '@material-ui/core/Fab';
|
import Fab from '@material-ui/core/Fab';
|
||||||
|
@ -22,6 +48,7 @@ import EditIcon from '@material-ui/icons/Edit';
|
||||||
import Backdrop from '@material-ui/core/Backdrop';
|
import Backdrop from '@material-ui/core/Backdrop';
|
||||||
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
|
import PlayArrowIcon from '@material-ui/icons/PlayArrow';
|
||||||
import PauseIcon from '@material-ui/icons/Pause';
|
import PauseIcon from '@material-ui/icons/Pause';
|
||||||
|
import FolderIcon from '@material-ui/icons/Folder';
|
||||||
|
|
||||||
import Table from '@material-ui/core/Table';
|
import Table from '@material-ui/core/Table';
|
||||||
import TableBody from '@material-ui/core/TableBody';
|
import TableBody from '@material-ui/core/TableBody';
|
||||||
|
@ -46,8 +73,6 @@ import { TopMenu } from './topmenu';
|
||||||
import Checkbox from '@material-ui/core/Checkbox';
|
import Checkbox from '@material-ui/core/Checkbox';
|
||||||
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
|
import * as BadgeImpl from '@material-ui/core/Badge/Badge';
|
||||||
import Button from '@material-ui/core/Button';
|
import Button from '@material-ui/core/Button';
|
||||||
import Menu from '@material-ui/core/Menu';
|
|
||||||
import MenuItem from '@material-ui/core/MenuItem';
|
|
||||||
import { W95Main } from './win95/main';
|
import { W95Main } from './win95/main';
|
||||||
|
|
||||||
const useStyles = makeStyles(theme => ({
|
const useStyles = makeStyles(theme => ({
|
||||||
|
@ -126,7 +151,7 @@ const useStyles = makeStyles(theme => ({
|
||||||
indexCell: {
|
indexCell: {
|
||||||
whiteSpace: 'nowrap',
|
whiteSpace: 'nowrap',
|
||||||
paddingRight: 0,
|
paddingRight: 0,
|
||||||
width: `2ch`,
|
width: `16px`,
|
||||||
},
|
},
|
||||||
backdrop: {
|
backdrop: {
|
||||||
zIndex: theme.zIndex.drawer + 1,
|
zIndex: theme.zIndex.drawer + 1,
|
||||||
|
@ -138,7 +163,9 @@ const useStyles = makeStyles(theme => ({
|
||||||
},
|
},
|
||||||
controlButtonInTrackCommon: {
|
controlButtonInTrackCommon: {
|
||||||
width: `16px`,
|
width: `16px`,
|
||||||
|
height: `16px`,
|
||||||
verticalAlign: 'middle',
|
verticalAlign: 'middle',
|
||||||
|
cursor: 'pointer',
|
||||||
marginLeft: theme.spacing(-0.5),
|
marginLeft: theme.spacing(-0.5),
|
||||||
},
|
},
|
||||||
playButtonInTrackListPlaying: {
|
playButtonInTrackListPlaying: {
|
||||||
|
@ -153,15 +180,17 @@ const useStyles = makeStyles(theme => ({
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
},
|
},
|
||||||
playButtonInTrackListNotPlaying: {
|
playButtonInTrackListNotPlaying: {
|
||||||
visibility: 'hidden',
|
width: `0px`,
|
||||||
},
|
},
|
||||||
trackRow: {
|
trackRow: {
|
||||||
|
userSelect: 'none',
|
||||||
'&:hover': {
|
'&:hover': {
|
||||||
/* For the tracks that aren't currently playing */
|
/* For the tracks that aren't currently playing */
|
||||||
'& $playButtonInTrackListNotPlaying': {
|
'& $playButtonInTrackListNotPlaying': {
|
||||||
visibility: 'visible',
|
width: `16px`,
|
||||||
},
|
},
|
||||||
'& $trackIndex': {
|
'& $trackIndex': {
|
||||||
|
width: `0px`,
|
||||||
display: 'none',
|
display: 'none',
|
||||||
},
|
},
|
||||||
|
|
||||||
|
@ -174,8 +203,34 @@ const useStyles = makeStyles(theme => ({
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
inGroupTrackRow: {
|
||||||
|
'& > $indexCell': {
|
||||||
|
transform: `translateX(${theme.spacing(3)}px)`,
|
||||||
|
},
|
||||||
|
'& > $titleCell': {
|
||||||
|
transform: `translateX(${theme.spacing(3)}px)`,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
deleteGroupButton: {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
groupHeadRow: {
|
||||||
|
'&:hover': {
|
||||||
|
'& svg:not($deleteGroupButton)': {
|
||||||
|
display: 'none',
|
||||||
|
},
|
||||||
|
'& $deleteGroupButton': {
|
||||||
|
display: 'inline-block',
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
hoveringOverGroup: {
|
||||||
|
backgroundColor: `${fade(theme.palette.secondary.dark, 0.4)}`,
|
||||||
|
},
|
||||||
trackIndex: {
|
trackIndex: {
|
||||||
display: 'inline-block',
|
display: 'inline-block',
|
||||||
|
height: '16px',
|
||||||
|
width: '16px',
|
||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
|
@ -187,6 +242,7 @@ export const Main = (props: {}) => {
|
||||||
const { vintageMode } = useShallowEqualSelector(state => state.appState);
|
const { vintageMode } = useShallowEqualSelector(state => state.appState);
|
||||||
|
|
||||||
const [selected, setSelected] = React.useState<number[]>([]);
|
const [selected, setSelected] = React.useState<number[]>([]);
|
||||||
|
const [lastClicked, setLastClicked] = useState(-1);
|
||||||
const selectedCount = selected.length;
|
const selectedCount = selected.length;
|
||||||
|
|
||||||
const [moveMenuAnchorEl, setMoveMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
const [moveMenuAnchorEl, setMoveMenuAnchorEl] = React.useState<null | HTMLElement>(null);
|
||||||
|
@ -207,6 +263,18 @@ export const Main = (props: {}) => {
|
||||||
[dispatch, selected, handleCloseMoveMenu]
|
[dispatch, selected, handleCloseMoveMenu]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const handleDrop = useCallback(
|
||||||
|
(result: DropResult, provided: ResponderProvided) => {
|
||||||
|
if (!result.destination) return;
|
||||||
|
let sourceList = parseInt(result.source.droppableId),
|
||||||
|
sourceIndex = result.source.index,
|
||||||
|
targetList = parseInt(result.destination.droppableId),
|
||||||
|
targetIndex = result.destination.index;
|
||||||
|
dispatch(dragDropTrack(sourceList, sourceIndex, targetList, targetIndex));
|
||||||
|
},
|
||||||
|
[dispatch]
|
||||||
|
);
|
||||||
|
|
||||||
const handleShowDumpDialog = useCallback(() => {
|
const handleShowDumpDialog = useCallback(() => {
|
||||||
dispatch(dumpDialogActions.setVisible(true));
|
dispatch(dumpDialogActions.setVisible(true));
|
||||||
}, [dispatch]);
|
}, [dispatch]);
|
||||||
|
@ -235,14 +303,27 @@ export const Main = (props: {}) => {
|
||||||
|
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const tracks = getSortedTracks(disc);
|
const tracks = getSortedTracks(disc);
|
||||||
|
const groupedTracks = getGroupedTracks(disc);
|
||||||
|
|
||||||
// Action Handlers
|
// Action Handlers
|
||||||
const handleSelectClick = (event: React.MouseEvent, item: number) => {
|
const handleSelectClick = (event: React.MouseEvent, item: number) => {
|
||||||
if (selected.includes(item)) {
|
if(event.shiftKey && selected.length && lastClicked !== -1){
|
||||||
|
let rangeBegin = Math.min(lastClicked+1, item),
|
||||||
|
rangeEnd = Math.max(lastClicked-1, item);
|
||||||
|
let copy = [...selected];
|
||||||
|
for(let i = rangeBegin; i<=rangeEnd; i++){
|
||||||
|
let index = copy.indexOf(i);
|
||||||
|
if(index === -1) copy.push(i);
|
||||||
|
else copy.splice(index, 1);
|
||||||
|
}
|
||||||
|
if(!copy.includes(item)) copy.push(item);
|
||||||
|
setSelected(copy);
|
||||||
|
}else if (selected.includes(item)) {
|
||||||
setSelected(selected.filter(i => i !== item));
|
setSelected(selected.filter(i => i !== item));
|
||||||
} else {
|
} else {
|
||||||
setSelected([...selected, item]);
|
setSelected([...selected, item]);
|
||||||
}
|
}
|
||||||
|
setLastClicked(item);
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
|
const handleSelectAllClick = (event: React.ChangeEvent<HTMLInputElement>) => {
|
||||||
|
@ -253,15 +334,25 @@ export const Main = (props: {}) => {
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleRenameDoubleClick = (event: React.MouseEvent, item: number) => {
|
const handleRenameDoubleClick = (event: React.MouseEvent, index: number, renameGroup?: boolean) => {
|
||||||
let selectedIndex = item;
|
let group, track;
|
||||||
let currentName = getTracks(disc!).find(track => track.index === selectedIndex)?.title ?? '';
|
if (renameGroup) {
|
||||||
|
group = groupedTracks.find(n => n.tracks[0]?.index === index);
|
||||||
|
if (!group) return;
|
||||||
|
} else {
|
||||||
|
track = tracks.find(n => n.index === index);
|
||||||
|
if (!track) return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let currentName = (track ? track.title : group?.title) ?? '';
|
||||||
|
let currentFullWidthName = (track ? track.fullWidthTitle : group?.fullWidthTitle) ?? '';
|
||||||
dispatch(
|
dispatch(
|
||||||
batchActions([
|
batchActions([
|
||||||
renameDialogActions.setVisible(true),
|
renameDialogActions.setVisible(true),
|
||||||
|
renameDialogActions.setGroupIndex(group ? index : null),
|
||||||
renameDialogActions.setCurrentName(currentName),
|
renameDialogActions.setCurrentName(currentName),
|
||||||
renameDialogActions.setIndex(selectedIndex),
|
renameDialogActions.setCurrentFullWidthName(currentFullWidthName),
|
||||||
|
renameDialogActions.setIndex(track?.index ?? -1),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -274,6 +365,14 @@ export const Main = (props: {}) => {
|
||||||
dispatch(deleteTracks(selected));
|
dispatch(deleteTracks(selected));
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const handleGroupTracks = (event: React.MouseEvent) => {
|
||||||
|
dispatch(groupTracks(selected));
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleGroupRemoval = (event: React.MouseEvent, groupBegin: number) => {
|
||||||
|
dispatch(deleteGroup(groupBegin));
|
||||||
|
};
|
||||||
|
|
||||||
const handlePlayTrack = async (event: React.MouseEvent, track: number) => {
|
const handlePlayTrack = async (event: React.MouseEvent, track: number) => {
|
||||||
if (deviceStatus?.track !== track) {
|
if (deviceStatus?.track !== track) {
|
||||||
dispatch(control('goto', track));
|
dispatch(control('goto', track));
|
||||||
|
@ -324,6 +423,64 @@ export const Main = (props: {}) => {
|
||||||
return <W95Main {...p} />;
|
return <W95Main {...p} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const getTrackRow = (track: Track, inGroup: boolean, additional: {}) => {
|
||||||
|
const child = (
|
||||||
|
<TableRow
|
||||||
|
{...additional}
|
||||||
|
hover
|
||||||
|
selected={selected.includes(track.index)}
|
||||||
|
onDoubleClick={event => handleRenameDoubleClick(event, track.index)}
|
||||||
|
onClick={event => handleSelectClick(event, track.index)}
|
||||||
|
className={clsx(classes.trackRow, { [classes.inGroupTrackRow]: inGroup })}
|
||||||
|
>
|
||||||
|
<TableCell className={classes.indexCell}>
|
||||||
|
{track.index === deviceStatus?.track && ['playing', 'paused'].includes(deviceStatus?.state) ? (
|
||||||
|
<span>
|
||||||
|
<PlayArrowIcon
|
||||||
|
className={clsx(classes.controlButtonInTrackCommon, classes.playButtonInTrackListPlaying, {
|
||||||
|
[classes.currentControlButton]: deviceStatus?.state === 'playing',
|
||||||
|
})}
|
||||||
|
onClick={event => {
|
||||||
|
handleCurrentClick(event);
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<PauseIcon
|
||||||
|
className={clsx(classes.controlButtonInTrackCommon, classes.pauseButtonInTrackListPlaying, {
|
||||||
|
[classes.currentControlButton]: deviceStatus?.state === 'paused',
|
||||||
|
})}
|
||||||
|
onClick={event => {
|
||||||
|
handleCurrentClick(event);
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
<span className={classes.trackIndex}>{track.index + 1}</span>
|
||||||
|
<PlayArrowIcon
|
||||||
|
className={clsx(classes.controlButtonInTrackCommon, classes.playButtonInTrackListNotPlaying)}
|
||||||
|
onClick={event => {
|
||||||
|
handlePlayTrack(event, track.index);
|
||||||
|
event.stopPropagation();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell className={classes.titleCell} title={track.title ?? ''}>
|
||||||
|
{track.fullWidthTitle ? `${track.fullWidthTitle} / ` : ``}
|
||||||
|
{track.title || `No Title`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className={classes.durationCell}>
|
||||||
|
<span className={classes.formatBadge}>{EncodingName[track.encoding]}</span>
|
||||||
|
<span className={classes.durationCellTime}>{formatTimeFromFrames(track.duration, false)}</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
);
|
||||||
|
return child;
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Box className={classes.headBox}>
|
<Box className={classes.headBox}>
|
||||||
|
@ -372,40 +529,10 @@ export const Main = (props: {}) => {
|
||||||
</Typography>
|
</Typography>
|
||||||
) : (
|
) : (
|
||||||
<Typography component="h3" variant="h6" className={classes.toolbarLabel}>
|
<Typography component="h3" variant="h6" className={classes.toolbarLabel}>
|
||||||
|
{disc?.fullWidthTitle && `${disc.fullWidthTitle} / `}
|
||||||
{disc?.title || `Untitled Disc`}
|
{disc?.title || `Untitled Disc`}
|
||||||
</Typography>
|
</Typography>
|
||||||
)}
|
)}
|
||||||
{selectedCount === 1 ? (
|
|
||||||
<React.Fragment>
|
|
||||||
<Tooltip title="Move to Position">
|
|
||||||
<Button aria-controls="move-menu" aria-label="Move" onClick={handleShowMoveMenu}>
|
|
||||||
Move
|
|
||||||
</Button>
|
|
||||||
</Tooltip>
|
|
||||||
<Menu
|
|
||||||
id="move-menu"
|
|
||||||
anchorEl={moveMenuAnchorEl}
|
|
||||||
open={!!moveMenuAnchorEl}
|
|
||||||
onClose={handleCloseMoveMenu}
|
|
||||||
PaperProps={{
|
|
||||||
style: {
|
|
||||||
maxHeight: 300,
|
|
||||||
},
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{Array(tracks.length)
|
|
||||||
.fill(null)
|
|
||||||
.map((_, i) => {
|
|
||||||
return (
|
|
||||||
<MenuItem key={`pos-${i}`} onClick={() => handleMoveSelectedTrack(i)}>
|
|
||||||
{i + 1}
|
|
||||||
</MenuItem>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
</Menu>
|
|
||||||
</React.Fragment>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<React.Fragment>
|
<React.Fragment>
|
||||||
<Tooltip title="Record from MD">
|
<Tooltip title="Record from MD">
|
||||||
|
@ -424,6 +551,21 @@ export const Main = (props: {}) => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
{selectedCount > 0 ? (
|
||||||
|
<Tooltip title="Group">
|
||||||
|
<IconButton
|
||||||
|
aria-label="group"
|
||||||
|
disabled={
|
||||||
|
!isSequential(selected.sort((a, b) => a - b)) ||
|
||||||
|
tracks.filter(n => n.group === null && selected.includes(n.index)).length !== selected.length
|
||||||
|
}
|
||||||
|
onClick={handleGroupTracks}
|
||||||
|
>
|
||||||
|
<FolderIcon />
|
||||||
|
</IconButton>
|
||||||
|
</Tooltip>
|
||||||
|
) : null}
|
||||||
|
|
||||||
{selectedCount > 0 ? (
|
{selectedCount > 0 ? (
|
||||||
<Tooltip title="Rename">
|
<Tooltip title="Rename">
|
||||||
<IconButton aria-label="rename" disabled={selectedCount !== 1} onClick={handleRenameActionClick}>
|
<IconButton aria-label="rename" disabled={selectedCount !== 1} onClick={handleRenameActionClick}>
|
||||||
|
@ -432,7 +574,7 @@ export const Main = (props: {}) => {
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
) : null}
|
) : null}
|
||||||
</Toolbar>
|
</Toolbar>
|
||||||
<Box className={classes.main} {...getRootProps()}>
|
<Box className={classes.main} {...getRootProps()} id="main">
|
||||||
<input {...getInputProps()} />
|
<input {...getInputProps()} />
|
||||||
<Table size="small">
|
<Table size="small">
|
||||||
<TableHead>
|
<TableHead>
|
||||||
|
@ -442,64 +584,82 @@ export const Main = (props: {}) => {
|
||||||
<TableCell align="right">Duration</TableCell>
|
<TableCell align="right">Duration</TableCell>
|
||||||
</TableRow>
|
</TableRow>
|
||||||
</TableHead>
|
</TableHead>
|
||||||
<TableBody>
|
<DragDropContext
|
||||||
{tracks.map(track => (
|
onDragEnd={handleDrop}
|
||||||
<TableRow
|
>
|
||||||
hover
|
<TableBody>
|
||||||
selected={selected.includes(track.index)}
|
{groupedTracks.map((group, index) => (
|
||||||
key={track.index}
|
<TableRow key={`${index}`}>
|
||||||
onDoubleClick={event => handleRenameDoubleClick(event, track.index)}
|
<TableCell colSpan={3} style={{ padding: '0' }}>
|
||||||
onClick={event => handleSelectClick(event, track.index)}
|
<Table size="small">
|
||||||
className={classes.trackRow}
|
<Droppable droppableId={`${index}`} key={`${index}`}>
|
||||||
>
|
{(provided: DroppableProvided, snapshot: DroppableStateSnapshot) => (
|
||||||
<TableCell className={classes.indexCell}>
|
<TableBody
|
||||||
{track.index === deviceStatus?.track && ['playing', 'paused'].includes(deviceStatus?.state) ? (
|
{...provided.droppableProps}
|
||||||
<span>
|
ref={provided.innerRef}
|
||||||
<PlayArrowIcon
|
className={clsx({[classes.hoveringOverGroup]: snapshot.isDraggingOver})}
|
||||||
className={clsx(classes.controlButtonInTrackCommon, classes.playButtonInTrackListPlaying, {
|
>
|
||||||
[classes.currentControlButton]: deviceStatus?.state === 'playing',
|
{group.title !== null && (
|
||||||
})}
|
<TableRow
|
||||||
onClick={event => {
|
hover
|
||||||
handleCurrentClick(event);
|
className={classes.groupHeadRow}
|
||||||
event.stopPropagation();
|
key={`${index}-head`}
|
||||||
}}
|
onDoubleClick={event =>
|
||||||
/>
|
handleRenameDoubleClick(event, group.tracks[0].index, true)
|
||||||
<PauseIcon
|
}
|
||||||
className={clsx(classes.controlButtonInTrackCommon, classes.pauseButtonInTrackListPlaying, {
|
>
|
||||||
[classes.currentControlButton]: deviceStatus?.state === 'paused',
|
<TableCell className={classes.indexCell}>
|
||||||
})}
|
<FolderIcon className={classes.controlButtonInTrackCommon} />
|
||||||
onClick={event => {
|
<DeleteIcon
|
||||||
handleCurrentClick(event);
|
className={clsx(
|
||||||
event.stopPropagation();
|
classes.controlButtonInTrackCommon,
|
||||||
}}
|
classes.deleteGroupButton
|
||||||
/>
|
)}
|
||||||
</span>
|
onClick={event => {
|
||||||
) : (
|
handleGroupRemoval(event, group.tracks[0].index);
|
||||||
<span>
|
}}
|
||||||
<span className={classes.trackIndex}>{track.index + 1}</span>
|
/>
|
||||||
<PlayArrowIcon
|
</TableCell>
|
||||||
className={clsx(
|
<TableCell className={classes.titleCell} title={group.title!}>
|
||||||
classes.controlButtonInTrackCommon,
|
{group.fullWidthTitle ? `${group.fullWidthTitle} / ` : ``}
|
||||||
classes.playButtonInTrackListNotPlaying
|
{group.title || `No Name`}
|
||||||
|
</TableCell>
|
||||||
|
<TableCell align="right" className={classes.durationCell}>
|
||||||
|
<span className={classes.durationCellTime}>
|
||||||
|
{formatTimeFromFrames(
|
||||||
|
group.tracks.map(n => n.duration).reduce((a, b) => a + b),
|
||||||
|
false
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</TableCell>
|
||||||
|
</TableRow>
|
||||||
|
)}
|
||||||
|
{group.title === null && group.tracks.length === 0 && <TableRow style={{height: '1px'}}/>}
|
||||||
|
{group.tracks.map(n => (
|
||||||
|
<Draggable
|
||||||
|
draggableId={`${group.index}-${n.index}`}
|
||||||
|
key={`${n.index - group.tracks[0].index}`}
|
||||||
|
index={n.index - group.tracks[0].index}
|
||||||
|
>
|
||||||
|
{(providedInGroup: DraggableProvided) =>
|
||||||
|
getTrackRow(n, group.title !== null, {
|
||||||
|
ref: providedInGroup.innerRef,
|
||||||
|
...providedInGroup.draggableProps,
|
||||||
|
...providedInGroup.dragHandleProps,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
</Draggable>
|
||||||
|
))}
|
||||||
|
{provided.placeholder}
|
||||||
|
</TableBody>
|
||||||
)}
|
)}
|
||||||
onClick={event => {
|
</Droppable>
|
||||||
handlePlayTrack(event, track.index);
|
</Table>
|
||||||
event.stopPropagation();
|
</TableCell>
|
||||||
}}
|
</TableRow>
|
||||||
/>
|
))}
|
||||||
</span>
|
</TableBody>
|
||||||
)}
|
</DragDropContext>
|
||||||
</TableCell>
|
|
||||||
<TableCell className={classes.titleCell} title={track.title}>
|
|
||||||
{track.title || `No Title`}
|
|
||||||
</TableCell>
|
|
||||||
<TableCell align="right" className={classes.durationCell}>
|
|
||||||
<span className={classes.formatBadge}>{track.encoding}</span>
|
|
||||||
<span className={classes.durationCellTime}>{track.duration}</span>
|
|
||||||
</TableCell>
|
|
||||||
</TableRow>
|
|
||||||
))}
|
|
||||||
</TableBody>
|
|
||||||
</Table>
|
</Table>
|
||||||
<Backdrop className={classes.backdrop} open={isDragActive}>
|
<Backdrop className={classes.backdrop} open={isDragActive}>
|
||||||
Drop your Music to Upload
|
Drop your Music to Upload
|
||||||
|
|
|
@ -2,8 +2,9 @@ import React, { useCallback } from 'react';
|
||||||
import { useDispatch } from 'react-redux';
|
import { useDispatch } from 'react-redux';
|
||||||
import { useShallowEqualSelector } from '../utils';
|
import { useShallowEqualSelector } from '../utils';
|
||||||
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
|
import { actions as renameDialogActions } from '../redux/rename-dialog-feature';
|
||||||
import { renameTrack, renameDisc } from '../redux/actions';
|
import { renameTrack, renameDisc, renameGroup } from '../redux/actions';
|
||||||
|
|
||||||
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
import Dialog from '@material-ui/core/Dialog';
|
import Dialog from '@material-ui/core/Dialog';
|
||||||
import DialogActions from '@material-ui/core/DialogActions';
|
import DialogActions from '@material-ui/core/DialogActions';
|
||||||
import DialogContent from '@material-ui/core/DialogContent';
|
import DialogContent from '@material-ui/core/DialogContent';
|
||||||
|
@ -21,30 +22,66 @@ const Transition = React.forwardRef(function Transition(
|
||||||
return <Slide direction="up" ref={ref} {...props} />;
|
return <Slide direction="up" ref={ref} {...props} />;
|
||||||
});
|
});
|
||||||
|
|
||||||
|
const useStyles = makeStyles(theme => ({
|
||||||
|
marginUpDown: {
|
||||||
|
marginTop: theme.spacing(3),
|
||||||
|
marginBottom: theme.spacing(3),
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
export const RenameDialog = (props: {}) => {
|
export const RenameDialog = (props: {}) => {
|
||||||
let dispatch = useDispatch();
|
let dispatch = useDispatch();
|
||||||
|
let classes = useStyles();
|
||||||
|
|
||||||
let renameDialogVisible = useShallowEqualSelector(state => state.renameDialog.visible);
|
let renameDialogVisible = useShallowEqualSelector(state => state.renameDialog.visible);
|
||||||
let renameDialogTitle = useShallowEqualSelector(state => state.renameDialog.title);
|
let renameDialogTitle = useShallowEqualSelector(state => state.renameDialog.title);
|
||||||
|
let renameDialogFullWidthTitle = useShallowEqualSelector(state => state.renameDialog.fullWidthTitle);
|
||||||
let renameDialogIndex = useShallowEqualSelector(state => state.renameDialog.index);
|
let renameDialogIndex = useShallowEqualSelector(state => state.renameDialog.index);
|
||||||
|
let renameDialogGroupIndex = useShallowEqualSelector(state => state.renameDialog.groupIndex);
|
||||||
|
let allowFullWidth = useShallowEqualSelector(state => state.appState.fullWidthSupport);
|
||||||
|
|
||||||
const what = renameDialogIndex < 0 ? `Disc` : `Track`;
|
const what = renameDialogGroupIndex !== null ? `Group` : renameDialogIndex < 0 ? `Disc` : `Track`;
|
||||||
|
|
||||||
const handleCancelRename = () => {
|
const handleCancelRename = () => {
|
||||||
dispatch(renameDialogActions.setVisible(false));
|
dispatch(renameDialogActions.setVisible(false));
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleDoRename = () => {
|
const handleDoRename = () => {
|
||||||
if (renameDialogIndex < 0) {
|
if (renameDialogGroupIndex !== null) {
|
||||||
dispatch(renameDisc({ newName: renameDialogTitle }));
|
// Just rename the group with this range
|
||||||
|
dispatch(
|
||||||
|
renameGroup({
|
||||||
|
newName: renameDialogTitle,
|
||||||
|
newFullWidthName: renameDialogFullWidthTitle,
|
||||||
|
groupIndex: renameDialogGroupIndex,
|
||||||
|
})
|
||||||
|
);
|
||||||
|
} else if (renameDialogIndex < 0) {
|
||||||
|
dispatch(
|
||||||
|
renameDisc({
|
||||||
|
newName: renameDialogTitle,
|
||||||
|
newFullWidthName: renameDialogFullWidthTitle,
|
||||||
|
})
|
||||||
|
);
|
||||||
} else {
|
} else {
|
||||||
dispatch(renameTrack({ index: renameDialogIndex, newName: renameDialogTitle }));
|
dispatch(
|
||||||
|
renameTrack({
|
||||||
|
index: renameDialogIndex,
|
||||||
|
newName: renameDialogTitle,
|
||||||
|
newFullWidthName: renameDialogFullWidthTitle,
|
||||||
|
})
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
handleCancelRename(); // Close the dialog
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleChange = useCallback(
|
const handleChange = useCallback(
|
||||||
(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
(event: React.ChangeEvent<HTMLTextAreaElement | HTMLInputElement>) => {
|
||||||
dispatch(renameDialogActions.setCurrentName(event.target.value.substring(0, 120))); // MAX title length
|
if (event.target.id === 'name') {
|
||||||
|
dispatch(renameDialogActions.setCurrentName(event.target.value.substring(0, 120))); // MAX title length
|
||||||
|
} else {
|
||||||
|
dispatch(renameDialogActions.setCurrentFullWidthName(event.target.value.substring(0, 105)));
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[dispatch]
|
[dispatch]
|
||||||
);
|
);
|
||||||
|
@ -86,6 +123,20 @@ export const RenameDialog = (props: {}) => {
|
||||||
}}
|
}}
|
||||||
onChange={handleChange}
|
onChange={handleChange}
|
||||||
/>
|
/>
|
||||||
|
{allowFullWidth && (
|
||||||
|
<TextField
|
||||||
|
id="fullWidthTitle"
|
||||||
|
label={`Full-Width ${what} Name`}
|
||||||
|
type="text"
|
||||||
|
fullWidth
|
||||||
|
className={classes.marginUpDown}
|
||||||
|
value={renameDialogFullWidthTitle}
|
||||||
|
onKeyDown={event => {
|
||||||
|
event.key === `Enter` && handleDoRename();
|
||||||
|
}}
|
||||||
|
onChange={handleChange}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
</DialogContent>
|
</DialogContent>
|
||||||
<DialogActions>
|
<DialogActions>
|
||||||
<Button onClick={handleCancelRename}>Cancel</Button>
|
<Button onClick={handleCancelRename}>Cancel</Button>
|
||||||
|
|
|
@ -14,6 +14,7 @@ import { useShallowEqualSelector } from '../utils';
|
||||||
import Link from '@material-ui/core/Link';
|
import Link from '@material-ui/core/Link';
|
||||||
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
import ListItemIcon from '@material-ui/core/ListItemIcon';
|
||||||
import ListItemText from '@material-ui/core/ListItemText';
|
import ListItemText from '@material-ui/core/ListItemText';
|
||||||
|
import Tooltip from '@material-ui/core/Tooltip';
|
||||||
import { makeStyles } from '@material-ui/core/styles';
|
import { makeStyles } from '@material-ui/core/styles';
|
||||||
|
|
||||||
import RefreshIcon from '@material-ui/icons/Refresh';
|
import RefreshIcon from '@material-ui/icons/Refresh';
|
||||||
|
@ -33,14 +34,19 @@ const useStyles = makeStyles(theme => ({
|
||||||
listItemIcon: {
|
listItemIcon: {
|
||||||
minWidth: theme.spacing(5),
|
minWidth: theme.spacing(5),
|
||||||
},
|
},
|
||||||
|
toolTippedText: {
|
||||||
|
textDecoration: 'underline',
|
||||||
|
textDecorationStyle: 'dotted',
|
||||||
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
export const TopMenu = function(props: { onClick?: () => void }) {
|
export const TopMenu = function(props: { onClick?: () => void }) {
|
||||||
const classes = useStyles();
|
const classes = useStyles();
|
||||||
const dispatch = useDispatch();
|
const dispatch = useDispatch();
|
||||||
|
|
||||||
let { mainView, darkMode, vintageMode } = useShallowEqualSelector(state => state.appState);
|
let { mainView, darkMode, vintageMode, fullWidthSupport } = useShallowEqualSelector(state => state.appState);
|
||||||
let discTitle = useShallowEqualSelector(state => state.main.disc?.title ?? ``);
|
let discTitle = useShallowEqualSelector(state => state.main.disc?.title ?? ``);
|
||||||
|
let fullWidthDiscTitle = useShallowEqualSelector(state => state.main.disc?.fullWidthTitle ?? ``);
|
||||||
|
|
||||||
const githubLinkRef = React.useRef<null | HTMLAnchorElement>(null);
|
const githubLinkRef = React.useRef<null | HTMLAnchorElement>(null);
|
||||||
const helpLinkRef = React.useRef<null | HTMLAnchorElement>(null);
|
const helpLinkRef = React.useRef<null | HTMLAnchorElement>(null);
|
||||||
|
@ -71,6 +77,10 @@ export const TopMenu = function(props: { onClick?: () => void }) {
|
||||||
handleMenuClose();
|
handleMenuClose();
|
||||||
}, [dispatch, handleMenuClose]);
|
}, [dispatch, handleMenuClose]);
|
||||||
|
|
||||||
|
const handleAllowFullWidth = useCallback(() => {
|
||||||
|
dispatch(appActions.setFullWidthSupport(!fullWidthSupport));
|
||||||
|
}, [dispatch, fullWidthSupport]);
|
||||||
|
|
||||||
const handleRefresh = useCallback(() => {
|
const handleRefresh = useCallback(() => {
|
||||||
dispatch(listContent());
|
dispatch(listContent());
|
||||||
handleMenuClose();
|
handleMenuClose();
|
||||||
|
@ -81,11 +91,13 @@ export const TopMenu = function(props: { onClick?: () => void }) {
|
||||||
batchActions([
|
batchActions([
|
||||||
renameDialogActions.setVisible(true),
|
renameDialogActions.setVisible(true),
|
||||||
renameDialogActions.setCurrentName(discTitle),
|
renameDialogActions.setCurrentName(discTitle),
|
||||||
|
renameDialogActions.setGroupIndex(null),
|
||||||
|
renameDialogActions.setCurrentFullWidthName(fullWidthDiscTitle),
|
||||||
renameDialogActions.setIndex(-1),
|
renameDialogActions.setIndex(-1),
|
||||||
])
|
])
|
||||||
);
|
);
|
||||||
handleMenuClose();
|
handleMenuClose();
|
||||||
}, [dispatch, handleMenuClose, discTitle]);
|
}, [dispatch, handleMenuClose, discTitle, fullWidthDiscTitle]);
|
||||||
|
|
||||||
const handleExit = useCallback(() => {
|
const handleExit = useCallback(() => {
|
||||||
dispatch(appActions.setMainView('WELCOME'));
|
dispatch(appActions.setMainView('WELCOME'));
|
||||||
|
@ -147,6 +159,23 @@ export const TopMenu = function(props: { onClick?: () => void }) {
|
||||||
<ListItemText>Wipe Disc</ListItemText>
|
<ListItemText>Wipe Disc</ListItemText>
|
||||||
</MenuItem>
|
</MenuItem>
|
||||||
);
|
);
|
||||||
|
menuItems.push(
|
||||||
|
<MenuItem key="allowFullWidth" onClick={handleAllowFullWidth}>
|
||||||
|
<ListItemIcon className={classes.listItemIcon}>
|
||||||
|
{fullWidthSupport ? <ToggleOnIcon fontSize="small" /> : <ToggleOffIcon fontSize="small" />}
|
||||||
|
</ListItemIcon>
|
||||||
|
<ListItemText>
|
||||||
|
{`Allow `}
|
||||||
|
<Tooltip
|
||||||
|
title="Minidiscs have 2 slots for titles - the default half-width one used for standard alphabet and half-width katakana, and full-width for hiragana and kanji."
|
||||||
|
arrow
|
||||||
|
>
|
||||||
|
<span className={classes.toolTippedText}>Full-Width</span>
|
||||||
|
</Tooltip>
|
||||||
|
{`Title Editing`}
|
||||||
|
</ListItemText>
|
||||||
|
</MenuItem>
|
||||||
|
);
|
||||||
menuItems.push(
|
menuItems.push(
|
||||||
<MenuItem key="exit" onClick={handleExit}>
|
<MenuItem key="exit" onClick={handleExit}>
|
||||||
<ListItemIcon className={classes.listItemIcon}>
|
<ListItemIcon className={classes.listItemIcon}>
|
||||||
|
|
|
@ -86,7 +86,8 @@ export const W95Main = (props: {
|
||||||
tracks: {
|
tracks: {
|
||||||
index: number;
|
index: number;
|
||||||
title: string;
|
title: string;
|
||||||
group: string;
|
fullWidthTitle: string;
|
||||||
|
group: string | null;
|
||||||
duration: string;
|
duration: string;
|
||||||
encoding: string;
|
encoding: string;
|
||||||
}[];
|
}[];
|
||||||
|
@ -122,6 +123,7 @@ export const W95Main = (props: {
|
||||||
<img alt="device" src={DeviceIconUrl} style={{ marginTop: -10, marginLeft: 10 }} />
|
<img alt="device" src={DeviceIconUrl} style={{ marginTop: -10, marginLeft: 10 }} />
|
||||||
<div className={classes.toolbarItem}>
|
<div className={classes.toolbarItem}>
|
||||||
{`${props.deviceName}: (` || `Loading...`}
|
{`${props.deviceName}: (` || `Loading...`}
|
||||||
|
{props.disc?.fullWidthTitle && `${props.disc?.fullWidthTitle} / `}
|
||||||
{props.disc?.title || `Untitled Disc`}
|
{props.disc?.title || `Untitled Disc`}
|
||||||
{`)`}
|
{`)`}
|
||||||
</div>
|
</div>
|
||||||
|
@ -202,7 +204,7 @@ export const W95Main = (props: {
|
||||||
>
|
>
|
||||||
<TableDataCell style={{ textAlign: 'center', width: '2ch' }}>{track.index + 1}</TableDataCell>
|
<TableDataCell style={{ textAlign: 'center', width: '2ch' }}>{track.index + 1}</TableDataCell>
|
||||||
<TableDataCell style={{ width: '80%' }}>
|
<TableDataCell style={{ width: '80%' }}>
|
||||||
<div>{track.title || `No Title`}</div>
|
<div>{track.fullWidthTitle && `${track.fullWidthTitle} / `}{track.title || `No Title`}</div>
|
||||||
</TableDataCell>
|
</TableDataCell>
|
||||||
<TableDataCell style={{ textAlign: 'right', width: '20%' }}>
|
<TableDataCell style={{ textAlign: 'right', width: '20%' }}>
|
||||||
<span>{track.encoding}</span>
|
<span>{track.encoding}</span>
|
||||||
|
|
|
@ -22,7 +22,7 @@ export const W95RenameDialog = (props: {
|
||||||
<span>Rename {props.what}</span>
|
<span>Rename {props.what}</span>
|
||||||
</WindowHeader>
|
</WindowHeader>
|
||||||
<WindowContent>
|
<WindowContent>
|
||||||
<p style={{ marginBottom: 4 }}>Track Name:</p>
|
<p style={{ marginBottom: 4 }}>{props.what} Name:</p>
|
||||||
<TextField
|
<TextField
|
||||||
style={{ marginBottom: 16 }}
|
style={{ marginBottom: 16 }}
|
||||||
value={props.renameDialogTitle}
|
value={props.renameDialogTitle}
|
||||||
|
|
|
@ -9,7 +9,15 @@ import { actions as mainActions } from './main-feature';
|
||||||
import serviceRegistry from '../services/registry';
|
import serviceRegistry from '../services/registry';
|
||||||
import { Wireformat, getTracks } from 'netmd-js';
|
import { Wireformat, getTracks } from 'netmd-js';
|
||||||
import { AnyAction } from '@reduxjs/toolkit';
|
import { AnyAction } from '@reduxjs/toolkit';
|
||||||
import { getAvailableCharsForTrackTitle, framesToSec, sleepWithProgressCallback, sleep, askNotificationPermission } from '../utils';
|
import {
|
||||||
|
getAvailableCharsForTitle,
|
||||||
|
framesToSec,
|
||||||
|
sleepWithProgressCallback,
|
||||||
|
sleep,
|
||||||
|
askNotificationPermission,
|
||||||
|
getGroupedTracks,
|
||||||
|
getHalfWidthTitleLength,
|
||||||
|
} from '../utils';
|
||||||
import * as mm from 'music-metadata-browser';
|
import * as mm from 'music-metadata-browser';
|
||||||
import { TitleFormatType, UploadFormat } from './convert-dialog-feature';
|
import { TitleFormatType, UploadFormat } from './convert-dialog-feature';
|
||||||
import NotificationCompleteIconUrl from '../images/record-complete-notification-icon.png';
|
import NotificationCompleteIconUrl from '../images/record-complete-notification-icon.png';
|
||||||
|
@ -50,6 +58,128 @@ export function control(action: 'play' | 'stop' | 'next' | 'prev' | 'goto' | 'pa
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function renameGroup({ groupIndex, newName, newFullWidthName }: { groupIndex: number; newName: string; newFullWidthName?: string }) {
|
||||||
|
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||||
|
await serviceRegistry!.netmdService?.renameGroup(groupIndex, newName, newFullWidthName);
|
||||||
|
listContent()(dispatch);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function groupTracks(indexes: number[]) {
|
||||||
|
return async function(dispatch: AppDispatch) {
|
||||||
|
let begin = indexes[0];
|
||||||
|
let length = indexes[indexes.length - 1] - begin + 1;
|
||||||
|
const { netmdService } = serviceRegistry;
|
||||||
|
|
||||||
|
netmdService!.addGroup(begin, length, '');
|
||||||
|
listContent()(dispatch);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function deleteGroup(groupBegin: number) {
|
||||||
|
return async function(dispatch: AppDispatch) {
|
||||||
|
const { netmdService } = serviceRegistry;
|
||||||
|
netmdService!.deleteGroup(groupBegin);
|
||||||
|
listContent()(dispatch);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function dragDropTrack(sourceList: number, sourceIndex: number, targetList: number, targetIndex: number) {
|
||||||
|
// This code is here, because it would need to be duplicated in both netmd and netmd-mock.
|
||||||
|
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||||
|
if (sourceList === targetList && sourceIndex === targetIndex) return;
|
||||||
|
dispatch(appStateActions.setLoading(true));
|
||||||
|
const groupedTracks = getGroupedTracks(await serviceRegistry.netmdService!.listContent());
|
||||||
|
// Remove the moved item from its current list
|
||||||
|
let movedItem = groupedTracks[sourceList].tracks.splice(sourceIndex, 1)[0];
|
||||||
|
let newIndex: number;
|
||||||
|
|
||||||
|
// Calculate bounds
|
||||||
|
let boundsStartList, boundsEndList, boundsStartIndex, boundsEndIndex, offset;
|
||||||
|
|
||||||
|
if (sourceList < targetList) {
|
||||||
|
boundsStartList = sourceList;
|
||||||
|
boundsStartIndex = sourceIndex;
|
||||||
|
boundsEndList = targetList;
|
||||||
|
boundsEndIndex = targetIndex;
|
||||||
|
offset = -1;
|
||||||
|
} else if (sourceList > targetList) {
|
||||||
|
boundsStartList = targetList;
|
||||||
|
boundsStartIndex = targetIndex;
|
||||||
|
boundsEndList = sourceList;
|
||||||
|
boundsEndIndex = sourceIndex;
|
||||||
|
offset = 1;
|
||||||
|
} else {
|
||||||
|
if (sourceIndex < targetIndex) {
|
||||||
|
boundsStartList = boundsEndList = sourceList;
|
||||||
|
boundsStartIndex = sourceIndex;
|
||||||
|
boundsEndIndex = targetIndex;
|
||||||
|
offset = -1;
|
||||||
|
} else {
|
||||||
|
boundsStartList = boundsEndList = targetList;
|
||||||
|
boundsStartIndex = targetIndex;
|
||||||
|
boundsEndIndex = sourceIndex;
|
||||||
|
offset = 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Shift indices
|
||||||
|
for (let i = boundsStartList; i <= boundsEndList; i++) {
|
||||||
|
let startingIndex = i === boundsStartList ? boundsStartIndex : 0;
|
||||||
|
let endingIndex = i === boundsEndList ? boundsEndIndex : groupedTracks[i].tracks.length;
|
||||||
|
for (let j = startingIndex; j < endingIndex; j++) {
|
||||||
|
groupedTracks[i].tracks[j].index += offset;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate the moved track's destination index
|
||||||
|
if (targetList === 0) {
|
||||||
|
newIndex = targetIndex;
|
||||||
|
} else {
|
||||||
|
if (targetIndex === 0) {
|
||||||
|
let prevList = groupedTracks[targetList - 1];
|
||||||
|
let i = 2;
|
||||||
|
while(prevList && prevList.tracks.length === 0){
|
||||||
|
// Skip past all the empty lists
|
||||||
|
prevList = groupedTracks[targetList - (i++)];
|
||||||
|
}
|
||||||
|
if (prevList) {
|
||||||
|
// If there's a previous list, make this tracks's index previous list's last item's index + 1
|
||||||
|
let lastIndexOfPrevList = prevList.tracks[prevList.tracks.length - 1].index;
|
||||||
|
newIndex = lastIndexOfPrevList + 1;
|
||||||
|
} else newIndex = 0; // Else default to index 0
|
||||||
|
} else {
|
||||||
|
newIndex = groupedTracks[targetList].tracks[0].index + targetIndex;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (movedItem.index !== newIndex) {
|
||||||
|
await serviceRegistry!.netmdService!.moveTrack(movedItem.index, newIndex, false);
|
||||||
|
}
|
||||||
|
|
||||||
|
movedItem.index = newIndex;
|
||||||
|
groupedTracks[targetList].tracks.splice(targetIndex, 0, movedItem);
|
||||||
|
let ungrouped = [];
|
||||||
|
|
||||||
|
// Recompile the groups and update them on the player
|
||||||
|
let normalGroups = [];
|
||||||
|
for (let group of groupedTracks) {
|
||||||
|
if(group.tracks.length === 0) continue;
|
||||||
|
if (group.index === -1) ungrouped.push(...group.tracks);
|
||||||
|
else normalGroups.push(group);
|
||||||
|
}
|
||||||
|
if (ungrouped.length)
|
||||||
|
normalGroups.unshift({
|
||||||
|
index: 0,
|
||||||
|
title: null,
|
||||||
|
fullWidthTitle: null,
|
||||||
|
tracks: ungrouped,
|
||||||
|
});
|
||||||
|
await serviceRegistry.netmdService!.rewriteGroups(normalGroups);
|
||||||
|
listContent()(dispatch);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function pair() {
|
export function pair() {
|
||||||
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
return async function(dispatch: AppDispatch, getState: () => RootState) {
|
||||||
dispatch(appStateActions.setPairingFailed(false));
|
dispatch(appStateActions.setPairingFailed(false));
|
||||||
|
@ -86,7 +216,15 @@ export function listContent() {
|
||||||
return async function(dispatch: AppDispatch) {
|
return async function(dispatch: AppDispatch) {
|
||||||
// Issue loading
|
// Issue loading
|
||||||
dispatch(appStateActions.setLoading(true));
|
dispatch(appStateActions.setLoading(true));
|
||||||
let disc = await serviceRegistry.netmdService!.listContent();
|
let disc;
|
||||||
|
try{
|
||||||
|
disc = await serviceRegistry.netmdService!.listContent();
|
||||||
|
}catch(err){
|
||||||
|
if(window.confirm("Warning: This disc's title data seems to be corrupted.\nDo you wish to erase it?")) {
|
||||||
|
await serviceRegistry.netmdService!.wipeDiscTitleInfo();
|
||||||
|
disc = await serviceRegistry.netmdService!.listContent();
|
||||||
|
} else throw err;
|
||||||
|
}
|
||||||
let deviceName = await serviceRegistry.netmdService!.getDeviceName();
|
let deviceName = await serviceRegistry.netmdService!.getDeviceName();
|
||||||
let deviceStatus = null;
|
let deviceStatus = null;
|
||||||
try {
|
try {
|
||||||
|
@ -105,12 +243,12 @@ export function listContent() {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameTrack({ index, newName }: { index: number; newName: string }) {
|
export function renameTrack({ index, newName, newFullWidthName }: { index: number; newName: string; newFullWidthName?: string }) {
|
||||||
return async function(dispatch: AppDispatch) {
|
return async function(dispatch: AppDispatch) {
|
||||||
const { netmdService } = serviceRegistry;
|
const { netmdService } = serviceRegistry;
|
||||||
dispatch(renameDialogActions.setVisible(false));
|
dispatch(renameDialogActions.setVisible(false));
|
||||||
try {
|
try {
|
||||||
await netmdService!.renameTrack(index, newName);
|
await netmdService!.renameTrack(index, newName, newFullWidthName);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error(err);
|
console.error(err);
|
||||||
dispatch(batchActions([errorDialogAction.setVisible(true), errorDialogAction.setErrorMessage(`Rename failed.`)]));
|
dispatch(batchActions([errorDialogAction.setVisible(true), errorDialogAction.setErrorMessage(`Rename failed.`)]));
|
||||||
|
@ -119,10 +257,13 @@ export function renameTrack({ index, newName }: { index: number; newName: string
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export function renameDisc({ newName }: { newName: string }) {
|
export function renameDisc({ newName, newFullWidthName }: { newName: string; newFullWidthName?: string }) {
|
||||||
return async function(dispatch: AppDispatch) {
|
return async function(dispatch: AppDispatch) {
|
||||||
const { netmdService } = serviceRegistry;
|
const { netmdService } = serviceRegistry;
|
||||||
await netmdService!.renameDisc(newName);
|
await netmdService!.renameDisc(
|
||||||
|
newName.replace(/\/\//g, ' /'), // Make sure the title doesn't interfere with the groups
|
||||||
|
newFullWidthName?.replace(////g, '/')
|
||||||
|
);
|
||||||
dispatch(renameDialogActions.setVisible(false));
|
dispatch(renameDialogActions.setVisible(false));
|
||||||
listContent()(dispatch);
|
listContent()(dispatch);
|
||||||
};
|
};
|
||||||
|
@ -138,11 +279,7 @@ export function deleteTracks(indexes: number[]) {
|
||||||
}
|
}
|
||||||
const { netmdService } = serviceRegistry;
|
const { netmdService } = serviceRegistry;
|
||||||
dispatch(appStateActions.setLoading(true));
|
dispatch(appStateActions.setLoading(true));
|
||||||
indexes = indexes.sort();
|
await netmdService!.deleteTracks(indexes);
|
||||||
indexes.reverse();
|
|
||||||
for (let index of indexes) {
|
|
||||||
await netmdService!.deleteTrack(index);
|
|
||||||
}
|
|
||||||
listContent()(dispatch);
|
listContent()(dispatch);
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
@ -387,8 +524,8 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
|
||||||
};
|
};
|
||||||
|
|
||||||
let disc = getState().main.disc;
|
let disc = getState().main.disc;
|
||||||
let maxTitleLength = disc ? getAvailableCharsForTrackTitle(getTracks(disc).map(track => track.title || ``)) : -1;
|
let useFullWidth = getState().appState.fullWidthSupport;
|
||||||
maxTitleLength = Math.floor(maxTitleLength / files.length);
|
let availableCharacters = getAvailableCharsForTitle(disc!);
|
||||||
|
|
||||||
let error: any;
|
let error: any;
|
||||||
let errorMessage = ``;
|
let errorMessage = ``;
|
||||||
|
@ -407,16 +544,22 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
|
||||||
console.error(err);
|
console.error(err);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (maxTitleLength > -1) {
|
const fixLength = (l: number) => Math.ceil(l / 7) * 7;
|
||||||
title = title.substring(0, maxTitleLength);
|
let halfWidthTitle = title.substr(0, Math.min(getHalfWidthTitleLength(title), availableCharacters));
|
||||||
|
availableCharacters -= fixLength(getHalfWidthTitleLength(halfWidthTitle));
|
||||||
|
|
||||||
|
let fullWidthTitle = '';
|
||||||
|
if (useFullWidth) {
|
||||||
|
fullWidthTitle = title.substr(0, Math.min(title.length * 2, availableCharacters, 210 /* limit is 105 */) / 2);
|
||||||
|
availableCharacters -= fixLength(fullWidthTitle.length * 2);
|
||||||
}
|
}
|
||||||
|
|
||||||
trackUpdate.current = i++;
|
trackUpdate.current = i++;
|
||||||
trackUpdate.titleCurrent = title;
|
trackUpdate.titleCurrent = halfWidthTitle;
|
||||||
updateTrack();
|
updateTrack();
|
||||||
updateProgressCallback({ written: 0, encrypted: 0, total: 100 });
|
updateProgressCallback({ written: 0, encrypted: 0, total: 100 });
|
||||||
try {
|
try {
|
||||||
await netmdService?.upload(title, data, wireformat, updateProgressCallback);
|
await netmdService?.upload(halfWidthTitle, fullWidthTitle, data, wireformat, updateProgressCallback);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
error = err;
|
error = err;
|
||||||
errorMessage = `${file.name}: Error uploading to device. There might not be enough space left.`;
|
errorMessage = `${file.name}: Error uploading to device. There might not be enough space left.`;
|
||||||
|
|
|
@ -15,6 +15,7 @@ export interface AppState {
|
||||||
aboutDialogVisible: boolean;
|
aboutDialogVisible: boolean;
|
||||||
notifyWhenFinished: boolean;
|
notifyWhenFinished: boolean;
|
||||||
hasNotificationSupport: boolean;
|
hasNotificationSupport: boolean;
|
||||||
|
fullWidthSupport: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const buildInitialState = (): AppState => {
|
export const buildInitialState = (): AppState => {
|
||||||
|
@ -29,6 +30,7 @@ export const buildInitialState = (): AppState => {
|
||||||
aboutDialogVisible: false,
|
aboutDialogVisible: false,
|
||||||
notifyWhenFinished: loadPreference('notifyWhenFinished', false),
|
notifyWhenFinished: loadPreference('notifyWhenFinished', false),
|
||||||
hasNotificationSupport: true,
|
hasNotificationSupport: true,
|
||||||
|
fullWidthSupport: loadPreference('fullWidthSupport', false),
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
|
||||||
|
@ -72,6 +74,10 @@ export const slice = createSlice({
|
||||||
showAboutDialog: (state, action: PayloadAction<boolean>) => {
|
showAboutDialog: (state, action: PayloadAction<boolean>) => {
|
||||||
state.aboutDialogVisible = action.payload;
|
state.aboutDialogVisible = action.payload;
|
||||||
},
|
},
|
||||||
|
setFullWidthSupport: (state, action: PayloadAction<boolean>) => {
|
||||||
|
state.fullWidthSupport = action.payload;
|
||||||
|
savePreference('fullWidthSupport', state.fullWidthSupport);
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -4,13 +4,17 @@ import { enableBatching } from 'redux-batched-actions';
|
||||||
export interface RenameDialogState {
|
export interface RenameDialogState {
|
||||||
visible: boolean;
|
visible: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
|
fullWidthTitle: string;
|
||||||
index: number;
|
index: number;
|
||||||
|
groupIndex: number | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
const initialState: RenameDialogState = {
|
const initialState: RenameDialogState = {
|
||||||
visible: false,
|
visible: false,
|
||||||
title: '',
|
title: '',
|
||||||
|
fullWidthTitle: '',
|
||||||
index: -1,
|
index: -1,
|
||||||
|
groupIndex: null,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const slice = createSlice({
|
export const slice = createSlice({
|
||||||
|
@ -23,9 +27,15 @@ export const slice = createSlice({
|
||||||
setCurrentName: (state: RenameDialogState, action: PayloadAction<string>) => {
|
setCurrentName: (state: RenameDialogState, action: PayloadAction<string>) => {
|
||||||
state.title = action.payload;
|
state.title = action.payload;
|
||||||
},
|
},
|
||||||
|
setCurrentFullWidthName: (state: RenameDialogState, action: PayloadAction<string>) => {
|
||||||
|
state.fullWidthTitle = action.payload;
|
||||||
|
},
|
||||||
setIndex: (state: RenameDialogState, action: PayloadAction<number>) => {
|
setIndex: (state: RenameDialogState, action: PayloadAction<number>) => {
|
||||||
state.index = action.payload;
|
state.index = action.payload;
|
||||||
},
|
},
|
||||||
|
setGroupIndex: (state: RenameDialogState, action: PayloadAction<number | null>) => {
|
||||||
|
state.groupIndex = action.payload;
|
||||||
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
@ -1,6 +1,6 @@
|
||||||
import { Track, Channels, Encoding, Wireformat, TrackFlag, DeviceStatus } from 'netmd-js';
|
import { Track, Channels, Encoding, Wireformat, TrackFlag, DeviceStatus, Group } from 'netmd-js';
|
||||||
import { NetMDService } from './netmd';
|
import { NetMDService } from './netmd';
|
||||||
import { sleep, sanitizeTitle, asyncMutex } from '../utils';
|
import { sleep, sanitizeFullWidthTitle, sanitizeHalfWidthTitle, asyncMutex, recomputeGroupsAfterTrackMove, isSequential } from '../utils';
|
||||||
import { assert } from 'netmd-js/dist/utils';
|
import { assert } from 'netmd-js/dist/utils';
|
||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
|
|
||||||
|
@ -9,6 +9,7 @@ class NetMDMockService implements NetMDService {
|
||||||
public mutex = new Mutex();
|
public mutex = new Mutex();
|
||||||
public _tracksTitlesMaxLength = 1700;
|
public _tracksTitlesMaxLength = 1700;
|
||||||
public _discTitle: string = 'Mock Disc';
|
public _discTitle: string = 'Mock Disc';
|
||||||
|
public _fullWidthDiscTitle: string = '';
|
||||||
public _discCapacity: number = 80 * 60 * 512;
|
public _discCapacity: number = 80 * 60 * 512;
|
||||||
public _tracks: Track[] = [
|
public _tracks: Track[] = [
|
||||||
{
|
{
|
||||||
|
@ -18,6 +19,7 @@ class NetMDMockService implements NetMDService {
|
||||||
channel: Channels.stereo,
|
channel: Channels.stereo,
|
||||||
protected: TrackFlag.unprotected,
|
protected: TrackFlag.unprotected,
|
||||||
title: 'Long name for - Mock Track 1 - by some artist -12398729837198723',
|
title: 'Long name for - Mock Track 1 - by some artist -12398729837198723',
|
||||||
|
fullWidthTitle: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 5 * 60 * 512,
|
duration: 5 * 60 * 512,
|
||||||
|
@ -26,6 +28,7 @@ class NetMDMockService implements NetMDService {
|
||||||
channel: Channels.stereo,
|
channel: Channels.stereo,
|
||||||
protected: TrackFlag.unprotected,
|
protected: TrackFlag.unprotected,
|
||||||
title: 'Mock Track 2',
|
title: 'Mock Track 2',
|
||||||
|
fullWidthTitle: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 5 * 60 * 512,
|
duration: 5 * 60 * 512,
|
||||||
|
@ -34,6 +37,7 @@ class NetMDMockService implements NetMDService {
|
||||||
channel: Channels.stereo,
|
channel: Channels.stereo,
|
||||||
protected: TrackFlag.unprotected,
|
protected: TrackFlag.unprotected,
|
||||||
title: 'Mock Track 3',
|
title: 'Mock Track 3',
|
||||||
|
fullWidthTitle: '',
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
duration: 5 * 60 * 512,
|
duration: 5 * 60 * 512,
|
||||||
|
@ -42,8 +46,33 @@ class NetMDMockService implements NetMDService {
|
||||||
channel: Channels.stereo,
|
channel: Channels.stereo,
|
||||||
protected: TrackFlag.unprotected,
|
protected: TrackFlag.unprotected,
|
||||||
title: 'Mock Track 4',
|
title: 'Mock Track 4',
|
||||||
|
fullWidthTitle: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
duration: 5 * 60 * 512,
|
||||||
|
encoding: Encoding.sp,
|
||||||
|
index: 4,
|
||||||
|
channel: Channels.stereo,
|
||||||
|
protected: TrackFlag.unprotected,
|
||||||
|
title: 'Mock Track 5',
|
||||||
|
fullWidthTitle: '',
|
||||||
},
|
},
|
||||||
];
|
];
|
||||||
|
public _groups: Group[] = [
|
||||||
|
{
|
||||||
|
title: null,
|
||||||
|
index: 0,
|
||||||
|
tracks: this._tracks.slice(2),
|
||||||
|
fullWidthTitle: '',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: 'Test',
|
||||||
|
fullWidthTitle: '',
|
||||||
|
index: 0,
|
||||||
|
tracks: [this._tracks[0], this._tracks[1]],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
public _status: DeviceStatus = {
|
public _status: DeviceStatus = {
|
||||||
discPresent: true,
|
discPresent: true,
|
||||||
track: 0,
|
track: 0,
|
||||||
|
@ -69,6 +98,20 @@ class NetMDMockService implements NetMDService {
|
||||||
return this._tracks.reduce((acc, track) => acc + (track.title?.length ?? 0), 0);
|
return this._tracks.reduce((acc, track) => acc + (track.title?.length ?? 0), 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
_getDisc() {
|
||||||
|
return {
|
||||||
|
title: this._discTitle,
|
||||||
|
fullWidthTitle: this._fullWidthDiscTitle,
|
||||||
|
writeProtected: false,
|
||||||
|
writable: true,
|
||||||
|
left: this._discCapacity - this._getUsed(),
|
||||||
|
used: this._getUsed(),
|
||||||
|
total: this._discCapacity,
|
||||||
|
trackCount: this._tracks.length,
|
||||||
|
groups: this._groups,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
async pair() {
|
async pair() {
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
@ -80,24 +123,58 @@ class NetMDMockService implements NetMDService {
|
||||||
async listContent() {
|
async listContent() {
|
||||||
// This object ends up in the state of redux and Immer will freeze it.
|
// This object ends up in the state of redux and Immer will freeze it.
|
||||||
// That's why it's deep cloned
|
// That's why it's deep cloned
|
||||||
return JSON.parse(
|
return JSON.parse(JSON.stringify(this._getDisc()));
|
||||||
JSON.stringify({
|
}
|
||||||
title: this._discTitle,
|
|
||||||
writeProtected: false,
|
async renameGroup(groupBegin: number, newName: string, newFullWidth?: string) {
|
||||||
writable: true,
|
let group = this._groups.slice(1).find(n => n.index === groupBegin);
|
||||||
left: this._discCapacity - this._getUsed(),
|
if (!group) return;
|
||||||
used: this._getUsed(),
|
group.title = newName;
|
||||||
total: this._discCapacity,
|
if (newFullWidth !== undefined) group.fullWidthTitle = newFullWidth;
|
||||||
trackCount: this._tracks.length,
|
}
|
||||||
groups: [
|
|
||||||
{
|
async addGroup(groupBegin: number, groupLength: number, newName: string) {
|
||||||
index: 0,
|
let ungrouped = this._groups.find(n => n.title === null);
|
||||||
title: null,
|
if(!ungrouped) return; // You can only group tracks that aren't already in a different group, if there's no such tracks, there's no point to continue
|
||||||
tracks: this._tracks,
|
let ungroupedLengthBeforeGroup = ungrouped.tracks.length;
|
||||||
},
|
|
||||||
],
|
let thisGroupTracks = ungrouped.tracks.filter(n => n.index >= groupBegin && n.index < groupBegin + groupLength);
|
||||||
})
|
ungrouped.tracks = ungrouped.tracks.filter(n => !thisGroupTracks.includes(n));
|
||||||
);
|
|
||||||
|
if(ungroupedLengthBeforeGroup - ungrouped.tracks.length !== groupLength){
|
||||||
|
throw new Error('A track cannot be in 2 groups!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSequential(thisGroupTracks.map(n => n.index))) {
|
||||||
|
throw new Error('Invalid sequence of tracks!');
|
||||||
|
}
|
||||||
|
this._groups.push({
|
||||||
|
title: newName,
|
||||||
|
fullWidthTitle: '',
|
||||||
|
index: groupBegin,
|
||||||
|
tracks: thisGroupTracks,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
async deleteGroup(groupBegin: number) {
|
||||||
|
const thisGroup = this._groups.slice(1).find(n => n.tracks[0].index === groupBegin);
|
||||||
|
if (!thisGroup) return;
|
||||||
|
let ungroupedGroup = this._groups.find(n => n.title === null);
|
||||||
|
if(!ungroupedGroup){
|
||||||
|
ungroupedGroup = {
|
||||||
|
title: null,
|
||||||
|
fullWidthTitle: null,
|
||||||
|
tracks: [],
|
||||||
|
index: 0
|
||||||
|
};
|
||||||
|
this._groups.unshift(ungroupedGroup);
|
||||||
|
}
|
||||||
|
ungroupedGroup.tracks = ungroupedGroup.tracks.concat(thisGroup.tracks).sort((a, b) => a.index - b.index);
|
||||||
|
this._groups.splice(this._groups.indexOf(thisGroup), 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
async rewriteGroups(groups: Group[]) {
|
||||||
|
this._groups = [...groups];
|
||||||
}
|
}
|
||||||
|
|
||||||
async getDeviceStatus() {
|
async getDeviceStatus() {
|
||||||
|
@ -110,43 +187,68 @@ class NetMDMockService implements NetMDService {
|
||||||
|
|
||||||
async finalize() {}
|
async finalize() {}
|
||||||
|
|
||||||
async renameTrack(index: number, newTitle: string) {
|
async renameTrack(index: number, newTitle: string, fullWidthTitle?: string) {
|
||||||
newTitle = sanitizeTitle(newTitle);
|
newTitle = sanitizeHalfWidthTitle(newTitle);
|
||||||
if (this._getTracksTitlesLength() + newTitle.length > this._tracksTitlesMaxLength) {
|
if (this._getTracksTitlesLength() + newTitle.length > this._tracksTitlesMaxLength) {
|
||||||
throw new Error(`Track's title too long`);
|
throw new Error(`Track's title too long`);
|
||||||
}
|
}
|
||||||
|
if (fullWidthTitle !== undefined) {
|
||||||
|
this._tracks[index].fullWidthTitle = sanitizeFullWidthTitle(fullWidthTitle);
|
||||||
|
}
|
||||||
this._tracks[index].title = newTitle;
|
this._tracks[index].title = newTitle;
|
||||||
}
|
}
|
||||||
|
|
||||||
async renameDisc(newName: string) {
|
async renameDisc(newName: string, fullWidthName?: string) {
|
||||||
this._discTitle = newName;
|
this._discTitle = sanitizeHalfWidthTitle(newName);
|
||||||
|
if (fullWidthName !== undefined) this._fullWidthDiscTitle = sanitizeFullWidthTitle(fullWidthName);
|
||||||
}
|
}
|
||||||
|
|
||||||
async deleteTrack(index: number) {
|
async deleteTracks(indexes: number[]) {
|
||||||
this._tracks.splice(index, 1);
|
debugger;
|
||||||
|
indexes = indexes.sort();
|
||||||
|
indexes.reverse();
|
||||||
|
for(let index of indexes){
|
||||||
|
this._groups = recomputeGroupsAfterTrackMove(this._getDisc(), index, -1).groups;
|
||||||
|
this._tracks.splice(index, 1);
|
||||||
|
this._groups.forEach(n => n.tracks = n.tracks.filter(n => this._tracks.includes(n)));
|
||||||
|
}
|
||||||
this._updateTrackIndexes();
|
this._updateTrackIndexes();
|
||||||
}
|
}
|
||||||
|
|
||||||
async moveTrack(src: number, dst: number) {
|
async moveTrack(src: number, dst: number, updateGroups?: boolean) {
|
||||||
let t = this._tracks.splice(src, 1);
|
let t = this._tracks.splice(src, 1);
|
||||||
assert(t.length === 1);
|
assert(t.length === 1);
|
||||||
this._tracks.splice(dst, 0, t[0]);
|
this._tracks.splice(dst, 0, t[0]);
|
||||||
this._updateTrackIndexes();
|
this._updateTrackIndexes();
|
||||||
|
if (updateGroups || updateGroups === undefined) this._groups = recomputeGroupsAfterTrackMove(this._getDisc(), src, dst).groups;
|
||||||
}
|
}
|
||||||
|
|
||||||
async wipeDisc() {
|
async wipeDisc() {
|
||||||
this._tracks = [];
|
this._tracks = [];
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async wipeDiscTitleInfo() {
|
||||||
|
this._groups = [{
|
||||||
|
index: 0,
|
||||||
|
title: null,
|
||||||
|
fullWidthTitle: null,
|
||||||
|
tracks: this._tracks
|
||||||
|
}];
|
||||||
|
this._discTitle = "";
|
||||||
|
this._fullWidthDiscTitle = "";
|
||||||
|
}
|
||||||
|
|
||||||
async upload(
|
async upload(
|
||||||
title: string,
|
title: string,
|
||||||
|
fullWidthTitle: string,
|
||||||
data: ArrayBuffer,
|
data: ArrayBuffer,
|
||||||
format: Wireformat,
|
format: Wireformat,
|
||||||
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
|
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
|
||||||
) {
|
) {
|
||||||
progressCallback({ written: 0, encrypted: 0, total: 100 });
|
progressCallback({ written: 0, encrypted: 0, total: 100 });
|
||||||
|
|
||||||
title = sanitizeTitle(title);
|
let halfWidthTitle = sanitizeHalfWidthTitle(title);
|
||||||
|
fullWidthTitle = sanitizeFullWidthTitle(fullWidthTitle);
|
||||||
|
|
||||||
if (this._getTracksTitlesLength() + title.length > this._tracksTitlesMaxLength) {
|
if (this._getTracksTitlesLength() + title.length > this._tracksTitlesMaxLength) {
|
||||||
throw new Error(`Track's title too long`); // Simulates reject from device
|
throw new Error(`Track's title too long`); // Simulates reject from device
|
||||||
|
@ -160,12 +262,13 @@ class NetMDMockService implements NetMDService {
|
||||||
}
|
}
|
||||||
|
|
||||||
this._tracks.push({
|
this._tracks.push({
|
||||||
title,
|
title: halfWidthTitle,
|
||||||
duration: 5 * 60 * 512,
|
duration: 5 * 60 * 512,
|
||||||
encoding: Encoding.sp,
|
encoding: Encoding.sp,
|
||||||
index: this._tracks.length,
|
index: this._tracks.length,
|
||||||
protected: TrackFlag.unprotected,
|
protected: TrackFlag.unprotected,
|
||||||
channel: 0,
|
channel: 0,
|
||||||
|
fullWidthTitle: fullWidthTitle,
|
||||||
});
|
});
|
||||||
|
|
||||||
await sleep(1000);
|
await sleep(1000);
|
||||||
|
|
|
@ -9,10 +9,19 @@ import {
|
||||||
download,
|
download,
|
||||||
getDeviceStatus,
|
getDeviceStatus,
|
||||||
DeviceStatus,
|
DeviceStatus,
|
||||||
|
Group,
|
||||||
} from 'netmd-js';
|
} from 'netmd-js';
|
||||||
import { makeGetAsyncPacketIteratorOnWorkerThread } from 'netmd-js/dist/web-encrypt-worker';
|
import { makeGetAsyncPacketIteratorOnWorkerThread } from 'netmd-js/dist/web-encrypt-worker';
|
||||||
import { Logger } from 'netmd-js/dist/logger';
|
import { Logger } from 'netmd-js/dist/logger';
|
||||||
import { asyncMutex, sanitizeTitle, sleep } from '../utils';
|
import {
|
||||||
|
asyncMutex,
|
||||||
|
sanitizeHalfWidthTitle,
|
||||||
|
sanitizeFullWidthTitle,
|
||||||
|
sleep,
|
||||||
|
isSequential,
|
||||||
|
compileDiscTitles,
|
||||||
|
recomputeGroupsAfterTrackMove,
|
||||||
|
} from '../utils';
|
||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
|
|
||||||
const Worker = require('worker-loader!netmd-js/dist/web-encrypt-worker.js'); // eslint-disable-line import/no-webpack-loader-syntax
|
const Worker = require('worker-loader!netmd-js/dist/web-encrypt-worker.js'); // eslint-disable-line import/no-webpack-loader-syntax
|
||||||
|
@ -25,13 +34,19 @@ export interface NetMDService {
|
||||||
listContent(): Promise<Disc>;
|
listContent(): Promise<Disc>;
|
||||||
getDeviceName(): Promise<string>;
|
getDeviceName(): Promise<string>;
|
||||||
finalize(): Promise<void>;
|
finalize(): Promise<void>;
|
||||||
renameTrack(index: number, newTitle: string): Promise<void>;
|
renameTrack(index: number, newTitle: string, newFullWidthTitle?: string): Promise<void>;
|
||||||
renameDisc(newName: string): Promise<void>;
|
renameDisc(newName: string, newFullWidthName?: string): Promise<void>;
|
||||||
deleteTrack(index: number): Promise<void>;
|
renameGroup(groupBegin: number, newTitle: string, newFullWidthTitle?: string): Promise<void>;
|
||||||
moveTrack(src: number, dst: number): Promise<void>;
|
addGroup(groupBegin: number, groupLength: number, name: string): Promise<void>;
|
||||||
|
deleteGroup(groupIndex: number): Promise<void>;
|
||||||
|
rewriteGroups(groups: Group[]): Promise<void>;
|
||||||
|
deleteTracks(indexes: number[]): Promise<void>;
|
||||||
|
moveTrack(src: number, dst: number, updateGroups?: boolean): Promise<void>;
|
||||||
wipeDisc(): Promise<void>;
|
wipeDisc(): Promise<void>;
|
||||||
|
wipeDiscTitleInfo(): Promise<void>;
|
||||||
upload(
|
upload(
|
||||||
title: string,
|
title: string,
|
||||||
|
fullWidthTitle: string,
|
||||||
data: ArrayBuffer,
|
data: ArrayBuffer,
|
||||||
format: Wireformat,
|
format: Wireformat,
|
||||||
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
|
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
|
||||||
|
@ -49,6 +64,7 @@ export interface NetMDService {
|
||||||
export class NetMDUSBService implements NetMDService {
|
export class NetMDUSBService implements NetMDService {
|
||||||
private netmdInterface?: NetMDInterface;
|
private netmdInterface?: NetMDInterface;
|
||||||
private logger?: Logger;
|
private logger?: Logger;
|
||||||
|
private cachedContentList?: Disc;
|
||||||
public mutex = new Mutex();
|
public mutex = new Mutex();
|
||||||
public statusMonitorTimer: any;
|
public statusMonitorTimer: any;
|
||||||
|
|
||||||
|
@ -70,7 +86,33 @@ export class NetMDUSBService implements NetMDService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async _rewriteRawTitles(titleObject: { newRawTitle: string; newRawFullWidthTitle: string } | null) {
|
||||||
|
if(titleObject === null) return;
|
||||||
|
await this.netmdInterface!.cacheTOC();
|
||||||
|
await this.netmdInterface!.setDiscTitle(sanitizeHalfWidthTitle(titleObject.newRawTitle));
|
||||||
|
await this.netmdInterface!.setDiscTitle(sanitizeFullWidthTitle(titleObject.newRawFullWidthTitle), true);
|
||||||
|
await this.netmdInterface!.syncTOC();
|
||||||
|
this._dropCache();
|
||||||
|
}
|
||||||
|
|
||||||
|
async _listContent() {
|
||||||
|
// listContent takes a long time to execute (>3000ms), so I think caching it should speed up the app
|
||||||
|
if(!this.cachedContentList) {
|
||||||
|
console.log("There's no cached version of the TOC, caching");
|
||||||
|
this.cachedContentList = await listContent(this.netmdInterface!);
|
||||||
|
}else{
|
||||||
|
console.log("There's a cached TOC available.");
|
||||||
|
}
|
||||||
|
return JSON.parse(JSON.stringify(this.cachedContentList)) as Disc;
|
||||||
|
}
|
||||||
|
|
||||||
|
_dropCache() {
|
||||||
|
console.log("Cached TOC Dropped");
|
||||||
|
this.cachedContentList = undefined;
|
||||||
|
}
|
||||||
|
|
||||||
async pair() {
|
async pair() {
|
||||||
|
this._dropCache();
|
||||||
let iface = await openNewDevice(navigator.usb, this.logger);
|
let iface = await openNewDevice(navigator.usb, this.logger);
|
||||||
if (iface === null) {
|
if (iface === null) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -80,6 +122,7 @@ export class NetMDUSBService implements NetMDService {
|
||||||
}
|
}
|
||||||
|
|
||||||
async connect() {
|
async connect() {
|
||||||
|
this._dropCache();
|
||||||
let iface = await openPairedDevice(navigator.usb, this.logger);
|
let iface = await openPairedDevice(navigator.usb, this.logger);
|
||||||
if (iface === null) {
|
if (iface === null) {
|
||||||
return false;
|
return false;
|
||||||
|
@ -90,7 +133,8 @@ export class NetMDUSBService implements NetMDService {
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async listContent() {
|
async listContent() {
|
||||||
return await listContent(this.netmdInterface!);
|
this._dropCache();
|
||||||
|
return await this._listContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
|
@ -106,24 +150,109 @@ export class NetMDUSBService implements NetMDService {
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async finalize() {
|
async finalize() {
|
||||||
await this.netmdInterface!.netMd.finalize();
|
await this.netmdInterface!.netMd.finalize();
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async renameTrack(index: number, title: string) {
|
async rewriteGroups(groups: Group[]) {
|
||||||
// Removing non ascii chars... Sorry, I didn't implement char encoding.
|
const disc = await this._listContent();
|
||||||
title = sanitizeTitle(title);
|
disc.groups = groups;
|
||||||
|
await this._rewriteRawTitles(compileDiscTitles(disc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@asyncMutex
|
||||||
|
async renameTrack(index: number, title: string, fullWidthTitle?: string) {
|
||||||
|
title = sanitizeHalfWidthTitle(title);
|
||||||
await this.netmdInterface!.cacheTOC();
|
await this.netmdInterface!.cacheTOC();
|
||||||
await this.netmdInterface!.setTrackTitle(index, title);
|
await this.netmdInterface!.setTrackTitle(index, title);
|
||||||
|
if (fullWidthTitle !== undefined) {
|
||||||
|
await this.netmdInterface!.setTrackTitle(index, sanitizeFullWidthTitle(fullWidthTitle), true);
|
||||||
|
}
|
||||||
await this.netmdInterface!.syncTOC();
|
await this.netmdInterface!.syncTOC();
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async renameDisc(newName: string) {
|
async renameGroup(groupBegin: number, newName: string, newFullWidthName?: string) {
|
||||||
|
const disc = await this._listContent();
|
||||||
|
let thisGroup = disc.groups.find(n => n.tracks[0].index === groupBegin);
|
||||||
|
if (!thisGroup) return;
|
||||||
|
|
||||||
|
thisGroup.title = newName;
|
||||||
|
if (newFullWidthName !== undefined) thisGroup.fullWidthTitle = newFullWidthName;
|
||||||
|
await this._rewriteRawTitles(compileDiscTitles(disc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@asyncMutex
|
||||||
|
async addGroup(groupBegin: number, groupLength: number, title: string) {
|
||||||
|
const disc = await this._listContent();
|
||||||
|
let ungrouped = disc.groups.find(n => n.title === null);
|
||||||
|
if(!ungrouped) return; // You can only group tracks that aren't already in a different group, if there's no such tracks, there's no point to continue
|
||||||
|
let ungroupedLengthBeforeGroup = ungrouped.tracks.length;
|
||||||
|
|
||||||
|
let thisGroupTracks = ungrouped.tracks.filter(n => n.index >= groupBegin && n.index < groupBegin + groupLength);
|
||||||
|
ungrouped.tracks = ungrouped.tracks.filter(n => !thisGroupTracks.includes(n));
|
||||||
|
|
||||||
|
if(ungroupedLengthBeforeGroup - ungrouped.tracks.length !== groupLength){
|
||||||
|
throw new Error('A track cannot be in 2 groups!');
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!isSequential(thisGroupTracks.map(n => n.index))) {
|
||||||
|
throw new Error('Invalid sequence of tracks!');
|
||||||
|
}
|
||||||
|
disc.groups.push({
|
||||||
|
title,
|
||||||
|
fullWidthTitle: '',
|
||||||
|
index: groupBegin,
|
||||||
|
tracks: thisGroupTracks,
|
||||||
|
});
|
||||||
|
await this._rewriteRawTitles(compileDiscTitles(disc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@asyncMutex
|
||||||
|
async deleteGroup(groupBegin: number) {
|
||||||
|
const disc = await this._listContent();
|
||||||
|
|
||||||
|
let thisGroup = disc.groups.find(n => n.tracks[0].index === groupBegin);
|
||||||
|
if (thisGroup) disc.groups.splice(disc.groups.indexOf(thisGroup), 1);
|
||||||
|
|
||||||
|
await this._rewriteRawTitles(compileDiscTitles(disc));
|
||||||
|
}
|
||||||
|
|
||||||
|
@asyncMutex
|
||||||
|
async renameDisc(newName: string, newFullWidthName?: string) {
|
||||||
// TODO: This whole function should be moved in netmd-js
|
// TODO: This whole function should be moved in netmd-js
|
||||||
const oldName = await this.netmdInterface!.getDiscTitle();
|
const oldName = await this.netmdInterface!.getDiscTitle();
|
||||||
|
const oldFullWidthName = await this.netmdInterface!.getDiscTitle(true);
|
||||||
const oldRawName = await this.netmdInterface!._getDiscTitle();
|
const oldRawName = await this.netmdInterface!._getDiscTitle();
|
||||||
|
const oldRawFullWidthName = await this.netmdInterface!._getDiscTitle(true);
|
||||||
const hasGroups = oldRawName.indexOf('//') >= 0;
|
const hasGroups = oldRawName.indexOf('//') >= 0;
|
||||||
|
const hasFullWidthGroups = oldRawName.indexOf('//') >= 0;
|
||||||
const hasGroupsAndTitle = oldRawName.startsWith('0;');
|
const hasGroupsAndTitle = oldRawName.startsWith('0;');
|
||||||
|
const hasFullWidthGroupsAndTitle = oldRawName.startsWith('0;');
|
||||||
|
|
||||||
|
newName = sanitizeHalfWidthTitle(newName);
|
||||||
|
newFullWidthName = newFullWidthName && sanitizeFullWidthTitle(newFullWidthName);
|
||||||
|
|
||||||
|
if (newFullWidthName !== oldFullWidthName && newFullWidthName !== undefined) {
|
||||||
|
let newFullWidthNameWithGroups;
|
||||||
|
if (hasFullWidthGroups) {
|
||||||
|
if (hasFullWidthGroupsAndTitle) {
|
||||||
|
newFullWidthNameWithGroups = oldRawFullWidthName.replace(
|
||||||
|
/^0;.*?///,
|
||||||
|
newFullWidthName !== '' ? `0;${newFullWidthName}//` : ``
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
newFullWidthNameWithGroups = `0;${newFullWidthName}//${oldRawFullWidthName}`; // Add the new title
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
newFullWidthNameWithGroups = newFullWidthName;
|
||||||
|
}
|
||||||
|
await this.netmdInterface!.cacheTOC();
|
||||||
|
await this.netmdInterface!.setDiscTitle(newFullWidthNameWithGroups, true);
|
||||||
|
await this.netmdInterface!.syncTOC();
|
||||||
|
this._dropCache();
|
||||||
|
}
|
||||||
|
|
||||||
if (newName === oldName) {
|
if (newName === oldName) {
|
||||||
return;
|
return;
|
||||||
|
@ -144,26 +273,52 @@ export class NetMDUSBService implements NetMDService {
|
||||||
await this.netmdInterface!.cacheTOC();
|
await this.netmdInterface!.cacheTOC();
|
||||||
await this.netmdInterface!.setDiscTitle(newNameWithGroups);
|
await this.netmdInterface!.setDiscTitle(newNameWithGroups);
|
||||||
await this.netmdInterface!.syncTOC();
|
await this.netmdInterface!.syncTOC();
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async deleteTrack(index: number) {
|
async deleteTracks(indexes: number[]) {
|
||||||
await this.netmdInterface!.eraseTrack(index);
|
indexes = indexes.sort();
|
||||||
await sleep(100);
|
indexes.reverse();
|
||||||
|
let content = await this._listContent();
|
||||||
|
for(let index of indexes){
|
||||||
|
content = recomputeGroupsAfterTrackMove(content, index, -1);
|
||||||
|
await this.netmdInterface!.eraseTrack(index);
|
||||||
|
await sleep(100);
|
||||||
|
}
|
||||||
|
await this._rewriteRawTitles(compileDiscTitles(content));
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async wipeDisc() {
|
async wipeDisc() {
|
||||||
await this.netmdInterface!.eraseDisc();
|
await this.netmdInterface!.eraseDisc();
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
async moveTrack(src: number, dst: number) {
|
async wipeDiscTitleInfo() {
|
||||||
|
await this._rewriteRawTitles({
|
||||||
|
newRawTitle: '',
|
||||||
|
newRawFullWidthTitle: '',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
@asyncMutex
|
||||||
|
async moveTrack(src: number, dst: number, updateGroups?: boolean) {
|
||||||
await this.netmdInterface!.moveTrack(src, dst);
|
await this.netmdInterface!.moveTrack(src, dst);
|
||||||
|
|
||||||
|
if (updateGroups === undefined || updateGroups) {
|
||||||
|
await this._rewriteRawTitles(
|
||||||
|
compileDiscTitles(recomputeGroupsAfterTrackMove(await this._listContent(), src, dst))
|
||||||
|
);
|
||||||
|
}
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upload(
|
async upload(
|
||||||
title: string,
|
title: string,
|
||||||
|
fullWidthTitle: string,
|
||||||
data: ArrayBuffer,
|
data: ArrayBuffer,
|
||||||
format: Wireformat,
|
format: Wireformat,
|
||||||
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
|
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
|
||||||
|
@ -182,9 +337,9 @@ export class NetMDUSBService implements NetMDService {
|
||||||
updateProgress();
|
updateProgress();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Removing non ascii chars... Sorry, I didn't implement char encoding.
|
let halfWidthTitle = sanitizeHalfWidthTitle(title);
|
||||||
title = sanitizeTitle(title);
|
fullWidthTitle = sanitizeFullWidthTitle(fullWidthTitle);
|
||||||
let mdTrack = new MDTrack(title, format, data, 0x80000, webWorkerAsyncPacketIterator);
|
let mdTrack = new MDTrack(halfWidthTitle, format, data, 0x80000, fullWidthTitle, webWorkerAsyncPacketIterator);
|
||||||
|
|
||||||
await download(this.netmdInterface!, mdTrack, ({ writtenBytes }) => {
|
await download(this.netmdInterface!, mdTrack, ({ writtenBytes }) => {
|
||||||
written = writtenBytes;
|
written = writtenBytes;
|
||||||
|
@ -192,6 +347,7 @@ export class NetMDUSBService implements NetMDService {
|
||||||
});
|
});
|
||||||
|
|
||||||
w.terminate();
|
w.terminate();
|
||||||
|
this._dropCache();
|
||||||
}
|
}
|
||||||
|
|
||||||
@asyncMutex
|
@asyncMutex
|
||||||
|
|
268
src/utils.ts
268
src/utils.ts
|
@ -1,8 +1,10 @@
|
||||||
import { Disc, formatTimeFromFrames, Encoding } from 'netmd-js';
|
import { Disc, formatTimeFromFrames, Encoding, getTracks, Group } from 'netmd-js';
|
||||||
import { useSelector, shallowEqual } from 'react-redux';
|
import { useSelector, shallowEqual } from 'react-redux';
|
||||||
import { RootState } from './redux/store';
|
import { RootState } from './redux/store';
|
||||||
import { Mutex } from 'async-mutex';
|
import { Mutex } from 'async-mutex';
|
||||||
import { Theme } from '@material-ui/core';
|
import { Theme } from '@material-ui/core';
|
||||||
|
import jconv from 'jconv';
|
||||||
|
import { halfWidthToFullWidthRange } from 'netmd-js/dist/utils';
|
||||||
|
|
||||||
export function sleep(ms: number) {
|
export function sleep(ms: number) {
|
||||||
return new Promise(resolve => {
|
return new Promise(resolve => {
|
||||||
|
@ -49,12 +51,32 @@ export function loadPreference<T>(key: string, defaultValue: T): T {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function getAvailableCharsForTrackTitle(trackTitles: string[]) {
|
export function getAvailableCharsForTitle(disc: Disc, includeGroups?: boolean) {
|
||||||
const maxChars = 1700; // see https://www.minidisc.org/md_toc.html
|
const cellLimit = 255;
|
||||||
const usedChars = trackTitles.reduce((acc, title) => {
|
// see https://www.minidisc.org/md_toc.html
|
||||||
return acc + title.length;
|
const fixLength = (len: number) => Math.ceil(len / 7);
|
||||||
}, 0);
|
|
||||||
return maxChars - usedChars;
|
let groups = disc.groups.filter(n => n.title !== null);
|
||||||
|
|
||||||
|
// Assume worst-case scenario
|
||||||
|
let fwTitle = disc.fullWidthTitle + `0;//`;
|
||||||
|
let hwTitle = disc.title + `0;//`;
|
||||||
|
if(includeGroups || includeGroups === undefined)
|
||||||
|
for(let group of groups) {
|
||||||
|
let range = `${group.tracks[0].index+1}${group.tracks.length - 1 !== 0 && (`-${group.tracks[group.tracks.length - 1].index+1}`)}//`
|
||||||
|
// The order of these characters doesn't matter. It's for length only
|
||||||
|
fwTitle += group.fullWidthTitle + range;
|
||||||
|
hwTitle += group.title + range;
|
||||||
|
}
|
||||||
|
|
||||||
|
let usedCells = 0;
|
||||||
|
usedCells += fixLength(fwTitle.length * 2);
|
||||||
|
usedCells += fixLength(getHalfWidthTitleLength(hwTitle));
|
||||||
|
for (let trk of getTracks(disc)) {
|
||||||
|
usedCells += fixLength((trk.fullWidthTitle?.length ?? 0) * 2);
|
||||||
|
usedCells += fixLength(getHalfWidthTitleLength(trk.title ?? ''));
|
||||||
|
}
|
||||||
|
return Math.max(cellLimit - usedCells, 0) * 7;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function framesToSec(frames: number) {
|
export function framesToSec(frames: number) {
|
||||||
|
@ -65,21 +87,71 @@ export function sanitizeTitle(title: string) {
|
||||||
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
|
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
|
||||||
}
|
}
|
||||||
|
|
||||||
const EncodingName: { [k: number]: string } = {
|
export function getHalfWidthTitleLength(title: string) {// Some characters are written as 2 bytes
|
||||||
|
// prettier-ignore
|
||||||
|
const multiByteChars: { [key: string]: number } = { "ガ": 1, "ギ": 1, "グ": 1, "ゲ": 1, "ゴ": 1, "ザ": 1, "ジ": 1, "ズ": 1, "ゼ": 1, "ゾ": 1, "ダ": 1, "ヂ": 1, "ヅ": 1, "デ": 1, "ド": 1, "バ": 1, "パ": 1, "ビ": 1, "ピ": 1, "ブ": 1, "プ": 1, "ベ": 1, "ペ": 1, "ボ": 1, "ポ": 1, "ヮ": 1, "ヰ": 1, "ヱ": 1, "ヵ": 1, "ヶ": 1, "ヴ": 1, "ヽ": 1, "ヾ": 1, "が": 1, "ぎ": 1, "ぐ": 1, "げ": 1, "ご": 1, "ざ": 1, "じ": 1, "ず": 1, "ぜ": 1, "ぞ": 1, "だ": 1, "ぢ": 1, "づ": 1, "で": 1, "ど": 1, "ば": 1, "ぱ": 1, "び": 1, "ぴ": 1, "ぶ": 1, "ぷ": 1, "べ": 1, "ぺ": 1, "ぼ": 1, "ぽ": 1, "ゎ": 1, "ゐ": 1, "ゑ": 1, "ゕ": 1, "ゖ": 1, "ゔ": 1, "ゝ": 1, "ゞ": 1 };
|
||||||
|
return title.length + title.split('').map(n => multiByteChars[n] ?? 0).reduce((a, b) => a + b, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeHalfWidthTitle(title: string) {
|
||||||
|
// prettier-ignore
|
||||||
|
const mappings: { [key: string]: string } = { 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', ''': "'", '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '-': '-', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': '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
|
||||||
|
.split('')
|
||||||
|
.map(n => {
|
||||||
|
if (mappings[n]) return mappings[n];
|
||||||
|
if (n.charCodeAt(0) < 0x7f || allowedHalfWidthKana.includes(n)) return n;
|
||||||
|
return ' ';
|
||||||
|
})
|
||||||
|
.join('');
|
||||||
|
// Check if the amount of characters is the same as the amount of encoded bytes (when accounting for dakuten). Otherwise the disc might end up corrupted
|
||||||
|
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
|
||||||
|
if (sjisEncoded.length !== getHalfWidthTitleLength(title)) return sanitizeTitle(title); //Fallback
|
||||||
|
return newTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function sanitizeFullWidthTitle(title: string) {
|
||||||
|
// prettier-ignore
|
||||||
|
const mappings: { [key: string]: string } = { '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', "'": ''', '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '-': '-', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': '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 newTitle = title
|
||||||
|
.split('')
|
||||||
|
.map(n => mappings[n] ?? n)
|
||||||
|
.join('');
|
||||||
|
|
||||||
|
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
|
||||||
|
if (jconv.decode(sjisEncoded, 'SJIS') !== newTitle) return sanitizeTitle(title); // Fallback
|
||||||
|
if (sjisEncoded.length !== title.length * 2) return sanitizeTitle(title); // Fallback (every character in the full-width title is 2 bytes)
|
||||||
|
return newTitle;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const EncodingName: { [k: number]: string } = {
|
||||||
[Encoding.sp]: 'SP',
|
[Encoding.sp]: 'SP',
|
||||||
[Encoding.lp2]: 'LP2',
|
[Encoding.lp2]: 'LP2',
|
||||||
[Encoding.lp4]: 'LP4',
|
[Encoding.lp4]: 'LP4',
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export type DisplayTrack = {
|
||||||
|
index: number;
|
||||||
|
title: string;
|
||||||
|
fullWidthTitle: string;
|
||||||
|
group: string | null;
|
||||||
|
duration: string;
|
||||||
|
encoding: string
|
||||||
|
};
|
||||||
|
|
||||||
export function getSortedTracks(disc: Disc | null) {
|
export function getSortedTracks(disc: Disc | null) {
|
||||||
let tracks: { index: number; title: string; group: string; duration: string; encoding: string }[] = [];
|
let tracks: DisplayTrack[] = [];
|
||||||
if (disc !== null) {
|
if (disc !== null) {
|
||||||
for (let group of disc.groups) {
|
for (let group of disc.groups) {
|
||||||
for (let track of group.tracks) {
|
for (let track of group.tracks) {
|
||||||
tracks.push({
|
tracks.push({
|
||||||
index: track.index,
|
index: track.index,
|
||||||
title: track.title ?? `Unknown Title`,
|
title: track.title ?? `Unknown Title`,
|
||||||
group: group.title ?? ``,
|
fullWidthTitle: track.fullWidthTitle ?? ``,
|
||||||
|
group: group.title ?? null,
|
||||||
encoding: EncodingName[track.encoding],
|
encoding: EncodingName[track.encoding],
|
||||||
duration: formatTimeFromFrames(track.duration, false),
|
duration: formatTimeFromFrames(track.duration, false),
|
||||||
});
|
});
|
||||||
|
@ -90,6 +162,182 @@ export function getSortedTracks(disc: Disc | null) {
|
||||||
return tracks;
|
return tracks;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export function getGroupedTracks(disc: Disc | null) {
|
||||||
|
if (!disc) return [];
|
||||||
|
let groupedList: Group[] = [];
|
||||||
|
let ungroupedTracks = [...(disc.groups.find(n => n.title === null)?.tracks ?? [])];
|
||||||
|
|
||||||
|
let lastIndex = 0;
|
||||||
|
|
||||||
|
for (let group of disc.groups) {
|
||||||
|
if (group.title === null) continue; // Ungrouped tracks
|
||||||
|
let toCopy = group.tracks[0].index - lastIndex;
|
||||||
|
groupedList.push({
|
||||||
|
index: -1,
|
||||||
|
title: null,
|
||||||
|
fullWidthTitle: null,
|
||||||
|
tracks: toCopy === 0 ? [] : ungroupedTracks.splice(0, toCopy),
|
||||||
|
});
|
||||||
|
lastIndex = group.tracks[group.tracks.length - 1].index + 1;
|
||||||
|
groupedList.push(group);
|
||||||
|
}
|
||||||
|
groupedList.push({
|
||||||
|
index: -1,
|
||||||
|
title: null,
|
||||||
|
fullWidthTitle: null,
|
||||||
|
tracks: ungroupedTracks,
|
||||||
|
});
|
||||||
|
return groupedList;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function reverseTimeFormat(time: string) {
|
||||||
|
if (time.includes('+')) time = time.substring(0, time.indexOf('+')); // Remove frame numbers
|
||||||
|
const segments = time.split(':');
|
||||||
|
let seconds = 0;
|
||||||
|
for (let seg of segments) {
|
||||||
|
seconds *= 60;
|
||||||
|
seconds += parseInt(seg);
|
||||||
|
}
|
||||||
|
return seconds;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function recomputeGroupsAfterTrackMove(disc: Disc, trackIndex: number, targetIndex: number) {
|
||||||
|
// Used for moving tracks in netmd-mock and deleting
|
||||||
|
let offset = trackIndex > targetIndex ? 1 : -1;
|
||||||
|
let deleteMode = targetIndex === -1;
|
||||||
|
|
||||||
|
if (deleteMode) {
|
||||||
|
offset = -1;
|
||||||
|
targetIndex = disc.trackCount;
|
||||||
|
}
|
||||||
|
|
||||||
|
let boundsStart = Math.min(trackIndex, targetIndex);
|
||||||
|
let boundsEnd = Math.max(trackIndex, targetIndex);
|
||||||
|
|
||||||
|
let allTracks = disc.groups
|
||||||
|
.map(n => n.tracks)
|
||||||
|
.reduce((a, b) => a.concat(b), [])
|
||||||
|
.sort((a, b) => a.index - b.index)
|
||||||
|
.filter(n => !deleteMode || n.index !== trackIndex);
|
||||||
|
|
||||||
|
let groupBoundaries: {
|
||||||
|
name: string | null;
|
||||||
|
fullWidthName: string | null;
|
||||||
|
start: number;
|
||||||
|
end: number;
|
||||||
|
}[] = disc.groups
|
||||||
|
.filter(n => n.title !== null)
|
||||||
|
.map(group => ({
|
||||||
|
name: group.title,
|
||||||
|
fullWidthName: group.fullWidthTitle,
|
||||||
|
start: group.tracks[0].index,
|
||||||
|
end: group.tracks[0].index + group.tracks.length - 1,
|
||||||
|
})); // Convert to a format better for shifting
|
||||||
|
|
||||||
|
let anyChanges = false;
|
||||||
|
|
||||||
|
for (let group of groupBoundaries) {
|
||||||
|
if (group.start > boundsStart && group.start <= boundsEnd) {
|
||||||
|
group.start += offset;
|
||||||
|
anyChanges = true;
|
||||||
|
}
|
||||||
|
if (group.end >= boundsStart && group.end < boundsEnd) {
|
||||||
|
group.end += offset;
|
||||||
|
anyChanges = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if(!anyChanges) return disc;
|
||||||
|
|
||||||
|
let newDisc: Disc = { ...disc };
|
||||||
|
|
||||||
|
// Convert back
|
||||||
|
newDisc.groups = groupBoundaries.map(n => ({
|
||||||
|
title: n.name,
|
||||||
|
fullWidthTitle: n.fullWidthName,
|
||||||
|
index: n.start,
|
||||||
|
tracks: allTracks.slice(n.start, n.end + 1),
|
||||||
|
})).filter(n => n.tracks.length > 0);
|
||||||
|
|
||||||
|
// Convert ungrouped tracks
|
||||||
|
let allGrouped = newDisc.groups.map(n => n.tracks).reduce((a, b) => a.concat(b), []);
|
||||||
|
let ungrouped = allTracks.filter(n => !allGrouped.includes(n));
|
||||||
|
|
||||||
|
// Fix all the track indexes
|
||||||
|
if (deleteMode) {
|
||||||
|
for (let i = 0; i < allTracks.length; i++) {
|
||||||
|
allTracks[i].index = i;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (ungrouped.length) newDisc.groups.unshift({ title: null, fullWidthTitle: null, index: 0, tracks: ungrouped });
|
||||||
|
|
||||||
|
return newDisc;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function compileDiscTitles(disc: Disc) {
|
||||||
|
let availableCharactersForTitle = getAvailableCharsForTitle({
|
||||||
|
...disc,
|
||||||
|
title: '',
|
||||||
|
fullWidthTitle: '',
|
||||||
|
}, false);
|
||||||
|
// If the disc or any of the groups, or any track has a full-width title, provide support for them
|
||||||
|
const useFullWidth =
|
||||||
|
disc.fullWidthTitle ||
|
||||||
|
disc.groups.filter(n => !!n.fullWidthTitle).length > 0 ||
|
||||||
|
disc.groups.map(n => n.tracks).reduce((a, b) => a.concat(b), []).filter(n => !!n.fullWidthTitle).length > 0;
|
||||||
|
|
||||||
|
const fixLength = (l: number) => Math.ceil(l / 7) * 7;
|
||||||
|
|
||||||
|
let newRawTitle = '',
|
||||||
|
newRawFullWidthTitle = '';
|
||||||
|
if (disc.title) newRawTitle = `0;${disc.title}//`;
|
||||||
|
if (useFullWidth) newRawFullWidthTitle = `0;${disc.fullWidthTitle}//`;
|
||||||
|
for (let n of disc.groups) {
|
||||||
|
if (n.title === null || n.tracks.length === 0) continue;
|
||||||
|
let range = `${n.tracks[0].index + 1}`;
|
||||||
|
if (n.tracks.length !== 1) {
|
||||||
|
// Special case
|
||||||
|
range += `-${n.tracks[0].index + n.tracks.length}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
let newRawTitleAfterGroup = newRawTitle + `${range};${n.title}//`,
|
||||||
|
newRawFullWidthTitleAfterGroup = newRawFullWidthTitle + halfWidthToFullWidthRange(range) + `;${n.fullWidthTitle ?? ''}//`;
|
||||||
|
|
||||||
|
|
||||||
|
let titlesLengthInTOC = fixLength(getHalfWidthTitleLength(newRawTitleAfterGroup));
|
||||||
|
|
||||||
|
if(useFullWidth) titlesLengthInTOC += fixLength(newRawFullWidthTitleAfterGroup.length * 2)
|
||||||
|
|
||||||
|
if(availableCharactersForTitle - titlesLengthInTOC < 0) break;
|
||||||
|
|
||||||
|
newRawTitle = newRawTitleAfterGroup;
|
||||||
|
newRawFullWidthTitle = newRawFullWidthTitleAfterGroup;
|
||||||
|
}
|
||||||
|
|
||||||
|
let titlesLengthInTOC = fixLength(getHalfWidthTitleLength(newRawTitle));
|
||||||
|
if(useFullWidth) titlesLengthInTOC += fixLength(newRawFullWidthTitle.length * 2); // If this check fails the titles without the groups already take too much space, don't change anything
|
||||||
|
if(availableCharactersForTitle - titlesLengthInTOC < 0) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
newRawTitle,
|
||||||
|
newRawFullWidthTitle: useFullWidth ? newRawFullWidthTitle : '',
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isSequential(numbers: number[]) {
|
||||||
|
if (numbers.length === 0) return true;
|
||||||
|
let last = numbers[0];
|
||||||
|
for (let num of numbers) {
|
||||||
|
if (num === last) {
|
||||||
|
++last;
|
||||||
|
} else return false;
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
export function asyncMutex(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
export function asyncMutex(target: any, propertyKey: string, descriptor: PropertyDescriptor) {
|
||||||
// This is meant to be used only with classes having a "mutex" instance property
|
// This is meant to be used only with classes having a "mutex" instance property
|
||||||
const oldValue = descriptor.value;
|
const oldValue = descriptor.value;
|
||||||
|
|
|
@ -20,7 +20,8 @@
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
"jsx": "react",
|
"jsx": "react",
|
||||||
"downlevelIteration": true,
|
"downlevelIteration": true,
|
||||||
"experimentalDecorators": true
|
"experimentalDecorators": true,
|
||||||
|
"typeRoots": ["./node_modules/@types", "./typings"]
|
||||||
},
|
},
|
||||||
"include": [
|
"include": [
|
||||||
"src"
|
"src"
|
||||||
|
|
|
@ -0,0 +1 @@
|
||||||
|
declare module 'jconv';
|
Loading…
Reference in New Issue