/**
 * Video Player & Canvas KTV Karaoke Renderer
 */
class KTVPlayer {
  constructor(state) {
    this.state = state;
    this.video = document.getElementById("video-element");
    this.canvas = document.getElementById("ktv-canvas");
    this.ctx = this.canvas.getContext("2d");
    this.container = document.querySelector(".preview-container");
    this.dragLayer = document.getElementById("position-drag-layer");
    
    this.initEvents();
    this.initDragHandles();
    this.initProgressBar();
    this.startRenderLoop();
  }

  initEvents() {
    this.video.addEventListener("timeupdate", () => {
      this.state.setCurrentTime(this.video.currentTime);
      this.updateTimeDisplay();
      this.updateProgressBar();
    });

    this.video.addEventListener("loadedmetadata", () => {
      this.state.videoDuration = this.video.duration;
      this.resizeCanvas();
      this.updateTimeDisplay();
      this.updateProgressBar();
      this.state.emit("videoLoaded", {
        duration: this.video.duration,
        width: this.video.videoWidth,
        height: this.video.videoHeight
      });
    });

    window.addEventListener("resize", () => this.resizeCanvas());

    this.state.on("styleChanged", () => this.render());
    this.state.on("sentencesChanged", () => this.render());
  }

  resizeCanvas() {
    const rect = this.video.getBoundingClientRect();
    const dpr = window.devicePixelRatio || 1;
    this.canvas.width = rect.width * dpr;
    this.canvas.height = rect.height * dpr;
    this.canvas.style.width = `${rect.width}px`;
    this.canvas.style.height = `${rect.height}px`;
    this.canvas.style.left = `${this.video.offsetLeft}px`;
    this.canvas.style.top = `${this.video.offsetTop}px`;
    this.ctx.setTransform(dpr, 0, 0, dpr, 0, 0);
  }

  startRenderLoop() {
    const loop = () => {
      if (this.video && !this.video.paused) {
        this.state.setCurrentTime(this.video.currentTime);
        this.updateTimeDisplay();
        this.updateProgressBar();
      }
      this.render();
      requestAnimationFrame(loop);
    };
    requestAnimationFrame(loop);
  }

  play() {
    this.video.play().then(() => {
      this.state.isPlaying = true;
      this.state.emit("playStateChange", true);
    }).catch(e => console.log("Play error:", e));
  }

  pause() {
    this.video.pause();
    this.state.isPlaying = false;
    this.state.emit("playStateChange", false);
  }

  togglePlay() {
    if (this.video.paused) this.play();
    else this.pause();
  }

  seek(timeInSeconds) {
    const maxDur = (this.video.duration && !isNaN(this.video.duration) && this.video.duration > 0)
      ? this.video.duration
      : (this.state.videoDuration || 9999);
    const targetTime = Math.max(0, Math.min(maxDur, timeInSeconds));
    this.video.currentTime = targetTime;
    this.state.setCurrentTime(targetTime);
    this.updateTimeDisplay();
    this.updateProgressBar();
    this.render();
  }

  setPlaybackRate(rate) {
    this.video.playbackRate = rate;
    this.state.playbackRate = rate;
  }

  updateTimeDisplay() {
    const maxDur = (this.video.duration && !isNaN(this.video.duration) && this.video.duration > 0)
      ? this.video.duration
      : (this.state.videoDuration || 0);
    const currentEl = document.getElementById("current-time-text");
    const totalEl = document.getElementById("total-time-text");
    if (currentEl) currentEl.innerText = this.formatTime(this.video.currentTime);
    if (totalEl) totalEl.innerText = this.formatTime(maxDur);
  }

  updateProgressBar() {
    const fill = document.getElementById("video-progress-fill");
    const thumb = document.getElementById("video-progress-thumb");
    const maxDur = (this.video.duration && !isNaN(this.video.duration) && this.video.duration > 0)
      ? this.video.duration
      : (this.state.videoDuration || 0);
    if (maxDur <= 0) return;
    const pct = Math.max(0, Math.min(100, (this.video.currentTime / maxDur) * 100));
    if (fill) fill.style.width = `${pct}%`;
    if (thumb) thumb.style.left = `${pct}%`;
  }

