/* * Copyright (c) Baidu, Inc. All Rights Reserved. * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. */ import Icon from '@cosui/cosmic/icon'; import type {MusicPlayerProps, MusicPlayerEvents, lyricItem} from './interface'; import {MusicPlayerStatus, Status} from './interface'; import MusicPlayerBase from './base'; import {isURL} from '@cosui/cosmic/util'; export default class MusicPlayer extends MusicPlayerBase { static template = `
{{ name }}
{{ title }}
{{ item.content }}
{{formatTimeDuration(currentTime)}}
/{{formatTimeDuration(duration)}}
`; static components = { 'cos-icon': Icon }; static computed = { _isUrl(this: MusicPlayer): boolean { const icon = this.data.get('icon'); return isURL(icon); } }; private _onTimeUpdate: (e: Event) => void; private _onPlay: (e: Event) => void; private _onPause: (e: Event) => void; private _onLyricsScroll: EventListener; private _rafId: number | null = null; private _progressBarInnerEl: HTMLElement | null = null; initData(): MusicPlayerProps { return { _playStatus: MusicPlayerStatus.PENDING, _currentLyricIndex: 0, _progress: 0, _isSeeking: false, _showTopMask: false, _showBottomMask: false, icon: '', title: '', poster: '', lyrics: [], src: '', currentTime: 0, duration: 0, status: Status.FINISHED }; } attached() { const audio = this.ref('audio') as unknown as HTMLAudioElement; this._onTimeUpdate = this.onTimeUpdate.bind(this); this._onPlay = this.play.bind(this); this._onPause = this.pause.bind(this); audio.addEventListener('timeupdate', this._onTimeUpdate); audio.addEventListener('play', this._onPlay); audio.addEventListener('pause', this._onPause); this.handleMousemove = this.handleMousemove.bind(this); this.handleMouseup = this.handleMouseup.bind(this); const lyricsWrapper = this.ref('lyricsWrapper') as unknown as HTMLElement; if (lyricsWrapper) { this._onLyricsScroll = this.updateMaskState.bind(this); lyricsWrapper.addEventListener('scroll', this._onLyricsScroll); } this.nextTick(() => { this.updateMaskState(); this.updateLyricsOpacity(0); }); } detached() { const audio = this.ref('audio') as unknown as HTMLAudioElement; if (audio) { audio.removeEventListener('timeupdate', this._onTimeUpdate); audio.removeEventListener('play', this._onPlay); audio.removeEventListener('pause', this._onPause); } const lyricsWrapper = this.ref('lyricsWrapper') as unknown as HTMLElement; if (lyricsWrapper && this._onLyricsScroll) { lyricsWrapper.removeEventListener('scroll', this._onLyricsScroll); } } // 时间戳更新 + 滚动歌词 onTimeUpdate(event: any) { // 进度条拖拽中不更新 if (this.data.get('_isSeeking')) { return; } const audio = this.ref('audio') as unknown as HTMLAudioElement; const currentTime = event.currentTime || (audio.currentTime * 1000); const duration = event.duration || (audio.duration * 1000); // 更新进度条 this.data.set('currentTime', currentTime); const progress = (currentTime / duration) * 100; this.data.set('_progress', progress); // 更新歌词 const lyrics = this.data.get('lyrics') || []; let index = lyrics.findIndex( lyric => (lyric.startTime ?? 0) <= currentTime && currentTime < (lyric.endTime ?? Infinity) ); // 边界处理 if (index === -1 && lyrics.length) { if (currentTime < (lyrics[0].startTime ?? 0)) { index = 0; } else if (currentTime >= (lyrics[lyrics.length - 1].startTime ?? 0)) { index = lyrics.length - 1; } } requestAnimationFrame(() => { if (index !== -1 && this.data.get('_currentLyricIndex') !== index) { this.data.set('_currentLyricIndex', index); this.scrollLyrics(index); } }); } // 滚动歌词,让当前高亮位于居中偏上的位置 scrollLyrics(index: number) { const lyrics = this.ref('lyricsWrapper') as unknown as HTMLElement; if (!lyrics) { return; } const lyricNodes = lyrics.querySelectorAll('.cosd-music-player-content-lyric'); const activeLyric = lyricNodes[index] as HTMLElement; if (!activeLyric) { return; } // 获取高亮元素和容器尺寸 const containerHeight = lyrics.clientHeight; const lineHeight = activeLyric.offsetHeight; // 获取高亮元素相对于容器位置 const lineRect = activeLyric.getBoundingClientRect(); const containerRect = lyrics.getBoundingClientRect(); const relativePosition = lineRect.top - containerRect.top; // 计算让高亮元素出现在容器中间的滚动距离 const currentTop = relativePosition + lyrics.scrollTop - (containerHeight / 2) + (lineHeight / 2); const scrollTop = currentTop + (lineHeight / 2); lyrics.scrollTo({ top: scrollTop, behavior: 'smooth' }); } // 更新歌词透明度 updateLyricsOpacity(index: number) { const lyrics = this.ref('lyricsWrapper') as unknown as HTMLElement; const lyricNodes = Array.from(lyrics.querySelectorAll('.cosd-music-player-content-lyric')); const len = lyricNodes.length; if (!lyrics || !len) { return; } lyricNodes.forEach((node, i) => { node?.classList.remove( '.cos-opacity-100()', '.cos-opacity-75()', '.cos-opacity-50()' ); // 默认透明度 50 let opacityClass = '.cos-opacity-50()'; // 头部特殊处理 if (index === 0) { if (i <= 1) { opacityClass = '.cos-opacity-100()'; } else if (i <= 3) { opacityClass = '.cos-opacity-75()'; } } // 尾部特殊处理 else if (index === len - 1) { if (i >= len - 2) { opacityClass = '.cos-opacity-100()'; } else if (i >= len - 4) { opacityClass = '.cos-opacity-75()'; } } else { // 中间高亮行处理 const distance = Math.abs(i - index); if (distance === 0) { opacityClass = '.cos-opacity-100()'; } else if (distance === 1) { opacityClass = '.cos-opacity-75()'; } } node?.classList.add(opacityClass); }); } updateMaskState() { const lyricsWrapper = this.ref('lyricsWrapper') as unknown as HTMLElement; if (!lyricsWrapper) { return; } const {scrollTop, scrollHeight, clientHeight} = lyricsWrapper; if (scrollHeight <= clientHeight) { this.data.set('_showTopMask', false); this.data.set('_showBottomMask', false); return; } this.data.set('_showTopMask', scrollTop > 0); this.data.set('_showBottomMask', scrollTop + clientHeight < scrollHeight - 1); } handleLyricClick(item: lyricItem) { this.fire('lyricsClick', {item}); const audio = this.ref('audio') as unknown as HTMLAudioElement; const _playStatus = this.data.get('_playStatus'); audio.currentTime = (item.startTime ?? 0) / 1000; // 如果音频未播放,则播放 _playStatus !== MusicPlayerStatus.PLAYING && this.play(); this.onTimeUpdate({ currentTime: item.startTime }); } /** * 格式化时长 */ formatTimeDuration = (timeInSeconds: number): string => { const minutes = Math.floor(timeInSeconds / 1000 / 60).toString().padStart(2, '0'); const seconds = Math.floor((timeInSeconds / 1000) % 60).toString().padStart(2, '0'); return `${minutes}:${seconds}`; }; // 进度条拖拽开始 handleMousedown(event: MouseEvent) { this.fire('seeking', {event}); if (event?.defaultPrevented) { return; } this.data.set('_isSeeking', true); const progressThumb = this.ref('progressThumb') as unknown as HTMLElement; progressThumb?.classList.add('dragging'); document.addEventListener('mousemove', this.handleMousemove); document.addEventListener('mouseup', this.handleMouseup); event.preventDefault(); } // 拖拽中实时更新进度条宽度 handleMousemove(event: MouseEvent) { if (!this.data.get('_isSeeking')) { return; } if (this._rafId) { cancelAnimationFrame(this._rafId); } this._rafId = requestAnimationFrame(() => { const progressBarEl = this.ref('progressBar') as unknown as HTMLElement; if (!progressBarEl) { return; } const rect = progressBarEl.getBoundingClientRect(); let offsetX = event.x - rect.left; offsetX = Math.max(0, Math.min(offsetX, rect.width)); const progress = (offsetX / rect.width) * 100; this.data.set('_progress', progress); }); } // 进度条拖拽结束 handleMouseup(event: MouseEvent) { this.fire('seeked', {event}); if (!this.data.get('_isSeeking') || event?.defaultPrevented) { return; } this.data.set('_isSeeking', false); const progressBarEl = this.ref('progressBar') as unknown as HTMLElement; const progressBarInnerEl = this.ref('progressBarInner') as unknown as HTMLElement; if (!progressBarEl) { return; } progressBarInnerEl?.classList.remove('dragging'); const rect = progressBarEl.getBoundingClientRect(); let offsetX = event.x - rect.left; offsetX = Math.max(0, Math.min(offsetX, rect.width)); const progress = offsetX / rect.width; const duration = this.data.get('duration') ?? 0; const newTime = progress * duration; const audio = this.ref('audio') as unknown as HTMLAudioElement; audio.currentTime = newTime / 1000; this.data.set('_progress', progress * 100); this.data.set('currentTime', newTime); if (this.data.get('_playStatus') !== MusicPlayerStatus.PLAYING) { this.play(); } this.onTimeUpdate({currentTime: newTime}); document.removeEventListener('mousemove', this.handleMousemove); document.removeEventListener('mouseup', this.handleMouseup); this._progressBarInnerEl = null; this._rafId && cancelAnimationFrame(this._rafId); this._rafId = null; } // 点击进度条跳转 handleProgressClick(event: MouseEvent) { const progressBarEl = this.ref('progressBar') as unknown as HTMLElement; const progressBarInnerEl = this.ref('progressBarInner') as unknown as HTMLElement; if (!progressBarEl || !progressBarInnerEl) { return; } const rect = progressBarEl.getBoundingClientRect(); let offsetX = event.x - rect.left; offsetX = Math.max(0, Math.min(offsetX, rect.width)); const newTime = (offsetX / rect.width) * (this.data.get('duration') ?? 0); const audio = this.ref('audio') as unknown as HTMLAudioElement; audio.currentTime = (newTime ?? 0) / 1000; this.data.set('_progress', (offsetX / rect.width) * 100); this.data.set('currentTime', newTime); // 播放 const _playStatus = this.data.get('_playStatus'); _playStatus !== MusicPlayerStatus.PLAYING && this.play(); this.onTimeUpdate({currentTime: newTime}); } }