Added support for full-width titles (full-width katakana, hiragana and kanji). Thank you for testing, @is-that-a-thing-now

This commit is contained in:
asivery 2021-06-25 01:05:28 +02:00
parent 01686324ed
commit 206e92b495
10 changed files with 180 additions and 41 deletions

View File

@ -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);

View File

@ -86,13 +86,13 @@ const useStyles = makeStyles(theme => ({
toolbarHighlight: toolbarHighlight:
theme.palette.type === 'light' theme.palette.type === 'light'
? { ? {
color: theme.palette.secondary.main, color: theme.palette.secondary.main,
backgroundColor: lighten(theme.palette.secondary.light, 0.85), backgroundColor: lighten(theme.palette.secondary.light, 0.85),
} }
: { : {
color: theme.palette.text.primary, color: theme.palette.text.primary,
backgroundColor: theme.palette.secondary.dark, backgroundColor: theme.palette.secondary.dark,
}, },
headBox: { headBox: {
display: 'flex', display: 'flex',
justifyContent: 'space-between', justifyContent: 'space-between',
@ -255,12 +255,15 @@ export const Main = (props: {}) => {
const handleRenameDoubleClick = (event: React.MouseEvent, item: number) => { const handleRenameDoubleClick = (event: React.MouseEvent, item: number) => {
let selectedIndex = item; let selectedIndex = item;
let currentName = getTracks(disc!).find(track => track.index === selectedIndex)?.title ?? ''; let track = getTracks(disc!).find(track => track.index === selectedIndex);
let currentName = track?.title ?? '';
let currentFullWidthName = track?.fullWidthTitle ?? '';
dispatch( dispatch(
batchActions([ batchActions([
renameDialogActions.setVisible(true), renameDialogActions.setVisible(true),
renameDialogActions.setCurrentName(currentName), renameDialogActions.setCurrentName(currentName),
renameDialogActions.setCurrentFullWidthName(currentFullWidthName),
renameDialogActions.setIndex(selectedIndex), renameDialogActions.setIndex(selectedIndex),
]) ])
); );
@ -372,6 +375,7 @@ 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>
)} )}
@ -491,6 +495,7 @@ export const Main = (props: {}) => {
)} )}
</TableCell> </TableCell>
<TableCell className={classes.titleCell} title={track.title}> <TableCell className={classes.titleCell} title={track.title}>
{track.fullWidthTitle ? `${track.fullWidthTitle} / ` : ``}
{track.title || `No Title`} {track.title || `No Title`}
</TableCell> </TableCell>
<TableCell align="right" className={classes.durationCell}> <TableCell align="right" className={classes.durationCell}>

View File

@ -4,6 +4,7 @@ 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 } 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,12 +22,22 @@ 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 allowFullWidth = useShallowEqualSelector(state => state.appState.fullWidthSupport);
const what = renameDialogIndex < 0 ? `Disc` : `Track`; const what = renameDialogIndex < 0 ? `Disc` : `Track`;
@ -36,15 +47,27 @@ export const RenameDialog = (props: {}) => {
const handleDoRename = () => { const handleDoRename = () => {
if (renameDialogIndex < 0) { if (renameDialogIndex < 0) {
dispatch(renameDisc({ newName: renameDialogTitle })); 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, 64)));
}
}, },
[dispatch] [dispatch]
); );
@ -86,6 +109,21 @@ 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>

View File