  initProgressBar() {
    const container = document.getElementById("video-progress-wrap");
    if (!container) return;

    let isScrubbing = false;

    const scrub = (e) => {
      const rect = container.getBoundingClientRect();
      const pct = Math.max(0, Math.min(1, (e.clientX - rect.left) / rect.width));
      const maxDur = (this.video.duration && !isNaN(this.video.duration) && this.video.duration > 0)
        ? this.video.duration
        : (this.state.videoDuration || 0);
      if (maxDur > 0) {
        this.seek(pct * maxDur);
      }
    };

    container.addEventListener("mousedown", (e) => {
      isScrubbing = true;
      scrub(e);
      e.preventDefault();
    });

    window.addEventListener("mousemove", (e) => {
      if (isScrubbing) {
        if (e.buttons === 0) {
          isScrubbing = false;
          return;
        }
        scrub(e);
      }
    });

    window.addEventListener("mouseup", () => {
      isScrubbing = false;
    });
  }

  formatTime(seconds) {
    const m = Math.floor(seconds / 60);
    const s = Math.floor(seconds % 60);
    const cs = Math.floor((seconds % 1) * 100);
    return `${String(m).padStart(2, '0')}:${String(s).padStart(2, '0')}.${String(cs).padStart(2, '0')}`;
  }

  /**
   * Main KTV Subtitle Canvas Rendering Logic
   */
  render() {
    const rect = this.video.getBoundingClientRect();
    if (rect.width === 0 || rect.height === 0) return;

    const width = rect.width;
    const height = rect.height;
    const ctx = this.ctx;
    const curTime = this.video.currentTime;
    const style = this.state.style;
    const sentences = this.state.sentences;

    ctx.clearRect(0, 0, width, height);

    // Scale font size according to current display width (reference 1920)
    const scaleFactor = width / 1920;

    // 1. Render Title Cards (Intro & Outro)
    this.renderTitleCards(ctx, curTime, style, width, height, scaleFactor);

    if (!sentences || sentences.length === 0) return;

    // Filter valid sentences with start/end time (must have at least one timed word)
    const valid = sentences.map((s, idx) => {
      const words = s.words || [];
      const hasTiming = words.some(w => (w.endTime || 0) > (w.startTime || 0));
      const start = words.length > 0 ? (words[0].startTime || 0) : 0;
      const end = words.length > 0 ? (words[words.length - 1].endTime || start + 1) : start + 1;
      return { idx, s, start, end, words, hasTiming };
    }).filter(v => v.hasTiming);

    const fontSize = Math.max(18, Math.round(style.fontSize * scaleFactor));
    const outlineWidth = Math.max(2, Math.round(style.outlineWidth * scaleFactor));
    const fontStr = `${style.bold ? 'bold ' : ''}${fontSize}px "${style.fontFamily}", "Microsoft JhengHei", sans-serif`;

    ctx.font = fontStr;
    ctx.textBaseline = "top";
    ctx.lineJoin = "round";
    ctx.miterLimit = 2;

    // Determine which sentences to show on Line 1 (Odd: idx 0, 2, 4...) and Line 2 (Even: idx 1, 3, 5...)
    // A sentence appears ~3.5s before start, and stays until ~0.8s after end or until next sentence on same line
    const oddCandidates = valid.filter(v => v.idx % 2 === 0);
    const evenCandidates = valid.filter(v => v.idx % 2 === 1);

    const activeOdd = this.findActiveSentence(oddCandidates, curTime);
    const activeEven = this.findActiveSentence(evenCandidates, curTime);

    // Render Line 1 (Odd sentence - Left-aligned)
    if (activeOdd) {
      const x = width * style.line1X;
      const y = height * style.line1Y;
      this.renderSentenceLine(ctx, activeOdd, x, y, curTime, style, scaleFactor, fontSize, outlineWidth, false);
    }

    // Render Line 2 (Even sentence - Right-aligned to style.line2X)
    if (activeEven) {
      const x = width * style.line2X;
      const y = height * style.line2Y;
      this.renderSentenceLine(ctx, activeEven, x, y, curTime, style, scaleFactor, fontSize, outlineWidth, true);
    }
  }

