import { css, html, LitElement, svg } from 'lit'; import { property, query, state } from 'lit/decorators.js'; import { GoogleService } from './GoogleService.js'; const VERSION = '1.5.4'; const INSTRUMENTAL_THRESHOLD_MS = 7000; // Show dots for gaps >= 7s const FETCH_TIMEOUT_MS = 8000; // Timeout for all lyrics fetch requests const SEEK_THRESHOLD_MS = 500; const SCROLL_ANIMATION_DURATION_MS = 350; const GAP_PULSE_DURATION_MS = 4000; const GAP_PULSE_CYCLE_MS = GAP_PULSE_DURATION_MS * 2; const GAP_EXIT_LEAD_MS = 600; const GAP_MIN_SCALE = 0.85; /** * Fetch with an automatic timeout via AbortSignal. * Rejects if the request takes longer than `timeoutMs`. */ function fetchWithTimeout( url: string, options: Parameters[1] = {}, timeoutMs = FETCH_TIMEOUT_MS, ): Promise { const controller = new AbortController(); const timeoutId = setTimeout(() => controller.abort(), timeoutMs); return fetch(url, { ...options, signal: controller.signal }).finally(() => clearTimeout(timeoutId), ); } const KPOE_SERVERS = [ 'https://lyricsplus.binimum.org', 'https://lyricsplus-seven.vercel.app', 'https://lyricsplus.prjktla.workers.dev', 'https://lyrics-plus-backend.vercel.app', ]; const DEFAULT_KPOE_SOURCE_ORDER = 'apple,lyricsplus,musixmatch,spotify,qq,deezer,musixmatch-word'; const GENIUS_WORKER_URL = 'https://fetch-genius.samidy.workers.dev/'; interface Syllable { text: string; part: boolean; timestamp: number; endtime: number; romanizedText?: string; lineSynced?: boolean; // New flag for line-synced lyrics } interface LyricsLine { text: Syllable[]; background: boolean; backgroundText: Syllable[]; oppositeTurn: boolean; timestamp: number; endtime: number; isWordSynced?: boolean; alignment?: 'start' | 'end'; songPart?: string; romanizedText?: string; translation?: string; } interface SongMetadata { title: string; artist: string; album?: string; durationMs?: number; songwriters?: string; } interface SongCatalogResult { title?: string; artist?: string; album?: string; durationMs?: number; songwriters?: string; id?: { appleMusic?: string; [key: string]: unknown; }; isrc?: string; } interface ParsedQueryMetadata { title?: string; artist?: string; album?: string; } interface YouLyPlusLyricsResult { lines: LyricsLine[]; source: string; songwriters?: string; } interface ResolvedMetadata { metadata?: SongMetadata; appleId?: string; appleSong?: any; catalogIsrc?: string; } export class AmLyrics extends LitElement { static styles = css` /* ========================================================================== YOULYPLUS-INSPIRED STYLING - Design Tokens & Variables ========================================================================== */ :host { --lyplus-lyrics-palette: var( --am-lyrics-highlight-color, var(--highlight-color, #ffffff) ); --lyplus-text-primary: var(--lyplus-lyrics-palette); /* Use color-mix with the text color rather than just opacity so it adapts */ --lyplus-text-secondary: color-mix( in srgb, var(--lyplus-lyrics-palette), transparent 45% ); --lyplus-padding-base: 1em; --lyplus-padding-line: 10px; --lyplus-padding-gap: 0.3em; --lyplus-border-radius-base: 0.6em; --lyplus-gap-dot-size: 0.4em; --lyplus-gap-dot-margin: 0.08em; --lyplus-font-size-base: 32px; --lyplus-font-size-base-grow: 24.5; --lyplus-font-size-subtext: 0.6em; --char-rise-y: calc(-0.035 * var(--lyplus-font-size-base)); --lyplus-blur-amount: 0.07em; --lyplus-blur-amount-near: 0.035em; --lyplus-fade-gap-timing-function: ease-out; --lyrics-scroll-padding-top: 25%; display: block; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; background: transparent; height: 100%; overflow: hidden; font-weight: bold; color: var(--lyplus-text-primary); } /* ========================================================================== CONTAINER & SCROLL BEHAVIOR ========================================================================== */ .lyrics-container { padding: 20px; padding-top: 80px; border-radius: 8px; background-color: transparent; width: 100%; height: 100%; max-height: 100vh; overflow-y: auto; -webkit-overflow-scrolling: touch; box-sizing: border-box; scrollbar-width: none; overflow-anchor: none; } .lyrics-container::-webkit-scrollbar { display: none; } /* Disable transitions during touch-scrolling for 1:1 feedback */ .lyrics-container.touch-scrolling .lyrics-line, .lyrics-container.touch-scrolling .lyrics-plus-metadata { transition: none !important; filter: none !important; } /* Apply smooth gliding transition for mouse-wheel scrolling */ .lyrics-container.wheel-scrolling .lyrics-line { transition: transform 0.3s ease-out !important; filter: none !important; } .lyrics-line.scroll-animate { /* Preserve the graceful fade duration; the keyframe handles the transform, so we only need to keep opacity/filter transitions alive without !important overriding the base rule. */ transition: opacity 0.7s ease, filter 0.7s ease, transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99) var(--lyrics-line-delay, 0ms); animation-name: lyrics-scroll; animation-duration: var(--scroll-duration, 400ms); animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99); animation-fill-mode: both; animation-delay: var(--lyrics-line-delay, 0ms); } .lyrics-container.user-scrolling .lyrics-line { --lyrics-line-delay: 0ms !important; transition-delay: 0ms !important; } /* ========================================================================== LYRICS LINE BASE STYLES ========================================================================== */ .lyrics-line { padding: var(--lyplus-padding-line); opacity: 0.8; color: var(--lyplus-text-secondary); font-size: var(--lyplus-font-size-base); cursor: pointer; transform-origin: left; /* Graceful 0.7 s fade so the line stays mostly bright while the 0.4 s scroll animation runs, then settles into the inactive state. */ transition: opacity 0.7s ease, transform 0.4s cubic-bezier(0.41, 0, 0.12, 0.99) var(--lyrics-line-delay, 0ms), filter 0.7s ease; content-visibility: auto; contain: layout style; text-rendering: optimizeLegibility; } .lyrics-line:not(.scroll-animate) { animation: none; } /* --- Line Container & Vocal Containers --- */ .lyrics-line-container { overflow-wrap: break-word; transform-origin: left; transform: translateZ(0); transition: transform 0.7s ease, background-color 0.7s, color 0.7s; } .lyrics-line.active .lyrics-line-container, .lyrics-line.pre-active .lyrics-line-container { transform: translateZ(0); transition: transform 0.5s ease, background-color 0.18s, color 0.18s; } .main-vocal-container { transform-origin: 5% 50%; margin: 0; } .background-vocal-container { max-height: 0; overflow: hidden; opacity: 0; font-size: var(--lyplus-font-size-subtext); line-height: 1.15; color: color-mix(in srgb, var(--lyplus-text-secondary) 80%, transparent); transition: max-height var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99), opacity var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99); margin: 0; pointer-events: none; } .background-vocal-wrap { display: block; padding-top: 0; padding-bottom: 0; transition: padding-top var(--scroll-duration, 400ms) cubic-bezier(0.41, 0, 0.12, 0.99); } .lyrics-line.singer-right .background-vocal-container, .lyrics-line.rtl-text .background-vocal-container { margin-left: auto; margin-right: 0; } /* Background vocals expand only when .bg-expanded is present. This is separate from .active so bg vocals can collapse immediately while .active stays to keep text white until the scroll passes. */ .lyrics-line.bg-expanded .background-vocal-container { max-height: 4em; opacity: 1; will-change: max-height, opacity; } .lyrics-line.bg-expanded .background-vocal-wrap { padding-top: 0.26em; } /* --- Line States & Modifiers --- */ .lyrics-line.active { opacity: 1; color: var(--lyplus-text-primary); } .lyrics-line.pre-active { opacity: 1; } .lyrics-line.persist-highlight { filter: none !important; opacity: 1; } .lyrics-line.persist-highlight .lyrics-syllable.finished, .lyrics-line.persist-highlight .lyrics-syllable.finished span.char { transition: none !important; } .lyrics-line.singer-right { text-align: end; } .lyrics-line.singer-right .lyrics-line-container, .lyrics-line.singer-right .main-vocal-container { transform-origin: right; } .lyrics-line.rtl-text { direction: rtl; text-align: right !important; transform-origin: right; } .lyrics-line.rtl-text .lyrics-line-container, .lyrics-line.rtl-text .main-vocal-container { transform-origin: right; } .lyrics-line.rtl-text .lyrics-romanization-container, .lyrics-line.rtl-text .lyrics-translation-container { text-align: right; } /* --- Unsynced (Plain Text) Lyrics Overrides --- */ .lyrics-container.is-unsynced .lyrics-line { opacity: 1 !important; color: var(--lyplus-text-primary) !important; filter: none !important; transform: none !important; cursor: default; } .lyrics-container.is-unsynced .lyrics-line-container { transform: none !important; background-color: transparent !important; } .lyrics-container.is-unsynced .lyrics-syllable { color: var(--lyplus-text-primary) !important; background-color: transparent !important; -webkit-background-clip: unset !important; background-clip: unset !important; -webkit-text-fill-color: unset !important; text-fill-color: unset !important; text-shadow: none !important; filter: none !important; opacity: 1 !important; transform: none !important; } @media (hover: hover) and (pointer: fine) { .lyrics-line:hover { filter: none !important; opacity: 1 !important; } .lyrics-container.is-unsynced .lyrics-line:hover { background: transparent !important; } } /* --- Blur Effect for Inactive Lines --- */ .lyrics-container.blur-inactive-enabled:not(.not-focused) .lyrics-line:not(.active):not(.pre-active):not(.lyrics-gap):not( .persist-highlight ) { filter: blur(var(--lyplus-blur-amount)); } /* Viewport Virtualization: Strip expensive filters and animations from offscreen lines. IntersectionObserver toggles this class. */ .lyrics-line.far-line { filter: none !important; will-change: auto !important; animation: none !important; } .lyrics-container.blur-inactive-enabled:not(.not-focused) .lyrics-line.post-active-line:not(.lyrics-gap):not(.active):not( .pre-active ):not(.persist-highlight), .lyrics-container.blur-inactive-enabled:not(.not-focused) .lyrics-line.next-active-line:not(.lyrics-gap):not(.active):not( .pre-active ):not(.persist-highlight), .lyrics-container.blur-inactive-enabled:not(.not-focused) .lyrics-line.lyrics-activest:not(.active):not(.lyrics-gap):not( .pre-active ):not(.persist-highlight) { filter: blur(var(--lyplus-blur-amount-near)); } /* Unblur all lines when user is scrolling */ .lyrics-container.user-scrolling .lyrics-line { transition: none !important; filter: none !important; opacity: 0.8 !important; } /* Unblur early for pre-active lines */ .lyrics-container.blur-inactive-enabled .lyrics-line.pre-active { filter: blur(0px) !important; opacity: 1; } /* ========================================================================== WORD & SYLLABLE STYLES ========================================================================== */ .lyrics-word:not(.allow-break) { display: inline-block; vertical-align: baseline; white-space: nowrap; } .lyrics-word.allow-break { display: inline; } .lyrics-word.char-rise { display: inline-block; vertical-align: baseline; white-space: nowrap; } .lyrics-word.char-rise.allow-break { display: inline; white-space: normal; } .lyrics-syllable-wrap { display: inline; } .lyrics-syllable-wrap.has-transliteration { display: inline-flex; flex-direction: column; align-items: start; } .lyrics-syllable { display: inline-block; vertical-align: baseline; color: transparent; background-color: var(--lyplus-text-secondary); white-space: pre-wrap; font-variant-ligatures: none; font-feature-settings: 'liga' 0; background-clip: text; -webkit-background-clip: text; transition: color 0.7s, background-color 0.7s, transform 0.7s ease; } /* --- Syllable States --- */ .lyrics-syllable.finished { background-color: var(--lyplus-text-primary); /* Unified transition: transform keeps its 1s glow decay, while background-color and color fade at 0.7s so everything dims together when the line becomes inactive. */ transition: transform 1s ease, background-color 0.7s ease, color 0.7s ease; } .lyrics-syllable.finished.has-chars { background-color: transparent; } .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable { transition: transform 1s ease, background-color 0.5s, color 0.5s; } /* --- Wipe Highlight Effect --- */ .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.no-chars, .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.pre-highlight.no-chars { background-repeat: no-repeat; background-image: linear-gradient( 90deg, #ffffff00 0%, var(--lyplus-text-primary, #fff) 50%, #0000 100% ), linear-gradient( 90deg, var(--lyplus-text-primary, #fff) 100%, #0000 100% ); background-size: 0.5em 100%, 0% 100%; background-position: -0.5em 0%, -0.25em 0%; } .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.highlight.rtl-text, .lyrics-line.active:not(.lyrics-gap) .lyrics-syllable.pre-highlight.rtl-text { direction: rtl; background-image: linear-gradient( -90deg, var(--lyplus-text-primary) 0%, transparent 100% ), linear-gradient( -90deg, var(--lyplus-text-primary) 100%, transparent 100% ); background-position: calc(100% + 0.5em) 0%, right 0%; } /* Background vocals: muted gray wipe instead of white. Must match specificity of the main .active .highlight rule (0,3,1). */ .lyrics-line.active .background-vocal-container .lyrics-syllable.highlight.no-chars, .lyrics-line.active .background-vocal-container .lyrics-syllable.pre-highlight.no-chars, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.highlight.no-chars, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.pre-highlight.no-chars { background-image: linear-gradient( 90deg, #ffffff00 0%, color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 50%, #0000 100% ), linear-gradient( 90deg, color-mix(in srgb, var(--lyplus-text-primary, #fff) 50%, #888888) 100%, #0000 100% ); } .lyrics-line.active .background-vocal-container .lyrics-syllable.highlight.rtl-text, .lyrics-line.active .background-vocal-container .lyrics-syllable.pre-highlight.rtl-text, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.highlight.rtl-text, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.pre-highlight.rtl-text { background-image: linear-gradient( -90deg, color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 0%, transparent 100% ), linear-gradient( -90deg, color-mix(in srgb, var(--lyplus-text-primary) 50%, #888888) 100%, transparent 100% ); } /* Non-growable words float up with a gentle curve */ .lyrics-line.active:not(.lyrics-gap) .lyrics-word:not(.growable) .lyrics-syllable.highlight { transform: translate3d(0, var(--char-rise-y, -1.12px), 0); } .lyrics-line.persist-highlight:not(.lyrics-gap) .lyrics-word:not(.growable) .lyrics-syllable.finished { transform: translate3d(0, var(--char-rise-y, -1.12px), 0); } .lyrics-word.growable .lyrics-syllable.cleanup .char { transform: translate3d(0, var(--char-rise-y, -1.12px), 0); } .lyrics-word.char-rise .lyrics-syllable.cleanup .char { transform: translate3d(0, var(--char-rise-y, -1.12px), 0); } .lyrics-line.persist-highlight .lyrics-word.growable .lyrics-syllable.finished .char, .lyrics-line.persist-highlight .lyrics-word.char-rise .lyrics-syllable.finished .char { transform: translate3d(0, var(--char-rise-y, -1.12px), 0); } /* Background vocal overrides — placed AFTER main rules so they win on equal specificity. */ .background-vocal-container .lyrics-syllable { background-color: color-mix( in srgb, var(--lyplus-text-secondary) 50%, #888888 ); } .lyrics-line.active:not(.lyrics-gap) .background-vocal-container .lyrics-syllable.finished, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.finished { background-color: color-mix( in srgb, var(--lyplus-text-primary) 50%, #888888 ); } .background-vocal-container .lyrics-syllable.line-synced { color: color-mix( in srgb, var(--lyplus-text-secondary) 50%, #888888 ) !important; } .lyrics-line.active:not(.lyrics-gap) .background-vocal-container .lyrics-syllable.line-synced, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.line-synced { color: color-mix( in srgb, var(--lyplus-text-primary) 50%, #888888 ) !important; } .lyrics-line.active:not(.lyrics-gap) .background-vocal-container .lyrics-syllable.line-synced.finished, .lyrics-line.pre-active .background-vocal-container .lyrics-syllable.line-synced.finished { color: color-mix( in srgb, var(--lyplus-text-primary) 50%, #888888 ) !important; } .lyrics-syllable.pre-highlight { animation-name: pre-wipe-universal; animation-duration: var(--pre-wipe-duration); animation-delay: var(--pre-wipe-delay); animation-timing-function: linear; animation-fill-mode: forwards; } .lyrics-syllable.pre-highlight.rtl-text { animation-name: pre-wipe-universal-rtl; } .lyrics-syllable.transliteration { font-size: var(--lyplus-font-size-subtext); white-space: pre-wrap; pointer-events: none; user-select: none; } /* Syllable with chars: make syllable transparent, chars handle color */ .lyrics-line .lyrics-syllable.has-chars:not(.finished) { background-color: transparent; color: transparent; } .lyrics-syllable span.char { display: inline-block; background-color: var(--lyplus-text-secondary); white-space: break-spaces; font-variant-ligatures: none; font-feature-settings: 'liga' 0; background-clip: text; -webkit-background-clip: text; backface-visibility: hidden; transform-origin: 50% 80%; transition: color 0.7s, background-color 0.7s, transform 0.7s ease; } .lyrics-syllable.finished span.char { background-color: var(--lyplus-text-primary); transition: color 0.7s, background-color 0.7s, transform 0.7s ease; } /* Active char spans: structural only, wipe animation sets gradient */ .lyrics-line.active .lyrics-syllable span.char { background-clip: text; -webkit-background-clip: text; background-repeat: no-repeat; background-image: linear-gradient( 90deg, #ffffff00 0%, var(--lyplus-text-primary, #fff) 50%, #0000 100% ), linear-gradient( 90deg, var(--lyplus-text-primary, #fff) 100%, #0000 100% ); background-size: 0.5em 100%, 0% 100%; background-position: -0.5em 0%, -0.25em 0%; transition: transform 0.7s ease, color 0.18s; } .lyrics-line.active .lyrics-syllable span.char.highlight { background-image: linear-gradient( -90deg, var(--lyplus-text-primary, #fff) 0%, #0000 100% ), linear-gradient( -90deg, var(--lyplus-text-primary, #fff) 100%, #0000 100% ); background-position: calc(100% + 0.5em) 0%, calc(100% + 0.25em) 0%; } .lyrics-line.active .lyrics-syllable.pre-highlight span.char { background-image: linear-gradient( 90deg, #ffffff00 0%, var(--lyplus-text-primary, #fff) 50%, #0000 100% ), linear-gradient( 90deg, var(--lyplus-text-primary, #fff) 100%, #0000 100% ); background-size: 0.75em 100%, 0% 100%; background-position: -0.85em 0%, -0.25em 0%; } /* ========================================================================== INSTRUMENTAL GAP STYLES ========================================================================== */ .lyrics-gap { max-height: 1.6em; padding: var(--lyplus-padding-gap); overflow: visible; opacity: 0; box-sizing: content-box; background-clip: unset; transform-origin: top; content-visibility: visible !important; contain: none !important; transition: opacity 160ms ease-out, transform var(--scroll-duration, 280ms) var(--lyrics-line-delay, 0ms); } .lyrics-gap.active { opacity: 1; transition: opacity 160ms ease-out, transform var(--scroll-duration, 280ms); } /* Exiting state: quickly collapse width and height so dots don't distort page, or remove max-height transition */ .lyrics-gap.gap-exiting { opacity: 1; } .lyrics-gap .main-vocal-container { transform: translateY(-25%) scale(1); transition: transform 400ms cubic-bezier(0.22, 1, 0.36, 1); } .lyrics-gap:not(.active):not(.gap-exiting) .main-vocal-container { transform: translateY(-25%) scale(0); } /* Pulse — must come BEFORE .gap-exiting so exiting wins via specificity+order */ .lyrics-gap.active .main-vocal-container { animation: gap-loop var(--gap-pulse-duration, 4000ms) ease-in-out infinite alternate; animation-delay: var(--gap-loop-delay, 0ms); } /* Jump animation plays during exit — disable transition so animation wins. Placed AFTER .active so it wins when both classes are present briefly. */ .lyrics-gap.gap-exiting .main-vocal-container { animation: gap-ended var(--gap-exit-duration, 360ms) cubic-bezier(0.33, 1, 0.68, 1) forwards; transition: none !important; } .lyrics-gap .lyrics-syllable { display: inline-block; width: var(--lyplus-gap-dot-size); height: var(--lyplus-gap-dot-size); background-color: var(--lyplus-text-primary); border-radius: 50%; margin: 0 var(--lyplus-gap-dot-margin); } /* Line-synced lyrics should fade in instantly/quickly instead of wiping */ .lyrics-syllable.line-synced { background: transparent !important; color: var(--lyplus-text-secondary) !important; } .lyrics-line.active .lyrics-syllable.line-synced { animation: fade-in-line 0.2s ease-out forwards !important; color: var(--lyplus-text-primary) !important; } .lyrics-line.pre-active .lyrics-syllable.line-synced { animation: fade-in-line 0.14s ease-out forwards !important; color: var(--lyplus-text-primary) !important; } .lyrics-line.active .lyrics-syllable.line-synced span.char, .lyrics-line.pre-active .lyrics-syllable.line-synced span.char { background-image: none !important; background-color: var(--lyplus-text-primary) !important; transition: background-color 120ms ease-out !important; } @keyframes fade-in-line { from { opacity: 0.5; color: var(--lyplus-text-secondary); } to { opacity: 1; color: var(--lyplus-lyrics-palette); } } .lyrics-gap .lyrics-syllable { background-color: var(--lyplus-text-secondary); background-clip: unset; } .lyrics-gap.active .lyrics-syllable.finished, .lyrics-gap.gap-exiting .lyrics-syllable.finished, .lyrics-gap:not(.active):not(.gap-exiting).post-active-line .lyrics-syllable, .lyrics-gap:not(.active):not(.gap-exiting).lyrics-activest .lyrics-syllable { background-color: var(--lyplus-text-primary); animation: none !important; opacity: 1; } /* ========================================================================== METADATA & FOOTER STYLES ========================================================================== */ .lyrics-plus-metadata { display: block; position: relative; box-sizing: border-box; font-weight: normal; transform: translateY(var(--lyrics-scroll-offset, 0px)); transition: opacity 0.3s ease, transform 0.6s cubic-bezier(0.23, 1, 0.32, 1) var(--lyrics-line-delay, 0ms), filter 0.3s ease; } .lyrics-plus-empty { display: block; height: 100vh; transform: translateY(var(--lyrics-scroll-offset, 0px)); } .lyrics-footer { display: flex; justify-content: space-between; align-items: center; flex-wrap: wrap; text-align: left; font-size: calc(var(--lyplus-font-size-base) * 0.5); color: var(--lyplus-text-secondary); padding: 20px 0 50vh 0; margin-top: 10px; font-weight: 400; opacity: 0.8; transition: opacity 0.3s ease, transform 0.5s cubic-bezier(0.41, 0, 0.12, 0.99), filter 0.3s ease; transform-origin: left; } .lyrics-footer.lyrics-line { font-size: calc(var(--lyplus-font-size-base) * 0.5); padding: 20px var(--lyplus-padding-line) 50vh var(--lyplus-padding-line); margin-top: 0; } .lyrics-footer.active { opacity: 1; color: rgba(255, 255, 255, 0.5); /* Grey instead of primary */ } .lyrics-footer.scroll-animate { transition: none !important; animation-name: lyrics-scroll; animation-duration: var(--scroll-duration, 280ms); animation-timing-function: cubic-bezier(0.41, 0, 0.12, 0.99); animation-fill-mode: both; animation-delay: var(--lyrics-line-delay, 0ms); } .lyrics-container.blur-inactive-enabled:not(.not-focused) .lyrics-footer:not(.active) { filter: blur(var(--lyplus-blur-amount)); opacity: 0.5; } .lyrics-container.user-scrolling .lyrics-footer { transition: none !important; filter: none !important; opacity: 0.8 !important; } .lyrics-footer p { margin: 5px 0; } .lyrics-footer a { color: var(--lyplus-text-primary); /* Stand out using primary color */ text-underline-offset: 2px; opacity: 0.8; transition: opacity 0.2s; } .lyrics-footer a:hover { opacity: 1; } .footer-content { display: flex; align-items: flex-start; flex-direction: column; gap: 8px; } .footer-controls { display: flex; align-items: center; } /* ========================================================================== HEADER & CONTROLS ========================================================================== */ .lyrics-header { display: flex; padding: 10px 0; margin-bottom: 10px; gap: 10px; justify-content: space-between; align-items: center; } .lyrics-header .download-button { background: none; border: none; cursor: pointer; color: #aaa; padding: 0; margin-left: 10px; vertical-align: middle; display: inline-flex; align-items: center; font-family: inherit; } .lyrics-header .download-button:hover { color: rgba(255, 255, 255, 0.9); } .header-controls { display: flex; gap: 8px; } .download-controls { display: flex; align-items: center; gap: 4px; } .control-button { background: transparent; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; padding: 2px 8px; font-size: 0.8em; color: rgba(255, 255, 255, 0.6); cursor: pointer; transition: all 0.2s; font-weight: normal; } .control-button:hover { color: rgba(255, 255, 255, 0.9); border-color: rgba(255, 255, 255, 0.5); } .control-button.active { background-color: var(--lyplus-text-primary); border-color: var(--lyplus-text-primary); color: #000; } .format-select { background: transparent; border: 1px solid rgba(255, 255, 255, 0.3); border-radius: 4px; color: rgba(255, 255, 255, 0.6); font-size: 0.8em; margin-left: 10px; padding: 2px 5px; cursor: pointer; font-weight: normal; font-family: inherit; } .format-select:hover { color: rgba(255, 255, 255, 0.9); border-color: rgba(255, 255, 255, 0.5); } .format-select option { background: #1a1a1a; color: #fff; } /* ========================================================================== TRANSLATION & ROMANIZATION ========================================================================== */ .lyrics-translation-container, .lyrics-romanization-container { padding-top: 0.2em; opacity: 0.8; font-size: var(--lyplus-font-size-subtext); overflow-wrap: break-word; pointer-events: none; user-select: none; transition: opacity 0.3s ease, color 0.3s; font-weight: normal; } .lyrics-romanization-container { direction: ltr !important; } .lyrics-romanization-container.rtl-text { direction: rtl !important; text-align: right; } .lyrics-romanization-container .lyrics-syllable { white-space: pre-wrap; } .lyrics-translation-container { opacity: 0.5; } .main-line-wrapper.small { font-size: 0.5em; opacity: 0.8; display: block; margin-bottom: 0px; } .translation-line { font-size: 1em; font-weight: bold; display: block; margin-top: 0px; line-height: 1.1; } .romanized-line { font-size: 0.5em; color: rgba(255, 255, 255, 0.5); display: block; margin-top: 2px; font-weight: normal; } /* ========================================================================== SKELETON LOADING ========================================================================== */ @keyframes skeleton-loading { 0% { background-color: rgba(255, 255, 255, 0.1); } 100% { background-color: rgba(255, 255, 255, 0.2); } } .skeleton-line { height: 2.5em; margin: 20px 0; border-radius: 8px; animation: skeleton-loading 1s linear infinite alternate; opacity: 0.7; width: 60%; } .skeleton-line:nth-child(even) { width: 80%; } .skeleton-line:nth-child(3n) { width: 50%; } .skeleton-line:nth-child(5n) { width: 70%; } .no-lyrics { color: rgba(255, 255, 255, 0.5); font-size: 1.2em; text-align: center; padding: 2em; font-weight: normal; } /* ========================================================================== KEYFRAME ANIMATIONS ========================================================================== */ /* Wipe animation for syllables */ @keyframes wipe { from { background-size: 0.75em 100%, 0% 100%; background-position: -0.375em 0%, left; } to { background-size: 0.75em 100%, 100% 100%; background-position: calc(100% + 0.375em) 0%, left; } } @keyframes start-wipe { 0% { background-size: 0.75em 100%, 0% 100%; background-position: -0.75em 0%, -0.375em 0%; } 100% { background-size: 0.75em 100%, 100% 100%; background-position: calc(100% + 0.375em) 0%, left; } } @keyframes wipe-rtl { from { background-size: 0.75em 100%, 0% 100%; background-position: calc(100% + 0.375em) 0%, calc(100% + 0.36em) 0%; } to { background-size: 0.75em 100%, 100% 100%; background-position: -0.75em 0%, right 0%; } } @keyframes start-wipe-rtl { 0% { background-size: 0.75em 100%, 0% 100%; background-position: calc(100% + 0.75em) 0%, calc(100% + 0.5em) 0%; } 100% { background-size: 0.75em 100%, 100% 100%; background-position: -0.75em 0%, right 0%; } } @keyframes pre-wipe-universal { from { background-size: 0.75em 100%, 0% 100%; background-position: -0.75em 0%, left; } to { background-size: 0.75em 100%, 0% 100%; background-position: -0.375em 0%, left; } } @keyframes pre-wipe-universal-rtl { from { background-size: 0.75em 100%, 0% 100%; background-position: calc(100% + 0.75em) 0%, right 0%; } to { background-size: 0.75em 100%, 0% 100%; background-position: calc(100% + 0.375em) 0%, right 0%; } } @keyframes pre-wipe-char { from { background-size: 0.75em 100%, 0% 100%; background-position: -0.75em 0%, left; } to { background-size: 0.75em 100%, 0% 100%; background-position: -0.375em 0%, left; } } /* Gap dot animations */ @keyframes gap-loop { from { transform: translateY(-25%) scale(1.12); } to { transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85)); } } @keyframes gap-ended { 0% { transform: translateY(-25%) scale(var(--gap-exit-scale, 0.85)); } 35% { transform: translateY(-25%) scale(1.2); } 100% { transform: translateY(-25%) scale(0); } } @keyframes fade-gap { from { background-color: var(--lyplus-text-secondary); } to { background-color: var(--lyplus-text-primary); } } /* Scroll animation — class is removed and re-added (with a forced reflow in between) to reliably restart the animation each time */ @keyframes lyrics-scroll { from { transform: translate3d(0, var(--scroll-delta), 0); } to { transform: translate3d(0, 0, 0); } } /* Character grow animation — translate3d+scale3d for smooth transform, drop-shadow for glow */ @keyframes grow-dynamic { 0% { transform: translate3d(0, 0, 0) scale3d(1, 1, 1); filter: drop-shadow( 0 0 0 color-mix(in srgb, var(--lyplus-lyrics-palette), transparent 100%) ); } 25%, 30% { transform: translate3d( var(--char-offset-x, 0px), var(--translate-y-peak, -2px), 0 ) scale3d(var(--matrix-scale, 1.1), var(--matrix-scale, 1.1), 1); filter: drop-shadow( 0 0 0.1em color-mix( in srgb, var(--lyplus-lyrics-palette), transparent calc((1 - var(--shadow-intensity, 1)) * 100%) ) ); } 75%, 100% { transform: translate3d(0, var(--char-rise-y, -1.12px), 0) scale3d(1, 1, 1); filter: drop-shadow( 0 0 0 color-mix(in srgb, var(--lyplus-lyrics-palette), transparent 100%) ); } } @keyframes rise-char { 0% { transform: translate3d(0, 0, 0); } 65%, 100% { transform: translate3d(0, var(--char-rise-y, -1.12px), 0); } } @keyframes grow-static { 0%, 100% { transform: scale3d(1.01, 1.01, 1.1) translateY(-0.05%); text-shadow: 0 0 0 color-mix(in srgb, var(--lyplus-lyrics-palette), transparent 100%); } 30%, 40% { transform: scale3d(1.1, 1.1, 1.1) translateY(-0.05%); text-shadow: 0 0 0.3em color-mix(in srgb, var(--lyplus-lyrics-palette), transparent 50%); } } /* Fade in animation */ @keyframes fadeInUp { from { opacity: 0; transform: translateY(20px); } to { opacity: 0.7; transform: translateY(0); } } /* Legacy support */ .opposite-turn { text-align: right; } .singer-right { text-align: right; justify-content: flex-end; } .singer-left { text-align: left; justify-content: flex-start; } /* Legacy progress-text for backward compatibility */ .progress-text { position: relative; display: inline-block; background: linear-gradient( to right, var(--lyplus-text-primary) 0%, var(--lyplus-text-primary) var(--line-progress, 0%), var(--lyplus-text-secondary) var(--line-progress, 0%), var(--lyplus-text-secondary) 100% ); -webkit-background-clip: text; background-clip: text; -webkit-text-fill-color: transparent; color: var(--lyplus-text-secondary); transform: translate3d(0, 0, 0); will-change: background-size; } .progress-text::before { display: none; } .active-line { font-weight: bold; } .background-text { display: block; color: var(--lyplus-text-secondary); font-size: 0.8em; font-style: normal; margin: 0; flex-shrink: 0; line-height: 1.1; } .background-text.before { order: -1; } .background-text.after { order: 1; } .instrumental-line { display: inline-flex; align-items: baseline; gap: 8px; color: var(--lyplus-text-secondary); font-size: 0.9em; padding: 4px 10px; animation: fadeInUp 220ms ease; font-weight: normal; } .instrumental-duration { color: var(--lyplus-text-secondary); font-size: 0.8em; } `; @property({ type: String }) query?: string; @property({ type: String }) musicId?: string; @property({ type: String }) isrc?: string; @property({ type: String }) ttml?: string; @property({ type: String, attribute: 'song-title' }) songTitle?: string; @state() private downloadFormat: 'auto' | 'lrc' | 'ttml' = 'auto'; @property({ type: String, attribute: 'song-artist' }) songArtist?: string; @property({ type: String, attribute: 'song-album' }) songAlbum?: string; @property({ type: String, attribute: 'songwriters' }) songwriters?: string; @property({ type: Number, attribute: 'song-duration' }) songDurationMs?: number; @property({ type: String, attribute: 'highlight-color' }) highlightColor = '#ffffff'; @property({ type: String, attribute: 'font-family' }) fontFamily: string | undefined; @property({ type: Boolean }) autoScroll = true; @property({ type: Boolean }) interpolate = true; @state() private showRomanization = false; @state() private showTranslation = false; private async toggleRomanization() { this.showRomanization = !this.showRomanization; await this.applyRomanization(); } private async applyRomanization() { if (this.showRomanization && this.lyrics) { const needsRomanization = this.lyrics.some( l => !l.romanizedText && (!l.text || !l.text.some(s => s.romanizedText)), ); if (needsRomanization) { this.isLoading = true; try { const romanizedLines = await GoogleService.romanize(this.lyrics); this.lyrics = romanizedLines; } catch (e) { // eslint-disable-next-line no-console console.error('Romanization failed', e); } finally { this.isLoading = false; } } } } private async toggleTranslation() { this.showTranslation = !this.showTranslation; await this.applyTranslation(); } private async applyTranslation() { if (this.showTranslation && this.lyrics) { const needsTranslation = this.lyrics.some(l => !l.translation); if (needsTranslation) { this.isLoading = true; try { // Prepare batch: extract text from all lines const textToTranslate = this.lyrics.map(line => { if (line.translation) return ''; return line.text.map(s => s.text).join(''); }); // If all are empty, skip if (textToTranslate.every(t => !t)) { this.isLoading = false; return; } const result = await GoogleService.translate(textToTranslate, 'en'); const translations = Array.isArray(result) ? result : [result]; const newLyrics = this.lyrics.map((line, index) => { if (line.translation) return line; return { ...line, translation: translations[index] || undefined, }; }); this.lyrics = newLyrics; } catch (e) { // eslint-disable-next-line no-console console.error('Translation failed', e); } finally { this.isLoading = false; } } } } @property({ type: Number }) duration?: number; private _currentTime = 0; @property({ type: Number, attribute: 'currenttime', hasChanged: () => false }) set currentTime(value: number) { const oldValue = this._currentTime; // If the new time is significantly smaller than the old time (e.g. song looped) if (value < oldValue && oldValue - value > 1000 && this.lyrics) { this.activeLineIndices = []; this.activeMainWordIndices.clear(); this.activeBackgroundWordIndices.clear(); this.mainWordProgress.clear(); this.backgroundWordProgress.clear(); this.mainWordAnimations.clear(); this.backgroundWordAnimations.clear(); this.preActiveLineElements = []; this.positionedLineElements = []; this.activeGapLineElements = []; // Stop all running animations and clear highlights immediately if (this.lyricsContainer) { const activeLines = this.lyricsContainer.querySelectorAll( '.lyrics-line.active, .lyrics-line.pre-active, .lyrics-line.bg-expanded', ); activeLines.forEach(line => { line.classList.remove('active', 'pre-active', 'bg-expanded'); AmLyrics.resetSyllables(line as HTMLElement); }); const activeGaps = this.lyricsContainer.querySelectorAll( '.lyrics-gap.active, .lyrics-gap.gap-exiting', ); activeGaps.forEach(gap => gap.classList.remove('active', 'gap-exiting'), ); // Reset gap cache since we manually messed with the elements this.gapElementCache.clear(); } } this._currentTime = value; if (oldValue !== value && this.lyrics) { this._onTimeChanged(oldValue, value); } } get currentTime(): number { return this._currentTime; } @state() private isLoading = false; @state() private lyrics?: LyricsLine[]; private activeLineIndices: number[] = []; private activeMainWordIndices: Map = new Map(); private activeBackgroundWordIndices: Map = new Map(); private mainWordProgress: Map = new Map(); private backgroundWordProgress: Map = new Map(); @state() private lyricsSource: string | null = null; @state() private availableSources: YouLyPlusLyricsResult[] = []; @state() private currentSourceIndex = 0; private isFetchingAlternatives = false; private hasFetchedAllProviders = false; private _updateFooter() { const footer = this.shadowRoot?.querySelector('.lyrics-footer'); if (!footer) return; const switchBtn = footer.querySelector('.source-switch-btn'); const svgEl = footer.querySelector('.source-switch-svg'); const labelEl = footer.querySelector('.source-switch-label'); if (switchBtn) { (switchBtn as HTMLButtonElement).disabled = this.isFetchingAlternatives; } if (svgEl) { svgEl.setAttribute( 'style', `margin-right: 4px; ${this.isFetchingAlternatives ? 'animation: spin 1s linear infinite;' : ''}`, ); } if (labelEl) { labelEl.textContent = this.isFetchingAlternatives ? 'Switching...' : 'Switch'; } } private animationFrameId?: number; private mainWordAnimations: Map< number, { startTime: number; duration: number } > = new Map(); private backgroundWordAnimations: Map< number, { startTime: number; duration: number } > = new Map(); @query('.lyrics-container') private lyricsContainer?: HTMLElement; private lastInstrumentalIndex: number | null = null; private userScrollTimeoutId?: number; private isUserScrolling = false; private isProgrammaticScroll = false; private isClickSeeking = false; private clickSeekTimeout?: ReturnType; // Cached DOM elements for animation updates private cachedLyricsLines: HTMLElement[] = []; // Cached line elements array for scroll/position queries private cachedLineArray: HTMLElement[] = []; // Cached line and gap element maps for fast lookup private lineElementCache = new Map(); private gapElementCache = new Map(); // Cached gap computation results private cachedAllGaps: Array<{ insertBeforeIndex: number; gapStart: number; gapEnd: number; }> = []; // Cached isUnsynced flag private cachedIsUnsynced = false; // Cached pre-computed line data for render private cachedLineData: Array<{ wordGroups: Syllable[][]; groupGrowable: boolean[]; groupGlowing: boolean[]; groupCharRise: boolean[]; vwFullText: string[]; vwFullDuration: number[]; vwCharOffset: number[]; vwStartMs: number[]; vwEndMs: number[]; lineIsRTL: boolean; }> | null = null; // Active line tracking private activeLineIds: Set = new Set(); private currentPrimaryActiveLine: HTMLElement | null = null; private lastPrimaryActiveLine: HTMLElement | null = null; // Scroll animation state private scrollAnimationState: { isAnimating: boolean; pendingUpdate: number | null; } | null = null; private currentScrollOffset = 0; private animatingLines: HTMLElement[] = []; private scrollAnimationTimeout?: ReturnType; private scrollUnlockTimeout?: ReturnType; // AbortController for cancelling in-flight lyrics fetches private fetchAbortController?: AbortController; // Syllable animation tracking private lastActiveIndex = 0; private visibleLineIds: Set = new Set(); // IntersectionObserver for viewport virtualization private visibilityObserver?: IntersectionObserver; // Cached scroll padding top value private cachedScrollPaddingTop: number | null = null; // Cached element tracking to avoid repeated querySelectorAll calls private preActiveLineElements: HTMLElement[] = []; private positionedLineElements: HTMLElement[] = []; private activeGapLineElements: HTMLElement[] = []; // Bound handler references for proper event listener removal private _boundHandleUserScroll = this.handleUserScroll.bind(this); private _boundAnimateProgress = this.animateProgress.bind(this); connectedCallback() { super.connectedCallback(); this.fetchLyrics(); } disconnectedCallback() { super.disconnectedCallback(); if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } if (this.userScrollTimeoutId) { clearTimeout(this.userScrollTimeoutId); this.userScrollTimeoutId = undefined; } if (this.clickSeekTimeout) { clearTimeout(this.clickSeekTimeout); this.clickSeekTimeout = undefined; } if (this.scrollAnimationTimeout) { clearTimeout(this.scrollAnimationTimeout); this.scrollAnimationTimeout = undefined; } if (this.scrollUnlockTimeout) { clearTimeout(this.scrollUnlockTimeout); this.scrollUnlockTimeout = undefined; } // Cancel any in-flight fetch requests this.fetchAbortController?.abort(); this.fetchAbortController = undefined; // Remove scroll event listeners if (this.lyricsContainer) { this.lyricsContainer.removeEventListener( 'wheel', this._boundHandleUserScroll, ); this.lyricsContainer.removeEventListener( 'touchmove', this._boundHandleUserScroll, ); } this.preActiveLineElements = []; this.positionedLineElements = []; this.activeGapLineElements = []; this.visibilityObserver?.disconnect(); this.visibilityObserver = undefined; } private async fetchLyrics() { // Cancel any in-flight fetch to prevent stale results from racing this.fetchAbortController?.abort(); const controller = new AbortController(); this.fetchAbortController = controller; this.isLoading = true; this.lyrics = undefined; this.lyricsSource = null; this.availableSources = []; this.currentSourceIndex = 0; this.isFetchingAlternatives = false; this.hasFetchedAllProviders = false; this._updateFooter(); try { if (this.ttml) { const parseResult = AmLyrics.parseTTML(this.ttml); if (parseResult && parseResult.lines.length > 0) { this.lyrics = parseResult.lines; this.lyricsSource = 'Local'; if (parseResult.songwriters) { this.songwriters = parseResult.songwriters; } this.availableSources = [ { lines: this.lyrics, source: 'Local', songwriters: this.songwriters, }, ]; this.currentSourceIndex = 0; this.hasFetchedAllProviders = true; this._updateFooter(); await this.onLyricsLoaded(); return; } } const resolvedMetadata = await this.resolveSongMetadata(); // If a newer fetch was triggered while we awaited, bail out if (controller.signal.aborted) return; const isMusicIdOnlyRequest = Boolean(this.musicId) && !this.songTitle && !this.songArtist && !this.query && !this.isrc; const collectedSources: YouLyPlusLyricsResult[] = []; if (resolvedMetadata?.metadata && !isMusicIdOnlyRequest) { const title = resolvedMetadata.metadata.title?.trim() || ''; const artist = resolvedMetadata.metadata.artist?.trim() || ''; const biniResult = await AmLyrics.fetchLyricsFromBiniLyrics( title, artist, resolvedMetadata.catalogIsrc, resolvedMetadata.metadata, ); if (biniResult && biniResult.lines.length > 0) { collectedSources.push(biniResult); } const hasWordSync = (sources: YouLyPlusLyricsResult[]) => sources.some(s => s.lines.some(l => l.isWordSynced || (l.text && l.text.length > 1)), ); if (collectedSources.length === 0 || !hasWordSync(collectedSources)) { const unisonResult = await AmLyrics.fetchLyricsFromUnison( resolvedMetadata.metadata, ); if (unisonResult && unisonResult.lines.length > 0) { collectedSources.push(unisonResult); } } if (collectedSources.length === 0 || !hasWordSync(collectedSources)) { const youLyResults = await AmLyrics.fetchLyricsFromYouLyPlus( title, artist, resolvedMetadata.catalogIsrc, resolvedMetadata.metadata, true, ); if (youLyResults && youLyResults.length > 0) { collectedSources.push(...youLyResults); } } } const hasLineSync = (sources: YouLyPlusLyricsResult[]) => sources.some(s => s.lines.some(l => l.timestamp > 0 || l.endtime > 0)); if ( (collectedSources.length === 0 || !hasLineSync(collectedSources)) && resolvedMetadata?.metadata ) { // Fallback: LRCLIB const lrclibResult = await AmLyrics.fetchLyricsFromLrclib( resolvedMetadata.metadata, ); if (lrclibResult && lrclibResult.lines.length > 0) { collectedSources.push({ lines: lrclibResult.lines, source: 'LRCLIB', }); } } if (collectedSources.length === 0 && resolvedMetadata?.metadata) { const geniusResult = await AmLyrics.fetchLyricsFromGenius( resolvedMetadata.metadata, ); if (geniusResult && geniusResult.lines.length > 0) { collectedSources.push({ lines: geniusResult.lines, source: 'Genius', }); } } this.hasFetchedAllProviders = collectedSources.length === 0 || collectedSources.some( s => s.source === 'LRCLIB' || s.source === 'Genius', ); this._updateFooter(); if (collectedSources.length > 0) { this.availableSources = AmLyrics.mergeAndSortSources(collectedSources); this.currentSourceIndex = 0; const sourceResult = this.availableSources[0]; this.lyrics = sourceResult.lines; this.lyricsSource = sourceResult.source; if (sourceResult.songwriters) { this.songwriters = sourceResult.songwriters; } await this.onLyricsLoaded(); return; } this.lyrics = undefined; this.lyricsSource = null; } finally { // Only update loading state if this fetch wasn't superseded if (!controller.signal.aborted) { this.isLoading = false; } } } private async onLyricsLoaded() { this.activeLineIndices = []; this.activeMainWordIndices.clear(); this.activeBackgroundWordIndices.clear(); this.mainWordProgress.clear(); this.backgroundWordProgress.clear(); this.mainWordAnimations.clear(); this.backgroundWordAnimations.clear(); this.preActiveLineElements = []; this.positionedLineElements = []; this.activeGapLineElements = []; if (this.lyricsContainer) { this.isProgrammaticScroll = true; this.lyricsContainer.scrollTop = 0; window.setTimeout(() => { this.isProgrammaticScroll = false; }, 100); } await this.autoProcessLyrics(); } private async autoProcessLyrics() { if (this.showRomanization) { await this.applyRomanization(); } if (this.showTranslation) { await this.applyTranslation(); } } private static getRankForCollected( sourceLabel: string, parsedLines: any[], ): number { const lower = sourceLabel.toLowerCase(); const hasWordSync = parsedLines.some( (line: any) => line.text && Array.isArray(line.text) && line.text.length > 1, ); const isUnsynced = parsedLines.length > 0 && parsedLines.every( (line: any) => line.timestamp === 0 && line.endtime === 0, ); const isQQ = lower.includes('qq') || lower.includes('lyricsplus'); if (lower.includes('apple') && hasWordSync) return 1; if (lower.includes('bini') && hasWordSync) return 2; if (lower.includes('unison') && hasWordSync) return 3; if (isQQ && hasWordSync) return 4; if (lower.includes('musixmatch') && hasWordSync) return 5; if (lower.includes('lrclib') && hasWordSync) return 6; if (hasWordSync) return 7; if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 8; if (lower.includes('bini') && !hasWordSync && !isUnsynced) return 9; if (lower.includes('unison') && !hasWordSync && !isUnsynced) return 10; if (isQQ && !hasWordSync && !isUnsynced) return 11; if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced) return 12; if (lower.includes('lrclib') && !hasWordSync && !isUnsynced) return 13; if (!hasWordSync && !isUnsynced) return 14; if (lower.includes('apple') && isUnsynced) return 15; if (lower.includes('bini') && isUnsynced) return 16; if (lower.includes('unison') && isUnsynced) return 17; if (isQQ && isUnsynced) return 18; if (lower.includes('musixmatch') && isUnsynced) return 19; if (lower.includes('lrclib') && isUnsynced) return 20; if (lower.includes('genius')) return 21; return 30; } private static mergeAndSortSources( collectedSources: { lines: LyricsLine[]; source: string }[], ): { lines: LyricsLine[]; source: string }[] { const uniqueSourcesMap = new Map< string, { lines: LyricsLine[]; source: string } >(); for (const source of collectedSources) { const normalizedSource = source.source .toLowerCase() .includes('lyricsplus') ? 'QQ' : source.source; if (!uniqueSourcesMap.has(normalizedSource)) { uniqueSourcesMap.set(normalizedSource, { ...source, source: normalizedSource, }); } } return Array.from(uniqueSourcesMap.values()).sort( (a, b) => AmLyrics.getRankForCollected(a.source, a.lines) - AmLyrics.getRankForCollected(b.source, b.lines), ); } private async switchSource() { if (this.isFetchingAlternatives) return; if (!this.hasFetchedAllProviders) { this.isFetchingAlternatives = true; this._updateFooter(); try { const resolvedMetadata = await this.resolveSongMetadata(); if (resolvedMetadata?.metadata) { const newSources: YouLyPlusLyricsResult[] = []; // Try Unison if not fetched if ( !this.availableSources.some(s => s.source.toLowerCase().includes('unison'), ) ) { const unisonResult = await AmLyrics.fetchLyricsFromUnison( resolvedMetadata.metadata, ); if (unisonResult && unisonResult.lines.length > 0) { newSources.push(unisonResult); } } // Try YouLyPlus (KPoe) if we don't have Apple or QQ if ( !this.availableSources.some( s => s.source.toLowerCase().includes('apple') || s.source.toLowerCase().includes('qq'), ) ) { const title = resolvedMetadata.metadata.title?.trim() || ''; const artist = resolvedMetadata.metadata.artist?.trim() || ''; const youLyResults = await AmLyrics.fetchLyricsFromYouLyPlus( title, artist, resolvedMetadata.catalogIsrc, resolvedMetadata.metadata, true, ); if (youLyResults && youLyResults.length > 0) { newSources.push(...youLyResults); } } // Try LRCLIB if not fetched if ( !this.availableSources.some(s => s.source.toLowerCase().includes('lrclib'), ) ) { const lrclibResult = await AmLyrics.fetchLyricsFromLrclib( resolvedMetadata.metadata, ); if (lrclibResult && lrclibResult.lines.length > 0) { newSources.push({ lines: lrclibResult.lines, source: 'LRCLIB' }); } } if ( !this.availableSources.some(s => s.source.toLowerCase().includes('genius'), ) ) { const geniusResult = await AmLyrics.fetchLyricsFromGenius( resolvedMetadata.metadata, ); if (geniusResult && geniusResult.lines.length > 0) { newSources.push({ lines: geniusResult.lines, source: 'Genius' }); } } if (newSources.length > 0) { this.availableSources = AmLyrics.mergeAndSortSources([ ...this.availableSources, ...newSources, ]); // Re-sync current index since sorting might shift elements this.currentSourceIndex = this.availableSources.findIndex( s => s.source === this.lyricsSource, ); if (this.currentSourceIndex === -1) this.currentSourceIndex = 0; } } } finally { this.hasFetchedAllProviders = true; this.isFetchingAlternatives = false; this._updateFooter(); } } if (this.availableSources.length > 1) { this.currentSourceIndex = (this.currentSourceIndex + 1) % this.availableSources.length; const sourceResult = this.availableSources[this.currentSourceIndex]; this.lyrics = sourceResult.lines; this.lyricsSource = sourceResult.source; if (sourceResult.songwriters) { this.songwriters = sourceResult.songwriters; } await this.onLyricsLoaded(); } } private async resolveSongMetadata(): Promise { const metadata: SongMetadata = { title: this.songTitle?.trim() ?? '', artist: this.songArtist?.trim() ?? '', album: this.songAlbum?.trim() || undefined, songwriters: this.songwriters?.trim() || undefined, durationMs: undefined, }; if (typeof this.songDurationMs === 'number' && this.songDurationMs > 0) { metadata.durationMs = this.songDurationMs; } else if (typeof this.duration === 'number' && this.duration > 0) { metadata.durationMs = this.duration; } const appleSong: any = null; let appleId = this.musicId; let catalogIsrc: string | undefined = this.isrc; if ( this.query && (!metadata.title || !metadata.artist || !metadata.album) ) { const parsed = AmLyrics.parseQueryMetadata(this.query); if (parsed) { if (!metadata.title && parsed.title) { metadata.title = parsed.title; } if (!metadata.artist && parsed.artist) { metadata.artist = parsed.artist; } if (!metadata.album && parsed.album) { metadata.album = parsed.album; } } } let catalogResult: SongCatalogResult | null = null; if (this.query && (!metadata.title || !metadata.artist)) { catalogResult = await AmLyrics.searchLyricsPlusCatalog(this.query); if (catalogResult) { if (!metadata.title && catalogResult.title) { metadata.title = catalogResult.title; } if (!metadata.artist && catalogResult.artist) { metadata.artist = catalogResult.artist; } if (!metadata.album && catalogResult.album) { metadata.album = catalogResult.album; } if (!metadata.songwriters && catalogResult.songwriters) { metadata.songwriters = catalogResult.songwriters; } if ( metadata.durationMs == null && typeof catalogResult.durationMs === 'number' && catalogResult.durationMs > 0 ) { metadata.durationMs = catalogResult.durationMs; } if (!appleId && catalogResult.id?.appleMusic) { appleId = catalogResult.id.appleMusic; } if (!catalogIsrc && catalogResult.isrc) { catalogIsrc = catalogResult.isrc; } } } const trimmedTitle = metadata.title?.trim() ?? ''; const trimmedArtist = metadata.artist?.trim() ?? ''; const trimmedAlbum = metadata.album?.trim(); const sanitizedDuration = typeof metadata.durationMs === 'number' && Number.isFinite(metadata.durationMs) && metadata.durationMs > 0 ? Math.round(metadata.durationMs) : undefined; const finalMetadata = trimmedTitle && trimmedArtist ? { title: trimmedTitle, artist: trimmedArtist, album: trimmedAlbum || undefined, durationMs: sanitizedDuration, } : undefined; return { metadata: finalMetadata, appleId, appleSong, catalogIsrc, }; } private static parseQueryMetadata( rawQuery: string, ): ParsedQueryMetadata | null { const trimmed = rawQuery?.trim(); if (!trimmed) return null; const result: ParsedQueryMetadata = {}; const hyphenSplit = trimmed.split(/\s[-–—]\s/); if (hyphenSplit.length >= 2) { const [rawTitle, ...rest] = hyphenSplit; const rawArtist = rest.join(' - '); const titleCandidate = rawTitle.trim(); const artistCandidate = rawArtist.trim(); if (titleCandidate && artistCandidate) { result.title = titleCandidate; result.artist = artistCandidate; return result; } } const bySplit = trimmed.split(/\s+[bB]y\s+/); if (bySplit.length === 2) { const [maybeTitle, maybeArtist] = bySplit.map(part => part.trim()); if (maybeTitle && maybeArtist) { result.title = maybeTitle; result.artist = maybeArtist; return result; } } return null; } private static async searchLyricsPlusCatalog( searchTerm: string, ): Promise { const trimmedQuery = searchTerm?.trim(); if (!trimmedQuery) return null; for (const base of KPOE_SERVERS) { const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; const url = `${normalizedBase}/v1/songlist/search?q=${encodeURIComponent( trimmedQuery, )}`; try { // eslint-disable-next-line no-await-in-loop const response = await fetchWithTimeout(url); if (response.ok) { // eslint-disable-next-line no-await-in-loop const payload = await response.json(); let results: SongCatalogResult[] = []; const typedPayload = payload as { results?: SongCatalogResult[]; } | null; if (Array.isArray(typedPayload?.results)) { results = typedPayload.results as SongCatalogResult[]; } else if (Array.isArray(payload)) { results = payload as SongCatalogResult[]; } if (results.length > 0) { const primary = results.find( (item: SongCatalogResult) => item?.id && item.id.appleMusic, ); return (primary ?? results[0]) as SongCatalogResult; } } } catch (error) { // Ignore and try next server } } return null; } private static async fetchLyricsFromBiniLyrics( title: string, artist: string, isrc?: string, metadata: { durationMs?: number; album?: string } = {}, ): Promise { if ((!title || !artist) && !isrc) return null; try { let cacheData: any = null; if (isrc) { try { const isrcUrl = `https://lyrics-api.binimum.org/?isrc=${encodeURIComponent(isrc)}`; const isrcRes = await fetchWithTimeout(isrcUrl); if (isrcRes.ok) { const data = await isrcRes.json(); if (data.results && data.results.length > 0) { cacheData = data; } } } catch { // Fall through to title/artist search } } if (!cacheData && title && artist) { const cacheParams = new URLSearchParams({ track: title, artist, }); if (metadata.album) { cacheParams.append('album', metadata.album); } if (metadata.durationMs && metadata.durationMs > 0) { cacheParams.append( 'duration', Math.round(metadata.durationMs / 1000).toString(), ); } const cacheUrl = `https://lyrics-api.binimum.org/?${cacheParams.toString()}`; const cacheRes = await fetchWithTimeout(cacheUrl); if (cacheRes.ok) { cacheData = await cacheRes.json(); } } if (cacheData && cacheData.results && cacheData.results.length > 0) { const result = cacheData.results[0]; if (result.lyricsUrl) { const ttmlRes = await fetchWithTimeout(result.lyricsUrl); if (ttmlRes.ok) { const ttmlText = await ttmlRes.text(); const parseResult = AmLyrics.parseTTML(ttmlText); if (parseResult && parseResult.lines.length > 0) { return { lines: parseResult.lines, source: 'BiniLyrics', songwriters: parseResult.songwriters, }; } } } } } catch (e) { // eslint-disable-next-line no-console console.error('Cache API failed', e); } return null; } private static async fetchLyricsFromYouLyPlus( title: string, artist: string, isrc?: string, metadata: { durationMs?: number; album?: string } = {}, skipBiniCache = false, ): Promise { if ((!title || !artist) && !isrc) return []; const params = new URLSearchParams(); if (title) params.append('title', title); if (artist) params.append('artist', artist); if (isrc) params.append('isrc', isrc); if (metadata.album) { params.append('album', metadata.album); } if (metadata.durationMs && metadata.durationMs > 0) { params.append( 'duration', Math.round(metadata.durationMs / 1000).toString(), ); } if (!DEFAULT_KPOE_SOURCE_ORDER.includes('apple')) { params.append('source', DEFAULT_KPOE_SOURCE_ORDER); } const getRank = (sourceLabel: string, parsedLines: any[]): number => { const lower = sourceLabel.toLowerCase(); const hasWordSync = parsedLines.some( (line: any) => line.text && Array.isArray(line.text) && line.text.length > 1, ); const isUnsynced = parsedLines.length > 0 && parsedLines.every( (line: any) => line.timestamp === 0 && line.endtime === 0, ); const isQQ = lower.includes('qq') || lower.includes('lyricsplus'); if (lower.includes('apple') && hasWordSync) return 1; if (lower.includes('bini') && hasWordSync) return 2; if (lower.includes('unison') && hasWordSync) return 3; if (isQQ && hasWordSync) return 4; if (lower.includes('musixmatch') && hasWordSync) return 5; if (hasWordSync) return 6; if (lower.includes('apple') && !hasWordSync && !isUnsynced) return 7; if (lower.includes('bini') && !hasWordSync && !isUnsynced) return 8; if (lower.includes('unison') && !hasWordSync && !isUnsynced) return 9; if (isQQ && !hasWordSync && !isUnsynced) return 10; if (lower.includes('musixmatch') && !hasWordSync && !isUnsynced) return 11; if (!hasWordSync && !isUnsynced) return 12; if (lower.includes('apple') && isUnsynced) return 13; if (lower.includes('bini') && isUnsynced) return 14; if (lower.includes('unison') && isUnsynced) return 15; if (isQQ && isUnsynced) return 16; if (lower.includes('musixmatch') && isUnsynced) return 17; return 30; }; const allResults: YouLyPlusLyricsResult[] = []; if (!skipBiniCache) { const biniResult = await AmLyrics.fetchLyricsFromBiniLyrics( title, artist, isrc, metadata, ); if (biniResult) { allResults.push(biniResult); return allResults; } } // Shuffle servers so we pick a random one first, with all others as fallback // Try up to 3 servers to improve reliability when some have CORS or connectivity issues const shuffledServers = [...KPOE_SERVERS] .sort(() => Math.random() - 0.5) .slice(0, 3); for (const base of shuffledServers) { const normalizedBase = base.endsWith('/') ? base.slice(0, -1) : base; const url = `${normalizedBase}/v2/lyrics/get?${params.toString()}`; let payload: any = null; try { // eslint-disable-next-line no-await-in-loop const response = await fetchWithTimeout(url); if (response.ok) { // eslint-disable-next-line no-await-in-loop payload = await response.json(); } } catch { payload = null; } if (payload) { const lines = AmLyrics.convertKPoeLyrics(payload); if (lines && lines.length > 0) { const sourceLabel = payload?.metadata?.source || payload?.metadata?.provider || 'LyricsPlus (KPoe)'; const rank = getRank(sourceLabel, lines); const result = { lines, source: sourceLabel }; allResults.push(result); // If source is Apple synced, we have the best so we can just immediately break the sweep if (rank === 1) { break; } } } } // If we haven't found a completely synced result (rank 1 or 2) among the servers, // force an explicit query against lyricsplus.binimum.org looking for word lyrics const hasHighRankResult = allResults.some( r => getRank(r.source, r.lines) <= 2, ); if (!hasHighRankResult) { try { const fallbackParams = new URLSearchParams(params); const url = `https://lyricsplus.binimum.org/v2/lyrics/get?${fallbackParams.toString()}`; const response = await fetchWithTimeout(url); if (response.ok) { const payload = await response.json(); if (payload) { const lines = AmLyrics.convertKPoeLyrics(payload); const sourceLabel = payload?.metadata?.source || payload?.metadata?.provider || 'LyricsPlus (KPoe)'; const hasWordSync = lines?.some( (line: any) => line.text && Array.isArray(line.text) && line.text.length > 1, ); if (lines && lines.length > 0 && hasWordSync) { allResults.push({ lines, source: sourceLabel }); } } } } catch (error) { // Explicit fallback failed, ignore } } return allResults; } /** * Parse LRC subtitle format into LyricsLine[]. * Handles "[mm:ss.xx] text" lines. */ private static parseLrcSubtitles(lrc: string): LyricsLine[] { if (!lrc || typeof lrc !== 'string') return []; const lines: LyricsLine[] = []; const rawLines = lrc.split('\n'); const parsed: { timestamp: number; text: string }[] = []; for (const raw of rawLines) { const match = raw.match(/^\[(\d{1,3}):(\d{2})\.(\d{2,3})\]\s?(.*)$/); if (!match) { // Skip non-timestamped lines (headers like [ti:], [ar:], etc.) // eslint-disable-next-line no-continue continue; } const minutes = parseInt(match[1], 10); const seconds = parseInt(match[2], 10); let centiseconds = parseInt(match[3], 10); // Handle both mm:ss.xx (centiseconds) and mm:ss.xxx (milliseconds) if (match[3].length === 3) { centiseconds = Math.round(centiseconds / 10); } const timestamp = (minutes * 60 + seconds) * 1000 + centiseconds * 10; const text = match[4] || ''; parsed.push({ timestamp, text }); } for (let i = 0; i < parsed.length; i += 1) { const { timestamp, text } = parsed[i]; // Endtime is the start of the next line, or timestamp + 5s for the last line const endtime = i + 1 < parsed.length ? parsed[i + 1].timestamp : timestamp + 5000; // Skip empty lines (instrumental gaps) if (!text.trim()) { // eslint-disable-next-line no-continue continue; } const syllable: Syllable = { text, part: false, timestamp, endtime, lineSynced: true, }; lines.push({ text: [syllable], background: false, backgroundText: [], oppositeTurn: false, timestamp, endtime, isWordSynced: false, }); } return lines; } /** * Fetch lyrics from LRCLIB. * Uses search endpoint, prefers synced lyrics. */ private static async fetchLyricsFromLrclib( metadata: SongMetadata, ): Promise { const title = metadata.title?.trim(); const artist = metadata.artist?.trim(); if (!title || !artist) return null; try { const searchQuery = `${artist} ${title}`; const params = new URLSearchParams({ q: searchQuery }); const response = await fetchWithTimeout( `https://lrclib.net/api/search?${params.toString()}`, { headers: { 'User-Agent': `apple-music-web-components/${VERSION}`, }, }, ); if (!response.ok) return null; const results = await response.json(); if (!Array.isArray(results) || results.length === 0) return null; // Prefer results with synced lyrics const withSynced = results.find( (r: any) => r.syncedLyrics && typeof r.syncedLyrics === 'string', ); const bestMatch = withSynced || results[0]; // Try synced lyrics first if (bestMatch.syncedLyrics) { const lines = AmLyrics.parseLrcSubtitles(bestMatch.syncedLyrics); if (lines.length > 0) { return { lines, source: 'LRCLIB' }; } } // Fall back to plain lyrics (unsynced) if (bestMatch.plainLyrics && typeof bestMatch.plainLyrics === 'string') { const plainLines = bestMatch.plainLyrics .split('\n') .filter((l: string) => l.trim()); if (plainLines.length > 0) { const lines: LyricsLine[] = plainLines.map( (text: string): LyricsLine => ({ text: [ { text, part: false, timestamp: 0, endtime: 0, }, ], background: false, backgroundText: [], oppositeTurn: false, timestamp: 0, endtime: 0, isWordSynced: false, }), ); return { lines, source: 'LRCLIB (unsynced)' }; } } } catch { // LRCLIB fetch failed } return null; } private static async fetchLyricsFromGenius( metadata: SongMetadata, ): Promise { const title = metadata.title?.trim(); const artist = metadata.artist?.trim(); if (!title || !artist) return null; try { const params = new URLSearchParams({ title, artist }); const response = await fetchWithTimeout( `${GENIUS_WORKER_URL}?${params.toString()}`, ); if (!response.ok) return null; const data = await response.json(); if (data.lyrics) { const plainLines = data.lyrics .split('\n') .map((l: string) => l.trim()) .filter((l: string) => l && !l.startsWith('[')); if (plainLines.length > 0) { const lines: LyricsLine[] = plainLines.map( (text: string): LyricsLine => ({ text: [ { text, part: false, timestamp: 0, endtime: 0, }, ], background: false, backgroundText: [], oppositeTurn: false, timestamp: 0, endtime: 0, isWordSynced: false, }), ); return { lines, source: 'Genius' }; } } } catch { // Genius fetch failed, will fall through to return null } return null; } private static async fetchLyricsFromUnison( metadata: SongMetadata, ): Promise { const title = metadata.title?.trim(); const artist = metadata.artist?.trim(); if (!title || !artist) return null; const params = new URLSearchParams(); params.append('song', title); params.append('artist', artist); if (metadata.album) { params.append('album', metadata.album); } if (metadata.durationMs && metadata.durationMs > 0) { params.append( 'duration', Math.round(metadata.durationMs / 1000).toString(), ); } try { const response = await fetchWithTimeout( `https://unison.boidu.dev/lyrics?${params.toString()}`, ); if (!response.ok) return null; const data = await response.json(); if (!data.success || !data.data?.lyrics) return null; const lyricsData = data.data; const format = lyricsData.format || 'lrc'; const syncType = lyricsData.syncType || 'linesync'; const lyricsText = lyricsData.lyrics; if (format === 'ttml') { const parseResult = AmLyrics.parseTTML(lyricsText); if (parseResult && parseResult.lines.length > 0) { return { lines: parseResult.lines, source: 'Unison', songwriters: parseResult.songwriters, }; } } if (format === 'lrc') { if (syncType === 'plain') { const plainLines = lyricsText .split('\n') .map((l: string) => l.trim()) .filter((l: string) => l); if (plainLines.length > 0) { const lines: LyricsLine[] = plainLines.map( (text: string): LyricsLine => ({ text: [{ text, part: false, timestamp: 0, endtime: 0 }], background: false, backgroundText: [], oppositeTurn: false, timestamp: 0, endtime: 0, isWordSynced: false, }), ); return { lines, source: 'Unison (unsynced)' }; } } else { const lines = AmLyrics.parseLrcSubtitles(lyricsText); if (lines.length > 0) { return { lines, source: 'Unison' }; } } } } catch { // Unison fetch failed } return null; } private static calculateLineAlignments( lineSingers: (string | undefined)[], agentTypes: Record, ): ('start' | 'end' | undefined)[] { const lineSideAssignments = new Array(lineSingers.length).fill(undefined); let currentSideIsLeft = true; let lastPersonSingerId: string | null = null; let rightCount = 0; let totalCount = 0; lineSingers.forEach((singerId, index) => { let sideClass: 'start' | 'end' | undefined; if (singerId) { let type = agentTypes[singerId]; if (!type) { if (singerId === 'v1000') { type = 'group'; } else if (singerId === 'v2000') { type = 'other'; } else { type = 'person'; } } if (type === 'group') { sideClass = 'start'; } else { if (lastPersonSingerId === null) { if (type === 'other') { currentSideIsLeft = false; } else { currentSideIsLeft = true; } } else if (singerId !== lastPersonSingerId) { currentSideIsLeft = !currentSideIsLeft; } sideClass = currentSideIsLeft ? 'start' : 'end'; lastPersonSingerId = singerId; } } if (sideClass) { totalCount += 1; if (sideClass === 'end') rightCount += 1; } lineSideAssignments[index] = sideClass; }); if (totalCount > 0 && Math.round((rightCount / totalCount) * 100) >= 85) { const flip = (s: 'start' | 'end' | undefined) => { if (s === 'start') return 'end'; if (s === 'end') return 'start'; return s; }; for (let i = 0; i < lineSideAssignments.length; i += 1) { lineSideAssignments[i] = flip(lineSideAssignments[i]); } } return lineSideAssignments; } private static parseTTML( ttmlString: string, ): { lines: LyricsLine[]; songwriters?: string } | null { try { const parser = new DOMParser(); const doc = parser.parseFromString(ttmlString, 'text/xml'); const translations: Record = {}; const transliterations: Record = {}; const agentMap: Record = {}; const agents = doc.getElementsByTagName('ttm:agent'); for (let i = 0; i < agents.length; i += 1) { const agent = agents[i]; const id = agent.getAttribute('xml:id'); const type = agent.getAttribute('type'); if (id && type) { agentMap[id] = type; } } let songwriters: string | undefined; const songwritersNodes = doc.getElementsByTagName('songwriter'); if (songwritersNodes.length > 0) { const names: string[] = []; for (let i = 0; i < songwritersNodes.length; i += 1) { if (songwritersNodes[i].textContent) { names.push(songwritersNodes[i].textContent!); } } if (names.length > 0) { songwriters = names.join(', '); } } const translationNodes = doc.getElementsByTagName('translation'); for (let i = 0; i < translationNodes.length; i += 1) { const texts = translationNodes[i].getElementsByTagName('text'); for (let j = 0; j < texts.length; j += 1) { const textNode = texts[j]; const key = textNode.getAttribute('for'); if (key && textNode.textContent) { translations[key] = textNode.textContent; } } } const timeToMs = (timeStr: string | null): number => { if (!timeStr) return 0; const parts = timeStr.split(':'); let seconds = 0; if (parts.length === 2) { seconds = parseInt(parts[0], 10) * 60 + parseFloat(parts[1]); } else if (parts.length === 3) { seconds = parseInt(parts[0], 10) * 3600 + parseInt(parts[1], 10) * 60 + parseFloat(parts[2]); } else { seconds = parseFloat(parts[0]); } return Math.round(seconds * 1000); }; const transliterationNodes = doc.getElementsByTagName('transliteration'); for (let i = 0; i < transliterationNodes.length; i += 1) { const texts = transliterationNodes[i].getElementsByTagName('text'); for (let j = 0; j < texts.length; j += 1) { const textNode = texts[j]; const key = textNode.getAttribute('for'); if (!key) { // eslint-disable-next-line no-continue continue; } const spans = Array.from( textNode.getElementsByTagName('span'), ).filter(span => span.getAttribute('begin')); if (spans.length > 0) { const syllabus: any[] = []; let fullText = ''; for (let k = 0; k < spans.length; k += 1) { const span = spans[k]; const begin = span.getAttribute('begin'); const end = span.getAttribute('end'); let spanText = span.textContent || ''; const nextNode = span.nextSibling; if ( nextNode && nextNode.nodeType === 3 && /^\s/.test(nextNode.textContent || '') && !spanText.endsWith(' ') ) { spanText += ' '; } if (spanText.trim() === '') { // eslint-disable-next-line no-continue continue; } syllabus.push({ time: timeToMs(begin), duration: timeToMs(end) - timeToMs(begin), text: spanText, }); fullText += spanText; } transliterations[key] = { text: fullText.trim(), syllabus }; } else if (textNode.textContent) { transliterations[key] = { text: textNode.textContent.trim().replace(/\s+/g, ' '), }; } } } const lines: LyricsLine[] = []; const pNodes = doc.getElementsByTagName('p'); const lineSingers: (string | undefined)[] = []; for (let i = 0; i < pNodes.length; i += 1) { lineSingers.push(pNodes[i].getAttribute('ttm:agent') || undefined); } const alignments = AmLyrics.calculateLineAlignments( lineSingers, agentMap, ); for (let i = 0; i < pNodes.length; i += 1) { const p = pNodes[i]; const key = p.getAttribute('itunes:key'); const beginMs = timeToMs(p.getAttribute('begin')); const endMs = timeToMs(p.getAttribute('end')); let songPart: string | undefined; if (p.parentNode && (p.parentNode as Element).tagName === 'div') { songPart = (p.parentNode as Element).getAttribute('itunes:songPart') || undefined; } const mainSyllables: Syllable[] = []; const bgSyllables: Syllable[] = []; const spans = p.getElementsByTagName('span'); if (spans.length > 0) { for (let j = 0; j < spans.length; j += 1) { const span = spans[j]; if (span.getAttribute('ttm:role') === 'x-bg') { const bgInnerSpans = span.getElementsByTagName('span'); for (let k = 0; k < bgInnerSpans.length; k += 1) { const bgSpan = bgInnerSpans[k]; let bgText = bgSpan.textContent || ''; const nextNode = bgSpan.nextSibling; if ( nextNode && nextNode.nodeType === 3 && /^\s/.test(nextNode.textContent || '') && !bgText.endsWith(' ') ) { bgText += ' '; } bgSyllables.push({ text: bgText, timestamp: timeToMs(bgSpan.getAttribute('begin')), endtime: timeToMs(bgSpan.getAttribute('end')), part: !/\s$/.test(bgText), }); } // eslint-disable-next-line no-continue continue; } if ( span.parentNode && (span.parentNode as Element).getAttribute?.('ttm:role') === 'x-bg' ) { // eslint-disable-next-line no-continue continue; } let text = span.textContent || ''; const nextNode = span.nextSibling; if ( nextNode && nextNode.nodeType === 3 && /^\s/.test(nextNode.textContent || '') && !text.endsWith(' ') ) { text += ' '; } mainSyllables.push({ text, timestamp: timeToMs(span.getAttribute('begin')), endtime: timeToMs(span.getAttribute('end')), part: !/\s$/.test(text), }); } } else { mainSyllables.push({ text: p.textContent?.trim() || '', timestamp: beginMs, endtime: endMs, part: false, lineSynced: true, }); } const alignment = alignments[i]; // Distribute line-level transliteration to individual syllables // so that per-syllable animated romanisation works (like KPoe lyrics) const lineTransliterationItem = key ? transliterations[key] : undefined; if ( lineTransliterationItem && mainSyllables.length > 1 && spans.length > 0 ) { if ( lineTransliterationItem.syllabus && lineTransliterationItem.syllabus.length === mainSyllables.length ) { mainSyllables.forEach((syl, mapIdx) => { // eslint-disable-next-line no-param-reassign syl.romanizedText = lineTransliterationItem.syllabus[mapIdx].text; }); } else { const lineTransliteration = lineTransliterationItem.text; const romanWords = lineTransliteration.split(/\s+/).filter(Boolean); const syllableGroups: number[][] = []; for (let si = 0; si < mainSyllables.length; si += 1) { if (mainSyllables[si].part && syllableGroups.length > 0) { syllableGroups[syllableGroups.length - 1].push(si); } else { syllableGroups.push([si]); } } const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test( mainSyllables.map(s => s.text).join(''), ); if (romanWords.length === syllableGroups.length) { syllableGroups.forEach((group, gi) => { // eslint-disable-next-line no-param-reassign mainSyllables[group[0]].romanizedText = romanWords[gi]; }); } else if (romanWords.length === mainSyllables.length) { mainSyllables.forEach((syl, mapIdx) => { // eslint-disable-next-line no-param-reassign syl.romanizedText = romanWords[mapIdx]; }); } else if (isCJK) { let romanIdx = 0; for (const group of syllableGroups) { const syl = mainSyllables[group[0]]; const sylText = group .map(gIndex => mainSyllables[gIndex].text) .join(''); const validChars = sylText.match( /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7afA-Za-z0-9]/g, ) || []; const needed = validChars.length; if (needed > 0 && romanIdx < romanWords.length) { // eslint-disable-next-line no-param-reassign syl.romanizedText = romanWords .slice(romanIdx, romanIdx + needed) .join(' '); romanIdx += needed; } } } } } lines.push({ text: mainSyllables, background: bgSyllables.length > 0, backgroundText: bgSyllables, timestamp: beginMs, endtime: endMs, isWordSynced: spans.length > 0, alignment, songPart, translation: key ? translations[key] : undefined, romanizedText: lineTransliterationItem?.text, oppositeTurn: alignment === 'end', }); } return { lines, songwriters }; } catch (e) { // eslint-disable-next-line no-console console.error('Failed to parse TTML', e); return null; } } private static convertKPoeLyrics(payload: any): LyricsLine[] | null { if (!payload) { return null; } let rawLyrics: any[] | null = null; if (Array.isArray(payload?.lyrics)) { rawLyrics = payload.lyrics; } else if (Array.isArray(payload?.data?.lyrics)) { rawLyrics = payload.data.lyrics; } else if (Array.isArray(payload?.data)) { rawLyrics = payload.data; } if (!rawLyrics || rawLyrics.length === 0) { return null; } const sanitizedEntries = rawLyrics.filter((item: any) => Boolean(item)); const lines: LyricsLine[] = []; // If type is 'Line', we revert to line-by-line highlighting by skipping syllabus parsing const isLineType = payload.type === 'Line' || payload.type === 'line'; // Convert metadata.agents to type map const agentTypes: Record = {}; if (payload.metadata?.agents) { Object.entries(payload.metadata.agents).forEach( ([key, agent]: [string, any]) => { const mappedKey = agent.alias || key; agentTypes[mappedKey] = agent.type; }, ); } const lineSingers = sanitizedEntries.map( (entry: any) => entry.element?.singer, ); const alignments = AmLyrics.calculateLineAlignments( lineSingers, agentTypes, ); for (let i = 0; i < sanitizedEntries.length; i += 1) { const entry = sanitizedEntries[i]; const start = AmLyrics.toMilliseconds(entry.time); const duration = AmLyrics.toMilliseconds(entry.duration); const alignment = alignments[i]; const lineText = typeof entry.text === 'string' ? entry.text : ''; const lineStart = AmLyrics.toMilliseconds(entry.time); const lineDuration = AmLyrics.toMilliseconds(entry.duration); const explicitEnd = AmLyrics.toMilliseconds(entry.endTime); const lineEnd = explicitEnd || lineStart + (lineDuration || 0); let syllabus = []; if (Array.isArray(entry.syllabus)) { syllabus = entry.syllabus.filter((s: any) => Boolean(s)); } else if (Array.isArray(entry.words)) { syllabus = entry.words.filter((s: any) => Boolean(s)); } const mainSyllables: Syllable[] = []; const backgroundSyllables: Syllable[] = []; if (!isLineType && syllabus.length > 0) { for (const syl of syllabus) { const sylStart = AmLyrics.toMilliseconds(syl.time, lineStart); const sylDuration = AmLyrics.toMilliseconds(syl.duration); // If there's only 1 syllable and duration is 0, it's likely a line-synced fallback. // Otherwise, it's an instantaneous boundary (like a space or comma) and should not span the line. const sylEnd = sylDuration === 0 && syllabus.length === 1 ? lineEnd : sylStart + sylDuration; const syllable: Syllable = { text: typeof syl.text === 'string' ? syl.text : '', part: Boolean(syl.part), timestamp: sylStart, endtime: sylEnd, }; if (syl.isBackground) { backgroundSyllables.push(syllable); } else { mainSyllables.push(syllable); } } } if (mainSyllables.length === 0 && lineText) { mainSyllables.push({ text: lineText, part: false, timestamp: lineStart, endtime: lineEnd || lineStart, lineSynced: isLineType, // Mark as line-synced }); } const hasWordSync = mainSyllables.length > 0 || backgroundSyllables.length > 0; const { transliteration } = entry; let romanizedTextFromPayload: string | undefined; if (transliteration) { romanizedTextFromPayload = transliteration.text; // If syllabus data matches, map it to main syllables if ( Array.isArray(transliteration.syllabus) && transliteration.syllabus.length === mainSyllables.length ) { transliteration.syllabus.forEach((s: any, idx: number) => { mainSyllables[idx].romanizedText = s.text; }); } } // Extract translation from KPoe API if available const translationText = entry.translation?.text; const lineResult: LyricsLine = { text: mainSyllables, background: backgroundSyllables.length > 0, backgroundText: backgroundSyllables, oppositeTurn: alignment === 'end' || (Array.isArray(entry.element) ? entry.element.includes('opposite') || entry.element.includes('right') : false), timestamp: lineStart, endtime: start + duration, isWordSynced: isLineType ? false : hasWordSync, alignment, songPart: entry.element?.songPart, romanizedText: romanizedTextFromPayload, translation: translationText, }; lines.push(lineResult); } return lines; } private static toMilliseconds(value: unknown, fallback = 0): number { const num = Number(value); if (!Number.isFinite(num) || Number.isNaN(num)) { return fallback; } if (!Number.isInteger(num)) { return Math.round(num * 1000); } return Math.max(0, Math.round(num)); } firstUpdated() { // Set up scroll event listener for user scroll detection // Use wheel/touchmove which are guaranteed to be user initiated, // unlike 'scroll' which fires for both user and programmatic/inertia if (this.lyricsContainer) { this.lyricsContainer.addEventListener( 'wheel', this._boundHandleUserScroll, { passive: true }, ); this.lyricsContainer.addEventListener( 'touchmove', this._boundHandleUserScroll, { passive: true }, ); } } /** * Handle currentTime changes imperatively, bypassing Lit's render cycle. * This prevents the template from re-rendering on every frame, which would * reset imperative animation classes (highlight, finished, etc.) set by * updateSyllablesForLine. */ private _onTimeChanged(oldTime: number, newTime: number): void { const timeDiff = Math.abs(newTime - oldTime); const isSeek = timeDiff > SEEK_THRESHOLD_MS; const newActiveLines = this.findActiveLineIndices(newTime); const oldActiveLines = this.activeLineIndices; // Reset animation if active lines change or if we skip time. const linesChanged = !AmLyrics.arraysEqual(newActiveLines, oldActiveLines); if (linesChanged || isSeek) { if (this.lyricsContainer) { // Remove .active and .bg-expanded immediately when a line drops. // All visual fading is handled by CSS transitions — no JS delays, // so overlapping lyrics never get stuck with multiple .active lines. for (const lineIndex of oldActiveLines) { if (!newActiveLines.includes(lineIndex)) { const lineElement = this._getLineElement(lineIndex); if (lineElement) { if (isSeek || this.isUserScrolling) { AmLyrics.unfinishSyllables(lineElement); } else { AmLyrics.finishSyllablesUpToTime(lineElement, newTime); } lineElement.classList.remove('active', 'bg-expanded'); if (lineElement.classList.contains('pre-active')) { lineElement.classList.remove('pre-active'); } const preIdx = this.preActiveLineElements.indexOf(lineElement); if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1); } } } // Add 'active' and 'bg-expanded' to newly active lines for (const lineIndex of newActiveLines) { if (!oldActiveLines.includes(lineIndex)) { const lineElement = this._getLineElement(lineIndex); if (lineElement) { lineElement.classList.add('active', 'bg-expanded'); lineElement.classList.remove('pre-active'); const preIdx = this.preActiveLineElements.indexOf(lineElement); if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1); } } } // Remove pre-active from lines that are now active (they no longer // need the unblur preview class) and from lines that dropped. for (const lineElement of this.preActiveLineElements) { const idx = AmLyrics.getLineIndexFromElement(lineElement); if ( idx === null || (!newActiveLines.includes(idx) && lineElement !== this.currentPrimaryActiveLine) ) { lineElement.classList.remove('pre-active'); } } this.preActiveLineElements = this.preActiveLineElements.filter(el => el.classList.contains('pre-active'), ); } this.startAnimationFromTime(newTime); } // Predictive scroll: run on every tick so we scroll *before* the next // line starts, matching YouLyPlus behaviour. this._handleActiveLineScroll(oldActiveLines, isSeek); this.clearPastLineHighlights(); if (this.lyricsContainer) { // Update syllables in active lines using cached elements for (const lineIndex of this.activeLineIndices) { const lineElement = this._getLineElement(lineIndex); if (lineElement) { AmLyrics.updateSyllablesForLine(lineElement, newTime); } } // Also update syllables in active gap lines (breathing dots) for (const gapLine of this.activeGapLineElements) { AmLyrics.updateSyllablesForLine(gapLine, newTime); } // Imperatively manage gap active state if (this.gapElementCache.size > 0) { for (const [, gap] of this.gapElementCache) { const gapStartTime = (gap as any)._cachedStartTime ?? parseFloat(gap.getAttribute('data-start-time') || '0'); const gapEndTime = (gap as any)._cachedEndTime ?? parseFloat(gap.getAttribute('data-end-time') || '0'); const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime; const isActive = gap.classList.contains('active'); const isExiting = gap.classList.contains('gap-exiting'); const exitLeadMs = GAP_EXIT_LEAD_MS; const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs; if (shouldBeActive && (!isActive || isSeek) && !isExiting) { gap.classList.remove('gap-exiting'); if (isSeek && isActive) { gap.classList.remove('active'); // eslint-disable-next-line no-void void (gap as HTMLElement).offsetWidth; // Force reflow } const gapDuration = gapEndTime - gapStartTime; const baseLoopDelay = AmLyrics.getGapLoopDelay(gapDuration); const totalDelay = baseLoopDelay + (newTime - gapStartTime); (gap as HTMLElement).style.setProperty( '--gap-loop-delay', `-${totalDelay}ms`, ); gap.classList.add('active'); if (!this.activeGapLineElements.includes(gap as HTMLElement)) { this.activeGapLineElements.push(gap as HTMLElement); } const dotSyllables = gap.querySelectorAll('.lyrics-syllable'); dotSyllables.forEach(dot => { const dotStart = parseFloat( dot.getAttribute('data-start-time') || '0', ); const dotEnd = parseFloat( dot.getAttribute('data-end-time') || '0', ); if (newTime > dotEnd) { dot.classList.add('finished'); if (!dot.classList.contains('highlight')) { AmLyrics.updateSyllableAnimation( dot as HTMLElement, newTime - dotStart, ); } } else if (newTime >= dotStart && newTime <= dotEnd) { AmLyrics.updateSyllableAnimation( dot as HTMLElement, newTime - dotStart, ); } }); } else if (shouldStartExiting) { // Cancel gap-loop first, force reflow, then start gap-ended // so the browser sees a clean animation swap gap.classList.remove('active'); // eslint-disable-next-line no-void void (gap as HTMLElement).offsetWidth; gap.classList.add('gap-exiting'); const gapIdx = this.activeGapLineElements.indexOf( gap as HTMLElement, ); if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1); setTimeout(() => { gap.classList.remove('gap-exiting'); }, GAP_EXIT_LEAD_MS); } else if (!shouldBeActive && (isActive || isExiting)) { gap.classList.remove('active'); gap.classList.remove('gap-exiting'); const gapIdx = this.activeGapLineElements.indexOf( gap as HTMLElement, ); if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1); } else if (isExiting && newTime < gapEndTime - exitLeadMs) { gap.classList.remove('gap-exiting'); } } } else if (this.lyricsContainer) { // Fallback: no cache yet, use querySelectorAll const allGaps = this.lyricsContainer.querySelectorAll('.lyrics-gap'); allGaps.forEach(gap => { const gapStartTime = parseFloat( gap.getAttribute('data-start-time') || '0', ); const gapEndTime = parseFloat( gap.getAttribute('data-end-time') || '0', ); const shouldBeActive = newTime >= gapStartTime && newTime < gapEndTime; const isActive = gap.classList.contains('active'); const isExiting = gap.classList.contains('gap-exiting'); const exitLeadMs = GAP_EXIT_LEAD_MS; const shouldStartExiting = isActive && !isExiting && newTime >= gapEndTime - exitLeadMs; if (shouldBeActive && (!isActive || isSeek) && !isExiting) { gap.classList.remove('gap-exiting'); if (isSeek && isActive) { gap.classList.remove('active'); // eslint-disable-next-line no-void void (gap as HTMLElement).offsetWidth; // Force reflow } const gapDuration = gapEndTime - gapStartTime; const baseLoopDelay = AmLyrics.getGapLoopDelay(gapDuration); const totalDelay = baseLoopDelay + (newTime - gapStartTime); (gap as HTMLElement).style.setProperty( '--gap-loop-delay', `-${totalDelay}ms`, ); gap.classList.add('active'); if (!this.activeGapLineElements.includes(gap as HTMLElement)) { this.activeGapLineElements.push(gap as HTMLElement); } } else if (shouldStartExiting) { // Cancel gap-loop first, force reflow, then start gap-ended gap.classList.remove('active'); // eslint-disable-next-line no-void void (gap as HTMLElement).offsetWidth; gap.classList.add('gap-exiting'); const gapIdx = this.activeGapLineElements.indexOf( gap as HTMLElement, ); if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1); setTimeout(() => { gap.classList.remove('gap-exiting'); }, GAP_EXIT_LEAD_MS); } else if (!shouldBeActive && (isActive || isExiting)) { gap.classList.remove('active'); gap.classList.remove('gap-exiting'); const gapIdx = this.activeGapLineElements.indexOf( gap as HTMLElement, ); if (gapIdx !== -1) this.activeGapLineElements.splice(gapIdx, 1); } else if (isExiting && newTime < gapEndTime - exitLeadMs) { gap.classList.remove('gap-exiting'); } }); } // Track instrumental gap state const currentGap = this.findInstrumentalGapAt(newTime); if (currentGap) { this.lastInstrumentalIndex = currentGap.insertBeforeIndex; // Un-highlight the previous line immediately when gap dots are playing if (currentGap.insertBeforeIndex > 0) { const prevLine = this._getLineElement( currentGap.insertBeforeIndex - 1, ); if ( prevLine && prevLine.classList.contains('persist-highlight') && !prevLine.classList.contains('active') ) { AmLyrics.unfinishSyllables(prevLine); } } } else if (this.lastInstrumentalIndex !== null) { this.lastInstrumentalIndex = null; } // Check footer active state const lastLyric = this.lyrics && this.lyrics.length > 0 ? this.lyrics[this.lyrics.length - 1] : null; const footer = this.lyricsContainer.querySelector( '.lyrics-footer', ) as HTMLElement; if (footer && lastLyric && lastLyric.endtime > 0) { const isFooterActive = newTime > lastLyric.endtime + 200; // Snappier 200ms buffer if (isFooterActive && !footer.classList.contains('active')) { footer.classList.add('active'); // Clear pre-active from the last lyric so it doesn't stay // unblurred when the footer takes over. const lastLine = this.lyricsContainer.querySelector( '.lyrics-line:last-of-type', ) as HTMLElement; if (lastLine) { lastLine.classList.remove('pre-active'); const preIdx = this.preActiveLineElements.indexOf(lastLine); if (preIdx !== -1) this.preActiveLineElements.splice(preIdx, 1); } if ( this.autoScroll && !this.isUserScrolling && !this.isClickSeeking ) { this.focusLine(footer); } } else if (!isFooterActive && footer.classList.contains('active')) { footer.classList.remove('active'); } } } } updated(changedProperties: Map) { if (changedProperties.has('lyrics')) { this._invalidateCaches(); this._ensureLineDataCache(); this._updateCachedIsUnsynced(); // Recalculate timing data for accurate animations whenever lyrics change this._updateCharTimingData(); // Apply 'active' classes imperatively after lyrics first render, // since the template no longer binds the 'active' class (to avoid // clobbering imperative scroll-animate classes on re-render). if (this.lyricsContainer && this.lyrics) { const activeLines = this.findActiveLineIndices(this.currentTime); for (const lineIndex of activeLines) { const lineEl = this._getLineElement(lineIndex); if (lineEl) lineEl.classList.add('active', 'bg-expanded'); } // Trigger a faux time-change so that updateSyllablesForLine fires // to setup inline syllable CSS wipe animations for whatever the current time is this._onTimeChanged(0, this.currentTime); // Ensure position classes are applied on initial render if not playing yet if (this.positionedLineElements.length === 0) { const firstLine = this.lyricsContainer.querySelector( '.lyrics-line', ) as HTMLElement; if (firstLine) this.updatePositionClasses(firstLine); } // Set up IntersectionObserver for viewport virtualization this.visibilityObserver?.disconnect(); this.visibilityObserver = new IntersectionObserver( entries => { entries.forEach(entry => { const el = entry.target as HTMLElement; el.classList.toggle('far-line', !entry.isIntersecting); }); }, { root: this.lyricsContainer, rootMargin: '200px', threshold: 0, }, ); const lines = this.lyricsContainer.querySelectorAll('.lyrics-line'); lines.forEach(line => this.visibilityObserver!.observe(line)); } } // Handle duration reset (-1 stops playback and resets currentTime to 0) if (changedProperties.has('duration') && this.duration === -1) { this.currentTime = 0; this.activeLineIndices = []; this.activeMainWordIndices.clear(); this.activeBackgroundWordIndices.clear(); this.mainWordProgress.clear(); this.backgroundWordProgress.clear(); this.mainWordAnimations.clear(); this.backgroundWordAnimations.clear(); this.preActiveLineElements = []; this.positionedLineElements = []; this.activeGapLineElements = []; this.setUserScrolling(false); // Cancel any running animations if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } // Clear user scroll timeout if (this.userScrollTimeoutId) { clearTimeout(this.userScrollTimeoutId); this.userScrollTimeoutId = undefined; } if (this.scrollUnlockTimeout) { clearTimeout(this.scrollUnlockTimeout); this.scrollUnlockTimeout = undefined; } if (this.scrollAnimationTimeout) { clearTimeout(this.scrollAnimationTimeout); this.scrollAnimationTimeout = undefined; } // Scroll to top if (this.lyricsContainer) { this.lyricsContainer.scrollTop = 0; } return; // Exit early, don't process other changes } if ( (changedProperties.has('query') || changedProperties.has('musicId') || changedProperties.has('isrc') || changedProperties.has('ttml') || changedProperties.has('songTitle') || changedProperties.has('songArtist') || changedProperties.has('songAlbum') || changedProperties.has('songDurationMs')) && !changedProperties.has('currentTime') ) { this.fetchLyrics(); } if (changedProperties.has('currentTime') && this.lyrics) { // currentTime changes are now handled by the custom setter (_onTimeChanged) // This block intentionally left empty — only here for backwards compat with // any subclasses that might check changedProperties } } /** * Handle scrolling when active line indices change. * Called imperatively from _onTimeChanged instead of from updated(). * * Uses predictive scroll like YouLyPlus: computes a scrollLookAheadMs based * on the gap to the next line, finds the primary line at predictiveTime, * and scrolls with a duration matching the lookahead. */ private _handleActiveLineScroll( _oldActiveIndices: number[], forceScroll = false, ): void { if (!this.lyricsContainer || !this.lyrics || this.lyrics.length === 0) { return; } // If the footer is already active, it set up its own scroll. // Don't override it with a scroll back to the last lyric. const footer = this.lyricsContainer.querySelector('.lyrics-footer'); if (footer?.classList.contains('active')) { return; } // 1. Compute scroll lookahead based on gap to next line (YouLyPlus style) let scrollLookAheadMs = 350; let currentAudioIndex = -1; for (let i = 0; i < this.lyrics.length; i += 1) { if (this.lyrics[i].timestamp > this.currentTime) { currentAudioIndex = i - 1; break; } } if (currentAudioIndex === -1 && this.lyrics.length > 0) { if (this.currentTime >= this.lyrics[this.lyrics.length - 1].timestamp) { currentAudioIndex = this.lyrics.length - 1; } } if ( currentAudioIndex !== -1 && currentAudioIndex + 1 < this.lyrics.length ) { const currentLine = this.lyrics[currentAudioIndex]; const nextLine = this.lyrics[currentAudioIndex + 1]; const gap = nextLine.timestamp - currentLine.endtime; scrollLookAheadMs = Math.min(500, Math.max(350, gap)); } // 2. Find scroll target at predictive time const predictiveTime = this.currentTime + scrollLookAheadMs; const predictiveActiveIndices = this.findActiveLineIndices(predictiveTime); let targetElement: HTMLElement | null = null; if (predictiveActiveIndices.length > 0) { const targetLineIdx = this.getPrimaryScrollLineIndex( predictiveActiveIndices, predictiveTime, ); if (targetLineIdx !== null && targetLineIdx !== -1) { targetElement = this._getLineElement(targetLineIdx); } } if (!targetElement) { // Fallback: closest line before predictiveTime const targetLineIdx = this.getLineIndexAtTime(predictiveTime, 0); if (targetLineIdx !== null && targetLineIdx !== -1) { targetElement = this._getLineElement(targetLineIdx); } } if (!targetElement) return; // Unblur the upcoming target line early (pre-active) so background // vocals start their max-height/opacity transition in sync with scroll. if (!targetElement.classList.contains('active')) { targetElement.classList.add('pre-active'); if (!this.preActiveLineElements.includes(targetElement)) { this.preActiveLineElements.push(targetElement); } } const scrollDuration = scrollLookAheadMs; this.focusLine(targetElement, forceScroll, scrollDuration); } private _textWidthCanvas: HTMLCanvasElement | undefined; private _textWidthCtx: CanvasRenderingContext2D | null | undefined; private _getTextWidth(text: string, font: string): number { if (!this._textWidthCanvas) { this._textWidthCanvas = document.createElement('canvas'); this._textWidthCtx = this._textWidthCanvas.getContext('2d', { willReadFrequently: true, }); } if (this._textWidthCtx) { this._textWidthCtx.font = font; return this._textWidthCtx.measureText(text).width; } return 0; } private _rebuildDomCache() { if (!this.lyricsContainer) return; this.lineElementCache.clear(); this.gapElementCache.clear(); this.cachedLineArray = []; if (!this.lyrics) return; for (let i = 0; i < this.lyrics.length; i += 1) { const lineEl = this.lyricsContainer.querySelector( `#lyrics-line-${i}`, ) as HTMLElement | null; if (lineEl) this.lineElementCache.set(i, lineEl); const gapEl = this.lyricsContainer.querySelector( `#gap-${i}`, ) as HTMLElement | null; if (gapEl) { // Cache numeric timing values to avoid parseFloat on every frame (gapEl as any)._cachedStartTime = parseFloat( gapEl.getAttribute('data-start-time') || '0', ); (gapEl as any)._cachedEndTime = parseFloat( gapEl.getAttribute('data-end-time') || '0', ); this.gapElementCache.set(i, gapEl); } } // Rebuild cached line array for scroll/position queries const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line'); this.cachedLineArray = Array.from(lineElements) as HTMLElement[]; } private _getLineElement(index: number): HTMLElement | null { const cached = this.lineElementCache.get(index); if (cached) return cached; if (!this.lyricsContainer) return null; const el = this.lyricsContainer.querySelector( `#lyrics-line-${index}`, ) as HTMLElement | null; if (el) this.lineElementCache.set(index, el); return el; } private _getGapElement(index: number): HTMLElement | null { const cached = this.gapElementCache.get(index); if (cached) return cached; if (!this.lyricsContainer) return null; const el = this.lyricsContainer.querySelector( `#gap-${index}`, ) as HTMLElement | null; if (el) this.gapElementCache.set(index, el); return el; } private _invalidateCaches() { this.cachedAllGaps = []; this.cachedIsUnsynced = false; this.cachedLineData = null; this.lineElementCache.clear(); this.gapElementCache.clear(); this.cachedLineArray = []; this.cachedScrollPaddingTop = null; this.preActiveLineElements = []; this.positionedLineElements = []; this.activeGapLineElements = []; this.visibilityObserver?.disconnect(); this.visibilityObserver = undefined; } private _updateCachedIsUnsynced() { this.cachedIsUnsynced = this.lyrics && this.lyrics.length > 0 ? this.lyrics.every(l => l.timestamp === 0 && l.endtime === 0) : false; } private _ensureLineDataCache() { if (this.cachedLineData || !this.lyrics) return; this.cachedLineData = this.lyrics.map(line => { const wordGroups: Syllable[][] = []; let currentGroupBuffer: Syllable[] = []; line.text.forEach((syllable, idx) => { currentGroupBuffer.push(syllable); const nextSyllable = line.text[idx + 1]; const endsWithDelimiter = !nextSyllable || syllable.part === false || /\s$/.test(syllable.text) || (nextSyllable && (syllable as any).isBackground !== (nextSyllable as any).isBackground); if (endsWithDelimiter) { wordGroups.push(currentGroupBuffer); currentGroupBuffer = []; } }); if (currentGroupBuffer.length > 0) { wordGroups.push(currentGroupBuffer); } const groupGrowable: boolean[] = new Array(wordGroups.length).fill(false); const groupGlowing: boolean[] = new Array(wordGroups.length).fill(false); const groupCharRise: boolean[] = new Array(wordGroups.length).fill(false); const vwFullText: string[] = new Array(wordGroups.length).fill(''); const vwFullDuration: number[] = new Array(wordGroups.length).fill(0); const vwCharOffset: number[] = new Array(wordGroups.length).fill(0); const vwStartMs: number[] = new Array(wordGroups.length).fill(0); const vwEndMs: number[] = new Array(wordGroups.length).fill(0); let lineIsRTL = false; let vwStart = 0; while (vwStart < wordGroups.length) { let vwEnd = vwStart; while (vwEnd < wordGroups.length - 1) { const grp = wordGroups[vwEnd]; const lastText = grp[grp.length - 1].text; if (/\s$/.test(lastText)) break; vwEnd += 1; } const combinedText = wordGroups .slice(vwStart, vwEnd + 1) .flatMap(g => g.map(s => s.text)) .join('') .trim(); const combinedStart = wordGroups[vwStart][0].timestamp; const lastGrp = wordGroups[vwEnd]; const combinedEnd = lastGrp[lastGrp.length - 1].endtime; const combinedDuration = combinedEnd - combinedStart; const isCJK = /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test( combinedText, ); const isRTL = /[\u0600-\u06FF\u0750-\u077F\u08A0-\u08FF\u0590-\u05FF]/.test( combinedText, ); if (isRTL) lineIsRTL = true; const hasHyphen = combinedText.includes('-'); const wordLen = combinedText.length; const canAnimateByChar = !isCJK && !isRTL && !hasHyphen && wordLen > 0; const isLineSynced = line.isWordSynced === false || line.text.some(s => s.lineSynced); let isGrowableVW = canAnimateByChar && wordLen > 0 && wordLen <= 7; if (isGrowableVW) { if (wordLen < 3) { isGrowableVW = combinedDuration >= 1050 && combinedDuration >= wordLen * 525; } else { isGrowableVW = combinedDuration >= 850 && combinedDuration >= wordLen * 190; } } const hasCharRiseDuration = combinedDuration >= Math.max(700, wordLen * 85); const hasLongShortWordDuration = wordLen >= 4 && combinedDuration >= Math.max(1300, wordLen * 260); const isCharRiseVW = canAnimateByChar && !isLineSynced && !isGrowableVW && ((wordLen >= 8 && hasCharRiseDuration) || (wordLen < 8 && hasLongShortWordDuration)); const isGlowingVW = isGrowableVW && !isLineSynced; let charOff = 0; for (let gi = vwStart; gi <= vwEnd; gi += 1) { groupGrowable[gi] = isGrowableVW; groupGlowing[gi] = isGlowingVW; groupCharRise[gi] = isCharRiseVW; vwFullText[gi] = combinedText; vwFullDuration[gi] = combinedDuration; vwCharOffset[gi] = charOff; vwStartMs[gi] = combinedStart; vwEndMs[gi] = combinedEnd; const grpText = wordGroups[gi].map(s => s.text).join(''); charOff += grpText.replace(/\s/g, '').length; } vwStart = vwEnd + 1; } return { wordGroups, groupGrowable, groupGlowing, groupCharRise, vwFullText, vwFullDuration, vwCharOffset, vwStartMs, vwEndMs, lineIsRTL, }; }); } private _updateCharTimingData() { if (!this.shadowRoot) return; this._rebuildDomCache(); // Get the computed font from the first syllable to ensure accuracy const referenceSyllable = this.shadowRoot.querySelector('.lyrics-syllable'); if (!referenceSyllable) return; const computedStyle = getComputedStyle(referenceSyllable); const { font } = computedStyle; // Full font string const fontSize = parseFloat(computedStyle.fontSize); const charTimedWords = this.shadowRoot.querySelectorAll( '.lyrics-word.growable, .lyrics-word.char-rise', ); if (!charTimedWords) return; charTimedWords.forEach((wordSpan: any) => { const syllableWraps = wordSpan.querySelectorAll('.lyrics-syllable-wrap'); // Flatten syllables const syllables: HTMLElement[] = []; syllableWraps.forEach((wrap: HTMLElement) => { const syl = wrap.querySelector('.lyrics-syllable'); if (syl) syllables.push(syl as HTMLElement); }); syllables.forEach(sylSpan => { const charSpans = sylSpan.querySelectorAll('.char'); if (charSpans.length === 0) return; // Logic from YouLyPlus renderCharWipes: // Use textContent from spans to ensure we measure what is rendered const chars = Array.from(charSpans).map(span => span.textContent || ''); const charWidths = chars.map(c => this._getTextWidth(c, font)); const totalSyllableWidth = charWidths.reduce((a, b) => a + b, 0); const duration = parseFloat(sylSpan.dataset.duration || '0'); const velocityPxPerMs = duration > 0 ? totalSyllableWidth / duration : 0; // Gradient width in pixels = 0.375 * fontSize // This matches YouLyPlus visual gradient size const gradientWidthPx = 0.375 * fontSize; const gradientDurationMs = velocityPxPerMs > 0 ? gradientWidthPx / velocityPxPerMs : 100; let cumulativeCharWidth = 0; charSpans.forEach((spanArg: any, i: number) => { const charWidth = charWidths[i]; const span = spanArg; if (totalSyllableWidth > 0) { const startPercent = cumulativeCharWidth / totalSyllableWidth; const durationPercent = charWidth / totalSyllableWidth; span.dataset.wipeStart = startPercent.toFixed(4); span.dataset.wipeDuration = durationPercent.toFixed(4); // The critical missing piece: span.dataset.preWipeArrival = (duration * startPercent).toFixed(2); span.dataset.preWipeDuration = gradientDurationMs.toFixed(2); } cumulativeCharWidth += charWidth; }); }); }); } private static arraysEqual(a: number[], b: number[]): boolean { return a.length === b.length && a.every((val, i) => val === b[i]); } private static getLineIndexFromElement( lineElement: HTMLElement | null, ): number | null { if (!lineElement) return null; const match = lineElement.id.match(/^lyrics-line-(\d+)$/); return match ? parseInt(match[1], 10) : null; } private static getGapLoopDelay(gapDuration: number): number { const desiredPhase = GAP_PULSE_DURATION_MS; const targetTime = gapDuration - GAP_EXIT_LEAD_MS; const normalizedTarget = ((targetTime % GAP_PULSE_CYCLE_MS) + GAP_PULSE_CYCLE_MS) % GAP_PULSE_CYCLE_MS; return ( (((desiredPhase - normalizedTarget) % GAP_PULSE_CYCLE_MS) + GAP_PULSE_CYCLE_MS) % GAP_PULSE_CYCLE_MS ); } private clearPreActiveClasses(exceptLineIndex: number | null = null): void { if (!this.lyricsContainer) return; const keptLines: HTMLElement[] = []; for (const lineElement of this.preActiveLineElements) { const lineIndex = AmLyrics.getLineIndexFromElement(lineElement); if (lineIndex === exceptLineIndex) { keptLines.push(lineElement); } else { lineElement.classList.remove('pre-active'); } } this.preActiveLineElements = keptLines; } private getPrimaryActiveLineIndex(activeIndices: number[]): number | null { if (activeIndices.length === 0) return null; const groupStart = activeIndices[0]; const groupEnd = activeIndices[activeIndices.length - 1]; let candidateIndex = Math.max(groupStart, groupEnd - 2); const currentPrimaryIndex = AmLyrics.getLineIndexFromElement( this.currentPrimaryActiveLine, ); if ( currentPrimaryIndex !== null && activeIndices.includes(currentPrimaryIndex) ) { if (activeIndices.length <= 3) { candidateIndex = currentPrimaryIndex; } else if (candidateIndex < currentPrimaryIndex) { candidateIndex = currentPrimaryIndex; } } return candidateIndex; } private getPrimaryScrollLineIndex( _activeIndices: number[], time: number, ): number | null { if (!this.lyrics || this.lyrics.length === 0) return null; // YouLyPlus-style: primary is simply the line at predictive time. const primaryIndex = this.getLineIndexAtTime(time, this.lastActiveIndex); if (primaryIndex === -1) return null; // Guard: if new primary is ahead of current but they share the same // end time, keep current to prevent bounce during overlaps. const currentPrimaryIndex = AmLyrics.getLineIndexFromElement( this.currentPrimaryActiveLine, ); if ( currentPrimaryIndex !== null && primaryIndex > currentPrimaryIndex && this.lyrics[currentPrimaryIndex] && this.lyrics[primaryIndex] && this.lyrics[currentPrimaryIndex].endtime === this.lyrics[primaryIndex].endtime ) { const activeCount = this.findActiveLineIndices(time).length; if (activeCount <= 3) { return currentPrimaryIndex; } } return primaryIndex; } private getOverlapClusterForActiveIndices( activeIndices: number[], time: number, ): { start: number; end: number; startedEnd: number; startedEndTime: number; } | null { if (!this.lyrics || activeIndices.length === 0) return null; let start = activeIndices[0]; while ( start > 0 && this.lyrics[start - 1].endtime >= this.lyrics[start].timestamp ) { start -= 1; } let end = start; let clusterEndTime = this.lyrics[start].endtime; while ( end + 1 < this.lyrics.length && this.lyrics[end + 1].timestamp <= clusterEndTime ) { end += 1; clusterEndTime = Math.max(clusterEndTime, this.lyrics[end].endtime); } let startedEnd = start; let startedEndTime = this.lyrics[start].endtime; for (let i = start; i <= end; i += 1) { if (this.lyrics[i].timestamp <= time) { startedEnd = i; startedEndTime = Math.max(startedEndTime, this.lyrics[i].endtime); } else { break; } } return { start, end, startedEnd, startedEndTime }; } private focusLine( lineElement: HTMLElement, forceScroll = false, scrollDuration: number | undefined = undefined, skipScroll = false, preservePrimary = false, ): void { const primaryChanged = lineElement !== this.currentPrimaryActiveLine; if (primaryChanged && !preservePrimary) { // .active is now managed solely by findActiveLineIndices (which uses // effectiveEndTimes). Lines stay active until their extended end, // so we no longer need to remove .active here. this.lastPrimaryActiveLine = this.currentPrimaryActiveLine; this.currentPrimaryActiveLine = lineElement; const lineIndex = AmLyrics.getLineIndexFromElement(lineElement); if (lineIndex !== null) { this.lastActiveIndex = lineIndex; } } // Only update blur/opacity position classes when the primary line // actually changes (or on force scroll). Running this every tick // causes visual churn and upward glitches. if (primaryChanged || forceScroll) { this.updatePositionClasses(lineElement); } if ( !skipScroll && (forceScroll || primaryChanged || preservePrimary) && this.autoScroll && !this.isUserScrolling && !this.isClickSeeking ) { this.scrollToActiveLineYouLy(lineElement, forceScroll, scrollDuration); } } private setUserScrolling(value: boolean) { this.isUserScrolling = value; if (value) { this.lyricsContainer?.classList.add('user-scrolling'); } else { this.lyricsContainer?.classList.remove('user-scrolling'); } } private handleUserScroll() { // Ignore programmatic scrolls and click-seek scrolls if (this.isProgrammaticScroll || this.isClickSeeking) { return; } // Mark that user is currently scrolling this.setUserScrolling(true); this.clearPastLineHighlights(); // Clear any existing timeout if (this.userScrollTimeoutId) { clearTimeout(this.userScrollTimeoutId); } // Set timeout to re-enable auto-scroll after 2 seconds of no scrolling this.userScrollTimeoutId = window.setTimeout(() => { this.setUserScrolling(false); this.userScrollTimeoutId = undefined; // Optionally scroll back to current active line when re-enabling auto-scroll if (this.activeLineIndices.length > 0) { this._handleActiveLineScroll([], false); } }, 2000); } private clearPastLineHighlights() { if (!this.lyricsContainer) return; const lineElements = this.cachedLineArray.length ? this.cachedLineArray : (Array.from( this.lyricsContainer.querySelectorAll( '.lyrics-line:not(.lyrics-gap)', ), ) as HTMLElement[]); const containerRect = this.lyricsContainer.getBoundingClientRect(); const anchorY = containerRect.top + this.getScrollPaddingTop(); for (let i = 0; i < lineElements.length; i += 1) { const lineElement = lineElements[i]; const isActive = lineElement.classList.contains('active'); const lineRect = lineElement.getBoundingClientRect(); const hasScrolledPast = lineRect.bottom < anchorY - 2; if (!isActive && hasScrolledPast) { AmLyrics.unfinishSyllables(lineElement); } } } /** * Find the first (lowest-index) line whose raw time range contains `timeMs`. * Uses a stable forward scan so overlapping ranges always return the same * line, preventing primary-target jitter that causes scroll glitches. */ private getLineIndexAtTime(timeMs: number, startHintIndex = 0): number { if (!this.lyrics || this.lyrics.length === 0) return -1; const len = this.lyrics.length; // 1. Check hint and immediate neighbours first (fast path) const hint = Math.max(0, Math.min(startHintIndex, len - 1)); for (let i = hint; i < len; i += 1) { const line = this.lyrics[i]; if (line.timestamp > timeMs) break; if (timeMs >= line.timestamp && timeMs < line.endtime) { return i; } } for (let i = hint - 1; i >= 0; i -= 1) { const line = this.lyrics[i]; if (timeMs >= line.timestamp && timeMs < line.endtime) { return i; } if (line.endtime < timeMs) break; } // 2. Full forward scan — guaranteed deterministic for overlaps for (let i = 0; i < len; i += 1) { const line = this.lyrics[i]; if (line.timestamp > timeMs) break; if (timeMs >= line.timestamp && timeMs < line.endtime) { return i; } } return -1; } private findActiveLineIndices(time: number): number[] { if (!this.lyrics || this.lyrics.length === 0) return []; const activeLines: number[] = []; for (let i = 0; i < this.lyrics.length; i += 1) { const line = this.lyrics[i]; if (line.timestamp > time) break; if (time >= line.timestamp && time < line.endtime) { activeLines.push(i); } } return activeLines; } private findInstrumentalGapAt( time: number, ): { insertBeforeIndex: number; gapStart: number; gapEnd: number } | null { if (!this.lyrics || this.lyrics.length === 0) return null; // Start-of-song gap: from 0 to first line timestamp const first = this.lyrics[0]; if (time >= 0 && time < first.timestamp) { const gapStart = 0; const gapEnd = first.timestamp; if (gapEnd - gapStart >= INSTRUMENTAL_THRESHOLD_MS) { return { insertBeforeIndex: 0, gapStart, gapEnd }; } return null; } // Find consecutive pair (i, i+1) that bounds the current time for (let i = 0; i < this.lyrics.length - 1; i += 1) { const curr = this.lyrics[i]; const next = this.lyrics[i + 1]; const gapStart = curr.endtime; const gapEnd = next.timestamp; if (time > gapStart && time < gapEnd) { if (gapEnd - gapStart >= INSTRUMENTAL_THRESHOLD_MS) { return { insertBeforeIndex: i + 1, gapStart, gapEnd }; } return null; } } return null; } /** * Find ALL instrumental gaps in the song, regardless of current time. * Used by the template to always render gap elements in the DOM. */ private findAllInstrumentalGaps(): Array<{ insertBeforeIndex: number; gapStart: number; gapEnd: number; }> { if (this.cachedAllGaps.length > 0) return this.cachedAllGaps; if (!this.lyrics || this.lyrics.length === 0) return []; const gaps: Array<{ insertBeforeIndex: number; gapStart: number; gapEnd: number; }> = []; // Start-of-song gap const first = this.lyrics[0]; if (first.timestamp >= INSTRUMENTAL_THRESHOLD_MS) { gaps.push({ insertBeforeIndex: 0, gapStart: 0, gapEnd: first.timestamp }); } // Inter-line gaps for (let i = 0; i < this.lyrics.length - 1; i += 1) { const curr = this.lyrics[i]; const next = this.lyrics[i + 1]; const gapStart = curr.endtime; const gapEnd = next.timestamp; if (gapEnd - gapStart >= INSTRUMENTAL_THRESHOLD_MS) { gaps.push({ insertBeforeIndex: i + 1, gapStart, gapEnd }); } } this.cachedAllGaps = gaps; return gaps; } private startAnimationFromTime(time: number) { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } if (!this.lyrics) return; const activeLineIndices = this.findActiveLineIndices(time); if (!AmLyrics.arraysEqual(activeLineIndices, this.activeLineIndices)) { this.activeLineIndices = activeLineIndices; } // Clear previous state this.activeMainWordIndices.clear(); this.activeBackgroundWordIndices.clear(); this.mainWordAnimations.clear(); this.backgroundWordAnimations.clear(); this.mainWordProgress.clear(); this.backgroundWordProgress.clear(); if (activeLineIndices.length === 0) { return; } // Set up animations for each active line for (const lineIndex of activeLineIndices) { const line = this.lyrics[lineIndex]; // Find main word based on the reset time let mainWordIdx = -1; for (let i = 0; i < line.text.length; i += 1) { if (time >= line.text[i].timestamp && time <= line.text[i].endtime) { mainWordIdx = i; break; } } this.activeMainWordIndices.set(lineIndex, mainWordIdx); // Find background word based on the reset time let backWordIdx = -1; if (line.backgroundText) { for (let i = 0; i < line.backgroundText.length; i += 1) { if ( time >= line.backgroundText[i].timestamp && time <= line.backgroundText[i].endtime ) { backWordIdx = i; break; } } } this.activeBackgroundWordIndices.set(lineIndex, backWordIdx); } // With the state correctly set, configure the animation parameters this.setupAnimations(); // Start the animation loop if (this.interpolate) { this.animateProgress(); } } private updateActiveLineAndWords() { if (!this.lyrics) return; const activeLineIndices = this.findActiveLineIndices(this.currentTime); if (!AmLyrics.arraysEqual(activeLineIndices, this.activeLineIndices)) { this.activeLineIndices = activeLineIndices; } // Clear previous state this.activeMainWordIndices.clear(); this.activeBackgroundWordIndices.clear(); for (const lineIdx of activeLineIndices) { const line = this.lyrics[lineIdx]; let mainWordIdx = -1; for (let i = 0; i < line.text.length; i += 1) { if ( this.currentTime >= line.text[i].timestamp && this.currentTime <= line.text[i].endtime ) { mainWordIdx = i; break; } } this.activeMainWordIndices.set(lineIdx, mainWordIdx); let backWordIdx = -1; if (line.backgroundText) { for (let i = 0; i < line.backgroundText.length; i += 1) { if ( this.currentTime >= line.backgroundText[i].timestamp && this.currentTime <= line.backgroundText[i].endtime ) { backWordIdx = i; break; } } } this.activeBackgroundWordIndices.set(lineIdx, backWordIdx); } } private setupAnimations() { if (this.activeLineIndices.length === 0 || !this.lyrics) { this.mainWordAnimations.clear(); this.backgroundWordAnimations.clear(); return; } for (const lineIndex of this.activeLineIndices) { const line = this.lyrics[lineIndex]; const mainWordIndex = this.activeMainWordIndices.get(lineIndex) ?? -1; const backgroundWordIndex = this.activeBackgroundWordIndices.get(lineIndex) ?? -1; // Main word animation if (mainWordIndex !== -1) { const word = line.text[mainWordIndex]; const wordDuration = word.endtime - word.timestamp; const elapsedInWord = this.currentTime - word.timestamp; this.mainWordAnimations.set(lineIndex, { startTime: performance.now() - elapsedInWord, duration: wordDuration, }); } else { this.mainWordAnimations.set(lineIndex, { startTime: 0, duration: 0 }); } // Background word animation if (backgroundWordIndex !== -1 && line.backgroundText) { const word = line.backgroundText[backgroundWordIndex]; const wordDuration = word.endtime - word.timestamp; const elapsedInWord = this.currentTime - word.timestamp; this.backgroundWordAnimations.set(lineIndex, { startTime: performance.now() - elapsedInWord, duration: wordDuration, }); } else { this.backgroundWordAnimations.set(lineIndex, { startTime: 0, duration: 0, }); } } } private handleLineClick(line: LyricsLine) { // Reset all syllables to prevent highlighting conflicts during seek if (this.lyricsContainer) { const allLines = this.lyricsContainer.querySelectorAll('.lyrics-line'); allLines.forEach(lineEl => { AmLyrics.resetSyllables(lineEl as HTMLElement); // Remove scroll-animate class and properties to stop any scroll animations lineEl.classList.remove('scroll-animate'); (lineEl as HTMLElement).style.removeProperty('--scroll-delta'); (lineEl as HTMLElement).style.removeProperty('--lyrics-line-delay'); }); // Ensure container state is clean this.lyricsContainer.classList.remove('wheel-scrolling'); } // Cancel any ongoing scroll animations if (this.scrollAnimationState) { this.scrollAnimationState.isAnimating = false; this.scrollAnimationState.pendingUpdate = null; } // Clear scroll animation timeouts if (this.scrollAnimationTimeout) { clearTimeout(this.scrollAnimationTimeout); this.scrollAnimationTimeout = undefined; } // Also clear user scroll timeout to prevent stale scrollToActiveLine if (this.userScrollTimeoutId) { clearTimeout(this.userScrollTimeoutId); this.userScrollTimeoutId = undefined; } this.setUserScrolling(false); // Reset active line tracking to prevent scroll fighting this.currentPrimaryActiveLine = null; this.lastPrimaryActiveLine = null; this.activeLineIds.clear(); this.animatingLines = []; // Find the clicked line element and scroll to it with forceScroll (like YouLyPlus) // Timestamps are already in milliseconds — match the data-start-time attribute directly const clickedLineElement = this.lyricsContainer?.querySelector( `.lyrics-line[data-start-time="${line.text[0]?.timestamp || 0}"]`, ) as HTMLElement | null; if (clickedLineElement && this.lyricsContainer) { // Update active line reference to the clicked line this.currentPrimaryActiveLine = clickedLineElement; // Reset currentScrollOffset to actual scroll position to prevent stale delta this.currentScrollOffset = -this.lyricsContainer.scrollTop; // Set click-seek cooldown to prevent updated() scroll from fighting this.isClickSeeking = true; if (this.clickSeekTimeout) clearTimeout(this.clickSeekTimeout); this.clickSeekTimeout = setTimeout(() => { this.isClickSeeking = false; }, 800); this.scrollToActiveLineYouLy(clickedLineElement, true); } const event = new CustomEvent('line-click', { detail: { timestamp: line.timestamp, }, bubbles: true, composed: true, }); this.dispatchEvent(event); } private static getBackgroundTextPlacement( line: LyricsLine, ): 'before' | 'after' { if ( !line.backgroundText || line.backgroundText.length === 0 || line.text.length === 0 ) { return 'after'; // Default to after if no comparison is possible } // Compare the start times of the first syllables const mainTextStartTime = line.text[0].timestamp; const backgroundTextStartTime = line.backgroundText[0].timestamp; return backgroundTextStartTime < mainTextStartTime ? 'before' : 'after'; } private scrollToActiveLine() { if (!this.lyricsContainer || this.activeLineIndices.length === 0) { return; } // Scroll to the first active line const firstActiveLineIndex = Math.min(...this.activeLineIndices); const activeLineElement = this.lyricsContainer.querySelector( `.lyrics-line:nth-child(${firstActiveLineIndex + 1})`, ) as HTMLElement; if (activeLineElement) { const containerHeight = this.lyricsContainer.clientHeight; const lineTop = activeLineElement.offsetTop; const lineHeight = activeLineElement.clientHeight; // Check if the line has background text placed before the main text const hasBackgroundBefore = activeLineElement.querySelector( '.background-text.before', ); // Calculate the offset to center the main text content, accounting for background text placement let offsetAdjustment = 0; if (hasBackgroundBefore) { const backgroundElement = hasBackgroundBefore as HTMLElement; offsetAdjustment = backgroundElement.clientHeight / 2; // Adjust to focus on main content } const top = lineTop - containerHeight / 2 + lineHeight / 2 - offsetAdjustment; // Use requestAnimationFrame for smoother iOS performance requestAnimationFrame(() => { this.isProgrammaticScroll = true; this.lyricsContainer?.scrollTo({ top, behavior: 'smooth' }); // Reset the flag after a short delay to allow the scroll to complete setTimeout(() => { this.isProgrammaticScroll = false; }, 100); }); } } private scrollToInstrumental(insertBeforeIndex: number) { if (!this.lyricsContainer) return; // Find the gap element by ID instead of nth-child const gapTarget = this.lyricsContainer.querySelector( `#gap-${insertBeforeIndex}`, ) as HTMLElement | null; if (gapTarget) { // Use same scroll position as lyrics (scroll-padding-top from top), not center // This matches YouLyPlus behavior where gaps don't scroll to a different position const paddingTop = this.getScrollPaddingTop(); const targetTranslateY = paddingTop - gapTarget.offsetTop; this.isProgrammaticScroll = true; this.clearPastLineHighlights(); this.animateScrollYouLy(targetTranslateY, false); setTimeout(() => { this.isProgrammaticScroll = false; }, 250); } } // === YouLyPlus-style Animation Methods === /** * Get the scroll padding top value from CSS variable */ private getScrollPaddingTop(): number { if (this.cachedScrollPaddingTop !== null) return this.cachedScrollPaddingTop; if (!this.lyricsContainer) return 0; const style = getComputedStyle(this); const paddingTopValue = style.getPropertyValue('--lyrics-scroll-padding-top') || '25%'; let result: number; if (paddingTopValue.includes('%')) { result = this.lyricsContainer.clientHeight * (parseFloat(paddingTopValue) / 100); } else { result = parseFloat(paddingTopValue) || 0; } this.cachedScrollPaddingTop = result; return result; } /** * Animate scroll with staggered delay for smooth YouLyPlus-style scrolling */ private animateScrollYouLy( newTranslateY: number, forceScroll = false, scrollDuration: number | undefined = undefined, ): void { if (!this.lyricsContainer) return; const parent = this.lyricsContainer; const targetTop = Math.max(0, -newTranslateY); if (!this.scrollAnimationState) { this.scrollAnimationState = { isAnimating: false, pendingUpdate: null, }; this.animatingLines = []; } const animState = this.scrollAnimationState; if (animState.isAnimating && !forceScroll) { const pendingTop = animState.pendingUpdate === null ? null : Math.max(0, -animState.pendingUpdate); if ( Math.abs(parent.scrollTop - targetTop) < 2 || (pendingTop !== null && Math.abs(pendingTop - targetTop) < 2) ) { return; } animState.pendingUpdate = newTranslateY; return; } if (this.scrollAnimationTimeout) { clearTimeout(this.scrollAnimationTimeout); this.scrollAnimationTimeout = undefined; } if (this.scrollUnlockTimeout) { clearTimeout(this.scrollUnlockTimeout); this.scrollUnlockTimeout = undefined; } const { animatingLines } = this; const appliedTranslateY = -targetTop; const prevOffset = -parent.scrollTop; const delta = prevOffset - appliedTranslateY; this.currentScrollOffset = appliedTranslateY; // Skip animation if already at the target position (e.g., first lines at top) if (Math.abs(parent.scrollTop - targetTop) < 1 && Math.abs(delta) < 1) { animState.isAnimating = false; animState.pendingUpdate = null; return; } if (forceScroll) { // Clean up any lingering scroll animations before smooth scroll for (const line of animatingLines) { line.classList.remove('scroll-animate'); line.style.removeProperty('--scroll-delta'); line.style.removeProperty('--lyrics-line-delay'); line.style.removeProperty('--scroll-duration'); } animatingLines.length = 0; parent.scrollTo({ top: targetTop, behavior: 'smooth' }); animState.isAnimating = false; animState.pendingUpdate = null; return; } // --- Step 1: Remove scroll-animate and custom properties from ALL // previously animating lines so stale deltas don't interfere. --- for (const line of animatingLines) { line.classList.remove('scroll-animate'); line.style.removeProperty('--scroll-delta'); line.style.removeProperty('--lyrics-line-delay'); line.style.removeProperty('--scroll-duration'); } animatingLines.length = 0; // Get lines for staggered animation — use cached array if (this.cachedLineArray.length === 0) { const lineElements = this.lyricsContainer.querySelectorAll('.lyrics-line'); this.cachedLineArray = Array.from(lineElements) as HTMLElement[]; } const lineArray = this.cachedLineArray; const referenceLine = this.currentPrimaryActiveLine || this.lastPrimaryActiveLine || lineArray[0]; if (!referenceLine) return; const referenceIndex = lineArray.indexOf(referenceLine); if (referenceIndex === -1) return; const duration = Math.min( 450, scrollDuration ?? SCROLL_ANIMATION_DURATION_MS, ); const delayIncrement = duration * 0.1; const lookAhead = 20; const len = lineArray.length; const start = Math.max(0, referenceIndex - lookAhead); const end = Math.min(len, referenceIndex + lookAhead); let maxAnimationDuration = 0; const newAnimatingLines: HTMLElement[] = []; const scrollingDown = delta >= 0; if (scrollingDown) { let delayCounter = 0; for (let i = start; i < end; i += 1) { const line = lineArray[i]; const delay = i >= referenceIndex ? delayCounter * delayIncrement : 0; if (i >= referenceIndex && !line.classList.contains('lyrics-gap')) { delayCounter += 1; } line.style.setProperty('--scroll-delta', `${delta}px`); line.style.setProperty('--lyrics-line-delay', `${delay}ms`); line.style.setProperty('--scroll-duration', `${duration + 100}ms`); newAnimatingLines.push(line); const lineDuration = duration + delay; if (lineDuration > maxAnimationDuration) { maxAnimationDuration = lineDuration; } } } else { let delayCounter = 0; for (let i = end - 1; i >= start; i -= 1) { const line = lineArray[i]; const delay = i <= referenceIndex ? delayCounter * delayIncrement : 0; if (i <= referenceIndex && !line.classList.contains('lyrics-gap')) { delayCounter += 1; } line.style.setProperty('--scroll-delta', `${delta}px`); line.style.setProperty('--lyrics-line-delay', `${delay}ms`); line.style.setProperty('--scroll-duration', `${duration + 100}ms`); newAnimatingLines.push(line); const lineDuration = duration + delay; if (lineDuration > maxAnimationDuration) { maxAnimationDuration = lineDuration; } } } // --- Step 3: Force reflow so the browser sees the class removal --- // Use offsetHeight which is cheaper than getBoundingClientRect // eslint-disable-next-line no-void void parent.offsetHeight; // --- Step 4: Re-add scroll-animate class to start fresh animations --- for (const line of newAnimatingLines) { line.classList.add('scroll-animate'); animatingLines.push(line); } animState.isAnimating = true; // YouLyPlus-style early unlock: allow new scrolls to start after a // short base duration, even if CSS animations are still running. const BASE_DURATION = 400; this.scrollUnlockTimeout = setTimeout(() => { animState.isAnimating = false; if (animState.pendingUpdate !== null) { const pendingValue = animState.pendingUpdate; animState.pendingUpdate = null; this.animateScrollYouLy(pendingValue, false, scrollDuration); } }, BASE_DURATION); this.scrollAnimationTimeout = setTimeout(() => { for (let i = 0; i < animatingLines.length; i += 1) { const line = animatingLines[i]; line.classList.remove('scroll-animate'); line.style.removeProperty('--scroll-delta'); line.style.removeProperty('--lyrics-line-delay'); line.style.removeProperty('--scroll-duration'); } animatingLines.length = 0; this.scrollAnimationTimeout = undefined; }, maxAnimationDuration + 50); parent.scrollTo({ top: targetTop, behavior: 'instant' }); } /** * Update position classes for YouLyPlus-style opacity/blur gradients */ private updatePositionClasses(lineToScroll: HTMLElement): void { if (!this.lyricsContainer) return; const positionClasses = [ 'lyrics-activest', 'post-active-line', 'next-active-line', 'prev-1', 'prev-2', 'prev-3', 'prev-4', 'next-1', 'next-2', 'next-3', 'next-4', ]; // Remove old position classes from tracked elements for (const el of this.positionedLineElements) { el.classList.remove(...positionClasses); } this.positionedLineElements = []; // Add new position classes lineToScroll.classList.add('lyrics-activest'); this.positionedLineElements.push(lineToScroll); if (this.cachedLineArray.length === 0) { this.cachedLineArray = Array.from( this.lyricsContainer.querySelectorAll('.lyrics-line'), ) as HTMLElement[]; } const lineElements = this.cachedLineArray; const scrollLineIndex = lineElements.indexOf(lineToScroll); if (scrollLineIndex === -1) return; for ( let i = Math.max(0, scrollLineIndex - 4); i <= Math.min(lineElements.length - 1, scrollLineIndex + 4); i += 1 ) { const position = i - scrollLineIndex; if (position !== 0) { const element = lineElements[i]; if (position === -1) element.classList.add('post-active-line'); else if (position === 1) element.classList.add('next-active-line'); else if (position < 0) element.classList.add(`prev-${Math.abs(position)}`); else element.classList.add(`next-${position}`); this.positionedLineElements.push(element); } } } /** * Scroll to active line with YouLyPlus-style animation */ private scrollToActiveLineYouLy( activeLine: HTMLElement, forceScroll = false, scrollDuration: number | undefined = undefined, ): void { if (!activeLine || !this.lyricsContainer) return; const paddingTop = this.getScrollPaddingTop(); const targetTranslateY = paddingTop - activeLine.offsetTop; const scrollContainerTop = this.lyricsContainer.getBoundingClientRect().top; // Skip if already at target position if ( !forceScroll && Math.abs( activeLine.getBoundingClientRect().top - scrollContainerTop - paddingTop, ) < 1 ) { return; } // Skip scroll if near the bottom of content and we aren't trying to scroll back up if (!forceScroll && !activeLine.classList.contains('lyrics-footer')) { const parent = this.lyricsContainer; const atBottom = parent.scrollTop + parent.clientHeight >= parent.scrollHeight - 50; const targetTop = Math.max(0, -(paddingTop - activeLine.offsetTop)); if (atBottom && targetTop > parent.scrollTop - 50) { return; } } this.lyricsContainer.classList.remove('not-focused', 'user-scrolling'); this.isProgrammaticScroll = true; this.setUserScrolling(false); if (this.userScrollTimeoutId) { clearTimeout(this.userScrollTimeoutId); this.userScrollTimeoutId = undefined; } this.clearPastLineHighlights(); const duration = scrollDuration ?? SCROLL_ANIMATION_DURATION_MS; setTimeout(() => { this.isProgrammaticScroll = false; }, duration + 160); this.animateScrollYouLy(targetTranslateY, forceScroll, scrollDuration); } /** * Update syllable highlight animation - apply CSS wipe animation * (Exact copy from YouLyPlus _updateSyllableAnimation) */ private static updateSyllableAnimation( syllable: HTMLElement, elapsedTimeMs = 0, ): void { if (syllable.classList.contains('highlight')) return; const { classList } = syllable; const isRTL = classList.contains('rtl-text'); const charSpans = Array.from( syllable.querySelectorAll('span.char'), ) as HTMLElement[]; const wordElement = syllable.parentElement?.parentElement; // syllable-wrap -> word const virtualWordId = (wordElement as HTMLElement | undefined)?.dataset .virtualWordId; let wordElements: HTMLElement[] = []; if (virtualWordId && wordElement?.parentElement) { wordElements = Array.from( wordElement.parentElement.querySelectorAll('.lyrics-word'), ).filter( el => (el as HTMLElement).dataset.virtualWordId === virtualWordId, ) as HTMLElement[]; } else if (wordElement) { wordElements = [wordElement as HTMLElement]; } const allWordCharSpans = wordElements.flatMap( word => Array.from(word.querySelectorAll('span.char')) as HTMLElement[], ); const isGrowable = wordElement?.classList.contains('growable'); const isCharRise = wordElement?.classList.contains('char-rise'); const isFirstSyllable = syllable.getAttribute('data-syllable-index') === '0'; const syllableStartMs = parseFloat( syllable.getAttribute('data-start-time') || '0', ); const virtualWordStartMs = parseFloat( (wordElement as HTMLElement | undefined)?.dataset.virtualWordStart || '', ); const isFirstInVirtualWord = isFirstSyllable && (!Number.isFinite(virtualWordStartMs) || Math.abs(syllableStartMs - virtualWordStartMs) < 0.5); const isFirstInContainer = isFirstSyllable; // Simplified const isGap = syllable.closest('.lyrics-gap') !== null; // Get duration from data attribute const syllableDurationMs = parseFloat(syllable.getAttribute('data-duration') || '0') || 300; const wordDurationMs = parseFloat( syllable.getAttribute('data-word-duration') || syllable.getAttribute('data-duration') || '0', ) || syllableDurationMs; // Use a Map to collect animations like YouLyPlus const charAnimationsMap = new Map(); const styleUpdates: Array<{ element: HTMLElement; property: string; value: string; }> = []; // Step 1: Grow Pass if (isGrowable && isFirstInVirtualWord && allWordCharSpans.length > 0) { const finalDuration = wordDurationMs; const baseDelayPerChar = finalDuration * 0.09; const growDurationMs = finalDuration * 1.5; allWordCharSpans.forEach(span => { const matrixScale = span.dataset.matrixScale || '1.1'; const charOffsetX = span.dataset.charOffsetX || '0'; const shadowIntensity = span.dataset.shadowIntensity || '0.6'; const translateYPeak = span.dataset.translateYPeak || '-2'; const syllableCharIndex = parseFloat( span.dataset.syllableCharIndex || '0', ); const growDelay = baseDelayPerChar * syllableCharIndex; charAnimationsMap.set( span, `grow-dynamic ${growDurationMs}ms ease-in-out ${growDelay}ms forwards`, ); styleUpdates.push({ element: span, property: '--matrix-scale', value: matrixScale, }); styleUpdates.push({ element: span, property: '--char-offset-x', value: `${charOffsetX}px`, }); styleUpdates.push({ element: span, property: '--shadow-intensity', value: shadowIntensity, }); styleUpdates.push({ element: span, property: '--translate-y-peak', value: `${translateYPeak}px`, }); }); } if (isCharRise && isFirstInVirtualWord && allWordCharSpans.length > 0) { const finalDuration = Math.max(wordDurationMs, syllableDurationMs); const baseDelayPerChar = finalDuration * 0.09; const riseDurationMs = finalDuration * 1.5; allWordCharSpans.forEach(span => { const charIndex = parseFloat(span.dataset.syllableCharIndex || '0'); const riseDelay = baseDelayPerChar * charIndex; charAnimationsMap.set( span, `rise-char ${riseDurationMs}ms ease-in-out ${riseDelay}ms forwards`, ); }); } // Step 2: Wipe Pass if (charSpans.length > 0) { charSpans.forEach((span, charIndex) => { const startPct = parseFloat(span.dataset.wipeStart || '0'); const durationPct = parseFloat(span.dataset.wipeDuration || '0'); const wipeDelay = syllableDurationMs * startPct - elapsedTimeMs; const wipeDuration = syllableDurationMs * durationPct; const useStartAnimation = isFirstInContainer && charIndex === 0; let charWipeAnimation = 'wipe'; if (useStartAnimation) { charWipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe'; } else { charWipeAnimation = isRTL ? 'wipe-rtl' : 'wipe'; } const existingAnimation = charAnimationsMap.get(span) || span.style.animation || ''; const animationParts = []; if ( existingAnimation && (existingAnimation.includes('grow-dynamic') || existingAnimation.includes('rise-char')) ) { animationParts.push(existingAnimation.split(',')[0].trim()); } if (charIndex > 0 && wipeDelay > 0 && wipeDuration > 0) { const arrivalTime = (span.dataset.preWipeArrival ? parseFloat(span.dataset.preWipeArrival) : syllableDurationMs * startPct) - elapsedTimeMs; const measuredPreWipeDuration = parseFloat( span.dataset.preWipeDuration || '100', ); const preWipeDuration = Math.min( measuredPreWipeDuration, wipeDuration * 0.9, syllableDurationMs * 0.08, arrivalTime, ); const animDelay = arrivalTime - preWipeDuration; if (preWipeDuration >= 16) { animationParts.push( `pre-wipe-char ${preWipeDuration}ms linear ${animDelay}ms none`, ); } } if (wipeDuration > 0) { animationParts.push( `${charWipeAnimation} ${wipeDuration}ms linear ${wipeDelay}ms forwards`, ); } if (animationParts.length > 0) { charAnimationsMap.set(span, animationParts.join(', ')); } }); } else { // Syllable-level wipe for regular (non-growable) words without chars const wipeRatio = parseFloat( syllable.getAttribute('data-wipe-ratio') || '1', ); const visualDuration = syllableDurationMs * wipeRatio; let wipeAnimation = 'wipe'; if (isFirstInContainer) { wipeAnimation = isRTL ? 'start-wipe-rtl' : 'start-wipe'; } else { wipeAnimation = isRTL ? 'wipe-rtl' : 'wipe'; } if (syllable.classList.contains('line-synced')) return; const currentWipeAnimation = isGap ? 'fade-gap' : wipeAnimation; // eslint-disable-next-line no-param-reassign syllable.style.animation = `${currentWipeAnimation} ${visualDuration}ms ${isGap ? 'ease-out' : 'linear'} ${-elapsedTimeMs}ms forwards`; } // --- WRITE PHASE --- classList.remove('pre-highlight'); classList.add('highlight'); for (const [span, animationString] of charAnimationsMap.entries()) { span.style.willChange = 'transform'; span.style.animation = animationString; } // Apply style updates for (const update of styleUpdates) { update.element.style.setProperty(update.property, update.value); } } /** * Reset syllable animation state */ private static resetSyllable(syllable: HTMLElement): void { if (!syllable) return; // eslint-disable-next-line no-param-reassign syllable.style.animation = ''; syllable.style.removeProperty('--pre-wipe-duration'); syllable.style.removeProperty('--pre-wipe-delay'); // Force background to secondary and disable transition to prevent lingering white // eslint-disable-next-line no-param-reassign syllable.style.transition = 'none'; // eslint-disable-next-line no-param-reassign syllable.style.backgroundColor = 'var(--lyplus-text-secondary)'; // Reset character animations — disable transition so finished chars don't slowly fade const charSpans = syllable.querySelectorAll('span.char'); for (let i = 0; i < charSpans.length; i += 1) { const el = charSpans[i] as HTMLElement; el.style.animation = ''; el.style.transition = 'none'; el.style.backgroundColor = 'var(--lyplus-text-secondary)'; } // Immediately remove all state classes syllable.classList.remove( 'highlight', 'finished', 'pre-highlight', 'cleanup', ); } /** * Reset all syllables in a line — batches deferred cleanup into a single rAF */ private static resetSyllables(line: HTMLElement): void { if (!line) return; line.classList.remove('persist-highlight'); // eslint-disable-next-line no-param-reassign (line as any)._cachedSyllableElements = null; const syllables = line.getElementsByClassName('lyrics-syllable'); for (let i = 0; i < syllables.length; i += 1) { AmLyrics.resetSyllable(syllables[i] as HTMLElement); } // Batch deferred style cleanup into a single rAF for all syllables in the line requestAnimationFrame(() => { for (let i = 0; i < syllables.length; i += 1) { const syllable = syllables[i] as HTMLElement; syllable.style.removeProperty('background-color'); syllable.style.removeProperty('transition'); const chars = syllable.querySelectorAll('span.char'); for (let j = 0; j < chars.length; j += 1) { const el = chars[j] as HTMLElement; el.style.removeProperty('background-color'); el.style.removeProperty('transition'); el.style.removeProperty('will-change'); } } }); } /** * Gentle reset for normal playback: remove highlight/finished classes * without forcing inline styles. Lets CSS transition fade syllables * back to secondary colour smoothly. */ private static unfinishSyllables(line: HTMLElement): void { if (!line) return; line.classList.remove('persist-highlight'); const syllables = line.getElementsByClassName('lyrics-syllable'); for (let i = 0; i < syllables.length; i += 1) { const s = syllables[i] as HTMLElement; s.classList.remove('highlight', 'finished', 'pre-highlight', 'cleanup'); s.style.animation = ''; s.style.removeProperty('--pre-wipe-duration'); s.style.removeProperty('--pre-wipe-delay'); s.style.removeProperty('background-color'); s.style.removeProperty('transition'); const chars = s.querySelectorAll('span.char'); for (let j = 0; j < chars.length; j += 1) { const el = chars[j] as HTMLElement; el.style.animation = ''; el.style.removeProperty('will-change'); el.style.removeProperty('background-color'); el.style.removeProperty('transition'); el.style.removeProperty('filter'); } } } private static finishSyllablesUpToTime( line: HTMLElement, currentTimeMs: number, ): void { if (!line) return; let hasFinishedSyllable = false; let syllables: HTMLElement[] = (line as any)._cachedSyllableElements; if (!syllables) { syllables = Array.from( line.querySelectorAll('.lyrics-syllable'), ) as HTMLElement[]; for (let i = 0; i < syllables.length; i += 1) { const syllable = syllables[i]; (syllable as any)._cachedStartTime = parseFloat( syllable.getAttribute('data-start-time') || '0', ); (syllable as any)._cachedEndTime = parseFloat( syllable.getAttribute('data-end-time') || '0', ); } // eslint-disable-next-line no-param-reassign (line as any)._cachedSyllableElements = syllables; } for (let i = 0; i < syllables.length; i += 1) { const syllable = syllables[i]; const startTime = (syllable as any)._cachedStartTime; if (Number.isFinite(startTime) && currentTimeMs >= startTime) { const { classList } = syllable; if (!classList.contains('finished')) { if (!classList.contains('highlight')) { AmLyrics.updateSyllableAnimation( syllable, Math.max(0, currentTimeMs - startTime), ); } classList.add('finished'); } hasFinishedSyllable = true; classList.remove('highlight'); classList.remove('pre-highlight'); classList.add('cleanup'); syllable.style.animation = ''; syllable.style.removeProperty('--pre-wipe-duration'); syllable.style.removeProperty('--pre-wipe-delay'); const chars = syllable.querySelectorAll('span.char'); for (let ci = 0; ci < chars.length; ci += 1) { const charEl = chars[ci] as HTMLElement; const currentAnim = charEl.style.animation || ''; if ( currentAnim.includes('grow-dynamic') || currentAnim.includes('rise-char') ) { const parts = currentAnim.split(',').map(p => p.trim()); const transformAnim = parts.find( p => p.includes('grow-dynamic') || p.includes('rise-char'), ); charEl.style.animation = transformAnim || ''; } else { charEl.style.animation = ''; } } } } if (hasFinishedSyllable) { line.classList.add('persist-highlight'); } else { line.classList.remove('persist-highlight'); } } /** * Update syllables based on current time * Uses DOM caching and pre-highlight reset for smooth transitions */ private static updateSyllablesForLine( line: HTMLElement, currentTimeMs: number, ): void { // DOM cache: avoid querySelectorAll on every frame let syllables: HTMLElement[] = (line as any)._cachedSyllableElements; if (!syllables) { syllables = Array.from( line.querySelectorAll('.lyrics-syllable'), ) as HTMLElement[]; for (let i = 0; i < syllables.length; i += 1) { const syllable = syllables[i]; (syllable as any)._cachedStartTime = parseFloat( syllable.getAttribute('data-start-time') || '0', ); (syllable as any)._cachedEndTime = parseFloat( syllable.getAttribute('data-end-time') || '0', ); } // eslint-disable-next-line no-param-reassign (line as any)._cachedSyllableElements = syllables; } for (let i = 0; i < syllables.length; i += 1) { const syllable = syllables[i]; const startTime = (syllable as any)._cachedStartTime; const endTime = (syllable as any)._cachedEndTime; if (Number.isFinite(startTime) && Number.isFinite(endTime)) { const { classList } = syllable; const hasHighlight = classList.contains('highlight'); const hasFinished = classList.contains('finished'); const hasPreHighlight = classList.contains('pre-highlight'); const hasActiveState = hasHighlight || hasFinished || hasPreHighlight; // Early exit check if (!(currentTimeMs < startTime - 1000 && !hasActiveState)) { let preHighlightReset = false; // Pre-highlight reset logic if (hasPreHighlight && i > 0) { const prevSyllable = syllables[i - 1]; if (!prevSyllable.classList.contains('highlight')) { classList.remove('pre-highlight'); syllable.style.removeProperty('--pre-wipe-duration'); syllable.style.removeProperty('--pre-wipe-delay'); syllable.style.animation = ''; preHighlightReset = true; } } if (!preHighlightReset) { if (currentTimeMs >= startTime && currentTimeMs <= endTime) { // Currently active if (!hasHighlight) { AmLyrics.updateSyllableAnimation( syllable, currentTimeMs - startTime, ); } if (hasFinished) { classList.remove('finished'); } } else if (currentTimeMs > endTime) { // Finished if (!hasFinished) { if (!hasHighlight) { AmLyrics.updateSyllableAnimation( syllable, currentTimeMs - startTime, ); } classList.add('finished'); // Keep the completed wipe state until user scroll resets it. } } else if (hasHighlight || hasFinished) { // Not yet started AmLyrics.resetSyllable(syllable); } } } } } } private animateProgress() { const now = performance.now(); let running = false; if (!this.lyrics || this.activeLineIndices.length === 0) { if (this.animationFrameId) { cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } return; } // Process each active line for (const lineIndex of this.activeLineIndices) { const line = this.lyrics[lineIndex]; const mainWordAnimation = this.mainWordAnimations.get(lineIndex); // Main text animation if (mainWordAnimation && mainWordAnimation.duration > 0) { const elapsed = now - mainWordAnimation.startTime; if (elapsed >= 0) { const progress = Math.min(1, elapsed / mainWordAnimation.duration); this.mainWordProgress.set(lineIndex, progress); if (progress < 1) { running = true; } else { // Word animation finished. Look for the next word in the same line. const currentMainWordIndex = this.activeMainWordIndices.get(lineIndex) ?? -1; const nextWordIndex = currentMainWordIndex + 1; if ( currentMainWordIndex !== -1 && nextWordIndex < line.text.length ) { const currentWord = line.text[currentMainWordIndex]; const nextWord = line.text[nextWordIndex]; this.activeMainWordIndices.set(lineIndex, nextWordIndex); const gap = nextWord.timestamp - currentWord.endtime; const nextWordDuration = nextWord.endtime - nextWord.timestamp; this.mainWordAnimations.set(lineIndex, { startTime: performance.now() + gap, duration: nextWordDuration, }); running = true; } else { this.mainWordAnimations.set(lineIndex, { startTime: 0, duration: 0, }); } } } else { // Waiting in a gap this.mainWordProgress.set(lineIndex, 0); running = true; } } // Background text animation const backgroundWordAnimation = this.backgroundWordAnimations.get(lineIndex); if (backgroundWordAnimation && backgroundWordAnimation.duration > 0) { const elapsed = now - backgroundWordAnimation.startTime; if (elapsed >= 0) { const progress = Math.min( 1, elapsed / backgroundWordAnimation.duration, ); this.backgroundWordProgress.set(lineIndex, progress); if (progress < 1) { running = true; } else { // Word animation finished. Look for the next word in the same line. const currentBackgroundWordIndex = this.activeBackgroundWordIndices.get(lineIndex) ?? -1; if ( line.backgroundText && currentBackgroundWordIndex !== -1 && currentBackgroundWordIndex < line.backgroundText.length - 1 ) { const nextWordIndex = currentBackgroundWordIndex + 1; const currentWord = line.backgroundText[currentBackgroundWordIndex]; const nextWord = line.backgroundText[nextWordIndex]; this.activeBackgroundWordIndices.set(lineIndex, nextWordIndex); const gap = nextWord.timestamp - currentWord.endtime; const nextWordDuration = nextWord.endtime - nextWord.timestamp; this.backgroundWordAnimations.set(lineIndex, { startTime: performance.now() + gap, duration: nextWordDuration, }); running = true; } else { this.backgroundWordAnimations.set(lineIndex, { startTime: 0, duration: 0, }); } } } else { // Waiting in a gap this.backgroundWordProgress.set(lineIndex, 0); running = true; } } } if (running) { this.animationFrameId = requestAnimationFrame(this._boundAnimateProgress); } else if (this.animationFrameId) { // Stop animation if no words are running cancelAnimationFrame(this.animationFrameId); this.animationFrameId = undefined; } } private generateLRC(): string { if (!this.lyrics) return ''; let lrc = ''; // Add metadata if available if (this.songTitle) lrc += `[ti:${this.songTitle}]\n`; if (this.songArtist) lrc += `[ar:${this.songArtist}]\n`; if (this.songAlbum) lrc += `[al:${this.songAlbum}]\n`; if (this.lyricsSource) lrc += `[re:${this.lyricsSource}]\n`; for (const line of this.lyrics) { if (line.text && line.text.length > 0) { const timestamp = AmLyrics.formatTimestampLRC(line.timestamp); // Construct line text from syllables const lineText = line.text .map(s => s.text) .join('') .trim(); lrc += `[${timestamp}]${lineText}\n`; } } return lrc; } private generateTTML(): string { if (!this.lyrics) return ''; // Basic TTML structure let ttml = '\n'; ttml += '\n'; ttml += ' \n'; let currentPart: string | undefined; for (let i = 0; i < this.lyrics.length; i += 1) { const line = this.lyrics[i]; const part = line.songPart; // If part changed (or first line), start new div if (part !== currentPart || i === 0) { if (i > 0) { ttml += ' \n'; } currentPart = part; if (currentPart) { ttml += `
\n`; } else { ttml += '
\n'; } } // For TTML, we can represent syllables as spans if word-synced const begin = AmLyrics.formatTimestampTTML(line.timestamp); const end = AmLyrics.formatTimestampTTML(line.endtime); ttml += `

