southernMD 南山有壶酒
作词 : 偽物
分类
  • 暂无内容
站点信息
标签云
目录
作词 : 偽物
一个歌词滚动器
2023/8/16 21:26:12  |
0  |
69
JS

基础的准备

包括歌词,ui,这边准备了一首日文歌,歌词包括翻译,歌词以及罗马音

js 复制代码
const lrcString = `
[00:00.00]
[00:26.32]この世に生きて生きる意味を知らない
`

const lrcRoMaString = `
[00:00.000]
[00:26.320]ko no yo ni i ki te i ki ru i mi wo shi ra na i
`

const lrcTlyString = `
[00:26.32]不清楚出生存在与此世的理由
`
//只保留一句,多余歌词省略

const lrc:Ref<lrcType[]> = ref([{ lyric: "", time: 0 }]);
const romalrc:Ref<lrcType[]> = ref([{ lyric: "", time: 0 }]);
const tlyric:Ref<lrcType[]> = ref([{ lyric: "", time: 0 }]);
lrc.value = parseLyricLine(lrcString)
romalrc.value = parseLyricLine(lrcRoMaString)
tlyric.value = parseLyricLine(lrcTlyString)
js 复制代码
export const parseLyricLine = (str: string = ''): Array<lrcType> => {
    if (str != '') {
        // console.log(str);

        let lrc: Array<lrcType> = []
        const timeExp = /(\[(\d{2}):(\d{2}).?(\d{0,3})\])/g;
        str.split("\n")
            .filter((value) => {
                return value.trim() !== "";
            }).map((value) => {
                let t = value.trim().match(timeExp) as RegExpMatchArray
                if (t) {
                    t.forEach((element) => {
                        let result = element.match(/\[(\d{2}):(\d{2}).?(\d{0,3})\]/);
                        if (result) {
                            let time = Number(result[1]) * 60 * 1000 + Number(result[2]) * 1000 + Number(result[3])
                            let lyric = value.substring(value.lastIndexOf(']') + 1)
                            let obj = {
                                time, lyric
                            }
                            lrc.push(obj);
                        }
                    })
                }
            })
        lrc.sort((a, b) => {
            return a.time - b.time;
        })
        return lrc
    } else {
        return []
    }
}

为什么要排序,因为有一种奇怪的歌词,他只有一行但是有多个时间标签

js 复制代码
const lrc = "[01:16.42][02:11.33][02:22.08][02:32.97][02:43.88][03:41.12][03:52.04][04:02.96][04:13.88]正反対だね君らとボクらはまるでアベコベ 先天性の病気"

然后布置ui

html 复制代码
<template>
     <div class="main" style="height: 80%" @mouseover="showLine" @mouseout="hideLine">
        <div class="line" ref="line" :class="{opacity:!showLineFlag}" @click="gotoPlay">
            <div class="time">{{dayjsMMSS(scroolTime)}}</div>
            <i class="iconfont icon-gf-play"></i>
        </div>
        <el-scrollbar height="80%"  ref="scrollbarRef" >
            <div class="lrc">
                <div  class="lrc-block lrc-block-weight" v-for="(value, index) in lrc" :data-time="value.time"
                    :data-index="String(index)">
                    <div class="lrc-main lrcRush">
                        {{ value.lyric }}
                    </div>
                    <div v-if="
                    yinOryi[1] == true &&
                    romalrc != undefined &&
                    Number(romalrc?.length) != 0
                    " class="lrc-roma">
                        {{ $roma(index) }}
                    </div>
                    <div v-else-if="
                    yinOryi[0] == true && tlyric != undefined && Number(tlyric?.length) != 0
                    " class="lrc-tly">
                        {{ $tly(index) }}
                    </div>
                </div>
            </div>
        </el-scrollbar>
        <div class="option" v-show="showLineFlag">
            <div class="top">
                <div class="shang" @click="jian">
                    <i class="iconfont icon-xialajiantou1"></i>
                </div>
                <div class="xia" @click="jia">
                    <i class="iconfont icon-xiangxiajiantou"></i>
                </div>
            </div>
            <div class="bottom">
                <div class="yin" :class="{optionColor:yinOryi[1]}" @click="change(1)"
                    v-show="romalrc?.length !== 1 && romalrc?.length !== 0">
                    <span>音</span>
                </div>
                <div class="yi" :class="{optionColor:yinOryi[0]}" @click="change(0)"
                    v-show="tlyric?.length !== 1 && tlyric?.length !== 0">
                    <span>译</span>
                </div>
            </div>
        </div>
    </div>
