@uimaxbai/am-lyrics 1.1.7 → 1.2.0
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/README.md +4 -0
- package/demo.webp +0 -0
- package/dist/src/AmLyrics.d.ts +8 -0
- package/dist/src/AmLyrics.d.ts.map +1 -1
- package/dist/src/GoogleService.d.ts +16 -3
- package/dist/src/GoogleService.d.ts.map +1 -1
- package/dist/src/am-lyrics.js +502 -376
- package/dist/src/am-lyrics.js.map +1 -1
- package/dist/src/react.js +502 -376
- package/dist/src/react.js.map +1 -1
- package/dist/tsconfig.tsbuildinfo +1 -1
- package/package.json +1 -1
- package/src/AmLyrics.ts +617 -432
- package/src/GoogleService.ts +49 -19
package/dist/src/am-lyrics.js
CHANGED
|
@@ -85,6 +85,7 @@ const CONFIG = {
|
|
|
85
85
|
GOOGLE: {
|
|
86
86
|
MAX_RETRIES: 3,
|
|
87
87
|
RETRY_DELAY_MS: 1000,
|
|
88
|
+
FETCH_TIMEOUT_MS: 6000,
|
|
88
89
|
},
|
|
89
90
|
};
|
|
90
91
|
/**
|
|
@@ -96,6 +97,11 @@ class GoogleService {
|
|
|
96
97
|
setTimeout(resolve, ms);
|
|
97
98
|
});
|
|
98
99
|
}
|
|
100
|
+
static fetchWithTimeout(url, timeoutMs = CONFIG.GOOGLE.FETCH_TIMEOUT_MS) {
|
|
101
|
+
const controller = new AbortController();
|
|
102
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
103
|
+
return fetch(url, { signal: controller.signal }).finally(() => clearTimeout(timeoutId));
|
|
104
|
+
}
|
|
99
105
|
static isPurelyLatinScript(text) {
|
|
100
106
|
// Basic check for Latin script characters plus common punctuation and numbers
|
|
101
107
|
// eslint-disable-next-line no-control-regex
|
|
@@ -137,7 +143,7 @@ class GoogleService {
|
|
|
137
143
|
try {
|
|
138
144
|
const url = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=${targetLang}&dt=t&q=${encodeURIComponent(joinedText)}`;
|
|
139
145
|
// eslint-disable-next-line no-await-in-loop
|
|
140
|
-
const response = await
|
|
146
|
+
const response = await GoogleService.fetchWithTimeout(url);
|
|
141
147
|
if (!response.ok)
|
|
142
148
|
throw new Error(`Status ${response.status}`);
|
|
143
149
|
// eslint-disable-next-line no-await-in-loop
|
|
@@ -210,9 +216,11 @@ class GoogleService {
|
|
|
210
216
|
// Determine if we should treat as word-synced (has syllabus) or line-synced
|
|
211
217
|
const lines = Array.isArray(originalLyrics)
|
|
212
218
|
? originalLyrics
|
|
213
|
-
: originalLyrics.data ||
|
|
214
|
-
|
|
215
|
-
|
|
219
|
+
: originalLyrics.data ||
|
|
220
|
+
originalLyrics.content ||
|
|
221
|
+
[];
|
|
222
|
+
if (!lines || lines.length === 0)
|
|
223
|
+
return Array.isArray(originalLyrics) ? originalLyrics : [];
|
|
216
224
|
// Check if word synced
|
|
217
225
|
const isWordSynced = lines.some((l) => l.isWordSynced !== false && Array.isArray(l.text) && l.text.length > 1);
|
|
218
226
|
if (isWordSynced) {
|
|
@@ -229,7 +237,9 @@ class GoogleService {
|
|
|
229
237
|
)
|
|
230
238
|
return line;
|
|
231
239
|
// Get the entire line text to romanize together for context-aware pronunciation
|
|
232
|
-
const fullText = line.text
|
|
240
|
+
const fullText = line.text
|
|
241
|
+
.map((s) => s.text)
|
|
242
|
+
.join('');
|
|
233
243
|
// romanizeTexts expects an array of strings, so we pass an array of one
|
|
234
244
|
const [romanizedFullLine] = await this.romanizeTexts([fullText]);
|
|
235
245
|
const newSyllabus = line.text.map((s) => ({
|
|
@@ -279,7 +289,7 @@ class GoogleService {
|
|
|
279
289
|
while (attempt < CONFIG.GOOGLE.MAX_RETRIES && !success) {
|
|
280
290
|
try {
|
|
281
291
|
const romanizeUrl = `https://translate.googleapis.com/translate_a/single?client=gtx&sl=auto&tl=en&dt=rm&q=${encodeURIComponent(text)}`;
|
|
282
|
-
const response = await
|
|
292
|
+
const response = await GoogleService.fetchWithTimeout(romanizeUrl);
|
|
283
293
|
const data = await response.json();
|
|
284
294
|
// Response format is [[["...","...","...","romanization"]],...]
|
|
285
295
|
// or [null, ...] for English/Latin input where no romanization is needed
|
|
@@ -309,8 +319,26 @@ class GoogleService {
|
|
|
309
319
|
}
|
|
310
320
|
}
|
|
311
321
|
|
|
312
|
-
const VERSION = '1.
|
|
322
|
+
const VERSION = '1.2.0';
|
|
313
323
|
const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s
|
|
324
|
+
const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests
|
|
325
|
+
const SEEK_THRESHOLD_MS = 500;
|
|
326
|
+
const PRE_SCROLL_LEAD_MS = 500;
|
|
327
|
+
const SCROLL_ANIMATION_DURATION_MS = 280;
|
|
328
|
+
const SCROLL_DELAY_INCREMENT_MS = 24;
|
|
329
|
+
const GAP_PULSE_DURATION_MS = 4000;
|
|
330
|
+
const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2;
|
|
331
|
+
const GAP_EXIT_LEAD_MS = 360;
|
|
332
|
+
const GAP_MIN_SCALE = 0.85;
|
|
333
|
+
/**
|
|
334
|
+
* Fetch with an automatic timeout via AbortSignal.
|
|
335
|
+
* Rejects if the request takes longer than `timeoutMs`.
|
|
336
|
+
*/
|
|
337
|
+
function fetchWithTimeout(url, options = {}, timeoutMs = FETCH_TIMEOUT_MS) {
|
|
338
|
+
const controller = new AbortController();
|
|
339
|
+
const timeoutId = setTimeout(() => controller.abort(), timeoutMs);
|
|
340
|
+
return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timeoutId));
|
|
341
|
+
}
|
|
314
342
|
const KPOE_SERVERS = [
|
|
315
343
|
'https://lyricsplus.binimum.org',
|
|
316
344
|
'https://lyricsplus.atomix.one',
|
|
@@ -374,6 +402,9 @@ class AmLyrics extends i {
|
|
|
374
402
|
// Syllable animation tracking
|
|
375
403
|
this.lastActiveIndex = 0;
|
|
376
404
|
this.visibleLineIds = new Set();
|
|
405
|
+
// Bound handler references for proper event listener removal
|
|
406
|
+
this._boundHandleUserScroll = this.handleUserScroll.bind(this);
|
|
407
|
+
this._boundAnimateProgress = this.animateProgress.bind(this);
|
|
377
408
|
}
|
|
378
409
|
async toggleRomanization() {
|
|
379
410
|
this.showRomanization = !this.showRomanization;
|
|
@@ -459,12 +490,38 @@ class AmLyrics extends i {
|
|
|
459
490
|
super.disconnectedCallback();
|
|
460
491
|
if (this.animationFrameId) {
|
|
461
492
|
cancelAnimationFrame(this.animationFrameId);
|
|
493
|
+
this.animationFrameId = undefined;
|
|
462
494
|
}
|
|
463
495
|
if (this.userScrollTimeoutId) {
|
|
464
496
|
clearTimeout(this.userScrollTimeoutId);
|
|
497
|
+
this.userScrollTimeoutId = undefined;
|
|
498
|
+
}
|
|
499
|
+
if (this.clickSeekTimeout) {
|
|
500
|
+
clearTimeout(this.clickSeekTimeout);
|
|
501
|
+
this.clickSeekTimeout = undefined;
|
|
502
|
+
}
|
|
503
|
+
if (this.scrollUnlockTimeout) {
|
|
504
|
+
clearTimeout(this.scrollUnlockTimeout);
|
|
505
|
+
this.scrollUnlockTimeout = undefined;
|
|
506
|
+
}
|
|
507
|
+
if (this.scrollAnimationTimeout) {
|
|
508
|
+
clearTimeout(this.scrollAnimationTimeout);
|
|
509
|
+
this.scrollAnimationTimeout = undefined;
|
|
510
|
+
}
|
|
511
|
+
// Cancel any in-flight fetch requests
|
|
512
|
+
this.fetchAbortController?.abort();
|
|
513
|
+
this.fetchAbortController = undefined;
|
|
514
|
+
// Remove scroll event listeners
|
|
515
|
+
if (this.lyricsContainer) {
|
|
516
|
+
this.lyricsContainer.removeEventListener('wheel', this._boundHandleUserScroll);
|
|
517
|
+
this.lyricsContainer.removeEventListener('touchmove', this._boundHandleUserScroll);
|
|
465
518
|
}
|
|
466
519
|
}
|
|
467
520
|
async fetchLyrics() {
|
|
521
|
+
// Cancel any in-flight fetch to prevent stale results from racing
|
|
522
|
+
this.fetchAbortController?.abort();
|
|
523
|
+
const controller = new AbortController();
|
|
524
|
+
this.fetchAbortController = controller;
|
|
468
525
|
this.isLoading = true;
|
|
469
526
|
this.lyrics = undefined;
|
|
470
527
|
this.lyricsSource = null;
|
|
@@ -474,6 +531,9 @@ class AmLyrics extends i {
|
|
|
474
531
|
this.hasFetchedAllProviders = false;
|
|
475
532
|
try {
|
|
476
533
|
const resolvedMetadata = await this.resolveSongMetadata();
|
|
534
|
+
// If a newer fetch was triggered while we awaited, bail out
|
|
535
|
+
if (controller.signal.aborted)
|
|
536
|
+
return;
|
|
477
537
|
const isMusicIdOnlyRequest = Boolean(this.musicId) &&
|
|
478
538
|
!this.songTitle &&
|
|
479
539
|
!this.songArtist &&
|
|
@@ -532,7 +592,10 @@ class AmLyrics extends i {
|
|
|
532
592
|
this.lyricsSource = null;
|
|
533
593
|
}
|
|
534
594
|
finally {
|
|
535
|
-
this
|
|
595
|
+
// Only update loading state if this fetch wasn't superseded
|
|
596
|
+
if (!controller.signal.aborted) {
|
|
597
|
+
this.isLoading = false;
|
|
598
|
+
}
|
|
536
599
|
}
|
|
537
600
|
}
|
|
538
601
|
async onLyricsLoaded() {
|
|
@@ -793,7 +856,7 @@ class AmLyrics extends i {
|
|
|
793
856
|
const url = `${normalizedBase}/v1/songlist/search?q=${encodeURIComponent(trimmedQuery)}`;
|
|
794
857
|
try {
|
|
795
858
|
// eslint-disable-next-line no-await-in-loop
|
|
796
|
-
const response = await
|
|
859
|
+
const response = await fetchWithTimeout(url);
|
|
797
860
|
if (response.ok) {
|
|
798
861
|
// eslint-disable-next-line no-await-in-loop
|
|
799
862
|
const payload = await response.json();
|
|
@@ -827,7 +890,9 @@ class AmLyrics extends i {
|
|
|
827
890
|
if (metadata.durationMs && metadata.durationMs > 0) {
|
|
828
891
|
params.append('duration', Math.round(metadata.durationMs / 1000).toString());
|
|
829
892
|
}
|
|
830
|
-
|
|
893
|
+
if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) {
|
|
894
|
+
params.append('source', DEFAULT_KPOE_SOURCE_ORDER);
|
|
895
|
+
}
|
|
831
896
|
const getRank = (sourceLabel, parsedLines) => {
|
|
832
897
|
const lower = sourceLabel.toLowerCase();
|
|
833
898
|
const hasWordSync = parsedLines.some((line) => line.text && Array.isArray(line.text) && line.text.length > 1);
|
|
@@ -872,13 +937,13 @@ class AmLyrics extends i {
|
|
|
872
937
|
cacheParams.append('duration', Math.round(metadata.durationMs / 1000).toString());
|
|
873
938
|
}
|
|
874
939
|
const cacheUrl = `https://lyrics-api.binimum.org/?${cacheParams.toString()}`;
|
|
875
|
-
const cacheRes = await
|
|
940
|
+
const cacheRes = await fetchWithTimeout(cacheUrl);
|
|
876
941
|
if (cacheRes.ok) {
|
|
877
942
|
const cacheData = await cacheRes.json();
|
|
878
943
|
if (cacheData.results && cacheData.results.length > 0) {
|
|
879
944
|
const result = cacheData.results[0];
|
|
880
945
|
if (result.timing_type === 'word' && result.lyricsUrl) {
|
|
881
|
-
const ttmlRes = await
|
|
946
|
+
const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
|
|
882
947
|
if (ttmlRes.ok) {
|
|
883
948
|
const ttmlText = await ttmlRes.text();
|
|
884
949
|
const lines = AmLyrics.parseTTML(ttmlText);
|
|
@@ -893,7 +958,7 @@ class AmLyrics extends i {
|
|
|
893
958
|
const fallbackParams = new URLSearchParams(params);
|
|
894
959
|
const fallbackUrl = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
|
|
895
960
|
try {
|
|
896
|
-
const fallbackRes = await
|
|
961
|
+
const fallbackRes = await fetchWithTimeout(fallbackUrl);
|
|
897
962
|
if (fallbackRes.ok) {
|
|
898
963
|
const payload = await fallbackRes.json();
|
|
899
964
|
const lines = AmLyrics.convertKPoeLyrics(payload);
|
|
@@ -914,7 +979,7 @@ class AmLyrics extends i {
|
|
|
914
979
|
}
|
|
915
980
|
// If fallback fails or has no word sync, fall back to bini lyrics
|
|
916
981
|
if (result.lyricsUrl) {
|
|
917
|
-
const ttmlRes = await
|
|
982
|
+
const ttmlRes = await fetchWithTimeout(result.lyricsUrl);
|
|
918
983
|
if (ttmlRes.ok) {
|
|
919
984
|
const ttmlText = await ttmlRes.text();
|
|
920
985
|
const lines = AmLyrics.parseTTML(ttmlText);
|
|
@@ -936,23 +1001,23 @@ class AmLyrics extends i {
|
|
|
936
1001
|
console.error('Cache API failed', e);
|
|
937
1002
|
}
|
|
938
1003
|
// Shuffle servers so we pick a random one first, with all others as fallback
|
|
939
|
-
//
|
|
1004
|
+
// Try up to 3 servers to improve reliability when some have CORS or connectivity issues
|
|
940
1005
|
const shuffledServers = [...KPOE_SERVERS]
|
|
941
1006
|
.sort(() => Math.random() - 0.5)
|
|
942
|
-
.slice(0,
|
|
1007
|
+
.slice(0, 3);
|
|
943
1008
|
for (const base of shuffledServers) {
|
|
944
1009
|
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
945
1010
|
const url = `${normalizedBase}/v2/lyrics/get?${params.toString()}`;
|
|
946
1011
|
let payload = null;
|
|
947
1012
|
try {
|
|
948
1013
|
// eslint-disable-next-line no-await-in-loop
|
|
949
|
-
const response = await
|
|
1014
|
+
const response = await fetchWithTimeout(url);
|
|
950
1015
|
if (response.ok) {
|
|
951
1016
|
// eslint-disable-next-line no-await-in-loop
|
|
952
1017
|
payload = await response.json();
|
|
953
1018
|
}
|
|
954
1019
|
}
|
|
955
|
-
catch
|
|
1020
|
+
catch {
|
|
956
1021
|
payload = null;
|
|
957
1022
|
}
|
|
958
1023
|
if (payload) {
|
|
@@ -978,7 +1043,7 @@ class AmLyrics extends i {
|
|
|
978
1043
|
try {
|
|
979
1044
|
const fallbackParams = new URLSearchParams(params);
|
|
980
1045
|
const url = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`;
|
|
981
|
-
const response = await
|
|
1046
|
+
const response = await fetchWithTimeout(url);
|
|
982
1047
|
if (response.ok) {
|
|
983
1048
|
const payload = await response.json();
|
|
984
1049
|
if (payload) {
|
|
@@ -1014,7 +1079,6 @@ class AmLyrics extends i {
|
|
|
1014
1079
|
if (!match) {
|
|
1015
1080
|
// Skip non-timestamped lines (headers like [ti:], [ar:], etc.)
|
|
1016
1081
|
// eslint-disable-next-line no-continue
|
|
1017
|
-
// eslint-disable-next-line no-continue
|
|
1018
1082
|
continue;
|
|
1019
1083
|
}
|
|
1020
1084
|
const minutes = parseInt(match[1], 10);
|
|
@@ -1034,7 +1098,6 @@ class AmLyrics extends i {
|
|
|
1034
1098
|
const endtime = i + 1 < parsed.length ? parsed[i + 1].timestamp : timestamp + 5000;
|
|
1035
1099
|
// Skip empty lines (instrumental gaps)
|
|
1036
1100
|
if (!text.trim()) {
|
|
1037
|
-
// eslint-disable-next-line no-continue
|
|
1038
1101
|
// eslint-disable-next-line no-continue
|
|
1039
1102
|
continue;
|
|
1040
1103
|
}
|
|
@@ -1066,9 +1129,9 @@ class AmLyrics extends i {
|
|
|
1066
1129
|
const artist = metadata.artist?.trim();
|
|
1067
1130
|
if (!title || !artist)
|
|
1068
1131
|
return null;
|
|
1069
|
-
// Pick
|
|
1132
|
+
// Pick 3 random unique servers for better reliability
|
|
1070
1133
|
const shuffled = [...TIDAL_SERVERS].sort(() => Math.random() - 0.5);
|
|
1071
|
-
const serversToTry = shuffled.slice(0,
|
|
1134
|
+
const serversToTry = shuffled.slice(0, 3);
|
|
1072
1135
|
for (const base of serversToTry) {
|
|
1073
1136
|
try {
|
|
1074
1137
|
const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base;
|
|
@@ -1076,9 +1139,8 @@ class AmLyrics extends i {
|
|
|
1076
1139
|
const searchQuery = `${title} ${artist}`;
|
|
1077
1140
|
const searchParams = new URLSearchParams({ s: searchQuery });
|
|
1078
1141
|
// eslint-disable-next-line no-await-in-loop
|
|
1079
|
-
const searchResponse = await
|
|
1142
|
+
const searchResponse = await fetchWithTimeout(`${normalizedBase}/search/?${searchParams.toString()}`);
|
|
1080
1143
|
if (!searchResponse.ok) {
|
|
1081
|
-
// eslint-disable-next-line no-continue
|
|
1082
1144
|
// eslint-disable-next-line no-continue
|
|
1083
1145
|
continue;
|
|
1084
1146
|
}
|
|
@@ -1086,7 +1148,6 @@ class AmLyrics extends i {
|
|
|
1086
1148
|
const searchData = await searchResponse.json();
|
|
1087
1149
|
const items = searchData?.data?.items;
|
|
1088
1150
|
if (!Array.isArray(items) || items.length === 0) {
|
|
1089
|
-
// eslint-disable-next-line no-continue
|
|
1090
1151
|
// eslint-disable-next-line no-continue
|
|
1091
1152
|
continue;
|
|
1092
1153
|
}
|
|
@@ -1100,15 +1161,13 @@ class AmLyrics extends i {
|
|
|
1100
1161
|
}
|
|
1101
1162
|
const trackId = bestTrack?.id;
|
|
1102
1163
|
if (!trackId) {
|
|
1103
|
-
// eslint-disable-next-line no-continue
|
|
1104
1164
|
// eslint-disable-next-line no-continue
|
|
1105
1165
|
continue;
|
|
1106
1166
|
}
|
|
1107
1167
|
// Step 2: Fetch lyrics
|
|
1108
1168
|
// eslint-disable-next-line no-await-in-loop
|
|
1109
|
-
const lyricsResponse = await
|
|
1169
|
+
const lyricsResponse = await fetchWithTimeout(`${normalizedBase}/lyrics/?id=${trackId}`);
|
|
1110
1170
|
if (!lyricsResponse.ok) {
|
|
1111
|
-
// eslint-disable-next-line no-continue
|
|
1112
1171
|
// eslint-disable-next-line no-continue
|
|
1113
1172
|
continue;
|
|
1114
1173
|
}
|
|
@@ -1144,7 +1203,7 @@ class AmLyrics extends i {
|
|
|
1144
1203
|
try {
|
|
1145
1204
|
const searchQuery = `${artist} ${title}`;
|
|
1146
1205
|
const params = new URLSearchParams({ q: searchQuery });
|
|
1147
|
-
const response = await
|
|
1206
|
+
const response = await fetchWithTimeout(`https://lrclib.net/api/search?${params.toString()}`, {
|
|
1148
1207
|
headers: {
|
|
1149
1208
|
'User-Agent': `apple-music-web-components/${VERSION}`,
|
|
1150
1209
|
},
|
|
@@ -1202,7 +1261,7 @@ class AmLyrics extends i {
|
|
|
1202
1261
|
return null;
|
|
1203
1262
|
try {
|
|
1204
1263
|
const params = new URLSearchParams({ title, artist });
|
|
1205
|
-
const response = await
|
|
1264
|
+
const response = await fetchWithTimeout(`${GENIUS_WORKER_URL}?${params.toString()}`);
|
|
1206
1265
|
if (!response.ok)
|
|
1207
1266
|
return null;
|
|
1208
1267
|
const data = await response.json();
|
|
@@ -1233,8 +1292,7 @@ class AmLyrics extends i {
|
|
|
1233
1292
|
}
|
|
1234
1293
|
}
|
|
1235
1294
|
catch {
|
|
1236
|
-
//
|
|
1237
|
-
console.error('No Genius lyrics found');
|
|
1295
|
+
// Genius fetch failed, will fall through to return null
|
|
1238
1296
|
}
|
|
1239
1297
|
return null;
|
|
1240
1298
|
}
|
|
@@ -1326,19 +1384,6 @@ class AmLyrics extends i {
|
|
|
1326
1384
|
}
|
|
1327
1385
|
}
|
|
1328
1386
|
}
|
|
1329
|
-
const transliterationNodes = doc.getElementsByTagName('transliteration');
|
|
1330
|
-
for (let i = 0; i < transliterationNodes.length; i += 1) {
|
|
1331
|
-
const texts = transliterationNodes[i].getElementsByTagName('text');
|
|
1332
|
-
for (let j = 0; j < texts.length; j += 1) {
|
|
1333
|
-
const textNode = texts[j];
|
|
1334
|
-
const key = textNode.getAttribute('for');
|
|
1335
|
-
if (key && textNode.textContent) {
|
|
1336
|
-
transliterations[key] = textNode.textContent
|
|
1337
|
-
.trim()
|
|
1338
|
-
.replace(/\s+/g, ' ');
|
|
1339
|
-
}
|
|
1340
|
-
}
|
|
1341
|
-
}
|
|
1342
1387
|
const timeToMs = (timeStr) => {
|
|
1343
1388
|
if (!timeStr)
|
|
1344
1389
|
return 0;
|
|
@@ -1358,6 +1403,52 @@ class AmLyrics extends i {
|
|
|
1358
1403
|
}
|
|
1359
1404
|
return Math.round(seconds * 1000);
|
|
1360
1405
|
};
|
|
1406
|
+
const transliterationNodes = doc.getElementsByTagName('transliteration');
|
|
1407
|
+
for (let i = 0; i < transliterationNodes.length; i += 1) {
|
|
1408
|
+
const texts = transliterationNodes[i].getElementsByTagName('text');
|
|
1409
|
+
for (let j = 0; j < texts.length; j += 1) {
|
|
1410
|
+
const textNode = texts[j];
|
|
1411
|
+
const key = textNode.getAttribute('for');
|
|
1412
|
+
if (!key) {
|
|
1413
|
+
// eslint-disable-next-line no-continue
|
|
1414
|
+
continue;
|
|
1415
|
+
}
|
|
1416
|
+
const spans = Array.from(textNode.getElementsByTagName('span')).filter(span => span.getAttribute('begin'));
|
|
1417
|
+
if (spans.length > 0) {
|
|
1418
|
+
const syllabus = [];
|
|
1419
|
+
let fullText = '';
|
|
1420
|
+
for (let k = 0; k < spans.length; k += 1) {
|
|
1421
|
+
const span = spans[k];
|
|
1422
|
+
const begin = span.getAttribute('begin');
|
|
1423
|
+
const end = span.getAttribute('end');
|
|
1424
|
+
let spanText = span.textContent || '';
|
|
1425
|
+
const nextNode = span.nextSibling;
|
|
1426
|
+
if (nextNode &&
|
|
1427
|
+
nextNode.nodeType === 3 &&
|
|
1428
|
+
/^\s/.test(nextNode.textContent || '') &&
|
|
1429
|
+
!spanText.endsWith(' ')) {
|
|
1430
|
+
spanText += ' ';
|
|
1431
|
+
}
|
|
1432
|
+
if (spanText.trim() === '') {
|
|
1433
|
+
// eslint-disable-next-line no-continue
|
|
1434
|
+
continue;
|
|
1435
|
+
}
|
|
1436
|
+
syllabus.push({
|
|
1437
|
+
time: timeToMs(begin),
|
|
1438
|
+
duration: timeToMs(end) - timeToMs(begin),
|
|
1439
|
+
text: spanText,
|
|
1440
|
+
});
|
|
1441
|
+
fullText += spanText;
|
|
1442
|
+
}
|
|
1443
|
+
transliterations[key] = { text: fullText.trim(), syllabus };
|
|
1444
|
+
}
|
|
1445
|
+
else if (textNode.textContent) {
|
|
1446
|
+
transliterations[key] = {
|
|
1447
|
+
text: textNode.textContent.trim().replace(/\s+/g, ' '),
|
|
1448
|
+
};
|
|
1449
|
+
}
|
|
1450
|
+
}
|
|
1451
|
+
}
|
|
1361
1452
|
const lines = [];
|
|
1362
1453
|
const pNodes = doc.getElementsByTagName('p');
|
|
1363
1454
|
const lineSingers = [];
|
|
@@ -1435,6 +1526,64 @@ class AmLyrics extends i {
|
|
|
1435
1526
|
});
|
|
1436
1527
|
}
|
|
1437
1528
|
const alignment = alignments[i];
|
|
1529
|
+
// Distribute line-level transliteration to individual syllables
|
|
1530
|
+
// so that per-syllable animated romanisation works (like KPoe lyrics)
|
|
1531
|
+
const lineTransliterationItem = key ? transliterations[key] : undefined;
|
|
1532
|
+
if (lineTransliterationItem &&
|
|
1533
|
+
mainSyllables.length > 1 &&
|
|
1534
|
+
spans.length > 0) {
|
|
1535
|
+
if (lineTransliterationItem.syllabus &&
|
|
1536
|
+
lineTransliterationItem.syllabus.length === mainSyllables.length) {
|
|
1537
|
+
mainSyllables.forEach((syl, mapIdx) => {
|
|
1538
|
+
// eslint-disable-next-line no-param-reassign
|
|
1539
|
+
syl.romanizedText = lineTransliterationItem.syllabus[mapIdx].text;
|
|
1540
|
+
});
|
|
1541
|
+
}
|
|
1542
|
+
else {
|
|
1543
|
+
const lineTransliteration = lineTransliterationItem.text;
|
|
1544
|
+
const romanWords = lineTransliteration.split(/\s+/).filter(Boolean);
|
|
1545
|
+
const syllableGroups = [];
|
|
1546
|
+
for (let si = 0; si < mainSyllables.length; si += 1) {
|
|
1547
|
+
if (mainSyllables[si].part && syllableGroups.length > 0) {
|
|
1548
|
+
syllableGroups[syllableGroups.length - 1].push(si);
|
|
1549
|
+
}
|
|
1550
|
+
else {
|
|
1551
|
+
syllableGroups.push([si]);
|
|
1552
|
+
}
|
|
1553
|
+
}
|
|
1554
|
+
const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(mainSyllables.map(s => s.text).join(''));
|
|
1555
|
+
if (romanWords.length === syllableGroups.length) {
|
|
1556
|
+
syllableGroups.forEach((group, gi) => {
|
|
1557
|
+
// eslint-disable-next-line no-param-reassign
|
|
1558
|
+
mainSyllables[group[0]].romanizedText = romanWords[gi];
|
|
1559
|
+
});
|
|
1560
|
+
}
|
|
1561
|
+
else if (romanWords.length === mainSyllables.length) {
|
|
1562
|
+
mainSyllables.forEach((syl, mapIdx) => {
|
|
1563
|
+
// eslint-disable-next-line no-param-reassign
|
|
1564
|
+
syl.romanizedText = romanWords[mapIdx];
|
|
1565
|
+
});
|
|
1566
|
+
}
|
|
1567
|
+
else if (isCJK) {
|
|
1568
|
+
let romanIdx = 0;
|
|
1569
|
+
for (const group of syllableGroups) {
|
|
1570
|
+
const syl = mainSyllables[group[0]];
|
|
1571
|
+
const sylText = group
|
|
1572
|
+
.map(gIndex => mainSyllables[gIndex].text)
|
|
1573
|
+
.join('');
|
|
1574
|
+
const validChars = sylText.match(/[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7afA-Za-z0-9]/g) || [];
|
|
1575
|
+
const needed = validChars.length;
|
|
1576
|
+
if (needed > 0 && romanIdx < romanWords.length) {
|
|
1577
|
+
// eslint-disable-next-line no-param-reassign
|
|
1578
|
+
syl.romanizedText = romanWords
|
|
1579
|
+
.slice(romanIdx, romanIdx + needed)
|
|
1580
|
+
.join(' ');
|
|
1581
|
+
romanIdx += needed;
|
|
1582
|
+
}
|
|
1583
|
+
}
|
|
1584
|
+
}
|
|
1585
|
+
}
|
|
1586
|
+
}
|
|
1438
1587
|
lines.push({
|
|
1439
1588
|
text: mainSyllables,
|
|
1440
1589
|
background: bgSyllables.length > 0,
|
|
@@ -1445,7 +1594,7 @@ class AmLyrics extends i {
|
|
|
1445
1594
|
alignment,
|
|
1446
1595
|
songPart,
|
|
1447
1596
|
translation: key ? translations[key] : undefined,
|
|
1448
|
-
romanizedText:
|
|
1597
|
+
romanizedText: lineTransliterationItem?.text,
|
|
1449
1598
|
oppositeTurn: alignment === 'end',
|
|
1450
1599
|
});
|
|
1451
1600
|
}
|
|
@@ -1590,8 +1739,8 @@ class AmLyrics extends i {
|
|
|
1590
1739
|
// Use wheel/touchmove which are guaranteed to be user initiated,
|
|
1591
1740
|
// unlike 'scroll' which fires for both user and programmatic/inertia
|
|
1592
1741
|
if (this.lyricsContainer) {
|
|
1593
|
-
this.lyricsContainer.addEventListener('wheel', this.
|
|
1594
|
-
this.lyricsContainer.addEventListener('touchmove', this.
|
|
1742
|
+
this.lyricsContainer.addEventListener('wheel', this._boundHandleUserScroll, { passive: true });
|
|
1743
|
+
this.lyricsContainer.addEventListener('touchmove', this._boundHandleUserScroll, { passive: true });
|
|
1595
1744
|
}
|
|
1596
1745
|
}
|
|
1597
1746
|
/**
|
|
@@ -1602,12 +1751,12 @@ class AmLyrics extends i {
|
|
|
1602
1751
|
*/
|
|
1603
1752
|
_onTimeChanged(oldTime, newTime) {
|
|
1604
1753
|
const timeDiff = Math.abs(newTime - oldTime);
|
|
1754
|
+
const isSeek = timeDiff > SEEK_THRESHOLD_MS;
|
|
1605
1755
|
const newActiveLines = this.findActiveLineIndices(newTime);
|
|
1606
1756
|
const oldActiveLines = this.activeLineIndices;
|
|
1607
1757
|
// Reset animation if active lines change or if we skip time.
|
|
1608
|
-
// A threshold of 0.5s (500ms) is used to detect a "skip".
|
|
1609
1758
|
const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines);
|
|
1610
|
-
if (linesChanged ||
|
|
1759
|
+
if (linesChanged || isSeek) {
|
|
1611
1760
|
// Imperatively manage 'active' class so that scroll-animate and other
|
|
1612
1761
|
// imperative classes are never clobbered.
|
|
1613
1762
|
if (this.lyricsContainer) {
|
|
@@ -1631,20 +1780,13 @@ class AmLyrics extends i {
|
|
|
1631
1780
|
}
|
|
1632
1781
|
}
|
|
1633
1782
|
}
|
|
1634
|
-
|
|
1635
|
-
|
|
1636
|
-
// Update position classes BEFORE scrolling so currentPrimaryActiveLine is current
|
|
1637
|
-
if (this.lyricsContainer && this.activeLineIndices.length > 0) {
|
|
1638
|
-
const primaryLineIndex = this.activeLineIndices[0];
|
|
1639
|
-
const primaryLine = this.lyricsContainer.querySelector(`#lyrics-line-${primaryLineIndex}`);
|
|
1640
|
-
if (primaryLine && primaryLine !== this.currentPrimaryActiveLine) {
|
|
1641
|
-
this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
|
|
1642
|
-
this.currentPrimaryActiveLine = primaryLine;
|
|
1643
|
-
this.updatePositionClasses(primaryLine);
|
|
1783
|
+
if (newActiveLines.length > 0) {
|
|
1784
|
+
this.clearPreActiveClasses();
|
|
1644
1785
|
}
|
|
1645
1786
|
}
|
|
1787
|
+
this.startAnimationFromTime(newTime);
|
|
1646
1788
|
// Trigger scroll imperatively (was previously in updated() via @state)
|
|
1647
|
-
this._handleActiveLineScroll(oldActiveLines);
|
|
1789
|
+
this._handleActiveLineScroll(oldActiveLines, isSeek);
|
|
1648
1790
|
}
|
|
1649
1791
|
// YouLyPlus-style syllable animation updates
|
|
1650
1792
|
if (this.lyricsContainer) {
|
|
@@ -1669,7 +1811,7 @@ class AmLyrics extends i {
|
|
|
1669
1811
|
const isActive = gap.classList.contains('active');
|
|
1670
1812
|
const isExiting = gap.classList.contains('gap-exiting');
|
|
1671
1813
|
// Start exit animation early so it completes before the next lyric
|
|
1672
|
-
const exitLeadMs =
|
|
1814
|
+
const exitLeadMs = GAP_EXIT_LEAD_MS;
|
|
1673
1815
|
const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs;
|
|
1674
1816
|
if (shouldBeActive && !isActive && !isExiting) {
|
|
1675
1817
|
// Entering gap: remove any leftover exit state, add active
|
|
@@ -1694,13 +1836,6 @@ class AmLyrics extends i {
|
|
|
1694
1836
|
AmLyrics.updateSyllableAnimation(dot);
|
|
1695
1837
|
}
|
|
1696
1838
|
});
|
|
1697
|
-
// Scroll to the gap element so dots animate in with
|
|
1698
|
-
// the staggered scroll rather than popping in.
|
|
1699
|
-
if (this.autoScroll &&
|
|
1700
|
-
!this.isUserScrolling &&
|
|
1701
|
-
!this.isClickSeeking) {
|
|
1702
|
-
this.scrollToActiveLineYouLy(gap);
|
|
1703
|
-
}
|
|
1704
1839
|
}
|
|
1705
1840
|
else if (shouldStartExiting) {
|
|
1706
1841
|
// Exiting gap: keep visible while dots animate out
|
|
@@ -1709,7 +1844,7 @@ class AmLyrics extends i {
|
|
|
1709
1844
|
// After exit animation completes, remove gap-exiting to collapse
|
|
1710
1845
|
setTimeout(() => {
|
|
1711
1846
|
gap.classList.remove('gap-exiting');
|
|
1712
|
-
},
|
|
1847
|
+
}, GAP_EXIT_LEAD_MS);
|
|
1713
1848
|
}
|
|
1714
1849
|
else if (isActive && !shouldBeActive) {
|
|
1715
1850
|
// NEW: Immediate cleanup if we seeked out of valid range
|
|
@@ -1729,48 +1864,32 @@ class AmLyrics extends i {
|
|
|
1729
1864
|
else if (this.lastInstrumentalIndex !== null) {
|
|
1730
1865
|
this.lastInstrumentalIndex = null;
|
|
1731
1866
|
}
|
|
1732
|
-
// Update position classes for YouLyPlus blur/opacity effect
|
|
1733
|
-
// (only needed when lines didn't change — when they DID change,
|
|
1734
|
-
// position classes are already updated above before scrolling)
|
|
1735
|
-
if (!linesChanged && this.activeLineIndices.length > 0) {
|
|
1736
|
-
const primaryLineIndex = this.activeLineIndices[0];
|
|
1737
|
-
const primaryLine = this.lyricsContainer.querySelector(`#lyrics-line-${primaryLineIndex}`);
|
|
1738
|
-
if (primaryLine && primaryLine !== this.currentPrimaryActiveLine) {
|
|
1739
|
-
this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
|
|
1740
|
-
this.currentPrimaryActiveLine = primaryLine;
|
|
1741
|
-
this.updatePositionClasses(primaryLine);
|
|
1742
|
-
}
|
|
1743
|
-
}
|
|
1744
1867
|
// Pre-scroll: scroll to upcoming line ~0.5s before it starts
|
|
1745
1868
|
if (this.autoScroll &&
|
|
1746
1869
|
!this.isUserScrolling &&
|
|
1747
1870
|
!this.isClickSeeking &&
|
|
1748
1871
|
this.lyrics) {
|
|
1749
|
-
const preScrollLeadMs = 500; // 500ms lead time
|
|
1750
1872
|
// Condition: ONLY pre-scroll if no other lyric is currently playing.
|
|
1751
1873
|
// If a lyric is playing, we must wait for it to finish (handled by updated()).
|
|
1752
1874
|
if (this.activeLineIndices.length === 0) {
|
|
1875
|
+
let preActiveLineIndex = null;
|
|
1753
1876
|
for (let i = 0; i < this.lyrics.length; i += 1) {
|
|
1754
1877
|
const line = this.lyrics[i];
|
|
1755
1878
|
const timeUntilStart = line.timestamp - newTime;
|
|
1756
1879
|
const nextLineEl = this.lyricsContainer.querySelector(`#lyrics-line-${i}`);
|
|
1757
|
-
if (timeUntilStart > 0 && timeUntilStart <=
|
|
1880
|
+
if (timeUntilStart > 0 && timeUntilStart <= PRE_SCROLL_LEAD_MS) {
|
|
1758
1881
|
// Time to pre-scroll and pre-activate!
|
|
1759
1882
|
if (nextLineEl) {
|
|
1760
1883
|
// Apply unblur & zoom effect ahead of lyric start
|
|
1884
|
+
preActiveLineIndex = i;
|
|
1761
1885
|
nextLineEl.classList.add('pre-active');
|
|
1762
|
-
|
|
1763
|
-
|
|
1764
|
-
this.scrollToActiveLineYouLy(nextLineEl);
|
|
1765
|
-
}
|
|
1886
|
+
this.clearPreActiveClasses(i);
|
|
1887
|
+
this.focusLine(nextLineEl);
|
|
1766
1888
|
}
|
|
1767
1889
|
break;
|
|
1768
1890
|
}
|
|
1769
|
-
else if (nextLineEl) {
|
|
1770
|
-
// Ensure lines outside the pre-scroll window don't stay pre-active
|
|
1771
|
-
nextLineEl.classList.remove('pre-active');
|
|
1772
|
-
}
|
|
1773
1891
|
}
|
|
1892
|
+
this.clearPreActiveClasses(preActiveLineIndex);
|
|
1774
1893
|
}
|
|
1775
1894
|
}
|
|
1776
1895
|
}
|
|
@@ -1834,32 +1953,16 @@ class AmLyrics extends i {
|
|
|
1834
1953
|
* Handle scrolling when active line indices change.
|
|
1835
1954
|
* Called imperatively from _onTimeChanged instead of from updated().
|
|
1836
1955
|
*/
|
|
1837
|
-
_handleActiveLineScroll(
|
|
1838
|
-
if (!this.
|
|
1839
|
-
this.isUserScrolling ||
|
|
1840
|
-
this.isClickSeeking ||
|
|
1841
|
-
this.activeLineIndices.length === 0) {
|
|
1956
|
+
_handleActiveLineScroll(_oldActiveIndices, forceScroll = false) {
|
|
1957
|
+
if (this.activeLineIndices.length === 0 || !this.lyricsContainer) {
|
|
1842
1958
|
return;
|
|
1843
1959
|
}
|
|
1844
|
-
|
|
1845
|
-
|
|
1846
|
-
if (newlyAdded.length === 0) {
|
|
1847
|
-
// Only lost lines (an overlap resolved) — don't scroll
|
|
1960
|
+
const targetLineIndex = this.getPrimaryActiveLineIndex(this.activeLineIndices);
|
|
1961
|
+
if (targetLineIndex === null)
|
|
1848
1962
|
return;
|
|
1849
|
-
}
|
|
1850
|
-
// New lines were added — scroll to the latest newly-added line.
|
|
1851
|
-
// Previous overlap logic skipped every other line for songs with tiny
|
|
1852
|
-
// timing overlaps between consecutive lines, causing a visible glitch.
|
|
1853
|
-
const latestNewIndex = newlyAdded[newlyAdded.length - 1];
|
|
1854
|
-
const targetLine = this.lyricsContainer?.querySelector(`#lyrics-line-${latestNewIndex}`);
|
|
1963
|
+
const targetLine = this.lyricsContainer.querySelector(`#lyrics-line-${targetLineIndex}`);
|
|
1855
1964
|
if (targetLine) {
|
|
1856
|
-
this.
|
|
1857
|
-
}
|
|
1858
|
-
else if (this.currentPrimaryActiveLine) {
|
|
1859
|
-
this.scrollToActiveLineYouLy(this.currentPrimaryActiveLine);
|
|
1860
|
-
}
|
|
1861
|
-
else {
|
|
1862
|
-
this.scrollToActiveLine();
|
|
1965
|
+
this.focusLine(targetLine, forceScroll);
|
|
1863
1966
|
}
|
|
1864
1967
|
}
|
|
1865
1968
|
_getTextWidth(text, font) {
|
|
@@ -1933,6 +2036,62 @@ class AmLyrics extends i {
|
|
|
1933
2036
|
static arraysEqual(a, b) {
|
|
1934
2037
|
return a.length === b.length && a.every((val, i) => val === b[i]);
|
|
1935
2038
|
}
|
|
2039
|
+
static getLineIndexFromElement(lineElement) {
|
|
2040
|
+
if (!lineElement)
|
|
2041
|
+
return null;
|
|
2042
|
+
const match = lineElement.id.match(/^lyrics-line-(\d+)$/);
|
|
2043
|
+
return match ? parseInt(match[1], 10) : null;
|
|
2044
|
+
}
|
|
2045
|
+
static getGapLoopDelay(gapDuration) {
|
|
2046
|
+
const desiredPhase = GAP_PULSE_DURATION_MS;
|
|
2047
|
+
const targetTime = gapDuration - GAP_EXIT_LEAD_MS;
|
|
2048
|
+
const normalizedTarget = ((targetTime % GAP_PULSE_CYCLE_MS) + GAP_PULSE_CYCLE_MS) %
|
|
2049
|
+
GAP_PULSE_CYCLE_MS;
|
|
2050
|
+
return ((((desiredPhase - normalizedTarget) % GAP_PULSE_CYCLE_MS) +
|
|
2051
|
+
GAP_PULSE_CYCLE_MS) %
|
|
2052
|
+
GAP_PULSE_CYCLE_MS);
|
|
2053
|
+
}
|
|
2054
|
+
clearPreActiveClasses(exceptLineIndex = null) {
|
|
2055
|
+
if (!this.lyricsContainer)
|
|
2056
|
+
return;
|
|
2057
|
+
this.lyricsContainer
|
|
2058
|
+
.querySelectorAll('.lyrics-line.pre-active')
|
|
2059
|
+
.forEach(element => {
|
|
2060
|
+
const lineElement = element;
|
|
2061
|
+
const lineIndex = AmLyrics.getLineIndexFromElement(lineElement);
|
|
2062
|
+
if (lineIndex !== exceptLineIndex) {
|
|
2063
|
+
lineElement.classList.remove('pre-active');
|
|
2064
|
+
}
|
|
2065
|
+
});
|
|
2066
|
+
}
|
|
2067
|
+
getPrimaryActiveLineIndex(activeIndices) {
|
|
2068
|
+
if (activeIndices.length === 0)
|
|
2069
|
+
return null;
|
|
2070
|
+
const groupStart = activeIndices[0];
|
|
2071
|
+
const groupEnd = activeIndices[activeIndices.length - 1];
|
|
2072
|
+
let candidateIndex = Math.max(groupStart, groupEnd - 2);
|
|
2073
|
+
const currentPrimaryIndex = AmLyrics.getLineIndexFromElement(this.currentPrimaryActiveLine);
|
|
2074
|
+
if (currentPrimaryIndex !== null &&
|
|
2075
|
+
activeIndices.includes(currentPrimaryIndex) &&
|
|
2076
|
+
candidateIndex < currentPrimaryIndex) {
|
|
2077
|
+
candidateIndex = currentPrimaryIndex;
|
|
2078
|
+
}
|
|
2079
|
+
return candidateIndex;
|
|
2080
|
+
}
|
|
2081
|
+
focusLine(lineElement, forceScroll = false) {
|
|
2082
|
+
const primaryChanged = lineElement !== this.currentPrimaryActiveLine;
|
|
2083
|
+
if (primaryChanged) {
|
|
2084
|
+
this.lastPrimaryActiveLine = this.currentPrimaryActiveLine;
|
|
2085
|
+
this.currentPrimaryActiveLine = lineElement;
|
|
2086
|
+
}
|
|
2087
|
+
this.updatePositionClasses(lineElement);
|
|
2088
|
+
if ((forceScroll || primaryChanged) &&
|
|
2089
|
+
this.autoScroll &&
|
|
2090
|
+
!this.isUserScrolling &&
|
|
2091
|
+
!this.isClickSeeking) {
|
|
2092
|
+
this.scrollToActiveLineYouLy(lineElement, forceScroll);
|
|
2093
|
+
}
|
|
2094
|
+
}
|
|
1936
2095
|
handleUserScroll() {
|
|
1937
2096
|
// Ignore programmatic scrolls and click-seek scrolls
|
|
1938
2097
|
if (this.isProgrammaticScroll || this.isClickSeeking) {
|
|
@@ -2202,7 +2361,8 @@ class AmLyrics extends i {
|
|
|
2202
2361
|
this.activeLineIds.clear();
|
|
2203
2362
|
this.animatingLines = [];
|
|
2204
2363
|
// Find the clicked line element and scroll to it with forceScroll (like YouLyPlus)
|
|
2205
|
-
|
|
2364
|
+
// Timestamps are already in milliseconds — match the data-start-time attribute directly
|
|
2365
|
+
const clickedLineElement = this.lyricsContainer?.querySelector(`.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`);
|
|
2206
2366
|
if (clickedLineElement && this.lyricsContainer) {
|
|
2207
2367
|
// Update active line reference to the clicked line
|
|
2208
2368
|
this.currentPrimaryActiveLine = clickedLineElement;
|
|
@@ -2328,11 +2488,10 @@ class AmLyrics extends i {
|
|
|
2328
2488
|
}
|
|
2329
2489
|
const { animatingLines } = this;
|
|
2330
2490
|
const targetTop = Math.max(0, -newTranslateY);
|
|
2331
|
-
|
|
2332
|
-
// The || operator treats 0 as falsy, which caused bounce when scrollTop was 0
|
|
2491
|
+
const appliedTranslateY = -targetTop;
|
|
2333
2492
|
const prevOffset = -parent.scrollTop;
|
|
2334
|
-
const delta = prevOffset -
|
|
2335
|
-
this.currentScrollOffset =
|
|
2493
|
+
const delta = prevOffset - appliedTranslateY;
|
|
2494
|
+
this.currentScrollOffset = appliedTranslateY;
|
|
2336
2495
|
// Skip animation if already at the target position (e.g., first lines at top)
|
|
2337
2496
|
if (Math.abs(parent.scrollTop - targetTop) < 1 && Math.abs(delta) < 1) {
|
|
2338
2497
|
animState.isAnimating = false;
|
|
@@ -2345,6 +2504,7 @@ class AmLyrics extends i {
|
|
|
2345
2504
|
line.classList.remove('scroll-animate');
|
|
2346
2505
|
line.style.removeProperty('--scroll-delta');
|
|
2347
2506
|
line.style.removeProperty('--lyrics-line-delay');
|
|
2507
|
+
line.style.removeProperty('--scroll-duration');
|
|
2348
2508
|
}
|
|
2349
2509
|
animatingLines.length = 0;
|
|
2350
2510
|
parent.scrollTo({ top: targetTop, behavior: 'smooth' });
|
|
@@ -2368,7 +2528,7 @@ class AmLyrics extends i {
|
|
|
2368
2528
|
const referenceIndex = lineArray.indexOf(referenceLine);
|
|
2369
2529
|
if (referenceIndex === -1)
|
|
2370
2530
|
return;
|
|
2371
|
-
const delayIncrement =
|
|
2531
|
+
const delayIncrement = SCROLL_DELAY_INCREMENT_MS;
|
|
2372
2532
|
const lookBehind = 10;
|
|
2373
2533
|
const lookAhead = 15;
|
|
2374
2534
|
const len = lineArray.length;
|
|
@@ -2385,8 +2545,9 @@ class AmLyrics extends i {
|
|
|
2385
2545
|
const delay = i >= referenceIndex ? (delayCounter - 1) * delayIncrement : 0;
|
|
2386
2546
|
line.style.setProperty('--scroll-delta', `${delta}px`);
|
|
2387
2547
|
line.style.setProperty('--lyrics-line-delay', `${delay}ms`);
|
|
2548
|
+
line.style.setProperty('--scroll-duration', `${SCROLL_ANIMATION_DURATION_MS}ms`);
|
|
2388
2549
|
newAnimatingLines.push(line);
|
|
2389
|
-
const lineDuration =
|
|
2550
|
+
const lineDuration = SCROLL_ANIMATION_DURATION_MS + delay;
|
|
2390
2551
|
if (lineDuration > maxAnimationDuration) {
|
|
2391
2552
|
maxAnimationDuration = lineDuration;
|
|
2392
2553
|
}
|
|
@@ -2401,7 +2562,7 @@ class AmLyrics extends i {
|
|
|
2401
2562
|
animatingLines.push(line);
|
|
2402
2563
|
}
|
|
2403
2564
|
animState.isAnimating = true;
|
|
2404
|
-
const BASE_DURATION =
|
|
2565
|
+
const BASE_DURATION = SCROLL_ANIMATION_DURATION_MS;
|
|
2405
2566
|
this.scrollUnlockTimeout = setTimeout(() => {
|
|
2406
2567
|
animState.isAnimating = false;
|
|
2407
2568
|
if (animState.pendingUpdate !== null) {
|
|
@@ -2416,6 +2577,7 @@ class AmLyrics extends i {
|
|
|
2416
2577
|
line.classList.remove('scroll-animate');
|
|
2417
2578
|
line.style.removeProperty('--scroll-delta');
|
|
2418
2579
|
line.style.removeProperty('--lyrics-line-delay');
|
|
2580
|
+
line.style.removeProperty('--scroll-duration');
|
|
2419
2581
|
}
|
|
2420
2582
|
animatingLines.length = 0;
|
|
2421
2583
|
this.scrollAnimationTimeout = undefined;
|
|
@@ -2497,7 +2659,7 @@ class AmLyrics extends i {
|
|
|
2497
2659
|
}
|
|
2498
2660
|
setTimeout(() => {
|
|
2499
2661
|
this.isProgrammaticScroll = false;
|
|
2500
|
-
},
|
|
2662
|
+
}, SCROLL_ANIMATION_DURATION_MS + 160);
|
|
2501
2663
|
this.animateScrollYouLy(targetTranslateY, forceScroll);
|
|
2502
2664
|
}
|
|
2503
2665
|
/**
|
|
@@ -2526,26 +2688,46 @@ class AmLyrics extends i {
|
|
|
2526
2688
|
// Use a Map to collect animations like YouLyPlus
|
|
2527
2689
|
const charAnimationsMap = new Map();
|
|
2528
2690
|
const styleUpdates = [];
|
|
2529
|
-
// Step 1
|
|
2691
|
+
// Step 1 & 2: Apply animations
|
|
2530
2692
|
if (isGrowable && isFirstSyllable && allWordCharSpans.length > 0) {
|
|
2531
|
-
|
|
2532
|
-
|
|
2533
|
-
const
|
|
2534
|
-
allWordCharSpans.forEach(span => {
|
|
2693
|
+
// Glow AND wipe applied to ALL characters simultaneously from the first syllable
|
|
2694
|
+
// This prevents CSS animation restarts because the `animation` property is set once.
|
|
2695
|
+
const firstSyllableStartTime = parseFloat(syllable.getAttribute('data-start-time') || '0');
|
|
2696
|
+
allWordCharSpans.forEach((span, charIndexInWord) => {
|
|
2535
2697
|
const horizontalOffset = parseFloat(span.dataset.horizontalOffset || '0');
|
|
2536
|
-
// Use syllableCharIndex like YouLyPlus, not loop index
|
|
2537
|
-
const charIndex = parseFloat(span.dataset.syllableCharIndex || '0');
|
|
2538
|
-
const growDelay = baseDelayPerChar * charIndex;
|
|
2539
|
-
// READ DATA ATTRIBUTES for style values
|
|
2540
2698
|
const maxScale = span.dataset.maxScale || '1.1';
|
|
2541
2699
|
const shadowIntensity = span.dataset.shadowIntensity || '0.6';
|
|
2542
2700
|
const translateYPeak = span.dataset.translateYPeak || '-2';
|
|
2543
|
-
|
|
2544
|
-
|
|
2701
|
+
const animationParts = [];
|
|
2702
|
+
const parentSyllable = span.closest('.lyrics-syllable');
|
|
2703
|
+
if (parentSyllable) {
|
|
2704
|
+
const parentDuration = parseFloat(parentSyllable.getAttribute('data-duration') || '0');
|
|
2705
|
+
const parentStartTime = parseFloat(parentSyllable.getAttribute('data-start-time') || '0');
|
|
2706
|
+
const startPct = parseFloat(span.dataset.wipeStart || '0');
|
|
2707
|
+
const durationPct = parseFloat(span.dataset.wipeDuration || '0');
|
|
2708
|
+
const relativeStartOffset = Math.max(0, parentStartTime - firstSyllableStartTime);
|
|
2709
|
+
const wipeDelay = relativeStartOffset + parentDuration * startPct;
|
|
2710
|
+
const wipeDuration = parentDuration * durationPct;
|
|
2711
|
+
const useStartAnimation = isFirstInContainer && charIndexInWord === 0;
|
|
2712
|
+
let charWipeAnimation = 'wipe';
|
|
2713
|
+
if (useStartAnimation)
|
|
2714
|
+
charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
|
|
2715
|
+
else
|
|
2716
|
+
charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
|
|
2717
|
+
// Blend word and syllable durations to let the gradient flow smoothly
|
|
2718
|
+
// while still responding to syllable pacing (no strict exactness, just natural flow)
|
|
2719
|
+
const growDelay = wipeDelay;
|
|
2720
|
+
const growDurationMs = Math.max(600, wordDurationMs * 0.8 + parentDuration * 1.5);
|
|
2721
|
+
animationParts.push(`grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`);
|
|
2722
|
+
if (wipeDuration > 0) {
|
|
2723
|
+
animationParts.push(`${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
|
|
2724
|
+
}
|
|
2725
|
+
}
|
|
2726
|
+
charAnimationsMap.set(span, animationParts.join(', '));
|
|
2545
2727
|
styleUpdates.push({
|
|
2546
2728
|
element: span,
|
|
2547
2729
|
property: '--char-offset-x',
|
|
2548
|
-
value: `${horizontalOffset}`,
|
|
2730
|
+
value: `${horizontalOffset}`,
|
|
2549
2731
|
});
|
|
2550
2732
|
styleUpdates.push({
|
|
2551
2733
|
element: span,
|
|
@@ -2560,58 +2742,64 @@ class AmLyrics extends i {
|
|
|
2560
2742
|
styleUpdates.push({
|
|
2561
2743
|
element: span,
|
|
2562
2744
|
property: '--translate-y-peak',
|
|
2563
|
-
value: `${translateYPeak}`,
|
|
2745
|
+
value: `${translateYPeak}`,
|
|
2564
2746
|
});
|
|
2565
2747
|
});
|
|
2566
2748
|
}
|
|
2567
|
-
|
|
2568
|
-
|
|
2569
|
-
//
|
|
2749
|
+
else if (isGrowable && !isFirstSyllable && charSpans.length > 0) {
|
|
2750
|
+
// For subsequent syllables of a growable word:
|
|
2751
|
+
// If they already have `grow-dynamic`, it means the first syllable correctly took care of BOTH grow and wipe!
|
|
2752
|
+
// Otherwise, they scrubbed directly into this syllable, so let's at least do the wipe.
|
|
2753
|
+
charSpans.forEach(span => {
|
|
2754
|
+
const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
|
|
2755
|
+
if (existingAnimation.includes('grow-dynamic'))
|
|
2756
|
+
return;
|
|
2757
|
+
const startPct = parseFloat(span.dataset.wipeStart || '0');
|
|
2758
|
+
const durationPct = parseFloat(span.dataset.wipeDuration || '0');
|
|
2759
|
+
const wipeDelay = syllableDurationMs * startPct;
|
|
2760
|
+
const wipeDuration = syllableDurationMs * durationPct;
|
|
2761
|
+
const charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
|
|
2762
|
+
if (wipeDuration > 0) {
|
|
2763
|
+
charAnimationsMap.set(span, `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
|
|
2764
|
+
}
|
|
2765
|
+
});
|
|
2766
|
+
}
|
|
2767
|
+
else if (charSpans.length > 0) {
|
|
2768
|
+
// Per-character wipe for non-growable words (matching YouLyPlus)
|
|
2570
2769
|
charSpans.forEach((span, charIndex) => {
|
|
2571
2770
|
const startPct = parseFloat(span.dataset.wipeStart || '0');
|
|
2572
2771
|
const durationPct = parseFloat(span.dataset.wipeDuration || '0');
|
|
2573
2772
|
const wipeDelay = syllableDurationMs * startPct;
|
|
2574
2773
|
const wipeDuration = syllableDurationMs * durationPct;
|
|
2575
2774
|
const useStartAnimation = isFirstInContainer && charIndex === 0;
|
|
2576
|
-
let charWipeAnimation;
|
|
2775
|
+
let charWipeAnimation = 'wipe';
|
|
2577
2776
|
if (useStartAnimation) {
|
|
2578
2777
|
charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
|
|
2579
2778
|
}
|
|
2580
2779
|
else {
|
|
2581
2780
|
charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
|
|
2582
2781
|
}
|
|
2583
|
-
// Get existing animation from map (grow-dynamic) and combine with wipe
|
|
2584
|
-
const existingAnimation = charAnimationsMap.get(span) || span.style.animation || '';
|
|
2585
|
-
const animationParts = [];
|
|
2586
|
-
if (existingAnimation && existingAnimation.includes('grow-dynamic')) {
|
|
2587
|
-
animationParts.push(existingAnimation.split(',')[0].trim());
|
|
2588
|
-
}
|
|
2589
2782
|
if (wipeDuration > 0) {
|
|
2590
|
-
|
|
2783
|
+
charAnimationsMap.set(span, `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`);
|
|
2591
2784
|
}
|
|
2592
|
-
charAnimationsMap.set(span, animationParts.join(', '));
|
|
2593
2785
|
});
|
|
2594
2786
|
}
|
|
2595
2787
|
else {
|
|
2596
|
-
// Syllable-level wipe for regular (non-growable) words
|
|
2788
|
+
// Syllable-level wipe for regular (non-growable) words without chars
|
|
2597
2789
|
const wipeRatio = parseFloat(syllable.getAttribute('data-wipe-ratio') || '1');
|
|
2598
2790
|
const visualDuration = syllableDurationMs * wipeRatio;
|
|
2599
|
-
let wipeAnimation;
|
|
2791
|
+
let wipeAnimation = 'wipe';
|
|
2600
2792
|
if (isFirstInContainer) {
|
|
2601
2793
|
wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe';
|
|
2602
2794
|
}
|
|
2603
2795
|
else {
|
|
2604
2796
|
wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe';
|
|
2605
2797
|
}
|
|
2606
|
-
if (syllable.classList.contains('line-synced'))
|
|
2607
|
-
// If line-synced, just add the class for CSS animation, or ensure valid state
|
|
2608
|
-
// The CSS rule .lyrics-syllable.line-synced handles the fade
|
|
2798
|
+
if (syllable.classList.contains('line-synced'))
|
|
2609
2799
|
return;
|
|
2610
|
-
}
|
|
2611
2800
|
const currentWipeAnimation = isGap ? 'fade-gap' : wipeAnimation;
|
|
2612
|
-
const syllableAnimation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
|
|
2613
2801
|
// eslint-disable-next-line no-param-reassign
|
|
2614
|
-
syllable.style.animation =
|
|
2802
|
+
syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} forwards`;
|
|
2615
2803
|
}
|
|
2616
2804
|
// --- WRITE PHASE ---
|
|
2617
2805
|
classList.remove('pre-highlight');
|
|
@@ -2831,7 +3019,7 @@ class AmLyrics extends i {
|
|
|
2831
3019
|
}
|
|
2832
3020
|
}
|
|
2833
3021
|
if (running) {
|
|
2834
|
-
this.animationFrameId = requestAnimationFrame(this.
|
|
3022
|
+
this.animationFrameId = requestAnimationFrame(this._boundAnimateProgress);
|
|
2835
3023
|
}
|
|
2836
3024
|
else if (this.animationFrameId) {
|
|
2837
3025
|
// Stop animation if no words are running
|
|
@@ -3066,6 +3254,7 @@ class AmLyrics extends i {
|
|
|
3066
3254
|
// the result to each individual group so it renders through the
|
|
3067
3255
|
// single-syllable path (which supports char-level glow).
|
|
3068
3256
|
const groupGrowable = new Array(wordGroups.length).fill(false);
|
|
3257
|
+
const groupGlowing = new Array(wordGroups.length).fill(false);
|
|
3069
3258
|
// Visual word info for growable char-level glow:
|
|
3070
3259
|
// Each group stores the combined visual word's text, duration, and
|
|
3071
3260
|
// the char offset of this group within the visual word.
|
|
@@ -3098,18 +3287,26 @@ class AmLyrics extends i {
|
|
|
3098
3287
|
const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test(combinedText);
|
|
3099
3288
|
const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test(combinedText);
|
|
3100
3289
|
const hasHyphen = combinedText.includes('-');
|
|
3101
|
-
const
|
|
3102
|
-
|
|
3103
|
-
|
|
3104
|
-
|
|
3105
|
-
|
|
3106
|
-
|
|
3107
|
-
|
|
3108
|
-
|
|
3109
|
-
|
|
3290
|
+
const wordLen = combinedText.length;
|
|
3291
|
+
let isGrowableVW = !isCJK && !isRTL && !hasHyphen && wordLen > 0 && wordLen <= 12;
|
|
3292
|
+
if (isGrowableVW) {
|
|
3293
|
+
if (wordLen < 3) {
|
|
3294
|
+
isGrowableVW =
|
|
3295
|
+
combinedDuration >= 1110 && combinedDuration >= wordLen * 550;
|
|
3296
|
+
}
|
|
3297
|
+
else {
|
|
3298
|
+
isGrowableVW =
|
|
3299
|
+
combinedDuration >= 800 && combinedDuration >= wordLen * 180;
|
|
3300
|
+
}
|
|
3301
|
+
}
|
|
3302
|
+
// Glow requirement (more strict)
|
|
3303
|
+
const isGlowingVW = isGrowableVW &&
|
|
3304
|
+
combinedDuration >= 1200 &&
|
|
3305
|
+
combinedDuration >= combinedText.length * 300;
|
|
3110
3306
|
let charOff = 0;
|
|
3111
3307
|
for (let gi = vwStart; gi <= vwEnd; gi += 1) {
|
|
3112
3308
|
groupGrowable[gi] = isGrowableVW;
|
|
3309
|
+
groupGlowing[gi] = isGlowingVW;
|
|
3113
3310
|
vwFullText[gi] = combinedText;
|
|
3114
3311
|
vwFullDuration[gi] = combinedDuration;
|
|
3115
3312
|
vwCharOffset[gi] = charOff;
|
|
@@ -3125,224 +3322,126 @@ class AmLyrics extends i {
|
|
|
3125
3322
|
const mainVocalElement = b `<p class="main-vocal-container">
|
|
3126
3323
|
${wordGroups.map((group, groupIdx) => {
|
|
3127
3324
|
const isGrowable = groupGrowable[groupIdx];
|
|
3128
|
-
|
|
3129
|
-
// skip continuation groups (rendered by the first group)
|
|
3130
|
-
if (isGrowable && vwCharOffset[groupIdx] > 0) {
|
|
3131
|
-
return '';
|
|
3132
|
-
}
|
|
3133
|
-
// Check if ANY syllable in group is line-synced
|
|
3325
|
+
const isGlowing = groupGlowing[groupIdx];
|
|
3134
3326
|
const groupLineSynced = group.some(s => s.lineSynced);
|
|
3135
|
-
|
|
3136
|
-
|
|
3137
|
-
|
|
3138
|
-
|
|
3139
|
-
|
|
3140
|
-
|
|
3141
|
-
|
|
3142
|
-
|
|
3143
|
-
|
|
3144
|
-
|
|
3145
|
-
|
|
3146
|
-
for (let gi = groupIdx; gi < wordGroups.length; gi += 1) {
|
|
3147
|
-
if (gi > groupIdx && vwCharOffset[gi] === 0)
|
|
3148
|
-
break;
|
|
3149
|
-
if (gi > groupIdx && !groupGrowable[gi])
|
|
3150
|
-
break;
|
|
3151
|
-
combinedRawText += wordGroups[gi].map(s => s.text).join('');
|
|
3152
|
-
}
|
|
3153
|
-
const syllableContent = b `${combinedRawText
|
|
3154
|
-
.split('')
|
|
3155
|
-
.map((char, charIndex) => {
|
|
3156
|
-
if (char === ' ') {
|
|
3157
|
-
return ' ';
|
|
3158
|
-
}
|
|
3159
|
-
const charStartPercent = charIndex / numChars;
|
|
3160
|
-
const minDuration = 1000;
|
|
3161
|
-
const maxDuration = 5000;
|
|
3162
|
-
const easingPower = 3;
|
|
3163
|
-
const progress = Math.min(1, Math.max(0, (wordDuration - minDuration) /
|
|
3164
|
-
(maxDuration - minDuration)));
|
|
3165
|
-
const easedProgress = progress ** easingPower;
|
|
3166
|
-
const isLongWord = numChars > 5;
|
|
3167
|
-
const isShortDuration = wordDuration < 1500;
|
|
3168
|
-
let maxDecayRate = 0;
|
|
3169
|
-
if (isLongWord || isShortDuration) {
|
|
3170
|
-
let decayStrength = 0;
|
|
3171
|
-
if (isLongWord)
|
|
3172
|
-
decayStrength += Math.min((numChars - 5) / 3, 1.0) * 0.4;
|
|
3173
|
-
if (isShortDuration)
|
|
3174
|
-
decayStrength +=
|
|
3175
|
-
Math.max(0, 1.0 - (wordDuration - 1000) / 500) * 0.4;
|
|
3176
|
-
maxDecayRate = Math.min(decayStrength, 0.85);
|
|
3177
|
-
}
|
|
3178
|
-
const positionInWord = numChars > 1 ? charIndex / (numChars - 1) : 0;
|
|
3179
|
-
const decayFactor = 1.0 - positionInWord * maxDecayRate;
|
|
3180
|
-
const charProgress = easedProgress * decayFactor;
|
|
3181
|
-
const baseGrowth = numChars <= 3 ? 0.07 : 0.05;
|
|
3182
|
-
const charMaxScale = 1.0 + baseGrowth + charProgress * 0.1;
|
|
3183
|
-
const charShadowIntensity = 0.4 + charProgress * 0.4;
|
|
3184
|
-
const normalizedGrowth = (charMaxScale - 1.0) / 0.13;
|
|
3185
|
-
const charTranslateYPeak = -normalizedGrowth * 6;
|
|
3186
|
-
const position = (charIndex + 0.5) / numChars;
|
|
3187
|
-
const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
|
|
3188
|
-
return b `<span
|
|
3189
|
-
class="char"
|
|
3190
|
-
data-char-index="${charIndex}"
|
|
3191
|
-
data-syllable-char-index="${charIndex}"
|
|
3192
|
-
data-wipe-start="${charStartPercent.toFixed(4)}"
|
|
3193
|
-
data-wipe-duration="${(1 / numChars).toFixed(4)}"
|
|
3194
|
-
data-horizontal-offset="${horizontalOffset.toFixed(2)}"
|
|
3195
|
-
data-max-scale="${charMaxScale.toFixed(3)}"
|
|
3196
|
-
data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
|
|
3197
|
-
data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
|
|
3198
|
-
>${char}</span
|
|
3199
|
-
>`;
|
|
3200
|
-
})}`;
|
|
3201
|
-
return b `<span class="lyrics-word growable">
|
|
3202
|
-
<span class="lyrics-syllable-wrap">
|
|
3203
|
-
<span
|
|
3204
|
-
class="lyrics-syllable ${groupLineSynced
|
|
3205
|
-
? 'line-synced'
|
|
3206
|
-
: ''}"
|
|
3207
|
-
data-start-time="${startTimeMs}"
|
|
3208
|
-
data-end-time="${endTimeMs}"
|
|
3209
|
-
data-duration="${wordDuration}"
|
|
3210
|
-
data-syllable-index="0"
|
|
3211
|
-
data-wipe-ratio="1"
|
|
3212
|
-
>${syllableContent}</span
|
|
3213
|
-
>
|
|
3214
|
-
</span>
|
|
3215
|
-
</span>`;
|
|
3216
|
-
}
|
|
3217
|
-
// For single-syllable groups, use original logic
|
|
3218
|
-
if (group.length === 1) {
|
|
3219
|
-
const syllable = group[0];
|
|
3327
|
+
const wordText = isGrowable ? vwFullText[groupIdx] : '';
|
|
3328
|
+
const wordDuration = isGrowable ? vwFullDuration[groupIdx] : 0;
|
|
3329
|
+
const wordNumChars = wordText.length;
|
|
3330
|
+
const groupCharOffset = isGrowable ? vwCharOffset[groupIdx] : 0;
|
|
3331
|
+
let sylCharAccumulator = 0;
|
|
3332
|
+
return b `<span
|
|
3333
|
+
class="lyrics-word ${isGrowable ? 'growable' : ''} ${isGlowing
|
|
3334
|
+
? 'glowing'
|
|
3335
|
+
: ''} ${group.length > 1 ? 'allow-break' : ''}"
|
|
3336
|
+
>
|
|
3337
|
+
${group.map((syllable, sylIdx) => {
|
|
3220
3338
|
const startTimeMs = syllable.timestamp;
|
|
3221
3339
|
const endTimeMs = syllable.endtime;
|
|
3222
3340
|
const durationMs = endTimeMs - startTimeMs;
|
|
3223
3341
|
const text = syllable.text || '';
|
|
3224
|
-
const trimmedText = text.trim();
|
|
3225
|
-
// Optional romanization per syllable (hide if same as the original text)
|
|
3226
3342
|
const romanizedText = this.showRomanization &&
|
|
3227
3343
|
syllable.romanizedText &&
|
|
3228
3344
|
syllable.romanizedText.trim() !== syllable.text.trim()
|
|
3229
3345
|
? b `<span
|
|
3230
|
-
|
|
3346
|
+
class="lyrics-syllable transliteration ${groupLineSynced
|
|
3231
3347
|
? 'line-synced'
|
|
3232
3348
|
: ''}"
|
|
3233
|
-
|
|
3234
|
-
|
|
3235
|
-
|
|
3236
|
-
|
|
3237
|
-
|
|
3238
|
-
|
|
3239
|
-
|
|
3349
|
+
data-start-time="${startTimeMs}"
|
|
3350
|
+
data-end-time="${endTimeMs}"
|
|
3351
|
+
data-duration="${durationMs}"
|
|
3352
|
+
data-syllable-index="0"
|
|
3353
|
+
data-wipe-ratio="1"
|
|
3354
|
+
>${syllable.romanizedText}</span
|
|
3355
|
+
>`
|
|
3240
3356
|
: '';
|
|
3241
|
-
|
|
3242
|
-
|
|
3243
|
-
|
|
3244
|
-
|
|
3357
|
+
let syllableContent = text;
|
|
3358
|
+
if (isGrowable) {
|
|
3359
|
+
let charIndexInsideSyllable = 0;
|
|
3360
|
+
const numCharsInSyllable = text.replace(/\s/g, '').length || 1;
|
|
3361
|
+
syllableContent = b `${text.split('').map(char => {
|
|
3362
|
+
if (char === ' ')
|
|
3245
3363
|
return ' ';
|
|
3246
|
-
|
|
3247
|
-
const
|
|
3248
|
-
|
|
3249
|
-
|
|
3250
|
-
const
|
|
3364
|
+
const charIndexInsideWord = groupCharOffset + sylCharAccumulator;
|
|
3365
|
+
const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable;
|
|
3366
|
+
sylCharAccumulator += 1;
|
|
3367
|
+
charIndexInsideSyllable += 1;
|
|
3368
|
+
const minDuration = 400;
|
|
3369
|
+
const maxDuration = 3000;
|
|
3251
3370
|
const easingPower = 3;
|
|
3252
|
-
const progress = Math.min(1, Math.max(0, (
|
|
3371
|
+
const progress = Math.min(1, Math.max(0, (wordDuration - minDuration) /
|
|
3253
3372
|
(maxDuration - minDuration)));
|
|
3254
3373
|
const easedProgress = progress ** easingPower;
|
|
3255
|
-
const isLongWord =
|
|
3256
|
-
const isShortDuration =
|
|
3374
|
+
const isLongWord = wordNumChars > 5;
|
|
3375
|
+
const isShortDuration = wordDuration < 1200;
|
|
3257
3376
|
let maxDecayRate = 0;
|
|
3258
3377
|
if (isLongWord || isShortDuration) {
|
|
3259
3378
|
let decayStrength = 0;
|
|
3260
3379
|
if (isLongWord)
|
|
3261
3380
|
decayStrength +=
|
|
3262
|
-
Math.min((
|
|
3263
|
-
if (isShortDuration)
|
|
3381
|
+
Math.min((wordNumChars - 5) / 5, 1.0) * 0.4;
|
|
3382
|
+
if (isShortDuration && wordNumChars > 3)
|
|
3383
|
+
decayStrength +=
|
|
3384
|
+
Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.3;
|
|
3385
|
+
else if (isShortDuration && wordNumChars <= 3)
|
|
3264
3386
|
decayStrength +=
|
|
3265
|
-
Math.max(0, 1.0 - (
|
|
3266
|
-
maxDecayRate = Math.min(decayStrength, 0.
|
|
3387
|
+
Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.1;
|
|
3388
|
+
maxDecayRate = Math.min(decayStrength, 0.7);
|
|
3267
3389
|
}
|
|
3268
|
-
const positionInWord =
|
|
3390
|
+
const positionInWord = wordNumChars > 1
|
|
3391
|
+
? charIndexInsideWord / (wordNumChars - 1)
|
|
3392
|
+
: 0;
|
|
3269
3393
|
const decayFactor = 1.0 - positionInWord * maxDecayRate;
|
|
3270
3394
|
const charProgress = easedProgress * decayFactor;
|
|
3271
|
-
const baseGrowth =
|
|
3272
|
-
const charMaxScale = 1.0 + baseGrowth + charProgress * 0.
|
|
3273
|
-
const
|
|
3274
|
-
|
|
3275
|
-
|
|
3276
|
-
|
|
3395
|
+
const baseGrowth = wordNumChars <= 3 ? 0.05 : 0.04;
|
|
3396
|
+
const charMaxScale = 1.0 + baseGrowth + charProgress * 0.08;
|
|
3397
|
+
const glowDurFactor = Math.min(1.1, wordDuration / 1500);
|
|
3398
|
+
let glowLenFactor = 1.0;
|
|
3399
|
+
if (wordNumChars <= 3) {
|
|
3400
|
+
glowLenFactor = 0.85;
|
|
3401
|
+
}
|
|
3402
|
+
else if (wordNumChars >= 6) {
|
|
3403
|
+
glowLenFactor = 1.1;
|
|
3404
|
+
}
|
|
3405
|
+
const glowIntensityScale = glowDurFactor * glowLenFactor;
|
|
3406
|
+
const charShadowIntensity = isGlowing
|
|
3407
|
+
? (0.35 + charProgress * 0.45) * glowIntensityScale
|
|
3408
|
+
: 0;
|
|
3409
|
+
const normalizedGrowth = (charMaxScale - 1.0) / 0.1;
|
|
3410
|
+
const effectiveDuration = (wordDuration + durationMs * 2) / 3;
|
|
3411
|
+
const peakMultiplier = Math.min(1, Math.max(0.3, effectiveDuration / 2000));
|
|
3412
|
+
const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak
|
|
3413
|
+
const position = (charIndexInsideWord + 0.5) / wordNumChars;
|
|
3277
3414
|
const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25);
|
|
3278
3415
|
return b `<span
|
|
3279
3416
|
class="char"
|
|
3280
|
-
data-char-index="${
|
|
3281
|
-
data-syllable-char-index="${
|
|
3282
|
-
data-wipe-start="${
|
|
3283
|
-
data-wipe-duration="${(1 /
|
|
3417
|
+
data-char-index="${charIndexInsideWord}"
|
|
3418
|
+
data-syllable-char-index="${charIndexInsideWord}"
|
|
3419
|
+
data-wipe-start="${charStartPercentVal.toFixed(4)}"
|
|
3420
|
+
data-wipe-duration="${(1 / numCharsInSyllable).toFixed(4)}"
|
|
3284
3421
|
data-horizontal-offset="${horizontalOffset.toFixed(2)}"
|
|
3285
3422
|
data-max-scale="${charMaxScale.toFixed(3)}"
|
|
3286
3423
|
data-shadow-intensity="${charShadowIntensity.toFixed(3)}"
|
|
3287
3424
|
data-translate-y-peak="${charTranslateYPeak.toFixed(3)}"
|
|
3288
3425
|
>${char}</span
|
|
3289
3426
|
>`;
|
|
3290
|
-
})}
|
|
3291
|
-
|
|
3292
|
-
return b `<span
|
|
3293
|
-
class="lyrics-word ${isGrowable ? 'growable' : ''}"
|
|
3294
|
-
>
|
|
3295
|
-
<span class="lyrics-syllable-wrap">
|
|
3427
|
+
})}`;
|
|
3428
|
+
}
|
|
3429
|
+
return b `<span class="lyrics-syllable-wrap">
|
|
3296
3430
|
<span
|
|
3297
|
-
class="lyrics-syllable ${
|
|
3431
|
+
class="lyrics-syllable ${groupLineSynced
|
|
3298
3432
|
? 'line-synced'
|
|
3299
3433
|
: ''}"
|
|
3300
3434
|
data-start-time="${startTimeMs}"
|
|
3301
3435
|
data-end-time="${endTimeMs}"
|
|
3302
3436
|
data-duration="${durationMs}"
|
|
3303
|
-
data-
|
|
3437
|
+
data-word-duration="${wordDuration}"
|
|
3438
|
+
data-syllable-index="${sylIdx}"
|
|
3304
3439
|
data-wipe-ratio="1"
|
|
3305
3440
|
>${syllableContent}</span
|
|
3306
3441
|
>
|
|
3307
3442
|
${romanizedText}
|
|
3308
|
-
</span
|
|
3309
|
-
|
|
3310
|
-
}
|
|
3311
|
-
// Multi-syllable group (part=true): render all syllables inside one lyrics-word
|
|
3312
|
-
return b `<span
|
|
3313
|
-
class="lyrics-word ${isGrowable ? 'growable' : ''} allow-break"
|
|
3314
|
-
>
|
|
3315
|
-
${group.map((syllable, sylIdx) => b `
|
|
3316
|
-
<span class="lyrics-syllable-wrap">
|
|
3317
|
-
<span
|
|
3318
|
-
class="lyrics-syllable ${groupLineSynced
|
|
3319
|
-
? 'line-synced'
|
|
3320
|
-
: ''}"
|
|
3321
|
-
data-start-time="${syllable.timestamp}"
|
|
3322
|
-
data-end-time="${syllable.endtime}"
|
|
3323
|
-
data-duration="${syllable.endtime - syllable.timestamp}"
|
|
3324
|
-
data-syllable-index="${sylIdx}"
|
|
3325
|
-
data-wipe-ratio="1"
|
|
3326
|
-
>${syllable.text}</span
|
|
3327
|
-
>
|
|
3328
|
-
${this.showRomanization &&
|
|
3329
|
-
syllable.romanizedText &&
|
|
3330
|
-
syllable.romanizedText.trim() !== syllable.text.trim()
|
|
3331
|
-
? b `<span
|
|
3332
|
-
class="lyrics-syllable transliteration ${groupLineSynced
|
|
3333
|
-
? 'line-synced'
|
|
3334
|
-
: ''}"
|
|
3335
|
-
data-start-time="${syllable.timestamp}"
|
|
3336
|
-
data-end-time="${syllable.endtime}"
|
|
3337
|
-
data-duration="${syllable.endtime -
|
|
3338
|
-
syllable.timestamp}"
|
|
3339
|
-
data-syllable-index="0"
|
|
3340
|
-
data-wipe-ratio="1"
|
|
3341
|
-
>${syllable.romanizedText}</span
|
|
3342
|
-
>`
|
|
3343
|
-
: ''}
|
|
3344
|
-
</span>
|
|
3345
|
-
`)}
|
|
3443
|
+
</span>`;
|
|
3444
|
+
})}
|
|
3346
3445
|
</span>`;
|
|
3347
3446
|
})}
|
|
3348
3447
|
</p>`;
|
|
@@ -3373,14 +3472,17 @@ class AmLyrics extends i {
|
|
|
3373
3472
|
let maybeInstrumentalBlock = null;
|
|
3374
3473
|
const gapForLine = gapByIndex.get(lineIndex);
|
|
3375
3474
|
if (gapForLine) {
|
|
3475
|
+
const gapDuration = gapForLine.gapEnd - gapForLine.gapStart;
|
|
3376
3476
|
// Calculate dot timing for fill-up animation (3 dots)
|
|
3377
|
-
const dotDuration =
|
|
3477
|
+
const dotDuration = gapDuration / 3;
|
|
3478
|
+
const gapLoopDelay = AmLyrics.getGapLoopDelay(gapDuration);
|
|
3378
3479
|
// Gap starts without 'active' — _onTimeChanged toggles it imperatively
|
|
3379
3480
|
maybeInstrumentalBlock = b `<div
|
|
3380
3481
|
id="gap-${lineIndex}"
|
|
3381
3482
|
class="lyrics-line lyrics-gap"
|
|
3382
3483
|
data-start-time="${gapForLine.gapStart}"
|
|
3383
3484
|
data-end-time="${gapForLine.gapEnd}"
|
|
3485
|
+
style="--gap-pulse-duration: ${GAP_PULSE_DURATION_MS}ms; --gap-loop-delay: -${gapLoopDelay}ms; --gap-exit-duration: ${GAP_EXIT_LEAD_MS}ms; --gap-exit-scale: ${GAP_MIN_SCALE};"
|
|
3384
3486
|
>
|
|
3385
3487
|
<div class="lyrics-line-container">
|
|
3386
3488
|
<p class="main-vocal-container">
|
|
@@ -3703,7 +3805,7 @@ AmLyrics.styles = i$3 `
|
|
|
3703
3805
|
.lyrics-line.scroll-animate {
|
|
3704
3806
|
transition: none !important; /* Prevent conflict with scroll animation */
|
|
3705
3807
|
animation-name: lyrics-scroll;
|
|
3706
|
-
animation-duration:
|
|
3808
|
+
animation-duration: var(--scroll-duration, 280ms);
|
|
3707
3809
|
animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99);
|
|
3708
3810
|
animation-fill-mode: both;
|
|
3709
3811
|
animation-delay: var(--lyrics-line-delay, 0ms);
|
|
@@ -3775,19 +3877,20 @@ AmLyrics.styles = i$3 `
|
|
|
3775
3877
|
opacity: 0;
|
|
3776
3878
|
font-size: var(--lyplus-font-size-subtext);
|
|
3777
3879
|
transition:
|
|
3778
|
-
max-height 0.
|
|
3779
|
-
opacity
|
|
3780
|
-
padding 0.
|
|
3880
|
+
max-height 350ms cubic-bezier(0.33, 1, 0.68, 1),
|
|
3881
|
+
opacity 300ms ease-out,
|
|
3882
|
+
padding 350ms cubic-bezier(0.33, 1, 0.68, 1);
|
|
3781
3883
|
margin: 0;
|
|
3782
3884
|
}
|
|
3783
3885
|
|
|
3784
|
-
.lyrics-line.active .background-vocal-container
|
|
3886
|
+
.lyrics-line.active .background-vocal-container,
|
|
3887
|
+
.lyrics-line.pre-active .background-vocal-container {
|
|
3785
3888
|
max-height: 4em;
|
|
3786
3889
|
opacity: 1;
|
|
3787
3890
|
transition:
|
|
3788
|
-
max-height 0.
|
|
3789
|
-
opacity
|
|
3790
|
-
padding 0.
|
|
3891
|
+
max-height 350ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
3892
|
+
opacity 300ms ease-out,
|
|
3893
|
+
padding 350ms cubic-bezier(0.22, 1, 0.36, 1);
|
|
3791
3894
|
will-change: max-height, opacity, padding;
|
|
3792
3895
|
}
|
|
3793
3896
|
|
|
@@ -3798,6 +3901,11 @@ AmLyrics.styles = i$3 `
|
|
|
3798
3901
|
will-change: transform, opacity;
|
|
3799
3902
|
}
|
|
3800
3903
|
|
|
3904
|
+
.lyrics-line.pre-active {
|
|
3905
|
+
opacity: 1;
|
|
3906
|
+
will-change: transform, opacity;
|
|
3907
|
+
}
|
|
3908
|
+
|
|
3801
3909
|
.lyrics-line.singer-right {
|
|
3802
3910
|
text-align: end;
|
|
3803
3911
|
}
|
|
@@ -3877,7 +3985,7 @@ AmLyrics.styles = i$3 `
|
|
|
3877
3985
|
/* Unblur early for pre-active lines */
|
|
3878
3986
|
.lyrics-container.blur-inactive-enabled .lyrics-line.pre-active {
|
|
3879
3987
|
filter: blur(0px) !important;
|
|
3880
|
-
opacity:
|
|
3988
|
+
opacity: 1;
|
|
3881
3989
|
}
|
|
3882
3990
|
|
|
3883
3991
|
/* ==========================================================================
|
|
@@ -4115,43 +4223,45 @@ AmLyrics.styles = i$3 `
|
|
|
4115
4223
|
INSTRUMENTAL GAP STYLES
|
|
4116
4224
|
========================================================================== */
|
|
4117
4225
|
.lyrics-gap {
|
|
4118
|
-
height: 0;
|
|
4226
|
+
max-height: 0;
|
|
4119
4227
|
padding: 0 var(--lyplus-padding-gap);
|
|
4120
4228
|
overflow: hidden;
|
|
4121
4229
|
opacity: 0;
|
|
4122
4230
|
box-sizing: content-box;
|
|
4123
4231
|
background-clip: unset;
|
|
4232
|
+
transform-origin: top;
|
|
4124
4233
|
transition:
|
|
4125
|
-
padding 0.
|
|
4126
|
-
height 0.
|
|
4127
|
-
opacity
|
|
4128
|
-
transform
|
|
4234
|
+
padding 220ms cubic-bezier(0.33, 1, 0.68, 1),
|
|
4235
|
+
max-height 220ms cubic-bezier(0.33, 1, 0.68, 1),
|
|
4236
|
+
opacity 160ms ease-out,
|
|
4237
|
+
transform var(--scroll-duration, 280ms) var(--lyrics-line-delay, 0ms);
|
|
4129
4238
|
}
|
|
4130
4239
|
|
|
4131
4240
|
.lyrics-gap.active {
|
|
4132
|
-
height: 1.
|
|
4241
|
+
max-height: 1.6em;
|
|
4133
4242
|
padding: var(--lyplus-padding-gap);
|
|
4134
4243
|
opacity: 1;
|
|
4135
4244
|
overflow: visible;
|
|
4136
4245
|
transition:
|
|
4137
|
-
padding 0.
|
|
4138
|
-
height 0.
|
|
4139
|
-
opacity
|
|
4140
|
-
transform
|
|
4141
|
-
will-change: height, opacity, padding;
|
|
4246
|
+
padding 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
4247
|
+
max-height 220ms cubic-bezier(0.22, 1, 0.36, 1),
|
|
4248
|
+
opacity 160ms ease-out,
|
|
4249
|
+
transform var(--scroll-duration, 280ms);
|
|
4250
|
+
will-change: max-height, opacity, padding;
|
|
4142
4251
|
}
|
|
4143
4252
|
|
|
4144
|
-
/* Exiting state:
|
|
4253
|
+
/* Exiting state: quickly collapse width and height so dots don't distort page, or remove max-height transition */
|
|
4145
4254
|
.lyrics-gap.gap-exiting {
|
|
4146
|
-
height:
|
|
4147
|
-
padding: var(--lyplus-padding-gap);
|
|
4148
|
-
opacity:
|
|
4255
|
+
max-height: 0;
|
|
4256
|
+
padding: 0 var(--lyplus-padding-gap);
|
|
4257
|
+
opacity: 0;
|
|
4149
4258
|
overflow: visible;
|
|
4150
4259
|
transition:
|
|
4151
|
-
padding 0.
|
|
4152
|
-
height
|
|
4153
|
-
|
|
4154
|
-
|
|
4260
|
+
padding var(--gap-exit-duration, 360ms) cubic-bezier(0.33, 1, 0.68, 1),
|
|
4261
|
+
max-height var(--gap-exit-duration, 360ms)
|
|
4262
|
+
cubic-bezier(0.33, 1, 0.68, 1),
|
|
4263
|
+
opacity 160ms ease-out,
|
|
4264
|
+
transform var(--scroll-duration, 280ms);
|
|
4155
4265
|
}
|
|
4156
4266
|
|
|
4157
4267
|
.lyrics-gap .main-vocal-container {
|
|
@@ -4160,7 +4270,8 @@ AmLyrics.styles = i$3 `
|
|
|
4160
4270
|
|
|
4161
4271
|
/* Jump animation plays during exit */
|
|
4162
4272
|
.lyrics-gap.gap-exiting .main-vocal-container {
|
|
4163
|
-
animation: gap-ended
|
|
4273
|
+
animation: gap-ended var(--gap-exit-duration, 360ms)
|
|
4274
|
+
cubic-bezier(0.33, 1, 0.68, 1) forwards;
|
|
4164
4275
|
}
|
|
4165
4276
|
|
|
4166
4277
|
.lyrics-gap:not(.active):not(.gap-exiting) .main-vocal-container {
|
|
@@ -4174,7 +4285,9 @@ AmLyrics.styles = i$3 `
|
|
|
4174
4285
|
}
|
|
4175
4286
|
|
|
4176
4287
|
.lyrics-gap.active .main-vocal-container .lyrics-word {
|
|
4177
|
-
animation: gap-loop
|
|
4288
|
+
animation: gap-loop var(--gap-pulse-duration, 4000ms) ease-in-out infinite
|
|
4289
|
+
alternate;
|
|
4290
|
+
animation-delay: var(--gap-loop-delay, 0ms);
|
|
4178
4291
|
will-change: transform;
|
|
4179
4292
|
}
|
|
4180
4293
|
|
|
@@ -4198,6 +4311,18 @@ AmLyrics.styles = i$3 `
|
|
|
4198
4311
|
color: var(--lyplus-text-primary) !important;
|
|
4199
4312
|
}
|
|
4200
4313
|
|
|
4314
|
+
.lyrics-line.pre-active .lyrics-syllable.line-synced {
|
|
4315
|
+
animation: fade-in-line 0.14s ease-out forwards !important;
|
|
4316
|
+
color: var(--lyplus-text-primary) !important;
|
|
4317
|
+
}
|
|
4318
|
+
|
|
4319
|
+
.lyrics-line.active .lyrics-syllable.line-synced span.char,
|
|
4320
|
+
.lyrics-line.pre-active .lyrics-syllable.line-synced span.char {
|
|
4321
|
+
background-image: none !important;
|
|
4322
|
+
background-color: var(--lyplus-text-primary) !important;
|
|
4323
|
+
transition: background-color 120ms ease-out !important;
|
|
4324
|
+
}
|
|
4325
|
+
|
|
4201
4326
|
@keyframes fade-in-line {
|
|
4202
4327
|
from {
|
|
4203
4328
|
opacity: 0.5;
|
|
@@ -4611,19 +4736,20 @@ AmLyrics.styles = i$3 `
|
|
|
4611
4736
|
/* Gap dot animations */
|
|
4612
4737
|
@keyframes gap-loop {
|
|
4613
4738
|
from {
|
|
4614
|
-
transform: scale(1.
|
|
4739
|
+
transform: scale(1.12);
|
|
4615
4740
|
}
|
|
4616
4741
|
to {
|
|
4617
|
-
transform: scale(0.85);
|
|
4742
|
+
transform: scale(var(--gap-exit-scale, 0.85));
|
|
4618
4743
|
}
|
|
4619
4744
|
}
|
|
4620
4745
|
|
|
4621
4746
|
@keyframes gap-ended {
|
|
4622
4747
|
0% {
|
|
4623
|
-
transform: translateY(-25%) scale(
|
|
4748
|
+
transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85))
|
|
4749
|
+
translateZ(0);
|
|
4624
4750
|
}
|
|
4625
4751
|
35% {
|
|
4626
|
-
transform: translateY(-
|
|
4752
|
+
transform: translateY(-5%) scale(1.08) translateZ(0);
|
|
4627
4753
|
}
|
|
4628
4754
|
100% {
|
|
4629
4755
|
transform: translateY(-25%) scale(0) translateZ(0);
|