| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899 |
- import { duration2text } from '../extensions/Extension';
- import { LyricController } from '../LyricController';
- import { Lyric } from '../bean/Lyric';
- import { ListAdapter } from '../extensions/ListAdapter';
- import { LyricLine } from '../bean/LyricLine';
- import { LyricWord } from '../bean/LyricWord';
- import { transverter, TransverterType, TransverterLanguage } from "@nutpi/chinese_transverter"
- import { LengthMetrics } from '@kit.ArkUI';
- import { shouldApplyNormalLyricKaraokeEffect } from './LyricViewRenderHelper';
- /**
- * A component to display the lyric with scroll animation.
- * This component only support api 10+.
- *
- * The custom setter include the lyric text style, fade style of edge, the line space, scroll animation duration,
- * the cache size to draw out of screen.
- */
- @Component
- export struct LyricView2 {
- /**
- * The lyricConfig for LyricView.
- */
- controller: LyricController = new LyricController()
- /**
- * Enable the lyric scroll to do seek action or not.
- * If false, the onSeekAction callback will not invoke anymore.
- */
- enableSeek: boolean = true
- /**
- * Enable word-by-word lyric render or not.
- * If false, lines with word timing will fallback to normal line render.
- */
- @State enableWordByWordLyric: boolean = true
- @State isLightText: boolean = true
- /**
- * The color of seek button and duration text.
- */
- seekUIColor: ResourceColor = '#000000'
- /**
- * The color of the seek location line.
- */
- seekLineColor: ResourceColor = '#0d000000'
- /**
- * The seek ui style.
- */
- seekUIStyle: 'seekLine' | 'listItem' = 'listItem'
- /**
- * The seek callback for scroll the lyric.
- * If return true, you must handle this action to do seek action of media player.
- * If return false, the lyric view will scroll to current index of the media playing position.
- */
- onSeekAction: (position: number) => boolean = () => false
- private currentLyric: Lyric | null = null
- private scroller = new Scroller()
- private listAdapter = new ListAdapter<LyricLine>()
- private w = 0 // the width of this view.
- private h = 0 // the height of this view.
- @State currentIndex: number = 0 // the focused index of the lyric line list.
- @State isLyricEmpty: boolean = true // is no lyric.
- @State textSize: number = 16
- @State isSingleLine: boolean = false
- @State lineSpace: number = 16
- @State textWeight: number = FontWeight.Medium
- @State textHighlightSize: number = 18
- @State textColor: string = '#000000'
- @State textHighlightColor: string = '#000000'
- @State isHighlightBold: boolean = false
- @State alignMode: 'left' | 'center' = 'center'
- @State emptyHint: string = ''
- @State cacheSize: number = 3
- @State animDuration: number = 300
- @State isUserTouching: boolean = false
- @State seekPosition: number = -1
- @State scrollDurationText: string = '00:00'
- @State seekIndex: number = -1
- private seekUiHideTimeout = -1
- private autoHideSeekUIDuration = 2000
- @State isLoadingData: boolean = false
- private loadTimeout = -1
- @State isHightLightCenter: boolean = true
- @State currentMediaPosition: number = 0
- @State transverterType: number = 0
- private lastPositionUpdateTs: number = 0
- private lastPositionForRender: number = -1
- private readonly positionUpdateThrottleMs: number = 33
- private readonly minPositionDeltaMs: number = 8
- private readonly karaokeTransitionWidth: number = 0.12
- @State highlightFontProgress: number = 1
- @State highlightFontActiveIndex: number = -1
- private highlightFontToken: number = 0
- private highlightFontStartTimeout = -1
- private readonly highlightFontStartDelayMs: number = 100
- private readonly highlightFontDurationMs: number = 180
- private readonly areaReloadThresholdPx: number = 1
- private onDataChangedListener = (lyric: Lyric | null) => {
- clearTimeout(this.loadTimeout)
- this.isLoadingData = true
- this.loadData(lyric)
- this.loadTimeout = setTimeout(() => {
- this.isLoadingData = false
- }, 300)
- }
- private onPositionChangedListener = (mediaPosition: number) => {
- // 如果是纯文本歌词,不进行位置同步
- if (this.currentLyric && this.currentLyric.isPlainText) {
- this.currentMediaPosition = mediaPosition
- return
- }
- const now = Date.now()
- if (now - this.lastPositionUpdateTs < this.positionUpdateThrottleMs &&
- Math.abs(mediaPosition - this.lastPositionForRender) < this.minPositionDeltaMs) {
- return
- }
- this.lastPositionUpdateTs = now
- this.lastPositionForRender = mediaPosition
- this.currentMediaPosition = mediaPosition
- this.onPositionChanged(mediaPosition)
- }
- private onInvalidatedListener = (reLayout: boolean) => {
- this.getAttrFromController()
- if (reLayout && this.currentIndex > 0) {
- this.animateToIndex(this.currentIndex)
- }
- }
- private loadData(lyric: Lyric | null) {
- this.currentLyric = lyric;
- clearTimeout(this.highlightFontStartTimeout)
- this.highlightFontToken += 1
- this.highlightFontActiveIndex = -1
- this.highlightFontProgress = 1
- if (this.w > 0 && this.h > 0) {
- if (this.currentLyric) {
- this.listAdapter.clear(false);
- let lyricLines = this.currentLyric.lyricList;
- if (lyricLines!==undefined&&lyricLines.length > 0) {
- let first = lyricLines[0].beginTime;
- // fill the top empty gap 注释掉修复 歌曲不播放的时候,可以显示完整的歌词,而不是显示一两行
- // for (let i = 0; i < this.centerOffsetSize; i++) {
- // this.listAdapter.addData(new LyricLine(' ', 0, first), false);
- // }
- lyricLines.forEach((line) => {
- this.listAdapter.addData(line, false);
- });
- // fill the bottom empty gap
- // let last = lyricLines[lyricLines.length - 1].nextTime;
- // for (let i = 0; i < this.centerOffsetSize; i++) {
- // this.listAdapter.addData(new LyricLine(' ', last + i, last + i), false);
- // }
- }
- this.listAdapter.notifyDataReload();
- } else {
- this.listAdapter.clear(true);
- }
- }
- this.isLyricEmpty = this.listAdapter.isEmpty();
- this.lastPositionUpdateTs = 0
- this.lastPositionForRender = -1
- if (this.isLyricEmpty) {
- this.currentIndex = 0
- this.scrollToIndexImmediately(0)
- return
- }
- const initialIndex = this.resolveInitialIndex()
- this.currentIndex = initialIndex
- this.scrollToIndexImmediately(initialIndex)
- }
- private resolveInitialIndex(): number {
- const count = this.listAdapter.totalCount()
- if (count <= 0) {
- return 0
- }
- if (this.currentLyric && this.currentLyric.isPlainText) {
- return 0
- }
- const firstLine = this.listAdapter.getData(0)
- const lastLine = this.listAdapter.getData(count - 1)
- const lastEndTime = lastLine.nextTime > lastLine.beginTime ? lastLine.nextTime : lastLine.beginTime
- if (this.currentMediaPosition <= firstLine.beginTime - 2000 || this.currentMediaPosition > lastEndTime + 2000) {
- return 0
- }
- return this.getIndex(this.currentMediaPosition)
- }
- private scrollToIndexImmediately(index: number): void {
- const count = this.listAdapter.totalCount()
- if (count <= 0) {
- return
- }
- const safeIndex = Math.max(0, Math.min(count - 1, index))
- if(this.isHightLightCenter){
- this.scroller.scrollToIndex(safeIndex, false, ScrollAlign.CENTER)
- }else{
- const targetIndex = Math.max(0, safeIndex - 2)
- this.scroller.scrollToIndex(targetIndex, false, ScrollAlign.START)
- }
- }
- private getAttrFromController() {
- this.currentLyric = this.controller.getLyric()
- this.textSize = this.controller.getTextSize()
- this.transverterType = this.controller.getTransverterType()
- this.isSingleLine = this.controller.getSingleLine()
- this.blurDegree = this.controller.getBlurDegree()
- this.isHightLightCenter = this.controller.getHightLightCenter()
- this.lineSpace = this.controller.getLineSpace()
- this.textColor = this.controller.getTextColor()
- this.textHighlightColor = this.controller.getHighlightColor()
- this.textHighlightSize = this.controller.getHighlightScale() * this.textSize
- this.isHighlightBold = this.controller.isHighlightBold()
- this.animDuration = this.controller.getAnimationDuration()
- this.cacheSize = this.controller.getCacheSize()
- this.emptyHint = this.controller.getEmptyHint()
- this.alignMode = this.controller.getAlignMode()
- this.textWeight = this.controller.getTextWeight()
- this.enableWordByWordLyric = this.controller.getEnableWordByWordLyric()
- this.isLightText = this.controller.getLightText()
- }
- aboutToAppear() {
- if (this.controller == null) {
- throw new Error('The lyric lyricConfig is not set!')
- }
- this.controller.onDataChangedListener = this.onDataChangedListener
- this.controller.onPositionChangedListener = this.onPositionChangedListener
- this.controller.onInvalidated = this.onInvalidatedListener
- this.getAttrFromController()
- // 初始化时将滚动位置设置为顶部
- this.scroller.scrollToIndex(0, true, ScrollAlign.START);
- }
- @Builder
- EmptyView() {
- Text(this.emptyHint)
- .fontSize(this.textSize)
- .fontColor(this.textColor)
- }
- @State blurDegree: number = 3
- // 优化建议代码示例:增加滚动节流
- private lastScrollTime: number = 0
- private scrollThrottle: number = 100 // 100ms节流
- @Builder
- LyricListView() {
- List({ space: this.lineSpace - 16, scroller: this.scroller }) {
- LazyForEach(this.listAdapter, (item: LyricLine, index: number) => {
- ListItem() {
- Stack() {
- // 逐字歌词渲染
- if (item.hasWords() && item.words.length > 0) {
- this.WordByWordLyric(item, index)
- } else {
- // 普通歌词渲染(原有逻辑,这个里面也有逐字歌词的功能,如果用户开启逐字的话)
- this.NormalLyricLine(item, index)
- }
- if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
- && this.enableSeek && this.isUserTouching){
- this.JumpProgress()
- }
- }
- .align(Alignment.End)
- }
- .padding(8)
- .border({ radius: 12 })
- .onClick(() => {
- if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0) {
- if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
- this.handleSeekAction();
- }
- }
- })
- },
- (item: LyricLine, index: number) => {
- return item.text + '_' + index + '_' + item.beginTime + '_' + item.nextTime
- })
- }
- .width('100%')
- .height('100%')
- .layoutWeight(1)
- .scrollBar(BarState.Off)
- .fadingEdge(true,{fadingEdgeLength:LengthMetrics.percent(20)})
- .edgeEffect(EdgeEffect.Spring)
- .contentEndOffset(this.h / 3)
- .contentStartOffset(this.isUserTouching?this.h / 3:0)
- .cachedCount(this.cacheSize)
- // .chainAnimation(true)
- // .animation({
- // curve: curves.springCurve(100, 10, 80, 10),
- // duration: 500
- // })
- .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible)
- .onScrollIndex((_, __, center) => {
- const now = Date.now()
- if (now - this.lastScrollTime < this.scrollThrottle) return
- this.lastScrollTime = now
- // 纯文本歌词不支持 seek 操作
- if (this.isUserTouching && !(this.currentLyric && this.currentLyric.isPlainText)) {
- //修复The value of "index" is out of range. It must be >= 0 && <= -1. Received value is: -1
- if (center >= 0 && center < this.listAdapter.totalCount()) {
- this.seekIndex = center;
- let targetPosition = this.listAdapter.getData(center).beginTime;
- this.scrollDurationText = duration2text(targetPosition);
- }
- // this.seekIndex = center
- // let targetPosition = this.listAdapter.getData(center).beginTime
- // this.scrollDurationText = duration2text(targetPosition)
- }
- })
- .onTouch((event) => {
- let motion = event.touches[0]
- switch (motion.type) {
- case TouchType.Down:
- clearTimeout(this.seekUiHideTimeout)
- break
- case TouchType.Move:
- this.isUserTouching = true
- break
- case TouchType.Up:
- case TouchType.Cancel:
- // 纯文本歌词不需要自动滚动回顶部
- if (this.currentLyric && this.currentLyric.isPlainText) {
- this.seekUiHideTimeout = setTimeout(() => {
- this.seekIndex = -1
- this.isUserTouching = false
- // 纯文本歌词不调用 animateToIndex,保持在当前位置
- }, this.autoHideSeekUIDuration)
- } else {
- this.seekUiHideTimeout = setTimeout(() => {
- this.seekIndex = -1
- this.isUserTouching = false
- this.animateToIndex(this.currentIndex)
- }, this.autoHideSeekUIDuration)
- }
- }
- })
- }
- // 普通歌词渲染(原有逻辑)
- @Builder
- NormalLyricLine(item: LyricLine, index: number) {
- Column(){
- Text(item.text)
- .fontSize(this.getAnimatedLyricFontSize(index))
- .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
- .fontColor(this.getNormalLyricTextColor(index))
- .fontWeight(this.getNormalLyricFontWeight(index))
- .padding(this.isSingleLine?0:{ top:5,bottom:5 })
- .visibility(this.isSingleLine?
- (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
- : Visibility.Visible)
- .width(this.getNormalLyricLineWidth(index, '80%'))
- .textShadow(this.isLightText ? {
- radius: 20,
- color: Color.White,
- offsetX: 0,
- offsetY: 0
- } : undefined)
- .blendMode(
- this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.DST_IN : undefined,
- this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
- )
- // 中文翻译(整行显示)
- if (item.translation) {
- Text(item.translation)
- .fontSize(this.getAnimatedLyricFontSize(index))
- .fontColor(this.currentMediaPosition >= item.beginTime ?
- this.getNormalLyricTextColor(index) : this.textColor)
- .fontWeight(this.getNormalLyricFontWeight(index))
- .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
- .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
- .visibility(this.isSingleLine?
- (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
- : Visibility.Visible)
- .textShadow(this.isLightText ? {
- radius: 20,
- color: Color.White,
- offsetX: 0,
- offsetY: 0
- } : undefined)
- .blendMode(
- this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.DST_IN : undefined,
- this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
- )
- .width(this.getNormalLyricLineWidth(index, '80%'))
- }
- }
- // 在 Row 上应用渐变
- .linearGradient(this.shouldApplyNormalLyricKaraoke(index) ? {
- direction: GradientDirection.Right,
- colors: this.getLyricItemLinearGradient(item, index)
- } : undefined)
- .blendMode(
- this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.SRC_OVER : undefined,
- this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
- )
- }
- private isCurrentNormalHighlightLine(index: number): boolean {
- return index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)
- }
- private shouldApplyNormalLyricKaraoke(index: number): boolean {
- return shouldApplyNormalLyricKaraokeEffect(
- this.enableWordByWordLyric,
- index == this.currentIndex,
- this.currentLyric ? this.currentLyric.isPlainText : false
- )
- }
- private getNormalLyricTextColor(index: number): ResourceColor {
- return this.isCurrentNormalHighlightLine(index) ? this.textHighlightColor : this.textColor
- }
- private getNormalLyricFontWeight(index: number): number | FontWeight | string {
- return this.isCurrentNormalHighlightLine(index) && this.isHighlightBold ? FontWeight.Bold : this.textWeight
- }
- private getNormalLyricLineWidth(index: number, normalWidth: string): string {
- if (this.alignMode == 'center') {
- return '100%'
- }
- return this.isCurrentNormalHighlightLine(index) ? '95%' : normalWidth
- }
- /**
- * 计算卡拉OK渐变 - 同色系从浅到深的平滑过渡
- */
- private clamp01(value: number): number {
- return Math.max(0, Math.min(1, value))
- }
- private smoothStep01(value: number): number {
- const x = this.clamp01(value)
- return x * x * (3 - 2 * x)
- }
- private createKaraokeGradient(progress: number, transitionWidth: number = this.karaokeTransitionWidth): [ResourceColor, number][] {
- const p = this.clamp01(progress)
- if (p <= 0) {
- return [[this.textColor, 0.0], [this.textColor, 1.0]]
- }
- if (p >= 1) {
- return [[this.textHighlightColor, 0.0], [this.textHighlightColor, 1.0]]
- }
- const width = Math.max(0.02, Math.min(0.3, transitionWidth))
- const half = width / 2
- const transitionStart = this.clamp01(p - half)
- const transitionEnd = this.clamp01(p + half)
- return [[this.textHighlightColor, 0.0],
- [this.textHighlightColor, transitionStart],
- [this.textColor, transitionEnd],
- [this.textColor, 1.0]]
- }
- private getAdaptiveWordTransitionWidth(wordDuration: number): number {
- const safeDuration = Math.max(wordDuration, 80)
- const shortWordBoost = this.clamp01((260 - safeDuration) / 260)
- return Math.max(0.08, Math.min(0.24, this.karaokeTransitionWidth + shortWordBoost * 0.08))
- }
- getLyricItemLinearGradient(item: LyricLine, index: number): [ResourceColor, number][] {
- // 只对当前播放行且包含逐字数据的行应用卡拉OK效果
- if (index !== this.currentIndex || item.words.length === 0) {
- //console.info('heanup', `getLyricItemLinearGradient - 非高亮行或无逐字数据: index=${index}, currentIndex=${this.currentIndex}, hasWords=${item.hasWords()}, wordsCount=${item.words.length}`)
- return [[Color.White, 0.0], [Color.White, 1.0]]
- }
- // 计算该行歌词的总时长
- let lyricDuration: number
- if (index < this.listAdapter.totalCount() - 1) {
- const nextLine = this.listAdapter.getData(index + 1)
- lyricDuration = nextLine.beginTime - item.beginTime
- } else {
- // 最后一行,使用 nextTime(如果有)或者估计时长
- lyricDuration = item.nextTime > item.beginTime ? item.nextTime - item.beginTime : 5000
- }
- //console.info('heanup', `getLyricItemLinearGradient - index=${index}, lyricDuration=${lyricDuration}, currentMediaPosition=${this.currentMediaPosition}, itemBeginTime=${item.beginTime}`)
- if (lyricDuration <= 0) {
- return [[Color.Transparent, 0.0], [Color.Transparent, 1.0]]
- }
- // 计算当前播放进度(0-1之间)
- let diff = this.currentMediaPosition - item.beginTime
- let value = diff / lyricDuration
- value = this.smoothStep01(value)
- return this.createKaraokeGradient(value, 0.1)
- }
- /**
- * 计算逐字歌词的卡拉OK渐变效果
- * 该方法针对逐字歌词格式,根据当前播放进度和每个字的时间信息计算渐变
- * @param item 当前歌词行
- * @param word 当前字的信息
- * @param index 当前行索引
- * @returns 渐变颜色数组
- */
- getWordByWordLyricLyricItemLinearGradient(item: LyricLine, word: LyricWord, index: number): [ResourceColor, number][] {
- // 非高亮行或无效数据,返回透明
- if (index !== this.currentIndex || !word || !word.word) {
- //console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - 非高亮行或无效word: index=${index}, currentIndex=${this.currentIndex}`)
- return [[Color.White, 0.0], [Color.White, 1.0]]
- }
- const rawDuration = Math.max(word.duration, 0)
- const effectiveDuration = rawDuration > 0 ? rawDuration : 180
- const preRoll = Math.min(90, Math.max(20, effectiveDuration * 0.16))
- const postRoll = Math.min(110, Math.max(26, effectiveDuration * 0.24))
- const smoothStart = word.startTime - preRoll
- const smoothEnd = word.startTime + effectiveDuration + postRoll
- const width = this.getAdaptiveWordTransitionWidth(effectiveDuration)
- if (this.currentMediaPosition <= smoothStart) {
- return this.createKaraokeGradient(0, width)
- }
- if (this.currentMediaPosition >= smoothEnd) {
- return this.createKaraokeGradient(1, width)
- }
- const linearProgress = this.clamp01((this.currentMediaPosition - smoothStart) / (smoothEnd - smoothStart))
- const easedProgress = this.smoothStep01(linearProgress)
- const blendedProgress = this.clamp01(easedProgress * 0.88 + linearProgress * 0.12)
- return this.createKaraokeGradient(blendedProgress, width)
- }
- private getWordPlaybackState(word: LyricWord): number {
- const duration = Math.max(word.duration, 0)
- const effectiveDuration = duration > 0 ? duration : 180
- const endTime = word.startTime + effectiveDuration
- const tailWindow = Math.min(90, Math.max(24, effectiveDuration * 0.2))
- if (this.currentMediaPosition <= word.startTime) {
- return 0
- }
- if (this.currentMediaPosition >= endTime + tailWindow) {
- return 2
- }
- return 1
- }
- @Builder
- WordByWordLyric(item: LyricLine, index: number) {
- Column() {
- Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start }) {
- ForEach(item.words, (word: LyricWord, wordIndex: number) => {
- Text(word.word)
- .fontSize(this.getAnimatedLyricFontSize(index))
- .fontColor(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) &&
- this.getWordPlaybackState(word) == 2 ? this.textHighlightColor : this.textColor)
- .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
- FontWeight.Bold : this.textWeight)
- .margin(isEnglish(word.word) ?{ right:4 }:{})
- .visibility(this.isSingleLine?
- (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
- : Visibility.Visible)
- .padding(this.isSingleLine?0:{ top:5,bottom:5 })
- .textShadow(this.isLightText?{
- radius: 25,
- color: Color.White,
- offsetX: 0,
- offsetY: 0
- }:undefined)
- .shaderStyle(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) &&
- this.getWordPlaybackState(word) == 1 ?{
- direction: GradientDirection.Right,
- colors: this.getWordByWordLyricLyricItemLinearGradient(item, word, index)
- }:undefined)
- })
- }
- .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
- // 中文翻译(整行显示)
- if (item.translation) {
- Row({ space: 0 }) {
- Text(item.translation)
- .fontSize(this.getAnimatedLyricFontSize(index))
- .fontColor(this.currentMediaPosition >= item.beginTime ?
- index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
- .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)? FontWeight.Bold : this.textWeight)
- .padding({ bottom:5 })
- .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
- .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
- .width('100%')
- .textShadow(this.isLightText ? {
- radius: 25,
- color: Color.White,
- offsetX: 0,
- offsetY: 0
- } : undefined)
- .visibility(this.isSingleLine?
- (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
- : Visibility.Visible)
- }
- .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
- .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
- }
- }
- .height('auto')
- }
- //修复get Property index out of bounds
- private handleSeekAction() {
- clearTimeout(this.seekUiHideTimeout);
- let itemCount = this.listAdapter.totalCount(); // 获取数据项的总数
- // 确保 seekIndex 在有效范围内
- if (this.seekIndex >= 0 && this.seekIndex < itemCount) {
- let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime;
- let isPlayerHandled = this.onSeekAction(targetPosition);
- if (!isPlayerHandled) {
- this.animateToIndex(this.currentIndex);
- }
- } else {
- console.error("Seek index out of bounds:", this.seekIndex);
- // 处理超出范围的情况,比如设定默认值或抛出错误
- }
- this.isUserTouching = false;
- }
- getTransverterText(message: string):string{
- if(this.transverterType==1){
- return transverter({
- type: TransverterType.TRADITIONAL,
- str: message,
- language: TransverterLanguage.ZH_TW
- });
- }else if(this.transverterType==2){
- return transverter({
- type: TransverterType.SIMPLIFIED,
- str: message,
- language: TransverterLanguage.ZH_CN
- });
- }else{
- return message;
- }
- }
- @Builder
- JumpProgress(){
- Row(){
- Row(){
- Row({space: 10}){
- Text(this.scrollDurationText)
- .fontSize(12)
- .fontWeight(FontWeight.Medium)
- .fontColor(Color.White)
- SymbolGlyph($r('sys.symbol.play'))
- .fontSize(13)
- .fontColor([Color.White])
- .alignSelf(ItemAlign.Center)
- }
- .borderRadius(10)
- .alignItems(VerticalAlign.Center)
- .height(32)
- .padding(10)
- .backgroundColor(Color.Transparent)
- .backgroundBlurStyle(BlurStyle.BACKGROUND_REGULAR,
- { colorMode: ThemeColorMode.LIGHT, adaptiveColor: AdaptiveColor.DEFAULT, scale: 1.0 })
- }
- .transition(
- TransitionEffect
- .scale({ x: 0.7, y: 0.7 })
- .combine(TransitionEffect
- .opacity(0.1)
- )
- .animation({
- duration: 150,
- curve: Curve.EaseInOut,
- })
- )
- }
- .hitTestBehavior(HitTestMode.Transparent)
- .width(110)
- .justifyContent(FlexAlign.End)
- .backgroundColor(Color.Transparent)
- }
- build() {
- Stack() {
- if (this.isLyricEmpty) {
- this.EmptyView()
- } else {
- this.LyricListView()
- }
- }
- .width('100%')
- .height('100%')
- .onAreaChange((_, newSize) => {
- const nextHeight = Number(newSize.height) || 0
- const nextWidth = Number(newSize.width) || 0
- const isFirstMeasure = this.h <= 0 || this.w <= 0
- const isSizeChanged = Math.abs(nextHeight - this.h) >= this.areaReloadThresholdPx ||
- Math.abs(nextWidth - this.w) >= this.areaReloadThresholdPx
- if (!isFirstMeasure && !isSizeChanged) {
- return
- }
- this.h = nextHeight
- this.w = nextWidth
- if (this.currentLyric) {
- if (this.listAdapter.isEmpty()) {
- this.loadData(this.currentLyric)
- } else {
- this.scrollToIndexImmediately(this.currentIndex)
- }
- }
- })
- }
- aboutToDisappear() {
- clearTimeout(this.loadTimeout)
- clearTimeout(this.seekUiHideTimeout)
- clearTimeout(this.highlightFontStartTimeout)
- }
- private getIndex(position: number): number {
- let size = this.listAdapter.totalCount()
- if (size === 0) return 0 // 空列表保护
- // 如果是纯文本歌词,始终返回 0(不滚动)
- if (this.currentLyric && this.currentLyric.isPlainText) {
- return 0
- }
- const first = this.listAdapter.getData(0).beginTime
- if (position < first) {
- return 0
- }
- const lastIndex = size - 1
- const last = this.listAdapter.getData(lastIndex).beginTime
- if (position >= last) {
- return lastIndex
- }
- let left = 0
- let right = lastIndex
- while (left <= right) {
- const mid = (left + right) >> 1
- const beginTime = this.listAdapter.getData(mid).beginTime
- if (beginTime <= position) {
- left = mid + 1
- } else {
- right = mid - 1
- }
- }
- return Math.max(0, Math.min(lastIndex, right))
- }
- private animateToIndex(index: number) {
- // printD('animate to index= ' + index)
- const count = this.listAdapter.totalCount()
- if (count <= 0) {
- this.currentIndex = 0
- return
- }
- const safeIndex = Math.max(0, Math.min(count - 1, index))
- const previousIndex = this.currentIndex
- this.currentIndex = safeIndex
- if (this.isUserTouching) {
- return
- }
- this.playHighlightFontAnimation(previousIndex, safeIndex)
- if(this.isHightLightCenter){
- this.scroller.scrollToIndex(safeIndex, true, ScrollAlign.CENTER)
- }else{
- // 计算目标索引,使其在居中位置上方有两条歌词
- const targetIndex = Math.max(0, safeIndex - 2);
- this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START);
- }
- }
- private shouldApplyHighlightFontAnimation(index: number): boolean {
- return index === this.currentIndex
- && index === this.highlightFontActiveIndex
- && !(this.currentLyric && this.currentLyric.isPlainText)
- && !this.isUserTouching
- }
- private getAnimatedLyricFontSize(index: number): number {
- if (!(index === this.currentIndex) || (this.currentLyric && this.currentLyric.isPlainText)) {
- return this.textSize
- }
- if (!this.shouldApplyHighlightFontAnimation(index)) {
- return this.textHighlightSize
- }
- const progress = Math.max(0, Math.min(1, this.highlightFontProgress))
- return this.textSize + (this.textHighlightSize - this.textSize) * progress
- }
- private playHighlightFontAnimation(previousIndex: number, nextIndex: number): void {
- if (previousIndex === nextIndex) {
- return
- }
- if (this.currentLyric && this.currentLyric.isPlainText) {
- return
- }
- clearTimeout(this.highlightFontStartTimeout)
- this.highlightFontToken += 1
- const token = this.highlightFontToken
- this.highlightFontActiveIndex = nextIndex
- this.highlightFontProgress = 0
- this.highlightFontStartTimeout = setTimeout(() => {
- if (token !== this.highlightFontToken) {
- return
- }
- animateTo({
- duration: this.highlightFontDurationMs,
- curve: Curve.EaseOut
- }, () => {
- if (token !== this.highlightFontToken) {
- return
- }
- this.highlightFontProgress = 1
- this.highlightFontActiveIndex = -1
- })
- }, this.highlightFontStartDelayMs)
- }
- private onPositionChanged(mediaPosition: number) {
- if (this.isLyricEmpty) {
- // printW('The lyric data is empty!')
- return
- }
- if (this.listAdapter.isEmpty()) {
- // printW('The lyric lines is empty!')
- return
- }
- // 如果是纯文本歌词,不进行滚动同步
- if (this.currentLyric && this.currentLyric.isPlainText) {
- return
- }
- if (this.currentIndex >= 0 && this.currentIndex < this.listAdapter.totalCount()) {
- const currentLine = this.listAdapter.getData(this.currentIndex)
- if (mediaPosition >= currentLine.beginTime && mediaPosition < currentLine.nextTime) {
- return
- }
- }
- let index = this.getIndex(mediaPosition)
- if (index != this.currentIndex) {
- this.animateToIndex(index)
- }
- }
- }
- function isEnglish(text: string, mode: string = 'strict', threshold: number = 0.9): boolean {
- if (!text || typeof text !== 'string') return false;
- switch (mode) {
- case 'basic':
- return /^[a-zA-Z\s.,!?'"-]+$/.test(text);
- case 'percentage':
- return checkByPercentage(text, threshold);
- default: // strict
- return /^[\u0000-\u007F]+$/.test(text.trim());
- }
- }
- function checkByPercentage(text: string, threshold: number): boolean {
- const validChars = text.match(/[a-zA-Z\s.,!?'"-]/g) || [];
- return (validChars.length / text.length) >= threshold;
- }
|