Parcourir la source

添加发现页功能UI 初步实现

onecold il y a 4 mois
Parent
commit
c3d9b719c3

+ 56 - 0
entry/src/main/ets/common/util/MediaTable.ets

@@ -1721,6 +1721,62 @@ export default  class MediaTable {
     });
   }
 
+  public queryRemoteSongs(limitCount: number = 0, callback: (result: VideoItem[]) => void): void {
+    try {
+      const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
+      predicates.beginWrap();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_WEBDAV);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_SMB);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_NAVIDROME);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_FTP);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_BAIDU);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_JELLYFIN);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_EMBY);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_AUDIOSTATION);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_PLEX);
+      predicates.or();
+      predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_DAOLIYU);
+      predicates.endWrap();
+      predicates.orderByDesc(DB_COLUMNS.C_TIME);
+
+      if (limitCount > 0) {
+        predicates.limitAs(limitCount);
+      }
+
+      this.accountTable.query(predicates, (resultSet: relationalStore.ResultSet) => {
+        const result = this.parseResultSetToVideoItems(resultSet);
+        callback(result);
+      });
+    } catch (err) {
+      Logger.error(`queryRemoteSongs error: ${(err as Error).message}`);
+      callback([]);
+    }
+  }
+
+  public queryRemoteSongsAsync(limitCount: number = 0): Promise<VideoItem[]> {
+    return new Promise<VideoItem[]>((resolve) => {
+      this.queryRemoteSongs(limitCount, (result: VideoItem[]) => {
+        resolve(result);
+      });
+    });
+  }
+
+  public queryRecentPlayedRecordsAsync(count: number): Promise<VideoItem[]> {
+    return new Promise<VideoItem[]>((resolve) => {
+      this.queryRecentPlayedRecords(count, (result: VideoItem[]) => {
+        resolve(result);
+      });
+    });
+  }
+
 }
 
 function generateBucket(item: VideoItem): relationalStore.ValuesBucket {

+ 11 - 2
entry/src/main/ets/pages/NewIndex.ets

@@ -58,6 +58,7 @@ import { RemoteMusicPage } from '../view/RemoteMusicPage';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
 import { PlayStatus } from '../common/PlayStatus';
 import { PointLightDefaultButton } from '../view/PointLight/PointLightDeFaultButton';
+import { FindView } from '../view/FindView';
 
 // import { FFmpeg, FFProgressMessageParser } from '@sj/ffmpeg';
 
@@ -360,10 +361,10 @@ struct NewIndex {
       this.loadPlaylistList()
     });
 