@ -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,12 @@ export const TopMenu = function(props: { onClick?: () => void }) {
batchActions([ batchActions([
renameDialogActions.setVisible(true), renameDialogActions.setVisible(true),
renameDialogActions.setCurrentName(discTitle), renameDialogActions.setCurrentName(discTitle),
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 +158,15 @@ 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}>

View File

@ -105,12 +105,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 +119,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);
}; };
@ -387,8 +390,12 @@ 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;
let tracks = disc && getTracks(disc);
let maxTitleLength = tracks ? getAvailableCharsForTrackTitle(tracks.map(track => track.title || ``)) : -1;
let maxFullWidthTitleLength = tracks ? getAvailableCharsForTrackTitle(tracks.map(track => track.fullWidthTitle || ``)) : -1;
maxTitleLength = Math.floor(maxTitleLength / files.length); maxTitleLength = Math.floor(maxTitleLength / files.length);
maxFullWidthTitleLength = Math.floor(maxFullWidthTitleLength / files.length);
let error: any; let error: any;
let errorMessage = ``; let errorMessage = ``;
@ -408,7 +415,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
} }
if (maxTitleLength > -1) { if (maxTitleLength > -1) {
title = title.substring(0, maxTitleLength); title = title.substring(0, useFullWidth ? Math.min(maxTitleLength, maxFullWidthTitleLength) : maxTitleLength);
} }
trackUpdate.current = i++; trackUpdate.current = i++;
@ -416,7 +423,7 @@ export function convertAndUpload(files: File[], format: UploadFormat, titleForma
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(title, data, wireformat, useFullWidth, 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.`;

View File

@ -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);
},
}, },
}); });

View File

@ -4,12 +4,14 @@ 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;
} }
const initialState: RenameDialogState = { const initialState: RenameDialogState = {
visible: false, visible: false,
title: '', title: '',
fullWidthTitle: '',
index: -1, index: -1,
}; };
@ -23,6 +25,9 @@ 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;
}, },

View File

@ -1,6 +1,6 @@
import { Track, Channels, Encoding, Wireformat, TrackFlag, DeviceStatus } from 'netmd-js'; import { Track, Channels, Encoding, Wireformat, TrackFlag, DeviceStatus } from 'netmd-js';
import { NetMDService } from './netmd'; import { NetMDService } from './netmd';
import { sleep, sanitizeHalfWidthTitle, asyncMutex } from '../utils'; import { sleep, sanitizeFullWidthTitle, sanitizeHalfWidthTitle, asyncMutex } 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,6 +46,7 @@ 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: '',
}, },
]; ];
public _status: DeviceStatus = { public _status: DeviceStatus = {
@ -108,18 +113,22 @@ class NetMDMockService implements NetMDService {
return `Generic MD Unit`; return `Generic MD Unit`;
} }
async finalize() {} async finalize() { }
async renameTrack(index: number, newTitle: string) { async renameTrack(index: number, newTitle: string, fullWidthTitle?: string) {
newTitle = sanitizeHalfWidthTitle(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 deleteTrack(index: number) {
@ -142,11 +151,13 @@ class NetMDMockService implements NetMDService {
title: string, title: string,
data: ArrayBuffer, data: ArrayBuffer,
format: Wireformat, format: Wireformat,
useFullWidth: boolean,
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 = sanitizeHalfWidthTitle(title); let halfWidthTitle = sanitizeHalfWidthTitle(title);
let fullWidthTitle = sanitizeFullWidthTitle(title);
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 +171,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: useFullWidth ? fullWidthTitle : '',
}); });
await sleep(1000); await sleep(1000);

View File

@ -12,7 +12,7 @@ import {
} 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, sanitizeHalfWidthTitle, sleep } from '../utils'; import { asyncMutex, sanitizeHalfWidthTitle, sanitizeFullWidthTitle, sleep } 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,8 +25,8 @@ 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>; deleteTrack(index: number): Promise<void>;
moveTrack(src: number, dst: number): Promise<void>; moveTrack(src: number, dst: number): Promise<void>;
wipeDisc(): Promise<void>; wipeDisc(): Promise<void>;
@ -34,6 +34,7 @@ export interface NetMDService {
title: string, title: string,
data: ArrayBuffer, data: ArrayBuffer,
format: Wireformat, format: Wireformat,
useFullWidth: boolean,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
): Promise<void>; ): Promise<void>;
@ -109,22 +110,46 @@ export class NetMDUSBService implements NetMDService {
} }
@asyncMutex @asyncMutex
async renameTrack(index: number, title: string) { async renameTrack(index: number, title: string, fullWidthTitle?: string) {
title = sanitizeHalfWidthTitle(title); 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();
} }
@asyncMutex @asyncMutex
async renameDisc(newName: string) { 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('');
newName = sanitizeHalfWidthTitle(newName); newName = sanitizeHalfWidthTitle(newName);
newFullWidthName = newFullWidthName && sanitizeFullWidthTitle(newFullWidthName);
if(newFullWidthName !== oldFullWidthName && newFullWidthName !== undefined){
let newFullWidthNameWithGroups;
if (hasFullWidthGroups) {
if (hasFullWidthGroupsAndTitle) {
newFullWidthNameWithGroups = oldRawFullWidthName.replace(/^.*?/, newFullWidthName !== '' ? `${newFullWidthName}` : ``);
} else {
newFullWidthNameWithGroups = `${newFullWidthName}${oldRawFullWidthName}`; // Add the new title
}
} else {
newFullWidthNameWithGroups = newFullWidthName;
}
await this.netmdInterface!.cacheTOC();
await this.netmdInterface!.setDiscTitle(newFullWidthNameWithGroups, true);
await this.netmdInterface!.syncTOC();
}
if (newName === oldName) { if (newName === oldName) {
return; return;
@ -167,6 +192,7 @@ export class NetMDUSBService implements NetMDService {
title: string, title: string,
data: ArrayBuffer, data: ArrayBuffer,
format: Wireformat, format: Wireformat,
useFullWidth: boolean,
progressCallback: (progress: { written: number; encrypted: number; total: number }) => void progressCallback: (progress: { written: number; encrypted: number; total: number }) => void
) { ) {
let total = data.byteLength; let total = data.byteLength;
@ -183,8 +209,9 @@ export class NetMDUSBService implements NetMDService {
updateProgress(); updateProgress();
}); });
title = sanitizeHalfWidthTitle(title); let halfWidthTitle = sanitizeHalfWidthTitle(title);
let mdTrack = new MDTrack(title, format, data, 0x80000, webWorkerAsyncPacketIterator); let fullWidthTitle = sanitizeFullWidthTitle(title);
let mdTrack = new MDTrack(halfWidthTitle, format, data, 0x80000, useFullWidth ? fullWidthTitle : '', webWorkerAsyncPacketIterator);
await download(this.netmdInterface!, mdTrack, ({ writtenBytes }) => { await download(this.netmdInterface!, mdTrack, ({ writtenBytes }) => {
written = writtenBytes; written = writtenBytes;

View File

@ -66,20 +66,37 @@ export function sanitizeTitle(title: string) {
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, ''); return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
} }
export function sanitizeHalfWidthTitle(title: string){ export function sanitizeHalfWidthTitle(title: string) {
const mappings: {[key: string]: string} = {'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', '': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '': '!', '': '"', '': '#', '': '$', '': '%', '': '&', '': "'", '': '(', '': ')', '': '*', '': '+', '': ',', '': '-', '': '.', '': '/', '': ':', '': ';', '': '<', '': '=', '': '>', '': '?', '': '@', '': 'A', '': 'B', '': 'C', '': 'D', '': 'E', '': 'F', '': 'G', '': 'H', '': 'I', '': 'J', '': 'K', '': 'L', '': 'M', '': 'N', '': 'O', '': 'P', '': 'Q', '': 'R', '': 'S', '': 'T', '': 'U', '': 'V', '': 'W', '': 'X', '': 'Y', '': 'Z', '': '[', '': '\\', '': ']', '': '^', '_': '_', '': '`', '': 'a', '': 'b', '': 'c', '': 'd', '': 'e', '': 'f', '': 'g', '': 'h', '': 'i', '': 'j', '': 'k', '': 'l', '': 'm', '': 'n', '': 'o', '': 'p', '': 'q', '': 'r', '': 's', '': 't', '': 'u', '': 'v', '': 'w', '': 'x', '': 'y', '': 'z', '': '{', '': '|', '': '}', '': '~', '\u3000': ' ', '': '0', '': '1', '': '2', '': '3', '': '4', '': '5', '': '6', '': '7', '': '8', '': '9'}; if (title.length > 120) title = title.substring(0, 120);
let 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}; //Dakuten kana are encoded as 2 bytes
const mappings: { [key: string]: string } = { 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', '': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '': '!', '': '"', '': '#', '': '$', '': '%', '': '&', '': "'", '': '(', '': ')', '': '*', '': '+', '': ',', '': '-', '': '.', '': '/', '': ':', '': ';', '': '<', '': '=', '': '>', '': '?', '': '@', '': 'A', '': 'B', '': 'C', '': 'D', '': 'E', '': 'F', '': 'G', '': 'H', '': 'I', '': 'J', '': 'K', '': 'L', '': 'M', '': 'N', '': 'O', '': 'P', '': 'Q', '': 'R', '': 'S', '': 'T', '': 'U', '': 'V', '': 'W', '': 'X', '': 'Y', '': 'Z', '': '[', '': '\\', '': ']', '': '^', '_': '_', '': '`', '': 'a', '': 'b', '': 'c', '': 'd', '': 'e', '': 'f', '': 'g', '': 'h', '': 'i', '': 'j', '': 'k', '': 'l', '': 'm', '': 'n', '': 'o', '': 'p', '': 'q', '': 'r', '': 's', '': 't', '': 'u', '': 'v', '': 'w', '': 'x', '': 'y', '': 'z', '': '{', '': '|', '': '}', '': '~', '\u3000': ' ', '': '0', '': '1', '': '2', '': '3', '': '4', '': '5', '': '6', '': '7', '': '8', '': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
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 }; //Dakuten kana are encoded as 2 bytes
const allowedHalfWidthKana: string[] = Object.values(mappings);
let allowedAdditionalChars = 0; let allowedAdditionalChars = 0;
const newTitle = title.split('').map(n => { const newTitle = title.split('').map(n => {
allowedAdditionalChars += (multiByteChars[n] ?? 0); allowedAdditionalChars += (multiByteChars[n] ?? 0);
return mappings[n] ?? n; if (mappings[n]) return mappings[n];
if (n.charCodeAt(0) < 0x7f || allowedHalfWidthKana.includes(n)) return n;
return ' ';
}).join(''); }).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 //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'); const sjisEncoded = jconv.encode(newTitle, 'SJIS');
if(sjisEncoded.length - allowedAdditionalChars !== title.length) return sanitizeTitle(title); //Fallback if (sjisEncoded.length - allowedAdditionalChars !== title.length) return sanitizeTitle(title); //Fallback
return newTitle; return newTitle;
} }
export function sanitizeFullWidthTitle(title: string) {
if (title.length > 100) title = title.substring(0, 100);
const mappings: { [key: string]: string} = {'!': '', '"': '', '#': '', '$': '', '%': '', '&': '', "'": '', '(': '', ')': '', '*': '', '+': '', ',': '', '-': '', '.': '', '/': '', ':': '', ';': '', '<': '', '=': '', '>': '', '?': '', '@': '', 'A': '', 'B': '', 'C': '', 'D': '', 'E': '', 'F': '', 'G': '', 'H': '', 'I': '', 'J': '', 'K': '', 'L': '', 'M': '', 'N': '', 'O': '', 'P': '', 'Q': '', 'R': '', 'S': '', 'T': '', 'U': '', 'V': '', 'W': '', 'X': '', 'Y': '', 'Z': '', '[': '', '\\': '', ']': '', '^': '', '_': '_', '`': '', 'a': '', 'b': '', 'c': '', 'd': '', 'e': '', 'f': '', 'g': '', 'h': '', 'i': '', 'j': '', 'k': '', 'l': '', 'm': '', 'n': '', 'o': '', 'p': '', 'q': '', 'r': '', 's': '', 't': '', 'u': '', 'v': '', 'w': '', 'x': '', 'y': '', 'z': '', '{': '', '|': '', '}': '', '~': '', ' ': '\u3000', '0': '', '1': '', '2': '', '3': '', '4': '', '5': '', '6': '', '7': '', '8': '', '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
return newTitle;
}
const EncodingName: { [k: number]: string } = { const EncodingName: { [k: number]: string } = {
[Encoding.sp]: 'SP', [Encoding.sp]: 'SP',
[Encoding.lp2]: 'LP2', [Encoding.lp2]: 'LP2',
@ -87,13 +104,14 @@ const EncodingName: { [k: number]: 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: { index: number; title: string; fullWidthTitle: string; group: string; duration: string; encoding: string }[] = [];
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`,
fullWidthTitle: track.fullWidthTitle ?? ``,
group: group.title ?? ``, group: group.title ?? ``,
encoding: EncodingName[track.encoding], encoding: EncodingName[track.encoding],
duration: formatTimeFromFrames(track.duration, false), duration: formatTimeFromFrames(track.duration, false),