  findActiveSentence(list, curTime) {
    for (let i = 0; i < list.length; i++) {
      const item = list[i];
      const prev = i > 0 ? list[i - 1] : null;
      const next = i < list.length - 1 ? list[i + 1] : null;

      const hasCountdown = Boolean(item.s && item.s.hasCountdown);
      const dotCount = (this.state.style.countdownDotCount === 3) ? 3 : 4;
      const bpm = (this.state.style.bpm && this.state.style.bpm > 0) ? this.state.style.bpm : 80;
      const beatSec = 60.0 / bpm;
      const neededLead = hasCountdown ? Math.max(2.5, (dotCount + 1.5) * beatSec) : 2.5;

      const appearTime = prev ? Math.max(prev.end + 0.4, item.start - neededLead) : Math.max(0, item.start - neededLead);

      let disappearTime;
      if (next) {
        const nextHasCd = Boolean(next.s && next.s.hasCountdown);
        const nextNeededLead = nextHasCd ? Math.max(2.5, (dotCount + 1.5) * beatSec) : 2.5;
        const nextAppearTime = Math.max(item.end + 0.4, next.start - nextNeededLead);
        disappearTime = Math.min(item.end + 1.0, nextAppearTime);
        if (disappearTime <= item.end) {
          disappearTime = item.end + 0.3;
        }
      } else {
        disappearTime = item.end + 2.0;
      }

      if (curTime >= appearTime && curTime <= disappearTime) {
        return { ...item, appearTime, disappearTime, hasCountdown };
      }
    }
    return null;
  }