\n`; for (const word of line.text) { const wBegin = AmLyrics.formatTimestampTTML(word.timestamp); const wEnd = AmLyrics.formatTimestampTTML(word.endtime); // Escape special characters in text const text = word.text .replace(/&/g, '&') .replace(//g, '>'); ttml += ` ${text}\n`; } ttml += '

\n'; } if (this.lyrics.length > 0) { ttml += '
\n'; } ttml += ' \n'; ttml += '
'; return ttml; } private static formatTimestampLRC(ms: number): string { const totalSeconds = ms / 1000; const minutes = Math.floor(totalSeconds / 60); const seconds = Math.floor(totalSeconds % 60); const hundredths = Math.floor((ms % 1000) / 10); const pad = (n: number) => n.toString().padStart(2, '0'); return `${pad(minutes)}:${pad(seconds)}.${pad(hundredths)}`; } private static formatTimestampTTML(ms: number): string { // TTML standard format: HH:MM:SS.mmm const totalSeconds = ms / 1000; const hours = Math.floor(totalSeconds / 3600); const minutes = Math.floor((totalSeconds % 3600) / 60); const seconds = Math.floor(totalSeconds % 60); const milliseconds = Math.floor(ms % 1000); const pad = (n: number, width = 2) => n.toString().padStart(width, '0'); return `${pad(hours)}:${pad(minutes)}:${pad(seconds)}.${pad(milliseconds, 3)}`; } private downloadLyrics() { if (!this.lyrics || this.lyrics.length === 0) return; // Determine format: TTML if ANY line is word-synced, else LRC const isWordSynced = this.lyrics.some(l => l.isWordSynced !== false); let content = ''; let extension = this.downloadFormat; if (extension === 'auto') { extension = isWordSynced ? 'ttml' : 'lrc'; } let mimeType = ''; if (extension === 'ttml') { content = this.generateTTML(); mimeType = 'application/xml'; } else { content = this.generateLRC(); mimeType = 'text/plain'; } if (!content) return; const blob = new Blob([content], { type: mimeType }); const url = URL.createObjectURL(blob); const a = document.createElement('a'); a.href = url; const filename = this.songTitle ? `${this.songTitle}${this.songArtist ? ` - ${this.songArtist}` : ''}.${extension}` : `lyrics.${extension}`; a.download = filename; document.body.appendChild(a); a.click(); document.body.removeChild(a); URL.revokeObjectURL(url); } render() { if (this.fontFamily) { this.style.fontFamily = this.fontFamily; } // Set both old internal CSS variables (for backward compatibility) // and new public CSS variables (which take precedence) this.style.setProperty('--highlight-color', this.highlightColor); const sourceLabel = this.lyricsSource ?? 'Unavailable'; const isUnsynced = this.cachedIsUnsynced; const renderContent = () => { if (this.isLoading) { // Render stylized skeleton lines return html`
`; } if (!this.lyrics || this.lyrics.length === 0) { return html`
No lyrics found.
`; } // Build a lookup map of ALL gaps so they are always in the DOM const allGaps = this.findAllInstrumentalGaps(); const gapByIndex = new Map( allGaps.map(g => [g.insertBeforeIndex, g] as const), ); return this.lyrics.map((line, lineIndex) => { const lineId = `lyrics-line-${lineIndex}`; // Calculate line timing const lineStartTime = line.text[0]?.timestamp || 0; const lineEndTime = line.text[line.text.length - 1]?.endtime || 0; // Always render background vocals in the DOM so the syllable cache // includes them and the wipe effect applies correctly. const hasBackground = line.backgroundText && line.backgroundText.length > 0; // Create background vocals container (with romanization support) const backgroundVocalElement = hasBackground ? html`

