Skip to content

Commit

Permalink
feat: lyric effect
Browse files Browse the repository at this point in the history
  • Loading branch information
leon-kfd committed Nov 15, 2023
1 parent f9cbe10 commit cf5db95
Show file tree
Hide file tree
Showing 9 changed files with 264 additions and 14 deletions.
1 change: 1 addition & 0 deletions index.html
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@
<meta charset="UTF-8" />
<link rel="icon" href="/favicon.ico" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
<meta name="referrer" content="no-referrer" />
<title>G Music Visualizer</title>
<style>
#fps > div {
Expand Down
4 changes: 4 additions & 0 deletions src/components/g-audio.module.scss
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,10 @@
.audioWrapper {
padding: 0 20px;
margin: 10px 0;
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 12px;
}
.exampleWrapper {
padding: 0 10px;
Expand Down
60 changes: 46 additions & 14 deletions src/components/g-audio.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -7,14 +7,25 @@ import SPathFill from './s-path-fill'
import SCircle from './s-circle'
import SPathDouble from './s-path-double'
import SDot from "./s-dot";
import SPaticle from "./s-particle";
import SCircleMultiple from './s-circle-multiple';
import { apiURL, DEFAULT_IMG } from '@/global'
// import SPaticle from "./s-particle";
import { DEFAULT_IMG } from '@/global'
import lrcParser from "@/plugins/LrcParser";
import Lyric, { LyricRef } from './lyric'
// import { GlobalState } from "@/store";

export const MusicVisualizerCtx = new MusicVisualizer()

const exampleList = [SLine, SPathDouble, SPath, SPathFill, SDot, SPaticle, SCircle, SCircleMultiple]
const exampleList = [
SLine,
SPathDouble,
SPath,
SPathFill,
SDot,
SCircle,
SCircleMultiple,
// SPaticle, // 性能不行, 先屏蔽
]

export default function GAudio() {
// const { setState: setGlobalState } = useContext(GlobalState)
Expand All @@ -31,7 +42,10 @@ export default function GAudio() {
const [isPlaying, setIsPlaying] = useState(false)
const [playList, setPlayList] = useState<any[]>([])

const [lrcContent, setLrcContent] = useState<ScriptItem[]>([])

const hiddenFileInput = useRef<HTMLInputElement>(null)
const lyricCtx = useRef<LyricRef>(null)

let lastTime: number
let raf = useRef<number>()
Expand Down Expand Up @@ -76,22 +90,15 @@ export default function GAudio() {
let posterPic = ''
if (playList && playList.length > 0) {
const randomIdx = ~~[Math.random() * playList.length]
const { name, url, artist, pic } = playList[randomIdx]
console.log('playList[randomIdx]', playList[randomIdx])
const { name, url, artist, pic, lrc } = playList[randomIdx]
loadLRC(lrc)
const { url: picURL } = await fetch(pic, { method: 'HEAD' })
console.log('picURL', picURL)
musicName = `${name} - ${artist}`
musicURL = url
posterPic = picURL.split('?')[0] + `?param=400y400`
// setGlobalState({ mainColor: `#${~~(Math.random() * 1000000)}`})
} else {
const transferTarget = encodeURIComponent(`https://api.wqwlkj.cn/wqwlapi/wyy_random.php?type=json`)
const res = await fetch(`${apiURL}/api/transfer?target=${transferTarget}`, { headers: { 'content-type': 'application/json; charset=utf-8' } })
const { data } = await res.json()
const { name, url, artistsname, picurl } = data
musicName = `${name} - ${artistsname}`
musicURL = url
posterPic = picurl
throw new Error('Can not get play list')
}
setMusicName(musicName)
setAudioURL(musicURL)
Expand All @@ -116,6 +123,28 @@ export default function GAudio() {
setAudioURL(url)
}

function handleAudioTimeUpdate() {
const audioCurrentTime = audio.current?.currentTime
if (typeof audioCurrentTime !== 'undefined') {
lyricCtx.current?.onUpdateTime(audioCurrentTime)
}
}

async function loadLRC(url: string) {
try {
const lrcText = await (await fetch(url)).text()
const lrc = lrcParser(lrcText)
if (lrc.scripts && lrc.scripts.length > 0) {
setLrcContent(lrc.scripts)
} else {
setLrcContent([])
}
} catch (e) {
console.error(e)
setLrcContent([])
}
}

return (
<>
<main className={style.page}>
Expand All @@ -126,7 +155,10 @@ export default function GAudio() {
<input type="file" style={{display: 'none'}} ref={hiddenFileInput} onChange={handleFileChange} />
</div>
<div className={style.audioWrapper}>
<audio controls onPlay={play} onPause={pause} ref={audio} src={audioURL} crossOrigin="anonymous"></audio>
<audio controls onPlay={play} onPause={pause} ref={audio} src={audioURL} crossOrigin="anonymous" onTimeUpdate={handleAudioTimeUpdate}></audio>
<div className="lyric-wrapper">
<Lyric isPlaying={isPlaying} lrcContent={lrcContent} ref={lyricCtx} />
</div>
</div>
<div className={style.exampleWrapper}>
{
Expand Down
64 changes: 64 additions & 0 deletions src/components/lyric.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,64 @@
import React, { useImperativeHandle, forwardRef, useEffect, useRef } from "react"

let activeIndex = -1
let an: Animation | null = null
export default forwardRef<LyricRef, LrcComponentProps>((props, ref) => {
useImperativeHandle(ref, () => ({
onUpdateTime
}))

const lyricEl = useRef<HTMLDivElement>(null)

const onUpdateTime = (val: number) => {
if (~activeIndex && props.lrcContent[activeIndex] && props.lrcContent[activeIndex].start <= val && props.lrcContent[activeIndex].end >= val) {
// do nothing
} else {
activeIndex = -1
for(let i = 0; i < props.lrcContent.length; i++) {
if (val >= props.lrcContent[i].start && val <= props.lrcContent[i].end) {
activeIndex = i
break
}
}
if (~activeIndex) {
const target = props.lrcContent[activeIndex]
console.log('need run animation', activeIndex, target.text, target.end - target.start)
runLrcAnimation(target.text, target.end - target.start)
} else {
runLrcAnimation('', 0)
}
}
}

function runLrcAnimation(text: string, duration: number) {
if (!lyricEl.current) {
return
}
lyricEl.current.innerHTML = text
an = lyricEl.current.animate([
{ 'backgroundSize': '0 100%' },
{ 'backgroundSize': '100% 100%' }
], {
duration: duration * 1000
})
}

useEffect(() => {
runLrcAnimation('', 0)
an?.pause()
}, [props.lrcContent])

useEffect(() => {
if (props.isPlaying) {
an?.play()
} else {
an?.pause()
}
}, [props.isPlaying])

return <div ref={lyricEl} className="lyric-content"></div>
})

export type LyricRef = {
onUpdateTime: (val: number) => void
}
16 changes: 16 additions & 0 deletions src/index.scss
Original file line number Diff line number Diff line change
Expand Up @@ -105,4 +105,20 @@ ul, li {
.s-module-fake {
width: 300px;
margin: 0 12px;
}

.lyric-wrapper {
height: 36px;
display: flex;
align-items: center;
.lyric-content {
background: #fff linear-gradient(to right, #7592f1, #7592f1) no-repeat 0 0;
background-size: 0 100%;
-webkit-background-clip: text;
-webkit-text-fill-color: transparent;
font-size: 24px;
font-family: LXGW WenKai Screen;
filter: drop-shadow(0 0 1px #262626);
white-space: nowrap;
}
}
92 changes: 92 additions & 0 deletions src/plugins/LrcParser.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,92 @@
// https://github.com/AdoneZ/lrc-parser/blob/master/index.ts
const EOL = '\n'

/**
*
* @param {string} data
* @example [length: 03:36]
* @return {<Array>{string}} ['length', '03:06']
*/

function extractInfo(data: string) {
const info = data.trim().slice(1, -1) // remove brackets: length: 03:06
const firstColonIndex = info.indexOf(':')

return [
info.substring(0, firstColonIndex).trim(),
info.substring(firstColonIndex + 1).trim()
]
}

function lrcParser(data: string) {
if (typeof data !== 'string') {
throw new TypeError('expect first argument to be a string')
}
// split a long string into lines by system's end-of-line marker line \r\n on Windows
// or \n on POSIX
let lines = data.split(EOL)
const timeStart = /\[(\d*\:\d*\.?\d*)\]/ // i.g [00:10.55]
const scriptText = /(.+)/ // Havana ooh na-na (ayy)
const timeEnd = timeStart
const startAndText = new RegExp(timeStart.source + scriptText.source)

const infos:string[] = []
const scripts: ScriptItem[] = []
const result: LrcJsonData = {}

for(let i = 0; startAndText.test(lines[i]) === false; i++) {
infos.push(lines[i])
}

infos.reduce((result, info) => {
const [key, value] = extractInfo(info)
result[key] = value
return result
}, result)

lines.splice(0, infos.length) // remove all info lines
const qualified = new RegExp(startAndText.source + '|' + timeEnd.source)
lines = lines.filter(line => qualified.test(line))

for (let i = 0, l = lines.length; i < l; i++) {
const matches = startAndText.exec(lines[i])
const timeEndMatches = timeEnd.exec(lines[i + 1])
if (matches && timeEndMatches) {
const [, start, text] = matches

let _text = text
let translateText = ''
const tMatch = text.match(/.*\((.*)\)/)
if (tMatch && tMatch.length > 1) {
translateText = tMatch[1]
_text = text.replace(`(${translateText})`, '')
}

const [, end] = timeEndMatches
scripts.push({
start: convertTime(start),
text: _text,
translateText,
end: convertTime(end),
})
}
}

result.scripts = scripts
return result
}

// convert time string to seconds
// i.g: [01:09.10] -> 69.10
function convertTime(string: string) {
const _string = string.split(':');
const minutes = parseInt(_string[0], 10)
const seconds = parseFloat(_string[1])
if (minutes > 0) {
const sc = minutes * 60 + seconds
return parseFloat(sc.toFixed(2))
}
return seconds
}

export default lrcParser
6 changes: 6 additions & 0 deletions src/types/lrc.d.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,6 @@
type ScriptItem = {start: number, text: string, translateText: string, end: number}

interface LrcJsonData {
[key: string]: any
scripts?: ScriptItem[]
}
5 changes: 5 additions & 0 deletions src/components/props.d.ts → src/types/props.d.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,4 +2,9 @@ interface SComponentProps {
isPlaying: boolean;
data: number[];
audioImg?: string;
}

interface LrcComponentProps {
isPlaying: boolean;
lrcContent: ScriptItem[];
}
30 changes: 30 additions & 0 deletions src/utils/base.ts
Original file line number Diff line number Diff line change
Expand Up @@ -55,3 +55,33 @@ export function getImageCircle(canvas: Canvas, { x, y, r, shadowColor }: ImageCi

return image
}


function getImageThemeColor(img: HTMLImageElement) {
// 提取图片主题色
const canvas = document.createElement('canvas')
const ctx = canvas.getContext('2d') as CanvasRenderingContext2D
const { width, height } = img
canvas.width = width
canvas.height = height
ctx.drawImage(img, 0, 0, width, height)
const imageData = ctx.getImageData(0, 0, width, height)
const data = imageData.data
let r = 0
let g = 0
let b = 0
for (let i = 0; i < data.length; i += 4) {
r += data[i]
g += data[i + 1]
b += data[i + 2]
}
const avgR = ~~(r / (width * height))
const avgG = ~~(g / (width * height))
const avgB = ~~(b / (width * height))
const hex = rgbToHex(avgR, avgG, avgB)
return hex
}

function rgbToHex(r: number, g: number, b: number) {
return '#' + ((1 << 24) + (r << 16) + (g << 8) + b).toString(16).slice(1)
}

0 comments on commit cf5db95

Please sign in to comment.