import { animateScrollTop, computeScrollDurationMs, } from '/@/renderer/features/lyrics/api/lyrics-scroll'; import { getLineEndMs, getLyricLineStartMs, lyricsHasWordCues, } from '/@/renderer/features/lyrics/api/lyrics-utils'; import { SynchronizedLyrics } from '/@/shared/types/domain-types'; const ANIMATING_CLASS = 'lyrics-word-animating'; const PRE_ANIMATING_CLASS = 'lyrics-word-pre-animating'; const PAUSED_CLASS = 'lyrics-word-paused'; const LINE_ACTIVE_CLASS = 'lyrics-line-active'; const LINE_ANIMATING_CLASS = 'lyrics-line-animating'; const LINE_PRE_ANIMATING_CLASS = 'lyrics-line-pre-animating'; const TIME_JUMP_THRESHOLD = 0.5; const CENTER_SCROLL_POS_RATIO = 0.5; const DEFAULT_ENDING_THRESHOLD = 0.5; const DEFAULT_LINE_LEAD_TIME_MS = 800; const RICHSYNC_TIMING_OFFSET_MS = 150; const SYNC_TIMING_OFFSET_MS = 115; const HIGHLIGHT_DURATION_MULTIPLIER = 1.6; export interface AnimEngineState { lastActiveElements: LineData[]; lastEventCreationTime: number; lastPlayState: boolean; lastTime: number; scroll: ScrollState; selectedElementIndex: number; } export interface BuildLyricsDataOptions { container: HTMLElement; lineIdPrefix: 'karaoke-line' | 'lyric'; lyrics: SynchronizedLyrics; } export interface LineData { accumulatedOffsetMs: number; animationStartTimeMs: number; duration: number; element: HTMLElement; hasWordCues: boolean; height: number; isAnimating: boolean; isAnimationPlayStatePlaying: boolean; isScrolled: boolean; isSelected: boolean; lastAnimSetupAt: number; parts: PartData[]; position: number; time: number; } export interface LyricsData { container: HTMLElement; lineIdPrefix: 'karaoke-line' | 'lyric'; lines: LineData[]; syncType: LyricsSyncType; } export type LyricsSyncType = 'none' | 'richsync' | 'synced'; export interface PartData { animationStartTimeMs: number; duration: number; element: HTMLElement; isAnimating: boolean; time: number; } export interface ScrollState { cancelScroll: (() => void) | null; doneFirstInstantScroll: boolean; nextScrollAllowedTime: number; pendingScroll: boolean; programmaticScrollUntil: number; queuedScroll: boolean; scrollPos: number; scrollResumeTime: number; skipScrolls: number; skipScrollsDecayTimes: number[]; wasUserScrolling: boolean; } export interface TickOptions { currentTimeMs: number; eventCreationTime: number; follow?: boolean; followScrollAlignment?: number; forceResync?: boolean; isPlaying: boolean; lineLeadTimeMs?: number; lyricsData: LyricsData; onLineActive?: (lineIndex: number) => void; scrollContainer: HTMLElement; smoothScroll?: boolean; } export const getFollowScrollPositionRatio = (followScrollAlignment = 0): number => { return Math.min(0.95, Math.max(0.05, CENTER_SCROLL_POS_RATIO + followScrollAlignment / 100)); }; const reflow = (element: HTMLElement): void => { void element.offsetHeight; }; export const createScrollState = (): ScrollState => ({ cancelScroll: null, doneFirstInstantScroll: false, nextScrollAllowedTime: 0, pendingScroll: false, programmaticScrollUntil: 0, queuedScroll: false, scrollPos: -1, scrollResumeTime: 0, skipScrolls: 0, skipScrollsDecayTimes: [], wasUserScrolling: false, }); export const createAnimEngineState = (): AnimEngineState => ({ lastActiveElements: [], lastEventCreationTime: 0, lastPlayState: false, lastTime: 0, scroll: createScrollState(), selectedElementIndex: 0, }); export const resetAnimEngine = (state: AnimEngineState): void => { cancelActiveLyricsScroll(state.scroll); state.lastActiveElements = []; state.scroll.skipScrollsDecayTimes = []; state.scroll.doneFirstInstantScroll = false; state.scroll.pendingScroll = false; state.scroll.queuedScroll = false; }; const collectLineParts = (lineElement: HTMLElement): PartData[] => { const words = lineElement.querySelectorAll( '.karaoke-word[data-duration]:not(.karaoke-romaji-word)', ); return Array.from(words).map((element) => ({ animationStartTimeMs: Number.POSITIVE_INFINITY, duration: Number.parseFloat(element.dataset.duration ?? '0'), element, isAnimating: false, time: Number.parseFloat(element.dataset.time ?? '0'), })); }; const getRomajiCounterparts = (mainElement: HTMLElement): HTMLElement[] => { const line = mainElement.closest('.karaoke-line'); if (!line) { return []; } const mainStart = Number.parseFloat(mainElement.dataset.time ?? 'NaN'); const mainDuration = Number.parseFloat(mainElement.dataset.duration ?? '0'); if (!Number.isFinite(mainStart)) { return []; } const mainEnd = mainStart + mainDuration; return Array.from( line.querySelectorAll('.karaoke-romaji-word[data-time][data-duration]'), ).filter((element) => { const start = Number.parseFloat(element.dataset.time ?? 'NaN'); const duration = Number.parseFloat(element.dataset.duration ?? '0'); if (!Number.isFinite(start)) { return false; } const end = start + duration; return start < mainEnd && mainStart < end; }); }; const cancelSungAnimationCleanup = (element: HTMLElement): void => { const existingTimeout = Number(element.dataset.sungCleanupTimeout); if (existingTimeout) { window.clearTimeout(existingTimeout); delete element.dataset.sungCleanupTimeout; } }; const completeSungVisualCleanup = (element: HTMLElement): void => { cancelSungAnimationCleanup(element); element.style.removeProperty('--karaoke-swipe-delay'); element.style.removeProperty('--karaoke-anim-delay'); element.classList.remove(ANIMATING_CLASS); element.classList.remove(PRE_ANIMATING_CLASS); element.classList.remove(PAUSED_CLASS); }; const getRemainingHighlightDurationSec = ( durationSec: number, startTimeSec: number, interpolatedTimeSec: number, ): number => { const highlightDurationSec = durationSec * HIGHLIGHT_DURATION_MULTIPLIER; const elapsedSec = Math.max(0, interpolatedTimeSec - startTimeSec); return Math.max(0, highlightDurationSec - elapsedSec); }; const scheduleSungAnimationCleanup = ( element: HTMLElement, durationSec: number, startTimeSec: number, interpolatedTimeSec: number, ): void => { const remainingSec = getRemainingHighlightDurationSec( durationSec, startTimeSec, interpolatedTimeSec, ); if (remainingSec <= 0.05) { completeSungVisualCleanup(element); return; } cancelSungAnimationCleanup(element); const timeoutId = window.setTimeout(() => { delete element.dataset.sungCleanupTimeout; completeSungVisualCleanup(element); }, remainingSec * 1000); element.dataset.sungCleanupTimeout = String(timeoutId); }; const clearElementAnimation = (element: HTMLElement): void => { cancelSungAnimationCleanup(element); element.style.removeProperty('--karaoke-swipe-delay'); element.style.removeProperty('--karaoke-anim-delay'); element.classList.remove(ANIMATING_CLASS); element.classList.remove(PRE_ANIMATING_CLASS); element.classList.remove(PAUSED_CLASS); }; const setupElementAnimation = ( element: HTMLElement, time: number, duration: number, interpolatedTimeSec: number, ): void => { const partTimeDelta = interpolatedTimeSec - time; element.classList.remove(ANIMATING_CLASS); element.classList.remove(PAUSED_CLASS); element.style.setProperty('--karaoke-swipe-delay', `${-partTimeDelta - duration * 0.1}s`); element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`); element.classList.add(PRE_ANIMATING_CLASS); reflow(element); element.classList.add(ANIMATING_CLASS); }; const syncRomajiAnimation = ( mainElement: HTMLElement, interpolatedTimeSec: number, action: 'clear' | 'clearSung' | 'pause' | 'resume' | 'setup' | 'sung', ): void => { for (const element of getRomajiCounterparts(mainElement)) { const time = Number.parseFloat(element.dataset.time ?? '0'); const duration = Number.parseFloat(element.dataset.duration ?? '0'); const end = time + duration; switch (action) { case 'clear': clearElementAnimation(element); if (interpolatedTimeSec < time) { element.classList.remove('sung'); } break; case 'clearSung': element.classList.remove('sung'); break; case 'pause': if (element.classList.contains(ANIMATING_CLASS)) { element.classList.add(PAUSED_CLASS); } break; case 'resume': element.classList.remove(PAUSED_CLASS); break; case 'setup': if ( element.classList.contains(ANIMATING_CLASS) || element.classList.contains('sung') || interpolatedTimeSec < time || interpolatedTimeSec >= end ) { break; } setupElementAnimation(element, time, duration, interpolatedTimeSec); break; case 'sung': if (interpolatedTimeSec >= end) { element.classList.add('sung'); scheduleSungAnimationCleanup(element, duration, time, interpolatedTimeSec); } break; default: break; } } }; const clearLineRomajiState = (lineElement: HTMLElement): void => { for (const element of lineElement.querySelectorAll('.karaoke-romaji-word')) { clearElementAnimation(element); element.classList.remove('sung'); } }; export const buildLyricsDataFromDom = ({ container, lineIdPrefix, lyrics, }: BuildLyricsDataOptions): LyricsData | null => { const hasWordCues = lyricsHasWordCues(lyrics); const lines: LineData[] = []; for (let index = 0; index < lyrics.length; index += 1) { const lineElement = document.getElementById(`${lineIdPrefix}-${index}`); if (!lineElement) { continue; } const line = lyrics[index]; const startMs = getLyricLineStartMs(line); const endMs = getLineEndMs(line); const durationMs = Math.max(0, endMs - startMs); const parts = hasWordCues && line.cueLines?.length ? collectLineParts(lineElement) : []; lines.push({ accumulatedOffsetMs: 0, animationStartTimeMs: Number.POSITIVE_INFINITY, duration: durationMs / 1000, element: lineElement, hasWordCues: parts.length > 0, height: -1, isAnimating: false, isAnimationPlayStatePlaying: false, isScrolled: false, isSelected: false, lastAnimSetupAt: 0, parts, position: -1, time: startMs / 1000, }); } if (!lines.length) { return null; } const allZero = lines.every((line) => line.time === 0); let syncType: LyricsSyncType = allZero ? 'none' : 'synced'; if (hasWordCues) { syncType = 'richsync'; } return { container, lineIdPrefix, lines, syncType, }; }; export const recalculateLinePositions = (lyricsData: LyricsData): void => { const { container, lines } = lyricsData; const containerRect = container.getBoundingClientRect(); for (const line of lines) { const rect = line.element.getBoundingClientRect(); line.position = rect.top - containerRect.top + container.scrollTop; line.height = rect.height; } }; const clearPartAnimation = (part: PartData): void => { cancelSungAnimationCleanup(part.element); part.element.style.removeProperty('--karaoke-swipe-delay'); part.element.style.removeProperty('--karaoke-anim-delay'); part.element.classList.remove(ANIMATING_CLASS); part.element.classList.remove(PRE_ANIMATING_CLASS); part.element.classList.remove(PAUSED_CLASS); part.animationStartTimeMs = Number.POSITIVE_INFINITY; part.isAnimating = false; }; const clearLineAnimation = (line: LineData): void => { line.element.style.removeProperty('--karaoke-swipe-delay'); line.element.style.removeProperty('--karaoke-anim-delay'); line.element.classList.remove(LINE_ANIMATING_CLASS); line.element.classList.remove(LINE_PRE_ANIMATING_CLASS); line.element.classList.remove(PAUSED_CLASS); line.animationStartTimeMs = Number.POSITIVE_INFINITY; }; const clearWordSungState = (part: PartData): void => { part.element.classList.remove('sung'); syncRomajiAnimation(part.element, 0, 'clearSung'); }; const markWordSung = (part: PartData, interpolatedTimeSec: number): void => { part.animationStartTimeMs = Number.POSITIVE_INFINITY; part.isAnimating = false; part.element.classList.add('sung'); scheduleSungAnimationCleanup(part.element, part.duration, part.time, interpolatedTimeSec); syncRomajiAnimation(part.element, interpolatedTimeSec, 'sung'); }; const setupPartAnimation = (part: PartData, interpolatedTimeSec: number, now: number): void => { const partTimeDelta = interpolatedTimeSec - part.time; part.element.classList.remove(ANIMATING_CLASS); part.element.classList.remove(PAUSED_CLASS); part.element.style.setProperty( '--karaoke-swipe-delay', `${-partTimeDelta - part.duration * 0.1}s`, ); part.element.style.setProperty('--karaoke-anim-delay', `${-partTimeDelta}s`); part.element.classList.add(PRE_ANIMATING_CLASS); reflow(part.element); part.element.classList.add(ANIMATING_CLASS); part.animationStartTimeMs = now - partTimeDelta * 1000; part.isAnimating = true; syncRomajiAnimation(part.element, interpolatedTimeSec, 'setup'); }; const setupLineAnimation = (line: LineData, interpolatedTimeSec: number, now: number): void => { const timeDelta = interpolatedTimeSec - line.time; line.element.classList.remove(LINE_ANIMATING_CLASS); line.element.classList.remove(PAUSED_CLASS); line.element.style.setProperty('--karaoke-swipe-delay', `${-timeDelta - line.duration * 0.1}s`); line.element.style.setProperty('--karaoke-anim-delay', `${-timeDelta}s`); line.element.classList.add(LINE_PRE_ANIMATING_CLASS); reflow(line.element); line.element.classList.add(LINE_ANIMATING_CLASS); line.animationStartTimeMs = now - timeDelta * 1000; line.isAnimating = true; line.lastAnimSetupAt = now; line.isAnimationPlayStatePlaying = true; line.accumulatedOffsetMs = 0; }; const clearLineKaraokeHighlights = (lineData: LineData, interpolatedTimeSec: number): void => { if (lineData.hasWordCues) { for (const part of lineData.parts) { clearPartAnimation(part); clearWordSungState(part); syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear'); } } else { clearLineAnimation(lineData); } lineData.isSelected = false; lineData.isAnimating = false; lineData.isAnimationPlayStatePlaying = false; }; const decaySkipScrolls = (state: ScrollState, now: number): void => { let decayCount = 0; for (const decayTime of state.skipScrollsDecayTimes) { if (decayTime > now) { break; } decayCount += 1; } if (decayCount > 0) { state.skipScrollsDecayTimes = state.skipScrollsDecayTimes.slice(decayCount); state.skipScrolls = Math.max(0, state.skipScrolls - decayCount); } }; const cancelActiveLyricsScroll = (ss: ScrollState): void => { if (ss.cancelScroll) { ss.cancelScroll(); ss.cancelScroll = null; } }; export const animateLyricsScrollTo = ( state: AnimEngineState, scrollContainer: HTMLElement, scrollPos: number, smoothScroll = true, ): number => { const ss = state.scroll; const now = Date.now(); const scrollTop = scrollContainer.scrollTop; cancelActiveLyricsScroll(ss); let useSmoothScroll = smoothScroll; if (scrollTop === 0 && !ss.doneFirstInstantScroll) { useSmoothScroll = false; ss.doneFirstInstantScroll = true; ss.nextScrollAllowedTime = 0; } const delta = scrollPos - scrollTop; if (useSmoothScroll && Math.abs(delta) > 2) { const durationMs = computeScrollDurationMs(delta); ss.cancelScroll = animateScrollTop(scrollContainer, scrollPos); ss.nextScrollAllowedTime = durationMs + now + 20; ss.programmaticScrollUntil = now + durationMs + 50; ss.scrollPos = scrollPos; ss.skipScrolls += 1; ss.skipScrollsDecayTimes.push(now + 2000); return durationMs; } scrollContainer.scrollTop = scrollPos; ss.programmaticScrollUntil = now + 100; ss.scrollPos = scrollPos; ss.skipScrolls += 1; ss.skipScrollsDecayTimes.push(now + 2000); return 0; }; const scrollToPosition = ( state: AnimEngineState, scrollContainer: HTMLElement, scrollPos: number, smoothScroll: boolean, ): void => { animateLyricsScrollTo(state, scrollContainer, scrollPos, smoothScroll); }; export const tickLyricsAnimation = (state: AnimEngineState, opts: TickOptions): number => { const now = Date.now(); const { currentTimeMs, follow = true, forceResync = false, isPlaying, smoothScroll = true, } = opts; const { eventCreationTime, lyricsData, onLineActive, scrollContainer } = opts; if (currentTimeMs === 0 && !isPlaying) { return -1; } const currentTimeSec = currentTimeMs / 1000; const timeJumped = forceResync || Math.abs( currentTimeSec - state.lastTime - (eventCreationTime - state.lastEventCreationTime) / 1000, ) > TIME_JUMP_THRESHOLD; if (timeJumped) { cancelActiveLyricsScroll(state.scroll); for (const lineData of lyricsData.lines) { lineData.isAnimating = false; lineData.isSelected = false; lineData.isAnimationPlayStatePlaying = false; if (lineData.isScrolled) { lineData.element.classList.remove(LINE_ACTIVE_CLASS); lineData.isScrolled = false; } if (lineData.hasWordCues) { for (const part of lineData.parts) { clearPartAnimation(part); clearWordSungState(part); } clearLineRomajiState(lineData.element); } else { clearLineAnimation(lineData); } } state.scroll.pendingScroll = true; } state.lastTime = currentTimeSec; state.lastPlayState = isPlaying; state.lastEventCreationTime = eventCreationTime; const timeOffsetSec = isPlaying ? (now - eventCreationTime) / 1000 : 0; const playbackTimeSec = currentTimeSec + timeOffsetSec; const timingOffsetMs = lyricsData.syncType === 'richsync' ? RICHSYNC_TIMING_OFFSET_MS : SYNC_TIMING_OFFSET_MS; const interpolatedTimeSec = playbackTimeSec + timingOffsetMs / 1000; const leadTimeSec = (opts.lineLeadTimeMs ?? DEFAULT_LINE_LEAD_TIME_MS) / 1000; const { lineIdPrefix, lines, syncType } = lyricsData; const isLineSyncMode = lineIdPrefix === 'lyric'; if (syncType === 'none') { return -1; } const scrollHeight = scrollContainer.getBoundingClientRect().height; const activeElems: LineData[] = []; let newLyricSelected = timeJumped; let activeLineIndex = -1; if (!isPlaying) { for (const lineData of lines) { clearLineKaraokeHighlights(lineData, interpolatedTimeSec); if (lineData.isScrolled) { lineData.element.classList.remove(LINE_ACTIVE_CLASS); lineData.isScrolled = false; } } } for (let index = 0; index < lines.length; index += 1) { const lineData = lines[index]; const time = lineData.time; const nextTime = index + 1 < lines.length ? lines[index + 1].time : Number.POSITIVE_INFINITY; const isInLineWindow = playbackTimeSec < nextTime || playbackTimeSec < time + lineData.duration; const isScrollCandidate = playbackTimeSec >= time - leadTimeSec && isInLineWindow; const isVisuallyActive = isLineSyncMode ? playbackTimeSec >= time && isInLineWindow : isScrollCandidate; if (isScrollCandidate) { activeElems.push(lineData); state.selectedElementIndex = index; activeLineIndex = index; if (isPlaying && !lineData.isScrolled) { newLyricSelected = true; state.scroll.pendingScroll = true; lineData.isScrolled = true; onLineActive?.(index); if (!isLineSyncMode) { lineData.element.classList.add(LINE_ACTIVE_CLASS); } } } else if (lineData.isScrolled) { lineData.isScrolled = false; if (!isLineSyncMode) { lineData.element.classList.remove(LINE_ACTIVE_CLASS); } } if (isLineSyncMode) { if (isVisuallyActive) { lineData.element.classList.add(LINE_ACTIVE_CLASS); } else { lineData.element.classList.remove(LINE_ACTIVE_CLASS); } } if (!isPlaying) { continue; } if (isLineSyncMode && !lineData.hasWordCues) { continue; } const setUpEarly = 2; const effectiveEnd = Math.max(nextTime, time + lineData.duration + 0.05); const isLineInRange = interpolatedTimeSec + setUpEarly >= time && interpolatedTimeSec < effectiveEnd; if (isLineInRange) { lineData.isSelected = true; if (lineData.hasWordCues) { for (const part of lineData.parts) { part.element.classList.remove(PAUSED_CLASS); syncRomajiAnimation(part.element, interpolatedTimeSec, 'resume'); } } else { lineData.element.classList.remove(PAUSED_CLASS); } lineData.isAnimationPlayStatePlaying = true; if (!lineData.hasWordCues && !lineData.isAnimating) { setupLineAnimation(lineData, interpolatedTimeSec, now); } if (lineData.hasWordCues) { for (const part of lineData.parts) { const partEnd = part.time + part.duration; if (interpolatedTimeSec >= partEnd) { if (!part.element.classList.contains('sung')) { markWordSung(part, interpolatedTimeSec); } } else if (interpolatedTimeSec < part.time) { if (part.isAnimating || part.element.classList.contains('sung')) { clearPartAnimation(part); clearWordSungState(part); syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear'); } } else if (!part.isAnimating) { setupPartAnimation(part, interpolatedTimeSec, now); } } } } else if (lineData.isSelected) { if (lineData.hasWordCues) { for (const part of lineData.parts) { if (interpolatedTimeSec >= part.time + part.duration) { markWordSung(part, interpolatedTimeSec); } else { clearPartAnimation(part); clearWordSungState(part); syncRomajiAnimation(part.element, interpolatedTimeSec, 'clear'); } } } else { clearLineAnimation(lineData); } lineData.isSelected = false; lineData.isAnimating = false; lineData.isAnimationPlayStatePlaying = false; } } const ss = state.scroll; const scrollPausedByUser = ss.scrollResumeTime >= now; const canAutoscroll = follow && (!scrollPausedByUser || ss.pendingScroll || ss.scrollPos === -1); if (canAutoscroll) { if (activeElems.length === 0 && lines.length > 0) { activeElems.push(lines[0]); } state.lastActiveElements = activeElems.filter((entry) => playbackTimeSec >= entry.time); if (activeElems.length > 0) { const scrollPosRatio = getFollowScrollPositionRatio(opts.followScrollAlignment); const scrollPosOffset = scrollHeight * scrollPosRatio; const lastActive = activeElems[activeElems.length - 1]; const useLastActiveOnly = newLyricSelected || activeElems.length > 1; const scrollLines = useLastActiveOnly ? [lastActive] : activeElems; const positions = scrollLines .filter( (lineData, index) => playbackTimeSec < lineData.time + lineData.duration - DEFAULT_ENDING_THRESHOLD || index === scrollLines.length - 1, ) .map((lineData) => lineData.position + lineData.height / 2); if (positions.length > 0 && positions.every((pos) => pos >= 0)) { const avgPos = positions.reduce((sum, pos) => sum + pos, 0) / positions.length; let scrollPos = avgPos - scrollPosOffset; scrollPos = Math.min(scrollPos, scrollLines[0].position); scrollPos = Math.max( scrollPos, lastActive.position - scrollHeight + lastActive.height, ); scrollPos = Math.min(scrollPos, lastActive.position); scrollPos = Math.max(0, scrollPos); const shouldScroll = ss.wasUserScrolling || newLyricSelected || ss.queuedScroll || ss.pendingScroll; const canScrollNow = now > ss.nextScrollAllowedTime; if (shouldScroll) { if (canScrollNow) { ss.queuedScroll = false; ss.pendingScroll = false; scrollToPosition(state, scrollContainer, scrollPos, smoothScroll); } else { ss.queuedScroll = true; } } } } } decaySkipScrolls(ss, now); if (ss.wasUserScrolling && ss.scrollResumeTime < now) { ss.wasUserScrolling = false; } return activeLineIndex; }; export const handleLyricsUserScroll = (state: AnimEngineState, pauseDurationMs = 3000): void => { state.scroll.wasUserScrolling = true; state.scroll.scrollResumeTime = Date.now() + pauseDurationMs; }; export const resumeLyricsAutoscroll = (state: AnimEngineState): void => { state.scroll.wasUserScrolling = false; state.scroll.scrollResumeTime = 0; }; export const shouldSkipLyricsScrollEvent = (state: AnimEngineState): boolean => { if (Date.now() < state.scroll.programmaticScrollUntil) { return true; } if (state.scroll.skipScrolls > 0) { state.scroll.skipScrolls -= 1; return true; } return false; }; export const resetLyricsAnimationDom = (lyricsData: LyricsData | null): void => { if (!lyricsData) { return; } for (const line of lyricsData.lines) { line.element.classList.remove( LINE_ACTIVE_CLASS, LINE_ANIMATING_CLASS, LINE_PRE_ANIMATING_CLASS, 'singing', 'complete', ); line.isScrolled = false; line.isSelected = false; line.isAnimating = false; for (const part of line.parts) { clearPartAnimation(part); clearWordSungState(part); } clearLineRomajiState(line.element); clearLineAnimation(line); } };