lrc-audio-player 0.1.1 → 0.1.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/react.cjs ADDED
@@ -0,0 +1,335 @@
1
+ "use strict";
2
+ "use client";
3
+ var __defProp = Object.defineProperty;
4
+ var __getOwnPropDesc = Object.getOwnPropertyDescriptor;
5
+ var __getOwnPropNames = Object.getOwnPropertyNames;
6
+ var __hasOwnProp = Object.prototype.hasOwnProperty;
7
+ var __export = (target, all) => {
8
+ for (var name in all)
9
+ __defProp(target, name, { get: all[name], enumerable: true });
10
+ };
11
+ var __copyProps = (to, from, except, desc) => {
12
+ if (from && typeof from === "object" || typeof from === "function") {
13
+ for (let key of __getOwnPropNames(from))
14
+ if (!__hasOwnProp.call(to, key) && key !== except)
15
+ __defProp(to, key, { get: () => from[key], enumerable: !(desc = __getOwnPropDesc(from, key)) || desc.enumerable });
16
+ }
17
+ return to;
18
+ };
19
+ var __toCommonJS = (mod) => __copyProps(__defProp({}, "__esModule", { value: true }), mod);
20
+
21
+ // src/react.ts
22
+ var react_exports = {};
23
+ __export(react_exports, {
24
+ useLyricPlayer: () => useLyricPlayer
25
+ });
26
+ module.exports = __toCommonJS(react_exports);
27
+ var import_react = require("react");
28
+
29
+ // src/parser.ts
30
+ var TIME_TAG = /\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/g;
31
+ var META_TAG = /^\[([a-zA-Z]+):(.*)\]$/;
32
+ var WORD_TAG = /<(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?>/g;
33
+ var KNOWN_META_KEYS = {
34
+ ti: "title",
35
+ ar: "artist",
36
+ al: "album",
37
+ au: "author",
38
+ by: "author",
39
+ offset: "offset"
40
+ };
41
+ function timeToSeconds(min, sec, frac) {
42
+ const fraction = frac ? Number(`0.${frac}`) : 0;
43
+ return Number(min) * 60 + Number(sec) + fraction;
44
+ }
45
+ function parseTokens(raw, lineTime) {
46
+ if (!WORD_TAG.test(raw)) {
47
+ return { text: raw.trim() };
48
+ }
49
+ WORD_TAG.lastIndex = 0;
50
+ const tokens = [];
51
+ let lastIndex = 0;
52
+ let lastTime = lineTime;
53
+ let match;
54
+ let plain = "";
55
+ while (match = WORD_TAG.exec(raw)) {
56
+ const chunk = raw.slice(lastIndex, match.index);
57
+ if (chunk.length) {
58
+ tokens.push({ time: lastTime, text: chunk });
59
+ plain += chunk;
60
+ }
61
+ lastTime = timeToSeconds(match[1], match[2], match[3]);
62
+ lastIndex = WORD_TAG.lastIndex;
63
+ }
64
+ const tail = raw.slice(lastIndex);
65
+ if (tail.length) {
66
+ tokens.push({ time: lastTime, text: tail });
67
+ plain += tail;
68
+ }
69
+ return { text: plain.trim(), tokens };
70
+ }
71
+ function parseLRC(lrc) {
72
+ const metadata = {};
73
+ const lines = [];
74
+ const rawLines = lrc.split(/\r?\n/);
75
+ for (const rawLine of rawLines) {
76
+ const trimmed = rawLine.trim();
77
+ if (!trimmed) continue;
78
+ const metaMatch = trimmed.match(META_TAG);
79
+ if (metaMatch && !TIME_TAG.test(trimmed)) {
80
+ const key = metaMatch[1].toLowerCase();
81
+ const value = metaMatch[2].trim();
82
+ const knownKey = KNOWN_META_KEYS[key];
83
+ if (knownKey === "offset") {
84
+ metadata.offset = Number(value);
85
+ } else if (knownKey) {
86
+ metadata[knownKey] = value;
87
+ } else {
88
+ metadata[key] = value;
89
+ }
90
+ TIME_TAG.lastIndex = 0;
91
+ continue;
92
+ }
93
+ TIME_TAG.lastIndex = 0;
94
+ const times = [];
95
+ let rest = trimmed;
96
+ let leadingMatch;
97
+ while (leadingMatch = rest.match(/^\[(\d{1,2}):(\d{2})(?:[.:](\d{1,3}))?\]/)) {
98
+ times.push(
99
+ timeToSeconds(leadingMatch[1], leadingMatch[2], leadingMatch[3])
100
+ );
101
+ rest = rest.slice(leadingMatch[0].length);
102
+ }
103
+ if (times.length === 0) {
104
+ continue;
105
+ }
106
+ for (const time of times) {
107
+ const { text, tokens } = parseTokens(rest, time);
108
+ lines.push({ time, text, tokens });
109
+ }
110
+ }
111
+ lines.sort((a, b) => a.time - b.time);
112
+ return { metadata, lines };
113
+ }
114
+ function parseJSONLyrics(json) {
115
+ const lines = typeof json === "string" ? JSON.parse(json) : json;
116
+ const sorted = [...lines].sort((a, b) => a.time - b.time);
117
+ return { metadata: {}, lines: sorted };
118
+ }
119
+
120
+ // src/player.ts
121
+ var LyricPlayer = class {
122
+ constructor(options) {
123
+ this.currentIndex = -1;
124
+ this.listeners = {};
125
+ this.handleTimeUpdate = () => {
126
+ const time = this.audio.currentTime;
127
+ const newIndex = this.findLineIndexAtTime(time);
128
+ if (newIndex !== this.currentIndex) {
129
+ this.currentIndex = newIndex;
130
+ this.emit("linechange", this.getCurrentLine(), newIndex);
131
+ }
132
+ this.emit("timeupdate", time);
133
+ };
134
+ this.audio = typeof options.audio === "string" ? new Audio(options.audio) : options.audio;
135
+ const parsed = this.resolveLyrics(options.lyrics);
136
+ this.metadata = parsed.metadata;
137
+ this.lines = parsed.lines;
138
+ const tagOffsetMs = parsed.metadata.offset ?? 0;
139
+ this.offsetSeconds = (tagOffsetMs + (options.offsetMs ?? 0)) / 1e3;
140
+ this.audio.addEventListener("timeupdate", this.handleTimeUpdate);
141
+ this.audio.addEventListener("play", () => this.emit("play"));
142
+ this.audio.addEventListener("pause", () => this.emit("pause"));
143
+ this.audio.addEventListener("ended", () => this.emit("ended"));
144
+ this.audio.addEventListener("error", (e) => this.emit("error", e));
145
+ }
146
+ // ---------------------------------------------------------------------
147
+ // Lyric source resolution
148
+ // ---------------------------------------------------------------------
149
+ resolveLyrics(lyrics) {
150
+ if (!lyrics) return { metadata: {}, lines: [] };
151
+ if (typeof lyrics === "string") {
152
+ return parseLRC(lyrics);
153
+ }
154
+ if (Array.isArray(lyrics)) {
155
+ return parseJSONLyrics(lyrics);
156
+ }
157
+ if ("type" in lyrics) {
158
+ switch (lyrics.type) {
159
+ case "lrc":
160
+ return parseLRC(lyrics.data);
161
+ case "json":
162
+ return parseJSONLyrics(lyrics.data);
163
+ case "parsed":
164
+ return lyrics.data;
165
+ }
166
+ }
167
+ return lyrics;
168
+ }
169
+ /** Replace the loaded lyrics at any time (e.g. after fetching a file). */
170
+ setLyrics(lyrics) {
171
+ const parsed = this.resolveLyrics(lyrics);
172
+ this.metadata = parsed.metadata;
173
+ this.lines.length = 0;
174
+ this.lines.push(...parsed.lines);
175
+ this.currentIndex = -1;
176
+ this.handleTimeUpdate();
177
+ }
178
+ /** Adjust the global lyric offset (in milliseconds) at runtime. */
179
+ setOffset(offsetMs) {
180
+ this.offsetSeconds = offsetMs / 1e3;
181
+ this.currentIndex = -1;
182
+ this.handleTimeUpdate();
183
+ }
184
+ // ---------------------------------------------------------------------
185
+ // Playback controls (thin wrappers over the audio element)
186
+ // ---------------------------------------------------------------------
187
+ play() {
188
+ return this.audio.play();
189
+ }
190
+ pause() {
191
+ this.audio.pause();
192
+ }
193
+ toggle() {
194
+ return this.audio.paused ? this.audio.play() : this.audio.pause();
195
+ }
196
+ seek(timeSeconds) {
197
+ this.audio.currentTime = Math.max(0, timeSeconds);
198
+ this.handleTimeUpdate();
199
+ }
200
+ /** Seek directly to the start of a given lyric line. */
201
+ seekToLine(index) {
202
+ const line = this.lines[index];
203
+ if (line) this.seek(line.time + this.offsetSeconds);
204
+ }
205
+ get currentTime() {
206
+ return this.audio.currentTime;
207
+ }
208
+ get duration() {
209
+ return this.audio.duration;
210
+ }
211
+ get paused() {
212
+ return this.audio.paused;
213
+ }
214
+ set volume(value) {
215
+ this.audio.volume = value;
216
+ }
217
+ get volume() {
218
+ return this.audio.volume;
219
+ }
220
+ // ---------------------------------------------------------------------
221
+ // Lyric lookup
222
+ // ---------------------------------------------------------------------
223
+ /** The currently active lyric line, or null if before the first line. */
224
+ getCurrentLine() {
225
+ return this.currentIndex >= 0 ? this.lines[this.currentIndex] : null;
226
+ }
227
+ /** The index of the currently active lyric line (-1 if none yet). */
228
+ getCurrentIndex() {
229
+ return this.currentIndex;
230
+ }
231
+ /** The next lyric line after the current one, if any. */
232
+ getNextLine() {
233
+ const next = this.lines[this.currentIndex + 1];
234
+ return next ?? null;
235
+ }
236
+ /**
237
+ * For lines with word-level timing, returns the index of the active
238
+ * token within the current line (-1 if no tokens or none active yet).
239
+ */
240
+ getCurrentTokenIndex() {
241
+ const line = this.getCurrentLine();
242
+ if (!line?.tokens?.length) return -1;
243
+ const t = this.audio.currentTime - this.offsetSeconds;
244
+ let idx = -1;
245
+ for (let i = 0; i < line.tokens.length; i++) {
246
+ if (line.tokens[i].time <= t) idx = i;
247
+ else break;
248
+ }
249
+ return idx;
250
+ }
251
+ getCurrentToken() {
252
+ const line = this.getCurrentLine();
253
+ const idx = this.getCurrentTokenIndex();
254
+ return line?.tokens && idx >= 0 ? line.tokens[idx] : null;
255
+ }
256
+ /**
257
+ * Find the line index active at an arbitrary time, without affecting
258
+ * playback or the cached current index. Uses binary search.
259
+ */
260
+ findLineIndexAtTime(timeSeconds) {
261
+ const t = timeSeconds - this.offsetSeconds;
262
+ const lines = this.lines;
263
+ if (lines.length === 0 || t < lines[0].time) return -1;
264
+ let lo = 0;
265
+ let hi = lines.length - 1;
266
+ while (lo < hi) {
267
+ const mid = lo + hi + 1 >> 1;
268
+ if (lines[mid].time <= t) lo = mid;
269
+ else hi = mid - 1;
270
+ }
271
+ return lo;
272
+ }
273
+ // ---------------------------------------------------------------------
274
+ // Event handling
275
+ // ---------------------------------------------------------------------
276
+ on(event, listener) {
277
+ let set = this.listeners[event];
278
+ if (!set) {
279
+ set = /* @__PURE__ */ new Set();
280
+ this.listeners[event] = set;
281
+ }
282
+ set.add(listener);
283
+ }
284
+ off(event, listener) {
285
+ this.listeners[event]?.delete(listener);
286
+ }
287
+ emit(event, ...args) {
288
+ this.listeners[event]?.forEach((listener) => listener(...args));
289
+ }
290
+ /** Remove all listeners and detach from the underlying audio element. */
291
+ destroy() {
292
+ this.audio.removeEventListener("timeupdate", this.handleTimeUpdate);
293
+ this.audio.pause();
294
+ this.listeners = {};
295
+ }
296
+ };
297
+
298
+ // src/react.ts
299
+ function useLyricPlayer(options) {
300
+ const audioRef = (0, import_react.useRef)(null);
301
+ const [player, setPlayer] = (0, import_react.useState)(null);
302
+ const [currentLine, setCurrentLine] = (0, import_react.useState)(null);
303
+ const [currentIndex, setCurrentIndex] = (0, import_react.useState)(-1);
304
+ const [lines, setLines] = (0, import_react.useState)([]);
305
+ const optionsRef = (0, import_react.useRef)(options);
306
+ optionsRef.current = options;
307
+ (0, import_react.useEffect)(() => {
308
+ const audioEl = audioRef.current;
309
+ if (!audioEl) return;
310
+ const { audio, lyrics, offsetMs } = optionsRef.current;
311
+ if (audio) audioEl.src = audio;
312
+ const instance = new LyricPlayer({
313
+ audio: audioEl,
314
+ lyrics,
315
+ offsetMs
316
+ });
317
+ setPlayer(instance);
318
+ setLines(instance.lines);
319
+ setCurrentLine(null);
320
+ setCurrentIndex(-1);
321
+ instance.on("linechange", (line, index) => {
322
+ setCurrentLine(line);
323
+ setCurrentIndex(index);
324
+ });
325
+ return () => {
326
+ instance.destroy();
327
+ setPlayer(null);
328
+ };
329
+ }, [options.audio, options.lyrics, options.offsetMs]);
330
+ return { player, audioRef, currentLine, currentIndex, lines };
331
+ }
332
+ // Annotate the CommonJS export names for ESM import in node:
333
+ 0 && (module.exports = {
334
+ useLyricPlayer
335
+ });
@@ -0,0 +1,51 @@
1
+ import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-C3ZCZJVs.cjs';
2
+ export { f as LyricSource } from './player-C3ZCZJVs.cjs';
3
+
4
+ interface UseLyricPlayerOptions extends Omit<LyricPlayerOptions, "audio"> {
5
+ /** Audio source URL. Set on the bound <audio> element. */
6
+ audio?: string;
7
+ }
8
+ interface UseLyricPlayerResult {
9
+ /** The underlying player instance (null until mounted and ready). */
10
+ player: LyricPlayer | null;
11
+ /** Ref to attach to your <audio> element. */
12
+ audioRef: React.RefObject<HTMLAudioElement | null>;
13
+ /** The currently active lyric line, or null before the first line. */
14
+ currentLine: LyricLine | null;
15
+ /** Index of the currently active lyric line (-1 if none yet). */
16
+ currentIndex: number;
17
+ /** All parsed lyric lines (empty until lyrics are loaded). */
18
+ lines: LyricLine[];
19
+ }
20
+ /**
21
+ * React hook that creates a {@link LyricPlayer} bound to an `<audio>`
22
+ * element via ref, and keeps the active lyric line in sync with React
23
+ * state via the `linechange` event.
24
+ *
25
+ * Must be used in a Client Component - `LyricPlayer` requires a real
26
+ * `HTMLAudioElement`, which doesn't exist during SSR.
27
+ *
28
+ * ```tsx
29
+ * 'use client';
30
+ *
31
+ * const { audioRef, currentLine, lines, player } = useLyricPlayer({
32
+ * audio: '/song.mp3',
33
+ * lyrics: lrcText,
34
+ * });
35
+ *
36
+ * return (
37
+ * <>
38
+ * <audio ref={audioRef} controls />
39
+ * <p>{currentLine?.text}</p>
40
+ * </>
41
+ * );
42
+ * ```
43
+ *
44
+ * The player is recreated whenever `audio` or `lyrics` change. If you're
45
+ * fetching lyrics asynchronously, wait until they're loaded before calling
46
+ * this hook (or pass `lyrics: ''` while loading - an empty string parses
47
+ * to zero lines and is cheap to recreate).
48
+ */
49
+ declare function useLyricPlayer(options: UseLyricPlayerOptions): UseLyricPlayerResult;
50
+
51
+ export { type UseLyricPlayerOptions, type UseLyricPlayerResult, useLyricPlayer };
@@ -0,0 +1,51 @@
1
+ import { h as LyricPlayerOptions, b as LyricPlayer, L as LyricLine } from './player-C3ZCZJVs.js';
2
+ export { f as LyricSource } from './player-C3ZCZJVs.js';
3
+
4
+ interface UseLyricPlayerOptions extends Omit<LyricPlayerOptions, "audio"> {
5
+ /** Audio source URL. Set on the bound <audio> element. */
6
+ audio?: string;
7
+ }
8
+ interface UseLyricPlayerResult {
9
+ /** The underlying player instance (null until mounted and ready). */
10
+ player: LyricPlayer | null;
11
+ /** Ref to attach to your <audio> element. */
12
+ audioRef: React.RefObject<HTMLAudioElement | null>;
13
+ /** The currently active lyric line, or null before the first line. */
14
+ currentLine: LyricLine | null;
15
+ /** Index of the currently active lyric line (-1 if none yet). */
16
+ currentIndex: number;
17
+ /** All parsed lyric lines (empty until lyrics are loaded). */
18
+ lines: LyricLine[];
19
+ }
20
+ /**
21
+ * React hook that creates a {@link LyricPlayer} bound to an `<audio>`
22
+ * element via ref, and keeps the active lyric line in sync with React
23
+ * state via the `linechange` event.
24
+ *
25
+ * Must be used in a Client Component - `LyricPlayer` requires a real
26
+ * `HTMLAudioElement`, which doesn't exist during SSR.
27
+ *
28
+ * ```tsx
29
+ * 'use client';
30
+ *
31
+ * const { audioRef, currentLine, lines, player } = useLyricPlayer({
32
+ * audio: '/song.mp3',
33
+ * lyrics: lrcText,
34
+ * });
35
+ *
36
+ * return (
37
+ * <>
38
+ * <audio ref={audioRef} controls />
39
+ * <p>{currentLine?.text}</p>
40
+ * </>
41
+ * );
42
+ * ```
43
+ *
44
+ * The player is recreated whenever `audio` or `lyrics` change. If you're
45
+ * fetching lyrics asynchronously, wait until they're loaded before calling
46
+ * this hook (or pass `lyrics: ''` while loading - an empty string parses
47
+ * to zero lines and is cheap to recreate).
48
+ */
49
+ declare function useLyricPlayer(options: UseLyricPlayerOptions): UseLyricPlayerResult;
50
+
51
+ export { type UseLyricPlayerOptions, type UseLyricPlayerResult, useLyricPlayer };
package/dist/react.js ADDED
@@ -0,0 +1,43 @@
1
+ "use client";
2
+ import {
3
+ LyricPlayer
4
+ } from "./chunk-O4Y7SZ2V.js";
5
+
6
+ // src/react.ts
7
+ import { useEffect, useRef, useState } from "react";
8
+ function useLyricPlayer(options) {
9
+ const audioRef = useRef(null);
10
+ const [player, setPlayer] = useState(null);
11
+ const [currentLine, setCurrentLine] = useState(null);
12
+ const [currentIndex, setCurrentIndex] = useState(-1);
13
+ const [lines, setLines] = useState([]);
14
+ const optionsRef = useRef(options);
15
+ optionsRef.current = options;
16
+ useEffect(() => {
17
+ const audioEl = audioRef.current;
18
+ if (!audioEl) return;
19
+ const { audio, lyrics, offsetMs } = optionsRef.current;
20
+ if (audio) audioEl.src = audio;
21
+ const instance = new LyricPlayer({
22
+ audio: audioEl,
23
+ lyrics,
24
+ offsetMs
25
+ });
26
+ setPlayer(instance);
27
+ setLines(instance.lines);
28
+ setCurrentLine(null);
29
+ setCurrentIndex(-1);
30
+ instance.on("linechange", (line, index) => {
31
+ setCurrentLine(line);
32
+ setCurrentIndex(index);
33
+ });
34
+ return () => {
35
+ instance.destroy();
36
+ setPlayer(null);
37
+ };
38
+ }, [options.audio, options.lyrics, options.offsetMs]);
39
+ return { player, audioRef, currentLine, currentIndex, lines };
40
+ }
41
+ export {
42
+ useLyricPlayer
43
+ };
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lrc-audio-player",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "Sync LRC/word-level lyrics to an HTML audio element with one constructor. Includes an optional React hook at lrc-audio-player/react.",
5
5
  "repository": {
6
6
  "type": "git",
@@ -33,11 +33,11 @@
33
33
  "dist"
34
34
  ],
35
35
  "scripts": {
36
- "build": "tsup src/index.ts --format cjs,esm --dts --clean",
37
- "prepublishOnly": "npm run typecheck && npm run test && npm run build",
38
- "dev": "tsup src/index.ts --format cjs,esm --dts --watch",
36
+ "build": "tsup src/index.ts src/react.ts --format cjs,esm --dts --clean",
37
+ "dev": "tsup src/index.ts src/react.ts --format cjs,esm --dts --watch",
39
38
  "test": "vitest run",
40
- "typecheck": "tsc --noEmit"
39
+ "typecheck": "tsc --noEmit",
40
+ "prepublishOnly": "npm run typecheck && npm run test && npm run build"
41
41
  },
42
42
  "keywords": [
43
43
  "lyrics",