LyricView2.ets 36 KB

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