feature: complete lyric features
This commit is contained in:
parent
10c5ea3916
commit
da3c070e2c
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "1.15.0",
|
"version": "2.3.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
|
@ -29,3 +29,10 @@ h2 {
|
|||||||
.text-shadow-none {
|
.text-shadow-none {
|
||||||
text-shadow: none;
|
text-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
body,
|
||||||
|
html {
|
||||||
|
position: fixed;
|
||||||
|
overflow: hidden;
|
||||||
|
overscroll-behavior: none;
|
||||||
|
}
|
||||||
|
@ -4,14 +4,23 @@
|
|||||||
import type { LyricPos } from './type';
|
import type { LyricPos } from './type';
|
||||||
import type { Spring } from '$lib/graphics/spring/spring';
|
import type { Spring } from '$lib/graphics/spring/spring';
|
||||||
|
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const scaleCurrentLine = viewportWidth > 640 ? 1.02 : 1.045 ;
|
||||||
|
|
||||||
export let line: ScriptItem;
|
export let line: ScriptItem;
|
||||||
export let index: number;
|
export let index: number;
|
||||||
export let debugMode: Boolean;
|
export let debugMode: Boolean;
|
||||||
|
export let lyricClick: Function;
|
||||||
|
|
||||||
let ref: HTMLDivElement;
|
let ref: HTMLDivElement;
|
||||||
|
let clickMask: HTMLSpanElement;
|
||||||
|
|
||||||
let time = 0;
|
let time = 0;
|
||||||
let positionX: number = 0;
|
let positionX: number = 0;
|
||||||
let positionY: number = 0;
|
let positionY: number = 0;
|
||||||
|
let scale = 1;
|
||||||
|
let opacity = 1;
|
||||||
|
let stopped = false;
|
||||||
let lastPosX: number | undefined = undefined;
|
let lastPosX: number | undefined = undefined;
|
||||||
let lastPosY: number | undefined = undefined;
|
let lastPosY: number | undefined = undefined;
|
||||||
let lastUpdateY: number | undefined = undefined;
|
let lastUpdateY: number | undefined = undefined;
|
||||||
@ -28,7 +37,7 @@
|
|||||||
time = (new Date().getTime() - lastUpdateY) / 1000;
|
time = (new Date().getTime() - lastUpdateY) / 1000;
|
||||||
springY.update(time);
|
springY.update(time);
|
||||||
positionY = springY.getCurrentPosition();
|
positionY = springY.getCurrentPosition();
|
||||||
if (!springY.arrived()) {
|
if (!springY.arrived() && !stopped) {
|
||||||
requestAnimationFrame(updateY);
|
requestAnimationFrame(updateY);
|
||||||
}
|
}
|
||||||
lastUpdateY = new Date().getTime();
|
lastUpdateY = new Date().getTime();
|
||||||
@ -66,6 +75,12 @@
|
|||||||
|
|
||||||
export const setCurrent = (isCurrent: boolean) => {
|
export const setCurrent = (isCurrent: boolean) => {
|
||||||
isCurrentLyric = isCurrent;
|
isCurrentLyric = isCurrent;
|
||||||
|
opacity = isCurrent ? 1 : 0.36;
|
||||||
|
scale = isCurrent ? scaleCurrentLine : 1;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const setBlur = (blur: number) => {
|
||||||
|
ref.style.filter = `blur(${blur}px)`;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const update = (pos: LyricPos, delay: number = 0) => {
|
export const update = (pos: LyricPos, delay: number = 0) => {
|
||||||
@ -73,10 +88,11 @@
|
|||||||
lastPosX = pos.x;
|
lastPosX = pos.x;
|
||||||
lastPosY = pos.y;
|
lastPosY = pos.y;
|
||||||
}
|
}
|
||||||
springY = createSpring(lastPosY, pos.y, .126, .85, delay);
|
springX!.setTargetPosition(pos.x, delay);
|
||||||
springX = createSpring(lastPosX, pos.x, .126, .85, delay);
|
springY!.setTargetPosition(pos.y, delay);
|
||||||
lastUpdateY = new Date().getTime();
|
lastUpdateY = new Date().getTime();
|
||||||
lastUpdateX = new Date().getTime();
|
lastUpdateX = new Date().getTime();
|
||||||
|
stopped = false;
|
||||||
requestAnimationFrame(updateY);
|
requestAnimationFrame(updateY);
|
||||||
requestAnimationFrame(updateX);
|
requestAnimationFrame(updateX);
|
||||||
lastPosX = pos.x;
|
lastPosX = pos.x;
|
||||||
@ -96,16 +112,51 @@
|
|||||||
lastPosY = pos.y;
|
lastPosY = pos.y;
|
||||||
positionX = pos.x;
|
positionX = pos.x;
|
||||||
positionY = pos.y;
|
positionY = pos.y;
|
||||||
|
springX = createSpring(pos.x, pos.x, 0.126, 0.8);
|
||||||
|
springY = createSpring(pos.y, pos.y, 0.126, 0.8);
|
||||||
|
};
|
||||||
|
|
||||||
|
export const stop = () => {
|
||||||
|
stopped = true;
|
||||||
};
|
};
|
||||||
|
|
||||||
export const getRef = () => ref;
|
export const getRef = () => ref;
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div style="transform: translateY({positionY}px) translateX({positionX}px);" class="absolute z-50" bind:this={ref}>
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
||||||
|
<!-- svelte-ignore a11y-no-static-element-interactions -->
|
||||||
|
<div
|
||||||
|
style="transform: translate3d({positionX}px, {positionY}px, 0); scale: {scale};
|
||||||
|
transition-property: scale, opacity; transition-duration: 0.5s; transition-timing-function: ease-in-out; opacity: {opacity};
|
||||||
|
transform-origin: center left;"
|
||||||
|
class="absolute z-50 w-full pr-12 lg:pr-16 cursor-default py-5"
|
||||||
|
bind:this={ref}
|
||||||
|
on:touchstart={() => {
|
||||||
|
clickMask.style.backgroundColor = "rgba(255,255,255,.3)";
|
||||||
|
}}
|
||||||
|
on:touchend={() => {
|
||||||
|
clickMask.style.backgroundColor = "transparent";
|
||||||
|
}}
|
||||||
|
on:click={() => {
|
||||||
|
lyricClick(index);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<span class="absolute w-[calc(100%-2.5rem)] lg:w-[calc(100%-2.75rem)] h-full
|
||||||
|
-translate-x-2 lg:-translate-x-5 -translate-y-5 rounded-lg duration-300 lg:hover:bg-[rgba(255,255,255,.15)]" bind:this={clickMask}>
|
||||||
|
|
||||||
|
</span>
|
||||||
{#if debugMode}
|
{#if debugMode}
|
||||||
<span class="text-lg absolute -translate-y-7">Line idx: {index}, duration: {(line.end - line.start).toFixed(3)}</span>
|
<span class="text-lg absolute -translate-y-7">
|
||||||
|
{index}: duration: {(line.end - line.start).toFixed(3)}, {line.start.toFixed(3)}~{line.end.toFixed(3)}
|
||||||
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
<span class={`text-white text-5xl font-semibold text-shadow-lg ${!isCurrentLyric && 'opacity-50'} `}>
|
<span class={`text-white text-[2rem] leading-9 lg:text-5xl lg:leading-[4rem] font-semibold text-shadow-lg mr-4`}>
|
||||||
{line.text}
|
{line.text}
|
||||||
</span>
|
</span>
|
||||||
|
{#if line.translation}
|
||||||
|
<br />
|
||||||
|
<span class={`pl-2 relative text-xl lg:text-2xl top-2 duration-300`}>
|
||||||
|
{line.translation}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
@ -3,9 +3,17 @@
|
|||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
import type { ScriptItem } from '$lib/lyrics/type';
|
import type { ScriptItem } from '$lib/lyrics/type';
|
||||||
import LyricLine from './lyricLine.svelte';
|
import LyricLine from './lyricLine.svelte';
|
||||||
import type { LyricLayout, LyricPos } from './type';
|
|
||||||
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||||
|
|
||||||
|
// constants
|
||||||
|
const viewportHeight = document.documentElement.clientHeight;
|
||||||
|
const viewportWidth = document.documentElement.clientWidth;
|
||||||
|
const marginY = viewportWidth > 640 ? 36 : 0 ;
|
||||||
|
const currentLyrictTop = viewportHeight * 0.02;
|
||||||
|
const deceleration = 0.95; // Velocity decay factor for inertia
|
||||||
|
const minVelocity = 0.1; // Minimum velocity to stop inertia
|
||||||
|
document.body.style.overflow = 'hidden';
|
||||||
|
|
||||||
// Props
|
// Props
|
||||||
export let originalLyrics: LrcJsonData;
|
export let originalLyrics: LrcJsonData;
|
||||||
export let progress: number;
|
export let progress: number;
|
||||||
@ -15,19 +23,31 @@
|
|||||||
let lyricLines: ScriptItem[] = [];
|
let lyricLines: ScriptItem[] = [];
|
||||||
let lyricExists = false;
|
let lyricExists = false;
|
||||||
let lyricsContainer: HTMLDivElement | null;
|
let lyricsContainer: HTMLDivElement | null;
|
||||||
let lyricLayouts: LyricLayout[] = [];
|
|
||||||
let debugMode = false;
|
let debugMode = false;
|
||||||
|
let nextUpdate = 0;
|
||||||
|
let lastProgress = 0;
|
||||||
let showTranslation = false;
|
let showTranslation = false;
|
||||||
|
let scrollEventAdded = false;
|
||||||
|
let scrolling = false;
|
||||||
|
let scrollingTimeout: Timer;
|
||||||
|
let lastY: number; // For tracking touch movements
|
||||||
|
let lastTime: number; // For tracking time between touch moves
|
||||||
|
let velocityY = 0; // Vertical scroll velocity
|
||||||
|
let inertiaFrame: number; // For storing the requestAnimationFrame reference
|
||||||
|
|
||||||
// References to lyric elements
|
// References to lyric elements
|
||||||
let lyricElements: HTMLDivElement[] = [];
|
let lyricElements: HTMLDivElement[] = [];
|
||||||
let lyricComponents: LyricLine[] = [];
|
let lyricComponents: LyricLine[] = [];
|
||||||
let lyricTopList: number[] = [];
|
let lyricTopList: number[] = [];
|
||||||
let nextUpdate = 0;
|
|
||||||
const marginY = 48;
|
let currentLyricIndex: number;
|
||||||
|
|
||||||
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||||
|
|
||||||
|
$: {
|
||||||
|
currentLyricIndex = getLyricIndex(progress);
|
||||||
|
}
|
||||||
|
|
||||||
function initLyricComponents() {
|
function initLyricComponents() {
|
||||||
initLyricTopList();
|
initLyricTopList();
|
||||||
for (let i = 0; i < lyricComponents.length; i++) {
|
for (let i = 0; i < lyricComponents.length; i++) {
|
||||||
@ -48,31 +68,24 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function computeLayout(progress: number) {
|
function computeLayout() {
|
||||||
if (!originalLyrics.scripts) return;
|
if (!originalLyrics.scripts) return;
|
||||||
const currentLyricIndex = getLyricIndex(progress);
|
|
||||||
const currentLyricDuration =
|
const currentLyricDuration =
|
||||||
originalLyrics.scripts[currentLyricIndex].end - originalLyrics.scripts[currentLyricIndex].start;
|
originalLyrics.scripts[currentLyricIndex].end - originalLyrics.scripts[currentLyricIndex].start;
|
||||||
const relativeOrigin = lyricTopList[currentLyricIndex];
|
const relativeOrigin = lyricTopList[currentLyricIndex] - currentLyrictTop;
|
||||||
for (let i = 0; i < lyricElements.length; i++) {
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
const currentLyricComponent = lyricComponents[i];
|
const currentLyricComponent = lyricComponents[i];
|
||||||
lyricLayouts[i] = {
|
|
||||||
blur: 0,
|
|
||||||
scale: 1,
|
|
||||||
pos: {
|
|
||||||
y: lyricTopList[i] - relativeOrigin,
|
|
||||||
x: 0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
let delay = 0;
|
let delay = 0;
|
||||||
if (i <= currentLyricIndex) {
|
if (i <= currentLyricIndex) {
|
||||||
delay = 0;
|
delay = 0;
|
||||||
} else {
|
} else {
|
||||||
delay = 0.013 + Math.min(Math.min(currentLyricDuration, 0.3), 0.075 * (i - currentLyricIndex));
|
delay = 0.013 + Math.min(Math.min(currentLyricDuration, 0.1), 0.075 * (i - currentLyricIndex));
|
||||||
}
|
}
|
||||||
|
const offset = Math.abs(i - currentLyricIndex);
|
||||||
|
let blurRadius = Math.min(offset * 1.7, 16);
|
||||||
|
currentLyricComponent.setBlur(blurRadius);
|
||||||
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
currentLyricComponent.update({ x: 0, y: lyricTopList[i] - relativeOrigin }, delay);
|
||||||
}
|
}
|
||||||
nextUpdate = originalLyrics.scripts[currentLyricIndex + 1].start;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
@ -88,9 +101,112 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleScroll(deltaY: number) {
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
const currentY = currentLyricComponent.getInfo().y;
|
||||||
|
currentLyricComponent.setBlur(0);
|
||||||
|
currentLyricComponent.stop();
|
||||||
|
currentLyricComponent.setY(currentY - deltaY);
|
||||||
|
}
|
||||||
|
scrolling = true;
|
||||||
|
//if (scrollingTimeout) clearTimeout(scrollingTimeout);
|
||||||
|
scrollingTimeout = setTimeout(() => {
|
||||||
|
scrolling = false;
|
||||||
|
}, 5000);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch start event
|
||||||
|
function handleTouchStart(event: TouchEvent) {
|
||||||
|
lastY = event.touches[0].clientY;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch move event
|
||||||
|
function handleTouchMove(event: TouchEvent) {
|
||||||
|
const currentY = event.touches[0].clientY;
|
||||||
|
const currentTime = Date.now();
|
||||||
|
const deltaY = lastY - currentY; // Calculate vertical swipe distance
|
||||||
|
const deltaTime = currentTime - lastTime;
|
||||||
|
|
||||||
|
// Calculate the scroll velocity (change in Y over time)
|
||||||
|
if (deltaTime > 0) {
|
||||||
|
velocityY = deltaY / deltaTime;
|
||||||
|
}
|
||||||
|
|
||||||
|
handleScroll(deltaY); // Simulate the scroll event
|
||||||
|
lastY = currentY; // Update lastY for the next move event
|
||||||
|
lastTime = currentTime; // Update the lastTime for the next move event
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle the touch end event
|
||||||
|
function handleTouchEnd() {
|
||||||
|
// Start inertia scrolling based on the velocity
|
||||||
|
function inertiaScroll() {
|
||||||
|
if (Math.abs(velocityY) < minVelocity) {
|
||||||
|
cancelAnimationFrame(inertiaFrame);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
handleScroll(velocityY * 16); // Multiply by frame time (16ms) to get smooth scroll
|
||||||
|
velocityY *= deceleration; // Apply deceleration to velocity
|
||||||
|
inertiaFrame = requestAnimationFrame(inertiaScroll); // Continue scrolling in next frame
|
||||||
|
}
|
||||||
|
inertiaScroll();
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if (lyricsContainer && !scrollEventAdded) {
|
||||||
|
// Wheel event for desktop
|
||||||
|
lyricsContainer.addEventListener(
|
||||||
|
'wheel',
|
||||||
|
(e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
const deltaY = e.deltaY;
|
||||||
|
handleScroll(deltaY);
|
||||||
|
},
|
||||||
|
{ passive: false }
|
||||||
|
);
|
||||||
|
|
||||||
|
// Touch events for mobile
|
||||||
|
lyricsContainer.addEventListener('touchstart', handleTouchStart, { passive: true });
|
||||||
|
lyricsContainer.addEventListener('touchmove', handleTouchMove, { passive: false });
|
||||||
|
lyricsContainer.addEventListener('touchend', handleTouchEnd, { passive: true });
|
||||||
|
|
||||||
|
scrollEventAdded = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (lyricsContainer && lyricComponents.length > 0) {
|
if (lyricsContainer && lyricComponents.length > 0) {
|
||||||
if (progress >= nextUpdate) computeLayout(progress);
|
if (progress >= nextUpdate - 0.5 && !scrolling) {
|
||||||
|
console.log("computeLayout")
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
if (Math.abs(lastProgress - progress) > 0) {
|
||||||
|
scrolling = false;
|
||||||
|
}
|
||||||
|
if (lastProgress - progress > 0) {
|
||||||
|
computeLayout();
|
||||||
|
nextUpdate = progress;
|
||||||
|
} else {
|
||||||
|
const lyricLength = originalLyrics.scripts!.length;
|
||||||
|
const currentEnd = originalLyrics.scripts![currentLyricIndex].end;
|
||||||
|
const nextStart = originalLyrics.scripts![Math.min(currentLyricIndex + 1, lyricLength - 1)].start;
|
||||||
|
if (currentEnd !== nextStart) {
|
||||||
|
nextUpdate = currentEnd;
|
||||||
|
}
|
||||||
|
else {
|
||||||
|
nextUpdate = nextStart;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
lastProgress = progress;
|
||||||
|
}
|
||||||
|
|
||||||
|
$: {
|
||||||
|
for (let i = 0; i < lyricElements.length; i++) {
|
||||||
|
const isCurrent = i == currentLyricIndex;
|
||||||
|
const currentLyricComponent = lyricComponents[i];
|
||||||
|
currentLyricComponent.setCurrent(isCurrent);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -102,17 +218,43 @@
|
|||||||
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
debugMode = localStorage.getItem('debugMode')!.toLowerCase() === 'true';
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// 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');
|
||||||
|
computeLayout();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function lyricClick(lyricIndex: number) {
|
||||||
|
if (player === null || originalLyrics.scripts === undefined) return;
|
||||||
|
player.currentTime = originalLyrics.scripts[lyricIndex].start;
|
||||||
|
player.play();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
<svelte:window on:keydown={onKeyDown} />
|
||||||
|
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute">
|
||||||
|
progress: {progress.toFixed(2)}, nextUpdate: {nextUpdate}, scrolling: {scrolling}, current: {currentLyricIndex}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if originalLyrics && originalLyrics.scripts}
|
{#if originalLyrics && originalLyrics.scripts}
|
||||||
<div
|
<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
|
class="absolute top-[6.5rem] md:top-36 xl:top-0 w-screen xl:w-[52vw] px-6 md:px-12
|
||||||
text-left no-scrollbar overflow-y-auto z-[1] pt-16"
|
lg:px-[7.5rem] xl:left-[46vw] xl:px-[3vw] h-[calc(100vh-17rem)] xl:h-screen font-sans
|
||||||
|
text-left no-scrollbar z-[1] pt-16 overflow-hidden"
|
||||||
bind:this={lyricsContainer}
|
bind:this={lyricsContainer}
|
||||||
>
|
>
|
||||||
{#each lyricLines as lyric, i}
|
{#each lyricLines as lyric, i}
|
||||||
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} />
|
<LyricLine line={lyric} index={i} bind:this={lyricComponents[i]} {debugMode} {lyricClick} />
|
||||||
{/each}
|
{/each}
|
||||||
<div class="relative w-full h-[50rem]"></div>
|
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
6
src/lib/components/lyrics/type.d.ts
vendored
6
src/lib/components/lyrics/type.d.ts
vendored
@ -1,10 +1,4 @@
|
|||||||
export interface LyricPos {
|
export interface LyricPos {
|
||||||
x: number;
|
x: number;
|
||||||
y: number;
|
y: number;
|
||||||
}
|
|
||||||
|
|
||||||
export interface LyricLayout {
|
|
||||||
pos: LyricPos;
|
|
||||||
blur: number;
|
|
||||||
scale: number;
|
|
||||||
}
|
}
|
Loading…
Reference in New Issue
Block a user