-    if(Utility.isOpenTime()&&!PreferencesUtil.getBooleanSync('isShowHaoPingDialog179', false)){
+    if(Utility.isOpenTime()&&!PreferencesUtil.getBooleanSync('isShowHaoPingDialog17', false)){
       setTimeout(()=>{
         Utility.showHaoPingDialog(this.getUIContext(),this.context,this.appName,this.bundleName)
-        PreferencesUtil.putSync('isShowHaoPingDialog179', true)
+        PreferencesUtil.putSync('isShowHaoPingDialog17', true)
       },25000)
     }
     this.initDefalutType()
@@ -461,6 +462,8 @@ struct NewIndex {
         SettingPage()
       }else if(this.mType === 5 ){
         AboutPage()
+      }else if(this.mType === 8 ){//发现页
+        FindView()
       }
 
 
@@ -1104,6 +1107,12 @@ struct NewIndex {
               this.currentSongListID = ''
               this.doShowDrawer()
               break
+            case MainViewModel.MENU_FIND:
+              this.mType = 8
+              this.modeType = 0
+              this.currentSongListID = ''
+              this.doShowDrawer()
+              break
             case MainViewModel.MENU_FILE_SCAN:
               this.mType = 2
               this.doShowDrawer()

+ 28 - 0
entry/src/main/ets/view/ConfigTitle.ets

@@ -0,0 +1,28 @@
+import { SymbolGlyphFancyModifier } from "../common/util/AttributeModifierUtil"
+
+
+@Component
+export struct ConfigTitle {
+  @State @Require text: string
+  @State currentIndex: number = 0
+
+  build() {
+    Row() {
+      Text(this.text)
+        .fontSize(18)
+        .fontWeight(FontWeight.Bold)
+        .lineHeight(25)
+        .fontColor('#E6000000')
+      Row({ space: 5 }) {
+        Text('换一换').fontSize(14).lineHeight(16).fontColor('#99000000')
+        SymbolGlyph($r('sys.symbol.arrow_clockwise'))
+          .attributeModifier(new SymbolGlyphFancyModifier(25, '', ''))
+      }
+      .onClick(() => {
+
+      })
+    }.width('100%')
+    .justifyContent(FlexAlign.SpaceBetween)
+    .padding({ top: 22, bottom: 10 })
+  }
+}

+ 755 - 0
entry/src/main/ets/view/FindView.ets

@@ -0,0 +1,755 @@
+import { StrUtil, ToastUtil } from '@pura/harmony-utils'
+import { BusinessError, emitter } from '@kit.BasicServicesKit'
+import { CommonConstants } from '../common/constants/CommonConstants'
+import { EventConstants } from '../common/constants/EventConstants'
+import MediaTable from '../common/util/MediaTable'
+import { BreakpointTypeEnum } from '../common/util/BreakpointSystem'
+import Logger from '../common/util/Logger'
+import { VideoItem } from '../viewmodel/VideoItem'
+import { ConfigTitle } from './ConfigTitle'
+import { PointLightActionButton } from './PointLight/PointLightActionButton'
+
+@Builder
+export function FindViewBuilder() {
+  FindView()
+}
+
+const TAG = 'FindView'
+const SWIPER_MAX_COUNT = 5
+const HOT_SECTION_COUNT = 10
+const CLOUD_SECTION_COUNT = 4
+const RECENT_SECTION_COUNT = 6
+const TOP_PLAYED_COUNT = 30
+
+interface FindPlaylistEventData {
+  playlistId: string
+  playlistName: string
+  songCount: number
+  startIndex: number
+  isJump: boolean
+  songFilePaths: string[]
+}
+
+@Component
+export struct FindView {
+  @Consume mType: number
+
+  @State private swiperSongs: VideoItem[] = []
+  @State private hotSongs: VideoItem[] = []
+  @State private remoteSongs: VideoItem[] = []
+  @State private recentSongs: VideoItem[] = []
+  @State private isRefreshing: boolean = false
+  @State private isPageLoading: boolean = true
+  @State private refreshText: string = '加载中...'
+  @State private swiperIndex: number = 0
+  @State private refreshPullRatio: number = 1
+  @State private maxRefreshingHeight: number = 100
+
+  @StorageProp('currentBreakpoint') currentBreakpoint: string = BreakpointTypeEnum.MD
+  @StorageProp('windowWidth') windowWidth: number = 0
+  @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+
+  private mediaTable?: MediaTable
+  private localSongsPool: VideoItem[] = []
+  private topPlayedSongs: VideoItem[] = []
+
+  aboutToAppear(): void {
+    this.ensureMediaTable()
+  }
+
+  private ensureMediaTable(): void {
+    if (this.mediaTable) {
+      void this.loadDiscoveryContent(false)
+      return
+    }
+    try {
+      this.mediaTable = new MediaTable(getContext(this), () => {
+        void this.loadDiscoveryContent(false)
+      })
+    } catch (error) {
+      const message = this.toErrorMessage(error)
+      Logger.error(TAG, `初始化媒体数据库失败: ${message}`)
+      this.refreshText = '推荐加载失败,请下拉重试'
+      this.isPageLoading = false
+    }
+  }
+
+  private async loadDiscoveryContent(triggeredByRefresh: boolean): Promise<void> {
+    if (!this.mediaTable) {
+      return
+    }
+
+    if (triggeredByRefresh) {
+      this.isRefreshing = true
+      this.refreshText = '正在刷新推荐...'
+    } else if (this.isPageLoading) {
+      this.refreshText = '加载中...'
+    }
+
+    try {
+      const allSongs = await this.mediaTable.queryAllVideos()
+      const uniqueLocalSongs = this.filterUniqueSongs(allSongs)
+      const coveredSongs = this.filterSongsWithCover(uniqueLocalSongs)
+
+      const requestResults = await Promise.all([
+        this.mediaTable.queryRemoteSongsAsync(16),
+        this.mediaTable.queryRecentPlayedRecordsAsync(RECENT_SECTION_COUNT),
+        this.queryTopPlayedSongs(TOP_PLAYED_COUNT)
+      ])
+      const remoteSongs = requestResults[0] as VideoItem[]
+      const recentSongs = requestResults[1] as VideoItem[]
+      const topPlayedSongs = requestResults[2] as VideoItem[]
+
+      this.localSongsPool = uniqueLocalSongs
+      this.topPlayedSongs = this.filterUniqueSongs(topPlayedSongs)
+      this.swiperSongs = this.pickRandomSongs(coveredSongs, SWIPER_MAX_COUNT)
+      this.hotSongs = this.pickRandomSongs(uniqueLocalSongs, HOT_SECTION_COUNT)
+      this.remoteSongs = this.pickRandomSongs(this.filterUniqueSongs(remoteSongs), CLOUD_SECTION_COUNT)
+      this.recentSongs = this.filterUniqueSongs(recentSongs).slice(0, RECENT_SECTION_COUNT)
+      this.swiperIndex = 0
+      this.refreshText = ''
+
+      Logger.info(
+        TAG,
+        `发现页加载完成: local=${this.localSongsPool.length}, swiper=${this.swiperSongs.length}, remote=${this.remoteSongs.length}, recent=${this.recentSongs.length}, top=${this.topPlayedSongs.length}`
+      )
+    } catch (error) {
+      const message = this.toErrorMessage(error)
+      Logger.error(TAG, `发现页加载失败: ${message}`)
+      this.swiperSongs = []
+      this.hotSongs = []
+      this.remoteSongs = []
+      this.recentSongs = []
+      this.topPlayedSongs = []
+      this.localSongsPool = []
+      this.refreshText = '推荐加载失败,请下拉重试'
+    } finally {
+      this.isRefreshing = false
+      this.isPageLoading = false
+    }
+  }
+
+  private queryTopPlayedSongs(limitCount: number): Promise<VideoItem[]> {
+    return new Promise<VideoItem[]>((resolve) => {
+      if (!this.mediaTable) {
+        resolve([])
+        return
+      }
+      this.mediaTable.queryByPlayCountDesc(limitCount, (result: VideoItem[]) => {
+        resolve(result)
+      })
+    })
+  }
+
+  private filterSongsWithCover(items: VideoItem[]): VideoItem[] {
+    const result: VideoItem[] = []
+    const seen: Set<string> = new Set<string>()
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i]
+      const filePath = item.filePath || ''
+      if (StrUtil.isEmpty(filePath) || seen.has(filePath)) {
+        continue
+      }
+      if (StrUtil.isEmpty(item.pixelMapPath)) {
+        continue
+      }
+      seen.add(filePath)
+      result.push(item)
+    }
+    return result
+  }
+
+  private filterUniqueSongs(items: VideoItem[]): VideoItem[] {
+    const result: VideoItem[] = []
+    const seen: Set<string> = new Set<string>()
+    for (let i = 0; i < items.length; i++) {
+      const item = items[i]
+      const filePath = item.filePath || ''
+      if (StrUtil.isEmpty(filePath) || seen.has(filePath)) {
+        continue
+      }
+      seen.add(filePath)
+      result.push(item)
+    }
+    return result
+  }
+
+  private pickRandomSongs(items: VideoItem[], count: number): VideoItem[] {
+    const copy: VideoItem[] = items.slice()
+    for (let i = copy.length - 1; i > 0; i--) {
+      const randomIndex = Math.floor(Math.random() * (i + 1))
+      const current = copy[i]
+      copy[i] = copy[randomIndex]
+      copy[randomIndex] = current
+    }
+    return copy.slice(0, Math.min(count, copy.length))
+  }
+
+  private emitPlaylistPlay(playlistId: string, playlistName: string, songs: VideoItem[], startIndex: number): void {
+    if (songs.length === 0) {
+      ToastUtil.showToast('暂无可播放歌曲')
+      return
+    }
+    const safeIndex = Math.min(Math.max(startIndex, 0), songs.length - 1)
+    const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
+    const playlistData: FindPlaylistEventData = {
+      playlistId,
+      playlistName,
+      songCount: songs.length,
+      startIndex: safeIndex,
+      isJump: true,
+      songFilePaths: songs.map((item: VideoItem): string => item.filePath)
+    }
+    emitter.emit(eventPlaylistPlay, { data: playlistData })
+  }
+
+  private handleActionButtonTap(type: string): void {
+    if (type === 'top') {
+      this.emitPlaylistPlay('find-top-played', '最近爱听', this.topPlayedSongs, 0)
+      return
+    }
+    if (type === 'home') {
+      this.mType = 0
+      return
+    }
+    if (this.localSongsPool.length === 0) {
+      ToastUtil.showToast('本地歌曲为空')
+      return
+    }
+    const startIndex = Math.floor(Math.random() * this.localSongsPool.length)
+    this.emitPlaylistPlay('find-random-local', '随机静听', this.localSongsPool, startIndex)
+  }
+
+  private handleSwiperTap(index: number): void {
+    this.emitPlaylistPlay('find-swiper', '发现推荐', this.swiperSongs, index)
+  }
+
+  private handleRemoteSongTap(index: number): void {
+    this.emitPlaylistPlay('find-cloud', '漫步云端', this.remoteSongs, index)
+  }
+
+  private handleHotSongTap(index: number): void {
+    this.emitPlaylistPlay('find-local-random', '本地随机', this.hotSongs, index)
+  }
+
+  private handleRecentSongTap(index: number): void {
+    this.emitPlaylistPlay('find-recent', '最近播放', this.recentSongs, index)
+  }
+
+  private getSwiperAspectRatio(): number {
+    return this.currentBreakpoint === BreakpointTypeEnum.SM ? 1.06 : 1.66
+  }
+
+  private getSwiperTextSize(smallSize: number, largeSize: number): number {
+    return this.currentBreakpoint === BreakpointTypeEnum.SM ? smallSize : largeSize
+  }
+
+  private getIndicatorItemWidth(): number {
+    const widthVp = this.windowWidth > 0 ? px2vp(this.windowWidth) : 360
+    const count = Math.max(1, this.swiperSongs.length)
+    const gap = 8
+    const horizontalPadding = this.currentBreakpoint === BreakpointTypeEnum.SM ? 64 : 120
+    const usableWidth = Math.max(80, widthVp - horizontalPadding - gap * (count - 1))
+    return usableWidth / count
+  }
+
+  private getSongTitle(item: VideoItem): string {
+    if (StrUtil.isNotEmpty(item.name)) {
+      return item.name
+    }
+    if (StrUtil.isNotEmpty(item.fileName)) {
+      return item.fileName as string
+    }
+    return '未知歌曲'
+  }
+
+  private getSongSubtitle(item: VideoItem): string {
+    if (StrUtil.isNotEmpty(item.artist) && StrUtil.isNotEmpty(item.album)) {
+      return `${item.artist} · ${item.album}`
+    }
+    if (StrUtil.isNotEmpty(item.artist)) {
+      return item.artist as string
+    }
+    if (StrUtil.isNotEmpty(item.album)) {
+      return item.album as string
+    }
+    return '本地音乐'
+  }
+
+  private getSongCover(item: VideoItem): string | Resource {
+    return StrUtil.isNotEmpty(item.pixelMapPath) ? item.pixelMapPath as string : $r('app.media.alt')
+  }
+
+  private getSongKey(item: VideoItem, index: number): string {
+    if (StrUtil.isNotEmpty(item.filePath)) {
+      return item.filePath
+    }
+    return `${this.getSongTitle(item)}_${index}`
+  }
+
+  private getCloudTypeLabel(item: VideoItem): string {
+    switch (item.type) {
+      case CommonConstants.TYPE_WEBDAV:
+        return 'WebDAV'
+      case CommonConstants.TYPE_SMB:
+        return 'SMB'
+      case CommonConstants.TYPE_NAVIDROME:
+        return 'Navidrome'
+      case CommonConstants.TYPE_FTP:
+        return 'FTP'
+      case CommonConstants.TYPE_BAIDU:
+        return '百度网盘'
+      case CommonConstants.TYPE_JELLYFIN:
+        return 'Jellyfin'
+      case CommonConstants.TYPE_EMBY:
+        return 'Emby'
+      case CommonConstants.TYPE_AUDIOSTATION:
+        return 'AudioStation'
+      case CommonConstants.TYPE_PLEX:
+        return 'Plex'
+      case CommonConstants.TYPE_DAOLIYU:
+        return '道理鱼'
+      default:
+        return '云端'
+    }
+  }
+
+  private getRecentSongSubtitle(item: VideoItem): string {
+    if (StrUtil.isNotEmpty(item.lastPlayedStr)) {
+      return item.lastPlayedStr as string
+    }
+    return this.getSongSubtitle(item)
+  }
+
+  private toErrorMessage(error: Object): string {
+    const businessError = error as BusinessError
+    if (businessError && businessError.message) {
+      return businessError.message
+    }
+    const rawError = error as Error
+    if (rawError && StrUtil.isNotEmpty(rawError.message)) {
+      return rawError.message
+    }
+    return `${error}`
+  }
+
+  @Builder
+  private buildLoadingState() {
+    Column({ space: 14 }) {
+      LoadingProgress()
+        .width(46)
+        .height(46)
+        .color(this.themeColor)
+
+      Text(this.refreshText)
+        .fontSize(15)
+        .fontWeight(FontWeight.Medium)
+        .fontColor('#99000000')
+    }
+    .width('100%')
+    .layoutWeight(1)
+    .justifyContent(FlexAlign.Center)
+    .alignItems(HorizontalAlign.Center)
+  }
+
+  @Builder
+  private buildTopErrorBanner() {
+    if (StrUtil.isNotEmpty(this.refreshText)) {
+      Row({ space: 6 }) {
+        SymbolGlyph($r('sys.symbol.exclamationmark_circle'))
+          .fontSize(15)
+          .fontColor(['#805A3D00'])
+        Text(this.refreshText)
+          .layoutWeight(1)
+          .fontSize(12)
+          .fontColor('#805A3D00')
+          .maxLines(1)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+      }
+      .width('100%')
+      .padding({ left: 12, right: 12, top: 10, bottom: 10 })
+      .backgroundColor('#FFF6E5')
+      .borderRadius(16)
+    }
+  }
+
+  @Builder
+  private buildSwiperSection() {
+    if (this.swiperSongs.length > 0) {
+      Swiper() {
+        ForEach(this.swiperSongs, (item: VideoItem, index: number) => {
+          Stack({ alignContent: Alignment.BottomStart }) {
+            Image(this.getSongCover(item))
+              .width('100%')
+              .aspectRatio(this.getSwiperAspectRatio())
+              .objectFit(ImageFit.Cover)
+
+            Column({ space: 10 }) {
+
+              Column({ space: 4 }) {
+                Text(this.getSongTitle(item))
+                  .fontColor(Color.White)
+                  .fontSize(this.getSwiperTextSize(18, 24))
+                  .fontWeight(FontWeight.Bold)
+                  .maxLines(1)
+                  .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+                Text(this.getSongSubtitle(item))
+                  .fontColor('#E6FFFFFF')
+                  .fontSize(this.getSwiperTextSize(12, 15))
+                  .maxLines(1)
+                  .textOverflow({ overflow: TextOverflow.Ellipsis })
+              }
+              .alignItems(HorizontalAlign.Start)
+            }
+            .width('100%')
+            .padding({
+              left: this.currentBreakpoint === BreakpointTypeEnum.SM ? 16 : 22,
+              right: this.currentBreakpoint === BreakpointTypeEnum.SM ? 16 : 22,
+              bottom: this.currentBreakpoint === BreakpointTypeEnum.SM ? 16 : 22
+            })
+            .linearGradient({
+              direction: GradientDirection.Top,
+              colors: [['#D9000000', 0], ['#7A000000', 0.48], ['#00000000', 1]]
+            })
+            .borderRadius(24)
+          }
+          .onClick(() => {
+            this.handleSwiperTap(index)
+          })
+        }, (item: VideoItem, index: number) => this.getSongKey(item, index))
+      }
+      .width('100%')
+      .clip(true)
+      .autoPlay(true)
+      .interval(3200)
+      .loop(true)
+      .onChange((index: number) => {
+        this.swiperIndex = index
+      })
+      .indicator(new DotIndicator()
+        .itemWidth(this.getIndicatorItemWidth())
+        .itemHeight(2)
+        .selectedItemWidth(this.getIndicatorItemWidth())
+        .selectedItemHeight(4))
+    } else {
+      this.buildSectionEmptyState('还没有可展示的封面音乐', '给本地歌曲补上封面后,这里会自动随机推荐', true)
+    }
+  }
+
+  @Builder
+  private buildActionButtons() {
+    Row({ space: 10 }) {
+      PointLightActionButton({
+        text: '最近爱听',
+        iconResource: $r('sys.symbol.heart'),
+        pointColor: this.themeColor,
+        textColor: $r('app.color.text_color'),
+        buttonColor: '#F6F7FB'
+      })
+        .layoutWeight(1)
+        .onClick(() => {
+          this.handleActionButtonTap('top')
+        })
+
+      PointLightActionButton({
+        text: '天天静听',
+        iconResource: $r('sys.symbol.music_note_list'),
+        pointColor: this.themeColor,
+        textColor: $r('app.color.text_color'),
+        buttonColor: '#F6F7FB'
+      })
+        .layoutWeight(1)
+        .onClick(() => {
+          this.handleActionButtonTap('home')
+        })
+
+      PointLightActionButton({
+        text: '随机静听',
+        iconResource: $r('sys.symbol.arrow_clockwise'),
+        pointColor: this.themeColor,
+        textColor: $r('app.color.text_color'),
+        buttonColor: '#F6F7FB'
+      })
+        .layoutWeight(1)
+        .onClick(() => {
+          this.handleActionButtonTap('random')
+        })
+    }
+    .width('100%')
+  }
+
+  @Builder
+  private buildCloudSection() {
+    ConfigTitle({ text: '漫步云端' })
+    if (this.remoteSongs.length === 0) {
+      this.buildSectionEmptyState('还没有网盘歌曲', '先去网盘页或远程音乐页加载歌曲,这里会自动展示', false)
+    } else {
+      GridRow({
+        columns: this.currentBreakpoint === BreakpointTypeEnum.SM ? 2 : 4,
+        gutter: 8
+      }) {
+        ForEach(this.remoteSongs, (item: VideoItem, index: number) => {
+          GridCol() {
+            Column({ space: 6 }) {
+              Stack({ alignContent: Alignment.Bottom }) {
+                Image(this.getSongCover(item))
+                  .width('100%')
+                  .aspectRatio(16 / 9)
+                  .borderRadius(16)
+                  .objectFit(ImageFit.Cover)
+
+                Row() {
+                  Text(this.getCloudTypeLabel(item))
+                    .fontColor(Color.White)
+                    .fontWeight(FontWeight.Medium)
+                    .padding({ left: 6, right: 6, top: 3, bottom: 3 })
+                    .fontSize(9)
+                    .backgroundColor('#7A000000')
+                    .borderRadius(999)
+                }
+                .width('100%')
+                .padding({ left: 8, right: 8, bottom: 8 })
+                .justifyContent(FlexAlign.Start)
+              }
+
+              Text(this.getSongTitle(item))
+                .fontSize(14)
+                .fontWeight(FontWeight.Bold)
+                .lineHeight(18)
+                .fontColor('#E6000000')
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+                .width('100%')
+
+              Text(this.getSongSubtitle(item))
+                .fontSize(11)
+                .lineHeight(14)
+                .fontColor('#80000000')
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+                .width('100%')
+            }
+            .onClick(() => {
+              this.handleRemoteSongTap(index)
+            })
+          }
+        }, (item: VideoItem, index: number) => this.getSongKey(item, index))
+      }
+    }
+  }
+
+  @Builder
+  private buildLocalRandomSection() {
+    ConfigTitle({ text: '本地随机' })
+    if (this.hotSongs.length === 0) {
+      this.buildSectionEmptyState('本地随机还没有内容', '本地曲库加载完成后,这里会随机展示歌曲', false)
+    } else {
+      List({ space: 8 }) {
+        ForEach(this.hotSongs, (item: VideoItem, index: number) => {
+          ListItem() {
+            Column({ space: 5 }) {
+              Stack({ alignContent: Alignment.Bottom }) {
+                Image(this.getSongCover(item))
+                  .aspectRatio(3 / 4)
+                  .borderRadius(16)
+                  .width(160)
+                  .objectFit(ImageFit.Cover)
+
+                Row() {
+                  Text(this.getSongSubtitle(item))
+                    .fontColor('#FFFFFFFF')
+                    .fontSize(9)
+                    .lineHeight(11)
+                    .maxLines(1)
+                    .textOverflow({ overflow: TextOverflow.Ellipsis })
+                }
+                .justifyContent(FlexAlign.End)
+                .padding({ left: 8, right: 8, bottom: 9 })
+                .width(160)
+              }
+
+              Text(this.getSongTitle(item))
+                .lineHeight(16)
+                .maxLines(1)
+                .width(160)
+                .fontColor('#E6000000')
+                .fontSize(14)
+                .fontWeight(FontWeight.Bold)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+
+              Text(this.getRecentSongSubtitle(item))
+                .fontColor('#99000000')
+                .fontSize(10)
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+                .lineHeight(11)
+                .width(160)
+                .textAlign(TextAlign.Start)
+            }
+            .onClick(() => {
+              this.handleHotSongTap(index)
+            })
+          }
+          .margin({
+            left: index === 0 ? 2 : 0,
+            right: index === this.hotSongs.length - 1 ? 2 : 0
+          })
+        }, (item: VideoItem, index: number) => this.getSongKey(item, index))
+      }
+      .listDirection(Axis.Horizontal)
+      .scrollBar(BarState.Off)
+      .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true })
+    }
+  }
+
+  @Builder
+  private buildRecentSection() {
+    ConfigTitle({ text: '最近播放' })
+    if (this.recentSongs.length === 0) {
+      this.buildSectionEmptyState('最近播放还是空的', '播放过的歌曲会自动出现在这里', false)
+    } else {
+      GridRow({
+        columns: this.currentBreakpoint === BreakpointTypeEnum.SM ? 3 : 5,
+        gutter: 8
+      }) {
+        ForEach(this.recentSongs, (item: VideoItem, index: number) => {
+          GridCol() {
+            Column({ space: 6 }) {
+              Stack({ alignContent: Alignment.Bottom }) {
+                Image(this.getSongCover(item))
+                  .width('100%')
+                  .aspectRatio(3 / 4)
+                  .borderRadius(16)
+                  .objectFit(ImageFit.Cover)
+
+                Row() {
+                  Text('最近播放')
+                    .fontColor(Color.White)
+                    .fontWeight(FontWeight.Medium)
+                    .padding({ left: 6, right: 6, top: 3, bottom: 3 })
+                    .fontSize(9)
+                    .backgroundColor('#7A000000')
+                    .borderRadius(999)
+
+                  Text(item.playCount ? `播放 ${item.playCount} 次` : '')
+                    .fontColor(Color.White)
+                    .fontSize(9)
+                    .visibility(item.playCount ? Visibility.Visible : Visibility.None)
+                }
+                .width('100%')
+                .padding({ left: 8, right: 8, bottom: 8 })
+                .justifyContent(FlexAlign.SpaceBetween)
+              }
+
+              Text(this.getSongTitle(item))
+                .fontSize(13)
+                .fontWeight(FontWeight.Bold)
+                .lineHeight(17)
+                .fontColor('#E6000000')
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+                .width('100%')
+
+              Text(this.getRecentSongSubtitle(item))
+                .fontSize(10)
+                .lineHeight(13)
+                .fontColor('#80000000')
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+                .width('100%')
+            }
+            .onClick(() => {
+              this.handleRecentSongTap(index)
+            })
+          }
+        }, (item: VideoItem, index: number) => this.getSongKey(item, index))
+      }
+    }
+  }
+
+  @Builder
+  private buildSectionEmptyState(title: string, subtitle: string, useLargeCard: boolean) {
+    Column({ space: 8 }) {
+      Image($r('app.media.alt'))
+        .width(useLargeCard ? 70 : 56)
+        .height(useLargeCard ? 70 : 56)
+        .opacity(0.72)
+
+      Text(title)
+        .fontSize(15)
+        .fontWeight(FontWeight.Medium)
+        .fontColor('#CC000000')
+
+      Text(subtitle)
+        .fontSize(12)
+        .fontColor('#80000000')
+        .textAlign(TextAlign.Center)
+    }
+    .width('100%')
+    .padding({ top: useLargeCard ? 42 : 26, bottom: useLargeCard ? 42 : 26, left: 12, right: 12 })
+    .backgroundColor('#F7F8FB')
+    .borderRadius(20)
+    .justifyContent(FlexAlign.Center)
+    .alignItems(HorizontalAlign.Center)
+  }
+
+  @Builder
+  private buildDiscoveryContent() {
+    Scroll() {
+      Column(){
+        this.buildTopErrorBanner()
+        this.buildSwiperSection()
+        Column({ space: 14 }) {
+          this.buildActionButtons()
+          this.buildLocalRandomSection()
+          this.buildCloudSection()
+          this.buildRecentSection()
+        }
+        .width('100%')
+        .padding({
+          left: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20,
+          right: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20,
+          top: 16,
+          bottom: 24
+        })
+        .alignItems(HorizontalAlign.Start)
+      }
+
+    }
+    .scrollBar(BarState.Off)
+    .edgeEffect(EdgeEffect.Spring)
+    .width('100%')
+    .layoutWeight(1)
+  }
+
+  build() {
+    Column() {
+      Refresh({ refreshing: $$this.isRefreshing }) {
+        Column() {
+          if (this.isPageLoading) {
+            this.buildLoadingState()
+          } else {
+            this.buildDiscoveryContent()
+          }
+        }
+        .width('100%')
+        .height('100%')
+      }
+      .pullDownRatio(this.refreshPullRatio)
+      .pullToRefresh(true)
+      .refreshOffset(0)
+      .onOffsetChange((offset: number) => {
+        this.refreshPullRatio = 1 - Math.pow((offset / this.maxRefreshingHeight), 3)
+      })
+      .onRefreshing(() => {
+        void this.loadDiscoveryContent(true)
+      })
+      .width('100%')
+      .height('100%')
+    }
+    .width('100%')
+    .height('100%')
+    .backgroundColor($r('sys.color.background_primary'))
+  }
+}