</template>

<script setup lang="ts">
import {ref,Ref,computed} from 'vue'
import {dayjsMMSS} from '@/utils/dayjs'
import {parseLyricLine} from '@/utils/parseLyricLine'
const scroolTime = ref(0)
const showLineFlag = ref(true)
const yinOryi = ref([true,false])
const lrcString = `
[00:00.00]
[00:26.32]この世に生きて生きる意味を知らない
`

const lrcRoMaString = `
[00:00.000]
[00:26.320]ko no yo ni i ki te i ki ru i mi wo shi ra na i
`

const lrcTlyString = `
[00:26.32]不清楚出生存在与此世的理由
`


const lrc:Ref<lrcType[]> = ref([{ lyric: "", time: 0 }]);
const romalrc:Ref<lrcType[]> = ref([{ lyric: "", time: 0 }]);
const tlyric:Ref<lrcType[]> = ref([{ lyric: "", time: 0 }]);
lrc.value = parseLyricLine(lrcString)
romalrc.value = parseLyricLine(lrcRoMaString)
tlyric.value = parseLyricLine(lrcTlyString)

const $roma = (index: number) => {
    if (lrc.value && romalrc.value) {
        for (let i = 0; i < romalrc.value?.length; i++) {
            try {
                if (
                    romalrc.value[i]?.time >= lrc.value[index]?.time &&
                    romalrc.value[i]?.time < lrc.value[index + 1]?.time
                ) {
                    return romalrc.value[i].lyric;
                }
            } catch (error) {
                return 
            }
        }
    }
    return
};

const $tly = (index: number) => {
    if (lrc.value && tlyric.value) {
        try {
            for (let i = 0; i < tlyric.value?.length; i++) {
                if (
                    tlyric.value[i]?.time >= lrc.value[index]?.time &&
                    tlyric.value[i]?.time < lrc.value[index + 1]?.time
                ) {
                    return tlyric.value[i].lyric;
                }
            }
        } catch (error) {
            return 
        }
    }
    return
};

const change = (index:number) => {
    if(index == 0){ 
      yinOryi.value[0] = !yinOryi.value[0] 
      if(yinOryi.value[1] && yinOryi.value[0])yinOryi.value[1] = false
    }else{
      yinOryi.value[1] = !yinOryi.value[1]
      if( yinOryi.value[0] && yinOryi.value[1])yinOryi.value[0] = false
    }
}
</script>

