551 lines
24 KiB
TypeScript
551 lines
24 KiB
TypeScript
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 => {
|
||
setTimeout(resolve, ms);
|
||
});
|
||
}
|
||
|
||
export function debounce<T extends Function>(func: T, timeout = 300): T {
|
||
let timer: ReturnType<typeof setTimeout>;
|
||
const debouncedFn = (...args: any) => {
|
||
clearTimeout(timer);
|
||
timer = setTimeout(() => {
|
||
func(...args);
|
||
}, timeout);
|
||
};
|
||
return (debouncedFn as any) as T;
|
||
}
|
||
|
||
export async function sleepWithProgressCallback(ms: number, cb: (perc: number) => void) {
|
||
let elapsedSecs = 1;
|
||
let interval = setInterval(() => {
|
||
elapsedSecs++;
|
||
cb(Math.min(100, ((elapsedSecs * 1000) / ms) * 100));
|
||
}, 1000);
|
||
await sleep(ms);
|
||
window.clearInterval(interval);
|
||
}
|
||
|
||
export function useShallowEqualSelector<TState = RootState, TSelected = unknown>(selector: (state: TState) => TSelected): TSelected {
|
||
return useSelector(selector, shallowEqual);
|
||
}
|
||
|
||
export function debugEnabled() {
|
||
return process.env.NODE_ENV === 'development';
|
||
}
|
||
|
||
export function getPublicPathFor(script: string) {
|
||
return `${process.env.PUBLIC_URL}/${script}`;
|
||
}
|
||
|
||
export function savePreference(key: string, value: unknown) {
|
||
localStorage.setItem(key, JSON.stringify(value));
|
||
}
|
||
|
||
export function loadPreference<T>(key: string, defaultValue: T): T {
|
||
let res = localStorage.getItem(key);
|
||
if (res === null) {
|
||
return defaultValue;
|
||
} else {
|
||
try {
|
||
return JSON.parse(res) as T;
|
||
} catch (e) {
|
||
return defaultValue;
|
||
}
|
||
}
|
||
}
|
||
|
||
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) {
|
||
return frames / 512;
|
||
}
|
||
|
||
export function timeToSeekArgs(timeInSecs: number): number[] {
|
||
let value = Math.round(timeInSecs); // ignore frames
|
||
|
||
let s = value % 60;
|
||
value = (value - s) / 60; // min
|
||
|
||
let m = value % 60;
|
||
value = (value - m) / 60; // hour
|
||
|
||
let h = value;
|
||
|
||
return [h, m, s, 0];
|
||
}
|
||
|
||
export function sanitizeTitle(title: string) {
|
||
return title.normalize('NFD').replace(/[^\x00-\x7F]/g, '');
|
||
}
|
||
|
||
export function getHalfWidthTitleLength(title: string) {
|
||
// Some characters are written as 2 bytes
|
||
// prettier-ignore
|
||
// '\u309C': -1, '\uFF9F': -1, '\u309B': -1, '\uFF9E': -1 but when they become part of a multi byte character, it will sum up to 0
|
||
const multiByteChars: { [key: string]: number } = { "ガ": 1, "ギ": 1, "グ": 1, "ゲ": 1, "ゴ": 1, "ザ": 1, "ジ": 1, "ズ": 1, "ゼ": 1, "ゾ": 1, "ダ": 1, "ヂ": 1, "ヅ": 1, "デ": 1, "ド": 1, "バ": 1, "パ": 1, "ビ": 1, "ピ": 1, "ブ": 1, "プ": 1, "ベ": 1, "ペ": 1, "ボ": 1, "ポ": 1, "ヮ": 1, "ヰ": 1, "ヱ": 1, "ヵ": 1, "ヶ": 1, "ヴ": 1, "ヽ": 1, "ヾ": 1, "が": 1, "ぎ": 1, "ぐ": 1, "げ": 1, "ご": 1, "ざ": 1, "じ": 1, "ず": 1, "ぜ": 1, "ぞ": 1, "だ": 1, "ぢ": 1, "づ": 1, "で": 1, "ど": 1, "ば": 1, "ぱ": 1, "び": 1, "ぴ": 1, "ぶ": 1, "ぷ": 1, "べ": 1, "ぺ": 1, "ぼ": 1, "ぽ": 1, "ゎ": 1, "ゐ": 1, "ゑ": 1, "ゕ": 1, "ゖ": 1, "ゔ": 1, "ゝ": 1, "ゞ": 1 };
|
||
return (
|
||
title.length +
|
||
title
|
||
.split('')
|
||
.map(n => multiByteChars[n] ?? 0)
|
||
.reduce((a, b) => a + b, 0)
|
||
);
|
||
}
|
||
|
||
export function sanitizeHalfWidthTitle(title: string) {
|
||
enum CharType {
|
||
normal,
|
||
dakuten,
|
||
handakuten,
|
||
}
|
||
|
||
const handakutenPossible = 'はひふへほハヒフヘホ'.split('');
|
||
const dakutenPossible = 'かきくけこさしすせそたちつてとカキクケコサシスセソタチツテト'.split('').concat(handakutenPossible);
|
||
|
||
//'Flatten' all the characters followed by the (han)dakuten character into one
|
||
let dakutenFix = [];
|
||
let type = CharType.normal;
|
||
for (const char of sanitizeFullWidthTitle(title, true)
|
||
.split('')
|
||
.reverse()) {
|
||
//This only works for full-width kana. It will get converted to half-width later anyway...
|
||
switch (type) {
|
||
case CharType.dakuten:
|
||
if (dakutenPossible.includes(char)) {
|
||
dakutenFix.push(String.fromCharCode(char.charCodeAt(0) + 1));
|
||
type = CharType.normal;
|
||
break;
|
||
} //Else fall through
|
||
case CharType.handakuten:
|
||
if (handakutenPossible.includes(char)) {
|
||
dakutenFix.push(String.fromCharCode(char.charCodeAt(0) + 2));
|
||
type = CharType.normal;
|
||
break;
|
||
} //Else fall through
|
||
case CharType.normal:
|
||
switch (char) {
|
||
case '\u309B':
|
||
case '\u3099':
|
||
case '\uFF9E':
|
||
type = CharType.dakuten;
|
||
break;
|
||
case '\u309C':
|
||
case '\u309A':
|
||
case '\uFF9F':
|
||
type = CharType.handakuten;
|
||
break;
|
||
default:
|
||
type = CharType.normal;
|
||
dakutenFix.push(char);
|
||
break;
|
||
}
|
||
break;
|
||
}
|
||
}
|
||
|
||
title = dakutenFix.reverse().join('');
|
||
// prettier-ignore
|
||
const mappings: { [key: string]: string } = { '-': '-', 'ー': '-', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': '-', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、', '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', ''': "'", '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'O': 'O', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'U': 'U', 'V': 'V', 'W': 'W', 'X': 'X', 'Y': 'Y', 'Z': 'Z', '[': '[', '\': '\\', ']': ']', '^': '^', '_': '_', '`': '`', 'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'l', 'm': 'm', 'n': 'n', 'o': 'o', 'p': 'p', 'q': 'q', 'r': 'r', 's': 's', 't': 't', 'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z', '{': '{', '|': '|', '}': '}', '~': '~', '\u3000': ' ', '0': '0', '1': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', 'ぁ': 'ァ', 'あ': 'ア', 'ぃ': 'ィ', 'い': 'イ', 'ぅ': 'ゥ', 'う': 'ウ', 'ぇ': 'ェ', 'え': 'エ', 'ぉ': 'ォ', 'お': 'オ', 'か': 'カ', 'が': 'ガ', 'き': 'キ', 'ぎ': 'ギ', 'く': 'ク', 'ぐ': 'グ', 'け': 'ケ', 'げ': 'ゲ', 'こ': 'コ', 'ご': 'ゴ', 'さ': 'サ', 'ざ': 'ザ', 'し': 'シ', 'じ': 'ジ', 'す': 'ス', 'ず': 'ズ', 'せ': 'セ', 'ぜ': 'ゼ', 'そ': 'ソ', 'ぞ': 'ゾ', 'た': 'タ', 'だ': 'ダ', 'ち': 'チ', 'ぢ': 'ヂ', 'っ': 'ッ', 'つ': 'ツ', 'づ': 'ヅ', 'て': 'テ', 'で': 'デ', 'と': 'ト', 'ど': 'ド', 'な': 'ナ', 'に': 'ニ', 'ぬ': 'ヌ', 'ね': 'ネ', 'の': 'ノ', 'は': 'ハ', 'ば': 'バ', 'ぱ': 'パ', 'ひ': 'ヒ', 'び': 'ビ', 'ぴ': 'ピ', 'ふ': 'フ', 'ぶ': 'ブ', 'ぷ': 'プ', 'へ': 'ヘ', 'べ': 'ベ', 'ぺ': 'ペ', 'ほ': 'ホ', 'ぼ': 'ボ', 'ぽ': 'ポ', 'ま': 'マ', 'み': 'ミ', 'む': 'ム', 'め': 'メ', 'も': 'モ', 'ゃ': 'ャ', 'や': 'ヤ', 'ゅ': 'ュ', 'ゆ': 'ユ', 'ょ': 'ョ', 'よ': 'ヨ', 'ら': 'ラ', 'り': 'リ', 'る': 'ル', 'れ': 'レ', 'ろ': 'ロ', 'わ': 'ワ', 'を': 'ヲ', 'ん': 'ン', 'ゎ': 'ヮ', 'ゐ': 'ヰ', 'ゑ': 'ヱ', 'ゕ': 'ヵ', 'ゖ': 'ヶ', 'ゔ': 'ヴ', 'ゝ': 'ヽ', 'ゞ': 'ヾ' };
|
||
const 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, justRemap: boolean = false) {
|
||
// prettier-ignore
|
||
const mappings: { [key: string]: string } = { '!': '!', '"': '"', '#': '#', '$': '$', '%': '%', '&': '&', "'": ''', '(': '(', ')': ')', '*': '*', '+': '+', ',': ',', '-': '-', '.': '.', '/': '/', ':': ':', ';': ';', '<': '<', '=': '=', '>': '>', '?': '?', '@': '@', 'A': 'A', 'B': 'B', 'C': 'C', 'D': 'D', 'E': 'E', 'F': 'F', 'G': 'G', 'H': 'H', 'I': 'I', 'J': 'J', 'K': 'K', 'L': 'L', 'M': 'M', 'N': 'N', 'O': 'O', 'P': 'P', 'Q': 'Q', 'R': 'R', 'S': 'S', 'T': 'T', 'U': 'U', 'V': 'V', 'W': 'W', 'X': 'X', 'Y': 'Y', 'Z': 'Z', '[': '[', '\\': '\', ']': ']', '^': '^', '_': '_', '`': '`', 'a': 'a', 'b': 'b', 'c': 'c', 'd': 'd', 'e': 'e', 'f': 'f', 'g': 'g', 'h': 'h', 'i': 'i', 'j': 'j', 'k': 'k', 'l': 'l', 'm': 'm', 'n': 'n', 'o': 'o', 'p': 'p', 'q': 'q', 'r': 'r', 's': 's', 't': 't', 'u': 'u', 'v': 'v', 'w': 'w', 'x': 'x', 'y': 'y', 'z': 'z', '{': '{', '|': '|', '}': '}', '~': '~', ' ': '\u3000', '0': '0', '1': '1', '2': '2', '3': '3', '4': '4', '5': '5', '6': '6', '7': '7', '8': '8', '9': '9', 'ァ': 'ァ', 'ア': 'ア', 'ィ': 'ィ', 'イ': 'イ', 'ゥ': 'ゥ', 'ウ': 'ウ', 'ェ': 'ェ', 'エ': 'エ', 'ォ': 'ォ', 'オ': 'オ', 'カ': 'カ', 'ガ': 'ガ', 'キ': 'キ', 'ギ': 'ギ', 'ク': 'ク', 'グ': 'グ', 'ケ': 'ケ', 'ゲ': 'ゲ', 'コ': 'コ', 'ゴ': 'ゴ', 'サ': 'サ', 'ザ': 'ザ', 'シ': 'シ', 'ジ': 'ジ', 'ス': 'ス', 'ズ': 'ズ', 'セ': 'セ', 'ゼ': 'ゼ', 'ソ': 'ソ', 'ゾ': 'ゾ', 'タ': 'タ', 'ダ': 'ダ', 'チ': 'チ', 'ヂ': 'ヂ', 'ッ': 'ッ', 'ツ': 'ツ', 'ヅ': 'ヅ', 'テ': 'テ', 'デ': 'デ', 'ト': 'ト', 'ド': 'ド', 'ナ': 'ナ', 'ニ': 'ニ', 'ヌ': 'ヌ', 'ネ': 'ネ', 'ノ': 'ノ', 'ハ': 'ハ', 'バ': 'バ', 'パ': 'パ', 'ヒ': 'ヒ', 'ビ': 'ビ', 'ピ': 'ピ', 'フ': 'フ', 'ブ': 'ブ', 'プ': 'プ', 'ヘ': 'ヘ', 'ベ': 'ベ', 'ペ': 'ペ', 'ホ': 'ホ', 'ボ': 'ボ', 'ポ': 'ポ', 'マ': 'マ', 'ミ': 'ミ', 'ム': 'ム', 'メ': 'メ', 'モ': 'モ', 'ャ': 'ャ', 'ヤ': 'ヤ', 'ュ': 'ュ', 'ユ': 'ユ', 'ョ': 'ョ', 'ヨ': 'ヨ', 'ラ': 'ラ', 'リ': 'リ', 'ル': 'ル', 'レ': 'レ', 'ロ': 'ロ', 'ワ': 'ワ', 'ヲ': 'ヲ', 'ン': 'ン', 'ー': 'ー', 'ヮ': 'ヮ', 'ヰ': 'ヰ', 'ヱ': 'ヱ', 'ヵ': 'ヵ', 'ヶ': 'ヶ', 'ヴ': 'ヴ', 'ヽ': 'ヽ', 'ヾ': 'ヾ', '・': '・', '「': '「', '」': '」', '。': '。', '、': '、' };
|
||
|
||
const newTitle = title
|
||
.split('')
|
||
.map(n => mappings[n] ?? n)
|
||
.join('');
|
||
|
||
if (justRemap) return newTitle;
|
||
|
||
const sjisEncoded = jconv.encode(newTitle, 'SJIS');
|
||
if (jconv.decode(sjisEncoded, 'SJIS') !== newTitle) return sanitizeTitle(title); // Fallback
|
||
if (sjisEncoded.length !== title.length * 2) return sanitizeTitle(title); // Fallback (every character in the full-width title is 2 bytes)
|
||
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;
|
||
durationInSecs: number;
|
||
};
|
||
|
||
export function getSortedTracks(disc: Disc | null) {
|
||
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`,
|
||
fullWidthTitle: track.fullWidthTitle ?? ``,
|
||
group: group.title ?? null,
|
||
encoding: EncodingName[track.encoding],
|
||
duration: formatTimeFromFrames(track.duration, false),
|
||
durationInSecs: track.duration / 512, // CAVEAT: 1s = 512 frames
|
||
});
|
||
}
|
||
}
|
||
}
|
||
tracks.sort((l, r) => l.index - r.index);
|
||
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 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;
|
||
descriptor.value = async function(...args: any) {
|
||
const mutex = (this as any).mutex as Mutex;
|
||
const release = await mutex.acquire();
|
||
try {
|
||
return await oldValue.apply(this, args);
|
||
} finally {
|
||
release();
|
||
}
|
||
};
|
||
return descriptor;
|
||
}
|
||
|
||
export function forAnyDesktop(theme: Theme) {
|
||
return theme.breakpoints.up(600 + theme.spacing(2) * 2);
|
||
}
|
||
|
||
export function belowDesktop(theme: Theme) {
|
||
return theme.breakpoints.down(600 + theme.spacing(2) * 2);
|
||
}
|
||
|
||
export function forWideDesktop(theme: Theme) {
|
||
return theme.breakpoints.up(700 + theme.spacing(2) * 2) + ` and (min-height: 750px)`;
|
||
}
|
||
|
||
export function askNotificationPermission(): Promise<NotificationPermission> {
|
||
// Adapted from: https://developer.mozilla.org/en-US/docs/Web/API/Notifications_API/Using_the_Notifications_API
|
||
function checkNotificationPromise() {
|
||
try {
|
||
Notification.requestPermission().then();
|
||
} catch (e) {
|
||
return false;
|
||
}
|
||
return true;
|
||
}
|
||
|
||
if (checkNotificationPromise()) {
|
||
return Notification.requestPermission();
|
||
} else {
|
||
return new Promise(resolve => Notification.requestPermission(resolve));
|
||
}
|
||
}
|
||
|
||
export async function getAtrac3Info(file: File) {
|
||
// see: http://soundfile.sapp.org/doc/WaveFormat/
|
||
// and: https://www.fatalerrors.org/a/detailed-explanation-of-wav-file-format.html
|
||
|
||
const fileData = await file.arrayBuffer();
|
||
if (fileData.byteLength < 44) {
|
||
return null;
|
||
}
|
||
|
||
const riffDescriptor = new Uint32Array(fileData.slice(0, 12));
|
||
if (riffDescriptor[0] !== 0x46464952 || riffDescriptor[2] !== 0x45564157) {
|
||
// 'RIFF' && 'WAVE'
|
||
return null;
|
||
}
|
||
|
||
// WAVE format
|
||
const waveDescriptor = new Uint32Array(fileData.slice(12, 20));
|
||
if (waveDescriptor[0] !== 0x20746d66) {
|
||
return false;
|
||
}
|
||
|
||
const audioFormatAndChanneld = new Uint16Array(fileData.slice(20, 24));
|
||
if (audioFormatAndChanneld[0] !== 0x270 || audioFormatAndChanneld[1] !== 2) {
|
||
// 'atrac3' && 2 channels
|
||
return null;
|
||
}
|
||
|
||
const sampleRateAndByteRate = new Uint32Array(fileData.slice(24, 32));
|
||
if (sampleRateAndByteRate[0] !== 44100) {
|
||
// Sample rate
|
||
return null;
|
||
}
|
||
const byteRate = sampleRateAndByteRate[1];
|
||
|
||
let mode: 'LP2' | 'LP105' | 'LP4' | null = null;
|
||
if (byteRate > 16000) {
|
||
mode = 'LP2';
|
||
} else if (byteRate > 13000) {
|
||
mode = 'LP105';
|
||
} else if (byteRate > 8000) {
|
||
mode = 'LP4';
|
||
} else {
|
||
mode = null;
|
||
}
|
||
|
||
if (mode === null) {
|
||
return null;
|
||
}
|
||
|
||
const waveBlockEndOffset = new Uint16Array(fileData.slice(36, 38));
|
||
|
||
let dataOffset = -1;
|
||
|
||
const nextBlockStartOffset = waveBlockEndOffset[0] + 38;
|
||
const nextBlockEndOffset = nextBlockStartOffset + 8;
|
||
const nextBlock = new Uint32Array(fileData.slice(nextBlockStartOffset, nextBlockEndOffset));
|
||
if (nextBlock[0] === 0x61746164) {
|
||
// data
|
||
dataOffset = nextBlockEndOffset;
|
||
} else if (nextBlock[0] === 0x74636166) {
|
||
// fact
|
||
const dataBlockLength = 8;
|
||
dataOffset = nextBlockEndOffset + nextBlock[1] + dataBlockLength;
|
||
}
|
||
|
||
if (dataOffset === -1) {
|
||
return null;
|
||
}
|
||
|
||
return {
|
||
mode,
|
||
dataOffset,
|
||
};
|
||
}
|
||
|
||
declare let process: any;
|