  renderSentenceLine(ctx, item, x, y, curTime, style, scaleFactor, fontSize, outlineWidth, isRightAligned = false) {
    // Compute fade-in and fade-out transparency
    const enableFade = (style.enableFade !== false);
    const fadeDuration = (typeof style.fadeDuration === "number") ? style.fadeDuration : 0.4;
    let lineAlpha = 1.0;
    const lineDuration = Math.max(0, item.disappearTime - item.appearTime);
    if (enableFade && fadeDuration > 0 && lineDuration > 0) {
      const effectiveFade = Math.min(fadeDuration, lineDuration / 2.0);
      if (curTime < item.appearTime + effectiveFade) {
        lineAlpha = Math.max(0, Math.min(1.0, (curTime - item.appearTime) / effectiveFade));
      } else if (curTime > item.disappearTime - effectiveFade) {
        lineAlpha = Math.max(0, Math.min(1.0, (item.disappearTime - curTime) / effectiveFade));
      }
    }

    if (lineAlpha <= 0) return;

    ctx.save();
    ctx.globalAlpha = lineAlpha;

    const s = item.s;
    const words = item.words;
    const singer = (s.singer || "duet").toLowerCase();

    // Determine singer wipe colors
    let wipeFill = style.duetWipeColor;
    let wipeOutline = style.duetOutlineColor || "#004D26";
    if (singer === "male" || singer === "男") {
      wipeFill = style.maleWipeColor;
      wipeOutline = style.maleOutlineColor || "#003366";
    } else if (singer === "female" || singer === "女") {
      wipeFill = style.femaleWipeColor;
      wipeOutline = style.femaleOutlineColor || "#660033";
    }

    // 1. Calculate word positions & measure widths
    const letterSpacing = (style.letterSpacing !== undefined ? style.letterSpacing : 2) * scaleFactor;
    const prefix = s.prefix || "";
    const prefixChars = prefix ? prefix.split("") : [];
    const prefixMetrics = [];
    let prefixTotalWidth = 0;

    for (let i = 0; i < prefixChars.length; i++) {
      const charW = ctx.measureText(prefixChars[i]).width;
      prefixMetrics.push({ text: prefixChars[i], width: charW });
      prefixTotalWidth += charW;
      if (i < prefixChars.length - 1) {
        prefixTotalWidth += letterSpacing;
      }
    }

    let wordsTotalWidth = 0;
    const measuredWords = [];
    for (let i = 0; i < words.length; i++) {
      const text = words[i].text || "";
      const wWidth = ctx.measureText(text).width;
      measuredWords.push({ w: words[i], text, wWidth });
      wordsTotalWidth += wWidth;
      if (i < words.length - 1) {
        wordsTotalWidth += letterSpacing;
      }
    }

    let totalLineWidth = 0;
    if (prefixTotalWidth > 0 && wordsTotalWidth > 0) {
      totalLineWidth = prefixTotalWidth + letterSpacing + wordsTotalWidth;
    } else if (prefixTotalWidth > 0) {
      totalLineWidth = prefixTotalWidth;
    } else {
      totalLineWidth = wordsTotalWidth;
    }

    // If right-aligned (Line 2), text starts at (x - totalLineWidth) so right edge is at x
    const textStartX = isRightAligned ? (x - totalLineWidth) : x;
    let runX = textStartX;

    // Build prefix draw list
    const prefixDrawList = [];
    for (const pm of prefixMetrics) {
      prefixDrawList.push({
        text: pm.text,
        x: runX,
        width: pm.width
      });
      runX += pm.width + letterSpacing;
    }

    // Build word metrics for lyrics
    const wordMetrics = [];
    for (const mw of measuredWords) {
      wordMetrics.push({
        text: mw.text,
        x: runX,
        width: mw.wWidth,
        start: mw.w.startTime || 0,
        end: mw.w.endTime || (mw.w.startTime || 0) + 0.3
      });
      runX += mw.wWidth + letterSpacing;
    }

    // 2. DRAW COUNTDOWN DOTS
    const hasCountdown = Boolean((s && s.hasCountdown) || item.hasCountdown);
    const dotCount = (style.countdownDotCount === 3) ? 3 : 4;
    const bpm = (style.bpm && style.bpm > 0) ? style.bpm : 80;
    const beatSec = 60.0 / bpm;
    const countdownStart = item.start - (dotCount + 1.0) * beatSec;
    const countdownEnd = item.start - 1.0 * beatSec;

    if (hasCountdown && style.enableCountdown !== false && curTime >= countdownStart && curTime < countdownEnd) {
      let dotFill = style.duetDotFill || "#00E676";
      let dotOutline = style.duetDotOutline || "#FFFFFF";
      let dotOutlineW = (style.duetDotOutlineWidth || 3) * scaleFactor;

      if (singer === "male" || singer === "男") {
        dotFill = style.maleDotFill || "#0055FF";
        dotOutline = style.maleDotOutline || "#FFFFFF";
        dotOutlineW = (style.maleDotOutlineWidth || 3) * scaleFactor;
      } else if (singer === "female" || singer === "女") {
        dotFill = style.femaleDotFill || "#FF4081";
        dotOutline = style.femaleDotOutline || "#FFFFFF";
        dotOutlineW = (style.femaleDotOutlineWidth || 3) * scaleFactor;
      }

      const dotDiameter = (style.countdownDotSize || 22) * scaleFactor;
      const dotRadius = dotDiameter / 2;
      const dotSpacing = dotDiameter * 1.5;

      const baseDotX = textStartX + (style.countdownOffsetX || 10) * scaleFactor;
      const dotCenterY = y + (style.countdownOffsetY || -46) * scaleFactor;

      for (let d = 0; d < dotCount; d++) {
        const dotCenterX = baseDotX + d * dotSpacing;
        // Right-to-left sequential disappearance (由右往左消失):
        // d = dotCount - 1 (rightmost dot) disappears first at item.start - dotCount * beatSec
        // d = 0 (leftmost dot) disappears last at item.start - 1 * beatSec
        const dotDisappearTime = item.start - (1 + d) * beatSec;

        ctx.save();
        if (curTime < dotDisappearTime) {
          ctx.beginPath();
          ctx.arc(dotCenterX, dotCenterY, dotRadius, 0, Math.PI * 2);
          ctx.fillStyle = dotFill;
          ctx.fill();

          ctx.lineWidth = dotOutlineW;
          ctx.strokeStyle = dotOutline;
          ctx.stroke();
        } else if (style.showFadedGhost) {
          ctx.beginPath();
          ctx.arc(dotCenterX, dotCenterY, dotRadius, 0, Math.PI * 2);
          ctx.fillStyle = "rgba(40, 40, 70, 0.4)";
          ctx.fill();

          ctx.lineWidth = 1.5;
          ctx.strokeStyle = "rgba(180, 180, 220, 0.35)";
          ctx.stroke();
        }
        ctx.restore();
      }
    }

    // 3. DRAW PREFIX (ALREADY COLORED / PRE-WIPED AS SINGER INDICATOR)
    if (prefixDrawList.length > 0) {
      ctx.save();
      ctx.shadowColor = style.shadowColor || "#000000";
      ctx.shadowBlur = (style.shadowWidth || 2) * scaleFactor * 2;
      ctx.shadowOffsetX = (style.shadowWidth || 2) * scaleFactor;
      ctx.shadowOffsetY = (style.shadowWidth || 2) * scaleFactor;

      for (const pm of prefixDrawList) {
        ctx.lineWidth = outlineWidth;
        ctx.strokeStyle = wipeOutline;
        ctx.strokeText(pm.text, pm.x, y);
        ctx.fillStyle = wipeFill;
        ctx.fillText(pm.text, pm.x, y);
      }
      ctx.restore();
    }

    // 4. DRAW BASE (UNSUNG) STATE FOR LYRIC WORDS
    ctx.save();
    ctx.shadowColor = style.shadowColor || "#000000";
    ctx.shadowBlur = (style.shadowWidth || 2) * scaleFactor * 2;
    ctx.shadowOffsetX = (style.shadowWidth || 2) * scaleFactor;
    ctx.shadowOffsetY = (style.shadowWidth || 2) * scaleFactor;

    // Draw words base
    for (const wm of wordMetrics) {
      ctx.lineWidth = outlineWidth;
      ctx.strokeStyle = style.baseOutlineColor;
      ctx.strokeText(wm.text, wm.x, y);
      ctx.fillStyle = style.baseColor;
      ctx.fillText(wm.text, wm.x, y);
    }
    ctx.restore();

    // Next, progressive wipe for words
    for (const wm of wordMetrics) {
      if (curTime < wm.start) {
        // Not sung yet
        continue;
      } else if (curTime >= wm.end) {
        // Fully sung! Draw with wipe colors
        ctx.save();
        ctx.lineWidth = outlineWidth;
        ctx.strokeStyle = wipeOutline;
        ctx.strokeText(wm.text, wm.x, y);
        ctx.fillStyle = wipeFill;
        ctx.fillText(wm.text, wm.x, y);
        ctx.restore();
      } else {
        // Currently singing this character! Smooth horizontal clip mask!
        const charDuration = wm.end - wm.start;
        const progress = Math.max(0, Math.min(1.0, (curTime - wm.start) / charDuration));
        const wipeWidth = wm.width * progress;

        ctx.save();
        ctx.beginPath();
        // Clip rectangle covering the sung portion of this character
        ctx.rect(wm.x - outlineWidth, y - outlineWidth, wipeWidth + outlineWidth, fontSize * 1.5 + outlineWidth * 2);
        ctx.clip();

        ctx.lineWidth = outlineWidth;
        ctx.strokeStyle = wipeOutline;
        ctx.strokeText(wm.text, wm.x, y);
        ctx.fillStyle = wipeFill;
        ctx.fillText(wm.text, wm.x, y);
        ctx.restore();
      }
    }
    ctx.restore();
  }

