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