import { PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils' import { PlayStatus } from '../../common/PlayStatus' import { CommonConstants } from '../../common/constants/CommonConstants' import { isPlaybackControlPlaying } from '../../common/player/PlaybackControlStateHelper' import { MusicPlaybackController } from '../../controller/MusicPlaybackController' import { LyricController } from '../../lyric/LyricController' import { Lyric } from '../../lyric/bean/Lyric' import { LyricParser } from '../../lyric/parse/LyricParser' import { LyricView2 } from '../../lyric/view/LyricView2' import { SettingPage } from '../../pages/SettingPage' import { PlaybackViewState, PlaybackViewStateCenter } from '../../playback/PlaybackViewStateCenter' import Logger from '../../common/util/Logger' import { VideoItem } from '../../viewmodel/VideoItem' import { resolvePlayerLyricContent, resolvePlayerTimeTexts } from './MusiPlayerViewStateHelper' import { PointLightButton } from '../PointLight/PointLightButton' import { PointLightDefaultButton } from '../PointLight/PointLightDeFaultButton' const TAG = 'MusicPlayerView' @Component export struct MusicPlayerView { @StorageProp('isLandscape') isLandscape: boolean = false; @StorageProp('topSafeHeight') topSafeHeight: number = 0; @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0; @StorageProp('windowWidth') windowWidth: number = 0; @StorageProp('windowHeight') windowHeight: number = 0; @StorageProp('isHiCarStatus') isHiCarStatus: boolean = false; @StorageProp('curDisplayIsHiCar') curDisplayIsHiCar: boolean = false; onClose: () => void = () => {} @State currentSong: VideoItem | undefined = undefined @State controlPlayStatus: number = PlayStatus.INIT @State progressValue: number = 0 @State playbackPositionMs: number = 0 @State playbackDurationMs: number = 0 @State cover: string | undefined = '' @StorageLink('musicPlayType') playType: number = 0 @StorageLink('playbackIsFavorite') isFavorite: boolean = false @StorageProp('themeColor') themeColor: string = PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR) @State isMusicBGCover: boolean = true @State currentTime: string = '00:00' @State totalTime: string = '00:00' @State currentSwiperIndex: number = 0 @State sliderProgressValue: number = 0 @State showSimple: boolean = false @State rotateAngle: number = 0 @State rotateAngle2: number = -45 @State isRotationRunning: boolean = false @State isPlayerLoading: boolean = false private rotationTimer: number = -1 private playbackController: MusicPlaybackController = MusicPlaybackController.getInstance() private lyricController: LyricController = new LyricController() .setEmptyHint('暂无歌词') .setTextColor('#DDDDDD') .setHighlightColor('#FFFFFF') .setLightText(true) .setAlignMode('center') .setHightLightCenter(true) private lyricParser: LyricParser = new LyricParser() private appliedLyricContent: string = '' private lyricSyncTimer: number = -1 private playbackViewStateCenter: PlaybackViewStateCenter = PlaybackViewStateCenter.getInstance() private readonly playbackStateObserver: (state: PlaybackViewState) => void = (state: PlaybackViewState): void => { this.applyPlaybackViewState(state) } aboutToAppear(): void { this.isMusicBGCover = PreferencesUtil.getBooleanSync(SettingPage.iS_MUSIC_BG_COVER, true) this.showSimple = PreferencesUtil.getBooleanSync('showSimple', false) Logger.info(TAG, '[PLAYER_DETACH] player aboutToAppear subscribe') this.playbackViewStateCenter.subscribe(this.playbackStateObserver) this.startLyricSyncTimer() } aboutToDisappear(): void { Logger.info(TAG, '[PLAYER_DETACH] player aboutToDisappear unsubscribe') this.playbackViewStateCenter.unsubscribe(this.playbackStateObserver) this.stopLyricSyncTimer() this.stopRotation() } private syncTimeTexts(): void { const timeTexts = resolvePlayerTimeTexts(this.playbackPositionMs, this.playbackDurationMs) this.currentTime = timeTexts.currentTime this.totalTime = timeTexts.totalTime } private applyPlaybackViewState(state: PlaybackViewState): void { const previousSongPath = this.currentSong?.filePath ?? '' const nextSongPath = state.currentSong?.filePath ?? '' const songChanged = previousSongPath !== nextSongPath const lyricChanged = this.currentSong?.lyricContent !== state.currentSong?.lyricContent Logger.info(TAG, `[PLAYER_DETACH] player apply song=${state.currentSong?.name ?? ''}, status=${state.controlPlayStatus}, ` + `position=${state.playbackPositionMs}, duration=${state.playbackDurationMs}, progress=${state.progressValue}, ` + `songChanged=${songChanged}, lyricChanged=${lyricChanged}`) this.currentSong = state.currentSong this.controlPlayStatus = state.controlPlayStatus this.progressValue = state.progressValue this.sliderProgressValue = state.progressValue this.playbackPositionMs = state.playbackPositionMs this.playbackDurationMs = state.playbackDurationMs this.cover = state.cover this.syncTimeTexts() if (songChanged || lyricChanged) { this.syncLyricContentIfNeeded(true) } else { this.syncLyricContentIfNeeded() } this.syncLyricPosition() this.syncRotationState() } private startLyricSyncTimer(): void { this.stopLyricSyncTimer() this.lyricSyncTimer = setInterval(() => { this.syncLyricContentIfNeeded() }, 500) } private stopLyricSyncTimer(): void { if (this.lyricSyncTimer >= 0) { clearInterval(this.lyricSyncTimer) this.lyricSyncTimer = -1 } } private syncLyricContentIfNeeded(force: boolean = false): void { const lyricContent = resolvePlayerLyricContent(this.currentSong) if (!force && lyricContent === this.appliedLyricContent) { return } this.appliedLyricContent = lyricContent if (lyricContent.length === 0) { this.lyricController.setLyric(null) return } const lines: string[] = lyricContent.split('\n').map((line: string) => line.trim()) const lyric: Lyric = this.lyricParser.parse(lines) this.lyricController.setLyric(lyric) this.syncLyricPosition() } private syncLyricPosition(): void { this.lyricController.updatePosition(Math.max(0, this.playbackPositionMs)) } private syncRotationState(): void { const isPlaying = isPlaybackControlPlaying(this.controlPlayStatus) if (isPlaying) { this.startRotation() return } this.stopRotation() } private startRotation(): void { if (!this.isRotationRunning) { this.isRotationRunning = true this.rotationTimer = setInterval(() => { if (this.isRotationRunning) { this.rotateAngle += 36 } }, 1000) } animateTo({ duration: 777, iterations: 1, curve: Curve.Linear }, () => { this.rotateAngle2 = -9 }) } private stopRotation(): void { this.isRotationRunning = false if (this.rotationTimer >= 0) { clearInterval(this.rotationTimer) this.rotationTimer = -1 } animateTo({ duration: 1000, curve: Curve.Linear, iterations: 1, }, () => { this.rotateAngle2 = -45 }) } private onSeek(value: number): void { const seekValue = MusicPlaybackController.resolveSeekValueFromPercent(value, this.playbackDurationMs) void this.playbackController.seekTo(`${Math.floor(seekValue)}`, 'musi-player-view') } private buildArtistAlbumText(): string { const artist = this.currentSong?.artist ?? '' const album = this.currentSong?.album ?? '' if (StrUtil.isNotEmpty(artist) && StrUtil.isNotEmpty(album)) { return `${artist} ${album}` } if (StrUtil.isNotEmpty(artist)) { return artist } if (StrUtil.isNotEmpty(album)) { return album } return '' } private resolveSongName(): string { return this.currentSong?.name ?? '暂无播放' } private resolveSongArtist(): string { return this.currentSong?.artist ?? '' } private toggleSimpleMode(): void { this.showSimple = !this.showSimple PreferencesUtil.put('showSimple', this.showSimple) } private notifyFeaturePending(featureName: string): void { ToastUtil.showToast(`${featureName}功能待迁移`) } @Builder private PlayTitle() { Row() { Row() { PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: $r('sys.symbol.chevron_down'), isPx: false, builderHeight: 20, builderWidth: 20, }) .onClick(() => { this.onClose() }) } .width('100%') } .width('100%') .padding({ left: 25, top: this.topSafeHeight }) } @Builder private RectangleCoverView() { Stack({ alignContent: Alignment.Center }) { Image(StrUtil.isNotEmpty(this.cover) ? this.cover as string : $r('app.media.alt')) .height(px2vp(this.windowHeight)*0.4) .width('auto') .aspectRatio(1) .objectFit(ImageFit.Contain) .alt($r('app.media.alt')) .margin({ right: 8, left: 8, top: 12 }) .borderRadius(20) .clip(true) .shadow({ radius: 22, type: ShadowType.BLUR, color: 'on_primary' }) .onClick(() => { this.isMusicBGCover = !this.isMusicBGCover }) } } @Builder private musicNameInfo() { Row() { Column({ space: 8 }) { Text(this.resolveSongName()) .fontSize(this.resolveSongName().length >= 28 ? 18 : 20) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.MARQUEE }) .maxLines(1) .width('99%') .textAlign(TextAlign.Start) .fontColor(Color.White) Text(this.buildArtistAlbumText()) .fontSize(15) .textOverflow({ overflow: TextOverflow.MARQUEE }) .fontColor(Color.White) .textAlign(TextAlign.Start) .visibility(StrUtil.isEmpty(this.buildArtistAlbumText()) ? Visibility.None : Visibility.Visible) .width('99%') .maxLines(1) } .layoutWeight(1) .margin({ left: 15 }) Blank() Row() { PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: this.showSimple ? $r('sys.symbol.sun_max') : $r('sys.symbol.moon'), isPx: false, builderHeight: 26, builderWidth: 26, }) .onClick(() => { this.toggleSimpleMode() }) } .margin({ left: 8, right: 8 }) } .width('90%') .margin({ top: 15 }) .visibility(this.currentSwiperIndex === 0 ? Visibility.Visible : Visibility.None) } @Builder private LyricsTopItem() { Row() { Stack({ alignContent: Alignment.Center }) { Image(StrUtil.isNotEmpty(this.cover) ? this.cover as string : $r('app.media.alt')) .height(58) .width(58) .clip(true) .alt($r('app.media.alt')) .borderRadius(8) .shadow({ radius: 22, type: ShadowType.BLUR, color: 'on_primary' }) } .width(62) .height('100%') Column() { Column() { Text(this.resolveSongName()) .fontSize(16) .maxLines(1) .fontWeight(FontWeight.Bolder) .fontColor(Color.White) .textOverflow({ overflow: TextOverflow.Ellipsis }) .margin({ left: 10 }) Text(this.resolveSongArtist()) .fontSize(13) .fontWeight(FontWeight.Bolder) .padding({ top: 8 }) .fontColor(Color.White) .margin({ left: 10 }) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .visibility(StrUtil.isEmpty(this.resolveSongArtist()) ? Visibility.None : Visibility.Visible) } .height('100%') .width('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Start) } .height('100%') .layoutWeight(1) .margin({ right: 8 }) Column() { PointLightDefaultButton({ isSysBol: false, pointColor: Color.White, imageResource: $r('app.media.lyric'), isPx: false, builderHeight: 23, builderWidth: 23, }) .onClick(() => { this.notifyFeaturePending('歌词设置') }) } .width(44) .height('100%') .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.End) } .width('90%') .padding({ left: 28, right: 28 }) .height(58) .alignItems(VerticalAlign.Center) } @Builder private playMenuBuilder() { Image($r('app.media.menu')) .width(24) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) } @Builder private playPreviousBuilder() { Image($r('app.media.ic_previous')) .width(32) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } @Builder private playNextBuilder() { Image($r('app.media.ic_next')) .width(32) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } @Builder private PlayOrPauseButton() { Stack() { Column() { Image(this.controlPlayStatus === PlayStatus.PLAY ? $r('app.media.hm_pause') : $r('app.media.hm_play2')) .width(40) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .fillColor(Color.White) .aspectRatio(CommonConstants.ASPECT_RATIO) } if (this.isPlayerLoading) { Progress({ value: 0, total: 100, type: ProgressType.Ring }) .width(43) .height(43) .color(Color.White) .style({ strokeWidth: 5, status: ProgressStatus.LOADING }) } } .width(41) .height(41) } @Builder private playModeBuilder() { Column() { if (this.playType === 0) { Image($r('app.media.loop')) .width(24) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } else if (this.playType === 1) { Image($r('app.media.single')) .width(24) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } else if (this.playType === 2) { Image($r('app.media.normal_play')) .width(24) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } else if (this.playType === 3) { Image($r('app.media.random')) .width(24) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } else { Image($r('app.media.noloop')) .width(24) .clickEffect({ level: ClickEffectLevel.LIGHT, scale: 0.5 }) .aspectRatio(CommonConstants.ASPECT_RATIO) } } } @Builder private playModeButton() { PointLightButton({ builder: () => { this.playModeBuilder() }, isPx: false, builderHeight: 26, builderWidth: 26, }) .onClick(async () => { await this.playbackController.setLoopMode() }) } @Builder private TopPlayProgressView() { Column() { Row() { Slider({ value: this.sliderProgressValue, min: 0, max: 100, step: 1, style: SliderStyle.InSet }) .height(20) .blockColor('rgba(255,255,255,1)') .trackColor('rgba(255,255,255,0.3)') .selectedColor(Color.White) .trackThickness(4) .showSteps(false) .showTips(true) .layoutWeight(1) .enabled(this.playbackDurationMs > 0) .onChange((value: number, mode: SliderChangeMode) => { this.sliderProgressValue = value if (mode !== 2) { return } this.onSeek(value) }) } .justifyContent(FlexAlign.Center) .padding({ left: 20, right: 20 }) .width('95%') Row() { Text(this.currentTime) .fontSize(10) .fontColor(Color.White) .margin({ left: 25 }) Blank() Text(this.totalTime) .fontSize(10) .fontColor(Color.White) .margin({ right: 25 }) } .width('90%') .height(10) } .width('100%') .alignItems(HorizontalAlign.Center) } @Builder private playCenterView() { Row() { PointLightButton({ builder: () => { this.playMenuBuilder() }, isPx: false, builderHeight: 26, builderWidth: 26, }) .onClick(() => { this.notifyFeaturePending('更多') }) PointLightButton({ builder: () => { this.playPreviousBuilder() }, isPx: false, builderHeight: 26, builderWidth: 26, }) .onClick(async () => { await this.playbackController.playPrevious() }) PointLightButton({ builder: () => { this.PlayOrPauseButton() }, isPx: false, builderHeight: 41, builderWidth: 41, }) .onClick(() => { void this.playbackController.playOrPause() }) PointLightButton({ builder: () => { this.playNextBuilder() }, isPx: false, builderHeight: 26, builderWidth: 26, }) .onClick(async () => { await this.playbackController.playNext() }) this.playModeButton() } .width('95%') .margin({ bottom: 15 }) .justifyContent(FlexAlign.SpaceEvenly) } @Builder private playListBuilder() { PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: $r('sys.symbol.music_note_list'), isPx: false, builderHeight: 26, builderWidth: 26, }) .onClick(() => { void this.playbackController.openPlayList() }) } @Builder private BottomView() { Row() { PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: $r('sys.symbol.rectangle_portrait_rotate'), isPx: false, builderHeight: 22, builderWidth: 22, }) .onClick(() => { this.notifyFeaturePending('旋转') }) PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: $r('sys.symbol.rename'), isPx: false, builderHeight: 22, builderWidth: 22, }) .onClick(() => { this.notifyFeaturePending('编辑标签') }) PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: $r('sys.symbol.slider_vertical_3'), isPx: false, builderHeight: 22, builderWidth: 22, }) .onClick(() => { this.notifyFeaturePending('均衡器') }) PointLightDefaultButton({ isSysBol: true, pointColor: Color.White, imageResource: this.isFavorite ? $r('sys.symbol.heart_fill') : $r('sys.symbol.heart'), isPx: false, builderHeight: 22, builderWidth: 22, }) .onClick(() => { this.notifyFeaturePending('收藏') }) this.playListBuilder() } .width('95%') .justifyContent(FlexAlign.SpaceAround) .visibility(this.showSimple ? Visibility.None : Visibility.Visible) .height(30) .animation({ duration: 666, curve: 'ease-in-out' // 可选动画曲线 }) } @Builder private BottomControl() { Column() { this.TopPlayProgressView() this.playCenterView() this.BottomView() } .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) .margin({ top: 10 }) .position({ bottom: 55}) // 将 固定在底部 } @Builder private CoverPageBuilder() { Column() { this.RectangleCoverView() this.musicNameInfo() this.BottomControl() } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Center) .padding({ top: 20 }) } @Builder private LyricPageBuilder() { Column() { this.LyricsTopItem() LyricView2({ controller: this.lyricController, enableSeek: true, seekUIColor: '#ff0000', seekLineColor: '#80ffffff', seekUIStyle: 'listItem', onSeekAction: (position: number) => { void this.playbackController.seekTo(`${Math.max(0, Math.floor(position))}`, 'musi-player-lyric-seek') return true } }) .width('100%') .layoutWeight(1) .margin({ top: 2, bottom: 10, left: 4, right: 4 }) } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .alignItems(HorizontalAlign.Center) .padding({ left: 4, right: 4, top: 38 }) } build() { Stack({ alignContent: Alignment.Center }) { Column() .width('100%') .height('100%') .linearGradient({ direction: GradientDirection.Right, colors: [['#00BC70', 0.0], ['#D81B60', 1.0]] }) .visibility(this.isMusicBGCover && StrUtil.isNotEmpty(this.cover) ? Visibility.None : Visibility.Visible) Column() { this.PlayTitle() Swiper() { this.CoverPageBuilder() this.LyricPageBuilder() } .onChange((index: number) => { this.currentSwiperIndex = index if (index === 1) { this.syncLyricContentIfNeeded() this.syncLyricPosition() } }) .position({ x: 0, y:this.isLandscape?42: 35 }) .indicator(false) .displayCount(1) .loop(false) .autoPlay(false) .layoutWeight(1) .width('100%') } .width('100%') .height('100%') .justifyContent(FlexAlign.Start) .padding({ bottom: 5 }) } .backgroundImage(!this.isMusicBGCover || StrUtil.isEmpty(this.cover) ? null : this.cover) .backgroundImageSize(!this.isMusicBGCover ? { width: '100%' } : { height: '150%', width: '100%' }) .backgroundBlurStyle(!this.isMusicBGCover ? BlurStyle.NONE : BlurStyle.BACKGROUND_ULTRA_THICK) .backgroundBrightness({ rate: 0, lightUpDegree: -0.1 }) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.TOP, SafeAreaEdge.BOTTOM]) } }