LyricView2.ets 16 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408
  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. /**
  7. * A component to display the lyric with scroll animation.
  8. * This component only support api 10+.
  9. *
  10. * The custom setter include the lyric text style, fade style of edge, the line space, scroll animation duration,
  11. * the cache size to draw out of screen.
  12. */
  13. @Component
  14. export struct LyricView2 {
  15. /**
  16. * The lyricConfig for LyricView.
  17. */
  18. controller: LyricController = new LyricController()
  19. /**
  20. * Enable the lyric scroll to do seek action or not.
  21. * If false, the onSeekAction callback will not invoke anymore.
  22. */
  23. enableSeek: boolean = true
  24. /**
  25. * The color of seek button and duration text.
  26. */
  27. seekUIColor: ResourceColor = '#000000'
  28. /**
  29. * The color of the seek location line.
  30. */
  31. seekLineColor: ResourceColor = '#0d000000'
  32. /**
  33. * The seek ui style.
  34. */
  35. seekUIStyle: 'seekLine' | 'listItem' = 'listItem'
  36. /**
  37. * The seek callback for scroll the lyric.
  38. * If return true, you must handle this action to do seek action of media player.
  39. * If return false, the lyric view will scroll to current index of the media playing position.
  40. */
  41. onSeekAction: (position: number) => boolean = () => false
  42. private currentLyric: Lyric | null = null
  43. private scroller = new Scroller()
  44. private listAdapter = new ListAdapter<LyricLine>()
  45. private w = 0 // the width of this view.
  46. private h = 0 // the height of this view.
  47. @State currentIndex: number = 0 // the focused index of the lyric line list.
  48. @State isLyricEmpty: boolean = true // is no lyric.
  49. @State textSize: number = 16
  50. @State lineSpace: number = 16
  51. @State textWeight: number = FontWeight.Medium
  52. @State textHighlightSize: number = 18
  53. @State textColor: string = '#000000'
  54. @State textHighlightColor: string = '#000000'
  55. @State isHighlightBold: boolean = false
  56. @State alignMode: 'left' | 'center' = 'center'
  57. @State emptyHint: string = ''
  58. @State cacheSize: number = 0
  59. @State animDuration: number = 300
  60. @State isUserTouching: boolean = false
  61. private centerOffsetSize: number = 0
  62. // seek action: center line view
  63. @State seekPosition: number = -1
  64. @State scrollDurationText: string = '00:00'
  65. @State seekIndex: number = -1
  66. private seekUiHideTimeout = -1
  67. private autoHideSeekUIDuration = 2000
  68. @State isLoadingData: boolean = false
  69. private loadTimeout = -1
  70. @State isHightLightCenter: boolean = true
  71. private onDataChangedListener = (lyric: Lyric | null) => {
  72. clearTimeout(this.loadTimeout)
  73. this.isLoadingData = true
  74. this.loadData(lyric)
  75. this.loadTimeout = setTimeout(() => {
  76. this.isLoadingData = false
  77. }, 300)
  78. }
  79. private onPositionChangedListener = (mediaPosition: number) => {
  80. this.onPositionChanged(mediaPosition)
  81. }
  82. private onInvalidatedListener = (reLayout: boolean) => {
  83. this.getAttrFromController()
  84. if (reLayout && this.currentIndex > 0) {
  85. this.animateToIndex(this.currentIndex)
  86. }
  87. }
  88. private loadData(lyric: Lyric | null) {
  89. this.currentLyric = lyric;
  90. if (this.w > 0 && this.h > 0) {
  91. if (this.currentLyric) {
  92. this.listAdapter.clear(false);
  93. let lyricLines = this.currentLyric.lyricList;
  94. if (lyricLines!==undefined&&lyricLines.length > 0) {
  95. let first = lyricLines[0].beginTime;
  96. // fill the top empty gap 注释掉修复 歌曲不播放的时候,可以显示完整的歌词,而不是显示一两行
  97. // for (let i = 0; i < this.centerOffsetSize; i++) {
  98. // this.listAdapter.addData(new LyricLine(' ', 0, first), false);
  99. // }
  100. lyricLines.forEach((line) => {
  101. this.listAdapter.addData(line, false);
  102. });
  103. // fill the bottom empty gap
  104. let last = lyricLines[lyricLines.length - 1].nextTime;
  105. for (let i = 0; i < this.centerOffsetSize; i++) {
  106. this.listAdapter.addData(new LyricLine(' ', last + i, last + i), false);
  107. }
  108. }
  109. this.listAdapter.notifyDataReload();
  110. } else {
  111. this.listAdapter.clear(true);
  112. }
  113. }
  114. this.isLyricEmpty = this.listAdapter.isEmpty();
  115. this.currentIndex = 0;
  116. this.animateToIndex(0);
  117. printW('loadData isEmpty = ' + this.isLyricEmpty);
  118. }
  119. private getAttrFromController() {
  120. this.currentLyric = this.controller.getLyric()
  121. this.textSize = this.controller.getTextSize()
  122. this.blurDegree = this.controller.getBlurDegree()
  123. this.isHightLightCenter = this.controller.getHightLightCenter()
  124. this.lineSpace = this.controller.getLineSpace()
  125. this.textColor = this.controller.getTextColor()
  126. this.textHighlightColor = this.controller.getHighlightColor()
  127. this.textHighlightSize = this.controller.getHighlightScale() * this.textSize
  128. this.isHighlightBold = this.controller.isHighlightBold()
  129. this.animDuration = this.controller.getAnimationDuration()
  130. this.cacheSize = this.controller.getCacheSize()
  131. this.emptyHint = this.controller.getEmptyHint()
  132. this.alignMode = this.controller.getAlignMode()
  133. this.textWeight = this.controller.getTextWeight()
  134. }
  135. aboutToAppear() {
  136. if (this.controller == null) {
  137. throw new Error('The lyric lyricConfig is not set!')
  138. }
  139. this.controller.onDataChangedListener = this.onDataChangedListener
  140. this.controller.onPositionChangedListener = this.onPositionChangedListener
  141. this.controller.onInvalidated = this.onInvalidatedListener
  142. this.getAttrFromController()
  143. // 初始化时将滚动位置设置为顶部
  144. this.scroller.scrollToIndex(0, true, ScrollAlign.START);
  145. }
  146. @Builder
  147. EmptyView() {
  148. Text(this.emptyHint)
  149. .fontSize(this.textSize)
  150. .fontColor(this.textColor)
  151. }
  152. @State blurDegree: number = 3
  153. private calculateBlurFactor(index: number, currentIndex: number): number {
  154. const distance = Math.abs(index - currentIndex);
  155. return Math.min(this.blurDegree, distance * 1.5);// Adjust the blur factor based on distance
  156. }
  157. private calculateOpacityFactor(index: number, currentIndex: number): number {
  158. const distance = Math.abs(index - currentIndex);
  159. return Math.max(0.5, 1 - distance * 0.18); // 透明度随着距离增加而减小
  160. }
  161. // 优化建议代码示例:增加滚动节流
  162. private lastScrollTime: number = 0
  163. private scrollThrottle: number = 100 // 100ms节流
  164. @Builder
  165. LyricListView() {
  166. List({ space: this.lineSpace - 16, scroller: this.scroller }) {
  167. LazyForEach(this.listAdapter, (item: LyricLine, index: number) => {
  168. ListItem() {
  169. Stack() {
  170. Text(item.text)
  171. .fontSize(this.textSize)
  172. .opacity(this.calculateOpacityFactor(index, this.currentIndex))
  173. .blur( this.calculateBlurFactor(index, this.currentIndex))
  174. .textAlign(this.alignMode == 'center' ? TextAlign.Center : TextAlign.Start)
  175. .scale({
  176. x: index == this.currentIndex ? this.controller.getHighlightScale() : 1,
  177. y: index == this.currentIndex ? this.controller.getHighlightScale() : 1,
  178. centerX: this.alignMode == 'center' ? '50%' : 0
  179. })
  180. .fontColor(index == this.currentIndex ? this.textHighlightColor : this.textColor)
  181. // .fontWeight(this.textWeight)
  182. .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold :
  183. this.textWeight)
  184. // .padding({
  185. .animation({ duration: this.animDuration, curve: Curve.FastOutSlowIn })
  186. .width(this.alignMode == 'center'?'100%':'76%')
  187. // if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  188. // && this.enableSeek && this.isUserTouching) {
  189. // Row() {
  190. // Image($r('app.media.cclyric_play'))
  191. // .width(24)
  192. // .height(24)
  193. // .fillColor(this.seekUIColor)
  194. // .objectFit(ImageFit.Fill)
  195. Text(this.scrollDurationText)
  196. .fontSize(this.textSize)
  197. .fontColor(this.seekUIColor)
  198. .textAlign( TextAlign.End)
  199. .width(100)
  200. .visibility(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  201. && this.enableSeek && this.isUserTouching?Visibility.Visible:Visibility.Hidden)
  202. // }.width('100%')
  203. // .justifyContent(FlexAlign.SpaceBetween)
  204. // }
  205. }
  206. .align(Alignment.End)
  207. }
  208. .padding(8)
  209. .border({ radius: 4 })
  210. .backgroundColor(this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0
  211. && this.enableSeek && this.isUserTouching ? '#80a9a9a9' : '#00000000')
  212. .onClick(() => {
  213. if (this.seekUIStyle == 'listItem' && index == this.seekIndex && item.text.length > 0) {
  214. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  215. this.handleSeekAction();
  216. }
  217. }
  218. })
  219. },
  220. (item: LyricLine, index: number) => {
  221. return item.text + '_' + index + '_' + item.beginTime + '_' + item.nextTime
  222. })
  223. }
  224. .width('100%')
  225. .height('100%')
  226. .scrollBar(BarState.Off)
  227. .cachedCount(this.cacheSize)
  228. .visibility(this.isLoadingData ? Visibility.Hidden : Visibility.Visible)
  229. .onScrollIndex((_, __, center) => {
  230. const now = Date.now()
  231. if (now - this.lastScrollTime < this.scrollThrottle) return
  232. this.lastScrollTime = now
  233. if (this.isUserTouching) {
  234. //修复The value of "index" is out of range. It must be >= 0 && <= -1. Received value is: -1
  235. if (center >= 0 && center < this.listAdapter.totalCount()) {
  236. this.seekIndex = center;
  237. let targetPosition = this.listAdapter.getData(center).beginTime;
  238. this.scrollDurationText = duration2text(targetPosition);
  239. } else {
  240. // 处理 center 不在范围内的情况
  241. console.error(`Index out of range: ${center}`);
  242. }
  243. // this.seekIndex = center
  244. // let targetPosition = this.listAdapter.getData(center).beginTime
  245. // this.scrollDurationText = duration2text(targetPosition)
  246. }
  247. })
  248. .onTouch((event) => {
  249. let motion = event.touches[0]
  250. switch (motion.type) {
  251. case TouchType.Down:
  252. clearTimeout(this.seekUiHideTimeout)
  253. break
  254. case TouchType.Move:
  255. this.isUserTouching = true
  256. break
  257. case TouchType.Up:
  258. case TouchType.Cancel:
  259. this.seekUiHideTimeout = setTimeout(() => {
  260. this.seekIndex = -1
  261. this.isUserTouching = false
  262. this.animateToIndex(this.currentIndex)
  263. }, this.autoHideSeekUIDuration)
  264. }
  265. })
  266. }
  267. private handleSeekAction() {
  268. clearTimeout(this.seekUiHideTimeout);
  269. let targetPosition = this.listAdapter.getData(this.seekIndex).beginTime;
  270. let isPlayerHandled = this.onSeekAction(targetPosition);
  271. if (!isPlayerHandled) {
  272. this.animateToIndex(this.currentIndex);
  273. }
  274. this.isUserTouching = false;
  275. }
  276. @Builder
  277. SeekLine() {
  278. Row() {
  279. Image($r('app.media.cclyric_play'))
  280. .width(24)
  281. .height(24)
  282. .fillColor(this.seekUIColor)
  283. .objectFit(ImageFit.Fill)
  284. .clickEffect({ level: ClickEffectLevel.MIDDLE })
  285. .onClick(() => {
  286. if (this.seekIndex != -1 && this.seekIndex != this.currentIndex) {
  287. this.handleSeekAction()
  288. }
  289. })
  290. Stack()
  291. .height(1)
  292. .layoutWeight(1)
  293. .backgroundColor(this.seekLineColor)
  294. .margin({ left: 8, right: 8 })
  295. Text(this.scrollDurationText)
  296. .fontSize(this.textSize)
  297. .fontColor(this.seekUIColor)
  298. }
  299. .visibility(this.enableSeek && this.isUserTouching ? Visibility.Visible : Visibility.Hidden)
  300. .width('100%')
  301. .height('100%')
  302. .hitTestBehavior(HitTestMode.Transparent)
  303. .transition(TransitionEffect.OPACITY.animation({ duration: this.animDuration }))
  304. }
  305. build() {
  306. Stack() {
  307. if (this.isLyricEmpty) {
  308. this.EmptyView()
  309. } else {
  310. this.LyricListView()
  311. }
  312. if (this.seekUIStyle == 'seekLine') {
  313. this.SeekLine()
  314. }
  315. }
  316. .width('100%')
  317. .height('100%')
  318. .onAreaChange((_, newSize) => {
  319. printW('onSizeChanged: ' + JSON.stringify(newSize))
  320. this.h = newSize.height as number
  321. this.w = newSize.width as number
  322. let lineH = this.textSize + this.lineSpace
  323. this.centerOffsetSize = Math.floor((this.h - lineH) / 2 / lineH)
  324. printW('offsetSize = ' + this.centerOffsetSize)
  325. if (this.currentLyric) {
  326. this.loadData(this.currentLyric)
  327. }
  328. })
  329. }
  330. aboutToDisappear() {
  331. clearTimeout(this.loadTimeout)
  332. clearTimeout(this.seekUiHideTimeout)
  333. }
  334. private getIndex(position: number): number {
  335. let size = this.listAdapter.totalCount()
  336. if (size === 0) return 0 // 空列表保护
  337. let first = this.listAdapter.getData(0).beginTime
  338. if (position < first) {
  339. return 0
  340. }
  341. let last = this.listAdapter.getData(size - 1).beginTime
  342. if (position > last) {
  343. return size - 1
  344. }
  345. for (let i = 0; i < size - 1; i++) {
  346. let line = this.listAdapter.getData(i)
  347. if (position >= line.beginTime && position < line.nextTime) {
  348. return i
  349. }
  350. }
  351. return this.currentIndex
  352. }
  353. private animateToIndex(index: number) {
  354. printD('animate to index= ' + index)
  355. this.currentIndex = index
  356. if (this.isUserTouching) {
  357. return
  358. }
  359. if(this.isHightLightCenter){
  360. this.scroller.scrollToIndex(index, true, ScrollAlign.CENTER)
  361. }else{
  362. // 计算目标索引,使其在居中位置上方有两条歌词
  363. const targetIndex = Math.max(0, index - 2);
  364. this.scroller.scrollToIndex(targetIndex, true, ScrollAlign.START);
  365. }
  366. }
  367. private onPositionChanged(mediaPosition: number) {
  368. if (this.isLyricEmpty) {
  369. printW('The lyric data is empty!')
  370. return
  371. }
  372. if (this.listAdapter.isEmpty()) {
  373. printW('The lyric lines is empty!')
  374. return
  375. }
  376. let index = this.getIndex(mediaPosition)
  377. if (index != this.currentIndex) {
  378. this.animateToIndex(index)
  379. }
  380. }
  381. }