+ 76 - 0
entry/src/main/ets/view/PointLight/PointLightActionButton.ets

@@ -0,0 +1,76 @@
+import { hdsEffect } from '@kit.UIDesignKit'
+import { deviceInfo } from '@kit.BasicServicesKit'
+
+@Component
+export struct PointLightActionButton {
+  @Require @Prop text: string
+  @Require @Prop iconResource: Resource
+  @Prop pointColor: ResourceColor = Color.White
+  @Prop textColor: ResourceColor = $r('app.color.text_color')
+  @Prop buttonColor: ResourceColor = $r('app.color.start_window_background_blur')
+  @Prop buttonHeight: Length = 58
+
+  public canShadow: boolean = true
+  public canPointLight: boolean = true
+  public pointLightHeight: number = 120
+
+  @StorageProp('EnablePointLight') enablePointLight: boolean = true
+  @StorageProp('EnableShadow') enableShadow: boolean = true
+  @StorageProp('SdkApiVersion') sdkApiVersion: number = 17
+
+  @State private pointLightOptions: hdsEffect.PointLightOptions | undefined = undefined
+
+  aboutToAppear(): void {
+    this.sdkApiVersion = deviceInfo.sdkApiVersion
+  }
+
+  build() {
+    Button() {
+      Row({ space: 10 }) {
+        SymbolGlyph(this.iconResource)
+          .fontSize(18)
+          .fontColor([this.pointColor])
+          .effectStrategy(SymbolEffectStrategy.HIERARCHICAL)
+        Text(this.text)
+          .layoutWeight(1)
+          .fontSize(12)
+          .fontWeight(FontWeight.Medium)
+          .fontColor(this.textColor)
+          .maxLines(1)
+          .textOverflow({ overflow: TextOverflow.Ellipsis })
+      }
+      .width('100%')
+      .padding({ left: 10, right: 10 })
+      .justifyContent(FlexAlign.SpaceBetween)
+      .alignItems(VerticalAlign.Center)
+    }
+    .width('100%')
+    .height(this.buttonHeight)
+    .stateEffect(false)
+    .type(ButtonType.Normal)
+    .backgroundColor(this.buttonColor)
+    .borderRadius(18)
+    .border({ width: 1, color: '#1AFFFFFF', radius: 20 })
+    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.96 })
+    .onTouch((event: TouchEvent) => {
+      if (event.type === TouchType.Down) {
+        this.pointLightOptions = {
+          color: this.pointColor,
+          intensity: 1,
+          height: this.pointLightHeight
+        }
+      } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
+        this.pointLightOptions = undefined
+      }
+    })
+    .shadow(this.canShadow && this.enableShadow ? ShadowStyle.OUTER_DEFAULT_XS : undefined)
+    .visualEffect(this.canPointLight && this.enablePointLight && this.sdkApiVersion >= 20
+      ? new hdsEffect.HdsEffectBuilder()
+        .pointLight({
+          options: this.pointLightOptions,
+          illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT
+        })
+        .buildEffect()
+      : undefined)
+  }
+}