<style scoped lang="less">
@lrc-choice-line:var(--lrcChoiceLine,rgb(204,203,203));
@font-color:var(--fontColor,rgb(0, 0, 0,.7));    //字体颜色控制
@lrc-detail-playing:var(--lrcDetailPlaying,rgb(0,0,0));
@lrc-option-bk:var(--lrcOptionBk,rgb(224,224,224));
@small-font-color:var(--smallFontColor,rgb(150, 150, 150));   
@small-font-color-hover:var(--smallFontColorHover,rgb(48, 48, 48));     //小字颜色hover
@lrc-detail-hover:var(--lrcDetailHover,rgb(73,73,73));
@lrc-detail:var(--lrcDetail,rgb(95,95,95));
@select-color:var(--selectColor,rgb(185, 197, 201));
      .main {
        position: relative;
        margin-top: 100px;

        :deep(.el-scrollbar) {
          margin-top: 30px;
          width: 50vw;
        }

        .opacity {
          opacity: 0;
        }

        .line {
          width: 70%;
          height: 1px;
          background-image: linear-gradient(to right,
              var(--lrcChoiceLine) 10%,
              rgba(0, 0, 0, 0) 20%),
            linear-gradient(to left,
              var(--lrcChoiceLine) 10%,
              rgba(0, 0, 0, 0) 20%);
          position: absolute;
          top: 40%;
          left: 0;
          right: 0;
          margin: 0 auto;
          z-index: 9;

          i {
            position: absolute;
            right: -17px;
            top: -7px;
            bottom: 0;
            color: @font-color;
            cursor: pointer;
          }

          .time {
            position: absolute;
            left: -35px;
            top: -7px;
            bottom: 0;
            font-size: 13px;
            color: @lrc-detail-playing;
          }
        }

        .option {
          position: absolute;
          height: 80%;
          right: 20px;
          top: 0;

          >div {
            >div {
              height: 15px;
              width: 15px;
              background-color: @lrc-option-bk;
            }
          }

          .top {

            .shang,
            .xia {
              margin: 5px 0;
              display: flex;
              justify-content: center;
              align-items: center;

              >i {
                font-size: 8px;
                position: relative;
                left: -0.5px;
                color: @font-color;
              }
            }
          }

          .bottom {
            position: absolute;
            bottom: 0px;
            left: auto;

            .yin,
            .yi {
              font-size: 12px;
              user-select: none;
              cursor: pointer;
              margin: 5px 0;

              >span {
                display: inline-block;
                position: relative;
                left: 1px;
                top: 1px;
                color: @small-font-color;

                &:hover {
                  color: @small-font-color-hover
                }
              }
            }

            .optionColor {
              >span {
                color: @font-color;
              }
            }
          }

        }
        .lrc {
          justify-content: center;
          align-items: center;
          display: flex;
          flex-direction: column;
          width: 100%;
          margin: 0 auto;
          margin-top: 20%;
          margin-bottom: 30%;

          .hover {
            >div {
              color: @lrc-detail-hover !important;
              // color: red !important;
            }
          }

          .playingColor {
            >div {
              color: @lrc-detail-playing !important;
            }

            .lrc-main {
              font-size: 16px !important;
            }

            .lrc-roma,
            .lrc-tly {
              font-size: 13px !important;
            }
          }

          .lrc-block {
            align-items: center;
            display: flex;
            flex-direction: column;
            text-align: center;
            margin: 15px 0;
            max-width: 100%;

            >div {
            //   display: inline-flex;
              line-height: 25px;
              white-space: nowrap;
              width: 100%;
            }

            .lrc-main {
              color: @lrc-detail;
              font-size: 14px;
              cursor: default;

              &::selection {
                background-color: @select-color;
              }
            }

            .lrc-roma,
            .lrc-tly {
              color: @lrc-detail;
              font-size: 12px;
              cursor: default;
              // margin-top: 10px;

              &::selection {
                background-color: @select-color;
              }
            }
          }
        }
      }
</style>

roma,tly,change,分别用于渲染罗马音,渲染翻译,切换歌词显示模式。计算原理是根据每条歌词寻找下一条时间大于等于当前歌词的翻译或读音。默认显示翻译(前提是有翻译),在点击时切换。

js 复制代码
const $roma = (index: number) => {
    if (lrc.value && romalrc.value) {
        for (let i = 0; i < romalrc.value?.length; i++) {
            try {
                if (
                    romalrc.value[i]?.time >= lrc.value[index]?.time &&
                    romalrc.value[i]?.time < lrc.value[index + 1]?.time
                ) {
                    return romalrc.value[i].lyric;
                }
            } catch (error) {
                return 
            }
        }
    }
    return
};

const $tly = (index: number) => {
    if (lrc.value && tlyric.value) {
        try {
            for (let i = 0; i < tlyric.value?.length; i++) {
                if (
                    tlyric.value[i]?.time >= lrc.value[index]?.time &&
                    tlyric.value[i]?.time < lrc.value[index + 1]?.time
                ) {
                    return tlyric.value[i].lyric;
                }
            }
        } catch (error) {
            return 
        }
    }
    return
};
const yinOryi = ref([true,false])
const change = (index:number) => {
    if(index == 0){ 
      yinOryi.value[0] = !yinOryi.value[0] 
      if(yinOryi.value[1] && yinOryi.value[0])yinOryi.value[1] = false
    }else{
      yinOryi.value[1] = !yinOryi.value[1]
      if( yinOryi.value[0] && yinOryi.value[1])yinOryi.value[0] = false
    }
}
如图所示

播放高亮

现在我们添加audio标签,监听timeupdate监视时间的变化,判定当前播放歌词的条件是当前播放时间在当前歌词与下一条歌词时间之间,或者是最后一条歌词之间。满足条件添加playingColor高亮样式

html 复制代码
<div class="lrc">
    <div :class="{
        playingColor: isPlaying(
        lrc[index]?.time,
        lrc[index + 1]?.time
        ),
    }" 