${line.backgroundText!.map((syllable, syllableIndex) => { const startTimeMs = syllable.timestamp; const endTimeMs = syllable.endtime; const durationMs = endTimeMs - startTimeMs; const bgRomanizedText = this.showRomanization && syllable.romanizedText && syllable.romanizedText.trim() !== syllable.text.trim() ? html`${syllable.romanizedText}` : ''; return html`${syllable.text}${bgRomanizedText}`; })}

` : ''; // Background vocals share the same line.translation and line.romanizedText // as the main vocal, so we intentionally do NOT render a separate // translation/romanization block for background — it would just duplicate // the main line's text. const bgPlacement = hasBackground ? AmLyrics.getBackgroundTextPlacement(line) : 'after'; const lineData = this.cachedLineData?.[lineIndex]; const wordGroups = lineData?.wordGroups ?? []; const groupGrowable = lineData?.groupGrowable ?? []; const groupGlowing = lineData?.groupGlowing ?? []; const groupCharRise = lineData?.groupCharRise ?? []; const vwFullText = lineData?.vwFullText ?? []; const vwFullDuration = lineData?.vwFullDuration ?? []; const vwCharOffset = lineData?.vwCharOffset ?? []; const vwStartMs = lineData?.vwStartMs ?? []; const vwEndMs = lineData?.vwEndMs ?? []; const lineIsRTL = lineData?.lineIsRTL ?? false; // Create main vocals using YouLyPlus syllable structure const mainVocalElement = html`

