LyricView2.ets 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534
  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 { 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 = 0
  63. @State animDuration: number = 300
  64. @State isUserTouching: boolean = false
  65. private centerOffsetSize: number = 0
  66. // seek action: center line view
  67. @State seekPosition: number = -1
  68. @State scrollDurationText: string = '00:00'
  69. @State seekIndex: number = -1
  70. private seekUiHideTimeout = -1
  71. private autoHideSeekUIDuration = 2000
  72. @State isLoadingData: boolean = false
  73. private loadTimeout = -1
  74. @State isHightLightCenter: boolean = true
  75. @State currentMediaPosition: number = 0
  76. @State transverterType: number = 0
  77. private onDataChangedListener = (lyric: Lyric | null) => {
  78. clearTimeout(this.loadTimeout)
  79. this.isLoadingData = true
  80. this.loadData(lyric)
  81. this.loadTimeout = setTimeout(() => {
  82. this.isLoadingData = false
  83. }, 300)
  84. }
  85. private onPositionChangedListener = (mediaPosition: number) => {
  86. this.currentMediaPosition = mediaPosition
  87. this.onPositionChanged(mediaPosition)
  88. }
  89. private onInvalidatedListener = (reLayout: boolean) => {
  90. this.getAttrFromController()
  91. if (reLayout && this.currentIndex > 0) {
  92. this.animateToIndex(this.currentIndex)
  93. }
  94. }
  95. private loadData(lyric: Lyric | null) {
  96. this.currentLyric = lyric;
  97. if (this.w > 0 && this.h > 0) {
  98. if (this.currentLyric) {
  99. this.listAdapter.clear(false);
  100. let lyricLines = this.currentLyric.lyricList;
  101. if (lyricLines!==undefined&&lyricLines.length > 0) {
  102. let first = lyricLines[0].beginTime;
  103. // fill the top empty gap 注释掉修复 歌曲不播放的时候,可以显示完整的歌词,而不是显示一两行
  104. // for (let i = 0; i < this.centerOffsetSize; i++) {
  105. // this.listAdapter.addData(new LyricLine(' ', 0, first), false);
  106. // }
  107. lyricLines.forEach((line) => {
  108. this.listAdapter.addData(line, false);
  109. });
  110. // fill the bottom empty gap
  111. let last = lyricLines[lyricLines.length - 1].nextTime;
  112. for (let i = 0; i < this.centerOffsetSize; i++) {
  113. this.listAdapter.addData(new LyricLine(' ', last + i, last + i), false);
  114. }
  115. }
  116. this.listAdapter.notifyDataReload();
  117. } else {
  118. this.listAdapter.clear(true);
  119. }
  120. }
  121. this.isLyricEmpty = this.listAdapter.isEmpty();
  122. this.currentIndex = 0;
  123. this.animateToIndex(0);
  124. printW('loadData isEmpty = ' + this.isLyricEmpty);
  125. }
  126. private getAttrFromController() {
  127. this.currentLyric = this.controller.getLyric()
  128. this.textSize = this.controller.getTextSize()
  129. this.transverterType = this.controller.getTransverterType()
  130. this.isSingleLine = this.controller.getSingleLine()
  131. this.blurDegree = this.controller.getBlurDegree()
  132. this.isHightLightCenter = this.controller.getHightLightCenter()
  133. this.lineSpace = this.controller.getLineSpace()
  134. this.textColor = this.controller.getTextColor()
  135. this.textHighlightColor = this.controller.getHighlightColor()
  136. this.textHighlightSize = this.controller.getHighlightScale() * this.textSize
  137. this.isHighlightBold = this.controller.isHighlightBold()
  138. this.animDuration = this.controller.getAnimationDuration()
  139. this.cacheSize = this.controller.getCacheSize()
  140. this.emptyHint = this.controller.getEmptyHint()
  141. this.alignMode = this.controller.getAlignMode()
  142. this.textWeight = this.controller.getTextWeight()
  143. }
  144. aboutToAppear() {
  145. if (this.controller == null) {
  146. throw new Error('The lyric lyricConfig is not set!')
  147. }
  148. this.controller.onDataChangedListener = this.onDataChangedListener
  149. this.controller.onPositionChangedListener = this.onPositionChangedListener
  150. this.controller.onInvalidated = this.onInvalidatedListener
  151. this.getAttrFromController()
  152. // 初始化时将滚动位置设置为顶部
  153. this.scroller.scrollToIndex(0, true, ScrollAlign.START);
  154. }
  155. @Builder
  156. EmptyView() {
  157. Text(this.emptyHint)
  158. .fontSize(this.textSize)
  159. .fontColor(this.textColor)
  160. }
  161. @State blurDegree: number = 3
  162. // 优化建议代码示例:增加滚动节流
  163. private lastScrollTime: number = 0
  164. private scrollThrottle: number = 100 // 100ms节流
  165. @Builder
  166. LyricListView() {
  167. List({ space: this.lineSpace - 16, scroller: this.scroller }) {
  168. LazyForEach(this.listAdapter, (item: LyricLine, index: number) => {
  169. ListItem() {
  170. Stack() {
  171. // 逐字歌词渲染
  172. if (item.hasWords() && item.words.length > 0) {
  173. this.WordByWordLyric(item, index)
  174. } else {
  175. // 普通歌词渲染(原有逻辑)
  176. this.NormalLyricLine(item, index)
  177. }
  178. Text(this.scrollDurationText)
  179. .fontSize(this.textSize)
  180. .fontColor(this.seekUIColor)
  181. .textAlign( TextAlign.End)
  182. .width(100)
  183. .visibility(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  184. && this.enableSeek && this.isUserTouching?Visibility.Visible:Visibility.Hidden)
  185. }
  186. .align(Alignment.End)
  187. }
  188. .padding(8)
  189. .transition(TransitionEffect.asymmetric(TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 500 }),
  190. TransitionEffect.scale({ x: 0, y: 0 }) ))
  191. .border({ radius: 12 })
  192. .backgroundColor(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  193. && this.enableSeek && this.isUserTouching ? '#80a9a9a9' : '#00000000')
  194. .onClick(() => {
  195. if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0) {
  196. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  197. this.handleSeekAction();
  198. }
  199. }
  200. })
  201. },
  202. (item: LyricLine, index: number) => {
  203. return item.text + '_' + index + '_' + item.beginTime + '_' + item.nextTime
  204. })
  205. }
  206. .width('100%')
  207. .height('100%')
  208. .scrollBar(BarState.Off)
  209. .fadingEdge(true,{fadingEdgeLength:LengthMetrics.percent(20)})
  210. .cachedCount(this.cacheSize)
  211. .transition(TransitionEffect.asymmetric(
  212. this.isSingleLine? TransitionEffect.scale({ x: 1.2, y: 1.2 }).animation({ duration: 500 }):
  213. TransitionEffect.move(TransitionEdge.BOTTOM).animation({ duration: 500 }),
  214. TransitionEffect.scale({ x: 0, y: 0 }) ))
  215. .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible)
  216. .onScrollIndex((_, __, center) => {
  217. const now = Date.now()
  218. if (now - this.lastScrollTime < this.scrollThrottle) return
  219. this.lastScrollTime = now
  220. if (this.isUserTouching) {
  221. //修复The value of "index" is out of range. It must be >= 0 && <= -1. Received value is: -1
  222. if (center >= 0 && center < this.listAdapter.totalCount()) {
  223. this.seekIndex = center;
  224. let targetPosition = this.listAdapter.getData(center).beginTime;
  225. this.scrollDurationText = duration2text(targetPosition);
  226. } else {
  227. // 处理 center 不在范围内的情况
  228. console.error(`Index out of range: ${center}`);
  229. }
  230. // this.seekIndex = center
  231. // let targetPosition = this.listAdapter.getData(center).beginTime
  232. // this.scrollDurationText = duration2text(targetPosition)
  233. }
  234. })
  235. .onTouch((event) => {
  236. let motion = event.touches[0]
  237. switch (motion.type) {
  238. case TouchType.Down:
  239. clearTimeout(this.seekUiHideTimeout)
  240. break
  241. case TouchType.Move:
  242. this.isUserTouching = true
  243. break
  244. case TouchType.Up:
  245. case TouchType.Cancel:
  246. this.seekUiHideTimeout = setTimeout(() => {
  247. this.seekIndex = -1
  248. this.isUserTouching = false
  249. this.animateToIndex(this.currentIndex)
  250. }, this.autoHideSeekUIDuration)
  251. }
  252. })
  253. }
  254. // 普通歌词渲染(原有逻辑)
  255. @Builder
  256. NormalLyricLine(item: LyricLine, index: number) {
  257. Column(){
  258. Text(item.text)
  259. .fontSize(this.textSize)
  260. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  261. .scale({
  262. x: index == this.currentIndex ? this.controller.getHighlightScale() : 1,
  263. y: index == this.currentIndex ? this.controller.getHighlightScale() : 1,
  264. centerX: this.alignMode == 'center' ? '50%' : 0
  265. })
  266. .fontColor(index == this.currentIndex ? this.textHighlightColor : this.textColor)
  267. .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
  268. .animation({ duration: this.animDuration, curve: Curve.FastOutSlowIn })
  269. .visibility(this.isSingleLine?
  270. (index == this.currentIndex ?Visibility.Visible:Visibility.None)
  271. :Visibility.Visible)
  272. .width(this.alignMode == 'center' ? '100%' : '76%')
  273. // 中文翻译(整行显示)
  274. if (item.translation) {
  275. Row({ space: 0 }) {
  276. Text(item.translation)
  277. .fontSize(index == this.currentIndex ?this.textSize*this.controller.getHighlightScale():this.textSize)
  278. .fontColor(this.currentMediaPosition >= item.beginTime ?
  279. index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor)
  280. .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
  281. .margin({ top: 4 })
  282. }
  283. .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
  284. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%')
  285. }
  286. }
  287. }
  288. @Builder
  289. WordByWordLyric(item: LyricLine, index: number) {
  290. Column() {
  291. Row({ space: 0 }) {
  292. ForEach(item.words, (word: LyricWord, wordIndex: number) => {
  293. Text(word.word)
  294. .fontSize(index == this.currentIndex ?this.textSize*this.controller.getHighlightScale():this.textSize)
  295. .fontColor(this.currentMediaPosition >= word.startTime ?
  296. index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor)
  297. .fontWeight(this.currentMediaPosition >= word.startTime && this.isHighlightBold ?
  298. index == this.currentIndex? FontWeight.Bold : this.textWeight: this.textWeight)
  299. .margin(isEnglish(word.word) ?{ right:4 }:{})
  300. .visibility(this.isSingleLine?
  301. (index == this.currentIndex ?Visibility.Visible:Visibility.None)
  302. :Visibility.Visible)
  303. .animation({
  304. // 动画播放速度
  305. tempo: 0.8,
  306. // 动画持续时间,单位是毫秒
  307. duration: 777,
  308. // 动画缓动函数
  309. curve: Curve.FastOutSlowIn
  310. })
  311. })
  312. }
  313. .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
  314. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%')
  315. // 中文翻译(整行显示)
  316. if (item.translation) {
  317. Row({ space: 0 }) {
  318. Text(item.translation)
  319. .fontSize(index == this.currentIndex ?this.textSize*this.controller.getHighlightScale():this.textSize)
  320. .fontColor(this.currentMediaPosition >= item.beginTime ?
  321. index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor)
  322. .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
  323. .margin({ top: 4 })
  324. }
  325. .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
  326. .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%')
  327. }
  328. }
  329. }
  330. isTopBottomLine(index:number){
  331. return index === 0 || index === this.listAdapter.totalCount() - 1;
  332. }
  333. //修复get Property index out of bounds
  334. private handleSeekAction() {
  335. clearTimeout(this.seekUiHideTimeout);
  336. let itemCount = this.listAdapter.totalCount(); // 获取数据项的总数
  337. // 确保 seekIndex 在有效范围内
  338. if (this.seekIndex >= 0 && this.seekIndex < itemCount) {
  339. let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime;
  340. let isPlayerHandled = this.onSeekAction(targetPosition);
  341. if (!isPlayerHandled) {
  342. this.animateToIndex(this.currentIndex);
  343. }
  344. } else {
  345. console.error("Seek index out of bounds:", this.seekIndex);
  346. // 处理超出范围的情况,比如设定默认值或抛出错误
  347. }
  348. this.isUserTouching = false;
  349. }
  350. getTransverterText(message: string):string{
  351. if(this.transverterType==1){
  352. return transverter({
  353. type: TransverterType.TRADITIONAL,
  354. str: message,
  355. language: TransverterLanguage.ZH_TW
  356. });
  357. }else if(this.transverterType==2){
  358. return transverter({
  359. type: TransverterType.SIMPLIFIED,
  360. str: message,
  361. language: TransverterLanguage.ZH_CN
  362. });
  363. }else{
  364. return message;
  365. }
  366. }
  367. @Builder
  368. SeekLine() {
  369. Row() {
  370. Image($r('app.media.cclyric_play'))
  371. .width(24)
  372. .height(24)
  373. .fillColor(this.seekUIColor)
  374. .objectFit(ImageFit.Fill)
  375. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  376. .onClick(() => {
  377. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  378. this.handleSeekAction()
  379. }
  380. })
  381. Stack()
  382. .height(1)
  383. .layoutWeight(1)
  384. .backgroundColor(this.seekLineColor)
  385. .margin({ left: 8, right: 8 })
  386. Text(this.scrollDurationText)
  387. .fontSize(this.textSize)
  388. .fontColor(this.seekUIColor)
  389. }
  390. .visibility(this.enableSeek && this.isUserTouching ? Visibility.Visible : Visibility.Hidden)
  391. .width('100%')
  392. .height('100%')
  393. .hitTestBehavior(HitTestMode.Transparent)
  394. .transition(TransitionEffect.OPACITY.animation({ duration: this.animDuration }))
  395. }
  396. build() {
  397. Stack() {
  398. if (this.isLyricEmpty) {
  399. this.EmptyView()
  400. } else {
  401. this.LyricListView()
  402. }
  403. if (this.seekUIStyle == 'seekLine') {
  404. this.SeekLine()
  405. }
  406. }
  407. .width('100%')
  408. .height('100%')
  409. .onAreaChange((_, newSize) => {
  410. printW('onSizeChanged: ' + JSON.stringify(newSize))
  411. this.h = newSize.height as number
  412. this.w = newSize.width as number
  413. let lineH = this.textSize + this.lineSpace
  414. this.centerOffsetSize = Math.floor((this.h - lineH) / 2 / lineH)
  415. printW('offsetSize = ' + this.centerOffsetSize)
  416. if (this.currentLyric) {
  417. this.loadData(this.currentLyric)
  418. }
  419. })
  420. }
  421. aboutToDisappear() {
  422. clearTimeout(this.loadTimeout)
  423. clearTimeout(this.seekUiHideTimeout)
  424. }
  425. private getIndex(position: number): number {
  426. let size = this.listAdapter.totalCount()
  427. if (size === 0) return 0 // 空列表保护
  428. let first = this.listAdapter.getData(0).beginTime
  429. if (position < first) {
  430. return 0
  431. }
  432. let last = this.listAdapter.getData(size - 1).beginTime
  433. if (position > last) {
  434. return size - 1
  435. }
  436. for (let i = 0; i < size - 1; i++) {
  437. let line = this.listAdapter.getData(i)
  438. if (position >= line.beginTime && position < line.nextTime) {
  439. return i
  440. }
  441. }
  442. return this.currentIndex
  443. }
  444. private animateToIndex(index: number) {
  445. printD('animate to index= ' + index)
  446. this.currentIndex = index
  447. if (this.isUserTouching) {
  448. return
  449. }
  450. if(this.isHightLightCenter){
  451. this.scroller.scrollToIndex(index, true, ScrollAlign.CENTER)
  452. }else{
  453. // 计算目标索引,使其在居中位置上方有两条歌词
  454. const targetIndex = Math.max(0, index - 2);
  455. this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START);
  456. }
  457. }
  458. private onPositionChanged(mediaPosition: number) {
  459. if (this.isLyricEmpty) {
  460. printW('The lyric data is empty!')
  461. return
  462. }
  463. if (this.listAdapter.isEmpty()) {
  464. printW('The lyric lines is empty!')
  465. return
  466. }
  467. let index = this.getIndex(mediaPosition)
  468. if (index != this.currentIndex) {
  469. this.animateToIndex(index)
  470. }
  471. }
  472. }
  473. function isEnglish(text: string, mode: string = 'strict', threshold: number = 0.9): boolean {
  474. if (!text || typeof text !== 'string') return false;
  475. switch (mode) {
  476. case 'basic':
  477. return /^[a-zA-Z\s.,!?'"-]+$/.test(text);
  478. case 'percentage':
  479. return checkByPercentage(text, threshold);
  480. default: // strict
  481. return /^[\u0000-\u007F]+$/.test(text.trim());
  482. }
  483. }
  484. function checkByPercentage(text: string, threshold: number): boolean {
  485. const validChars = text.match(/[a-zA-Z\s.,!?'"-]/g) || [];
  486. return (validChars.length / text.length) >= threshold;
  487. }