  initDragHandles() {
    const handle1 = document.getElementById("line1-drag-handle");
    const handle2 = document.getElementById("line2-drag-handle");
    if (!handle1 || !handle2) return;

    this.makeDraggable(handle1, (pctX, pctY) => {
      this.state.updateStyle({ line1X: pctX, line1Y: pctY });
      const x1Slider = document.getElementById("line1-x-slider");
      const y1Slider = document.getElementById("line1-y-slider");
      if (x1Slider) x1Slider.value = Math.round(pctX * 100);
      if (y1Slider) y1Slider.value = Math.round(pctY * 100);
      this.updateDragHandlePositions();
    });

    this.makeDraggable(handle2, (pctX, pctY) => {
      this.state.updateStyle({ line2X: pctX, line2Y: pctY });
      const x2Slider = document.getElementById("line2-x-slider");
      const y2Slider = document.getElementById("line2-y-slider");
      if (x2Slider) x2Slider.value = Math.round(pctX * 100);
      if (y2Slider) y2Slider.value = Math.round(pctY * 100);
      this.updateDragHandlePositions();
    });

    this.state.on("styleChanged", () => this.updateDragHandlePositions());
    this.updateDragHandlePositions();
  }

  updateDragHandlePositions() {
    const handle1 = document.getElementById("line1-drag-handle");
    const handle2 = document.getElementById("line2-drag-handle");
    if (!handle1 || !handle2) return;

    const vRect = this.video.getBoundingClientRect();
    if (vRect.width === 0) return;

    handle1.style.left = `${this.video.offsetLeft + vRect.width * this.state.style.line1X}px`;
    handle1.style.top = `${this.video.offsetTop + vRect.height * this.state.style.line1Y}px`;

    handle2.style.left = `${this.video.offsetLeft + vRect.width * this.state.style.line2X}px`;
    handle2.style.top = `${this.video.offsetTop + vRect.height * this.state.style.line2Y}px`;
  }

