LyricView2.ets 36 KB

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