Skip to content

Commit 71d1593

Browse files
implement new lyrics
1 parent ccbc374 commit 71d1593

File tree

10 files changed

+214
-56
lines changed

10 files changed

+214
-56
lines changed

src/apps/app/views/lyrics/lyrics.view.tsx

Lines changed: 92 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,7 @@
11
import { useApp } from "@app/providers";
2-
import { Container, Icon, Spinner } from "@common";
3-
import { useQueue } from "@queue";
4-
import { useLyrics } from "@youtube";
5-
import { For, Match, Switch, createMemo, onMount, type Component } from "solid-js";
2+
import { Container, Icon, Spinner, useTimedText } from "@common";
3+
import { LyricsUtil, useLyrics, useQueue } from "@queue";
4+
import { For, Match, Switch, createEffect, onMount, type Component } from "solid-js";
65
import "./lyrics.style.css";
76

87
const LyricsNotFound: Component = () => {
@@ -24,54 +23,108 @@ const Loading: Component = () => {
2423

2524
export const Lyrics: Component = () => {
2625
let container!: HTMLDivElement;
27-
const queue = useQueue()!;
2826
const app = useApp()!;
29-
const currentId = createMemo(() => queue.data.nowPlaying?.mediaSource.playedYoutubeVideoId || "");
30-
const lyrics = useLyrics(currentId);
31-
// let initialScroll = true;
32-
// let lastScrollTime = 0;
27+
const queue = useQueue()!;
28+
const lyrics = useLyrics();
29+
const timedText = useTimedText(() => {
30+
const lyrics = syncedLyrics();
31+
return {
32+
elapsed: queue.data.position / 1000,
33+
timedTexts: lyrics || [],
34+
};
35+
});
3336

3437
onMount(() => app.setTitle("Lyrics"));
3538

36-
// createEffect(() => {
37-
// if (timedText.index() === -1 && container) {
38-
// container.scrollTop = 0;
39-
// } else {
40-
// if (Date.now() - lastScrollTime < 3000) return;
41-
// const index = timedText.index();
42-
// if (initialScroll) {
43-
// setTimeout(() => scrollTo(index), 200);
44-
// initialScroll = false;
45-
// } else {
46-
// scrollTo(index);
47-
// }
48-
// }
49-
// });
50-
51-
// const scrollTo = (index: number) => {
52-
// const element = container?.childNodes[index] as HTMLDivElement;
53-
// if (!element) return;
54-
// container.scrollTop = element.offsetTop - container.offsetHeight / 2.5 + element.offsetHeight / 2;
55-
// };
56-
57-
// const onContainerScrollHandler = () => {
58-
// lastScrollTime = Date.now();
59-
// };
39+
let initialScroll = true;
40+
let lastScrollTime = 0;
41+
42+
const syncedLyrics = () => {
43+
const nowPlaying = queue.data.nowPlaying;
44+
if (!nowPlaying) return null;
45+
46+
const lyricsOptions = lyrics.data();
47+
if (!lyricsOptions?.length) return null;
48+
49+
const bestMatch = lyricsOptions
50+
.sort((a, b) => {
51+
const aDiff = Math.abs(a.duration - nowPlaying.mediaSource.duration);
52+
const bDiff = Math.abs(b.duration - nowPlaying.mediaSource.duration);
53+
return aDiff - bDiff;
54+
})
55+
.at(0)?.synced;
56+
57+
return bestMatch ? LyricsUtil.parse(bestMatch).synced : null;
58+
};
59+
60+
const normalLyrics = () => {
61+
const nowPlaying = queue.data.nowPlaying;
62+
if (!nowPlaying) return null;
63+
64+
const lyricsOptions = lyrics.data();
65+
if (!lyricsOptions?.length) return null;
66+
67+
const unsyncedLyrics = lyricsOptions.find((l) => l.unsynced);
68+
if (unsyncedLyrics?.unsynced) {
69+
return {
70+
content: unsyncedLyrics.unsynced,
71+
description: unsyncedLyrics.source,
72+
};
73+
}
74+
75+
const synced = lyricsOptions.find((l) => l.synced);
76+
if (synced?.synced) {
77+
return {
78+
content:
79+
LyricsUtil.parse(synced.synced)
80+
.synced?.map((s) => s.text)
81+
.join("\n") || "",
82+
description: synced.source,
83+
};
84+
}
85+
86+
return null;
87+
};
88+
89+
createEffect(() => {
90+
if (timedText.index() === -1 && container) {
91+
container.scrollTop = 0;
92+
} else {
93+
if (Date.now() - lastScrollTime < 3000) return;
94+
const index = timedText.index();
95+
if (initialScroll) {
96+
setTimeout(() => scrollTo(index), 200);
97+
initialScroll = false;
98+
} else {
99+
scrollTo(index);
100+
}
101+
}
102+
});
103+
104+
const scrollTo = (index: number) => {
105+
const element = container?.childNodes[index] as HTMLDivElement;
106+
if (!element) return;
107+
container.scrollTop = element.offsetTop - container.offsetHeight / 2.5 + element.offsetHeight / 2;
108+
};
109+
110+
const onContainerScrollHandler = () => {
111+
lastScrollTime = Date.now();
112+
};
60113

61114
return (
62115
<Container
63116
size="full"
64117
extraClass="h-full flex flex-col items-center space-y-2.5"
65118
centered
66119
ref={container}
67-
// onScroll={onContainerScrollHandler}
120+
onScroll={onContainerScrollHandler}
68121
>
69122
<Switch fallback={<LyricsNotFound />}>
70123
<Match when={lyrics.data.loading}>
71124
<Loading />
72125
</Match>
73-
{/* <Match when={videoTranscripts.data().length}>
74-
<For each={videoTranscripts.data()}>
126+
<Match when={syncedLyrics()} keyed>
127+
<For each={syncedLyrics()}>
75128
{(t, i) => (
76129
<div
77130
class="space-y-1 py-2 text"
@@ -84,12 +137,12 @@ export const Lyrics: Component = () => {
84137
"font-semibold text-2xl md:text-3xl !text-neutral-100": i() === timedText.index(),
85138
}}
86139
>
87-
<For each={t.texts}>{(text) => <div>{text}</div>}</For>
140+
<div>{t.text}</div>
88141
</div>
89142
)}
90143
</For>
91-
</Match> */}
92-
<Match when={lyrics.data()} keyed>
144+
</Match>
145+
<Match when={normalLyrics()} keyed>
93146
{({ content, description }) => (
94147
<>
95148
<For each={content.split(/\r?\n/)}>

src/libs/common/hooks/timed-text.hook.ts

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import { createEffect, createSignal, onCleanup, type Accessor } from "solid-js";
22

33
export type TimedText = {
4-
texts: string[];
5-
start: number;
6-
end: number;
4+
text: string;
5+
startTime: number;
6+
endTime?: number;
77
};
88

99
type Params = {
@@ -25,22 +25,25 @@ export const useTimedText = (params: Accessor<Params>) => {
2525

2626
const indexes = [];
2727
for (const [i, t] of data.entries()) {
28-
if (t.start <= elapsed && t.end >= elapsed) indexes.push(i);
28+
if (t.startTime <= elapsed && (!t.endTime || t.endTime >= elapsed)) indexes.push(i);
2929
}
3030

31-
let index = indexes.length > 1 ? indexes[indexes.length - 1] : data.findIndex((t) => elapsed <= t.end);
31+
let index =
32+
indexes.length > 1
33+
? indexes[indexes.length - 1]
34+
: data.findIndex((t) => elapsed <= (t.endTime ?? Infinity));
3235
const timedText = data.at(index);
3336
if (!timedText) return;
3437

3538
let delay = 0;
36-
const next = data.at(elapsed >= timedText.start ? index + 1 : index--);
37-
if (next) delay = next.start - elapsed;
39+
const next = data.at(elapsed >= timedText.startTime ? index + 1 : index--);
40+
if (next) delay = next.startTime - elapsed;
3841

3942
const last = data.at(-1);
40-
if (last && elapsed >= last.end) setIndex(data.length - 1);
43+
if (last && elapsed >= (last.endTime ?? Infinity)) setIndex(data.length - 1);
4144
else setIndex(index);
4245

43-
if (delay < 1000 && elapsed < data[data.length - 1].end && !last) {
46+
if (delay < 5000 && elapsed < (data[data.length - 1].endTime ?? Infinity) && !last) {
4447
optimisticUpdateTimeout = setTimeout(() => setIndex((v) => v + 1), delay);
4548
}
4649
});

src/libs/queue/apis/queue.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -101,6 +101,14 @@ export type IChangeAutoplayOptions = Partial<IAutoplayOptions> & {
101101
removeExcludedMemberId?: string;
102102
};
103103

104+
export interface ILyrics {
105+
source: string;
106+
richSynced: string | null;
107+
synced: string | null;
108+
unsynced: string | null;
109+
duration: number;
110+
}
111+
104112
export class QueueApi {
105113
constructor(private client: AxiosInstance) {}
106114

@@ -192,4 +200,10 @@ export class QueueApi {
192200
jam = async (queueId: string, count: number): Promise<void> => {
193201
await this.client.post(`/queues/${queueId}/jam`, { count: Math.min(count, 5) });
194202
};
203+
204+
lyrics = async (queueId: string): Promise<ILyrics[]> => {
205+
const response = await this.client.get(`/queues/${queueId}/lyrics`);
206+
if (response.status !== 200) return [];
207+
return response.data;
208+
};
195209
}

src/libs/queue/hooks/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./lyrics.hook";
Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { useApi } from "@common";
2+
import { createResource } from "solid-js";
3+
import { QueueApi } from "../apis";
4+
import { useQueue } from "../providers";
5+
6+
export const useLyrics = () => {
7+
const queue = useQueue()!;
8+
const api = useApi();
9+
const queueApi = new QueueApi(api.client);
10+
11+
const [data, { refetch, mutate }] = createResource(
12+
() => queue?.data.nowPlaying,
13+
(nowPlaying) => (nowPlaying ? queueApi.lyrics(queue.data.voiceChannel.id) : null),
14+
{ initialValue: null }
15+
);
16+
17+
return {
18+
data,
19+
mutate,
20+
refetch,
21+
};
22+
};

src/libs/queue/index.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,6 @@
11
export * from "./apis";
22
export * from "./components";
33
export * from "./constants";
4+
export * from "./hooks";
45
export * from "./providers";
6+
export * from "./utils";

src/libs/queue/providers/queue/hooks/player-position-updater.hook.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -12,11 +12,11 @@ export const usePlayerPositionUpdater = ({ queue, setQueue }: Params) => {
1212

1313
createEffect(() => {
1414
clearInterval(tickInterval);
15-
if (!queue.isPaused && queue.nowPlaying?.playedAt) tickInterval = setInterval(tick, 1000);
15+
if (!queue.isPaused && queue.nowPlaying?.playedAt) tickInterval = setInterval(tick, 250);
1616
});
1717

1818
const tick = () => {
1919
const multiplier = (queue.filtersState.timescale.speed || 1) * (queue.filtersState.timescale.rate || 1);
20-
setQueue("position", (p) => p + 1000 * multiplier);
20+
setQueue("position", (p) => p + 250 * multiplier);
2121
};
2222
};

src/libs/queue/utils/index.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
export * from "./lyrics.util";
Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,61 @@
1+
// from https://github.com/notigorwastaken/lrclib-api/blob/main/packages/lrclib-api/src/utils.ts
2+
3+
type SyncedLyricLine = {
4+
text: string;
5+
startTime: number;
6+
};
7+
8+
type UnsyncedLyricLine = {
9+
text: string;
10+
};
11+
12+
type ParsedLyrics = {
13+
synced: SyncedLyricLine[] | null;
14+
unsynced: UnsyncedLyricLine[];
15+
};
16+
17+
export class LyricsUtil {
18+
public static parse(lyrics: string): ParsedLyrics {
19+
// Preprocess lyrics by removing [tags] (e.g., [artist:Name]) and trimming extra whitespace
20+
const lines = lyrics
21+
.replace(/\[[a-zA-Z]+:.+\]/g, "") // Removes metadata tags like [artist:Name]
22+
.trim()
23+
.split("\n"); // Splits the lyrics into an array of lines
24+
25+
// Regular expressions for matching synced and karaoke timestamps
26+
const syncedTimestamp = /\[([0-9:.]+)\]/; // Matches [00:12.34]
27+
28+
const unsynced: UnsyncedLyricLine[] = []; // Array to store unsynchronized lyrics
29+
const synced: SyncedLyricLine[] = []; // Array to store synchronized lyrics
30+
31+
// Process each line to extract lyrics and timing information
32+
lines.forEach((line) => {
33+
// Match synchronized lyrics
34+
const syncMatch = line.match(syncedTimestamp);
35+
if (syncMatch) {
36+
const startTime = this.parseTime(syncMatch[1]);
37+
const text = line.replace(syncedTimestamp, "").trim();
38+
if (text) {
39+
synced.push({ text, startTime });
40+
}
41+
}
42+
// Add to unsynchronized lyrics if no timestamps are found
43+
else {
44+
const text = line.trim();
45+
if (text) {
46+
unsynced.push({ text });
47+
}
48+
}
49+
});
50+
51+
return {
52+
synced: synced.length > 0 ? synced : null,
53+
unsynced,
54+
};
55+
}
56+
57+
private static parseTime(time: string): number {
58+
const [minutes, seconds] = time.split(":").map(Number);
59+
return minutes * 60 + seconds;
60+
}
61+
}

src/libs/youtube/hooks/video-transcript.hook.ts

Lines changed: 7 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -19,18 +19,19 @@ export const useVideoTranscript = (videoId: IUseTranscriptProps) => {
1919
let text = transcript.text?.replaceAll("♪", "");
2020
if (!text.trim()) text = transcript.text;
2121

22-
const index = formatted.findIndex((t) => t.start === transcript.start && t.end === transcript.end);
22+
const index = formatted.findIndex((t) => t.startTime === transcript.start && t.endTime === transcript.end);
2323
if (index === -1) {
2424
formatted.push({
25-
...transcript,
26-
texts: [text],
25+
startTime: transcript.start,
26+
endTime: transcript.end,
27+
text,
2728
});
28-
} else if (!formatted[index].texts.includes(text)) {
29-
formatted[index].texts.push(text);
29+
} else if (!formatted[index].text.includes(text)) {
30+
formatted[index].text += `\n${text}`;
3031
}
3132
}
3233

33-
return formatted.sort((a, b) => a.start - b.start);
34+
return formatted.sort((a, b) => a.startTime - b.startTime);
3435
});
3536

3637
const isLoading = () => _data.loading;

0 commit comments

Comments
 (0)