@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.
@@ -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,277 @@ 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
+ *
3131
+ * 比空格更高以便优先一点在标点处换行
3132
+ */
3133
+ const PUNCTUATION_BREAK_REWARD_RATIO = .6;
3134
+ const PUNCTUATION_REGEX = /[,.;:!?,。;:!?、)】》」』’”)[\]}>~…]$/;
3135
+ /**
3136
+ * 计算平均行长度的断点位置
3137
+ * @param children 子节点信息
3138
+ * @param containerWidth 容器可用内容宽度
3139
+ * @param fullText 完整的行文本
3140
+ * @param segmenter 预创建的 Intl.Segmenter 分词器
3141
+ * @returns 需要在其前面插入 `<br>` 的子节点索引数组,升序
3142
+ */
3143
+ function calcBalancedBreaks(children, containerWidth, fullText, segmenter) {
3144
+ const n = children.length;
3145
+ if (n === 0 || containerWidth <= 0) return [];
3146
+ const cjkBoundaries = /* @__PURE__ */ new Set();
3147
+ let offset = 0;
3148
+ for (const { segment, isWordLike } of segmenter.segment(fullText)) {
3149
+ if (offset > 0 && isWordLike) {
3150
+ if ([...segment].some((ch) => isCJK(ch))) cjkBoundaries.add(offset);
3151
+ }
3152
+ offset += segment.length;
3153
+ }
3154
+ const charOffsets = new Int32Array(n + 1);
3155
+ const prefixWidth = new Float64Array(n + 1);
3156
+ for (let i = 0; i < n; i++) {
3157
+ charOffsets[i + 1] = charOffsets[i] + children[i].text.length;
3158
+ prefixWidth[i + 1] = prefixWidth[i] + children[i].width;
3159
+ }
3160
+ if (prefixWidth[n] <= containerWidth) return [];
3161
+ /**
3162
+ * dp[i] 表示将 index i 到 n-1 的节点进行排版的最小代价
3163
+ */
3164
+ const dp = new Float64Array(n + 1).fill(Number.POSITIVE_INFINITY);
3165
+ const nextBreak = new Int32Array(n + 1).fill(-1);
3166
+ dp[n] = 0;
3167
+ const PENALTY_CJK = (containerWidth * CJK_BREAK_PENALTY_RATIO) ** 2;
3168
+ const PENALTY_NORMAL = (containerWidth * NORMAL_BREAK_PENALTY_RATIO) ** 2;
3169
+ for (let i = n - 1; i >= 0; i--) for (let j = i + 1; j <= n; j++) {
3170
+ const w = prefixWidth[j] - prefixWidth[i];
3171
+ let lineCost = 0;
3172
+ if (w > containerWidth) if (j === i + 1) lineCost = (w - containerWidth) ** 2 * OVERFLOW_PENALTY_MULTIPLIER;
3173
+ else continue;
3174
+ else lineCost = (containerWidth - w) ** 2;
3175
+ let breakPenalty = 0;
3176
+ if (j < n) {
3177
+ const prevChild = children[j - 1];
3178
+ if (PUNCTUATION_REGEX.test(prevChild.text)) breakPenalty = -((containerWidth * PUNCTUATION_BREAK_REWARD_RATIO) ** 2);
3179
+ else if (prevChild.isSpace) breakPenalty = -((containerWidth * SPACE_BREAK_REWARD_RATIO) ** 2);
3180
+ else if (cjkBoundaries.has(charOffsets[j])) breakPenalty = PENALTY_CJK;
3181
+ else breakPenalty = PENALTY_NORMAL;
3182
+ }
3183
+ const totalCost = lineCost + breakPenalty + dp[j];
3184
+ if (totalCost < dp[i]) {
3185
+ dp[i] = totalCost;
3186
+ nextBreak[i] = j;
3187
+ }
3188
+ }
3189
+ const breaks = [];
3190
+ let curr = 0;
3191
+ while (curr < n) {
3192
+ curr = nextBreak[curr];
3193
+ if (curr > 0 && curr < n) breaks.push(curr);
3194
+ }
3195
+ return breaks;
3196
+ }
3197
+ //#endregion
3198
+ //#region src/utils/line-balancer.ts
3199
+ let sharedCanvasCtx = null;
3200
+ function getMeasurementContext() {
3201
+ if (!sharedCanvasCtx) sharedCanvasCtx = document.createElement("canvas").getContext("2d");
3202
+ return sharedCanvasCtx;
3203
+ }
3204
+ /**
3205
+ * 用于平衡歌词行在换行后的各行长度
3206
+ */
3207
+ var LineBalancer = class {
3208
+ isBalancing = false;
3209
+ lastBalancedContainerWidth = -1;
3210
+ constructor(mainElement) {
3211
+ this.mainElement = mainElement;
3212
+ }
3213
+ balanceLineBreaks(isNonDynamic, hasSplittedWords, wordSegmenter) {
3214
+ if (this.isBalancing || !this.mainElement) return;
3215
+ const computedStyle = getComputedStyle(this.mainElement);
3216
+ const paddingLeft = Number.parseFloat(computedStyle.paddingLeft) || 0;
3217
+ const paddingRight = Number.parseFloat(computedStyle.paddingRight) || 0;
3218
+ const containerWidth = this.mainElement.clientWidth - paddingLeft - paddingRight;
3219
+ if (containerWidth <= 0) return;
3220
+ if (isNonDynamic) {
3221
+ this.balanceNonDynamicLineBreaks(containerWidth, computedStyle, wordSegmenter);
3222
+ return;
3223
+ }
3224
+ if (!hasSplittedWords) return;
3225
+ this.balanceDynamicLineBreaks(containerWidth, wordSegmenter);
3226
+ }
3227
+ reset() {
3228
+ this.lastBalancedContainerWidth = -1;
3229
+ }
3230
+ executeLineBalance(containerWidth, adapter, wordSegmenter) {
3231
+ const existingBrs = this.mainElement.querySelectorAll("br");
3232
+ if (containerWidth === this.lastBalancedContainerWidth && existingBrs.length > 0) return;
3233
+ adapter.resetDOM();
3234
+ const prevWhiteSpace = this.mainElement.style.whiteSpace;
3235
+ this.mainElement.style.whiteSpace = "nowrap";
3236
+ const parentElement = this.mainElement.parentElement;
3237
+ let prevTransform = "";
3238
+ let transformChanged = false;
3239
+ if (parentElement) {
3240
+ prevTransform = parentElement.style.transform;
3241
+ if (prevTransform && prevTransform !== "none") {
3242
+ parentElement.style.transform = "none";
3243
+ transformChanged = true;
3244
+ }
3245
+ }
3246
+ let lockAcquired = false;
3247
+ try {
3248
+ const { childInfos, fullText } = adapter.buildChildInfos();
3249
+ let layoutWidth = childInfos.reduce((sum, c) => sum + c.width, 0);
3250
+ if (adapter.needsCalibration) {
3251
+ const range = document.createRange();
3252
+ range.selectNodeContents(this.mainElement);
3253
+ const visualWidth = range.getBoundingClientRect().width;
3254
+ if (layoutWidth > 0 && visualWidth > 0) {
3255
+ const scale = visualWidth / layoutWidth;
3256
+ for (const info of childInfos) info.width *= scale;
3257
+ }
3258
+ layoutWidth = visualWidth;
3259
+ }
3260
+ const safeContainerWidth = Math.max(1, containerWidth);
3261
+ if (layoutWidth <= safeContainerWidth) {
3262
+ this.lastBalancedContainerWidth = containerWidth;
3263
+ return;
3264
+ }
3265
+ const breaks = calcBalancedBreaks(childInfos, safeContainerWidth, fullText, wordSegmenter);
3266
+ if (breaks.length === 0) {
3267
+ this.lastBalancedContainerWidth = containerWidth;
3268
+ return;
3269
+ }
3270
+ this.isBalancing = true;
3271
+ lockAcquired = true;
3272
+ adapter.applyBreaks(breaks, childInfos);
3273
+ this.lastBalancedContainerWidth = containerWidth;
3274
+ this.isBalancing = false;
3275
+ } finally {
3276
+ this.mainElement.style.whiteSpace = prevWhiteSpace;
3277
+ if (transformChanged && parentElement) parentElement.style.transform = prevTransform;
3278
+ if (lockAcquired) this.isBalancing = false;
3279
+ }
3280
+ }
3281
+ balanceDynamicLineBreaks(containerWidth, wordSegmenter) {
3282
+ const infoToNode = [];
3283
+ this.executeLineBalance(containerWidth, {
3284
+ resetDOM: () => {
3285
+ this.mainElement.querySelectorAll("br").forEach((br) => {
3286
+ br.remove();
3287
+ });
3288
+ },
3289
+ buildChildInfos: () => {
3290
+ infoToNode.length = 0;
3291
+ const childNodes = Array.from(this.mainElement.childNodes);
3292
+ const childInfos = [];
3293
+ const range = document.createRange();
3294
+ for (const node of childNodes) if (node.nodeType === Node.TEXT_NODE) {
3295
+ const text = node.textContent ?? "";
3296
+ if (text.length === 0) continue;
3297
+ range.selectNodeContents(node);
3298
+ childInfos.push({
3299
+ width: range.getBoundingClientRect().width,
3300
+ text,
3301
+ isSpace: text.trim().length === 0
3302
+ });
3303
+ infoToNode.push(node);
3304
+ } else if (node.nodeType === Node.ELEMENT_NODE) {
3305
+ const el = node;
3306
+ const rect = el.getBoundingClientRect();
3307
+ const elStyle = getComputedStyle(el);
3308
+ const marginLeft = Number.parseFloat(elStyle.marginLeft) || 0;
3309
+ const marginRight = Number.parseFloat(elStyle.marginRight) || 0;
3310
+ childInfos.push({
3311
+ width: Math.max(0, rect.width + marginLeft + marginRight),
3312
+ text: el.textContent ?? "",
3313
+ isSpace: false
3314
+ });
3315
+ infoToNode.push(node);
3316
+ }
3317
+ return {
3318
+ childInfos,
3319
+ fullText: childInfos.map((c) => c.text).join("")
3320
+ };
3321
+ },
3322
+ applyBreaks: (breaks) => {
3323
+ for (let i = breaks.length - 1; i >= 0; i--) {
3324
+ const breakIndex = breaks[i];
3325
+ if (breakIndex >= 0 && breakIndex < infoToNode.length) this.mainElement.insertBefore(document.createElement("br"), infoToNode[breakIndex]);
3326
+ }
3327
+ },
3328
+ needsCalibration: false
3329
+ }, wordSegmenter);
3330
+ }
3331
+ balanceNonDynamicLineBreaks(containerWidth, computedStyle, wordSegmenter) {
3332
+ const fullText = this.mainElement.textContent ?? "";
3333
+ if (fullText.trim().length === 0) return;
3334
+ this.executeLineBalance(containerWidth, {
3335
+ resetDOM: () => {
3336
+ this.mainElement.innerHTML = "";
3337
+ this.mainElement.textContent = fullText;
3338
+ },
3339
+ buildChildInfos: () => {
3340
+ const ctx = getMeasurementContext();
3341
+ if (!ctx) {
3342
+ console.debug("Canvas 2D context is not supported, skipping line balancing");
3343
+ return {
3344
+ childInfos: [],
3345
+ fullText
3346
+ };
3347
+ }
3348
+ ctx.font = `${computedStyle.fontWeight} ${computedStyle.fontSize} ${computedStyle.fontFamily}`;
3349
+ if ("letterSpacing" in ctx) ctx.letterSpacing = computedStyle.letterSpacing !== "normal" ? computedStyle.letterSpacing : "0px";
3350
+ if ("wordSpacing" in ctx) ctx.wordSpacing = computedStyle.wordSpacing !== "normal" ? computedStyle.wordSpacing : "0px";
3351
+ const childInfos = [];
3352
+ for (const { segment } of wordSegmenter.segment(fullText)) childInfos.push({
3353
+ width: ctx.measureText(segment).width,
3354
+ text: segment,
3355
+ isSpace: segment.trim().length === 0
3356
+ });
3357
+ return {
3358
+ childInfos,
3359
+ fullText
3360
+ };
3361
+ },
3362
+ applyBreaks: (breaks, childInfos) => {
3363
+ this.mainElement.innerHTML = "";
3364
+ const breakSet = new Set(breaks);
3365
+ const fragment = document.createDocumentFragment();
3366
+ for (let i = 0; i < childInfos.length; i++) {
3367
+ if (breakSet.has(i)) fragment.appendChild(document.createElement("br"));
3368
+ fragment.appendChild(document.createTextNode(childInfos[i].text));
3369
+ }
3370
+ this.mainElement.appendChild(fragment);
3371
+ },
3372
+ needsCalibration: true
3373
+ }, wordSegmenter);
3374
+ }
3375
+ };
3376
+ //#endregion
3100
3377
  //#region src/utils/lyric-split-words.ts
