434 lines
16 KiB
Svelte
434 lines
16 KiB
Svelte
<script lang="ts">
|
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
|
import type { LrcJsonData } from '$lib/lyrics/LRCparser';
|
|
import nextUpdate from '$lib/state/nextUpdate';
|
|
import truncate from '$lib/utils/truncate';
|
|
|
|
// Component input properties
|
|
export let lyrics: string[];
|
|
export let originalLyrics: LrcJsonData;
|
|
export let progress: number;
|
|
export let player: HTMLAudioElement | null;
|
|
|
|
// Local state and variables
|
|
let getLyricIndex: Function;
|
|
let debugMode = false;
|
|
let showTranslation = false;
|
|
if (localStorage.getItem('debugMode') == null) {
|
|
localStorage.setItem('debugMode', 'false');
|
|
}
|
|
else {
|
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === "true";
|
|
}
|
|
if (localStorage.getItem('showTranslation') == null) {
|
|
localStorage.setItem('showTranslation', 'false');
|
|
}
|
|
else {
|
|
showTranslation = localStorage.getItem('showTranslation')!.toLowerCase() === "true";
|
|
}
|
|
let currentLyricIndex = -1;
|
|
let currentPositionIndex = -1;
|
|
let currentAnimationIndex = -1;
|
|
let lyricsContainer: HTMLDivElement | null;
|
|
let localProgress = 0;
|
|
let lastScroll = 0;
|
|
let scrolling = false;
|
|
let scriptScrolling = false;
|
|
|
|
let currentLyricTopMargin = 208;
|
|
|
|
// References to lyric elements
|
|
let refs: HTMLParagraphElement[] = [];
|
|
let _refs: any[] = [];
|
|
$: refs = _refs.filter(Boolean);
|
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
|
|
|
|
|
// handle KeyDown event
|
|
function onKeyDown(e: KeyboardEvent) {
|
|
if (e.altKey && e.shiftKey && (e.metaKey || e.key === 'OS') && e.key === 'Enter') {
|
|
debugMode = !debugMode;
|
|
localStorage.setItem('debugMode', debugMode ? 'true' : 'false');
|
|
}
|
|
else if (e.key === 't') {
|
|
showTranslation = !showTranslation;
|
|
localStorage.setItem('showTranslation', showTranslation ? 'true' : 'false');
|
|
setTimeout(() => {
|
|
scrollToLyric(refs[currentPositionIndex])
|
|
}, 50);
|
|
}
|
|
}
|
|
|
|
// using for debug mode
|
|
function extractTranslateValue(s: string): string | null {
|
|
const regex = /translateY\((-?\d*px)\)/;
|
|
let arr = regex.exec(s);
|
|
return arr==null ? null : arr[1];
|
|
}
|
|
|
|
// Helper function to get CSS class for a lyric based on its index and progress
|
|
function getClass(lyricIndex: number, progress: number) {
|
|
if (!originalLyrics.scripts) return 'previous-lyric';
|
|
if (currentLyricIndex === lyricIndex) return 'current-lyric';
|
|
else if (progress > originalLyrics.scripts[lyricIndex].end) return 'after-lyric';
|
|
else return 'previous-lyric';
|
|
}
|
|
|
|
// Function to move the lyrics up smoothly
|
|
async function moveToNextLine(h: number) {
|
|
console.debug(new Date().getTime() , 'moveToNextLine', h);
|
|
// the line that's going to process (like a pointer)
|
|
// by default, it's "the next line" after the lift
|
|
let processingLineIndex = currentPositionIndex + 2;
|
|
|
|
// modify translateY of all lines in viewport one by one to lift them up
|
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
|
const lyric = refs[i];
|
|
lyric.style.transition =
|
|
`transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease,
|
|
font-size 200ms ease, scale 250ms ease`;
|
|
lyric.style.transform = `translateY(${-h}px)`;
|
|
processingLineIndex = i;
|
|
await sleep(75);
|
|
const twoLinesAhead = refs[i - 2];
|
|
if (lyricsContainer && twoLinesAhead.getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break;
|
|
}
|
|
|
|
if (refs.length - processingLineIndex < 3) {
|
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
|
const lyric = refs[i];
|
|
lyric.style.transition =
|
|
'transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
lyric.style.transform = `translateY(${-h}px)`;
|
|
processingLineIndex = i;
|
|
await sleep(75);
|
|
}
|
|
} else {
|
|
for (let i = processingLineIndex; i < refs.length; i++) {
|
|
refs[i].style.transition =
|
|
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
const height = refs[i].getBoundingClientRect().height;
|
|
refs[i].style.transform = `translateY(${-height}px)`;
|
|
}
|
|
}
|
|
|
|
// wait until the animation end
|
|
await sleep(650);
|
|
|
|
// clear the transition to let the following style changes could be done without animation
|
|
for (let i = 0; i < refs.length; i++) {
|
|
refs[i].style.transition =
|
|
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
|
}
|
|
// reset the translateY, and immediately scroll down to provide visual stability
|
|
for (let i = 0; i < refs.length; i++) {
|
|
refs[i].style.transform = `translateY(0px)`;
|
|
}
|
|
scriptScrolling = true;
|
|
if (lyricsContainer !== null) {
|
|
lyricsContainer.scrollTop += h;
|
|
}
|
|
await sleep(500);
|
|
scriptScrolling = false;
|
|
}
|
|
|
|
// Scroll the lyrics container to the given lyric
|
|
async function scrollToLyric(currentLyric: HTMLParagraphElement) {
|
|
if (!originalLyrics || !originalLyrics.scripts || !lyricsContainer) return;
|
|
scriptScrolling = true;
|
|
lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - currentLyricTopMargin;
|
|
for (let i = 0; i < refs.length; i++) {
|
|
refs[i].style.transform = 'translateY(0px)';
|
|
}
|
|
setTimeout(() => {
|
|
scriptScrolling = false;
|
|
}, 500);
|
|
}
|
|
|
|
// Handle scroll events in the lyrics container
|
|
function scrollHandler() {
|
|
scrolling = !scriptScrolling;
|
|
if (scrolling && originalLyrics.scripts) {
|
|
lastScroll = new Date().getTime();
|
|
for (let i = 0; i < originalLyrics.scripts.length; i++) {
|
|
if (refs[i]) {
|
|
refs[i].style.filter = 'blur(0px)';
|
|
}
|
|
}
|
|
}
|
|
setTimeout(() => {
|
|
if (new Date().getTime() - lastScroll > 5000) {
|
|
scrolling = false;
|
|
}
|
|
}, 5500);
|
|
}
|
|
|
|
// Utility function to create a sleep/delay
|
|
function sleep(ms: number) {
|
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
}
|
|
|
|
// Scroll to corresponding lyric while adjusting progress
|
|
$: {
|
|
if ($userAdjustingProgress == true) {
|
|
const currentLyric = refs[getLyricIndex(progress)];
|
|
scrollToLyric(currentLyric);
|
|
}
|
|
}
|
|
|
|
// Update the current lyric and apply blur effect based on the progress
|
|
// worked in real-time.
|
|
$: {
|
|
(() => {
|
|
if (!lyricsContainer || !originalLyrics.scripts) return;
|
|
|
|
const scripts = originalLyrics.scripts;
|
|
currentPositionIndex = getLyricIndex(progress);
|
|
const cl = scripts[currentPositionIndex];
|
|
|
|
if (cl.start <= progress && progress <= cl.end) {
|
|
currentLyricIndex = currentPositionIndex;
|
|
nextUpdate.set(cl.end);
|
|
} else {
|
|
currentLyricIndex = -1;
|
|
nextUpdate.set(cl.start);
|
|
}
|
|
|
|
const currentLyric = refs[currentPositionIndex];
|
|
if ($userAdjustingProgress || scrolling || currentLyric.getBoundingClientRect().top < 0) return;
|
|
|
|
for (let i = 0; i < scripts.length; i++) {
|
|
const offset = Math.abs(i - currentPositionIndex);
|
|
const blurRadius = Math.min(offset * 0.96, 16);
|
|
if (refs[i]) {
|
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
|
}
|
|
}
|
|
})();
|
|
}
|
|
|
|
// Main function that control's lyrics update during playing
|
|
// triggered by nextUpdate's update
|
|
async function lyricsUpdate(){
|
|
if (
|
|
currentPositionIndex < 0 ||
|
|
currentPositionIndex === currentAnimationIndex ||
|
|
$userAdjustingProgress === true ||
|
|
scrolling
|
|
) return;
|
|
|
|
const currentLyric = refs[currentPositionIndex];
|
|
const currentLyricRect = currentLyric.getBoundingClientRect();
|
|
|
|
if (originalLyrics.scripts && currentLyricRect.top < 0) return;
|
|
|
|
const offsetHeight = truncate(currentLyricRect.top - currentLyricTopMargin, 0, Infinity);
|
|
|
|
// prepare current line
|
|
currentLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
|
currentLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
|
|
|
for (let i = currentPositionIndex - 1; i >= 0; i--) {
|
|
refs[i].style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
|
refs[i].style.transform = `translateY(${-offsetHeight}px)`;
|
|
}
|
|
if (currentPositionIndex + 1 < refs.length) {
|
|
const nextLyric = refs[currentPositionIndex + 1];
|
|
nextLyric.style.transition = `transform .6s cubic-bezier(.28,.01,.29,.99), filter 200ms ease,
|
|
opacity 200ms ease, font-size 200ms ease, scale 250ms ease`;
|
|
nextLyric.style.transform = `translateY(${-offsetHeight}px)`;
|
|
await moveToNextLine(offsetHeight);
|
|
}
|
|
currentAnimationIndex = currentPositionIndex;
|
|
}
|
|
|
|
|
|
nextUpdate.subscribe(lyricsUpdate)
|
|
|
|
// Process while user is adjusting progress
|
|
userAdjustingProgress.subscribe((adjusting) => {
|
|
if (!originalLyrics) return;
|
|
const scripts = originalLyrics.scripts;
|
|
if (!scripts) return;
|
|
if (adjusting) {
|
|
for (let i = 0; i < scripts.length; i++) {
|
|
refs[i].style.filter = `blur(0px)`;
|
|
}
|
|
} else {
|
|
for (let i = 0; i < scripts.length; i++) {
|
|
const offset = Math.abs(i - currentPositionIndex);
|
|
const blurRadius = Math.min(offset * 1.5, 16);
|
|
if (refs[i]) {
|
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
|
}
|
|
}
|
|
}
|
|
});
|
|
|
|
// Handle progress changes at system level
|
|
progressBarRaw.subscribe((progress: number) => {
|
|
if ($userAdjustingProgress === false && getLyricIndex) {
|
|
if (Math.abs(localProgress - progress) > 0.6) {
|
|
const currentLyric = refs[getLyricIndex(progress)];
|
|
scrollToLyric(currentLyric);
|
|
}
|
|
localProgress = progress;
|
|
}
|
|
});
|
|
|
|
function lyricClick(lyricIndex: number) {
|
|
if (player===null || originalLyrics.scripts === undefined) return;
|
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
|
player.play()
|
|
}
|
|
|
|
</script>
|
|
|
|
<svelte:window on:keydown={onKeyDown} />
|
|
|
|
{#if debugMode && lyricsContainer}
|
|
<div
|
|
class="absolute top-6 right-10 font-mono text-sm backdrop-blur-md z-20 bg-[rgba(255,255,255,.15)]
|
|
px-2 rounded-xl text-white">
|
|
<p>
|
|
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex}
|
|
AnimationIndex:{currentAnimationIndex}
|
|
NextUpdate: {$nextUpdate}
|
|
Progress: {progress.toFixed(2)}
|
|
scrollPosition: {lyricsContainer.scrollTop}
|
|
</p>
|
|
</div>
|
|
{/if}
|
|
|
|
|
|
{#if lyrics && originalLyrics && originalLyrics.scripts}
|
|
<div
|
|
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12 lg:px-[7.5rem] xl:left-[45vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
|
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
|
bind:this={lyricsContainer}
|
|
on:scroll={scrollHandler}
|
|
>
|
|
{#each lyrics as lyric, i}
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
|
<div bind:this={_refs[i]} class="relative h-fit text-shadow-lg" on:click={() => {lyricClick(i)}} >
|
|
{#if debugMode && refs[i] && refs[i].style !== undefined}
|
|
<span class="previous-lyric !text-lg !absolute !-translate-y-12">{i}
|
|
{originalLyrics.scripts[i].start} ~ {originalLyrics.scripts[i].end}
|
|
tY: {extractTranslateValue(refs[i].style.transform)}
|
|
top: {Math.round(refs[i].getBoundingClientRect().top)}px
|
|
</span>
|
|
{/if}
|
|
|
|
<p class={`${getClass(i, progress)} hover:bg-[rgba(200,200,200,0.2)] pl-2 rounded-lg duration-300 cursor-pointer `}>
|
|
{#if originalLyrics.scripts[i].singer}
|
|
<span class="singer">{originalLyrics.scripts[i].singer}</span>
|
|
{/if}
|
|
{lyric}
|
|
</p>
|
|
{#if originalLyrics.scripts[i].translation && showTranslation}
|
|
<div class={`${getClass(i, progress)} pl-2 relative !text-xl !md:text-2xl lg:!text-3xl !top-1 duration-300 `}>{originalLyrics.scripts[i].translation}</div>
|
|
{/if}
|
|
</div>
|
|
{/each}
|
|
<div class="relative w-full h-[50rem]"></div>
|
|
</div>
|
|
{/if}
|
|
|
|
<!--suppress CssUnusedSymbol -->
|
|
<style>
|
|
:root {
|
|
--lyric-mobile-font-size: 2rem;
|
|
--lyric-mobile-line-height: 2.4rem;
|
|
--lyric-mobile-margin: 1.5rem 0;
|
|
--lyric-mobile-font-weight: 600;
|
|
--lyric-desktop-font-size: 3.5rem;
|
|
--lyric-desktop-line-height: 4.5rem;
|
|
--lyric-desktop-margin: 1.75rem 0;
|
|
}
|
|
|
|
.lyrics {
|
|
mask-image: linear-gradient(
|
|
rgba(0, 0, 0, 0) 0%,
|
|
rgba(0, 0, 0, 1) 2rem,
|
|
rgba(0, 0, 0, 1) calc(100% - 5rem),
|
|
rgba(0, 0, 0, 0) 100%
|
|
);
|
|
}
|
|
|
|
.singer {
|
|
position: absolute;
|
|
display: inline-block;
|
|
bottom: 50%;
|
|
transform: translateY(calc(50%)) translateX(-3rem);
|
|
padding: 0.1rem 0.4rem;
|
|
background: rgba(255,255,255,.15);
|
|
border-radius: 0.4rem;
|
|
font-size: 1.5rem;
|
|
line-height: 2rem;
|
|
}
|
|
|
|
.no-scrollbar {
|
|
scrollbar-width: none;
|
|
}
|
|
|
|
.no-scrollbar::-webkit-scrollbar {
|
|
width: 0;
|
|
}
|
|
|
|
.current-lyric {
|
|
position: relative;
|
|
color: white;
|
|
font-weight: var(--lyric-mobile-font-weight);
|
|
font-size: var(--lyric-mobile-font-size);
|
|
line-height: var(--lyric-mobile-line-height);
|
|
margin: var(--lyric-mobile-margin);
|
|
scale: 1.02 1;
|
|
top: 1rem;
|
|
}
|
|
|
|
.previous-lyric {
|
|
position: relative;
|
|
color: rgba(255, 255, 255, 0.48);
|
|
font-weight: var(--lyric-mobile-font-weight);
|
|
font-size: var(--lyric-mobile-font-size);
|
|
line-height: var(--lyric-mobile-line-height);
|
|
margin: var(--lyric-mobile-margin);
|
|
top: 1rem;
|
|
}
|
|
|
|
.after-lyric {
|
|
position: relative;
|
|
color: rgba(255, 255, 255, 0.48);
|
|
font-weight: var(--lyric-mobile-font-weight);
|
|
font-size: var(--lyric-mobile-font-size);
|
|
line-height: var(--lyric-mobile-line-height);
|
|
margin: var(--lyric-mobile-margin);
|
|
top: 1rem;
|
|
}
|
|
|
|
@media (min-width: 1024px) {
|
|
.current-lyric {
|
|
font-size: var(--lyric-desktop-font-size);
|
|
line-height: var(--lyric-desktop-line-height);
|
|
margin: var(--lyric-desktop-margin);
|
|
}
|
|
|
|
.after-lyric {
|
|
font-size: var(--lyric-desktop-font-size);
|
|
line-height: var(--lyric-desktop-line-height);
|
|
margin: var(--lyric-desktop-margin);
|
|
}
|
|
|
|
.previous-lyric {
|
|
font-size: var(--lyric-desktop-font-size);
|
|
line-height: var(--lyric-desktop-line-height);
|
|
margin: var(--lyric-desktop-margin);
|
|
}
|
|
}
|
|
</style>
|