MusicPlayerWidgetCard.ets 7.9 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241
  1. import { MusicCardActionConstants } from '../../common/player/MusicCardActionConstants'
  2. import Logger from '../../common/util/Logger'
  3. import {
  4. buildMusicCardWidgetActionParams,
  5. clampMusicCardWidgetProgress,
  6. resolveMusicCardWidgetCoverSource,
  7. shouldDispatchMusicCardActionByCall
  8. } from '../common/MusicPlayerWidgetHelper'
  9. const cardStorage: LocalStorage = new LocalStorage()
  10. const ENTRY_ABILITY_NAME = 'EntryAbility'
  11. const TAG = 'MusicPlayerWidgetCard'
  12. @Entry(cardStorage)
  13. @Component
  14. struct MusicPlayerWidgetCard {
  15. @LocalStorageProp('formId') formId: string = ''
  16. @LocalStorageProp('title') title: string = '未在播放'
  17. @LocalStorageProp('artist') artist: string = ''
  18. @LocalStorageProp('coverPath') coverPath: string = ''
  19. @LocalStorageProp('coverImageName') coverImageName: string = ''
  20. @LocalStorageProp('hasCoverImage') hasCoverImage: boolean = false
  21. @LocalStorageProp('hasSong') hasSong: boolean = false
  22. @LocalStorageProp('isPlaying') isPlaying: boolean = false
  23. @LocalStorageProp('currentPositionMs') @Watch('syncSliderFromPlayback') currentPositionMs: number = 0
  24. @LocalStorageProp('durationMs') @Watch('syncSliderFromPlayback') durationMs: number = 0
  25. @LocalStorageProp('currentTimeText') currentTimeText: string = '00:00'
  26. @LocalStorageProp('durationTimeText') durationTimeText: string = '00:00'
  27. @State isChangeBg: boolean = true
  28. @State sliderProgressMs: number = 0
  29. /**
  30. * 组件挂载时记录当前卡片状态,并同步一次进度条显示。
  31. */
  32. aboutToAppear(): void {
  33. Logger.info(TAG,
  34. `musicCard small aboutToAppear formId=${this.formId}, title=${this.title}, artist=${this.artist}, ` +
  35. `coverImageName=${this.coverImageName}, coverPath=${this.coverPath}, hasCoverImage=${this.hasCoverImage}, ` +
  36. `hasSong=${this.hasSong}, isPlaying=${this.isPlaying}, currentPositionMs=${this.currentPositionMs}, ` +
  37. `durationMs=${this.durationMs}`)
  38. this.syncSliderFromPlayback()
  39. }
  40. /**
  41. * 根据当前播放进度与总时长刷新本地进度值,避免越界。
  42. */
  43. private syncSliderFromPlayback(): void {
  44. this.sliderProgressMs = clampMusicCardWidgetProgress(this.currentPositionMs, this.durationMs)
  45. }
  46. /**
  47. * 向卡片宿主发送播放控制动作,按动作类型选择 call 或 router 分发。
  48. */
  49. private postControlAction(action: string): void {
  50. Logger.info(TAG, `musicCard small postControlAction formId=${this.formId}, action=${action}`)
  51. const useCallDispatch = shouldDispatchMusicCardActionByCall(action)
  52. const params = buildMusicCardWidgetActionParams(this.formId, action)
  53. postCardAction(this, {
  54. action: useCallDispatch ? 'call' : 'router',
  55. abilityName: ENTRY_ABILITY_NAME,
  56. params
  57. })
  58. }
  59. /**
  60. * 通过 router 方式跳转到主界面,并携带卡片来源与动作参数。
  61. */
  62. private postRouterAction(action: string): void {
  63. Logger.info(TAG, `musicCard small postRouterAction formId=${this.formId}, action=${action}`)
  64. postCardAction(this, {
  65. action: 'router',
  66. abilityName: ENTRY_ABILITY_NAME,
  67. params: {
  68. ttmusic_music_card_form_id: this.formId,
  69. ttmusic_music_card_action: action,
  70. ttmusic_music_card_source: MusicCardActionConstants.ACTION_SOURCE_WIDGET
  71. }
  72. })
  73. }
  74. /**
  75. * 解析卡片封面的实际数据源,优先使用卡片中已缓存的封面资源。
  76. */
  77. private resolveCoverSource(): string | Resource {
  78. return resolveMusicCardWidgetCoverSource(this.coverImageName, this.coverPath, this.hasCoverImage)
  79. }
  80. /**
  81. * 将毫秒级播放进度换算为环形进度条使用的百分比值。
  82. */
  83. private resolveProgressValue(): number {
  84. if (this.durationMs <= 0) {
  85. return 0
  86. }
  87. return Math.min(100, Math.max(0, Math.floor(this.sliderProgressMs * 100 / this.durationMs)))
  88. }
  89. /**
  90. * 拼接标题与歌手文本,缺省时返回占位文案。
  91. */
  92. private resolveTitleArtistText(): string {
  93. const safeTitle = this.title?.trim() ?? ''
  94. const safeArtist = this.artist?.trim() ?? ''
  95. if (safeTitle !== '' && safeArtist !== '') {
  96. return `${safeTitle} - ${safeArtist}`
  97. }
  98. if (safeTitle !== '') {
  99. return safeTitle
  100. }
  101. if (safeArtist !== '') {
  102. return safeArtist
  103. }
  104. return '未在播放'
  105. }
  106. /**
  107. * 根据当前播放状态返回播放或暂停图标。
  108. */
  109. private resolvePlayPauseIcon(): Resource {
  110. return this.isPlaying ? $r('sys.symbol.pause_fill') : $r('sys.symbol.play_fill')
  111. }
  112. /**
  113. * 构建上一首、下一首等通用控制按钮。
  114. */
  115. @Builder
  116. private ControlButton(imageResource: Resource, action: string, buttonSize: number, symbolSize: number) {
  117. Button() {
  118. SymbolGlyph(imageResource)
  119. .fontSize(symbolSize)
  120. .fontColor([Color.White])
  121. .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
  122. }
  123. .width(buttonSize * 1.35)
  124. .height(buttonSize * 1.35)
  125. .backgroundColor(Color.Transparent)
  126. .type(ButtonType.Circle)
  127. .stateEffect(true)
  128. .onClick(() => this.postControlAction(action))
  129. }
  130. /**
  131. * 构建带环形进度指示的播放/暂停按钮。
  132. */
  133. @Builder
  134. private PlayProgressButton() {
  135. Stack({ alignContent: Alignment.Center }) {
  136. Progress({ value: this.resolveProgressValue(), total: 100, type: ProgressType.Ring })
  137. .width(34)
  138. .height(34)
  139. .color(Color.White)
  140. .style({ strokeWidth: 2 })
  141. Button() {
  142. SymbolGlyph(this.resolvePlayPauseIcon())
  143. .fontSize(19)
  144. .fontColor([Color.White])
  145. .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
  146. }
  147. .width(34)
  148. .height(34)
  149. .type(ButtonType.Circle)
  150. .backgroundColor('#26FFFFFF')
  151. .stateEffect(false)
  152. .onClick(() => this.postControlAction(MusicCardActionConstants.ACTION_PLAY_PAUSE))
  153. }
  154. .width(34)
  155. .height(34)
  156. }
  157. /**
  158. * 判断当前是否具备可用于背景展示的封面资源。
  159. */
  160. private hasCoverBackground(): boolean {
  161. if (this.hasCoverImage && this.coverImageName.length > 0) {
  162. return true;
  163. }
  164. return this.coverPath.length > 0;
  165. }
  166. /**
  167. * 构建音乐播放卡片主体界面,并绑定卡片交互事件。
  168. */
  169. build() {
  170. Stack() {
  171. if (this.hasCoverBackground()&&this.isChangeBg) {
  172. Column()
  173. .width('100%')
  174. .height('100%')
  175. .backgroundImage(this.resolveCoverSource())
  176. .backgroundImageSize({ width: '100%' })
  177. .backgroundBlurStyle(BlurStyle.BACKGROUND_ULTRA_THICK)
  178. } else {
  179. Column()
  180. .width('100%')
  181. .height('100%')
  182. .linearGradient( { direction: GradientDirection.Right, colors:[
  183. ['#00BC70',0.0],['#D81B60',1.0]]})
  184. }
  185. Column({ space: 12 }) {
  186. Image(this.resolveCoverSource())
  187. .width(60)
  188. .height(60)
  189. .borderRadius(6)
  190. .objectFit(ImageFit.Cover)
  191. .onClick(()=>{
  192. this.isChangeBg = !this.isChangeBg
  193. })
  194. Text(this.resolveTitleArtistText())
  195. .fontSize(13)
  196. .fontWeight(FontWeight.Bold)
  197. .fontColor(Color.White)
  198. .maxLines(1)
  199. .textAlign(TextAlign.Center)
  200. .textOverflow({ overflow: TextOverflow.Ellipsis })
  201. Row({ space: 16 }) {
  202. this.ControlButton($r('sys.symbol.backward_end_fill'),
  203. MusicCardActionConstants.ACTION_PREVIOUS, 18, 18)
  204. this.PlayProgressButton()
  205. this.ControlButton($r('sys.symbol.forward_end_fill'),
  206. MusicCardActionConstants.ACTION_NEXT, 18, 18)
  207. }
  208. .justifyContent(FlexAlign.Center)
  209. .alignItems(VerticalAlign.Center)
  210. }
  211. .width('100%')
  212. .height('100%')
  213. .alignItems(HorizontalAlign.Center)
  214. .justifyContent(FlexAlign.Center)
  215. .padding({ left: 16, right: 16, top: 16, bottom: 14 })
  216. }
  217. .width('100%')
  218. .height('100%')
  219. .clip(true)
  220. .onClick(() => this.postRouterAction(MusicCardActionConstants.ACTION_OPEN_PLAYER))
  221. }
  222. }