.......
js 复制代码
const audioRef = ref() as Ref<HTMLAudioElement>
onMounted(()=>{
    audioRef.value.addEventListener('timeupdate',()=>{
        console.log(audioRef.value.currentTime);
        playingTime.value = audioRef.value.currentTime
    })
})
const playingTime = ref()
const eqi = ref(0)
const isPlaying = (time: number, time2: number) => {
    return (
        ((playingTime.value + eqi.value) * 1000 >= time && (playingTime.value + eqi.value) * 1000 < time2) ||
        ((playingTime.value + eqi.value) * 1000 >= time && time2 == undefined)
    );

};
如图所示

跟随滚动

line是中间的基准线,dom为刚才计算出的高亮歌词,滚动距离为高亮距离顶部高度 - 基准线距离顶部的高度 + 基准线的高度。在以点击进度条修改播放的情况下dom会为null,必须使用nextTick在页面更新完之后才能拿到高亮歌词。由于在页面最小化时scroll滚动动画并不会执行,因此我们必须在页面隐藏时停止使用滚动动画,防止页面在从隐藏到显示的突然滚动。

js 复制代码
const scrollbarRef= ref<InstanceType<typeof ElScrollbar>>()
const line = ref<InstanceType<typeof HTMLElement>>()
const smallFlag = ref(false)
watch(playingTime, (newValue,oldValue) => {
    if(playingTime.value == 0){
      scrollbarRef.value!.scrollTo({
        top: 0,
      });
    }else{
      nextTick(()=>{
        let dom = document.querySelector(".playingColor") as HTMLElement;
        if (dom && line.value) {
            let newOffset = dom.offsetTop - line.value!.offsetTop + dom.offsetHeight ;
            if(Math.abs(newValue - oldValue) > 5 || smallFlag.value){
                scrollbarRef.value!.scrollTo({
                    top: newOffset,
                });
            }else{
                scrollbarRef.value!.scrollTo({
                    top: newOffset,
                    behavior: "smooth",
                });
            }
        }
      })
    }
});
//隐藏时切换模式
document.addEventListener('visibilitychange',()=>{
    if(document.hidden)smallFlag.value = true
    else smallFlag.value = false
})
如图所示

主动滚动样式

当鼠标进入歌词框时,滚动歌词的同时高亮歌词。在加载歌词的时候获取全部歌词的scrollTop,对于每条歌词,高亮的条件是:鼠标进入歌词框,其余的与自动滚动判断条件相同但是要额外加上已经滚动过的距离

js 复制代码
//滚动样式
let lrcOffset = ref<any>([])
const hadenScroll = ref(0)
const barScroll = (obj: any) => {
    hadenScroll.value = obj.scrollTop
}
const getLrcOffset= ()=>{
  nextTick(() => {
      lrcOffset.value.length = 0;
      let [...arr] = document.querySelectorAll('.lrcRush') as any;
      arr.forEach((dom: HTMLElement) => {
          lrcOffset.value.push(Number(dom.offsetTop))
      })
  })
}
onMounted(()=>{
    getLrcOffset()
})

const isHover = (index: number, time: number) => {
    if(line.value && showLineFlag.value && ((hadenScroll.value + line.value!.offsetTop >= lrcOffset.value[index] &&
    hadenScroll.value + line.value!.offsetTop < lrcOffset.value[index + 1]) ||
    (hadenScroll.value + line.value!.offsetTop >= lrcOffset.value[index] && lrcOffset.value[index + 1] == undefined))){
        scroolTime.value = time
        return true
    }else{
        return false
    }
}
如图所示

跳转播放与歌词偏移

比较简单

js 复制代码
const gotoPlay = () => {
    audioRef.value.currentTime = scroolTime.value / 1000;
    if(audioRef.value.paused){
        audioRef.value.play()
    }
}
//加0.5
const jia = () => {
    eqi.value += 0.5
}

//减0.5
const jian = () => {
    eqi.value -= 0.5
}

结语

至此一个完整的滚动歌词就实现了,别的就不说了

标题:一个歌词滚动器

作者:southernMD

发布于:

评论
昵称
邮箱
网站
评论
0 / 125
评论列表(0)
移至左侧
回到顶部
日间模式
开启音乐
隐藏面板
a