+ 2 - 3
entry/src/main/ets/viewmodel/MainViewModel.ets

@@ -25,8 +25,7 @@ export  class  MainViewModel{
   static readonly MENU_HOME: number = 66;
   static readonly MENU_MUSIC: number = 67;
   static readonly MENU_SETTING: number = 68;
-  static readonly MENU_TEST_SPEED: number = 77;
-  static readonly MENU_OPTIMIZE: number = 88;
+  static readonly MENU_FIND: number = 69;
   static readonly MENU_ABOUT: number = 99;
   static readonly MENU_UPDATE: number = 111;
   static readonly MENU_USER: number = 112;
@@ -47,11 +46,11 @@ export  class  MainViewModel{
   //测滑菜单的数据
   getDrawerData(): Array<ItemData> {
     let drawerGridData: ItemData[] = [
-
       new ItemData($r('app.string.local_music'), { type: 'symbol', value: $r('sys.symbol.music') }, MainViewModel.MENU_MUSIC, false),
       new ItemData($r('app.string.media_ku'), $r('app.media.hm_playlist'),MainViewModel.MENU_MIEDIA_KU,false),
       new ItemData($r('app.string.artist'), $r('app.media.kp_music'),MainViewModel.MENU_MIEDIA_ARTIST,false),
       new ItemData($r('app.string.album'), $r('app.media.llq'),MainViewModel.MENU_MIEDIA_ALBUM,false),
+      new ItemData($r('app.string.find_music'), { type: 'symbol', value: $r('sys.symbol.music_note_circle') }, MainViewModel.MENU_FIND, false),
       // new ItemData($r('app.string.user_center'), { type: 'symbol', value: $r('sys.symbol.person') }, MainViewModel.MENU_USER, false),
       new ItemData($r('app.string.file_scan'), { type: 'symbol', value: $r('sys.symbol.doc_text_badge_magnifyingglass') },MainViewModel.MENU_FILE_SCAN,false),
 

+ 4 - 1
entry/src/main/resources/base/element/string.json

@@ -722,7 +722,10 @@
     {
       "name": "create_foder",
       "value": "新建文件夹"
+    },
+    {
+      "name": "find_music",
+      "value": "发现页"
     }
-
   ]
 }

+ 1 - 1
lib/src/main/ets/view/LyricView2.ets

@@ -368,7 +368,7 @@ export struct LyricView2 {
                 } : undefined)
                 .blendMode(
                     index == this.currentIndex && this.enableWordByWordLyric&&
-            !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
+                        !(this.currentLyric && this.currentLyric.isPlainText) ? BlendMode.DST_IN : undefined,
                     index == this.currentIndex && this.enableWordByWordLyric
                         && !(this.currentLyric && this.currentLyric.isPlainText) ? BlendApplyType.OFFSCREEN : undefined
                 )

+ 0 - 0
oh_modules/.ohpm/@pura+harmony-dialog@1.0.6/oh_modules/@pura/harmony-dialog/consumer-rules.txt


+ 0 - 0
oh_modules/.ohpm/@pura+harmony-dialog@1.0.6/oh_modules/@pura/spinkit/consumer-rules.txt