  makeDraggable(elem, onUpdate) {
    let startX = 0, startY = 0, initLeft = 0, initTop = 0;
    let dragging = false;

    elem.addEventListener("mousedown", (e) => {
      dragging = true;
      startX = e.clientX;
      startY = e.clientY;
      initLeft = elem.offsetLeft;
      initTop = elem.offsetTop;
      e.stopPropagation();
    });

    window.addEventListener("mousemove", (e) => {
      if (!dragging) return;
      const dx = e.clientX - startX;
      const dy = e.clientY - startY;
      const newLeft = initLeft + dx;
      const newTop = initTop + dy;

      const vRect = this.video.getBoundingClientRect();
      const pctX = Math.max(0.01, Math.min(0.99, (newLeft - this.video.offsetLeft) / vRect.width));
      const pctY = Math.max(0.05, Math.min(0.95, (newTop - this.video.offsetTop) / vRect.height));

      onUpdate(round(pctX, 3), round(pctY, 3));
    });

    window.addEventListener("mouseup", () => {
      dragging = false;
    });
  }

  renderTitleCards(ctx, curTime, style, width, height, scaleFactor) {
    if (!style || !style.titleCards) return;
    const cards = [style.titleCards.intro, style.titleCards.outro];

    for (const card of cards) {
      if (!card || !card.enabled) continue;
      const startTime = (typeof card.startTime === "number") ? card.startTime : 0;
      const duration = card.duration || 4.5;
      const endTime = startTime + duration;

      if (curTime < startTime || curTime > endTime) continue;

      // Compute fade-in / fade-out alpha
      const fadeDuration = (typeof card.fadeDuration === "number") ? card.fadeDuration : 0.6;
      let alpha = 1.0;
      const elapsed = curTime - startTime;
      const remaining = endTime - curTime;
      if (fadeDuration > 0) {
        if (elapsed < fadeDuration) {
          alpha = Math.max(0, Math.min(1, elapsed / fadeDuration));
        } else if (remaining < fadeDuration) {
          alpha = Math.max(0, Math.min(1, remaining / fadeDuration));
        }
      }

      ctx.save();
      ctx.globalAlpha = alpha;
      ctx.lineJoin = "round";
      ctx.miterLimit = 2;

      // 1. Render Main Title
      if (card.titleText && card.titleText.trim()) {
        const titleFontSize = Math.max(16, Math.round((card.titleFontSize || 86) * scaleFactor));
        const titleOutlineW = Math.max(0, Math.round((card.titleOutlineWidth || 10) * scaleFactor));
        const titleFontFamily = card.titleFontFamily || style.fontFamily || "Microsoft JhengHei";
        const titleBoldPrefix = (card.titleBold !== false) ? "bold " : "";
        const titleSpacing = Math.max(0, (typeof card.titleLetterSpacing === "number" ? card.titleLetterSpacing : 6) * scaleFactor);

        ctx.font = `${titleBoldPrefix}${titleFontSize}px "${titleFontFamily}", "Microsoft JhengHei", sans-serif`;
        ctx.textBaseline = "middle";

        const titleX = width / 2;
        const titleY = height * (card.titleY || 0.35);

        if (titleSpacing <= 0) {
          ctx.textAlign = "center";
          if (titleOutlineW > 0) {
            ctx.strokeStyle = card.titleOutlineColor || "#FFFFFF";
            ctx.lineWidth = titleOutlineW;
            ctx.strokeText(card.titleText, titleX, titleY);
          }
          ctx.fillStyle = card.titleFillColor || "#00E676";
          ctx.fillText(card.titleText, titleX, titleY);
        } else {
          ctx.textAlign = "left";
          const chars = Array.from(card.titleText);
          const charWidths = chars.map(c => ctx.measureText(c).width);
          const totalW = charWidths.reduce((a, b) => a + b, 0) + (chars.length - 1) * titleSpacing;
          let curX = titleX - totalW / 2;
          for (let i = 0; i < chars.length; i++) {
            if (titleOutlineW > 0) {
              ctx.strokeStyle = card.titleOutlineColor || "#FFFFFF";
              ctx.lineWidth = titleOutlineW;
              ctx.strokeText(chars[i], curX, titleY);
            }
            ctx.fillStyle = card.titleFillColor || "#00E676";
            ctx.fillText(chars[i], curX, titleY);
            curX += charWidths[i] + titleSpacing;
          }
        }
      }

      // 2. Render Credits Lines (multi-line, block-centered & left-aligned)
      if (card.creditsText && card.creditsText.trim()) {
        const credFontSize = Math.max(12, Math.round((card.creditsFontSize || 42) * scaleFactor));
        const credOutlineW = Math.max(0, Math.round((card.creditsOutlineWidth || 6) * scaleFactor));
        const credFontFamily = card.creditsFontFamily || style.fontFamily || "Microsoft JhengHei";
        const credBoldPrefix = (card.creditsBold !== false) ? "bold " : "";
        const credSpacing = Math.max(0, (typeof card.creditsLetterSpacing === "number" ? card.creditsLetterSpacing : 2) * scaleFactor);

        ctx.font = `${credBoldPrefix}${credFontSize}px "${credFontFamily}", "Microsoft JhengHei", sans-serif`;
        ctx.textBaseline = "top";
        ctx.textAlign = "left";

        const lines = card.creditsText.split("\n");
        const lineSpacing = credFontSize * 1.38;
        const startY = height * (card.creditsY || 0.55);

        // Pre-measure all lines to find the maximum line width for block-centering
        const lineMetrics = lines.map(line => {
          const chars = Array.from(line);
          const charWidths = chars.map(c => ctx.measureText(c).width);
          const totalW = charWidths.reduce((a, b) => a + b, 0) + Math.max(0, chars.length - 1) * credSpacing;
          return { line, chars, charWidths, totalW };
        });

        const maxLineW = Math.max(...lineMetrics.map(m => m.totalW), 0);
        const blockLeftX = (width - maxLineW) / 2;

        lineMetrics.forEach((lm, lineIdx) => {
          const ly = startY + lineIdx * lineSpacing;
          let curX = blockLeftX;
          for (let i = 0; i < lm.chars.length; i++) {
            const ch = lm.chars[i];
            if (credOutlineW > 0) {
              ctx.strokeStyle = card.creditsOutlineColor || "#000000";
              ctx.lineWidth = credOutlineW;
              ctx.strokeText(ch, curX, ly);
            }
            ctx.fillStyle = card.creditsFillColor || "#FFFFFF";
            ctx.fillText(ch, curX, ly);
            curX += lm.charWidths[i] + credSpacing;
          }
        });
      }

      ctx.restore();
    }
  }
}

function round(val, dec) {
  return Number(Math.round(val + 'e' + dec) + 'e-' + dec);
}

window.KTVPlayer = KTVPlayer;
