@applemusic-like-lyrics/core 0.4.0 → 0.4.1

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.
@@ -722,6 +722,15 @@ declare abstract class LyricLineBase extends EventTarget implements Disposable {
722
722
  protected opacity: number;
723
723
  protected delay: number;
724
724
  readonly lineTransforms: LineTransforms;
725
+ /**
726
+ * 用于 CJK 词语边界检测的分词器
727
+ */
728
+ static readonly wordSegmenter: Intl.Segmenter | null;
729
+ /**
730
+ * Unicode 标准的全局 Grapheme Cluster 分词器
731
+ * 用于正确处理 emoji、复合字符等
732
+ */
733
+ static readonly graphemeSegmenter: Intl.Segmenter | null;
725
734
  abstract getLine(): LyricLine;
726
735
  abstract enable(time?: number, shouldPlay?: boolean): void;
727
736
  abstract disable(): void;
@@ -775,7 +784,10 @@ declare class LyricLineEl$1 extends LyricLineBase {
775
784
  private currentDarkAlpha;
776
785
  private targetBrightAlpha;
777
786
  private targetDarkAlpha;
778
- private segmenter;
787
+ /**
788
+ * 用于平衡换行、尽量减少各行长度差异的类
789
+ */
790
+ private balancer?;
779
791
  constructor(lyricPlayer: DomLyricPlayer, lyricLine?: LyricLine);
780
792
  private listenersMap;
781
793
  private readonly onMouseEvent;
@@ -722,6 +722,15 @@ declare abstract class LyricLineBase extends EventTarget implements Disposable {
722
722
  protected opacity: number;
723
723
  protected delay: number;
724
724
  readonly lineTransforms: LineTransforms;
725
+ /**
726
+ * 用于 CJK 词语边界检测的分词器
727
+ */
728
+ static readonly wordSegmenter: Intl.Segmenter | null;
729
+ /**
730
+ * Unicode 标准的全局 Grapheme Cluster 分词器
731
+ * 用于正确处理 emoji、复合字符等
732
+ */
733
+ static readonly graphemeSegmenter: Intl.Segmenter | null;
725
734
  abstract getLine(): LyricLine;
726
735
  abstract enable(time?: number, shouldPlay?: boolean): void;
727
736
  abstract disable(): void;
@@ -775,7 +784,10 @@ declare class LyricLineEl$1 extends LyricLineBase {
775
784
  private currentDarkAlpha;
776
785
  private targetBrightAlpha;
777
786
  private targetDarkAlpha;
778
- private segmenter;
787
+ /**
788
+ * 用于平衡换行、尽量减少各行长度差异的类
789
+ */
790
+ private balancer?;
779
791
  constructor(lyricPlayer: DomLyricPlayer, lyricLine?: LyricLine);
780
792
  private listenersMap;
781
793
  private readonly onMouseEvent;
@@ -2249,7 +2249,7 @@ var LyricPlayerBase = class extends EventTarget {
2249
2249
  bottomLine = new BottomLineEl(this);
2250
2250
  enableBlur = true;
2251
2251
  enableScale = true;
2252
- maskObsceneWords = MaskObsceneWordsMode.Disabled;
2252
+ maskObsceneWords = "";
2253
2253
  maskObsceneWordChar = "*";
2254
2254
  hidePassedLines = false;
2255
2255
  scrollBoundary = [0, 0];
@@ -2539,7 +2539,7 @@ var LyricPlayerBase = class extends EventTarget {
2539
2539
  const c = char.charAt(0) || "*";
2540
2540
  if (this.maskObsceneWordChar === c) return;
2541
2541
  this.maskObsceneWordChar = c;
2542
- if (this.maskObsceneWords !== MaskObsceneWordsMode.Disabled) {
2542
+ if (this.maskObsceneWords !== "") {
2543
2543
  this.rebuildLyricLines();
2544
2544
  this.calcLayout();
2545
2545
  }
@@ -2554,10 +2554,10 @@ var LyricPlayerBase = class extends EventTarget {
2554
2554
  */
2555
2555
  processObsceneWord(word) {
2556
2556
  const text = word.word;
2557
- if (!word.obscene || this.maskObsceneWords === MaskObsceneWordsMode.Disabled) return text;
2557
+ if (!word.obscene || this.maskObsceneWords === "") return text;
2558
2558
  const maskChar = this.maskObsceneWordChar;
2559
- if (this.maskObsceneWords === MaskObsceneWordsMode.FullMask) return text.replace(/\S/g, maskChar);
2560
- if (this.maskObsceneWords === MaskObsceneWordsMode.PartialMask) {
2559
+ if (this.maskObsceneWords === "full-mask") return text.replace(/\S/g, maskChar);
2560
+ if (this.maskObsceneWords === "partial-mask") {
2561
2561
  const trimmed = text.trim();
2562
2562
  if (trimmed.length <= 2) return text.replace(/\S/g, maskChar);
2563
2563
  const startPos = text.indexOf(trimmed);
@@ -2918,7 +2918,7 @@ var LyricPlayerBase = class extends EventTarget {
2918
2918
  let targetScale = 100;
2919
2919
  if (!isActive && this.isPlaying) if (line.isBG) targetScale = 75;
2920
2920
  else targetScale = SCALE_ASPECT;
2921
- const renderMode = isActive ? LyricLineRenderMode.GRADIENT : LyricLineRenderMode.SOLID;
2921
+ const renderMode = isActive ? 1 : 0;
2922
2922
  lineObj.setTransform(curPos, targetScale, targetOpacity, blurLevel, force, delay, renderMode);
2923
2923
  if (line.isBG && (isActive || !this.isPlaying)) curPos += this.lyricLinesSize.get(lineObj)?.[1] ?? LINE_HEIGHT_FALLBACK;
2924
2924
  else if (!line.isBG) curPos += this.lyricLinesSize.get(lineObj)?.[1] ?? LINE_HEIGHT_FALLBACK;
@@ -3071,8 +3071,17 @@ var LyricLineBase = class extends EventTarget {
3071
3071
  posY: new Spring(0),
3072
3072
  scale: new Spring(100)
3073
3073
  };
3074
+ /**
3075
+ * 用于 CJK 词语边界检测的分词器
3076
+ */
3077
+ static wordSegmenter = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter(void 0, { granularity: "word" }) : null;
3078
+ /**
3079
+ * Unicode 标准的全局 Grapheme Cluster 分词器
3080
+ * 用于正确处理 emoji、复合字符等
3081
+ */
3082
+ static graphemeSegmenter = typeof Intl !== "undefined" && Intl.Segmenter ? new Intl.Segmenter(void 0, { granularity: "grapheme" }) : null;
3074
3083
  onLineSizeChange(_size) {}
3075
- setTransform(top = this.top, scale = this.scale, opacity = this.opacity, blur = this.blur, _force = false, delay = 0, _mode = LyricLineRenderMode.SOLID) {
3084
+ setTransform(top = this.top, scale = this.scale, opacity = this.opacity, blur = this.blur, _force = false, delay = 0, _mode = 0) {
3076
3085
  this.top = top;
3077
3086
  this.scale = scale;
3078
3087
  this.opacity = opacity;
@@ -3097,8 +3106,251 @@ var LyricLineBase = class extends EventTarget {
3097
3106
  dispose() {}
3098
3107
  };
3099
3108
  //#endregion
3109
+ //#region src/utils/lyric-line-break.ts
3110
+ /**
3111
+ * 单个词超过容器宽度时的大惩罚倍数
3112
+ */
3113
+ const OVERFLOW_PENALTY_MULTIPLIER = 1e3;
3114
+ /**
3115
+ * 截断 CJK 词组边界的惩罚比例
3116
+ *
3117
+ * 相对于容器宽度
3118
+ */
3119
+ const CJK_BREAK_PENALTY_RATIO = .15;
3120
+ /**
3121
+ * 截断普通文本(非空格、非 CJK 词界)的惩罚比例
3122
+ */
3123
+ const NORMAL_BREAK_PENALTY_RATIO = .5;
3124
+ /**
3125
+ * 在空格处断开的奖励比例
3126
+ */
3127
+ const SPACE_BREAK_REWARD_RATIO = .4;
3128
+ /**
3129
+ * 计算平均行长度的断点位置
3130
+ * @param children 子节点信息
3131
+ * @param containerWidth 容器可用内容宽度
3132
+ * @param fullText 完整的行文本
3133
+ * @param segmenter 预创建的 Intl.Segmenter 分词器
3134
+ * @returns 需要在其前面插入 `<br>` 的子节点索引数组,升序
3135
+ */
3136
+ function calcBalancedBreaks(children, containerWidth, fullText, segmenter) {
3137
+ const n = children.length;
3138
+ if (n === 0 || containerWidth <= 0) return [];
3139
+ const cjkBoundaries = /* @__PURE__ */ new Set();
3140
+ let offset = 0;
3141
+ for (const { segment, isWordLike } of segmenter.segment(fullText)) {
3142
+ if (offset > 0 && isWordLike) {
3143
+ if ([...segment].some((ch) => isCJK(ch))) cjkBoundaries.add(offset);
3144
+ }
3145
+ offset += segment.length;
3146
+ }
3147
+ const charOffsets = new Int32Array(n + 1);
3148
+ const prefixWidth = new Float64Array(n + 1);
3149
+ for (let i = 0; i < n; i++) {
3150
+ charOffsets[i + 1] = charOffsets[i] + children[i].text.length;
3151
+ prefixWidth[i + 1] = prefixWidth[i] + children[i].width;
3152
+ }
3153
+ if (prefixWidth[n] <= containerWidth) return [];
3154
+ /**
3155
+ * dp[i] 表示将 index i 到 n-1 的节点进行排版的最小代价
3156
+ */
3157
+ const dp = new Float64Array(n + 1).fill(Number.POSITIVE_INFINITY);
3158
+ const nextBreak = new Int32Array(n + 1).fill(-1);
3159
+ dp[n] = 0;
3160
+ const PENALTY_CJK = (containerWidth * CJK_BREAK_PENALTY_RATIO) ** 2;
3161
+ const PENALTY_NORMAL = (containerWidth * NORMAL_BREAK_PENALTY_RATIO) ** 2;
3162
+ for (let i = n - 1; i >= 0; i--) for (let j = i + 1; j <= n; j++) {
3163
+ const w = prefixWidth[j] - prefixWidth[i];
3164
+ let lineCost = 0;
3165
+ if (w > containerWidth) if (j === i + 1) lineCost = (w - containerWidth) ** 2 * OVERFLOW_PENALTY_MULTIPLIER;
3166
+ else continue;
3167
+ else lineCost = (containerWidth - w) ** 2;
3168
+ let breakPenalty = 0;
3169
+ if (j < n) if (children[j - 1].isSpace) breakPenalty = -((containerWidth * SPACE_BREAK_REWARD_RATIO) ** 2);
3170
+ else if (cjkBoundaries.has(charOffsets[j])) breakPenalty = PENALTY_CJK;
3171
+ else breakPenalty = PENALTY_NORMAL;
3172
+ const totalCost = lineCost + breakPenalty + dp[j];
3173
+ if (totalCost < dp[i]) {
3174
+ dp[i] = totalCost;
3175
+ nextBreak[i] = j;
3176
+ }
3177
+ }
3178
+ const breaks = [];
3179
+ let curr = 0;
3180
+ while (curr < n) {
3181
+ curr = nextBreak[curr];
3182
+ if (curr > 0 && curr < n) breaks.push(curr);
3183
+ }
3184
+ return breaks;
3185
+ }
3186
+ //#endregion
3187
+ //#region src/utils/line-balancer.ts
3188
+ let sharedCanvasCtx = null;
3189
+ function getMeasurementContext() {
3190
+ if (!sharedCanvasCtx) sharedCanvasCtx = document.createElement("canvas").getContext("2d");
3191
+ return sharedCanvasCtx;
3192
+ }
3193
+ /**
3194
+ * 用于平衡歌词行在换行后的各行长度
3195
+ */
3196
+ var LineBalancer = class LineBalancer {
3197
+ isBalancing = false;
3198
+ lastBalancedContainerWidth = -1;
3199
+ /**
3200
+ * 防止误差导致的意外换行
3201
+ */
3202
+ static SAFE_WIDTH_PADDING = 25;
3203
+ constructor(mainElement) {
3204
+ this.mainElement = mainElement;
3205
+ }
3206
+ balanceLineBreaks(isNonDynamic, hasSplittedWords, wordSegmenter) {
3207
+ if (this.isBalancing || !this.mainElement) return;
3208
+ const computedStyle = getComputedStyle(this.mainElement);
3209
+ const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
3210
+ const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
3211
+ const containerWidth = this.mainElement.clientWidth - paddingLeft - paddingRight;
3212
+ if (containerWidth <= 0) return;
3213
+ if (isNonDynamic) {
3214
+ this.balanceNonDynamicLineBreaks(containerWidth, computedStyle, wordSegmenter);
3215
+ return;
3216
+ }
3217
+ if (!hasSplittedWords) return;
3218
+ this.balanceDynamicLineBreaks(containerWidth, wordSegmenter);
3219
+ }
3220
+ reset() {
3221
+ this.lastBalancedContainerWidth = -1;
3222
+ }
3223
+ executeLineBalance(containerWidth, adapter, wordSegmenter) {
3224
+ const existingBrs = this.mainElement.querySelectorAll("br");
3225
+ if (containerWidth === this.lastBalancedContainerWidth && existingBrs.length > 0) return;
3226
+ adapter.resetDOM();
3227
+ const prevWhiteSpace = this.mainElement.style.whiteSpace;
3228
+ this.mainElement.style.whiteSpace = "nowrap";
3229
+ try {
3230
+ const range = document.createRange();
3231
+ range.selectNodeContents(this.mainElement);
3232
+ const lineWidth = range.getBoundingClientRect().width;
3233
+ const safeContainerWidth = Math.max(1, containerWidth - LineBalancer.SAFE_WIDTH_PADDING);
3234
+ if (lineWidth <= safeContainerWidth) {
3235
+ this.lastBalancedContainerWidth = containerWidth;
3236
+ return;
3237
+ }
3238
+ const { childInfos, fullText } = adapter.buildChildInfos();
3239
+ const measuredTotal = childInfos.reduce((sum, c) => sum + c.width, 0);
3240
+ if (measuredTotal > 0 && lineWidth > 0) {
3241
+ const scale = lineWidth / measuredTotal;
3242
+ for (const info of childInfos) info.width *= scale;
3243
+ }
3244
+ const breaks = calcBalancedBreaks(childInfos, safeContainerWidth, fullText, wordSegmenter);
3245
+ if (breaks.length === 0) {
3246
+ this.lastBalancedContainerWidth = containerWidth;
3247
+ return;
3248
+ }
3249
+ this.isBalancing = true;
3250
+ adapter.applyBreaks(breaks, childInfos);
3251
+ this.lastBalancedContainerWidth = containerWidth;
3252
+ this.isBalancing = false;
3253
+ } finally {
3254
+ this.mainElement.style.whiteSpace = prevWhiteSpace;
3255
+ }
3256
+ }
3257
+ balanceDynamicLineBreaks(containerWidth, wordSegmenter) {
3258
+ const infoToNode = [];
3259
+ this.executeLineBalance(containerWidth, {
3260
+ resetDOM: () => {
3261
+ this.mainElement.querySelectorAll("br").forEach((br) => {
3262
+ br.remove();
3263
+ });
3264
+ },
3265
+ buildChildInfos: () => {
3266
+ infoToNode.length = 0;
3267
+ const childNodes = Array.from(this.mainElement.childNodes);
3268
+ const childInfos = [];
3269
+ const range = document.createRange();
3270
+ for (const node of childNodes) if (node.nodeType === Node.TEXT_NODE) {
3271
+ const text = node.textContent ?? "";
3272
+ if (text.length === 0) continue;
3273
+ range.selectNodeContents(node);
3274
+ childInfos.push({
3275
+ width: range.getBoundingClientRect().width,
3276
+ text,
3277
+ isSpace: text.trim().length === 0
3278
+ });
3279
+ infoToNode.push(node);
3280
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
3281
+ const el = node;
3282
+ const rect = el.getBoundingClientRect();
3283
+ const elStyle = getComputedStyle(el);
3284
+ const marginLeft = Number.parseFloat(elStyle.marginLeft) || 0;
3285
+ const marginRight = Number.parseFloat(elStyle.marginRight) || 0;
3286
+ childInfos.push({
3287
+ width: Math.max(0, rect.width + marginLeft + marginRight),
3288
+ text: el.textContent ?? "",
3289
+ isSpace: false
3290
+ });
3291
+ infoToNode.push(node);
3292
+ }
3293
+ return {
3294
+ childInfos,
3295
+ fullText: childInfos.map((c) => c.text).join("")
3296
+ };
3297
+ },
3298
+ applyBreaks: (breaks) => {
3299
+ for (let i = breaks.length - 1; i >= 0; i--) {
3300
+ const breakIndex = breaks[i];
3301
+ if (breakIndex >= 0 && breakIndex < infoToNode.length) this.mainElement.insertBefore(document.createElement("br"), infoToNode[breakIndex]);
3302
+ }
3303
+ }
3304
+ }, wordSegmenter);
3305
+ }
3306
+ balanceNonDynamicLineBreaks(containerWidth, computedStyle, wordSegmenter) {
3307
+ const fullText = this.mainElement.textContent ?? "";
3308
+ if (fullText.trim().length === 0) return;
3309
+ this.executeLineBalance(containerWidth, {
3310
+ resetDOM: () => {
3311
+ this.mainElement.innerHTML = "";
3312
+ this.mainElement.textContent = fullText;
3313
+ },
3314
+ buildChildInfos: () => {
3315
+ const ctx = getMeasurementContext();
3316
+ if (!ctx) {
3317
+ console.debug("Canvas 2D context is not supported, skipping line balancing");
3318
+ return {
3319
+ childInfos: [],
3320
+ fullText
3321
+ };
3322
+ }
3323
+ ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
3324
+ if ("letterSpacing" in ctx) ctx.letterSpacing = computedStyle.letterSpacing !== "normal" ? computedStyle.letterSpacing : "0px";
3325
+ if ("wordSpacing" in ctx) ctx.wordSpacing = computedStyle.wordSpacing !== "normal" ? computedStyle.wordSpacing : "0px";
3326
+ const childInfos = [];
3327
+ for (const { segment } of wordSegmenter.segment(fullText)) childInfos.push({
3328
+ width: ctx.measureText(segment).width,
3329
+ text: segment,
3330
+ isSpace: segment.trim().length === 0
3331
+ });
3332
+ return {
3333
+ childInfos,
3334
+ fullText
3335
+ };
3336
+ },
3337
+ applyBreaks: (breaks, childInfos) => {
3338
+ this.mainElement.innerHTML = "";
3339
+ const breakSet = new Set(breaks);
3340
+ const fragment = document.createDocumentFragment();
3341
+ for (let i = 0; i < childInfos.length; i++) {
3342
+ if (breakSet.has(i)) fragment.appendChild(document.createElement("br"));
3343
+ fragment.appendChild(document.createTextNode(childInfos[i].text));
3344
+ }
3345
+ this.mainElement.appendChild(fragment);
3346
+ }
3347
+ }, wordSegmenter);
3348
+ }
3349
+ };
3350
+ //#endregion
3100
3351
  //#region src/utils/lyric-split-words.ts
3101
- const hasSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter !== "undefined";
3352
+ const SPLIT_WHITESPACE_RE = /(\s+)/;
3353
+ const WHITESPACE_RE = /\s/g;
3102
3354
  /**
3103
3355
  * 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
3104
3356
  *
@@ -3109,27 +3361,41 @@ const hasSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter !== "u
3109
3361
  * @returns 重新分组后的单词数组
3110
3362
  */
3111
3363
  function chunkAndSplitLyricWords(words) {
3112
- const atoms = [];
3364
+ const result = [];
3365
+ let currentGroup = [];
3366
+ const flushGroup = () => {
3367
+ if (currentGroup.length > 0) {
3368
+ result.push(currentGroup.length === 1 ? currentGroup[0] : [...currentGroup]);
3369
+ currentGroup = [];
3370
+ }
3371
+ };
3372
+ const processAtom = (atom) => {
3373
+ const isSpace = atom.word.trim().length === 0;
3374
+ const hasRuby = (atom.ruby?.length ?? 0) > 0;
3375
+ const isCJKChar = isCJK(atom.word);
3376
+ if (!isSpace && !hasRuby && !isCJKChar) currentGroup.push(atom);
3377
+ else {
3378
+ flushGroup();
3379
+ result.push(atom);
3380
+ }
3381
+ };
3113
3382
  for (const w of words) {
3114
3383
  const isSpace = w.word.trim().length === 0;
3115
3384
  const romanWord = w.romanWord ?? "";
3116
3385
  const obscene = w.obscene ?? false;
3117
3386
  const hasRuby = (w.ruby?.length ?? 0) > 0;
3118
- if (isSpace) {
3119
- atoms.push({ ...w });
3120
- continue;
3121
- }
3122
- if (hasRuby) {
3123
- atoms.push({ ...w });
3387
+ if (isSpace || hasRuby) {
3388
+ processAtom({ ...w });
3124
3389
  continue;
3125
3390
  }
3126
- const parts = w.word.split(/(\s+)/).filter((p) => p.length > 0);
3391
+ const parts = w.word.split(SPLIT_WHITESPACE_RE).filter((p) => p.length > 0);
3392
+ const totalLength = w.word.replace(WHITESPACE_RE, "").length || 1;
3393
+ const timePerUnit = (w.endTime - w.startTime) / totalLength;
3127
3394
  let currentOffset = 0;
3128
- const totalLength = w.word.replace(/\s/g, "").length || 1;
3129
3395
  for (const part of parts) {
3130
3396
  if (!part.trim()) {
3131
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3132
- atoms.push({
3397
+ const startTime = w.startTime + currentOffset * timePerUnit;
3398
+ processAtom({
3133
3399
  word: part,
3134
3400
  romanWord: "",
3135
3401
  startTime,
@@ -3141,63 +3407,31 @@ function chunkAndSplitLyricWords(words) {
3141
3407
  if (isCJK(part) && part.length > 1 && romanWord.trim().length === 0) {
3142
3408
  const chars = part.split("");
3143
3409
  for (const char of chars) {
3144
- const charDuration = 1 / totalLength * (w.endTime - w.startTime);
3145
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3146
- atoms.push({
3410
+ const startTime = w.startTime + currentOffset * timePerUnit;
3411
+ processAtom({
3147
3412
  word: char,
3148
3413
  romanWord: "",
3149
3414
  startTime,
3150
- endTime: startTime + charDuration,
3415
+ endTime: startTime + timePerUnit,
3151
3416
  obscene
3152
3417
  });
3153
3418
  currentOffset += 1;
3154
3419
  }
3155
3420
  } else {
3156
3421
  const partRealLen = part.length;
3157
- const duration = partRealLen / totalLength * (w.endTime - w.startTime);
3158
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3159
- atoms.push({
3422
+ const startTime = w.startTime + currentOffset * timePerUnit;
3423
+ processAtom({
3160
3424
  word: part,
3161
3425
  romanWord,
3162
3426
  startTime,
3163
- endTime: startTime + duration,
3427
+ endTime: startTime + partRealLen * timePerUnit,
3164
3428
  obscene
3165
3429
  });
3166
3430
  currentOffset += partRealLen;
3167
3431
  }
3168
3432
  }
3169
3433
  }
3170
- if (!hasSegmenter) return atoms;
3171
- const fullText = atoms.map((a) => a.word).join("");
3172
- const segmenter = new Intl.Segmenter(void 0, { granularity: "word" });
3173
- const segments = Array.from(segmenter.segment(fullText));
3174
- const result = [];
3175
- let atomIndex = 0;
3176
- let expectedLength = 0;
3177
- let actualLength = 0;
3178
- let currentGroup = [];
3179
- for (const segment of segments) {
3180
- const segmentLen = segment.segment.length;
3181
- expectedLength += segmentLen;
3182
- while (actualLength < expectedLength && atomIndex < atoms.length) {
3183
- const currentAtom = atoms[atomIndex];
3184
- currentGroup.push(currentAtom);
3185
- actualLength += currentAtom.word.length;
3186
- atomIndex++;
3187
- }
3188
- if (actualLength === expectedLength) {
3189
- while (currentGroup.length > 1 && !currentGroup[0].word.trim()) {
3190
- const spaceAtom = currentGroup.shift();
3191
- if (spaceAtom) result.push(spaceAtom);
3192
- }
3193
- if (currentGroup.length === 1) result.push(currentGroup[0]);
3194
- else if (currentGroup.length > 1) result.push(currentGroup);
3195
- currentGroup = [];
3196
- }
3197
- }
3198
- while (atomIndex < atoms.length) result.push(atoms[atomIndex++]);
3199
- if (currentGroup.length > 0) if (currentGroup.length === 1) result.push(currentGroup[0]);
3200
- else result.push(currentGroup);
3434
+ flushGroup();
3201
3435
  return result;
3202
3436
  }
3203
3437
  //#endregion
@@ -3279,12 +3513,15 @@ var LyricLineEl$1 = class extends LyricLineBase {
3279
3513
  splittedWords = [];
3280
3514
  built = false;
3281
3515
  lineSize = [0, 0];
3282
- renderMode = LyricLineRenderMode.SOLID;
3516
+ renderMode = 0;
3283
3517
  currentBrightAlpha = 1;
3284
3518
  currentDarkAlpha = .2;
3285
3519
  targetBrightAlpha = 1;
3286
3520
  targetDarkAlpha = .2;
3287
- segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3521
+ /**
3522
+ * 用于平衡换行、尽量减少各行长度差异的类
3523
+ */
3524
+ balancer;
3288
3525
  constructor(lyricPlayer, lyricLine = {
3289
3526
  words: [],
3290
3527
  translatedLyric: "",
@@ -3312,6 +3549,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3312
3549
  main.setAttribute("class", lyric_player_module_default.lyricMainLine);
3313
3550
  trans.setAttribute("class", lyric_player_module_default.lyricSubLine);
3314
3551
  roman.setAttribute("class", lyric_player_module_default.lyricSubLine);
3552
+ if (LyricLineBase.wordSegmenter) this.balancer = new LineBalancer(main);
3315
3553
  this.rebuildStyle();
3316
3554
  }
3317
3555
  listenersMap = /* @__PURE__ */ new Map();
@@ -3385,7 +3623,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3385
3623
  disable() {
3386
3624
  this.isEnabled = false;
3387
3625
  this.element.classList.remove(lyric_player_module_default.active);
3388
- this.renderMode = LyricLineRenderMode.SOLID;
3626
+ this.renderMode = 0;
3389
3627
  const main = this.element.children[0];
3390
3628
  for (const word of this.splittedWords) {
3391
3629
  for (const a of word.elementAnimations) if (a.id === "float-word" || a.id.includes("emphasize-word-float-only")) {
@@ -3521,7 +3759,14 @@ var LyricLineEl$1 = class extends LyricLineBase {
3521
3759
  const displayWord = this.lyricPlayer.processObsceneWord(word);
3522
3760
  if (shouldEmphasize) {
3523
3761
  mainWordEl.classList.add(lyric_player_module_default.emphasize);
3524
- for (const { segment } of this.segmenter.segment(displayWord.trim())) {
3762
+ const trimmedWord = displayWord.trim();
3763
+ if (LyricLineBase.graphemeSegmenter) for (const { segment } of LyricLineBase.graphemeSegmenter.segment(trimmedWord)) {
3764
+ const charEl = document.createElement("span");
3765
+ charEl.innerText = segment;
3766
+ subElements.push(charEl);
3767
+ wordContainer.appendChild(charEl);
3768
+ }
3769
+ else for (const segment of Array.from(trimmedWord)) {
3525
3770
  const charEl = document.createElement("span");
3526
3771
  charEl.innerText = segment;
3527
3772
  subElements.push(charEl);
@@ -3703,6 +3948,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3703
3948
  word.padding = 0;
3704
3949
  }
3705
3950
  }
3951
+ if (this.balancer && LyricLineBase.wordSegmenter) this.balancer.balanceLineBreaks(this.lyricPlayer._getIsNonDynamic(), this.splittedWords.length > 0, LyricLineBase.wordSegmenter);
3706
3952
  if (this.lyricPlayer.supportMaskImage) this.generateWebAnimationBasedMaskImage();
3707
3953
  else this.generateCalcBasedMaskImage();
3708
3954
  if (this.isEnabled) {
@@ -3875,7 +4121,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3875
4121
  const factor = Math.max(0, Math.min(1, (scale - .97) / .03));
3876
4122
  const dynamicDarkAlpha = factor * .2 + .2;
3877
4123
  const dynamicBrightAlpha = factor * .8 + .2;
3878
- if (this.renderMode === LyricLineRenderMode.SOLID) {
4124
+ if (this.renderMode === 0) {
3879
4125
  this.targetBrightAlpha = dynamicDarkAlpha;
3880
4126
  this.targetDarkAlpha = dynamicDarkAlpha;
3881
4127
  } else {
@@ -3897,7 +4143,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3897
4143
  this.element.style.setProperty("--bright-mask-alpha", this.currentBrightAlpha.toFixed(3));
3898
4144
  this.element.style.setProperty("--dark-mask-alpha", this.currentDarkAlpha.toFixed(3));
3899
4145
  }
3900
- setTransform(top = this.top, scale = this.scale, opacity = 1, blur = 0, force = false, delay = 0, mode = LyricLineRenderMode.SOLID) {
4146
+ setTransform(top = this.top, scale = this.scale, opacity = 1, blur = 0, force = false, delay = 0, mode = 0) {
3901
4147
  super.setTransform(top, scale, opacity, blur, force, delay);
3902
4148
  this.renderMode = mode;
3903
4149
  const beforeInSight = this.isInSight;
@@ -3954,6 +4200,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3954
4200
  return !(t > pb + h + ov || b < -h - ov);
3955
4201
  }
3956
4202
  disposeElements() {
4203
+ this.balancer?.reset();
3957
4204
  for (const realWord of this.splittedWords) {
3958
4205
  for (const a of realWord.elementAnimations) a.cancel();
3959
4206
  for (const a of realWord.maskAnimations) a.cancel();