LyricView.ets 14 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374
  1. import { LyricLineWrapper } from '../bean/LyricLineWrapper';
  2. import { printD, printW } from '../extensions/Extension';
  3. import animator, { AnimatorResult } from '@ohos.animator';
  4. import { LyricController } from '../LyricController';
  5. import { Lyric } from '../bean/Lyric';
  6. /**
  7. * A component to display the lyric with scroll animation.
  8. *
  9. * The custom setter include the lyric text style, fade style of edge, the line space, scroll animation duration,
  10. * the cache size to draw out of screen.
  11. */
  12. @Component
  13. export struct LyricView {
  14. /**
  15. * The lyricConfig for LyricView.
  16. */
  17. controller: LyricController = new LyricController()
  18. private lyricLines = new Array<LyricLineWrapper>() // the lyric lines list.
  19. readonly settings = new RenderingContextSettings(true)
  20. readonly canvasCtx = new CanvasRenderingContext2D(this.settings) // paint to draw.
  21. private highLightTextSize = 0
  22. private highLightDrawOffset = 0
  23. private w = 0 // the width of this view.
  24. private h = 0 // the height of this view.
  25. private centerH = 0
  26. private centerW = 0
  27. private scrollY = 0 // the total scroll offset for current timestamp.
  28. private linearGradientEdge = 150 // the size of linear gradient at top and bottom.
  29. private targetScrollY = 0 // the target end scrollY of the animation.
  30. private centerYOffset = 0 // the center y of the lyric view.
  31. private currentIndex = 0 // the focused index of the lyric line list.
  32. private firstVisibleIndex = 0 // the first visible item index at top.
  33. private lastVisibleIndex = 0 // the last visible item index at bottom.
  34. private topOverOffIndex = -1 // the index of highlight item when has one more item out off the top.
  35. private anim: AnimatorResult | null = null
  36. private isAnimRunning = false
  37. private isEmpty = false // is no lyric.
  38. private lyric: Lyric | null = null // current lyric.
  39. private onDataChangedListener = (lyric: Lyric | null) => {
  40. this.lyric = lyric
  41. this.initSize()
  42. }
  43. private onPositionChangedListener = (mediaPosition: number) => {
  44. this.onPositionChanged(mediaPosition)
  45. }
  46. private onInvalidatedListener = (reLayout: boolean) => {
  47. if (reLayout) {
  48. this.initSize()
  49. }
  50. this.initPaint()
  51. if (reLayout && this.currentIndex > 0) {
  52. this.animateToIndex(this.currentIndex, true)
  53. } else {
  54. this.invalidate()
  55. }
  56. }
  57. aboutToAppear() {
  58. if (this.controller == null) {
  59. throw new Error("The lyric lyricConfig is not set!")
  60. }
  61. this.controller.onDataChangedListener = this.onDataChangedListener
  62. this.controller.onPositionChangedListener = this.onPositionChangedListener
  63. this.controller.onInvalidated = this.onInvalidatedListener
  64. this.lyric = this.controller.getLyric()
  65. this.anim = animator.create({
  66. duration: this.controller.getAnimationDuration(),
  67. easing: "ease-in-out",
  68. delay: 0,
  69. fill: "forwards",
  70. direction: "normal",
  71. iterations: 1,
  72. begin: this.scrollY,
  73. end: this.targetScrollY
  74. })
  75. this.anim.oncancel = () => {
  76. this.isAnimRunning = false
  77. }
  78. this.anim.onfinish = () => {
  79. this.isAnimRunning = false
  80. }
  81. this.anim.onframe = (value) => {
  82. this.scrollY = value
  83. this.invalidate()
  84. }
  85. }
  86. build() {
  87. Canvas(this.canvasCtx)
  88. .onAreaChange((oldSize, newSize) => {
  89. if (oldSize.width == newSize.width && oldSize.height == newSize.height) {
  90. printW("The size is not changed.")
  91. return
  92. }
  93. this.w = newSize.width as number
  94. this.h = newSize.height as number
  95. printD("onSizeChanged: canvas size= " + this.w + " * " + this.h)
  96. this.centerW = this.w / 2
  97. this.centerH = this.h / 2
  98. this.initPaint()
  99. this.initSize()
  100. })
  101. .width("100%")
  102. .height("100%")
  103. }
  104. private initPaint() {
  105. this.canvasCtx.fillStyle = this.controller.getTextColor()
  106. this.canvasCtx.strokeStyle = this.controller.getTextColor()
  107. this.canvasCtx.lineWidth = 1
  108. this.canvasCtx.font = vp2px(this.controller.getTextSize()) + "px"
  109. // TODO: the center textAlign has bugs when multi line in OpenHarmony 3.2
  110. if (this.controller.getAlignMode() == "center") {
  111. this.canvasCtx.textAlign = "center"
  112. }
  113. this.highLightTextSize = fp2px(this.controller.getTextSize() * this.controller.getHighlightScale())
  114. this.highLightDrawOffset = fp2px(this.controller.getTextSize()) * (this.controller.getHighlightScale() - 1) / 2
  115. }
  116. private initSize() {
  117. this.anim!.cancel()
  118. // reset state
  119. this.currentIndex = 0
  120. this.lyricLines = []
  121. this.isEmpty = this.lyric == null || this.lyric.lyricList.length <= 0
  122. // no lyric, draw empty hint
  123. if (this.isEmpty) {
  124. this.invalidate()
  125. return
  126. }
  127. this.scrollY = this.h / 2 // layout from center when first display
  128. let tempH = 0
  129. let list = this.lyric!.lyricList
  130. for (let i = 0; i < list.length; i++) {
  131. let lrc = list[i]
  132. let textMeasure = this.canvasCtx.measureText(lrc.text)
  133. let lp = textMeasure.width / this.w
  134. let lines = lp
  135. if (textMeasure.width % this.w != 0) {
  136. lines = lp < 1 ? 1 : Math.floor(lp) + 1
  137. }
  138. let lineHeight = lines * textMeasure.height
  139. if (i == 0) {
  140. tempH = Math.round(textMeasure.height / 2)
  141. this.centerYOffset = tempH
  142. }
  143. if (tempH < this.h / 2) {
  144. tempH += lineHeight + this.controller.getLineSpace()
  145. } else {
  146. if (this.topOverOffIndex == -1) {
  147. this.topOverOffIndex = i - 1 // if current index is topOverOffIndex, top off screen
  148. printW("init: topOverIndex= " + this.topOverOffIndex)
  149. }
  150. }
  151. this.lyricLines.push(new LyricLineWrapper(lrc, lineHeight))
  152. }
  153. this.firstVisibleIndex = this.getFirstVisibleIndex(this.currentIndex)
  154. this.lastVisibleIndex = this.getLastVisibleIndex(this.currentIndex)
  155. this.invalidate()
  156. }
  157. private invalidate() {
  158. // clear canvas
  159. this.canvasCtx.clearRect(0, 0, this.w, this.h)
  160. if (!this.isEmpty) {
  161. // draw lrc text
  162. this.drawLrc()
  163. // this.drawEdge()
  164. } else {
  165. this.drawEmpty()
  166. }
  167. }
  168. private drawEmpty() {
  169. let hint = this.controller.getEmptyHint()
  170. if (this.controller.getAlignMode() == "center") {
  171. this.canvasCtx.fillText(hint, this.centerW, this.centerH)
  172. } else {
  173. this.canvasCtx.fillText(hint, 0, this.centerH)
  174. }
  175. }
  176. private drawLrc() {
  177. let startY = this.centerYOffset // half of the first line measure text height
  178. this.canvasCtx.save()
  179. this.canvasCtx.translate(0, this.scrollY)
  180. // the draw count is the max visible lines in screen + 4 caches(2 top & 2 bottom)
  181. for (let i = 0; i < this.lyricLines.length; i++) {
  182. let lrc = this.lyricLines[i]
  183. // out the top of screen, the default cache is 2 lines
  184. let space = this.controller.getLineSpace()
  185. if (i < this.firstVisibleIndex - this.controller.getCacheSize()) {
  186. startY += (lrc.height + space)
  187. continue
  188. }
  189. // out the bottom of screen, the default cache is 2 lines
  190. if (i > this.lastVisibleIndex + this.controller.getCacheSize()) {
  191. break
  192. }
  193. // draw with high light for current line
  194. if (i == this.currentIndex) {
  195. let scaleHeight = this.drawHighLight(lrc, startY)
  196. startY += (scaleHeight + space + this.highLightDrawOffset)
  197. } else {
  198. if (this.controller.getAlignMode() == "center") {
  199. this.canvasCtx.fillText(lrc.text, this.centerW, startY)
  200. } else {
  201. this.canvasCtx.fillText(lrc.text, 0, startY)
  202. }
  203. startY += (lrc.height + space)
  204. }
  205. }
  206. this.canvasCtx.restore()
  207. }
  208. private drawHighLight(lrc: LyricLineWrapper, startY: number): number {
  209. this.canvasCtx.save()
  210. this.canvasCtx.font = this.controller.isHighlightBold() ?
  211. this.highLightTextSize + "px" + " bold" : this.highLightTextSize + "px"
  212. this.canvasCtx.fillStyle = this.controller.getHighlightColor()
  213. if (this.controller.getAlignMode() == "center") {
  214. this.canvasCtx.fillText(lrc.text, this.centerW, startY + this.highLightDrawOffset)
  215. } else {
  216. this.canvasCtx.fillText(lrc.text, 0, startY + this.highLightDrawOffset)
  217. }
  218. let textMeasure = this.canvasCtx.measureText(lrc.text)
  219. let height = Math.round(textMeasure.height)
  220. let lp = textMeasure.width / this.w
  221. let lines = lp
  222. if (textMeasure.width % this.w != 0) {
  223. lines = lp < 1 ? 1 : Math.floor(lp) + 1
  224. }
  225. let scaleHeight = height * lines
  226. this.canvasCtx.restore()
  227. return scaleHeight
  228. }
  229. /**
  230. * @deprecated since v1.0.2
  231. */
  232. private drawEdge() {
  233. this.canvasCtx.save()
  234. let topFade = this.canvasCtx.createLinearGradient(this.centerW, 0, this.centerW, this.linearGradientEdge)
  235. topFade.addColorStop(0.0, this.controller.getEdgeColor())
  236. topFade.addColorStop(1.0, '#00ffffff')
  237. this.canvasCtx.fillStyle = topFade
  238. this.canvasCtx.fillRect(0, 0, this.w, this.linearGradientEdge)
  239. let bottomFade = this.canvasCtx.createLinearGradient(this.centerW, this.h, this.centerW, this.h - this.linearGradientEdge)
  240. bottomFade.addColorStop(0.0, this.controller.getEdgeColor())
  241. bottomFade.addColorStop(1.0, '#00ffffff')
  242. this.canvasCtx.fillStyle = bottomFade
  243. this.canvasCtx.fillRect(0, this.h - this.linearGradientEdge, this.w, this.linearGradientEdge)
  244. this.canvasCtx.restore()
  245. }
  246. private getIndex(position: number): number {
  247. let first = this.lyricLines[0].beginTime
  248. if (position < first) {
  249. return 0
  250. }
  251. let last = this.lyricLines[this.lyricLines.length - 1].beginTime
  252. if (position > last) {
  253. return this.lyricLines.length - 1
  254. }
  255. for (let i = 0; i < this.lyricLines.length - 1; i++) {
  256. let line = this.lyricLines[i]
  257. if (position >= line.beginTime && position < line.nextTime) {
  258. return i
  259. }
  260. }
  261. return this.currentIndex
  262. }
  263. private getFirstVisibleIndex(index: number): number {
  264. if (index > this.topOverOffIndex) {
  265. let tempH = 0
  266. for (let i = index; i > 0; i--) {
  267. let lrc = this.lyricLines[i]
  268. tempH += (lrc.height + this.controller.getLineSpace())
  269. if (tempH > this.centerH) {
  270. return i
  271. }
  272. }
  273. }
  274. return 0
  275. }
  276. private getLastVisibleIndex(index: number): number {
  277. let tempH = 0
  278. for (let i = index; i < this.lyricLines.length; i++) {
  279. let lrc = this.lyricLines[i]
  280. tempH += (lrc.height + this.controller.getLineSpace())
  281. if (tempH > this.centerH) {
  282. return i
  283. }
  284. }
  285. return this.lyricLines.length - 1
  286. }
  287. private animateToIndex(index: number, focusAnimate: boolean = false) {
  288. printD("animate to index= " + index)
  289. if (index == this.currentIndex && !focusAnimate) {
  290. printD("index not changed, not animate!")
  291. return
  292. }
  293. // out of range, return
  294. if (index > this.lyricLines.length - 1 || index < 0) {
  295. printD("out of range, not animate!")
  296. return
  297. }
  298. this.firstVisibleIndex = this.getFirstVisibleIndex(index)
  299. this.lastVisibleIndex = this.getLastVisibleIndex(index)
  300. let animOffset = 0
  301. let isUp = index > this.currentIndex
  302. if (isUp) { // to next
  303. for (let i = this.currentIndex; i < index; i++) {
  304. let lrcH = this.lyricLines[i].height
  305. animOffset += lrcH + this.controller.getLineSpace()
  306. }
  307. } else { // to pre
  308. for (let i = index; i < this.currentIndex; i++) {
  309. let lrcH = this.lyricLines[i].height
  310. animOffset += lrcH + this.controller.getLineSpace()
  311. }
  312. }
  313. this.currentIndex = index
  314. printD("firstVisible= " + this.firstVisibleIndex + ", lastVisible= " + this.lastVisibleIndex + ", currentHighLight= " +
  315. this.currentIndex)
  316. // the animation is running, end it
  317. if (this.isAnimRunning) {
  318. this.anim!.cancel()
  319. this.scrollY = this.targetScrollY
  320. this.invalidate()
  321. printW("intercept and end anim!")
  322. }
  323. // at end, stop scroll
  324. if (this.currentIndex > this.lyricLines.length - 1) {
  325. return
  326. }
  327. // get the offset of one frame
  328. this.targetScrollY = Math.round(isUp ? this.scrollY - animOffset : this.scrollY + animOffset)
  329. // start animate
  330. let startY = this.scrollY
  331. printD(">>start anim " + startY + " -> " + this.targetScrollY)
  332. this.anim!.reset({
  333. duration: this.controller.getAnimationDuration(),
  334. easing: "ease-in-out",
  335. delay: 0,
  336. fill: "forwards",
  337. direction: "normal",
  338. iterations: 1,
  339. begin: startY,
  340. end: this.targetScrollY
  341. })
  342. this.anim!.play()
  343. this.isAnimRunning = true
  344. }
  345. private onPositionChanged(mediaPosition: number) {
  346. if (this.isEmpty) {
  347. printW("The lyric data is empty!")
  348. return
  349. }
  350. if (this.lyricLines.length <= 0) {
  351. printW("The lyric lines is empty!")
  352. return
  353. }
  354. let index = this.getIndex(mediaPosition)
  355. if (index != this.currentIndex) {
  356. this.animateToIndex(index)
  357. }
  358. }
  359. }