diff --git a/.eslintrc.js b/.eslintrc.js index 358da1a..4ef67d3 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -25,6 +25,7 @@ module.exports = { 'no-unused-vars': 'off', '@typescript-eslint/no-unused-vars': 'error', + 'import/prefer-default-export': 'off', 'import/no-extraneous-dependencies': 'off', 'no-restricted-syntax': 'off', 'no-continue': 'off', @@ -36,5 +37,15 @@ module.exports = { tsx: 'never', }, ], + 'prefer-destructuring': [ + 'error', + { + array: false, + object: true, + }, + { + enforceForRenamedProperties: false, + }, + ], }, }; diff --git a/.gitignore b/.gitignore index f0981ac..2add8e1 100644 --- a/.gitignore +++ b/.gitignore @@ -8,3 +8,4 @@ dist/ # parcel .cache/ +/src/unparse.ts \ No newline at end of file diff --git a/README.md b/README.md index 7d58fc5..ddee4ea 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # clrc [![version](https://img.shields.io/npm/v/clrc)](https://www.npmjs.com/package/clrc) [![license](https://img.shields.io/npm/l/clrc)](https://github.com/mebtte/react-lrc/blob/master/LICENSE) [![](https://img.shields.io/bundlephobia/minzip/clrc)](https://bundlephobia.com/result?p=clrc) -LRC format parser for JavaScript/TypeScript. Here is a [playground](https://mebtte.github.io/clrc). +Parser for LRC and enhanced LRC. Here is a [playground](https://mebtte.github.io/clrc). [2.x README](https://github.com/mebtte/clrc/blob/5c6efcbbfe08d4021e0a7d6252088c5deca428f7/README.md) @@ -8,6 +8,7 @@ LRC format parser for JavaScript/TypeScript. Here is a [playground](https://mebt - Typescript support - Browser & Node.js support +- Simple format and enhanced format support ## Install & Usage @@ -65,13 +66,100 @@ The output is: parse lrc string to array. +### parseEnhanced(lrcString) + +parse enhanced lrc string to array. here is a example: + +```txt +[ti: Somebody to Love] +[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies +``` + +the output is: + +```json +[ + { + "lineNumber": 0, + "raw": "[ti: Somebody to Love]", + "type": "metadata", + "key": "ti", + "value": " Somebody to Love" + }, + { + "lineNumber": 1, + "raw": "[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies ", + "type": "enhanced_lyric", + "startMillisecond": 0, + "content": " <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies ", + "words": [ + { + "index": 0, + "raw": "<00:00.04> When ", + "startMillisecond": 40, + "content": " When " + }, + { + "index": 1, + "raw": "<00:00.16> the ", + "startMillisecond": 160, + "content": " the " + }, + { + "index": 2, + "raw": "<00:00.82> truth ", + "startMillisecond": 820, + "content": " truth " + }, + { + "index": 3, + "raw": "<00:01.29> is ", + "startMillisecond": 1290, + "content": " is " + }, + { + "index": 4, + "raw": "<00:01.63> found ", + "startMillisecond": 1630, + "content": " found " + }, + { + "index": 5, + "raw": "<00:03.09> to ", + "startMillisecond": 3090, + "content": " to " + }, + { + "index": 6, + "raw": "<00:03.37> be ", + "startMillisecond": 3370, + "content": " be " + }, + { + "index": 7, + "raw": "<00:05.92> lies ", + "startMillisecond": 5920, + "content": " lies " + } + ] + } +] +``` + ### LineType types of line: - `LineType.INVALID` means it's invalid line -- `LineType.LYRIC` means it's lyric line - `LineType.METADATA` means it's metadata line +- `LineType.LYRIC` means it's lyric line +- `LineType.ENHANCED_LYRIC` means it's enhanced lyric line + +## Contributors + + + + ## License diff --git a/package.json b/package.json index ad36114..abea49a 100644 --- a/package.json +++ b/package.json @@ -7,7 +7,7 @@ "dev": "tsc --declaration --watch", "build": "tsc --declaration", "lint-staged": "lint-staged", - "prepare": "husky install", + "prepare": "husky install && npm run build", "prepublish": "npm run build" }, "repository": { @@ -30,6 +30,7 @@ }, "homepage": "https://github.com/mebtte/clrc#readme", "devDependencies": { + "@types/node": "^18.15.12", "@typescript-eslint/eslint-plugin": "^5.36.1", "@typescript-eslint/parser": "^5.36.1", "eslint": "^8.23.0", diff --git a/playground/package.json b/playground/package.json index 6cc1bc3..569cef7 100644 --- a/playground/package.json +++ b/playground/package.json @@ -6,13 +6,13 @@ "@types/react": "^18.0.18", "@types/react-dom": "^18.0.6", "@types/styled-components": "^5.1.26", + "clrc": "file:..", "react": "^18.2.0", "react-dom": "^18.2.0", "react-json-view": "^1.21.3", "react-scripts": "5.0.1", "styled-components": "^5.3.5", - "typescript": "^4.8.2", - "clrc": "file:.." + "typescript": "^4.8.2" }, "scripts": { "dev": "react-scripts start", diff --git a/playground/src/app.tsx b/playground/src/app.tsx index 802d469..0532650 100644 --- a/playground/src/app.tsx +++ b/playground/src/app.tsx @@ -1,9 +1,10 @@ -import React, { useState } from 'react'; +import React, { useDeferredValue, useEffect, useState } from 'react'; import styled from 'styled-components'; -import demoLrc from './lrc'; +import Github from './github'; import GlobalStyle from './global_style'; import JsonView from './json_view'; -import Github from './github'; +import { lrc as lrcDemo, enhancedLrc as enhancedLrcDemo } from './data'; +import Option from './option'; const Style = styled.div` position: absolute; @@ -13,10 +14,18 @@ const Style = styled.div` left: 0; display: flex; + + > .editor { + flex: 1; + min-width: 0; + + display: flex; + flex-direction: column; + } `; const Textarea = styled.textarea` flex: 1; - min-width: 0; + min-height: 0; padding: 10px; @@ -29,16 +38,26 @@ const Textarea = styled.textarea` `; const App = () => { - const [lrc, setLrc] = useState(demoLrc); + const [enhanced, setEnhanced] = useState(false); + + const [lrc, setLrc] = useState(enhanced ? enhancedLrcDemo : lrcDemo); const onLrcChange = (event: React.ChangeEvent) => setLrc(event.target.value); + useEffect(() => { + setLrc(enhanced ? enhancedLrcDemo : lrcDemo); + }, [enhanced]); + + const deferedLrc = useDeferredValue(lrc); return ( <> diff --git a/playground/src/lrc.ts b/playground/src/data.ts similarity index 76% rename from playground/src/lrc.ts rename to playground/src/data.ts index 5ba63bd..10c4792 100644 --- a/playground/src/lrc.ts +++ b/playground/src/data.ts @@ -1,4 +1,4 @@ -const lrc = `something wrong +export const lrc = `something wrong [by:mebtte] [ar:Greyson Chance] [01:17.62][00:51.80][01:43.27]Come with me for a little ride, see the shadows passing by @@ -30,4 +30,10 @@ const lrc = `something wrong [03:59.39]Come away with me, it's gonna be all right you'll see, you'll see [04:24.46]Come away with me`; -export default lrc; +// from https://en.wikipedia.org/wiki/LRC_(file_format)#Enhanced_format|Enhanced%20LRC +export const enhancedLrc = `[by: lrc-maker] +[ti: Somebody to Love] + +[00:00.00] <00:00.04> When <00:00.16> the <00:00.82> truth <00:01.29> is <00:01.63> found <00:03.09> to <00:03.37> be <00:05.92> lies +[00:06.47] <00:07.67> And <00:07.94> all <00:08.36> the <00:08.63> joy <00:10.28> within <00:10.53> you <00:13.09> dies +[00:13.34] <00:14.32> Don't <00:14.73> you <00:15.14> want <00:15.57> somebody <00:16.09> to <00:16.46> love`; diff --git a/playground/src/json_view.tsx b/playground/src/json_view.tsx index 1ecf3a7..114560f 100644 --- a/playground/src/json_view.tsx +++ b/playground/src/json_view.tsx @@ -1,7 +1,7 @@ -import { useMemo, useDeferredValue } from 'react'; +import { useMemo } from 'react'; import styled from 'styled-components'; import JsonView from 'react-json-view'; -import { parse } from 'clrc'; +import { parse, parseEnhanced } from 'clrc'; const Style = styled.div` padding: 10px; @@ -13,9 +13,11 @@ const Style = styled.div` background-color: rgb(222 222 222 / 0.2); `; -const Wrapper = ({ lrc }: { lrc: string }) => { - const deferedLrc = useDeferredValue(lrc); - const parsed = useMemo(() => parse(deferedLrc), [deferedLrc]); +const Wrapper = ({ lrc, enhanced }: { lrc: string; enhanced: boolean }) => { + const parsed = useMemo( + () => (enhanced ? parseEnhanced(lrc) : parse(lrc)), + [lrc, enhanced] + ); return ( + ); +} + +export default Option; diff --git a/src/constants.ts b/src/constants.ts index 70862a5..00736ef 100644 --- a/src/constants.ts +++ b/src/constants.ts @@ -1,8 +1,9 @@ // eslint-disable-next-line no-shadow export enum LineType { INVALID = 'invalid', - LYRIC = 'lyric', METADATA = 'metadata', + LYRIC = 'lyric', + ENHANCED_LYRIC = 'enhanced_lyric', } export interface Line { @@ -26,3 +27,15 @@ export interface LyricLine extends Line { export interface InvalidLine extends Line { type: LineType.INVALID; } + +export interface EnhancedWord { + index: number; + raw: string; + startMillisecond: number; + content: string; +} + +export interface EnhancedLyricLine extends Omit { + type: LineType.ENHANCED_LYRIC; + words: EnhancedWord[]; +} diff --git a/src/index.ts b/src/index.ts index bfba188..dcedcf1 100644 --- a/src/index.ts +++ b/src/index.ts @@ -1,4 +1,21 @@ -import { LineType, Line, MetadataLine, LyricLine } from './constants'; +import { + LineType, + Line, + MetadataLine, + LyricLine, + EnhancedLyricLine, + EnhancedWord, +} from './constants'; import parse from './parse'; +import parseEnhanced from './parse_enhanced'; -export { parse, LineType, Line, MetadataLine, LyricLine }; +export { + parse, + parseEnhanced, + LineType, + Line, + MetadataLine, + LyricLine, + EnhancedLyricLine, + EnhancedWord, +}; diff --git a/src/parse.ts b/src/parse.ts index d4b01f7..7a4a1b2 100644 --- a/src/parse.ts +++ b/src/parse.ts @@ -1,19 +1,16 @@ -import { LineType, Line, MetadataLine, LyricLine } from './constants'; +import { LineType, MetadataLine, LyricLine, InvalidLine } from './constants'; +import { timestampToMillsecond } from './utils'; -/** - * allow multiple time tag - * [time][time]content - */ -const LYRIC_LINE = /^((?:\[\d+:\d+(?:\.\d+)?\])+)(.*)$/; +const LYRIC_LINE = /^((?:\[\d{2,}:\d{2}(?:\.\d{2,3})?\])+)(.*)$/; // [time]content | [time][time][...]content const METADATA_LINE = /^\[(.+?):(.*?)\]$/; // [key:value] -const LYRIC_TIME = /^(\d+):(\d+)(?:\.(\d+))?$/; // 00:00.00 or 00:00 /** * parse lrc string * @author mebtte */ function parse(lrc: string) { - const parsedLines: Line[] = []; + const parsedLines: (InvalidLine | MetadataLine | LyricLine)[] = + []; const lines = lrc.split('\n'); for (let i = 0, { length } = lines; i < length; i += 1) { @@ -25,19 +22,12 @@ function parse(lrc: string) { const timesPart = lyricMatch[1]; // [time][time]content --> [time][time] const times = timesPart.split(']['); // [time1][time2] --> [time1 | time2] for (const time of times) { - const timeMatch = time.replace(/(\[|\])/g, '').match(LYRIC_TIME); - const minute = timeMatch[1]; - const second = timeMatch[2]; - const centisecond = timeMatch[3] || '00'; // compatible with [00:00] - const centisecondNumber = - centisecond.length === 3 ? +centisecond : +centisecond * 10; // // compatible with [00:00.000] const lyricLine: LyricLine = { lineNumber: i, raw, type: LineType.LYRIC, - startMillisecond: - +minute * 60 * 1000 + +second * 1000 + centisecondNumber, + startMillisecond: timestampToMillsecond(time.replace(/[[\]]/g, '')), content: lyricMatch[2], }; parsedLines.push(lyricLine); diff --git a/src/parse_enhanced.ts b/src/parse_enhanced.ts new file mode 100644 index 0000000..8fb214d --- /dev/null +++ b/src/parse_enhanced.ts @@ -0,0 +1,89 @@ +import { + EnhancedLyricLine, + InvalidLine, + LineType, + MetadataLine, + EnhancedWord, +} from './constants'; +import parse from './parse'; +import { timestampToMillsecond } from './utils'; + +/** + * <00:00.000> + * <00:00.00> + * <00:00> + */ +const ENHANCED_TIME = /<\d{2,}:\d{2}(?:\.(?:\d{2,3}))?>/g; + +function parseEnhanced( + lrc: string +): (InvalidLine | MetadataLine | EnhancedLyricLine)[] { + const lines = parse(lrc); + return lines.map((line) => { + if (line.type === LineType.LYRIC) { + const words: EnhancedWord[] = []; + + const wordTimeTagMatch = line.content.match(ENHANCED_TIME); + + if (!wordTimeTagMatch) { + /** + * no time tag but having content is invalid + * like [time] xxxxx + * @author mebtte + */ + if (line.content.trim().length) { + return { + type: LineType.INVALID, + lineNumber: line.lineNumber, + raw: line.raw, + } as InvalidLine; + } + } else { + const wordContents = line.content.split(ENHANCED_TIME); + + /** + * ignore first emptiness or only-space + * @author mebtte + */ + if (wordContents[0].trim().length === 0) { + wordContents.shift(); + } + + /** + * time tag' length should equal to content's length + * @author mebtte + */ + if (wordTimeTagMatch.length !== wordContents.length) { + return { + type: LineType.INVALID, + lineNumber: line.lineNumber, + raw: line.raw, + } as InvalidLine; + } + + for (let i = 0; i < wordTimeTagMatch.length; i += 1) { + const timestamp = wordTimeTagMatch[i]; + const content = wordContents[i]; + words.push({ + index: i, + raw: `${timestamp}${content}`, + startMillisecond: timestampToMillsecond( + timestamp.replace(/[<>]/g, '') + ), + content, + }); + } + } + + return { + ...line, + type: LineType.ENHANCED_LYRIC, + words, + }; + } + + return line; + }); +} + +export default parseEnhanced; diff --git a/src/utils.ts b/src/utils.ts new file mode 100644 index 0000000..bbfc6bf --- /dev/null +++ b/src/utils.ts @@ -0,0 +1,11 @@ +const TIMESTAMP = /^(\d{2,}):(\d{2})(?:\.(\d{2,3}))?$/; // 00:00.000 | 00:00.00 | 00:00 + +export function timestampToMillsecond(timestamp: string) { + const timeMatch = timestamp.match(TIMESTAMP); + const minute = timeMatch[1]; + const second = timeMatch[2]; + const centisecond = timeMatch[3] || '00'; // compatible with [00:00] + const centisecondNumber = + centisecond.length === 3 ? +centisecond : +centisecond * 10; // // compatible with [00:00.000] + return +minute * 60 * 1000 + +second * 1000 + centisecondNumber; +} diff --git a/tsconfig.json b/tsconfig.json index 10c508d..e11d32e 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -2,8 +2,6 @@ "compilerOptions": { "lib": ["ES2015"], "outDir": "dist", - "jsx": "react", - "esModuleInterop": true, "skipLibCheck": true }, "exclude": ["./playground", "./dist"]