${wordGroups.map((group, groupIdx) => { const isGrowable = groupGrowable[groupIdx]; const isGlowing = groupGlowing[groupIdx]; const isCharRise = groupCharRise[groupIdx]; const isAnimatedByChar = isGrowable || isCharRise; const groupLineSynced = group.some(s => s.lineSynced); const wordText = isAnimatedByChar ? vwFullText[groupIdx] : ''; const wordDuration = isAnimatedByChar ? vwFullDuration[groupIdx] : 0; const wordNumChars = wordText.length; const groupCharOffset = isAnimatedByChar ? vwCharOffset[groupIdx] : 0; const virtualWordId = isAnimatedByChar ? `${lineIndex}:${vwStartMs[groupIdx]}:${vwEndMs[groupIdx]}` : ''; const virtualWordStart = isAnimatedByChar ? vwStartMs[groupIdx] : ''; const virtualWordEnd = isAnimatedByChar ? vwEndMs[groupIdx] : ''; let sylCharAccumulator = 0; const groupText = group.map(s => s.text).join(''); const shouldAllowBreak = groupText.trim().length >= 16 || /[\u4e00-\u9fff\u3040-\u309f\u30a0-\u30ff\uac00-\ud7af]/.test( groupText, ); // Calculate dynamic rise duration based on the audio duration of the word const wordStartTimeMs = group[0].timestamp; const wordEndTimeMs = group[group.length - 1].endtime; const actualDurationMs = wordEndTimeMs - wordStartTimeMs; // Base float is 0.8s, plus a portion of the audio duration, capped between 1.0s and 2.5s const riseDuration = Math.max( 1.2, Math.min(2.5, 1.2 + (actualDurationMs / 1000) * 0.6), ); return html`${group.map((syllable, sylIdx) => { const startTimeMs = syllable.timestamp; const endTimeMs = syllable.endtime; const durationMs = endTimeMs - startTimeMs; const text = syllable.text || ''; const romanizedText = this.showRomanization && syllable.romanizedText && syllable.romanizedText.trim() !== syllable.text.trim() ? html`${syllable.romanizedText}` : ''; let syllableContent: any = text; if (isAnimatedByChar) { let charIndexInsideSyllable = 0; const numCharsInSyllable = text.replace(/\s/g, '').length || 1; syllableContent = html`${text.split('').map(char => { if (char === ' ') return ' '; const charIndexInsideWord = groupCharOffset + sylCharAccumulator; const charStartPercentVal = charIndexInsideSyllable / numCharsInSyllable; sylCharAccumulator += 1; charIndexInsideSyllable += 1; const minDuration = 400; const maxDuration = 3000; const easingPower = 3; const progress = Math.min( 1, Math.max( 0, (wordDuration - minDuration) / (maxDuration - minDuration), ), ); const easedProgress = progress ** easingPower; const isLongWord = wordNumChars > 5; const isShortDuration = wordDuration < 1200; let maxDecayRate = 0; if (isLongWord || isShortDuration) { let decayStrength = 0; if (isLongWord) decayStrength += Math.min((wordNumChars - 5) / 5, 1.0) * 0.4; if (isShortDuration && wordNumChars > 3) decayStrength += Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.3; else if (isShortDuration && wordNumChars <= 3) decayStrength += Math.max(0, 1.0 - (wordDuration - 800) / 400) * 0.1; maxDecayRate = Math.min(decayStrength, 0.7); } const positionInWord = wordNumChars > 1 ? charIndexInsideWord / (wordNumChars - 1) : 0; const decayFactor = 1.0 - positionInWord * maxDecayRate; const charProgress = easedProgress * decayFactor; const baseGrowth = wordNumChars <= 3 ? 0.05 : 0.04; const charMaxScale = 1.0 + baseGrowth + charProgress * 0.08; const glowDurFactor = Math.min(1.1, wordDuration / 1500); let glowLenFactor = 1.0; if (wordNumChars <= 3) { glowLenFactor = 0.85; } else if (wordNumChars >= 6) { glowLenFactor = 1.1; } const glowIntensityScale = glowDurFactor * glowLenFactor; const charShadowIntensity = isGlowing ? (0.35 + charProgress * 0.45) * glowIntensityScale : 0; const normalizedGrowth = (charMaxScale - 1.0) / 0.1; const effectiveDuration = (wordDuration + durationMs * 2) / 3; const peakMultiplier = Math.min( 1, Math.max(0.3, effectiveDuration / 2000), ); const charTranslateYPeak = -normalizedGrowth * (2 * peakMultiplier); // Further dampened lift peak const position = (charIndexInsideWord + 0.5) / wordNumChars; const horizontalOffset = (position - 0.5) * 2 * ((charMaxScale - 1.0) * 25); return html`${char}`; })}`; } return html`${syllableContent}${romanizedText}`; })}`; })}

`; // Translation container (if enabled) // Hide translation if it matches the original line text const fullLineText = line.text .map(s => s.text) .join('') .trim(); const translationElement = this.showTranslation && line.translation && line.translation.trim() !== fullLineText ? html`
${line.translation}
` : ''; // Line-synced romanization (fallback if no word-level romanization) // Hide if the romanized text matches the original line text const lineRomanizationElement = this.showRomanization && line.romanizedText && !line.text.some(s => s.romanizedText) && line.romanizedText.trim() !== fullLineText ? html`
${line.romanizedText}
` : ''; // Check for instrumental gap before this line let maybeInstrumentalBlock: unknown = null; const gapForLine = gapByIndex.get(lineIndex); if (gapForLine) { const gapDuration = gapForLine.gapEnd - gapForLine.gapStart; // Calculate dot timing for fill-up animation (3 dots) const dotDuration = gapDuration / 3; const gapLoopDelay = AmLyrics.getGapLoopDelay(gapDuration); // Gap starts without 'active' — _onTimeChanged toggles it imperatively maybeInstrumentalBlock = html`

`; } return html` ${maybeInstrumentalBlock}
this.handleLineClick(line)} tabindex="0" @keydown=${(e: KeyboardEvent) => { if (e.key === 'Enter' || e.key === ' ') { this.handleLineClick(line); } }} >
${bgPlacement === 'before' ? backgroundVocalElement : ''} ${mainVocalElement} ${bgPlacement === 'after' ? backgroundVocalElement : ''} ${lineRomanizationElement} ${translationElement}
`; }); }; return html`
${!this.isLoading && this.lyrics && this.lyrics.length > 0 ? html`
` : ''} ${renderContent()} ${!this.isLoading ? html` ` : ''}
`; } }