LyricView2.ets 30 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722
  1. import { duration2text, printD, printW } 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 { curves, 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 onDataChangedListener = (lyric: Lyric | null) => {
  76. clearTimeout(this.loadTimeout)
  77. this.isLoadingData = true
  78. this.loadData(lyric)
  79. this.loadTimeout = setTimeout(() => {
  80. this.isLoadingData = false
  81. }, 300)
  82. }
  83. private onPositionChangedListener = (mediaPosition: number) => {
  84. // 如果是纯文本歌词,不进行位置同步
  85. if (this.currentLyric && this.currentLyric.isPlainText) {
  86. this.currentMediaPosition = mediaPosition
  87. return
  88. }
  89. this.currentMediaPosition = mediaPosition
  90. this.onPositionChanged(mediaPosition)
  91. }
  92. private onInvalidatedListener = (reLayout: boolean) => {
  93. this.getAttrFromController()
  94. if (reLayout && this.currentIndex > 0) {
  95. this.animateToIndex(this.currentIndex)
  96. }
  97. }
  98. private loadData(lyric: Lyric | null) {
  99. this.currentLyric = lyric;
  100. if (this.w > 0 && this.h > 0) {
  101. if (this.currentLyric) {
  102. this.listAdapter.clear(false);
  103. let lyricLines = this.currentLyric.lyricList;
  104. if (lyricLines!==undefined&&lyricLines.length > 0) {
  105. let first = lyricLines[0].beginTime;
  106. // fill the top empty gap 注释掉修复 歌曲不播放的时候,可以显示完整的歌词,而不是显示一两行
  107. // for (let i = 0; i < this.centerOffsetSize; i++) {
  108. // this.listAdapter.addData(new LyricLine(' ', 0, first), false);
  109. // }
  110. lyricLines.forEach((line) => {
  111. this.listAdapter.addData(line, false);
  112. });
  113. // fill the bottom empty gap
  114. // let last = lyricLines[lyricLines.length - 1].nextTime;
  115. // for (let i = 0; i < this.centerOffsetSize; i++) {
  116. // this.listAdapter.addData(new LyricLine(' ', last + i, last + i), false);
  117. // }
  118. }
  119. this.listAdapter.notifyDataReload();
  120. } else {
  121. this.listAdapter.clear(true);
  122. }
  123. }
  124. this.isLyricEmpty = this.listAdapter.isEmpty();
  125. this.currentIndex = 0;
  126. this.animateToIndex(0);
  127. }
  128. private getAttrFromController() {
  129. this.currentLyric = this.controller.getLyric()
  130. this.textSize = this.controller.getTextSize()
  131. this.transverterType = this.controller.getTransverterType()
  132. this.isSingleLine = this.controller.getSingleLine()
  133. this.blurDegree = this.controller.getBlurDegree()
  134. this.isHightLightCenter = this.controller.getHightLightCenter()
  135. this.lineSpace = this.controller.getLineSpace()
  136. this.textColor = this.controller.getTextColor()
  137. this.textHighlightColor = this.controller.getHighlightColor()
  138. this.textHighlightSize = this.controller.getHighlightScale() * this.textSize
  139. this.isHighlightBold = this.controller.isHighlightBold()
  140. this.animDuration = this.controller.getAnimationDuration()
  141. this.cacheSize = this.controller.getCacheSize()
  142. this.emptyHint = this.controller.getEmptyHint()
  143. this.alignMode = this.controller.getAlignMode()
  144. this.textWeight = this.controller.getTextWeight()
  145. }
  146. aboutToAppear() {
  147. if (this.controller == null) {
  148. throw new Error('The lyric lyricConfig is not set!')
  149. }
  150. this.controller.onDataChangedListener = this.onDataChangedListener
  151. this.controller.onPositionChangedListener = this.onPositionChangedListener
  152. this.controller.onInvalidated = this.onInvalidatedListener
  153. this.getAttrFromController()
  154. // 初始化时将滚动位置设置为顶部
  155. this.scroller.scrollToIndex(0, true, ScrollAlign.START);
  156. }
  157. @Builder
  158. EmptyView() {
  159. Text(this.emptyHint)
  160. .fontSize(this.textSize)
  161. .fontColor(this.textColor)
  162. }
  163. @State blurDegree: number = 3
  164. // 优化建议代码示例:增加滚动节流
  165. private lastScrollTime: number = 0
  166. private scrollThrottle: number = 100 // 100ms节流
  167. @Builder
  168. LyricListView() {
  169. List({ space: this.lineSpace - 16, scroller: this.scroller }) {
  170. LazyForEach(this.listAdapter, (item: LyricLine, index: number) => {
  171. ListItem() {
  172. Stack() {
  173. // 逐字歌词渲染
  174. if (item.hasWords() && item.words.length > 0) {
  175. this.WordByWordLyric(item, index)
  176. } else {
  177. // 普通歌词渲染(原有逻辑)
  178. this.NormalLyricLine(item, index)
  179. }
  180. if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  181. && this.enableSeek && this.isUserTouching){
  182. this.JumpProgress()
  183. }
  184. }
  185. .align(Alignment.End)
  186. }
  187. .padding(8)
  188. .border({ radius: 12 })
  189. // .backgroundColor(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  190. // && this.enableSeek && this.isUserTouching ? '#80a9a9a9' : '#00000000')
  191. .onClick(() => {
  192. if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0) {
  193. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  194. this.handleSeekAction();
  195. }
  196. }
  197. })
  198. },
  199. (item: LyricLine, index: number) => {
  200. return item.text + '_' + index + '_' + item.beginTime + '_' + item.nextTime
  201. })
  202. }
  203. .width('100%')
  204. .height('100%')
  205. .layoutWeight(1)
  206. .scrollBar(BarState.Off)
  207. .fadingEdge(true,{fadingEdgeLength:LengthMetrics.percent(20)})
  208. .edgeEffect(EdgeEffect.Spring)
  209. .contentEndOffset(this.h / 3)
  210. .contentStartOffset(this.isUserTouching?this.h / 3:0)
  211. .cachedCount(this.cacheSize)
  212. // .chainAnimation(true)
  213. // .animation({
  214. // curve: curves.springCurve(100, 10, 80, 10),
  215. // duration: 500
  216. // })
  217. .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible)
  218. .onScrollIndex((_, __, center) => {
  219. const now = Date.now()
  220. if (now - this.lastScrollTime < this.scrollThrottle) return
  221. this.lastScrollTime = now
  222. // 纯文本歌词不支持 seek 操作
  223. if (this.isUserTouching && !(this.currentLyric && this.currentLyric.isPlainText)) {
  224. //修复The value of "index" is out of range. It must be >= 0 && <= -1. Received value is: -1
  225. if (center >= 0 && center < this.listAdapter.totalCount()) {
  226. this.seekIndex = center;
  227. let targetPosition = this.listAdapter.getData(center).beginTime;
  228. this.scrollDurationText = duration2text(targetPosition);
  229. } else {
  230. // 处理 center 不在范围内的情况
  231. console.error(`Index out of range: ${center}`);
  232. }
  233. // this.seekIndex = center
  234. // let targetPosition = this.listAdapter.getData(center).beginTime
  235. // this.scrollDurationText = duration2text(targetPosition)
  236. }
  237. })
  238. .onTouch((event) => {
  239. let motion = event.touches[0]
  240. switch (motion.type) {
  241. case TouchType.Down:
  242. clearTimeout(this.seekUiHideTimeout)
  243. break
  244. case TouchType.Move:
  245. this.isUserTouching = true
  246. break
  247. case TouchType.Up:
  248. case TouchType.Cancel:
  249. // 纯文本歌词不需要自动滚动回顶部
  250. if (this.currentLyric && this.currentLyric.isPlainText) {
  251. this.seekUiHideTimeout = setTimeout(() => {
  252. this.seekIndex = -1
  253. this.isUserTouching = false
  254. // 纯文本歌词不调用 animateToIndex,保持在当前位置
  255. }, this.autoHideSeekUIDuration)
  256. } else {
  257. this.seekUiHideTimeout = setTimeout(() => {
  258. this.seekIndex = -1
  259. this.isUserTouching = false
  260. this.animateToIndex(this.currentIndex)
  261. }, this.autoHideSeekUIDuration)
  262. }
  263. }
  264. })
  265. }
  266. // 普通歌词渲染(原有逻辑)
  267. @Builder
  268. NormalLyricLine(item: LyricLine, index: number) {
  269. Column(){
  270. Text(item.text)
  271. .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
  272. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  273. .fontColor(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor)
  274. .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
  275. .padding(this.isSingleLine?0:{ top:5,bottom:5 })
  276. .visibility(this.isSingleLine?
  277. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  278. : Visibility.Visible)
  279. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '80%')
  280. .animation({
  281. duration: 150,
  282. curve: Curve.Linear
  283. })
  284. .blendMode(
  285. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
  286. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
  287. )
  288. // 中文翻译(整行显示)
  289. if (item.translation) {
  290. Text(item.translation)
  291. .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
  292. .fontColor(this.currentMediaPosition >= item.beginTime ?
  293. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
  294. .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
  295. .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
  296. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  297. .visibility(this.isSingleLine?
  298. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  299. : Visibility.Visible)
  300. .blendMode(
  301. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
  302. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
  303. )
  304. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '80%')
  305. .animation({
  306. duration: 150,
  307. curve: Curve.Linear
  308. })
  309. }
  310. }
  311. // 在 Row 上应用渐变
  312. .linearGradient(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? {
  313. direction: GradientDirection.Right,
  314. colors: this.getLyricItemLinearGradient(item, index)
  315. } : undefined)
  316. .blendMode(
  317. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.SRC_OVER : undefined,
  318. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
  319. )
  320. }
  321. /**
  322. * 计算卡拉OK渐变 - 同色系从浅到深的平滑过渡
  323. */
  324. getLyricItemLinearGradient(item: LyricLine, index: number): [ResourceColor, number][] {
  325. // 只对当前播放行且包含逐字数据的行应用卡拉OK效果
  326. if (index !== this.currentIndex || item.words.length === 0) {
  327. //console.info('heanup', `getLyricItemLinearGradient - 非高亮行或无逐字数据: index=${index}, currentIndex=${this.currentIndex}, hasWords=${item.hasWords()}, wordsCount=${item.words.length}`)
  328. return [[Color.White, 0.0], [Color.White, 1.0]]
  329. }
  330. // 计算该行歌词的总时长
  331. let lyricDuration: number
  332. if (index < this.listAdapter.totalCount() - 1) {
  333. const nextLine = this.listAdapter.getData(index + 1)
  334. lyricDuration = nextLine.beginTime - item.beginTime
  335. } else {
  336. // 最后一行,使用 nextTime(如果有)或者估计时长
  337. lyricDuration = item.nextTime > item.beginTime ? item.nextTime - item.beginTime : 5000
  338. }
  339. //console.info('heanup', `getLyricItemLinearGradient - index=${index}, lyricDuration=${lyricDuration}, currentMediaPosition=${this.currentMediaPosition}, itemBeginTime=${item.beginTime}`)
  340. if (lyricDuration <= 0) {
  341. console.info('heanup', `getLyricItemLinearGradient - 歌词时长<=0, 返回透明`)
  342. return [[Color.Transparent, 0.0], [Color.Transparent, 1.0]]
  343. }
  344. // 计算当前播放进度(0-1之间)
  345. let diff = this.currentMediaPosition - item.beginTime
  346. let value = diff / lyricDuration
  347. value = Math.max(0, Math.min(1, value))
  348. return [[this.textHighlightColor, 0.0],
  349. [this.textHighlightColor, value],
  350. [this.textColor, value],
  351. [this.textColor, 1.0]]
  352. }
  353. /**
  354. * 计算逐字歌词的卡拉OK渐变效果
  355. * 该方法针对逐字歌词格式,根据当前播放进度和每个字的时间信息计算渐变
  356. * @param item 当前歌词行
  357. * @param word 当前字的信息
  358. * @param index 当前行索引
  359. * @returns 渐变颜色数组
  360. */
  361. getWordByWordLyricLyricItemLinearGradient(item: LyricLine, word: LyricWord, index: number): [ResourceColor, number][] {
  362. // 非高亮行或无效数据,返回透明
  363. if (index !== this.currentIndex || !word || !word.word) {
  364. //console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - 非高亮行或无效word: index=${index}, currentIndex=${this.currentIndex}`)
  365. return [[Color.White, 0.0], [Color.White, 1.0]]
  366. }
  367. // 计算该字的播放进度
  368. const wordEndTime = word.startTime + word.duration
  369. const wordDuration = word.duration
  370. // 异常情况处理
  371. if (wordDuration <= 0) {
  372. //console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - word时长<=0: startTime=${word.startTime}, duration=${word.duration}`)
  373. // 如果时长无效,检查当前播放位置是否已到达开始时间
  374. if (this.currentMediaPosition >= word.startTime) {
  375. // 已开始播放,全部高亮
  376. return [[this.textHighlightColor, 0.0], [this.textHighlightColor, 1.0]]
  377. } else {
  378. // 未开始播放,全部普通颜色
  379. return [[this.textColor, 0.0], [this.textColor, 1.0]]
  380. }
  381. }
  382. // 计算当前在该字内的播放进度(0-1之间)
  383. let progress = 0
  384. if (this.currentMediaPosition < word.startTime) {
  385. // 还没播放到这个字
  386. progress = 0
  387. } else if (this.currentMediaPosition >= wordEndTime) {
  388. // 这个字已经播放完
  389. progress = 1
  390. } else {
  391. // 正在播放这个字,计算进度
  392. const diff = this.currentMediaPosition - word.startTime
  393. progress = diff / wordDuration
  394. progress = Math.max(0, Math.min(1, progress))
  395. }
  396. console.info('heanup', `getWordByWordLyricLyricItemLinearGradient - word=${word.word}, progress=${progress}, currentPos=${this.currentMediaPosition}, wordStart=${word.startTime}, wordEnd=${wordEndTime}`)
  397. // 返回卡拉OK渐变效果
  398. // 0.0 到 progress:高亮色(已播放部分)
  399. // progress 到 1.0:普通色(未播放部分)
  400. return [[this.textHighlightColor, 0.0],
  401. [this.textHighlightColor, progress],
  402. [this.textColor, progress],
  403. [this.textColor, 1.0]]
  404. }
  405. @Builder
  406. WordByWordLyric(item: LyricLine, index: number) {
  407. Column() {
  408. Row({ space: 0 }) {
  409. ForEach(item.words, (word: LyricWord, wordIndex: number) => {
  410. Text(word.word)
  411. .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
  412. .fontColor(this.currentMediaPosition >= word.startTime ?
  413. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
  414. .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)? FontWeight.Bold : this.textWeight)
  415. .margin(isEnglish(word.word) ?{ right:4 }:{})
  416. .visibility(this.isSingleLine?
  417. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  418. : Visibility.Visible)
  419. .padding(this.isSingleLine?0:{ top:5,bottom:5 })
  420. // .shaderStyle(index == this.currentIndex ?{
  421. // direction: GradientDirection.Right,
  422. // colors: this.getWordByWordLyricLyricItemLinearGradient(item, word, index)
  423. // }:undefined)
  424. .animation({
  425. duration: 150,
  426. curve: Curve.Linear
  427. })
  428. })
  429. }
  430. .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
  431. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
  432. // 中文翻译(整行显示)
  433. if (item.translation) {
  434. Row({ space: 0 }) {
  435. Text(item.translation)
  436. .fontSize(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?this.textSize*this.controller.getHighlightScale():this.textSize)
  437. .fontColor(this.currentMediaPosition >= item.beginTime ?
  438. index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ? this.textHighlightColor : this.textColor : this.textColor)
  439. .fontWeight(index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText)? FontWeight.Bold : this.textWeight)
  440. .padding({ bottom:5 })
  441. .padding(this.isSingleLine?{ bottom:2 }:{ bottom:5 })
  442. .visibility(this.isSingleLine?
  443. (index >= this.currentIndex && index <= this.currentIndex + 1 ? Visibility.Visible : Visibility.None)
  444. : Visibility.Visible)
  445. .animation({
  446. duration: 150,
  447. curve: Curve.Linear
  448. })
  449. }
  450. .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
  451. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex && !(this.currentLyric && this.currentLyric.isPlainText) ?'95%': '85%')
  452. }
  453. }
  454. .height('auto')
  455. }
  456. //修复get Property index out of bounds
  457. private handleSeekAction() {
  458. clearTimeout(this.seekUiHideTimeout);
  459. let itemCount = this.listAdapter.totalCount(); // 获取数据项的总数
  460. // 确保 seekIndex 在有效范围内
  461. if (this.seekIndex >= 0 && this.seekIndex < itemCount) {
  462. let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime;
  463. let isPlayerHandled = this.onSeekAction(targetPosition);
  464. if (!isPlayerHandled) {
  465. this.animateToIndex(this.currentIndex);
  466. }
  467. } else {
  468. console.error("Seek index out of bounds:", this.seekIndex);
  469. // 处理超出范围的情况,比如设定默认值或抛出错误
  470. }
  471. this.isUserTouching = false;
  472. }
  473. getTransverterText(message: string):string{
  474. if(this.transverterType==1){
  475. return transverter({
  476. type: TransverterType.TRADITIONAL,
  477. str: message,
  478. language: TransverterLanguage.ZH_TW
  479. });
  480. }else if(this.transverterType==2){
  481. return transverter({
  482. type: TransverterType.SIMPLIFIED,
  483. str: message,
  484. language: TransverterLanguage.ZH_CN
  485. });
  486. }else{
  487. return message;
  488. }
  489. }
  490. @Builder
  491. JumpProgress(){
  492. Row(){
  493. Row(){
  494. Row({space: 10}){
  495. Text(this.scrollDurationText)
  496. .fontSize(12)
  497. .fontWeight(FontWeight.Medium)
  498. .fontColor(Color.White)
  499. SymbolGlyph($r('sys.symbol.play'))
  500. .fontSize(13)
  501. .fontColor([Color.White])
  502. .alignSelf(ItemAlign.Center)
  503. }
  504. .borderRadius(10)
  505. .alignItems(VerticalAlign.Center)
  506. .height(32)
  507. .padding(10)
  508. .backgroundColor(Color.Transparent)
  509. .backgroundBlurStyle(BlurStyle.BACKGROUND_REGULAR,
  510. { colorMode: ThemeColorMode.LIGHT, adaptiveColor: AdaptiveColor.DEFAULT, scale: 1.0 })
  511. }
  512. .transition(
  513. TransitionEffect
  514. .scale({ x: 0.7, y: 0.7 })
  515. .combine(TransitionEffect
  516. .opacity(0.1)
  517. )
  518. .animation({
  519. duration: 150,
  520. curve: Curve.EaseInOut,
  521. })
  522. )
  523. }
  524. .hitTestBehavior(HitTestMode.Transparent)
  525. .width(110)
  526. .justifyContent(FlexAlign.End)
  527. .backgroundColor(Color.Transparent)
  528. }
  529. @Builder
  530. SeekLine() {
  531. Row() {
  532. Image($r('app.media.cclyric_play'))
  533. .width(24)
  534. .height(24)
  535. .fillColor(this.seekUIColor)
  536. .objectFit(ImageFit.Fill)
  537. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  538. .onClick(() => {
  539. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  540. this.handleSeekAction()
  541. }
  542. })
  543. Stack()
  544. .height(1)
  545. .layoutWeight(1)
  546. .backgroundColor(this.seekLineColor)
  547. .margin({ left: 8, right: 8 })
  548. Text(this.scrollDurationText)
  549. .fontSize(this.textSize)
  550. .fontColor(this.seekUIColor)
  551. }
  552. .visibility(this.enableSeek && this.isUserTouching ? Visibility.Visible : Visibility.Hidden)
  553. .width('100%')
  554. .height('100%')
  555. .hitTestBehavior(HitTestMode.Transparent)
  556. .transition(TransitionEffect.OPACITY.animation({ duration: this.animDuration }))
  557. }
  558. build() {
  559. Stack() {
  560. if (this.isLyricEmpty) {
  561. this.EmptyView()
  562. } else {
  563. this.LyricListView()
  564. }
  565. }
  566. .width('100%')
  567. .height('100%')
  568. .onAreaChange((_, newSize) => {
  569. printW('onSizeChanged: ' + JSON.stringify(newSize))
  570. this.h = newSize.height as number
  571. this.w = newSize.width as number
  572. if (this.currentLyric) {
  573. this.loadData(this.currentLyric)
  574. }
  575. })
  576. }
  577. aboutToDisappear() {
  578. clearTimeout(this.loadTimeout)
  579. clearTimeout(this.seekUiHideTimeout)
  580. }
  581. private getIndex(position: number): number {
  582. let size = this.listAdapter.totalCount()
  583. if (size === 0) return 0 // 空列表保护
  584. // 如果是纯文本歌词,始终返回 0(不滚动)
  585. if (this.currentLyric && this.currentLyric.isPlainText) {
  586. return 0
  587. }
  588. let first = this.listAdapter.getData(0).beginTime
  589. if (position < first) {
  590. return 0
  591. }
  592. let last = this.listAdapter.getData(size - 1).beginTime
  593. if (position > last) {
  594. return size - 1
  595. }
  596. for (let i = 0; i < size - 1; i++) {
  597. let line = this.listAdapter.getData(i)
  598. if (position >= line.beginTime && position < line.nextTime) {
  599. return i
  600. }
  601. }
  602. return this.currentIndex
  603. }
  604. private animateToIndex(index: number) {
  605. printD('animate to index= ' + index)
  606. this.currentIndex = index
  607. if (this.isUserTouching) {
  608. return
  609. }
  610. if(this.isHightLightCenter){
  611. this.scroller.scrollToIndex(index, true, ScrollAlign.CENTER)
  612. }else{
  613. // 计算目标索引,使其在居中位置上方有两条歌词
  614. const targetIndex = Math.max(0, index - 2);
  615. this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START);
  616. }
  617. }
  618. private onPositionChanged(mediaPosition: number) {
  619. if (this.isLyricEmpty) {
  620. // printW('The lyric data is empty!')
  621. return
  622. }
  623. if (this.listAdapter.isEmpty()) {
  624. // printW('The lyric lines is empty!')
  625. return
  626. }
  627. // 如果是纯文本歌词,不进行滚动同步
  628. if (this.currentLyric && this.currentLyric.isPlainText) {
  629. return
  630. }
  631. let index = this.getIndex(mediaPosition)
  632. if (index != this.currentIndex) {
  633. this.animateToIndex(index)
  634. }
  635. }
  636. }
  637. function isEnglish(text: string, mode: string = 'strict', threshold: number = 0.9): boolean {
  638. if (!text || typeof text !== 'string') return false;
  639. switch (mode) {
  640. case 'basic':
  641. return /^[a-zA-Z\s.,!?'"-]+$/.test(text);
  642. case 'percentage':
  643. return checkByPercentage(text, threshold);
  644. default: // strict
  645. return /^[\u0000-\u007F]+$/.test(text.trim());
  646. }
  647. }
  648. function checkByPercentage(text: string, threshold: number): boolean {
  649. const validChars = text.match(/[a-zA-Z\s.,!?'"-]/g) || [];
  650. return (validChars.length / text.length) >= threshold;
  651. }