PlayerControls.ets 11 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367
  1. import { PlayStatus } from '../../common/PlayStatus'
  2. import { PointLightButton } from '../PointLight/PointLightButton'
  3. import { PointLightDefaultButton } from '../PointLight/PointLightDeFaultButton'
  4. @Component
  5. export struct PlayerControls {
  6. // 播控支持紧凑模式,给横屏封面页复用同一套独立组件。
  7. @Prop compactMode: boolean = false
  8. // 整体透明度和底部偏移由外部页面控制,避免组件再持有布局状态。
  9. @Prop controlOpacity: number = 1
  10. @Prop bottomOffset: number = 70
  11. // 是否展示底部功能操作区,HiCar 等场景沿用原有隐藏逻辑。
  12. @Prop showBottomRow: boolean = true
  13. // 播控区需要的播放态、进度态都作为纯数据输入。
  14. @Prop isLandscape: boolean = false
  15. @Prop progressValue: number = 0
  16. @Prop progressMaxValue: number = 100
  17. @Prop slideEnable: boolean = false
  18. @Prop currentTime: string = '00:00'
  19. @Prop totalTime: string = '00:00'
  20. @Prop controlPlayStatus: number = PlayStatus.INIT
  21. @Prop isCircleBtn: boolean = false
  22. @Prop isPlayerLoading: boolean = false
  23. @Prop playType: number = 0
  24. @Prop fastForwardSeconds: string = '10'
  25. @Prop isShowBackFast: boolean = false
  26. // 编辑、收藏等按钮是否展示,由外部决定当前歌曲是否可操作。
  27. @Prop showEditButton: boolean = false
  28. @Prop isFavorite: boolean = false
  29. // 频谱区域也改成可注入 builder,后续可以继续抽到独立组件而不动页面骨架。
  30. @BuilderParam spectrumBuilder: () => void
  31. // 播控内部只触发动作,不再持有具体业务逻辑。
  32. onPlayPrevious: () => void = (): void => {}
  33. onPlayOrPause: () => void = (): void => {}
  34. onPlayNext: () => void = (): void => {}
  35. onToggleMore: () => void = (): void => {}
  36. onSetLoopMode: () => void = (): void => {}
  37. onSeek: (value: number) => void = (_value: number): void => {}
  38. onBackFast: () => void = (): void => {}
  39. onForwardFast: () => void = (): void => {}
  40. onRotate: () => void = (): void => {}
  41. onToggleEdit: () => void = (): void => {}
  42. onToggleEqualizer: () => void = (): void => {}
  43. onToggleFavorite: () => void = (): void => {}
  44. onOpenPlaylist: () => void = (): void => {}
  45. private resolvePlayModeIcon(): Resource {
  46. // 播放模式图标统一在独立组件里收口,减少 LocalMusic 内部判断分支。
  47. if (this.playType === 0) {
  48. return $r('app.media.loop')
  49. }
  50. if (this.playType === 1) {
  51. return $r('app.media.single')
  52. }
  53. if (this.playType === 2) {
  54. return $r('app.media.normal_play')
  55. }
  56. if (this.playType === 3) {
  57. return $r('app.media.random')
  58. }
  59. return $r('app.media.noloop')
  60. }
  61. private resolveFastForwardText(): string {
  62. // 统一把快进秒数当成字符串展示,避免上层状态类型变化时影响 UI 组件。
  63. return this.fastForwardSeconds
  64. }
  65. @Builder
  66. private MoreButtonContent() {
  67. // 更多按钮沿用原有资源,保持现有视觉风格不变。
  68. Image($r('app.media.menu'))
  69. .width(24)
  70. }
  71. @Builder
  72. private PreviousButtonContent() {
  73. Image($r('app.media.ic_previous'))
  74. .width(32)
  75. .aspectRatio(1)
  76. }
  77. @Builder
  78. private NextButtonContent() {
  79. Image($r('app.media.ic_next'))
  80. .width(32)
  81. .aspectRatio(1)
  82. }
  83. @Builder
  84. private PlayOrPauseButtonContent() {
  85. Stack() {
  86. // 播放暂停按钮单独保留加载圈,兼容远程歌曲缓冲场景。
  87. Image(this.controlPlayStatus === PlayStatus.PLAY
  88. ? (this.isCircleBtn ? $r('app.media.hm_pause') : $r('app.media.ic_public_play'))
  89. : (this.isCircleBtn ? $r('app.media.hm_play2') : $r('app.media.ic_public_pause')))
  90. .width(this.isLandscape ? 38 : 40)
  91. .fillColor(Color.White)
  92. .aspectRatio(1)
  93. if (this.isPlayerLoading) {
  94. Progress({ value: 0, total: 100, type: ProgressType.Ring })
  95. .width(this.isLandscape ? 40 : 43)
  96. .height(this.isLandscape ? 40 : 43)
  97. .color(Color.White)
  98. .style({ strokeWidth: 5, status: ProgressStatus.LOADING })
  99. }
  100. }
  101. .width(this.isLandscape ? 38 : 41)
  102. .height(this.isLandscape ? 38 : 41)
  103. }
  104. @Builder
  105. private CenterControls() {
  106. Row() {
  107. PointLightButton({
  108. builder: () => {
  109. this.MoreButtonContent()
  110. },
  111. isPx: false,
  112. builderHeight: 26,
  113. builderWidth: 26,
  114. })
  115. .onClick(() => {
  116. this.onToggleMore()
  117. })
  118. PointLightButton({
  119. builder: () => {
  120. this.PreviousButtonContent()
  121. },
  122. isPx: false,
  123. builderHeight: 26,
  124. builderWidth: 26,
  125. })
  126. .onClick(() => {
  127. this.onPlayPrevious()
  128. })
  129. PointLightButton({
  130. builder: () => {
  131. this.PlayOrPauseButtonContent()
  132. },
  133. isPx: false,
  134. builderHeight: this.isLandscape ? 38 : 41,
  135. builderWidth: this.isLandscape ? 38 : 41,
  136. })
  137. .onClick(() => {
  138. this.onPlayOrPause()
  139. })
  140. PointLightButton({
  141. builder: () => {
  142. this.NextButtonContent()
  143. },
  144. isPx: false,
  145. builderHeight: 26,
  146. builderWidth: 26,
  147. })
  148. .onClick(() => {
  149. this.onPlayNext()
  150. })
  151. PointLightButton({
  152. builder: () => {
  153. Image(this.resolvePlayModeIcon())
  154. .width(24)
  155. .aspectRatio(1)
  156. },
  157. isPx: false,
  158. builderHeight: 26,
  159. builderWidth: 26,
  160. })
  161. .onClick(() => {
  162. this.onSetLoopMode()
  163. })
  164. }
  165. .width('95%')
  166. .justifyContent(FlexAlign.SpaceEvenly)
  167. }
  168. @Builder
  169. private ProgressControls() {
  170. Column() {
  171. Row() {
  172. Stack() {
  173. SymbolGlyph($r('sys.symbol.arrow_counterclockwise'))
  174. .fontColor([Color.White])
  175. .fontSize(21)
  176. .effectStrategy(1)
  177. Text(this.resolveFastForwardText())
  178. .fontColor(Color.White)
  179. .fontSize(10)
  180. .fontWeight(FontWeight.Bold)
  181. }
  182. .padding({ left: 12, bottom: 6 })
  183. .visibility(this.isShowBackFast ? Visibility.Visible : Visibility.None)
  184. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  185. .onClick(() => {
  186. this.onBackFast()
  187. })
  188. Slider({
  189. value: this.progressValue,
  190. min: 0,
  191. max: this.progressMaxValue,
  192. step: 1,
  193. style: SliderStyle.InSet
  194. })
  195. .height(20)
  196. .blockColor('rgba(255,255,255,1)')
  197. .trackColor('rgba(255,255,255,0.3)')
  198. .selectedColor(Color.White)
  199. .trackThickness(4)
  200. .showSteps(false)
  201. .showTips(true)
  202. .layoutWeight(1)
  203. .enabled(this.slideEnable)
  204. .onChange((value: number, mode: SliderChangeMode) => {
  205. if (mode !== 2) {
  206. return
  207. }
  208. this.onSeek(value)
  209. })
  210. Stack() {
  211. SymbolGlyph($r('sys.symbol.arrow_clockwise'))
  212. .fontColor([Color.White])
  213. .fontSize(21)
  214. .effectStrategy(1)
  215. Text(this.resolveFastForwardText())
  216. .fontColor(Color.White)
  217. .fontSize(10)
  218. .fontWeight(FontWeight.Bold)
  219. }
  220. .padding({ right: 10, bottom: 6 })
  221. .visibility(this.isShowBackFast ? Visibility.Visible : Visibility.None)
  222. .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.6 })
  223. .onClick(() => {
  224. this.onForwardFast()
  225. })
  226. }
  227. .justifyContent(FlexAlign.Center)
  228. .padding({ left: 20, right: 20 })
  229. .width(this.isLandscape ? '88%' : '95%')
  230. Row() {
  231. Text(this.currentTime)
  232. .fontSize(10)
  233. .fontColor(Color.White)
  234. .margin({ left: 25 })
  235. Blank()
  236. Text(this.totalTime)
  237. .fontSize(10)
  238. .fontColor(Color.White)
  239. .margin({ right: 25 })
  240. }
  241. .width(this.isLandscape ? '85%' : '90%')
  242. .height(10)
  243. }
  244. .width('100%')
  245. .alignItems(HorizontalAlign.Center)
  246. }
  247. @Builder
  248. private BottomActionRow() {
  249. Row() {
  250. PointLightDefaultButton({
  251. isSysBol: true,
  252. pointColor: Color.White,
  253. imageResource: $r('sys.symbol.rectangle_portrait_rotate'),
  254. isPx: false,
  255. builderHeight: 22,
  256. builderWidth: 22,
  257. })
  258. .onClick(() => {
  259. this.onRotate()
  260. })
  261. if (this.showEditButton) {
  262. // 编辑标签只在当前有歌曲时展示,避免空态按钮触发无效动作。
  263. PointLightDefaultButton({
  264. isSysBol: true,
  265. pointColor: Color.White,
  266. imageResource: $r('sys.symbol.rename'),
  267. isPx: false,
  268. builderHeight: 22,
  269. builderWidth: 22,
  270. })
  271. .onClick(() => {
  272. this.onToggleEdit()
  273. })
  274. }
  275. PointLightDefaultButton({
  276. isSysBol: true,
  277. pointColor: Color.White,
  278. imageResource: $r('sys.symbol.slider_vertical_3'),
  279. isPx: false,
  280. builderHeight: 22,
  281. builderWidth: 22,
  282. })
  283. .onClick(() => {
  284. this.onToggleEqualizer()
  285. })
  286. PointLightDefaultButton({
  287. isSysBol: true,
  288. pointColor: Color.White,
  289. imageResource: this.isFavorite ? $r('sys.symbol.heart_fill') : $r('sys.symbol.heart'),
  290. isPx: false,
  291. builderHeight: 22,
  292. builderWidth: 22,
  293. })
  294. .onClick(() => {
  295. this.onToggleFavorite()
  296. })
  297. PointLightDefaultButton({
  298. isSysBol: true,
  299. pointColor: Color.White,
  300. imageResource: $r('sys.symbol.music_note_list'),
  301. isPx: false,
  302. builderHeight: 26,
  303. builderWidth: 26,
  304. })
  305. .onClick(() => {
  306. this.onOpenPlaylist()
  307. })
  308. }
  309. .width(this.isLandscape ? '88%' : '95%')
  310. .justifyContent(FlexAlign.SpaceAround)
  311. .visibility(this.showBottomRow ? Visibility.Visible : Visibility.None)
  312. .height(30)
  313. .animation({
  314. duration: 666,
  315. curve: 'ease-in-out'
  316. })
  317. }
  318. @Builder
  319. private FullControls() {
  320. Column() {
  321. // 频谱显示是否启用由外部 builder 自己判断,组件本身不再持有该业务状态。
  322. this.spectrumBuilder()
  323. this.ProgressControls()
  324. this.CenterControls()
  325. this.BottomActionRow()
  326. }
  327. .justifyContent(FlexAlign.Center)
  328. .alignItems(HorizontalAlign.Center)
  329. .opacity(this.controlOpacity)
  330. .position({ bottom: this.bottomOffset })
  331. }
  332. build() {
  333. if (this.compactMode) {
  334. // 横屏封面区域只复用紧凑播控,不再在 LocalMusic 里额外维护一套 UI。
  335. this.CenterControls()
  336. } else {
  337. // 竖屏和完整播放页统一走独立播控组件。
  338. this.FullControls()
  339. }
  340. }
  341. }