最近公司项目接到一个需求,要实现录音播放时和文本同步,类似于音乐播放器播放歌曲时,歌词和声音同步的效果。要实现这个功能需要监听audio
标签的实时播放时间,然后在监听事件中处理文本,通过当前时间定位到对应的文本数据,并展示在页面。
原生实现
使用原生的audio
标签时,需要获取audio
标签dom对象,并给它添加timeupdate
事件,在事件中就可以实时处理文本了,代码步骤如下
// audio标签
<audio src="test.mp3" id="myAudio" />
// 1.获取 aduio 对象
var audio = document.querySelector("#myAudio");
// 2.注册事件
audio.addEventListener("timeupdate",function(){
// 当前播放时间,单位秒
var nowTime = audio.currentTime;
// ...其他处理
});
在事件中获取当前时间,然后定位到文本数据,再把文本颜色设置为红色,这样就实现了一个简单的播放器音频和文本同步的效果,最终效果图如下
React中实现
上面所说的是原生实现,但目前主流的一般会使用React或者Vue,当然也可以在React或Vue组件中操作Dom,但这个不符合框架的设计思想。另外还有一个问题,歌词很长的情况下,播放到滚动条下面时,文本会被遮住,这就要求当播放到滚动条下面,歌词被遮住时,滚动条需要自动往下滚动。
滚动条自动下滑
要实现滚动条自动下滑,有三个步骤
- 获取当前文本标签的Dom对象,相对于父容器(有滚动条的容器)顶部的距离:
sTopHeight
- 获取父容器上方被滚动条遮住的高度:
zHeight
- 获取父容器的可视高度,这个高度一般设置是固定高度:
xHeight
。计算公式当sTopHeight > zHeight + xHeight
为真时,说明文本已经超出可视高度,被遮住了,这时就需要向下滑动滚动条了
以下是实现代码,这时React中直接操作Dom方式的实现
constructor(props){
super(props);
this.state = {
txtList:[
{
start:24,
time:"00:24",
txt:"月色烙印在城墙"
},{
start:26,
time:"00:26",
txt:"风声呼啸过苍莽"
},{
start:29,
time:"00:29",
txt:"有双眸如炬般明亮"
},{
start:35,
time:"00:35",
txt:"将生死握于手掌"
},{
start:38,
time:"00:38",
txt:"待尘沙磨砺翅膀"
},{
start:41,
time:"00:41",
txt:"褪变出崭新模样"
},{
start:46,
time:"00:46",
txt:"那团火在何处绽放"
},{
start:49,
time:"00:49",
txt:"开始侵袭六腑五脏"
},{
start:51,
time:"00:51",
txt:"却使我全身的血脉都膨胀"
},{
start:57,
time:"00:57",
txt:"有太多的相识背叛"
},{
start:60,
time:"01:00",
txt:"不必谁原谅"
},{
start:63,
time:"01:03",
txt:"只恨这英雄年少不轻狂"
},{
start:68,
time:"00:26",
txt:"我乘风而下"
},{
start:70,
time:"01:10",
txt:"要在破晓处羽化"
},{
start:73,
time:"01:13",
txt:"要让满座的喧哗 屏息惊诧"
},{
start:80,
time:"01:20",
txt:"凭誓约回答"
},{
start:82,
time:"01:26",
txt:"两人的命运交叉"
},{
start:85,
time:"01:25",
txt:"跃再多悬壁陡崖"
},{
start:93,
time:"01:33",
txt:"我不怕"
}
]
}
}
componentDidMount(){
const audio = document.querySelector(".myAudio>audio");
const list = this.state.txtList;
const txtContain = document.querySelector('#txtContain');
audio.addEventListener("timeupdate",() => {
for(let i=0;i<list.length;i++){
let item = list[i];
item.chose='N';
}
for(let i=0;i<list.length;i++){
let item = list[i];
if(item.start === parseInt(audio.currentTime)){
item.chose='Y';
let txtDiv = document.querySelector('#txtContain > #txt'+item.start);
//可见窗体高度的一半(歌词正好在中间)+被覆盖的高度
let zHeight= parseInt(150+txtContain.scrollTop);
// 如果相对父容器偏移量大于(可见窗体高度+被覆盖的高度),则滚动条下移100px
if(txtDiv.offsetTop>zHeight){
//this.myScrollTop(txtContain,22,220);
txtContain.scrollTop=txtContain.scrollTop+22;
}
this.setState({txtList:list});
break;
}
}
});
const txtContains = document.querySelector('#txtContains');
this.setState({txtContains:txtContains});
}
render() {
const choseStyle={
color:'red',
height:'22px'
};
const noStyle={
color:'black',
height:'22px'
};
return (
<PageHeaderWrapper>
<div style={{marginTop:'30px',width:'600px',display:'inline-block'}}>
<div className="site-card-border-less-wrapper">
<Card title={this.state.fieldId} bordered={false} style={{ width: 500 }}>
<div> <AudioPlayer src={url} className="myAudio"/></div>
</Card>
</div>
<Card size="small" title="歌词" style={{width:'500px'}}>
<div style={{height:'250px',overflow:'auto'}} id="txtContain">
{this.state.txtList.map( (item) =>
<div key={item.start} style={item.chose === 'Y' ? choseStyle:noStyle}
id={'txt'+item.start}>
[{item.time}]:{item.txt}
</div>
)}
</div>
</Card>
</div>
</PageHeaderWrapper>
);
}
最终效果如下
效果美化
在React中直接操作Dom虽然可以实现效果,但看起来都不怎么优雅。由于我使用了React的组件react-h5-audio-player
,这个组件提供了timeupdate事件,所以优化以下代码,顺便把效果美化以下,播放行时加粗文本字体和行的阴影效果,哈哈。
audioTimeUpdate = (currentTime) => {
let list = this.state.txtLists;
let txtContain = this.state.txtContains;
for(let i=0;i<list.length;i++){
let item = list[i];
item.chose='N';
}
for(let i=0;i<list.length;i++){
let item = list[i];
if(item.start === parseInt(currentTime)){
item.chose='Y';
let txtDiv = document.querySelector('#txtContains > #txt'+item.start);
//可见窗体高度的一半(歌词正好在中间)+被覆盖的高度
let zHeight= parseInt(150+txtContain.scrollTop);
// 如果相对父容器偏移量大于(可见窗体高度+被覆盖的高度),则滚动条下移100px
if(txtDiv.offsetTop>zHeight){
//this.myScrollTop(txtContain,22,220);
txtContain.scrollTop=txtContain.scrollTop+22;
}
this.setState({txtLists:list});
break;
}
}
};
render() {
const choseStyles={
boxShadow:'0 0 50px rgb(166,124,64)',
height:'22px',
borderRadius:'8px',
paddingLeft:'5px',
fontWeight:600
};
const noStyles={
height:'22px'
};
return (
<PageHeaderWrapper>
<div style={{marginTop:'30px',width:'600px',display:'inline-block'}}>
<div className="site-card-border-less-wrapper">
<Card title={this.state.fieldId} bordered={false} style={{ width: 500 }}>
<div> <AudioPlayer src={url} onListen={this.audioTimeUpdate}/></div>
</Card>
</div>
<Card size="small" title="歌词" style={{width:'500px'}}>
<div style={{height:'250px',overflow:'auto'}} id="txtContains">
{this.state.txtLists.map( (item) =>
<div key={item.start} style={item.chose === 'Y' ? choseStyles:noStyles}
id={'txt'+item.start}>
[{item.time}]:{item.txt}
</div>
)}
</div>
</Card>
</div>
</PageHeaderWrapper>
);
}
最终效果如下
总结
在开发过程中遇到的问题,当我通过元素的offsetTop
属性获取元素相对于父容器的距离时,会出现距离不符合预期的情况,这实际上是offsetTop
定位机制的问题,与它类似的属性还有offsetLeft
,需要注意的是offsetTop
和offsetLeft
定位机制是相对于最近的祖先定位元素(即父元素的 position 属性被设置为 relative
、absolute
或 fixed
的元素)的向上或向左偏移值。如果父元素的 position 属性非 relative
、absolute
或 fixed
,则都是相对于body定位。