feature: Apple Music-style lyrics scrolling
This commit is contained in:
parent
b4eef94ed4
commit
7b87a649ac
32
package.json
32
package.json
@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "aquavox",
|
"name": "aquavox",
|
||||||
"version": "1.9.1",
|
"version": "1.10.0",
|
||||||
"private": false,
|
"private": false,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite dev",
|
"dev": "vite dev",
|
||||||
@ -14,26 +14,26 @@
|
|||||||
"go": "PORT=4173 node build"
|
"go": "PORT=4173 node build"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@iconify/svelte": "^4.0.1",
|
"@iconify/svelte": "^4.0.2",
|
||||||
"@sveltejs/adapter-auto": "^3.0.0",
|
"@sveltejs/adapter-auto": "^3.2.0",
|
||||||
"@sveltejs/adapter-node": "^5.0.1",
|
"@sveltejs/adapter-node": "^5.0.1",
|
||||||
"@sveltejs/kit": "^2.0.0",
|
"@sveltejs/kit": "^2.5.9",
|
||||||
"@sveltejs/vite-plugin-svelte": "^3.0.0",
|
"@sveltejs/vite-plugin-svelte": "^3.1.0",
|
||||||
"@types/eslint": "^8.56.0",
|
"@types/eslint": "^8.56.10",
|
||||||
"@types/uuid": "^9.0.8",
|
"@types/uuid": "^9.0.8",
|
||||||
"autoprefixer": "^10.4.19",
|
"autoprefixer": "^10.4.19",
|
||||||
"eslint": "^8.56.0",
|
"eslint": "^8.57.0",
|
||||||
"eslint-config-prettier": "^9.1.0",
|
"eslint-config-prettier": "^9.1.0",
|
||||||
"eslint-plugin-svelte": "^2.35.1",
|
"eslint-plugin-svelte": "^2.39.0",
|
||||||
"postcss": "^8.4.38",
|
"postcss": "^8.4.38",
|
||||||
"prettier": "^3.1.1",
|
"prettier": "^3.2.5",
|
||||||
"prettier-plugin-svelte": "^3.1.2",
|
"prettier-plugin-svelte": "^3.2.3",
|
||||||
"svelte": "^4.2.7",
|
"svelte": "^4.2.17",
|
||||||
"svelte-check": "^3.6.0",
|
"svelte-check": "^3.7.1",
|
||||||
"tailwindcss": "^3.4.3",
|
"tailwindcss": "^3.4.3",
|
||||||
"typescript": "^5.0.0",
|
"typescript": "^5.4.5",
|
||||||
"vite": "^5.0.3",
|
"vite": "^5.2.11",
|
||||||
"vitest": "^1.2.0"
|
"vitest": "^1.6.0"
|
||||||
},
|
},
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@ -42,9 +42,9 @@
|
|||||||
"jotai": "^2.8.0",
|
"jotai": "^2.8.0",
|
||||||
"jotai-svelte": "^0.0.2",
|
"jotai-svelte": "^0.0.2",
|
||||||
"localforage": "^1.10.0",
|
"localforage": "^1.10.0",
|
||||||
|
"lrc-parser-ts": "^1.0.3",
|
||||||
"music-metadata-browser": "^2.5.10",
|
"music-metadata-browser": "^2.5.10",
|
||||||
"rollup-plugin-node-polyfills": "^0.2.1",
|
"rollup-plugin-node-polyfills": "^0.2.1",
|
||||||
"srt-parser-2": "^1.2.3",
|
|
||||||
"uuid": "^9.0.1"
|
"uuid": "^9.0.1"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
584
pnpm-lock.yaml
584
pnpm-lock.yaml
File diff suppressed because it is too large
Load Diff
@ -6,6 +6,10 @@ describe('formatDuration test', () => {
|
|||||||
expect(formatDuration(120)).toBe('2:00');
|
expect(formatDuration(120)).toBe('2:00');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it('converts 119.935429 seconds to "1:59"', () => {
|
||||||
|
expect(formatDuration(119.935429)).toBe('1:59');
|
||||||
|
});
|
||||||
|
|
||||||
it('converts 185 seconds to "3:05"', () => {
|
it('converts 185 seconds to "3:05"', () => {
|
||||||
expect(formatDuration(185)).toBe('3:05');
|
expect(formatDuration(185)).toBe('3:05');
|
||||||
});
|
});
|
||||||
|
@ -55,5 +55,6 @@
|
|||||||
height: 100%;
|
height: 100%;
|
||||||
opacity: 0;
|
opacity: 0;
|
||||||
transition: .45s;
|
transition: .45s;
|
||||||
|
filter: brightness(0.8);
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -1,6 +1,8 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import formatDuration from '$lib/formatDuration';
|
import formatDuration from '$lib/formatDuration';
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
|
||||||
export let name: string;
|
export let name: string;
|
||||||
export let singer: string = '';
|
export let singer: string = '';
|
||||||
@ -10,33 +12,30 @@
|
|||||||
export let volume: number = 1;
|
export let volume: number = 1;
|
||||||
export let clickPlay: Function;
|
export let clickPlay: Function;
|
||||||
export let adjustProgress: Function;
|
export let adjustProgress: Function;
|
||||||
export let adjustRealProgress: Function;
|
export let adjustDisplayProgress: Function;
|
||||||
export let adjustVolume: Function;
|
export let adjustVolume: Function;
|
||||||
export let onSlide: boolean;
|
|
||||||
export let setOnSlide: Function;
|
|
||||||
export let hasLyrics: boolean;
|
export let hasLyrics: boolean;
|
||||||
|
|
||||||
let progressBar: HTMLInputElement;
|
let progressBar: HTMLDivElement;
|
||||||
let volumeBar: HTMLInputElement;
|
let volumeBar: HTMLInputElement;
|
||||||
let showInfoTop: boolean = false;
|
let showInfoTop: boolean = false;
|
||||||
let isInfoTopOverflowing = false;
|
let isInfoTopOverflowing = false;
|
||||||
let songInfoTopContainer: HTMLDivElement;
|
let songInfoTopContainer: HTMLDivElement;
|
||||||
let songInfoTopContent: HTMLSpanElement;
|
let songInfoTopContent: HTMLSpanElement;
|
||||||
|
|
||||||
const mql = window.matchMedia('(max-width: 1280px)');
|
const mql = window.matchMedia('(max-width: 1280px)');
|
||||||
|
|
||||||
function progressBarOnChange(e: any) {
|
|
||||||
adjustProgress(e.target.value / (duration + 0.001));
|
|
||||||
}
|
|
||||||
|
|
||||||
function progressBarOnInput(e: any) {
|
|
||||||
adjustRealProgress(e.target.value / (duration + 0.001));
|
|
||||||
}
|
|
||||||
|
|
||||||
function volumeBarOnChange(e: any) {
|
function volumeBarOnChange(e: any) {
|
||||||
adjustVolume(e.target.value);
|
adjustVolume(e.target.value);
|
||||||
localStorage.setItem('volume', e.target.value);
|
localStorage.setItem('volume', e.target.value);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function progressBarOnClick(e: MouseEvent) {
|
||||||
|
adjustProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||||
|
progressBarRaw.set(e.offsetX / progressBar.getBoundingClientRect().width * duration);
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
mql.addEventListener('change', (e) => {
|
mql.addEventListener('change', (e) => {
|
||||||
showInfoTop = e.matches && hasLyrics;
|
showInfoTop = e.matches && hasLyrics;
|
||||||
@ -45,8 +44,7 @@
|
|||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (songInfoTopContainer && songInfoTopContent) {
|
if (songInfoTopContainer && songInfoTopContent) {
|
||||||
isInfoTopOverflowing =
|
isInfoTopOverflowing = songInfoTopContent.offsetWidth > songInfoTopContainer.offsetWidth;
|
||||||
songInfoTopContent.offsetWidth > songInfoTopContainer.offsetWidth;
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -81,31 +79,39 @@
|
|||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
<div class="progress top-16">
|
<div class="progress top-16">
|
||||||
<div class="time-indicator text-shadow-md time-current">{formatDuration(progress)}</div>
|
<div class="time-indicator text-shadow-md time-current">
|
||||||
<input
|
{formatDuration(progress)}
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
class="progress-bar shadow-md"
|
class="progress-bar shadow-md"
|
||||||
|
on:click={(e) => progressBarOnClick(e)}
|
||||||
bind:this={progressBar}
|
bind:this={progressBar}
|
||||||
on:change={progressBarOnChange}
|
on:mousedown={() => {
|
||||||
on:input={progressBarOnInput}
|
userAdjustingProgress.set(true);
|
||||||
on:mousedown={() => setOnSlide(true)}
|
|
||||||
on:mouseup={() => {
|
|
||||||
setTimeout(() => {
|
|
||||||
setOnSlide(false);
|
|
||||||
}, 50);
|
|
||||||
}}
|
}}
|
||||||
type="range"
|
on:mousemove={(e) => {
|
||||||
min="0"
|
if ($userAdjustingProgress) {
|
||||||
max={duration - 0.2}
|
adjustDisplayProgress(e.offsetX / progressBar.getBoundingClientRect().width);
|
||||||
step="1"
|
}
|
||||||
value={onSlide ? progressBar.value : progress}
|
}}
|
||||||
/>
|
on:mouseup={() => {
|
||||||
|
userAdjustingProgress.set(false);
|
||||||
|
}}
|
||||||
|
role="slider"
|
||||||
|
aria-valuemin="0"
|
||||||
|
aria-valuemax={duration}
|
||||||
|
aria-valuenow={progress}
|
||||||
|
tabindex="0"
|
||||||
|
on:keydown
|
||||||
|
on:keyup
|
||||||
|
>
|
||||||
|
<div class="bar" style={`width: ${(progress / (duration + 0.001)) * 100}%;`}></div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
<div class="time-indicator text-shadow-md time-total">{formatDuration(duration)}</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="controls top-32 flex h-16 overflow-hidden items-center justify-center">
|
<div class="controls top-32 flex h-16 overflow-hidden items-center justify-center">
|
||||||
<button
|
<button style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );" class="control-btn previous">
|
||||||
style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );"
|
|
||||||
class="control-btn previous"
|
|
||||||
>
|
|
||||||
<img class="control-img switch-song-img" src="/previous.svg" alt="上一曲" />
|
<img class="control-img switch-song-img" src="/previous.svg" alt="上一曲" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button
|
||||||
@ -115,10 +121,7 @@
|
|||||||
>
|
>
|
||||||
<img class="control-img" src={paused ? '/play.svg' : '/pause.svg'} alt="暂停或播放" />
|
<img class="control-img" src={paused ? '/play.svg' : '/pause.svg'} alt="暂停或播放" />
|
||||||
</button>
|
</button>
|
||||||
<button
|
<button style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );" class="control-btn next">
|
||||||
style="filter: drop-shadow( 0 4px 8px rgba(0, 0, 0, 0.12) );"
|
|
||||||
class="control-btn next"
|
|
||||||
>
|
|
||||||
<img class="control-img switch-song-img" src="/next.svg" alt="下一曲" />
|
<img class="control-img switch-song-img" src="/next.svg" alt="下一曲" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
@ -132,7 +135,7 @@
|
|||||||
min="0"
|
min="0"
|
||||||
max="1"
|
max="1"
|
||||||
step="0.01"
|
step="0.01"
|
||||||
value={onSlide ? volumeBar.value : volume}
|
value={$userAdjustingProgress ? volumeBar.value : volume}
|
||||||
/>
|
/>
|
||||||
<img class="scale-75" src="/volumeUp.svg" alt="最大音量" />
|
<img class="scale-75" src="/volumeUp.svg" alt="最大音量" />
|
||||||
</div>
|
</div>
|
||||||
@ -252,24 +255,18 @@
|
|||||||
.progress-bar:hover {
|
.progress-bar:hover {
|
||||||
height: 0.7rem;
|
height: 0.7rem;
|
||||||
}
|
}
|
||||||
.progress-bar::-webkit-slider-thumb {
|
.bar {
|
||||||
-webkit-appearance: none;
|
|
||||||
appearance: none;
|
|
||||||
width: 0rem;
|
|
||||||
height: 0.7rem;
|
|
||||||
background-color: white;
|
background-color: white;
|
||||||
box-shadow: -700px 0 0 700px white;
|
position: absolute;
|
||||||
cursor: pointer;
|
content: '';
|
||||||
|
height: 0.4rem;
|
||||||
|
display: inline-block;
|
||||||
|
border-radius: 1rem;
|
||||||
|
transition: height 0.3s;
|
||||||
}
|
}
|
||||||
|
|
||||||
.progress-bar::-moz-range-thumb {
|
.progress-bar:hover .bar {
|
||||||
appearance: none;
|
height: 0.7rem;
|
||||||
width: 0px;
|
|
||||||
height: 0px;
|
|
||||||
background-color: white;
|
|
||||||
box-shadow: -700px 0 0 700px white;
|
|
||||||
cursor: pointer;
|
|
||||||
border: none;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.time-indicator {
|
.time-indicator {
|
||||||
|
@ -1,148 +1,156 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import BezierEasing from 'bezier-easing';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import type { Line } from 'srt-parser-2';
|
import createLyricsSearcher from '$lib/lyrics/lyricSearcher';
|
||||||
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
import type { LrcJsonData } from 'lrc-parser-ts';
|
||||||
export let lyrics: string[];
|
export let lyrics: string[];
|
||||||
export let originalLyrics: Line[];
|
export let originalLyrics: LrcJsonData;
|
||||||
export let progress: number;
|
export let progress: number;
|
||||||
function userSlideProgress() {
|
|
||||||
systemCouldScrollSince = 0;
|
|
||||||
}
|
|
||||||
export { userSlideProgress };
|
|
||||||
|
|
||||||
let currentScrollPos = '';
|
let getLyricIndex: Function;
|
||||||
let currentLyric: Line;
|
const debugMode = false;
|
||||||
let currentLyricIndex = -1;
|
let currentLyricIndex = -1;
|
||||||
|
let currentPositionIndex = -1;
|
||||||
|
let currentAnimationIndex = -1;
|
||||||
let lyricsContainer: HTMLDivElement;
|
let lyricsContainer: HTMLDivElement;
|
||||||
let systemScrolling = false;
|
let nextUpdate = -1;
|
||||||
let systemCouldScrollSince = 0;
|
let lastAdjustProgress = 0;
|
||||||
let lastScroll = 0;
|
|
||||||
|
|
||||||
let refs = [];
|
let refs: HTMLParagraphElement[] = [];
|
||||||
let _refs: any[] = [];
|
let _refs: any[] = [];
|
||||||
$: refs = _refs.filter(Boolean);
|
$: refs = _refs.filter(Boolean);
|
||||||
|
$: getLyricIndex = createLyricsSearcher(originalLyrics);
|
||||||
function smoothScrollTo(element: HTMLElement, to: number, duration: number, timingFunction: Function) {
|
|
||||||
if (systemCouldScrollSince > Date.now()) return;
|
|
||||||
systemScrolling = true;
|
|
||||||
const start = element.scrollTop;
|
|
||||||
const change = to - start;
|
|
||||||
const startTime = performance.now();
|
|
||||||
|
|
||||||
function animateScroll(timestamp: number) {
|
|
||||||
const elapsedTime = timestamp - startTime;
|
|
||||||
const progress = Math.min(elapsedTime / duration, 1);
|
|
||||||
const easedProgress = timingFunction(progress, 0.38, 0, 0.24, 0.99);
|
|
||||||
element.scrollTop = start + change * easedProgress;
|
|
||||||
|
|
||||||
console.log(elapsedTime);
|
|
||||||
if (elapsedTime < duration) {
|
|
||||||
requestAnimationFrame(animateScroll);
|
|
||||||
} else {
|
|
||||||
console.log('!');
|
|
||||||
setTimeout(() => {
|
|
||||||
console.log('?');
|
|
||||||
systemScrolling = false;
|
|
||||||
}, 100);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
requestAnimationFrame(animateScroll);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define your custom Bézier curve function
|
|
||||||
function customBezier(progress: number, p1x: number, p1y: number, p2x: number, p2y: number) {
|
|
||||||
function cubicBezier(t: number, p1: number, p2: number) {
|
|
||||||
const c = 3 * p1;
|
|
||||||
const b = 3 * (p2 - p1) - c;
|
|
||||||
const a = 1 - c - b;
|
|
||||||
return a * Math.pow(t, 3) + b * Math.pow(t, 2) + c * t;
|
|
||||||
}
|
|
||||||
|
|
||||||
return BezierEasing(p1x, p1y, p2x, p2y)(progress);
|
|
||||||
}
|
|
||||||
|
|
||||||
function getClass(lyricIndex: number, progress: number) {
|
function getClass(lyricIndex: number, progress: number) {
|
||||||
if (!currentLyric) return 'after-lyric';
|
if (!originalLyrics.scripts) return 'previous-lyric';
|
||||||
if (lyricIndex === currentLyricIndex) return 'current-lyric';
|
if (currentLyricIndex === lyricIndex) return 'current-lyric';
|
||||||
else if (progress > currentLyric.endSeconds) return 'after-lyric';
|
else if (progress > originalLyrics.scripts[lyricIndex].end) return 'after-lyric';
|
||||||
else return 'previous-lyric';
|
else return 'previous-lyric';
|
||||||
}
|
}
|
||||||
|
|
||||||
function inRange(x: number, min: number, max: number) {
|
|
||||||
return (x - min) * (x - max) <= 0;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
if (originalLyrics && lyricsContainer) {
|
if (lyricsContainer && originalLyrics && originalLyrics.scripts) {
|
||||||
let found = false;
|
const scripts = originalLyrics.scripts;
|
||||||
let finallyFound = false;
|
currentPositionIndex = getLyricIndex(progress);
|
||||||
for (let i = 0; i < originalLyrics.length; i++) {
|
const cl = scripts[currentPositionIndex];
|
||||||
let l = originalLyrics[i];
|
if (cl.start <= progress && progress <= cl.end) {
|
||||||
if (progress >= l.startSeconds && progress <= l.endSeconds) {
|
currentLyricIndex = currentPositionIndex;
|
||||||
currentLyric = l;
|
nextUpdate = cl.end;
|
||||||
currentLyricIndex = i;
|
} else {
|
||||||
found = true;
|
currentLyricIndex = -1;
|
||||||
const currentRef = refs[i];
|
nextUpdate = cl.start;
|
||||||
if (currentRef && currentScrollPos !== currentLyric.text) {
|
|
||||||
const targetScroll =
|
|
||||||
lyricsContainer.scrollTop +
|
|
||||||
currentRef.getBoundingClientRect().top -
|
|
||||||
lyricsContainer.getBoundingClientRect().height * 0.1 -
|
|
||||||
128;
|
|
||||||
const duration = 450;
|
|
||||||
smoothScrollTo(lyricsContainer, targetScroll, duration, customBezier);
|
|
||||||
lastScroll = 0;
|
|
||||||
currentScrollPos = currentLyric.text;
|
|
||||||
}
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
if (!found) {
|
if ($userAdjustingProgress === false) {
|
||||||
for (let i = 0; i < originalLyrics.length; i++) {
|
for (let i = 0; i < scripts.length; i++) {
|
||||||
let l = originalLyrics[i];
|
const offset = Math.abs(i - currentPositionIndex);
|
||||||
let nl = i + 1 < originalLyrics.length ? originalLyrics[i + 1] : originalLyrics[i];
|
const blurRadius = Math.min(offset * 1.5, 16);
|
||||||
if (
|
|
||||||
progress >= l.endSeconds &&
|
|
||||||
progress < nl.startSeconds &&
|
|
||||||
inRange(lastScroll, l.endSeconds, nl.startSeconds) === false
|
|
||||||
) {
|
|
||||||
const currentRef = refs[i];
|
|
||||||
const targetScroll = lyricsContainer.scrollTop + currentRef.getBoundingClientRect().top - 320;
|
|
||||||
const duration = 700;
|
|
||||||
currentLyricIndex = i - 0.1;
|
|
||||||
currentLyric = {
|
|
||||||
id: '-1',
|
|
||||||
startTime: '00:00:00,000',
|
|
||||||
startSeconds: l.endSeconds + 0.01,
|
|
||||||
endTime: '00:00:00,000',
|
|
||||||
endSeconds: nl.startSeconds - 0.01,
|
|
||||||
text: ''
|
|
||||||
};
|
|
||||||
smoothScrollTo(lyricsContainer, targetScroll, duration, customBezier);
|
|
||||||
lastScroll = progress;
|
|
||||||
finallyFound = true;
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (systemCouldScrollSince < Date.now()) {
|
|
||||||
for (let i = 0; i < lyrics.length; i++) {
|
|
||||||
const offset = Math.abs(i - currentLyricIndex);
|
|
||||||
const blurRadius = Math.min(offset * 1, 16);
|
|
||||||
if (refs[i]) {
|
if (refs[i]) {
|
||||||
refs[i].style.filter = `blur(${blurRadius}px)`;
|
refs[i].style.filter = `blur(${blurRadius}px)`;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (found == false && finallyFound == false) {
|
}
|
||||||
currentLyric = {
|
}
|
||||||
id: '-1',
|
|
||||||
startTime: '00:00:00,000',
|
function sleep(ms: number) {
|
||||||
startSeconds: 0,
|
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||||
endTime: '00:00:00,000',
|
}
|
||||||
endSeconds: 0,
|
|
||||||
text: ''
|
async function a(h: number) {
|
||||||
};
|
let pos = currentPositionIndex + 2;
|
||||||
|
for (let i = currentPositionIndex + 2; 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)`;
|
||||||
|
pos = i;
|
||||||
|
await sleep(75);
|
||||||
|
if (refs[i - 2].getBoundingClientRect().top > lyricsContainer.getBoundingClientRect().height) break;
|
||||||
|
}
|
||||||
|
for (let i = pos; i < refs.length; i++) {
|
||||||
|
refs[i].style.transition =
|
||||||
|
'transform 0s, filter 200ms ease, opacity 200ms ease, font-size 200ms ease, scale 250ms ease';
|
||||||
|
const h = refs[i].getBoundingClientRect().height;
|
||||||
|
refs[i].style.transform = `translateY(${-h}px)`;
|
||||||
|
}
|
||||||
|
await sleep(650);
|
||||||
|
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';
|
||||||
|
}
|
||||||
|
for (let i = 0; i < refs.length; i++) {
|
||||||
|
refs[i].style.transform = `translateY(0px)`;
|
||||||
|
}
|
||||||
|
lyricsContainer.scrollTop += h;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function b(currentLyric: HTMLParagraphElement) {
|
||||||
|
if (!originalLyrics || !originalLyrics.scripts || !lyricsContainer) return;
|
||||||
|
lyricsContainer.scrollTop += currentLyric.getBoundingClientRect().top - 144;
|
||||||
|
}
|
||||||
|
|
||||||
|
userAdjustingProgress.subscribe((v) => {
|
||||||
|
if (!originalLyrics) return;
|
||||||
|
const scripts = originalLyrics.scripts;
|
||||||
|
if (!scripts) return;
|
||||||
|
if (v) {
|
||||||
|
console.log('!');
|
||||||
|
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)`;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
progressBarRaw.subscribe((progress: number) => {
|
||||||
|
if ($userAdjustingProgress === false && getLyricIndex) {
|
||||||
|
const currentLyric = refs[getLyricIndex(progress)];
|
||||||
|
b(currentLyric);
|
||||||
|
lastAdjustProgress = currentPositionIndex;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
$: {
|
||||||
|
if ($userAdjustingProgress) {
|
||||||
|
nextUpdate = progress;
|
||||||
|
} else {
|
||||||
|
if (0 < nextUpdate - progress && nextUpdate - progress < 0.05) {
|
||||||
|
if (
|
||||||
|
currentPositionIndex >= 0 &&
|
||||||
|
currentPositionIndex !== currentAnimationIndex &&
|
||||||
|
currentPositionIndex !== lastAdjustProgress
|
||||||
|
) {
|
||||||
|
const offsetHeight =
|
||||||
|
refs[currentPositionIndex].getBoundingClientRect().height +
|
||||||
|
refs[currentPositionIndex].getBoundingClientRect().top -
|
||||||
|
144;
|
||||||
|
const currentLyric = refs[currentPositionIndex];
|
||||||
|
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';
|
||||||
|
const h = refs[i].getBoundingClientRect().height;
|
||||||
|
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)`;
|
||||||
|
a(offsetHeight);
|
||||||
|
}
|
||||||
|
currentAnimationIndex = currentPositionIndex;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -151,23 +159,24 @@
|
|||||||
{#if lyrics && originalLyrics}
|
{#if lyrics && originalLyrics}
|
||||||
<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 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] lyrics py-16"
|
text-left no-scrollbar overflow-y-auto z-[1] pt-16 lyrics"
|
||||||
bind:this={lyricsContainer}
|
bind:this={lyricsContainer}
|
||||||
on:scroll={(e) => {
|
|
||||||
if (systemScrolling == false) {
|
|
||||||
console.log('yes');
|
|
||||||
for (let i = 0; i < lyrics.length; i++) {
|
|
||||||
refs[i].style.filter = `blur(0px)`;
|
|
||||||
}
|
|
||||||
systemCouldScrollSince = Date.now() + 5000;
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
|
{#if debugMode}
|
||||||
|
<p class="fixed top-6 right-20">
|
||||||
|
LyricIndex: {currentLyricIndex} PositionIndex: {currentPositionIndex} AnimationIndex:{currentAnimationIndex}
|
||||||
|
NextUpdate: {Math.floor(nextUpdate / 60)}m {Math.round((nextUpdate % 60) * 100) / 100}s
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
{#each lyrics as lyric, i}
|
{#each lyrics as lyric, i}
|
||||||
<p bind:this={_refs[i]} class={getClass(i, progress)}>
|
<p bind:this={_refs[i]} class={`${getClass(i, progress)} text-shadow-lg`}>
|
||||||
|
{#if debugMode}
|
||||||
|
<span class="text-lg absolute">{i}</span>
|
||||||
|
{/if}
|
||||||
{lyric}
|
{lyric}
|
||||||
</p>
|
</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
<div class="relative w-full h-[50rem]"></div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@ -181,42 +190,38 @@
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
.no-scrollbar {
|
.no-scrollbar {
|
||||||
scrollbar-width: none;
|
scrollbar-width: 10px;
|
||||||
}
|
}
|
||||||
.no-scrollbar::-webkit-scrollbar {
|
.no-scrollbar::-webkit-scrollbar {
|
||||||
width: 0;
|
width: 10px;
|
||||||
}
|
}
|
||||||
.current-lyric {
|
.current-lyric {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: white;
|
color: white;
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 2.3rem;
|
font-size: 2.1rem;
|
||||||
line-height: 2.7rem;
|
line-height: 2.7rem;
|
||||||
top: 1rem;
|
|
||||||
transition: 0.2s;
|
|
||||||
margin: 1rem 0rem;
|
margin: 1rem 0rem;
|
||||||
|
scale: 1.02 1;
|
||||||
|
top: 1rem;
|
||||||
}
|
}
|
||||||
.previous-lyric {
|
.previous-lyric {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 2.3rem;
|
font-size: 2.1rem;
|
||||||
line-height: 2.7rem;
|
line-height: 2.7rem;
|
||||||
filter: blur(1px);
|
|
||||||
top: 1rem;
|
|
||||||
transition: 0.2s;
|
|
||||||
margin: 1rem 0rem;
|
margin: 1rem 0rem;
|
||||||
|
top: 1rem;
|
||||||
}
|
}
|
||||||
.after-lyric {
|
.after-lyric {
|
||||||
position: relative;
|
position: relative;
|
||||||
color: rgba(255, 255, 255, 0.7);
|
color: rgba(255, 255, 255, 0.7);
|
||||||
font-weight: 600;
|
font-weight: 600;
|
||||||
font-size: 2.3rem;
|
font-size: 2.1rem;
|
||||||
line-height: 2.7rem;
|
line-height: 2.7rem;
|
||||||
filter: blur(1px);
|
|
||||||
top: 1rem;
|
|
||||||
transition: 0.2s;
|
|
||||||
margin: 1rem 0rem;
|
margin: 1rem 0rem;
|
||||||
|
top: 1rem;
|
||||||
}
|
}
|
||||||
@media (min-width: 768px) {
|
@media (min-width: 768px) {
|
||||||
.current-lyric {
|
.current-lyric {
|
||||||
@ -239,18 +244,18 @@
|
|||||||
@media (min-width: 1024px) {
|
@media (min-width: 1024px) {
|
||||||
.current-lyric {
|
.current-lyric {
|
||||||
font-size: 3.5rem;
|
font-size: 3.5rem;
|
||||||
line-height: 4.5rem;
|
line-height: 6.5rem;
|
||||||
margin: 2rem 0rem;
|
margin: 0rem 0rem;
|
||||||
}
|
}
|
||||||
.after-lyric {
|
.after-lyric {
|
||||||
font-size: 3rem;
|
font-size: 3.5rem;
|
||||||
line-height: 3.5rem;
|
line-height: 6.5rem;
|
||||||
margin: 2rem 0rem;
|
margin: 0rem 0rem;
|
||||||
}
|
}
|
||||||
.previous-lyric {
|
.previous-lyric {
|
||||||
font-size: 3rem;
|
font-size: 3.5rem;
|
||||||
line-height: 3.5rem;
|
line-height: 6.5rem;
|
||||||
margin: 2rem 0rem;
|
margin: 0rem 0rem;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
@ -2,7 +2,7 @@ export default function(durationInSeconds: number): string {
|
|||||||
// Calculate hours, minutes, and seconds
|
// Calculate hours, minutes, and seconds
|
||||||
const hours = Math.floor(durationInSeconds / 3600);
|
const hours = Math.floor(durationInSeconds / 3600);
|
||||||
const minutes = Math.floor((durationInSeconds % 3600) / 60);
|
const minutes = Math.floor((durationInSeconds % 3600) / 60);
|
||||||
const seconds = Math.round(durationInSeconds) % 60;
|
const seconds = Math.floor(durationInSeconds) % 60;
|
||||||
|
|
||||||
// Format hours, minutes, and seconds into string
|
// Format hours, minutes, and seconds into string
|
||||||
let formattedTime = '';
|
let formattedTime = '';
|
||||||
|
@ -4,7 +4,7 @@ export default function(key: string){
|
|||||||
"audio/ogg": "OGG 容器",
|
"audio/ogg": "OGG 容器",
|
||||||
"audio/flac": "FLAC 无损音频",
|
"audio/flac": "FLAC 无损音频",
|
||||||
"audio/aac": "AAC 音频",
|
"audio/aac": "AAC 音频",
|
||||||
"srt": "SRT 字幕"
|
"lrc": "LRC 歌词"
|
||||||
}
|
}
|
||||||
if (!key) return "未知格式";
|
if (!key) return "未知格式";
|
||||||
else return dict[key as keyof typeof dict];
|
else return dict[key as keyof typeof dict];
|
||||||
|
33
src/lib/lyrics/lyricSearcher.ts
Normal file
33
src/lib/lyrics/lyricSearcher.ts
Normal file
@ -0,0 +1,33 @@
|
|||||||
|
import type { LrcJsonData } from "lrc-parser-ts";
|
||||||
|
|
||||||
|
export default function createLyricsSearcher(lrc: LrcJsonData): (progress: number) => number {
|
||||||
|
if (!lrc || !lrc.scripts) return () => 0;
|
||||||
|
const startTimes: number[] = lrc.scripts.map(script => script.start);
|
||||||
|
const endTimes: number[] = lrc.scripts.map(script => script.end);
|
||||||
|
|
||||||
|
return function(progress: number): number {
|
||||||
|
// 使用二分查找定位 progress 对应的歌词索引
|
||||||
|
let left = 0;
|
||||||
|
let right = startTimes.length - 1;
|
||||||
|
|
||||||
|
while (left <= right) {
|
||||||
|
const mid = Math.floor((left + right) / 2);
|
||||||
|
|
||||||
|
if (startTimes[mid] === progress) {
|
||||||
|
return mid;
|
||||||
|
} else if (startTimes[mid] < progress) {
|
||||||
|
left = mid + 1;
|
||||||
|
} else {
|
||||||
|
right = mid - 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// 循环结束后,检查 left 索引
|
||||||
|
if (left < startTimes.length && startTimes[left] > progress && (left === 0 || endTimes[left - 1] <= progress)) {
|
||||||
|
return left;
|
||||||
|
}
|
||||||
|
|
||||||
|
// 如果没有找到确切的 progress,返回小于等于 progress 的最大索引
|
||||||
|
return Math.max(0, right);
|
||||||
|
};
|
||||||
|
}
|
3
src/lib/state/progressBarRaw.ts
Normal file
3
src/lib/state/progressBarRaw.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const progressBarRaw = writable(0);
|
||||||
|
export default progressBarRaw;
|
3
src/lib/state/userAdjustingProgress.ts
Normal file
3
src/lib/state/userAdjustingProgress.ts
Normal file
@ -0,0 +1,3 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
const userAdjustingProgress = writable(false);
|
||||||
|
export default userAdjustingProgress;
|
@ -77,7 +77,7 @@
|
|||||||
</ul>
|
</ul>
|
||||||
</div>
|
</div>
|
||||||
<p>
|
<p>
|
||||||
AquaVox 1.9.1 · 早期公开预览 · 源代码参见
|
AquaVox 1.10.0 · 早期公开预览 · 源代码参见
|
||||||
<a href="https://github.com/alikia2x/aquavox">GitHub</a>
|
<a href="https://github.com/alikia2x/aquavox">GitHub</a>
|
||||||
</p>
|
</p>
|
||||||
<a href="/import">导入音乐</a> <br />
|
<a href="/import">导入音乐</a> <br />
|
||||||
|
@ -16,7 +16,7 @@
|
|||||||
|
|
||||||
<div class="w-full flex my-3">
|
<div class="w-full flex my-3">
|
||||||
<h2>歌词文件</h2>
|
<h2>歌词文件</h2>
|
||||||
<FileSelector accept=".srt" class="ml-auto top-2 relative" />
|
<FileSelector accept=".lrc" class="ml-auto top-2 relative" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<FileList />
|
<FileList />
|
||||||
|
@ -8,8 +8,8 @@
|
|||||||
import extractFileName from '$lib/extractFileName';
|
import extractFileName from '$lib/extractFileName';
|
||||||
import localforage from 'localforage';
|
import localforage from 'localforage';
|
||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
import srtParser2 from 'srt-parser-2';
|
import lrcParser, { type LrcJsonData } from 'lrc-parser-ts';
|
||||||
import type { Line } from 'srt-parser-2';
|
import userAdjustingProgress from '$lib/state/userAdjustingProgress';
|
||||||
import type { IAudioMetadata } from 'music-metadata-browser';
|
import type { IAudioMetadata } from 'music-metadata-browser';
|
||||||
|
|
||||||
const audioId = $page.params.id;
|
const audioId = $page.params.id;
|
||||||
@ -23,11 +23,9 @@
|
|||||||
let paused: boolean = true;
|
let paused: boolean = true;
|
||||||
let launched = false;
|
let launched = false;
|
||||||
let prepared: string[] = [];
|
let prepared: string[] = [];
|
||||||
let originalLyrics: Line[];
|
let originalLyrics: LrcJsonData;
|
||||||
let lyricsText: string[] = [];
|
let lyricsText: string[] = [];
|
||||||
let onAdjustingProgress = false;
|
|
||||||
let hasLyrics: boolean;
|
let hasLyrics: boolean;
|
||||||
let lyricComp: any;
|
|
||||||
const coverPath = writable('');
|
const coverPath = writable('');
|
||||||
let mainInterval: ReturnType<typeof setInterval>;
|
let mainInterval: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
@ -70,7 +68,6 @@
|
|||||||
getAudioIDMetadata(audioId, (metadata: IAudioMetadata | null) => {
|
getAudioIDMetadata(audioId, (metadata: IAudioMetadata | null) => {
|
||||||
if (!metadata) return;
|
if (!metadata) return;
|
||||||
duration = metadata.format.duration ? metadata.format.duration : 0;
|
duration = metadata.format.duration ? metadata.format.duration : 0;
|
||||||
console.log(metadata);
|
|
||||||
singer = metadata.common.artist ? metadata.common.artist : '未知歌手';
|
singer = metadata.common.artist ? metadata.common.artist : '未知歌手';
|
||||||
prepared.push('duration');
|
prepared.push('duration');
|
||||||
});
|
});
|
||||||
@ -95,9 +92,9 @@
|
|||||||
if (file) {
|
if (file) {
|
||||||
const f = file as File;
|
const f = file as File;
|
||||||
f.text().then((lr) => {
|
f.text().then((lr) => {
|
||||||
const parser = new srtParser2();
|
originalLyrics = lrcParser(lr);
|
||||||
originalLyrics = parser.fromSrt(lr);
|
if (!originalLyrics.scripts) return;
|
||||||
for (const line of originalLyrics) {
|
for (const line of originalLyrics.scripts) {
|
||||||
lyricsText.push(line.text);
|
lyricsText.push(line.text);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@ -135,14 +132,12 @@
|
|||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
audioPlayer.currentTime = duration * progress;
|
audioPlayer.currentTime = duration * progress;
|
||||||
currentProgress = duration * progress;
|
currentProgress = duration * progress;
|
||||||
lyricComp.userSlideProgress();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function adjustRealProgress(progress: number) {
|
function adjustDisplayProgress(progress: number) {
|
||||||
if (audioPlayer) {
|
if (audioPlayer) {
|
||||||
currentProgress = duration * progress;
|
currentProgress = duration * progress;
|
||||||
lyricComp.userSlideProgress();
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -152,24 +147,22 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function setOnSlide(value: boolean) {
|
|
||||||
onAdjustingProgress = value;
|
|
||||||
}
|
|
||||||
|
|
||||||
$: {
|
$: {
|
||||||
clearInterval(mainInterval);
|
clearInterval(mainInterval);
|
||||||
mainInterval = setInterval(() => {
|
mainInterval = setInterval(() => {
|
||||||
if (
|
if (
|
||||||
audioPlayer !== null &&
|
audioPlayer !== null &&
|
||||||
audioPlayer.currentTime !== undefined &&
|
audioPlayer.currentTime !== undefined
|
||||||
onAdjustingProgress === false
|
|
||||||
) {
|
) {
|
||||||
currentProgress = audioPlayer.currentTime;
|
if ($userAdjustingProgress === false)
|
||||||
|
currentProgress = audioPlayer.currentTime;
|
||||||
|
progressBarRaw.set(audioPlayer.currentTime);
|
||||||
}
|
}
|
||||||
}, 17);
|
}, 50);
|
||||||
}
|
}
|
||||||
|
|
||||||
import { onMount } from 'svelte';
|
import { onMount } from 'svelte';
|
||||||
|
import progressBarRaw from '$lib/state/progressBarRaw';
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
audioPlayer.volume = localStorage.getItem('volume') ? Number(localStorage.getItem('volume')) : 1;
|
||||||
@ -209,13 +202,11 @@
|
|||||||
{paused}
|
{paused}
|
||||||
{adjustProgress}
|
{adjustProgress}
|
||||||
{adjustVolume}
|
{adjustVolume}
|
||||||
{adjustRealProgress}
|
{adjustDisplayProgress}
|
||||||
onSlide={onAdjustingProgress}
|
|
||||||
{setOnSlide}
|
|
||||||
{hasLyrics}
|
{hasLyrics}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress} bind:this={lyricComp}/>
|
<Lyrics lyrics={lyricsText} {originalLyrics} progress={currentProgress}/>
|
||||||
|
|
||||||
<audio
|
<audio
|
||||||
bind:this={audioPlayer}
|
bind:this={audioPlayer}
|
||||||
|
Loading…
Reference in New Issue
Block a user