LyricView2.ets 36 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899
  1. import { duration2text } from '../extensions/Extension';
  2. import { LyricController } from '../LyricController';
  3. import { Lyric } from '../bean/Lyric';
  4. import { ListAdapter } from '../extensions/ListAdapter';
  5. import { LyricLine } from '../bean/LyricLine';
  6. import { LyricWord } from '../bean/LyricWord';
  7. import { transverter, TransverterType, TransverterLanguage } from "@nutpi/chinese_transverter"
  8. import { LengthMetrics } from '@kit.ArkUI';
  9. import { shouldApplyNormalLyricKaraokeEffect } from './LyricViewRenderHelper';
  10. /**
  11. * A component to display the lyric with scroll animation.
  12. * This component only support api 10+.
  13. *
  14. * The custom setter include the lyric text style, fade style of edge, the line space, scroll animation duration,
  15. * the cache size to draw out of screen.
  16. */
  17. @Component
  18. export struct LyricView2 {
  19. /**
  20. * The lyricConfig for LyricView.
  21. */
  22. controller: LyricController = new LyricController()
  23. /**
  24. * Enable the lyric scroll to do seek action or not.
  25. * If false, the onSeekAction callback will not invoke anymore.
  26. */
  27. enableSeek: boolean = true
  28. /**
  29. * Enable word-by-word lyric render or not.
  30. * If false, lines with word timing will fallback to normal line render.
  31. */
  32. @State enableWordByWordLyric: boolean = true
  33. @State isLightText: boolean = true
  34. /**
  35. * The color of seek button and duration text.
  36. */
  37. seekUIColor: ResourceColor = '#000000'
  38. /**
  39. * The color of the seek location line.
  40. */
  41. seekLineColor: ResourceColor = '#0d000000'
  42. /**
  43. * The seek ui style.
  44. */
  45. seekUIStyle: 'seekLine' | 'listItem' = 'listItem'
  46. /**
  47. * The seek callback for scroll the lyric.
  48. * If return true, you must handle this action to do seek action of media player.
  49. * If return false, the lyric view will scroll to current index of the media playing position.
  50. */
  51. onSeekAction: (position: number) => boolean = () => false
  52. private currentLyric: Lyric | null = null
  53. private scroller = new Scroller()
  54. private listAdapter = new ListAdapter<LyricLine>()
  55. private w = 0 // the width of this view.
  56. private h = 0 // the height of this view.
  57. @State currentIndex: number = 0 // the focused index of the lyric line list.
  58. @State isLyricEmpty: boolean = true // is no lyric.
  59. @State textSize: number = 16
  60. @State isSingleLine: boolean = false
  61. @State lineSpace: number = 16
  62. @State textWeight: number = FontWeight.Medium
  63. @State textHighlightSize: number = 18
  64. @State textColor: string = '#000000'
  65. @State textHighlightColor: string = '#000000'
  66. @State isHighlightBold: boolean = false
  67. @State alignMode: 'left' | 'center' = 'center'
  68. @State emptyHint: string = ''
  69. @State cacheSize: number = 3
  70. @State animDuration: number = 300
  71. @State isUserTouching: boolean = false
  72. @State seekPosition: number = -1
  73. @State scrollDurationText: string = '00:00'
  74. @State seekIndex: number = -1
  75. private seekUiHideTimeout = -1
  76. private autoHideSeekUIDuration = 2000
  77. @State isLoadingData: boolean = false
  78. private loadTimeout = -1
  79. @State isHightLightCenter: boolean = true
  80. @State currentMediaPosition: number = 0
  81. @State transverterType: number = 0
  82. private lastPositionUpdateTs: number = 0
  83. private lastPositionForRender: number = -1
  84. private readonly positionUpdateThrottleMs: number = 33
  85. private readonly minPositionDeltaMs: number = 8
  86. private readonly karaokeTransitionWidth: number = 0.12
  87. @State highlightFontProgress: number = 1
  88. @State highlightFontActiveIndex: number = -1
  89. private highlightFontToken: number = 0
  90. private highlightFontStartTimeout = -1
  91. private readonly highlightFontStartDelayMs: number = 100
  92. private readonly highlightFontDurationMs: number = 180
  93. private readonly areaReloadThresholdPx: number = 1
  94. private onDataChangedListener = (lyric: Lyric | null) => {
  95. clearTimeout(this.loadTimeout)
  96. this.isLoadingData = true
  97. this.loadData(lyric)
  98. this.loadTimeout = setTimeout(() => {
  99. this.isLoadingData = false
  100. }, 300)
  101. }
  102. private onPositionChangedListener = (mediaPosition: number) => {
  103. // 如果是纯文本歌词,不进行位置同步
  104. if (this.currentLyric && this.currentLyric.isPlainText) {
  105. this.currentMediaPosition = mediaPosition
  106. return
  107. }
  108. const now = Date.now()
  109. if (now - this.lastPositionUpdateTs < this.positionUpdateThrottleMs &&
  110. Math.abs(mediaPosition - this.lastPositionForRender) < this.minPositionDeltaMs) {
  111. return
  112. }
  113. this.lastPositionUpdateTs = now
  114. this.lastPositionForRender = mediaPosition
  115. this.currentMediaPosition = mediaPosition
  116. this.onPositionChanged(mediaPosition)
  117. }
  118. private onInvalidatedListener = (reLayout: boolean) => {
  119. this.getAttrFromController()
  120. if (reLayout && this.currentIndex > 0) {
  121. this.animateToIndex(this.currentIndex)
  122. }
  123. }
  124. private loadData(lyric: Lyric | null) {
  125. this.currentLyric = lyric;
  126. clearTimeout(this.highlightFontStartTimeout)
  127. this.highlightFontToken += 1
  128. this.highlightFontActiveIndex = -1
  129. this.highlightFontProgress = 1
  130. if (this.w > 0 && this.h > 0) {
  131. if (this.currentLyric) {
  132. this.listAdapter.clear(false);
  133. let lyricLines = this.currentLyric.lyricList;
  134. if (lyricLines!==undefined&&lyricLines.length > 0) {
  135. let first = lyricLines[0].beginTime;
  136. // fill the top empty gap 注释掉修复 歌曲不播放的时候,可以显示完整的歌词,而不是显示一两行
  137. // for (let i = 0; i < this.centerOffsetSize; i++) {
  138. // this.listAdapter.addData(new LyricLine(' ', 0, first), false);
  139. // }
  140. lyricLines.forEach((line) => {
  141. this.listAdapter.addData(line, false);
  142. });
  143. // fill the bottom empty gap
  144. // let last = lyricLines[lyricLines.length - 1].nextTime;
  145. // for (let i = 0; i < this.centerOffsetSize; i++) {
  146. // this.listAdapter.addData(new LyricLine(' ', last + i, last + i), false);
  147. // }
  148. }
  149. this.listAdapter.notifyDataReload();
  150. } else {
  151. this.listAdapter.clear(true);
  152. }
  153. }
  154. this.isLyricEmpty = this.listAdapter.isEmpty();
  155. this.lastPositionUpdateTs = 0
  156. this.lastPositionForRender = -1
  157. if (this.isLyricEmpty) {
  158. this.currentIndex = 0
  159. this.scrollToIndexImmediately(0)
  160. return
  161. }
  162. const initialIndex = this.resolveInitialIndex()
  163. this.currentIndex = initialIndex
  164. this.scrollToIndexImmediately(initialIndex)
  165. }
  166. private resolveInitialIndex(): number {
  167. const count = this.listAdapter.totalCount()
  168. if (count <= 0) {
  169. return 0
  170. }
  171. if (this.currentLyric && this.currentLyric.isPlainText) {
  172. return 0
  173. }
  174. const firstLine = this.listAdapter.getData(0)
  175. const lastLine = this.listAdapter.getData(count - 1)
  176. const lastEndTime = lastLine.nextTime > lastLine.beginTime ? lastLine.nextTime : lastLine.beginTime
  177. if (this.currentMediaPosition <= firstLine.beginTime - 2000 || this.currentMediaPosition > lastEndTime + 2000) {
  178. return 0
  179. }
  180. return this.getIndex(this.currentMediaPosition)
  181. }
  182. private scrollToIndexImmediately(index: number): void {
  183. const count = this.listAdapter.totalCount()
  184. if (count <= 0) {
  185. return
  186. }
  187. const safeIndex = Math.max(0, Math.min(count - 1, index))
  188. if(this.isHightLightCenter){
  189. this.scroller.scrollToIndex(safeIndex, false, ScrollAlign.CENTER)
  190. }else{
  191. const targetIndex = Math.max(0, safeIndex - 2)
  192. this.scroller.scrollToIndex(targetIndex, false, ScrollAlign.START)
  193. }
  194. }
  195. private getAttrFromController() {
  196. this.currentLyric = this.controller.getLyric()
  197. this.textSize = this.controller.getTextSize()
  198. this.transverterType = this.controller.getTransverterType()
  199. this.isSingleLine = this.controller.getSingleLine()
  200. this.blurDegree = this.controller.getBlurDegree()
  201. this.isHightLightCenter = this.controller.getHightLightCenter()
  202. this.lineSpace = this.controller.getLineSpace()
  203. this.textColor = this.controller.getTextColor()
  204. this.textHighlightColor = this.controller.getHighlightColor()
  205. this.textHighlightSize = this.controller.getHighlightScale() * this.textSize
  206. this.isHighlightBold = this.controller.isHighlightBold()
  207. this.animDuration = this.controller.getAnimationDuration()
  208. this.cacheSize = this.controller.getCacheSize()
  209. this.emptyHint = this.controller.getEmptyHint()
  210. this.alignMode = this.controller.getAlignMode()
  211. this.textWeight = this.controller.getTextWeight()
  212. this.enableWordByWordLyric = this.controller.getEnableWordByWordLyric()
  213. this.isLightText = this.controller.getLightText()
  214. }
  215. aboutToAppear() {
  216. if (this.controller == null) {
  217. throw new Error('The lyric lyricConfig is not set!')
  218. }
  219. this.controller.onDataChangedListener = this.onDataChangedListener
  220. this.controller.onPositionChangedListener = this.onPositionChangedListener
  221. this.controller.onInvalidated = this.onInvalidatedListener
  222. this.getAttrFromController()
  223. // 初始化时将滚动位置设置为顶部
  224. this.scroller.scrollToIndex(0, true, ScrollAlign.START);
  225. }
  226. @Builder
  227. EmptyView() {
  228. Text(this.emptyHint)
  229. .fontSize(this.textSize)
  230. .fontColor(this.textColor)
  231. }
  232. @State blurDegree: number = 3
  233. // 优化建议代码示例:增加滚动节流
  234. private lastScrollTime: number = 0
  235. private scrollThrottle: number = 100 // 100ms节流
  236. @Builder
  237. LyricListView() {
  238. List({ space: this.lineSpace - 16, scroller: this.scroller }) {
  239. LazyForEach(this.listAdapter, (item: LyricLine, index: number) => {
  240. ListItem() {
  241. Stack() {
  242. // 逐字歌词渲染
  243. if (item.hasWords() && item.words.length > 0) {
  244. this.WordByWordLyric(item, index)
  245. } else {
  246. // 普通歌词渲染(原有逻辑,这个里面也有逐字歌词的功能,如果用户开启逐字的话)
  247. this.NormalLyricLine(item, index)
  248. }
  249. if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  250. && this.enableSeek && this.isUserTouching){
  251. this.JumpProgress()
  252. }
  253. }
  254. .align(Alignment.End)
  255. }
  256. .padding(8)
  257. .border({ radius: 12 })
  258. .onClick(() => {
  259. if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0) {
  260. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  261. this.handleSeekAction();
  262. }
  263. }
  264. })
  265. },
  266. (item: LyricLine, index: number) => {
  267. return item.text + '_' + index + '_' + item.beginTime + '_' + item.nextTime
  268. })
  269. }
  270. .width('100%')
  271. .height('100%')
  272. .layoutWeight(1)
  273. .scrollBar(BarState.Off)
  274. .fadingEdge(true,{fadingEdgeLength:LengthMetrics.percent(20)})
  275. .edgeEffect(EdgeEffect.Spring)
  276. .contentEndOffset(this.h / 3)
  277. .contentStartOffset(this.isUserTouching?this.h / 3:0)
  278. .cachedCount(this.cacheSize)
  279. // .chainAnimation(true)
  280. // .animation({
  281. // curve: curves.springCurve(100, 10, 80, 10),
  282. // duration: 500
  283. // })
  284. .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible)
  285. .onScrollIndex((_, __, center) => {
  286. const now = Date.now()
  287. if (now - this.lastScrollTime < this.scrollThrottle) return
  288. this.lastScrollTime = now
  289. // 纯文本歌词不支持 seek 操作
  290. if (this.isUserTouching && !(this.currentLyric && this.currentLyric.isPlainText)) {
  291. //修复The value of "index" is out of range. It must be >= 0 && <= -1. Received value is: -1
  292. if (center >= 0 && center < this.listAdapter.totalCount()) {
  293. this.seekIndex = center;
  294. let targetPosition = this.listAdapter.getData(center).beginTime;
  295. this.scrollDurationText = duration2text(targetPosition);
  296. }
  297. // this.seekIndex = center
  298. // let targetPosition = this.listAdapter.getData(center).beginTime
  299. // this.scrollDurationText = duration2text(targetPosition)
  300. }
  301. })
  302. .onTouch((event) => {
  303. let motion = event.touches[0]
  304. switch (motion.type) {
  305. case TouchType.Down:
  306. clearTimeout(this.seekUiHideTimeout)
  307. break
  308. case TouchType.Move:
  309. this.isUserTouching = true
  310. break
  311. case TouchType.Up:
  312. case TouchType.Cancel:
  313. // 纯文本歌词不需要自动滚动回顶部
  314. if (this.currentLyric && this.currentLyric.isPlainText) {
  315. this.seekUiHideTimeout = setTimeout(() => {
  316. this.seekIndex = -1
  317. this.isUserTouching = false
  318. // 纯文本歌词不调用 animateToIndex,保持在当前位置
  319. }, this.autoHideSeekUIDuration)
  320. } else {
  321. this.seekUiHideTimeout = setTimeout(() => {
  322. this.seekIndex = -1
  323. this.isUserTouching = false
  324. this.animateToIndex(this.currentIndex)
  325. }, this.autoHideSeekUIDuration)
  326. }
  327. }
  328. })
  329. }
  330. // 普通歌词渲染(原有逻辑)
  331. @Builder
  332. NormalLyricLine(item: LyricLine, index: number) {
  333. Column(){
  334. Text(item.text)
  335. .fontSize(this.getAnimatedLyricFontSize(index))
  336. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  337. .fontColor(this.getNormalLyricTextColor(index))
  338. .fontWeight(this.getNormalLyricFontWeight(index))
  339. .padding(this.isSingleLine?0:{ top:5,bottom:5 })
  340. .visibility(this.isSingleLine?
  341. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  342. : Visibility.Visible)
  343. .width(this.getNormalLyricLineWidth(index, '80%'))
  344. .textShadow(this.isLightText ? {
  345. radius: 20,
  346. color: Color.White,
  347. offsetX: 0,
  348. offsetY: 0
  349. } : undefined)
  350. .blendMode(
  351. this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.DST_IN : undefined,
  352. this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
  353. )
  354. // 中文翻译(整行显示)
  355. if (item.translation) {
  356. Text(item.translation)
  357. .fontSize(this.getAnimatedLyricFontSize(index))
  358. .fontColor(this.currentMediaPosition >= item.beginTime ?
  359. this.getNormalLyricTextColor(index) : this.textColor)
  360. .fontWeight(this.getNormalLyricFontWeight(index))
  361. .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
  362. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  363. .visibility(this.isSingleLine?
  364. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  365. : Visibility.Visible)
  366. .textShadow(this.isLightText ? {
  367. radius: 20,
  368. color: Color.White,
  369. offsetX: 0,
  370. offsetY: 0
  371. } : undefined)
  372. .blendMode(
  373. this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.DST_IN : undefined,
  374. this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
  375. )
  376. .width(this.getNormalLyricLineWidth(index, '80%'))
  377. }
  378. }
  379. // 在 Row 上应用渐变
  380. .linearGradient(this.shouldApplyNormalLyricKaraoke(index) ? {
  381. direction: GradientDirection.Right,
  382. colors: this.getLyricItemLinearGradient(item, index)
  383. } : undefined)
  384. .blendMode(
  385. this.shouldApplyNormalLyricKaraoke(index) ? BlendMode.SRC_OVER : undefined,
  386. this.shouldApplyNormalLyricKaraoke(index) ? BlendApplyType.OFFSCREEN : undefined
  387. )
  388. }
  389. private isCurrentNormalHighlightLine(index: number): boolean {
  390. return index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)
  391. }
  392. private shouldApplyNormalLyricKaraoke(index: number): boolean {
  393. return shouldApplyNormalLyricKaraokeEffect(
  394. this.enableWordByWordLyric,
  395. index == this.currentIndex,
  396. this.currentLyric ? this.currentLyric.isPlainText : false
  397. )
  398. }
  399. private getNormalLyricTextColor(index: number): ResourceColor {
  400. return this.isCurrentNormalHighlightLine(index) ? this.textHighlightColor : this.textColor
  401. }
  402. private getNormalLyricFontWeight(index: number): number | FontWeight | string {
  403. return this.isCurrentNormalHighlightLine(index) && this.isHighlightBold ? FontWeight.Bold : this.textWeight
  404. }
  405. private getNormalLyricLineWidth(index: number, normalWidth: string): string {
  406. if (this.alignMode == 'center') {
  407. return '100%'
  408. }
  409. return this.isCurrentNormalHighlightLine(index) ? '95%' : normalWidth
  410. }
  411. /**
  412. * 计算卡拉OK渐变 - 同色系从浅到深的平滑过渡
  413. */
  414. private clamp01(value: number): number {
  415. return Math.max(0, Math.min(1, value))
  416. }
  417. private smoothStep01(value: number): number {
  418. const x = this.clamp01(value)
  419. return x * x * (3 - 2 * x)
  420. }
  421. private createKaraokeGradient(progress: number, transitionWidth: number = this.karaokeTransitionWidth): [ResourceColor, number][] {
  422. const p = this.clamp01(progress)
  423. if (p <= 0) {
  424. return [[this.textColor, 0.0], [this.textColor, 1.0]]
  425. }
  426. if (p >= 1) {
  427. return [[this.textHighlightColor, 0.0], [this.textHighlightColor, 1.0]]
  428. }
  429. const width = Math.max(0.02, Math.min(0.3, transitionWidth))
  430. const half = width / 2
  431. const transitionStart = this.clamp01(p - half)
  432. const transitionEnd = this.clamp01(p + half)
  433. return [[this.textHighlightColor, 0.0],
  434. [this.textHighlightColor, transitionStart],
  435. [this.textColor, transitionEnd],
  436. [this.textColor, 1.0]]
  437. }
  438. private getAdaptiveWordTransitionWidth(wordDuration: number): number {
  439. const safeDuration = Math.max(wordDuration, 80)
  440. const shortWordBoost = this.clamp01((260 - safeDuration) / 260)
  441. return Math.max(0.08, Math.min(0.24, this.karaokeTransitionWidth + shortWordBoost * 0.08))
  442. }
  443. getLyricItemLinearGradient(item: LyricLine, index: number): [ResourceColor, number][] {
  444. // 只对当前播放行且包含逐字数据的行应用卡拉OK效果
  445. if (index !== this.currentIndex || item.words.length === 0) {
  446. //console.info('heanup', `getLyricItemLinearGradient - 非高亮行或无逐字数据: index=${index}, currentIndex=${this.currentIndex}, hasWords=${item.hasWords()}, wordsCount=${item.words.length}`)
  447. return [[Color.White, 0.0], [Color.White, 1.0]]
  448. }
  449. // 计算该行歌词的总时长
  450. let lyricDuration: number
  451. if (index < this.listAdapter.totalCount() - 1) {
  452. const nextLine = this.listAdapter.getData(index + 1)
  453. lyricDuration = nextLine.beginTime - item.beginTime
  454. } else {
  455. // 最后一行,使用 nextTime(如果有)或者估计时长
  456. lyricDuration = item.nextTime > item.beginTime ? item.nextTime - item.beginTime : 5000
  457. }
  458. //console.info('heanup', `getLyricItemLinearGradient - index=${index}, lyricDuration=${lyricDuration}, currentMediaPosition=${this.currentMediaPosition}, itemBeginTime=${item.beginTime}`)
  459. if (lyricDuration <= 0) {
  460. return [[Color.Transparent, 0.0], [Color.Transparent, 1.0]]
  461. }
  462. // 计算当前播放进度(0-1之间)
  463. let diff = this.currentMediaPosition - item.beginTime
  464. let value = diff / lyricDuration
  465. value = this.smoothStep01(value)
  466. return this.createKaraokeGradient(value, 0.1)
  467. }
  468. /**
  469. * 计算逐字歌词的卡拉OK渐变效果
  470. * 该方法针对逐字歌词格式,根据当前播放进度和每个字的时间信息计算渐变
  471. * @param item 当前歌词行
  472. * @param word 当前字的信息
  473. * @param index 当前行索引
  474. * @returns 渐变颜色数组
  475. */
  476. getWordByWordLyricLyricItemLinearGradient(item: LyricLine, word: LyricWord, index: number): [ResourceColor, number][] {
  477. // 非高亮行或无效数据,返回透明
  478. if (index !== this.currentIndex || !word || !word.word) {
  479. //console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - 非高亮行或无效word: index=${index}, currentIndex=${this.currentIndex}`)
  480. return [[Color.White, 0.0], [Color.White, 1.0]]
  481. }
  482. const rawDuration = Math.max(word.duration, 0)
  483. const effectiveDuration = rawDuration > 0 ? rawDuration : 180
  484. const preRoll = Math.min(90, Math.max(20, effectiveDuration * 0.16))
  485. const postRoll = Math.min(110, Math.max(26, effectiveDuration * 0.24))
  486. const smoothStart = word.startTime - preRoll
  487. const smoothEnd = word.startTime + effectiveDuration + postRoll
  488. const width = this.getAdaptiveWordTransitionWidth(effectiveDuration)
  489. if (this.currentMediaPosition <= smoothStart) {
  490. return this.createKaraokeGradient(0, width)
  491. }
  492. if (this.currentMediaPosition >= smoothEnd) {
  493. return this.createKaraokeGradient(1, width)
  494. }
  495. const linearProgress = this.clamp01((this.currentMediaPosition - smoothStart) / (smoothEnd - smoothStart))
  496. const easedProgress = this.smoothStep01(linearProgress)
  497. const blendedProgress = this.clamp01(easedProgress * 0.88 + linearProgress * 0.12)
  498. return this.createKaraokeGradient(blendedProgress, width)
  499. }
  500. private getWordPlaybackState(word: LyricWord): number {
  501. const duration = Math.max(word.duration, 0)
  502. const effectiveDuration = duration > 0 ? duration : 180
  503. const endTime = word.startTime + effectiveDuration
  504. const tailWindow = Math.min(90, Math.max(24, effectiveDuration * 0.2))
  505. if (this.currentMediaPosition <= word.startTime) {
  506. return 0
  507. }
  508. if (this.currentMediaPosition >= endTime + tailWindow) {
  509. return 2
  510. }
  511. return 1
  512. }
  513. @Builder
  514. WordByWordLyric(item: LyricLine, index: number) {
  515. Column() {
  516. Flex({ direction: FlexDirection.Row, wrap: FlexWrap.Wrap, justifyContent: this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start }) {
  517. ForEach(item.words, (word: LyricWord, wordIndex: number) => {
  518. Text(word.word)
  519. .fontSize(this.getAnimatedLyricFontSize(index))
  520. .fontColor(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) &&
  521. this.getWordPlaybackState(word) == 2 ? this.textHighlightColor : this.textColor)
  522. .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?
  523. FontWeight.Bold : this.textWeight)
  524. .margin(isEnglish(word.word) ?{ right:4 }:{})
  525. .visibility(this.isSingleLine?
  526. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  527. : Visibility.Visible)
  528. .padding(this.isSingleLine?0:{ top:5,bottom:5 })
  529. .textShadow(this.isLightText?{
  530. radius: 25,
  531. color: Color.White,
  532. offsetX: 0,
  533. offsetY: 0
  534. }:undefined)
  535. .shaderStyle(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) &&
  536. this.getWordPlaybackState(word) == 1 ?{
  537. direction: GradientDirection.Right,
  538. colors: this.getWordByWordLyricLyricItemLinearGradient(item, word, index)
  539. }:undefined)
  540. })
  541. }
  542. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
  543. // 中文翻译(整行显示)
  544. if (item.translation) {
  545. Row({ space: 0 }) {
  546. Text(item.translation)
  547. .fontSize(this.getAnimatedLyricFontSize(index))
  548. .fontColor(this.currentMediaPosition >= item.beginTime ?
  549. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
  550. .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)? FontWeight.Bold : this.textWeight)
  551. .padding({ bottom:5 })
  552. .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
  553. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  554. .width('100%')
  555. .textShadow(this.isLightText ? {
  556. radius: 25,
  557. color: Color.White,
  558. offsetX: 0,
  559. offsetY: 0
  560. } : undefined)
  561. .visibility(this.isSingleLine?
  562. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  563. : Visibility.Visible)
  564. }
  565. .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
  566. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
  567. }
  568. }
  569. .height('auto')
  570. }
  571. //修复get Property index out of bounds
  572. private handleSeekAction() {
  573. clearTimeout(this.seekUiHideTimeout);
  574. let itemCount = this.listAdapter.totalCount(); // 获取数据项的总数
  575. // 确保 seekIndex 在有效范围内
  576. if (this.seekIndex >= 0 && this.seekIndex < itemCount) {
  577. let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime;
  578. let isPlayerHandled = this.onSeekAction(targetPosition);
  579. if (!isPlayerHandled) {
  580. this.animateToIndex(this.currentIndex);
  581. }
  582. } else {
  583. console.error("Seek index out of bounds:", this.seekIndex);
  584. // 处理超出范围的情况,比如设定默认值或抛出错误
  585. }
  586. this.isUserTouching = false;
  587. }
  588. getTransverterText(message: string):string{
  589. if(this.transverterType==1){
  590. return transverter({
  591. type: TransverterType.TRADITIONAL,
  592. str: message,
  593. language: TransverterLanguage.ZH_TW
  594. });
  595. }else if(this.transverterType==2){
  596. return transverter({
  597. type: TransverterType.SIMPLIFIED,
  598. str: message,
  599. language: TransverterLanguage.ZH_CN
  600. });
  601. }else{
  602. return message;
  603. }
  604. }
  605. @Builder
  606. JumpProgress(){
  607. Row(){
  608. Row(){
  609. Row({space: 10}){
  610. Text(this.scrollDurationText)
  611. .fontSize(12)
  612. .fontWeight(FontWeight.Medium)
  613. .fontColor(Color.White)
  614. SymbolGlyph($r('sys.symbol.play'))
  615. .fontSize(13)
  616. .fontColor([Color.White])
  617. .alignSelf(ItemAlign.Center)
  618. }
  619. .borderRadius(10)
  620. .alignItems(VerticalAlign.Center)
  621. .height(32)
  622. .padding(10)
  623. .backgroundColor(Color.Transparent)
  624. .backgroundBlurStyle(BlurStyle.BACKGROUND_REGULAR,
  625. { colorMode: ThemeColorMode.LIGHT, adaptiveColor: AdaptiveColor.DEFAULT, scale: 1.0 })
  626. }
  627. .transition(
  628. TransitionEffect
  629. .scale({ x: 0.7, y: 0.7 })
  630. .combine(TransitionEffect
  631. .opacity(0.1)
  632. )
  633. .animation({
  634. duration: 150,
  635. curve: Curve.EaseInOut,
  636. })
  637. )
  638. }
  639. .hitTestBehavior(HitTestMode.Transparent)
  640. .width(110)
  641. .justifyContent(FlexAlign.End)
  642. .backgroundColor(Color.Transparent)
  643. }
  644. build() {
  645. Stack() {
  646. if (this.isLyricEmpty) {
  647. this.EmptyView()
  648. } else {
  649. this.LyricListView()
  650. }
  651. }
  652. .width('100%')
  653. .height('100%')
  654. .onAreaChange((_, newSize) => {
  655. const nextHeight = Number(newSize.height) || 0
  656. const nextWidth = Number(newSize.width) || 0
  657. const isFirstMeasure = this.h <= 0 || this.w <= 0
  658. const isSizeChanged = Math.abs(nextHeight - this.h) >= this.areaReloadThresholdPx ||
  659. Math.abs(nextWidth - this.w) >= this.areaReloadThresholdPx
  660. if (!isFirstMeasure && !isSizeChanged) {
  661. return
  662. }
  663. this.h = nextHeight
  664. this.w = nextWidth
  665. if (this.currentLyric) {
  666. if (this.listAdapter.isEmpty()) {
  667. this.loadData(this.currentLyric)
  668. } else {
  669. this.scrollToIndexImmediately(this.currentIndex)
  670. }
  671. }
  672. })
  673. }
  674. aboutToDisappear() {
  675. clearTimeout(this.loadTimeout)
  676. clearTimeout(this.seekUiHideTimeout)
  677. clearTimeout(this.highlightFontStartTimeout)
  678. }
  679. private getIndex(position: number): number {
  680. let size = this.listAdapter.totalCount()
  681. if (size === 0) return 0 // 空列表保护
  682. // 如果是纯文本歌词,始终返回 0(不滚动)
  683. if (this.currentLyric && this.currentLyric.isPlainText) {
  684. return 0
  685. }
  686. const first = this.listAdapter.getData(0).beginTime
  687. if (position < first) {
  688. return 0
  689. }
  690. const lastIndex = size - 1
  691. const last = this.listAdapter.getData(lastIndex).beginTime
  692. if (position >= last) {
  693. return lastIndex
  694. }
  695. let left = 0
  696. let right = lastIndex
  697. while (left <= right) {
  698. const mid = (left + right) >> 1
  699. const beginTime = this.listAdapter.getData(mid).beginTime
  700. if (beginTime <= position) {
  701. left = mid + 1
  702. } else {
  703. right = mid - 1
  704. }
  705. }
  706. return Math.max(0, Math.min(lastIndex, right))
  707. }
  708. private animateToIndex(index: number) {
  709. // printD('animate to index= ' + index)
  710. const count = this.listAdapter.totalCount()
  711. if (count <= 0) {
  712. this.currentIndex = 0
  713. return
  714. }
  715. const safeIndex = Math.max(0, Math.min(count - 1, index))
  716. const previousIndex = this.currentIndex
  717. this.currentIndex = safeIndex
  718. if (this.isUserTouching) {
  719. return
  720. }
  721. this.playHighlightFontAnimation(previousIndex, safeIndex)
  722. if(this.isHightLightCenter){
  723. this.scroller.scrollToIndex(safeIndex, true, ScrollAlign.CENTER)
  724. }else{
  725. // 计算目标索引,使其在居中位置上方有两条歌词
  726. const targetIndex = Math.max(0, safeIndex - 2);
  727. this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START);
  728. }
  729. }
  730. private shouldApplyHighlightFontAnimation(index: number): boolean {
  731. return index === this.currentIndex
  732. && index === this.highlightFontActiveIndex
  733. && !(this.currentLyric && this.currentLyric.isPlainText)
  734. && !this.isUserTouching
  735. }
  736. private getAnimatedLyricFontSize(index: number): number {
  737. if (!(index === this.currentIndex) || (this.currentLyric && this.currentLyric.isPlainText)) {
  738. return this.textSize
  739. }
  740. if (!this.shouldApplyHighlightFontAnimation(index)) {
  741. return this.textHighlightSize
  742. }
  743. const progress = Math.max(0, Math.min(1, this.highlightFontProgress))
  744. return this.textSize + (this.textHighlightSize - this.textSize) * progress
  745. }
  746. private playHighlightFontAnimation(previousIndex: number, nextIndex: number): void {
  747. if (previousIndex === nextIndex) {
  748. return
  749. }
  750. if (this.currentLyric && this.currentLyric.isPlainText) {
  751. return
  752. }
  753. clearTimeout(this.highlightFontStartTimeout)
  754. this.highlightFontToken += 1
  755. const token = this.highlightFontToken
  756. this.highlightFontActiveIndex = nextIndex
  757. this.highlightFontProgress = 0
  758. this.highlightFontStartTimeout = setTimeout(() => {
  759. if (token !== this.highlightFontToken) {
  760. return
  761. }
  762. animateTo({
  763. duration: this.highlightFontDurationMs,
  764. curve: Curve.EaseOut
  765. }, () => {
  766. if (token !== this.highlightFontToken) {
  767. return
  768. }
  769. this.highlightFontProgress = 1
  770. this.highlightFontActiveIndex = -1
  771. })
  772. }, this.highlightFontStartDelayMs)
  773. }
  774. private onPositionChanged(mediaPosition: number) {
  775. if (this.isLyricEmpty) {
  776. // printW('The lyric data is empty!')
  777. return
  778. }
  779. if (this.listAdapter.isEmpty()) {
  780. // printW('The lyric lines is empty!')
  781. return
  782. }
  783. // 如果是纯文本歌词,不进行滚动同步
  784. if (this.currentLyric && this.currentLyric.isPlainText) {
  785. return
  786. }
  787. if (this.currentIndex >= 0 && this.currentIndex < this.listAdapter.totalCount()) {
  788. const currentLine = this.listAdapter.getData(this.currentIndex)
  789. if (mediaPosition >= currentLine.beginTime && mediaPosition < currentLine.nextTime) {
  790. return
  791. }
  792. }
  793. let index = this.getIndex(mediaPosition)
  794. if (index != this.currentIndex) {
  795. this.animateToIndex(index)
  796. }
  797. }
  798. }
  799. function isEnglish(text: string, mode: string = 'strict', threshold: number = 0.9): boolean {
  800. if (!text || typeof text !== 'string') return false;
  801. switch (mode) {
  802. case 'basic':
  803. return /^[a-zA-Z\s.,!?'"-]+$/.test(text);
  804. case 'percentage':
  805. return checkByPercentage(text, threshold);
  806. default: // strict
  807. return /^[\u0000-\u007F]+$/.test(text.trim());
  808. }
  809. }
  810. function checkByPercentage(text: string, threshold: number): boolean {
  811. const validChars = text.match(/[a-zA-Z\s.,!?'"-]/g) || [];
  812. return (validChars.length / text.length) >= threshold;
  813. }