@applemusic-like-lyrics/core 0.4.0 → 0.4.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.
@@ -30,9 +30,9 @@ let _pixi_filter_bulge_pinch = require("@pixi/filter-bulge-pinch");
30
30
  let _pixi_filter_color_matrix = require("@pixi/filter-color-matrix");
31
31
  let _pixi_sprite = require("@pixi/sprite");
32
32
  let _ungap_structured_clone = require("@ungap/structured-clone");
33
- _ungap_structured_clone = __toESM(_ungap_structured_clone);
33
+ _ungap_structured_clone = __toESM(_ungap_structured_clone, 1);
34
34
  let bezier_easing = require("bezier-easing");
35
- bezier_easing = __toESM(bezier_easing);
35
+ bezier_easing = __toESM(bezier_easing, 1);
36
36
  //#region src/bg-render/base.ts
37
37
  var AbstractBaseRenderer = class {};
38
38
  var BaseRenderer = class extends AbstractBaseRenderer {
@@ -2274,7 +2274,7 @@ var LyricPlayerBase = class extends EventTarget {
2274
2274
  bottomLine = new BottomLineEl(this);
2275
2275
  enableBlur = true;
2276
2276
  enableScale = true;
2277
- maskObsceneWords = MaskObsceneWordsMode.Disabled;
2277
+ maskObsceneWords = "";
2278
2278
  maskObsceneWordChar = "*";
2279
2279
  hidePassedLines = false;
2280
2280
  scrollBoundary = [0, 0];
@@ -2564,7 +2564,7 @@ var LyricPlayerBase = class extends EventTarget {
2564
2564
  const c = char.charAt(0) || "*";
2565
2565
  if (this.maskObsceneWordChar === c) return;
2566
2566
  this.maskObsceneWordChar = c;
2567
- if (this.maskObsceneWords !== MaskObsceneWordsMode.Disabled) {
2567
+ if (this.maskObsceneWords !== "") {
2568
2568
  this.rebuildLyricLines();
2569
2569
  this.calcLayout();
2570
2570
  }
@@ -2579,10 +2579,10 @@ var LyricPlayerBase = class extends EventTarget {
2579
2579
  */
2580
2580
  processObsceneWord(word) {
2581
2581
  const text = word.word;
2582
- if (!word.obscene || this.maskObsceneWords === MaskObsceneWordsMode.Disabled) return text;
2582
+ if (!word.obscene || this.maskObsceneWords === "") return text;
2583
2583
  const maskChar = this.maskObsceneWordChar;
2584
- if (this.maskObsceneWords === MaskObsceneWordsMode.FullMask) return text.replace(/\S/g, maskChar);
2585
- if (this.maskObsceneWords === MaskObsceneWordsMode.PartialMask) {
2584
+ if (this.maskObsceneWords === "full-mask") return text.replace(/\S/g, maskChar);
2585
+ if (this.maskObsceneWords === "partial-mask") {
2586
2586
  const trimmed = text.trim();
2587
2587
  if (trimmed.length <= 2) return text.replace(/\S/g, maskChar);
2588
2588
  const startPos = text.indexOf(trimmed);
@@ -2943,7 +2943,7 @@ var LyricPlayerBase = class extends EventTarget {
2943
2943
  let targetScale = 100;
2944
2944
  if (!isActive && this.isPlaying) if (line.isBG) targetScale = 75;
2945
2945
  else targetScale = SCALE_ASPECT;
2946
- const renderMode = isActive ? LyricLineRenderMode.GRADIENT : LyricLineRenderMode.SOLID;
2946
+ const renderMode = isActive ? 1 : 0;
2947
2947
  lineObj.setTransform(curPos, targetScale, targetOpacity, blurLevel, force, delay, renderMode);
2948
2948
  if (line.isBG && (isActive || !this.isPlaying)) curPos += this.lyricLinesSize.get(lineObj)?.[1] ?? LINE_HEIGHT_FALLBACK;
2949
2949
  else if (!line.isBG) curPos += this.lyricLinesSize.get(lineObj)?.[1] ?? LINE_HEIGHT_FALLBACK;
@@ -3096,8 +3096,17 @@ var LyricLineBase = class extends EventTarget {
3096
3096
  posY: new Spring(0),
3097
3097
  scale: new Spring(100)
3098
3098
  };
3099
+ /**
3100
+ * 用于 CJK 词语边界检测的分词器
3101
+ */
3102
+ static wordSegmenter = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
3103
+ /**
3104
+ * Unicode 标准的全局 Grapheme Cluster 分词器
3105
+ * 用于正确处理 emoji、复合字符等
3106
+ */
3107
+ static graphemeSegmenter = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
3099
3108
  onLineSizeChange(_size) {}
3100
- setTransform(top = this.top, scale = this.scale, opacity = this.opacity, blur = this.blur, _force = false, delay = 0, _mode = LyricLineRenderMode.SOLID) {
3109
+ setTransform(top = this.top, scale = this.scale, opacity = this.opacity, blur = this.blur, _force = false, delay = 0, _mode = 0) {
3101
3110
  this.top = top;
3102
3111
  this.scale = scale;
3103
3112
  this.opacity = opacity;
@@ -3122,8 +3131,277 @@ var LyricLineBase = class extends EventTarget {
3122
3131
  dispose() {}
3123
3132
  };
3124
3133
  //#endregion
3134
+ //#region src/utils/lyric-line-break.ts
3135
+ /**
3136
+ * 单个词超过容器宽度时的大惩罚倍数
3137
+ */
3138
+ const OVERFLOW_PENALTY_MULTIPLIER = 1e3;
3139
+ /**
3140
+ * 截断 CJK 词组边界的惩罚比例
3141
+ *
3142
+ * 相对于容器宽度
3143
+ */
3144
+ const CJK_BREAK_PENALTY_RATIO = .15;
3145
+ /**
3146
+ * 截断普通文本(非空格、非 CJK 词界)的惩罚比例
3147
+ */
3148
+ const NORMAL_BREAK_PENALTY_RATIO = .5;
3149
+ /**
3150
+ * 在空格处断开的奖励比例
3151
+ */
3152
+ const SPACE_BREAK_REWARD_RATIO = .4;
3153
+ /**
3154
+ * 在标点符号处断开的奖励比例
3155
+ *
3156
+ * 比空格更高以便优先一点在标点处换行
3157
+ */
3158
+ const PUNCTUATION_BREAK_REWARD_RATIO = .6;
3159
+ const PUNCTUATION_REGEX = /[,.;:!?,。;:!?、)】》」』’”)[\]}>~…]$/;
3160
+ /**
3161
+ * 计算平均行长度的断点位置
3162
+ * @param children 子节点信息
3163
+ * @param containerWidth 容器可用内容宽度
3164
+ * @param fullText 完整的行文本
3165
+ * @param segmenter 预创建的 Intl.Segmenter 分词器
3166
+ * @returns 需要在其前面插入 `<br>` 的子节点索引数组,升序
3167
+ */
3168
+ function calcBalancedBreaks(children, containerWidth, fullText, segmenter) {
3169
+ const n = children.length;
3170
+ if (n === 0 || containerWidth <= 0) return [];
3171
+ const cjkBoundaries = /* @__PURE__ */ new Set();
3172
+ let offset = 0;
3173
+ for (const { segment, isWordLike } of segmenter.segment(fullText)) {
3174
+ if (offset > 0 && isWordLike) {
3175
+ if ([...segment].some((ch) => isCJK(ch))) cjkBoundaries.add(offset);
3176
+ }
3177
+ offset += segment.length;
3178
+ }
3179
+ const charOffsets = new Int32Array(n + 1);
3180
+ const prefixWidth = new Float64Array(n + 1);
3181
+ for (let i = 0; i < n; i++) {
3182
+ charOffsets[i + 1] = charOffsets[i] + children[i].text.length;
3183
+ prefixWidth[i + 1] = prefixWidth[i] + children[i].width;
3184
+ }
3185
+ if (prefixWidth[n] <= containerWidth) return [];
3186
+ /**
3187
+ * dp[i] 表示将 index i 到 n-1 的节点进行排版的最小代价
3188
+ */
3189
+ const dp = new Float64Array(n + 1).fill(Number.POSITIVE_INFINITY);
3190
+ const nextBreak = new Int32Array(n + 1).fill(-1);
3191
+ dp[n] = 0;
3192
+ const PENALTY_CJK = (containerWidth * CJK_BREAK_PENALTY_RATIO) ** 2;
3193
+ const PENALTY_NORMAL = (containerWidth * NORMAL_BREAK_PENALTY_RATIO) ** 2;
3194
+ for (let i = n - 1; i >= 0; i--) for (let j = i + 1; j <= n; j++) {
3195
+ const w = prefixWidth[j] - prefixWidth[i];
3196
+ let lineCost = 0;
3197
+ if (w > containerWidth) if (j === i + 1) lineCost = (w - containerWidth) ** 2 * OVERFLOW_PENALTY_MULTIPLIER;
3198
+ else continue;
3199
+ else lineCost = (containerWidth - w) ** 2;
3200
+ let breakPenalty = 0;
3201
+ if (j < n) {
3202
+ const prevChild = children[j - 1];
3203
+ if (PUNCTUATION_REGEX.test(prevChild.text)) breakPenalty = -((containerWidth * PUNCTUATION_BREAK_REWARD_RATIO) ** 2);
3204
+ else if (prevChild.isSpace) breakPenalty = -((containerWidth * SPACE_BREAK_REWARD_RATIO) ** 2);
3205
+ else if (cjkBoundaries.has(charOffsets[j])) breakPenalty = PENALTY_CJK;
3206
+ else breakPenalty = PENALTY_NORMAL;
3207
+ }
3208
+ const totalCost = lineCost + breakPenalty + dp[j];
3209
+ if (totalCost < dp[i]) {
3210
+ dp[i] = totalCost;
3211
+ nextBreak[i] = j;
3212
+ }
3213
+ }
3214
+ const breaks = [];
3215
+ let curr = 0;
3216
+ while (curr < n) {
3217
+ curr = nextBreak[curr];
3218
+ if (curr > 0 && curr < n) breaks.push(curr);
3219
+ }
3220
+ return breaks;
3221
+ }
3222
+ //#endregion
3223
+ //#region src/utils/line-balancer.ts
3224
+ let sharedCanvasCtx = null;
3225
+ function getMeasurementContext() {
3226
+ if (!sharedCanvasCtx) sharedCanvasCtx = document.createElement("canvas").getContext("2d");
3227
+ return sharedCanvasCtx;
3228
+ }
3229
+ /**
3230
+ * 用于平衡歌词行在换行后的各行长度
3231
+ */
3232
+ var LineBalancer = class {
3233
+ isBalancing = false;
3234
+ lastBalancedContainerWidth = -1;
3235
+ constructor(mainElement) {
3236
+ this.mainElement = mainElement;
3237
+ }
3238
+ balanceLineBreaks(isNonDynamic, hasSplittedWords, wordSegmenter) {
3239
+ if (this.isBalancing || !this.mainElement) return;
3240
+ const computedStyle = getComputedStyle(this.mainElement);
3241
+ const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
3242
+ const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
3243
+ const containerWidth = this.mainElement.clientWidth - paddingLeft - paddingRight;
3244
+ if (containerWidth <= 0) return;
3245
+ if (isNonDynamic) {
3246
+ this.balanceNonDynamicLineBreaks(containerWidth, computedStyle, wordSegmenter);
3247
+ return;
3248
+ }
3249
+ if (!hasSplittedWords) return;
3250
+ this.balanceDynamicLineBreaks(containerWidth, wordSegmenter);
3251
+ }
3252
+ reset() {
3253
+ this.lastBalancedContainerWidth = -1;
3254
+ }
3255
+ executeLineBalance(containerWidth, adapter, wordSegmenter) {
3256
+ const existingBrs = this.mainElement.querySelectorAll("br");
3257
+ if (containerWidth === this.lastBalancedContainerWidth && existingBrs.length > 0) return;
3258
+ adapter.resetDOM();
3259
+ const prevWhiteSpace = this.mainElement.style.whiteSpace;
3260
+ this.mainElement.style.whiteSpace = "nowrap";
3261
+ const parentElement = this.mainElement.parentElement;
3262
+ let prevTransform = "";
3263
+ let transformChanged = false;
3264
+ if (parentElement) {
3265
+ prevTransform = parentElement.style.transform;
3266
+ if (prevTransform && prevTransform !== "none") {
3267
+ parentElement.style.transform = "none";
3268
+ transformChanged = true;
3269
+ }
3270
+ }
3271
+ let lockAcquired = false;
3272
+ try {
3273
+ const { childInfos, fullText } = adapter.buildChildInfos();
3274
+ let layoutWidth = childInfos.reduce((sum, c) => sum + c.width, 0);
3275
+ if (adapter.needsCalibration) {
3276
+ const range = document.createRange();
3277
+ range.selectNodeContents(this.mainElement);
3278
+ const visualWidth = range.getBoundingClientRect().width;
3279
+ if (layoutWidth > 0 && visualWidth > 0) {
3280
+ const scale = visualWidth / layoutWidth;
3281
+ for (const info of childInfos) info.width *= scale;
3282
+ }
3283
+ layoutWidth = visualWidth;
3284
+ }
3285
+ const safeContainerWidth = Math.max(1, containerWidth);
3286
+ if (layoutWidth <= safeContainerWidth) {
3287
+ this.lastBalancedContainerWidth = containerWidth;
3288
+ return;
3289
+ }
3290
+ const breaks = calcBalancedBreaks(childInfos, safeContainerWidth, fullText, wordSegmenter);
3291
+ if (breaks.length === 0) {
3292
+ this.lastBalancedContainerWidth = containerWidth;
3293
+ return;
3294
+ }
3295
+ this.isBalancing = true;
3296
+ lockAcquired = true;
3297
+ adapter.applyBreaks(breaks, childInfos);
3298
+ this.lastBalancedContainerWidth = containerWidth;
3299
+ this.isBalancing = false;
3300
+ } finally {
3301
+ this.mainElement.style.whiteSpace = prevWhiteSpace;
3302
+ if (transformChanged && parentElement) parentElement.style.transform = prevTransform;
3303
+ if (lockAcquired) this.isBalancing = false;
3304
+ }
3305
+ }
3306
+ balanceDynamicLineBreaks(containerWidth, wordSegmenter) {
3307
+ const infoToNode = [];
3308
+ this.executeLineBalance(containerWidth, {
3309
+ resetDOM: () => {
3310
+ this.mainElement.querySelectorAll("br").forEach((br) => {
3311
+ br.remove();
3312
+ });
3313
+ },
3314
+ buildChildInfos: () => {
3315
+ infoToNode.length = 0;
3316
+ const childNodes = Array.from(this.mainElement.childNodes);
3317
+ const childInfos = [];
3318
+ const range = document.createRange();
3319
+ for (const node of childNodes) if (node.nodeType === Node.TEXT_NODE) {
3320
+ const text = node.textContent ?? "";
3321
+ if (text.length === 0) continue;
3322
+ range.selectNodeContents(node);
3323
+ childInfos.push({
3324
+ width: range.getBoundingClientRect().width,
3325
+ text,
3326
+ isSpace: text.trim().length === 0
3327
+ });
3328
+ infoToNode.push(node);
3329
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
3330
+ const el = node;
3331
+ const rect = el.getBoundingClientRect();
3332
+ const elStyle = getComputedStyle(el);
3333
+ const marginLeft = Number.parseFloat(elStyle.marginLeft) || 0;
3334
+ const marginRight = Number.parseFloat(elStyle.marginRight) || 0;
3335
+ childInfos.push({
3336
+ width: Math.max(0, rect.width + marginLeft + marginRight),
3337
+ text: el.textContent ?? "",
3338
+ isSpace: false
3339
+ });
3340
+ infoToNode.push(node);
3341
+ }
3342
+ return {
3343
+ childInfos,
3344
+ fullText: childInfos.map((c) => c.text).join("")
3345
+ };
3346
+ },
3347
+ applyBreaks: (breaks) => {
3348
+ for (let i = breaks.length - 1; i >= 0; i--) {
3349
+ const breakIndex = breaks[i];
3350
+ if (breakIndex >= 0 && breakIndex < infoToNode.length) this.mainElement.insertBefore(document.createElement("br"), infoToNode[breakIndex]);
3351
+ }
3352
+ },
3353
+ needsCalibration: false
3354
+ }, wordSegmenter);
3355
+ }
3356
+ balanceNonDynamicLineBreaks(containerWidth, computedStyle, wordSegmenter) {
3357
+ const fullText = this.mainElement.textContent ?? "";
3358
+ if (fullText.trim().length === 0) return;
3359
+ this.executeLineBalance(containerWidth, {
3360
+ resetDOM: () => {
3361
+ this.mainElement.innerHTML = "";
3362
+ this.mainElement.textContent = fullText;
3363
+ },
3364
+ buildChildInfos: () => {
3365
+ const ctx = getMeasurementContext();
3366
+ if (!ctx) {
3367
+ console.debug("Canvas 2D context is not supported, skipping line balancing");
3368
+ return {
3369
+ childInfos: [],
3370
+ fullText
3371
+ };
3372
+ }
3373
+ ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
3374
+ if ("letterSpacing" in ctx) ctx.letterSpacing = computedStyle.letterSpacing !== "normal" ? computedStyle.letterSpacing : "0px";
3375
+ if ("wordSpacing" in ctx) ctx.wordSpacing = computedStyle.wordSpacing !== "normal" ? computedStyle.wordSpacing : "0px";
3376
+ const childInfos = [];
3377
+ for (const { segment } of wordSegmenter.segment(fullText)) childInfos.push({
3378
+ width: ctx.measureText(segment).width,
3379
+ text: segment,
3380
+ isSpace: segment.trim().length === 0
3381
+ });
3382
+ return {
3383
+ childInfos,
3384
+ fullText
3385
+ };
3386
+ },
3387
+ applyBreaks: (breaks, childInfos) => {
3388
+ this.mainElement.innerHTML = "";
3389
+ const breakSet = new Set(breaks);
3390
+ const fragment = document.createDocumentFragment();
3391
+ for (let i = 0; i < childInfos.length; i++) {
3392
+ if (breakSet.has(i)) fragment.appendChild(document.createElement("br"));
3393
+ fragment.appendChild(document.createTextNode(childInfos[i].text));
3394
+ }
3395
+ this.mainElement.appendChild(fragment);
3396
+ },
3397
+ needsCalibration: true
3398
+ }, wordSegmenter);
3399
+ }
3400
+ };
3401
+ //#endregion
3125
3402
  //#region src/utils/lyric-split-words.ts
3126
- const hasSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter !== "undefined";
3403
+ const SPLIT_WHITESPACE_RE = /(\s+)/;
3404
+ const WHITESPACE_RE = /\s/g;
3127
3405
  /**
3128
3406
  * 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
3129
3407
  *
@@ -3134,27 +3412,41 @@ const hasSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter !== "u
3134
3412
  * @returns 重新分组后的单词数组
3135
3413
  */
3136
3414
  function chunkAndSplitLyricWords(words) {
3137
- const atoms = [];
3415
+ const result = [];
3416
+ let currentGroup = [];
3417
+ const flushGroup = () => {
3418
+ if (currentGroup.length > 0) {
3419
+ result.push(currentGroup.length === 1 ? currentGroup[0] : [...currentGroup]);
3420
+ currentGroup = [];
3421
+ }
3422
+ };
3423
+ const processAtom = (atom) => {
3424
+ const isSpace = atom.word.trim().length === 0;
3425
+ const hasRuby = (atom.ruby?.length ?? 0) > 0;
3426
+ const isCJKChar = isCJK(atom.word);
3427
+ if (!isSpace && !hasRuby && !isCJKChar) currentGroup.push(atom);
3428
+ else {
3429
+ flushGroup();
3430
+ result.push(atom);
3431
+ }
3432
+ };
3138
3433
  for (const w of words) {
3139
3434
  const isSpace = w.word.trim().length === 0;
3140
3435
  const romanWord = w.romanWord ?? "";
3141
3436
  const obscene = w.obscene ?? false;
3142
3437
  const hasRuby = (w.ruby?.length ?? 0) > 0;
3143
- if (isSpace) {
3144
- atoms.push({ ...w });
3438
+ if (isSpace || hasRuby) {
3439
+ processAtom({ ...w });
3145
3440
  continue;
3146
3441
  }
3147
- if (hasRuby) {
3148
- atoms.push({ ...w });
3149
- continue;
3150
- }
3151
- const parts = w.word.split(/(\s+)/).filter((p) => p.length > 0);
3442
+ const parts = w.word.split(SPLIT_WHITESPACE_RE).filter((p) => p.length > 0);
3443
+ const totalLength = w.word.replace(WHITESPACE_RE, "").length || 1;
3444
+ const timePerUnit = (w.endTime - w.startTime) / totalLength;
3152
3445
  let currentOffset = 0;
3153
- const totalLength = w.word.replace(/\s/g, "").length || 1;
3154
3446
  for (const part of parts) {
3155
3447
  if (!part.trim()) {
3156
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3157
- atoms.push({
3448
+ const startTime = w.startTime + currentOffset * timePerUnit;
3449
+ processAtom({
3158
3450
  word: part,
3159
3451
  romanWord: "",
3160
3452
  startTime,
@@ -3166,63 +3458,31 @@ function chunkAndSplitLyricWords(words) {
3166
3458
  if (isCJK(part) && part.length > 1 && romanWord.trim().length === 0) {
3167
3459
  const chars = part.split("");
3168
3460
  for (const char of chars) {
3169
- const charDuration = 1 / totalLength * (w.endTime - w.startTime);
3170
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3171
- atoms.push({
3461
+ const startTime = w.startTime + currentOffset * timePerUnit;
3462
+ processAtom({
3172
3463
  word: char,
3173
3464
  romanWord: "",
3174
3465
  startTime,
3175
- endTime: startTime + charDuration,
3466
+ endTime: startTime + timePerUnit,
3176
3467
  obscene
3177
3468
  });
3178
3469
  currentOffset += 1;
3179
3470
  }
3180
3471
  } else {
3181
3472
  const partRealLen = part.length;
3182
- const duration = partRealLen / totalLength * (w.endTime - w.startTime);
3183
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3184
- atoms.push({
3473
+ const startTime = w.startTime + currentOffset * timePerUnit;
3474
+ processAtom({
3185
3475
  word: part,
3186
3476
  romanWord,
3187
3477
  startTime,
3188
- endTime: startTime + duration,
3478
+ endTime: startTime + partRealLen * timePerUnit,
3189
3479
  obscene
3190
3480
  });
3191
3481
  currentOffset += partRealLen;
3192
3482
  }
3193
3483
  }
3194
3484
  }
3195
- if (!hasSegmenter) return atoms;
3196
- const fullText = atoms.map((a) => a.word).join("");
3197
- const segmenter = new Intl.Segmenter(void 0, { granularity: "word" });
3198
- const segments = Array.from(segmenter.segment(fullText));
3199
- const result = [];
3200
- let atomIndex = 0;
3201
- let expectedLength = 0;
3202
- let actualLength = 0;
3203
- let currentGroup = [];
3204
- for (const segment of segments) {
3205
- const segmentLen = segment.segment.length;
3206
- expectedLength += segmentLen;
3207
- while (actualLength < expectedLength && atomIndex < atoms.length) {
3208
- const currentAtom = atoms[atomIndex];
3209
- currentGroup.push(currentAtom);
3210
- actualLength += currentAtom.word.length;
3211
- atomIndex++;
3212
- }
3213
- if (actualLength === expectedLength) {
3214
- while (currentGroup.length > 1 && !currentGroup[0].word.trim()) {
3215
- const spaceAtom = currentGroup.shift();
3216
- if (spaceAtom) result.push(spaceAtom);
3217
- }
3218
- if (currentGroup.length === 1) result.push(currentGroup[0]);
3219
- else if (currentGroup.length > 1) result.push(currentGroup);
3220
- currentGroup = [];
3221
- }
3222
- }
3223
- while (atomIndex < atoms.length) result.push(atoms[atomIndex++]);
3224
- if (currentGroup.length > 0) if (currentGroup.length === 1) result.push(currentGroup[0]);
3225
- else result.push(currentGroup);
3485
+ flushGroup();
3226
3486
  return result;
3227
3487
  }
3228
3488
  //#endregion
@@ -3304,12 +3564,15 @@ var LyricLineEl$1 = class extends LyricLineBase {
3304
3564
  splittedWords = [];
3305
3565
  built = false;
3306
3566
  lineSize = [0, 0];
3307
- renderMode = LyricLineRenderMode.SOLID;
3567
+ renderMode = 0;
3308
3568
  currentBrightAlpha = 1;
3309
3569
  currentDarkAlpha = .2;
3310
3570
  targetBrightAlpha = 1;
3311
3571
  targetDarkAlpha = .2;
3312
- segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3572
+ /**
3573
+ * 用于平衡换行、尽量减少各行长度差异的类
3574
+ */
3575
+ balancer;
3313
3576
  constructor(lyricPlayer, lyricLine = {
3314
3577
  words: [],
3315
3578
  translatedLyric: "",
@@ -3337,6 +3600,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3337
3600
  main.setAttribute("class", lyric_player_module_default.lyricMainLine);
3338
3601
  trans.setAttribute("class", lyric_player_module_default.lyricSubLine);
3339
3602
  roman.setAttribute("class", lyric_player_module_default.lyricSubLine);
3603
+ if (LyricLineBase.wordSegmenter) this.balancer = new LineBalancer(main);
3340
3604
  this.rebuildStyle();
3341
3605
  }
3342
3606
  listenersMap = /* @__PURE__ */ new Map();
@@ -3410,7 +3674,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3410
3674
  disable() {
3411
3675
  this.isEnabled = false;
3412
3676
  this.element.classList.remove(lyric_player_module_default.active);
3413
- this.renderMode = LyricLineRenderMode.SOLID;
3677
+ this.renderMode = 0;
3414
3678
  const main = this.element.children[0];
3415
3679
  for (const word of this.splittedWords) {
3416
3680
  for (const a of word.elementAnimations) if (a.id === "float-word" || a.id.includes("emphasize-word-float-only")) {
@@ -3546,7 +3810,14 @@ var LyricLineEl$1 = class extends LyricLineBase {
3546
3810
  const displayWord = this.lyricPlayer.processObsceneWord(word);
3547
3811
  if (shouldEmphasize) {
3548
3812
  mainWordEl.classList.add(lyric_player_module_default.emphasize);
3549
- for (const { segment } of this.segmenter.segment(displayWord.trim())) {
3813
+ const trimmedWord = displayWord.trim();
3814
+ if (LyricLineBase.graphemeSegmenter) for (const { segment } of LyricLineBase.graphemeSegmenter.segment(trimmedWord)) {
3815
+ const charEl = document.createElement("span");
3816
+ charEl.innerText = segment;
3817
+ subElements.push(charEl);
3818
+ wordContainer.appendChild(charEl);
3819
+ }
3820
+ else for (const segment of Array.from(trimmedWord)) {
3550
3821
  const charEl = document.createElement("span");
3551
3822
  charEl.innerText = segment;
3552
3823
  subElements.push(charEl);
@@ -3728,6 +3999,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3728
3999
  word.padding = 0;
3729
4000
  }
3730
4001
  }
4002
+ if (this.balancer && LyricLineBase.wordSegmenter) this.balancer.balanceLineBreaks(this.lyricPlayer._getIsNonDynamic(), this.splittedWords.length > 0, LyricLineBase.wordSegmenter);
3731
4003
  if (this.lyricPlayer.supportMaskImage) this.generateWebAnimationBasedMaskImage();
3732
4004
  else this.generateCalcBasedMaskImage();
3733
4005
  if (this.isEnabled) {
@@ -3900,7 +4172,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3900
4172
  const factor = Math.max(0, Math.min(1, (scale - .97) / .03));
3901
4173
  const dynamicDarkAlpha = factor * .2 + .2;
3902
4174
  const dynamicBrightAlpha = factor * .8 + .2;
3903
- if (this.renderMode === LyricLineRenderMode.SOLID) {
4175
+ if (this.renderMode === 0) {
3904
4176
  this.targetBrightAlpha = dynamicDarkAlpha;
3905
4177
  this.targetDarkAlpha = dynamicDarkAlpha;
3906
4178
  } else {
@@ -3922,7 +4194,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3922
4194
  this.element.style.setProperty("--bright-mask-alpha", this.currentBrightAlpha.toFixed(3));
3923
4195
  this.element.style.setProperty("--dark-mask-alpha", this.currentDarkAlpha.toFixed(3));
3924
4196
  }
3925
- setTransform(top = this.top, scale = this.scale, opacity = 1, blur = 0, force = false, delay = 0, mode = LyricLineRenderMode.SOLID) {
4197
+ setTransform(top = this.top, scale = this.scale, opacity = 1, blur = 0, force = false, delay = 0, mode = 0) {
3926
4198
  super.setTransform(top, scale, opacity, blur, force, delay);
3927
4199
  this.renderMode = mode;
3928
4200
  const beforeInSight = this.isInSight;
@@ -3979,6 +4251,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3979
4251
  return !(t > pb + h + ov || b < -h - ov);
3980
4252
  }
3981
4253
  disposeElements() {
4254
+ this.balancer?.reset();
3982
4255
  for (const realWord of this.splittedWords) {
3983
4256
  for (const a of realWord.elementAnimations) a.cancel();
3984
4257
  for (const a of realWord.maskAnimations) a.cancel();