3101
- const hasSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter !== "undefined";
3378
+ const SPLIT_WHITESPACE_RE = /(\s+)/;
3379
+ const WHITESPACE_RE = /\s/g;
3102
3380
  /**
3103
3381
  * 将输入的单词重新分组,之间没有空格的单词将会组合成一个单词数组
3104
3382
  *
@@ -3109,27 +3387,41 @@ const hasSegmenter = typeof Intl !== "undefined" && typeof Intl.Segmenter !== "u
3109
3387
  * @returns 重新分组后的单词数组
3110
3388
  */
3111
3389
  function chunkAndSplitLyricWords(words) {
3112
- const atoms = [];
3390
+ const result = [];
3391
+ let currentGroup = [];
3392
+ const flushGroup = () => {
3393
+ if (currentGroup.length > 0) {
3394
+ result.push(currentGroup.length === 1 ? currentGroup[0] : [...currentGroup]);
3395
+ currentGroup = [];
3396
+ }
3397
+ };
3398
+ const processAtom = (atom) => {
3399
+ const isSpace = atom.word.trim().length === 0;
3400
+ const hasRuby = (atom.ruby?.length ?? 0) > 0;
3401
+ const isCJKChar = isCJK(atom.word);
3402
+ if (!isSpace && !hasRuby && !isCJKChar) currentGroup.push(atom);
3403
+ else {
3404
+ flushGroup();
3405
+ result.push(atom);
3406
+ }
3407
+ };
3113
3408
  for (const w of words) {
3114
3409
  const isSpace = w.word.trim().length === 0;
3115
3410
  const romanWord = w.romanWord ?? "";
3116
3411
  const obscene = w.obscene ?? false;
3117
3412
  const hasRuby = (w.ruby?.length ?? 0) > 0;
3118
- if (isSpace) {
3119
- atoms.push({ ...w });
3413
+ if (isSpace || hasRuby) {
3414
+ processAtom({ ...w });
3120
3415
  continue;
3121
3416
  }
3122
- if (hasRuby) {
3123
- atoms.push({ ...w });
3124
- continue;
3125
- }
3126
- const parts = w.word.split(/(\s+)/).filter((p) => p.length > 0);
3417
+ const parts = w.word.split(SPLIT_WHITESPACE_RE).filter((p) => p.length > 0);
3418
+ const totalLength = w.word.replace(WHITESPACE_RE, "").length || 1;
3419
+ const timePerUnit = (w.endTime - w.startTime) / totalLength;
3127
3420
  let currentOffset = 0;
3128
- const totalLength = w.word.replace(/\s/g, "").length || 1;
3129
3421
  for (const part of parts) {
3130
3422
  if (!part.trim()) {
3131
- const startTime = w.startTime + currentOffset / totalLength * (w.endTime - w.startTime);
3132
- atoms.push({
3423
+ const startTime = w.startTime + currentOffset * timePerUnit;
3424
+ processAtom({
3133
3425
  word: part,
3134
3426
  romanWord: "",
3135
3427
  startTime,
@@ -3141,63 +3433,31 @@ function chunkAndSplitLyricWords(words) {
3141
3433
  if (isCJK(part) && part.length > 1 && romanWord.trim().length === 0) {
3142
3434
  const chars = part.split("");
3143
3435
  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({
3436
+ const startTime = w.startTime + currentOffset * timePerUnit;
3437
+ processAtom({
3147
3438
  word: char,
3148
3439
  romanWord: "",
3149
3440
  startTime,
3150
- endTime: startTime + charDuration,
3441
+ endTime: startTime + timePerUnit,
3151
3442
  obscene
3152
3443
  });
3153
3444
  currentOffset += 1;
3154
3445
  }
3155
3446
  } else {
3156
3447
  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({
3448
+ const startTime = w.startTime + currentOffset * timePerUnit;
3449
+ processAtom({
3160
3450
  word: part,
3161
3451
  romanWord,
3162
3452
  startTime,
3163
- endTime: startTime + duration,
3453
+ endTime: startTime + partRealLen * timePerUnit,
3164
3454
  obscene
3165
3455
  });
3166
3456
  currentOffset += partRealLen;
3167
3457
  }
3168
3458
  }
3169
3459
  }
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);
3460
+ flushGroup();
3201
3461
  return result;
3202
3462
  }
3203
3463
  //#endregion
@@ -3279,12 +3539,15 @@ var LyricLineEl$1 = class extends LyricLineBase {
3279
3539
  splittedWords = [];
3280
3540
  built = false;
3281
3541
  lineSize = [0, 0];
3282
- renderMode = LyricLineRenderMode.SOLID;
3542
+ renderMode = 0;
3283
3543
  currentBrightAlpha = 1;
3284
3544
  currentDarkAlpha = .2;
3285
3545
  targetBrightAlpha = 1;
3286
3546
  targetDarkAlpha = .2;
3287
- segmenter = new Intl.Segmenter(void 0, { granularity: "grapheme" });
3547
+ /**
3548
+ * 用于平衡换行、尽量减少各行长度差异的类
3549
+ */
3550
+ balancer;
3288
3551
  constructor(lyricPlayer, lyricLine = {
3289
3552
  words: [],
3290
3553
  translatedLyric: "",
@@ -3312,6 +3575,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3312
3575
  main.setAttribute("class", lyric_player_module_default.lyricMainLine);
3313
3576
  trans.setAttribute("class", lyric_player_module_default.lyricSubLine);
3314
3577
  roman.setAttribute("class", lyric_player_module_default.lyricSubLine);
3578
+ if (LyricLineBase.wordSegmenter) this.balancer = new LineBalancer(main);
3315
3579
  this.rebuildStyle();
3316
3580
  }
3317
3581
  listenersMap = /* @__PURE__ */ new Map();
@@ -3385,7 +3649,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3385
3649
  disable() {
3386
3650
  this.isEnabled = false;
3387
3651
  this.element.classList.remove(lyric_player_module_default.active);
3388
- this.renderMode = LyricLineRenderMode.SOLID;
3652
+ this.renderMode = 0;
3389
3653
  const main = this.element.children[0];
3390
3654
  for (const word of this.splittedWords) {
3391
3655
  for (const a of word.elementAnimations) if (a.id === "float-word" || a.id.includes("emphasize-word-float-only")) {
@@ -3521,7 +3785,14 @@ var LyricLineEl$1 = class extends LyricLineBase {
3521
3785
  const displayWord = this.lyricPlayer.processObsceneWord(word);
3522
3786
  if (shouldEmphasize) {
3523
3787
  mainWordEl.classList.add(lyric_player_module_default.emphasize);
3524
- for (const { segment } of this.segmenter.segment(displayWord.trim())) {
3788
+ const trimmedWord = displayWord.trim();
3789
+ if (LyricLineBase.graphemeSegmenter) for (const { segment } of LyricLineBase.graphemeSegmenter.segment(trimmedWord)) {
3790
+ const charEl = document.createElement("span");
3791
+ charEl.innerText = segment;
3792
+ subElements.push(charEl);
3793
+ wordContainer.appendChild(charEl);
3794
+ }
3795
+ else for (const segment of Array.from(trimmedWord)) {
3525
3796
  const charEl = document.createElement("span");
3526
3797
  charEl.innerText = segment;
3527
3798
  subElements.push(charEl);
@@ -3703,6 +3974,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3703
3974
  word.padding = 0;
3704
3975
  }
3705
3976
  }
3977
+ if (this.balancer && LyricLineBase.wordSegmenter) this.balancer.balanceLineBreaks(this.lyricPlayer._getIsNonDynamic(), this.splittedWords.length > 0, LyricLineBase.wordSegmenter);
3706
3978
  if (this.lyricPlayer.supportMaskImage) this.generateWebAnimationBasedMaskImage();
3707
3979
  else this.generateCalcBasedMaskImage();
3708
3980
  if (this.isEnabled) {
@@ -3875,7 +4147,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3875
4147
  const factor = Math.max(0, Math.min(1, (scale - .97) / .03));
3876
4148
  const dynamicDarkAlpha = factor * .2 + .2;
3877
4149
  const dynamicBrightAlpha = factor * .8 + .2;
3878
- if (this.renderMode === LyricLineRenderMode.SOLID) {
4150
+ if (this.renderMode === 0) {
3879
4151
  this.targetBrightAlpha = dynamicDarkAlpha;
3880
4152
  this.targetDarkAlpha = dynamicDarkAlpha;
3881
4153
  } else {
@@ -3897,7 +4169,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3897
4169
  this.element.style.setProperty("--bright-mask-alpha", this.currentBrightAlpha.toFixed(3));
3898
4170
  this.element.style.setProperty("--dark-mask-alpha", this.currentDarkAlpha.toFixed(3));
3899
4171
  }
3900
- setTransform(top = this.top, scale = this.scale, opacity = 1, blur = 0, force = false, delay = 0, mode = LyricLineRenderMode.SOLID) {
4172
+ setTransform(top = this.top, scale = this.scale, opacity = 1, blur = 0, force = false, delay = 0, mode = 0) {
3901
4173
  super.setTransform(top, scale, opacity, blur, force, delay);
3902
4174
  this.renderMode = mode;
3903
4175
  const beforeInSight = this.isInSight;
@@ -3954,6 +4226,7 @@ var LyricLineEl$1 = class extends LyricLineBase {
3954
4226
  return !(t > pb + h + ov || b < -h - ov);
3955
4227
  }
3956
4228
  disposeElements() {
4229
+ this.balancer?.reset();
3957
4230
  for (const realWord of this.splittedWords) {
3958
4231
  for (const a of realWord.elementAnimations) a.cancel();
3959
4232
  for (const a of realWord.maskAnimations) a.cancel();