import { SymbolGlyphModifier } from '@kit.ArkUI' import { PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils' import { BusinessError, emitter } from '@kit.BasicServicesKit' import { common } from '@kit.AbilityKit' import { CommonConstants } from '../common/constants/CommonConstants' import { EventConstants } from '../common/constants/EventConstants' import MediaTable from '../common/util/MediaTable' import { MenuModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil' import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSystem' import Logger from '../common/util/Logger' import { RemoteDriveManager } from '../common/util/RemoteDriveManager' import { cloneVideoItem } from '../common/util/RemotePlayerUtil' import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil' import { VideoItem } from '../viewmodel/VideoItem' import { WebDavAccount } from '../viewmodel/WebDavAccount' import { Playlist } from '../viewmodel/Playlist' import { ConfigTitle } from './ConfigTitle' import { PointLightActionButton } from './PointLight/PointLightActionButton' import { PointLightContentButton } from './PointLight/PointLightContentButton' import { TitleBarPointLightButton } from './PointLight/TitleBarPointLightButton' import { DeleteComptent } from './DeleteComptent' import { SettingPage } from '../pages/SettingPage' import { FindAlbumDetail } from './FindAlbumDetail' import { PlayingIndicator } from './PlayingIndicator' import { getFindPlaylistId, getFindPlaylistName, getFindVideoItems, setFindPlaylist } from '../common/util/FindPlaylistStore' import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore' import PlaylistTable from '../common/util/PlaylistTable' import { convertPlaylistSongsToVideoItems } from '../pages/PlaylistDetailPage' import { getWebdavVideoItems, setWebdavPlaylist } from '../pages/WebDavMainPage' import { getNavidromeVideoItems, setNavidromePlaylist } from '../common/util/NavidromePlaylistStore' import { buildPreferredRemotePlaybackPool, buildSortedDiscoverySongs, findPreferredRemotePlaybackAccountId, FindCollectionSortType, resolveQueueStartIndex, shouldWaitForIndexedRemotePlayback } from '../common/util/FindDiscoveryHelper' import { insertSongToNextPlayQueue, QueueInsertStatus } from '../common/util/PlayQueueHelper' import { resolveRemoteCoverForSong } from '../common/util/RemoteCoverResolver' @Builder export function FindViewBuilder() { FindView() } const TAG = 'FindView' const SWIPER_MAX_COUNT = 5 const HOT_SECTION_COUNT = 10 const REMOTE_POOL_COUNT = 32 const REMOTE_VISIBLE_COVER_RESOLVE_LIMIT = 24 const CLOUD_SECTION_COUNT = 18 const RECENT_POOL_COUNT = 24 const RECENT_SECTION_COUNT = 18 const POPULAR_SECTION_COUNT = 18 const FAVORITE_SECTION_COUNT = 18 const TOP_PLAYED_COUNT = 30 const FEATURED_ALBUM_COUNT = 18 const CLOUD_ALBUM_COUNT = 18 const PREFERRED_ALBUM_MIN_COUNT = 6 function getFindSongKey(item: VideoItem, index: number): string { if (StrUtil.isNotEmpty(item.filePath)) { return item.filePath } if (StrUtil.isNotEmpty(item.name)) { return `${item.name}_${index}` } if (StrUtil.isNotEmpty(item.fileName)) { return `${item.fileName}_${index}` } return `find_song_${index}` } interface FindAlbumGroup { id: string title: string artist: string coverPath: string songCount: number songs: VideoItem[] isRemote: boolean sourceLabel: string } interface FindSongPage { id: string items: VideoItem[] } interface FindAlbumPage { id: string items: FindAlbumGroup[] } function getFindAlbumKey(item: FindAlbumGroup, index: number): string { if (StrUtil.isNotEmpty(item.id)) { return item.id } return `find_album_${index}` } function getFindSongPageKey(item: FindSongPage, index: number): string { if (StrUtil.isNotEmpty(item.id)) { return item.id } return 'find_song_page_' + index } function getFindPlaylistKey(item: Playlist, index: number): string { if (StrUtil.isNotEmpty(item.id)) { return item.id } return 'find_playlist_' + index } function getFindAlbumPageKey(item: FindAlbumPage, index: number): string { if (StrUtil.isNotEmpty(item.id)) { return item.id } return 'find_album_page_' + index } @Component export struct FindView { @StorageProp('isLandscape') isLandscape: boolean = false; @State isCustomizeBg: boolean = false //自定义背景界面 @State isHomeBgFollowMusicCover: boolean = true //主页背景随当前播放封面 @State blurValue: number = 0 //背景模糊 @State bgBrightness: number = 0 //背景亮度 @State customizeBgPath: string | undefined = ''; @State isShowTitleBar: boolean = true //是否显示分类导航条 private prevOffsetY: number = 0; // 记录上一次的Y轴偏移量 @State autoHideTitle: boolean = true //滚动自动隐藏标题栏 @Consume mType: number @Consume isShowDrawer: boolean @Consume offsetX: number @State private swiperSongs: VideoItem[] = [] @State private hotSongs: VideoItem[] = [] @State private remoteSongs: VideoItem[] = [] @State private cloudSectionPages: FindSongPage[] = [] @State private recentSongs: VideoItem[] = [] @State private popularSongs: VideoItem[] = [] @State private popularSectionPages: FindSongPage[] = [] @State private favoriteSongs: VideoItem[] = [] @State private cloudMoodSongs: VideoItem[] = [] @State private heartPlaylists: Playlist[] = [] @State private featuredAlbums: FindAlbumGroup[] = [] @State private featuredAlbumPages: FindAlbumPage[] = [] @State private cloudAlbums: FindAlbumGroup[] = [] @State private cloudAlbumPages: FindAlbumPage[] = [] @State @Watch('syncFindCanBackState') private isSearchMode: boolean = false @State @Watch('syncFindCanBackState') private isAlbumMode: boolean = false @State private currentAlbumId: string = '' @State private currentAlbumTitle: string = '' @State private currentAlbumArtist: string = '' @State private currentAlbumCoverPath: string = '' @State private currentAlbumSourceLabel: string = '' @State private currentAlbumSongs: VideoItem[] = [] @State private currentAlbumDefaultSortType: number = FindCollectionSortType.TRACK_ASC @State private currentAlbumSupportRecentPlayedSort: boolean = false @State private searchText: string = '' @State private searchResults: VideoItem[] = [] @State private searchHistoryItems: string[] = [] @State private isSearchLoading: boolean = false @State private isRefreshing: boolean = false @State private isPageLoading: boolean = true @State private refreshText: string = '加载中...' @State private swiperIndex: number = 0 @State private cloudSectionPageIndex: number = 0 @State private featuredAlbumPageIndex: number = 0 @State private cloudAlbumPageIndex: number = 0 @State private recentSectionPageIndex: number = 0 @State private popularSectionPageIndex: number = 0 @State private favoriteSectionPageIndex: number = 0 @State private refreshPullRatio: number = 1 @State private maxRefreshingHeight: number = 100 @State private pendingDeleteSongs: VideoItem[] = [] searchController: SearchController = new SearchController() @StorageProp('currentBreakpoint') @Watch('onDiscoverBreakpointChange') currentBreakpoint: string = BreakpointTypeEnum.MD @StorageProp('windowWidth') windowWidth: number = 0 @StorageProp('topSafeHeight') topSafeHeight: number = 0 @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0 @StorageProp('isDarkMode') isDarkMode: boolean = false @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR @StorageProp('customizeMusicBgPath') customizeMusicBgPath: string = '' @StorageLink('currentSong') @Watch('onHomeBgCurrentSongChange') currentSong: VideoItem | undefined = undefined @StorageLink('isPlaying') isPlaying: boolean = false private mediaTable?: MediaTable private playlistTable?: PlaylistTable private remoteDriveManager: RemoteDriveManager = RemoteDriveManager.getInstance() private localSongsPool: VideoItem[] = [] private coveredSongsPool: VideoItem[] = [] private remoteSongsPool: VideoItem[] = [] private recentSongsPool: VideoItem[] = [] private topPlayedSongsPool: VideoItem[] = [] private favoriteSongsPool: VideoItem[] = [] private featuredAlbumsPool: FindAlbumGroup[] = [] private cloudAlbumsPool: FindAlbumGroup[] = [] private searchRemoteSongsPool: VideoItem[] = [] private remotePlaybackPool: VideoItem[] = [] private readonly searchHistoryScope: string = 'find_music' private searchTicket: number = 0 private cloudSectionPageVersion: number = 0 private featuredAlbumPageVersion: number = 0 private cloudAlbumPageVersion: number = 0 private popularSectionPageVersion: number = 0 private isRemoteBootstrapLoading: boolean = false private playlistSongsCache: Map = new Map() private heartPlaylistCoverTicket: number = 0 private deleteComponentId: number = 0 private remoteAccountMap: Map = new Map() private remoteCoverRepairTicket: number = 0 private remoteCoverRepairTimer: number = -1 aboutToAppear(): void { this.initSetting() this.syncFindCanBackState() let eventSetting: emitter.InnerEvent = { eventId: 333 } // 监听广播事件(通用设置配置更新) emitter.on(eventSetting, (eventData: emitter.EventData) => { this.initSetting() }); emitter.on({ eventId: EventConstants.EVENT_FIND_VIEW_BACK }, () => { this.handleInnerBack() }) this.ensureMediaTable() } aboutToDisappear(): void { AppStorage.setOrCreate('findCanBack', false) emitter.off(EventConstants.EVENT_FIND_VIEW_BACK) this.clearPendingRemoteCoverRepair() } initSetting(){ this.isCustomizeBg = PreferencesUtil.getBooleanSync(SettingPage.IS_CUSTOMIZE_BG, false) this.isHomeBgFollowMusicCover = PreferencesUtil.getBooleanSync(SettingPage.IS_HOME_BG_FOLLOW_MUSIC_COVER, true) this.customizeBgPath = PreferencesUtil.getStringSync(SettingPage.IS_CUSTOMIZE_BG_PATH, '') this.blurValue = PreferencesUtil.getNumberSync(SettingPage.CUSTOMIZE_BG_BLUR, 0) this.bgBrightness = PreferencesUtil.getNumberSync(SettingPage.BG_BRIGHTNESS, 0) this.isShowTitleBar = PreferencesUtil.getBooleanSync(SettingPage.IS_SHOW_TITLTBAR, true) this.autoHideTitle = PreferencesUtil.getBooleanSync('autoHideTitle', true) this.syncCustomizeMusicBgPath() } private onHomeBgCurrentSongChange(): void { this.syncCustomizeMusicBgPath() } private syncCustomizeMusicBgPath(): void { if (!this.isHomeBgFollowMusicCover) { return } const coverPath: string = this.currentSong?.pixelMapPath ?? '' AppStorage.setOrCreate('customizeMusicBgPath', coverPath) } private isMusicCoverHomeBackgroundActive(): boolean { return this.isHomeBgFollowMusicCover && this.customizeMusicBgPath.length > 0 } private shouldUseDiscoveryImageBackgroundOverlay(): boolean { return this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive() } private getDiscoveryTopBarBackgroundColor(): ResourceColor | string { if (!this.isSearchMode && !this.isAlbumMode) { return Color.Transparent } if (this.shouldUseDiscoveryImageBackgroundOverlay()) { return this.isDarkMode ? '#66111111' : '#66FFFFFF' } return $r('app.color.start_window_background_blur') } private getDiscoverySearchBackgroundColor(): ResourceColor | string { if (this.isMusicCoverHomeBackgroundActive()) { return this.isDarkMode ? '#80111111' : '#80FFFFFF' } if (this.isCustomizeBg) { return this.isDarkMode ? '#66111111' : '#73FFFFFF' } return $r('app.color.input_background') } private onDiscoverBreakpointChange(): void { Logger.info(TAG, `FindView onDiscoverBreakpointChange breakpoint=${this.currentBreakpoint}, columns=${this.getDiscoverSectionColumns()}`) this.rebuildPagedSectionSources() } private ensureMediaTable(): void { if (!this.playlistTable) { try { this.playlistTable = new PlaylistTable(getContext(this) as common.Context) } catch (error) { Logger.warn(TAG, `初始化歌单数据库失败: ${this.toErrorMessage(error as Object)}`) } } 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 { 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(REMOTE_POOL_COUNT), this.mediaTable.queryRecentPlayedRecordsAsync(RECENT_POOL_COUNT), this.queryTopPlayedSongs(TOP_PLAYED_COUNT), this.queryFavoriteSongs(), this.queryPlaylists() ]) const remoteSongs = requestResults[0] as VideoItem[] const recentSongs = requestResults[1] as VideoItem[] const topPlayedSongs = requestResults[2] as VideoItem[] const favoriteSongs = requestResults[3] as VideoItem[] const playlists = requestResults[4] as Playlist[] const uniqueRemoteSongs = this.filterUniqueSongs(remoteSongs) const uniqueRecentSongs = this.filterUniqueSongs(recentSongs) const uniqueTopPlayedSongs = this.filterUniqueSongs(topPlayedSongs) const uniqueFavoriteSongs = this.filterUniqueSongs(favoriteSongs) const sortedRecentSongs = buildSortedDiscoverySongs(uniqueRecentSongs, FindCollectionSortType.RECENT_DESC) const sortedFavoriteSongs = buildSortedDiscoverySongs(uniqueFavoriteSongs, FindCollectionSortType.NAME_ASC) this.playlistSongsCache.clear() this.localSongsPool = uniqueLocalSongs this.coveredSongsPool = coveredSongs this.remotePlaybackPool = [] this.applyRemoteDiscoverySongs(uniqueRemoteSongs) this.recentSongsPool = sortedRecentSongs this.topPlayedSongsPool = uniqueTopPlayedSongs this.favoriteSongsPool = sortedFavoriteSongs this.heartPlaylists = playlists this.featuredAlbumsPool = this.buildAlbumGroups(this.localSongsPool, false) this.searchRemoteSongsPool = [] this.swiperSongs = this.pickRandomSongs(this.coveredSongsPool, SWIPER_MAX_COUNT) this.hotSongs = this.pickPreferredSongs(this.localSongsPool, HOT_SECTION_COUNT) this.recentSongs = this.recentSongsPool.slice(0, Math.min(RECENT_SECTION_COUNT, this.recentSongsPool.length)) this.popularSongs = this.pickPreferredSongs(this.topPlayedSongsPool, POPULAR_SECTION_COUNT) this.favoriteSongs = this.favoriteSongsPool.slice(0, Math.min(FAVORITE_SECTION_COUNT, this.favoriteSongsPool.length)) this.featuredAlbums = this.pickAlbumGroups(this.featuredAlbumsPool, FEATURED_ALBUM_COUNT) this.swiperIndex = 0 this.resetPagedSectionIndices() this.rebuildPagedSectionSources() this.refreshText = '' this.heartPlaylistCoverTicket += 1 void this.resolveHeartPlaylistCovers(this.heartPlaylistCoverTicket, playlists) if (uniqueRemoteSongs.length > 0) { void this.prepareRemoteAccounts() this.scheduleVisibleRemoteCoverRepair(false, 1200) } else { void this.bootstrapRemoteDiscoverySongsIfNeeded() } Logger.info( TAG, `[remote-debug] loadDiscoveryContent rawRemote=${remoteSongs.length}, uniqueRemote=${uniqueRemoteSongs.length}, ` + `sample=${this.buildSongDebugLog(uniqueRemoteSongs)}` ) Logger.info( TAG, `发现页加载完成: local=${this.localSongsPool.length}, swiper=${this.swiperSongs.length}, remote=${this.remoteSongs.length}, recent=${this.recentSongs.length}, top=${this.topPlayedSongsPool.length}` ) } catch (error) { const message = this.toErrorMessage(error) Logger.error(TAG, `发现页加载失败: ${message}`) this.swiperSongs = [] this.hotSongs = [] this.cloudMoodSongs = [] this.remoteSongs = [] this.cloudSectionPages = [] this.recentSongs = [] this.popularSongs = [] this.popularSectionPages = [] this.favoriteSongs = [] this.heartPlaylists = [] this.localSongsPool = [] this.coveredSongsPool = [] this.remoteSongsPool = [] this.recentSongsPool = [] this.topPlayedSongsPool = [] this.favoriteSongsPool = [] this.featuredAlbumsPool = [] this.cloudAlbumsPool = [] this.searchRemoteSongsPool = [] this.remotePlaybackPool = [] this.featuredAlbums = [] this.featuredAlbumPages = [] this.cloudAlbums = [] this.cloudAlbumPages = [] this.refreshText = '推荐加载失败,请下拉重试' } finally { this.isRefreshing = false this.isPageLoading = false } } private async queryPlaylists(): Promise { if (!this.playlistTable) { return [] } try { const playlists = await this.playlistTable.queryAllPlaylists() return playlists.filter((item: Playlist) => StrUtil.isNotEmpty(item.id) && StrUtil.isNotEmpty(item.name)) } catch (error) { Logger.warn(TAG, `发现页查询歌单失败: ${this.toErrorMessage(error as Object)}`) return [] } } private applyRemoteDiscoverySongs(items: VideoItem[], refreshSections: boolean = false): void { this.remoteSongsPool = items this.cloudAlbumsPool = this.buildAlbumGroups(this.remoteSongsPool, true) this.cloudMoodSongs = this.pickPreferredSongs(this.remoteSongsPool, HOT_SECTION_COUNT) this.remoteSongs = this.pickPreferredSongs(this.remoteSongsPool, CLOUD_SECTION_COUNT) this.cloudAlbums = this.pickAlbumGroups(this.cloudAlbumsPool, CLOUD_ALBUM_COUNT) this.logRemoteDiscoveryCoverState('remoteSongsPool', this.remoteSongsPool) this.logRemoteDiscoveryCoverState('漫步云端', this.remoteSongs) this.logRemoteDiscoveryCoverState('云卷云舒', this.cloudMoodSongs) if (refreshSections) { this.cloudSectionPageIndex = 0 this.cloudAlbumPageIndex = 0 this.updateCloudSectionPages() this.updateCloudAlbumPages() } } private cacheRemoteAccounts(accounts: WebDavAccount[]): void { this.remoteAccountMap.clear() for (let i = 0; i < accounts.length; i++) { const account = accounts[i] if (account.id !== undefined && account.id !== null) { this.remoteAccountMap.set(account.id.toString(), account) } } } private async prepareRemoteAccounts(): Promise { try { const context = getContext(this) as common.Context this.remoteDriveManager.setContext(context) await this.remoteDriveManager.createWebDavTableInDB() await this.remoteDriveManager.queryWebDavAccountsFromDB() const accounts: WebDavAccount[] = this.remoteDriveManager.getAllWebDavAccounts() .filter((account: WebDavAccount) => account.id !== undefined && account.id !== null && account.id > 0) this.cacheRemoteAccounts(accounts) return accounts } catch (error) { Logger.warn(TAG, `发现页加载远程账号失败: ${this.toErrorMessage(error)}`) this.remoteAccountMap.clear() return [] } } private async ensureRemoteAccountMapReady(): Promise> { if (this.remoteAccountMap.size > 0) { return this.remoteAccountMap } await this.prepareRemoteAccounts() return this.remoteAccountMap } private collectVisibleRemoteCoverTargets(): VideoItem[] { const targets: VideoItem[] = [] targets.push(...this.remoteSongs) targets.push(...this.cloudMoodSongs) for (let index = 0; index < this.cloudAlbums.length; index += 1) { const group = this.cloudAlbums[index] if (group.songs.length > 0) { targets.push(group.songs[0]) } } // 只修当前发现页真正可见的一小批远程歌曲,避免初次进入时后台任务过重。 return this.filterUniqueSongs(targets).slice(0, REMOTE_VISIBLE_COVER_RESOLVE_LIMIT) } private refreshVisibleRemoteAlbumCovers(): void { for (let index = 0; index < this.cloudAlbumsPool.length; index += 1) { const coverSong = this.pickAlbumCoverSong(this.cloudAlbumsPool[index].songs) this.cloudAlbumsPool[index].coverPath = coverSong?.pixelMapPath ?? '' } for (let index = 0; index < this.cloudAlbums.length; index += 1) { const coverSong = this.pickAlbumCoverSong(this.cloudAlbums[index].songs) this.cloudAlbums[index].coverPath = coverSong?.pixelMapPath ?? '' } } private clearPendingRemoteCoverRepair(): void { if (this.remoteCoverRepairTimer >= 0) { clearTimeout(this.remoteCoverRepairTimer) this.remoteCoverRepairTimer = -1 } } private scheduleVisibleRemoteCoverRepair(persistResolvedCover: boolean = false, delayMs: number = 800): void { this.clearPendingRemoteCoverRepair() const ticket = ++this.remoteCoverRepairTicket this.remoteCoverRepairTimer = setTimeout(() => { this.remoteCoverRepairTimer = -1 const coverTargets = this.collectVisibleRemoteCoverTargets() if (coverTargets.length === 0) { return } Logger.info(TAG, `[cover-debug] scheduleVisibleRemoteCoverRepair ticket=${ticket}, count=${coverTargets.length}, ` + `persist=${persistResolvedCover}, delayMs=${delayMs}`) void this.resolveRemoteSongCovers(coverTargets, persistResolvedCover) .then(() => { if (ticket !== this.remoteCoverRepairTicket) { return } this.refreshVisibleRemoteAlbumCovers() this.remoteSongs = this.remoteSongs.slice() this.cloudMoodSongs = this.cloudMoodSongs.slice() this.cloudAlbums = this.cloudAlbums.slice() this.cloudSectionPages = this.cloudSectionPages.slice() this.cloudAlbumPages = this.cloudAlbumPages.slice() this.logRemoteDiscoveryCoverState('remoteSongsPool', this.remoteSongsPool) this.logRemoteDiscoveryCoverState('漫步云端', this.remoteSongs) this.logRemoteDiscoveryCoverState('云卷云舒', this.cloudMoodSongs) }) .catch((error: Object) => { Logger.warn(TAG, `发现页后台修正远程封面失败: ${this.toErrorMessage(error)}`) }) }, delayMs) } private async resolveRemoteSongCovers(items: VideoItem[], persistResolvedCover: boolean): Promise { if (!items || items.length === 0) { return items } const accountMap = await this.ensureRemoteAccountMapReady() const tasks: Promise[] = items.map(async (item: VideoItem): Promise => { const accountId = item.webdav_account_id ?? '' const account = accountMap.get(accountId) if (!account) { return item } const resolvedCover = await resolveRemoteCoverForSong(item, account) if (StrUtil.isEmpty(resolvedCover) || item.pixelMapPath === resolvedCover) { return item } Logger.info( TAG, `[cover-debug] repairRemoteSongCover title=${this.getSongTitle(item)}, type=${item.type}, acc=${accountId}, ` + `raw=${this.sanitizeCoverValue(item.pixelMapPath)}, resolved=${this.sanitizeCoverValue(resolvedCover)}` ) item.pixelMapPath = resolvedCover if (persistResolvedCover && this.mediaTable) { await this.mediaTable.saveOrUpdateWebDavItem(cloneVideoItem(item)) } return item }) return await Promise.all(tasks) } private async ensureRemoteDiscoverySongsAvailable(): Promise { if (!this.mediaTable) { return } if (this.remoteSongsPool.length > 0 || this.cloudAlbumsPool.length > 0) { return } const remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync(REMOTE_POOL_COUNT)) Logger.info( TAG, `[remote-debug] ensureRemoteDiscoverySongsAvailable dbRemote=${remoteSongs.length}, ` + `sample=${this.buildSongDebugLog(remoteSongs)}` ) if (remoteSongs.length > 0) { this.applyRemoteDiscoverySongs(remoteSongs, true) this.scheduleVisibleRemoteCoverRepair() return } await this.bootstrapRemoteDiscoverySongsIfNeeded() } private async prepareActiveRemoteAccount(fallbackSongs?: VideoItem[]): Promise { try { const context = getContext(this) as common.Context this.remoteDriveManager.setContext(context) await this.remoteDriveManager.createWebDavTableInDB() await this.remoteDriveManager.queryWebDavAccountsFromDB() const accounts: WebDavAccount[] = this.remoteDriveManager.getAllWebDavAccounts() if (accounts.length === 0) { return undefined } const activeAccount = this.remoteDriveManager.getActivatedWebDavAccount() || accounts[0] // 云端漫游优先挑“当前发现页已有歌曲所属账号”,避免固定落到第一个账号。 const preferredAccountId = findPreferredRemotePlaybackAccountId( fallbackSongs ?? [], activeAccount?.id?.toString() ?? '' ) if (StrUtil.isNotEmpty(preferredAccountId)) { for (let index = 0; index < accounts.length; index += 1) { if (accounts[index].id?.toString() === preferredAccountId) { Logger.info( TAG, `[remote-debug] prepareActiveRemoteAccount selected account=${accounts[index].name}, ` + `type=${accounts[index].webType}, preferredAccountId=${preferredAccountId}, ` + `fallbackSongs=${fallbackSongs?.length ?? 0}` ) return accounts[index] } } } return activeAccount } catch (error) { Logger.warn(TAG, `发现页加载远程账号失败: ${this.toErrorMessage(error)}`) return undefined } } private async queryIndexedRemotePlaybackSongs(fallbackSongs: VideoItem[] = []): Promise { const account = await this.prepareActiveRemoteAccount(fallbackSongs) if (!account) { Logger.warn(TAG, '[remote-debug] queryIndexedRemotePlaybackSongs skip: no active account') return [] } const supportsGlobalIndex = this.remoteDriveManager.supportsGlobalSearchIndexForAccount(account) const isIndexReady = supportsGlobalIndex && this.remoteDriveManager.isGlobalSearchIndexReady(account) Logger.info( TAG, `[remote-debug] queryIndexedRemotePlaybackSongs account=${account.name}, type=${account.webType}, ` + `supportsGlobalIndex=${supportsGlobalIndex}, isIndexReady=${isIndexReady}` ) if (supportsGlobalIndex && !isIndexReady) { ToastUtil.showToast('云端加载,请稍候') // 账号索引未就绪时,如果 DB 里已经有远程歌曲,就先用现有歌曲兜底播放。 if (!shouldWaitForIndexedRemotePlayback(isIndexReady, fallbackSongs.length)) { Logger.info( TAG, `[remote-debug] queryIndexedRemotePlaybackSongs fallbackToDb account=${account.name}, ` + `fallbackSongCount=${fallbackSongs.length}` ) void this.remoteDriveManager.ensureGlobalSearchIndex(account) return [] } } const indexedSongs = await this.remoteDriveManager.getGlobalSearchIndexSongs(account) const clonedSongs = indexedSongs.map((item: VideoItem) => cloneVideoItem(item)) Logger.info(TAG, `发现页全量云端索引池加载完成: account=${account.name}, type=${account.webType}, indexed=${clonedSongs.length}, ` + `sample=${this.buildSongDebugLog(clonedSongs)}`) return this.filterUniqueSongs(clonedSongs) } private async bootstrapRemoteDiscoverySongsIfNeeded(): Promise { if (this.isRemoteBootstrapLoading || !this.mediaTable || this.remoteSongsPool.length > 0) { return } this.isRemoteBootstrapLoading = true try { const account = await this.prepareActiveRemoteAccount() if (!account) { return } Logger.info(TAG, `发现页开始预热远程歌曲: account=${account.name}, type=${account.webType}`) const seedSongs = await this.remoteDriveManager.getDiscoverySeedSongs(account, REMOTE_POOL_COUNT) Logger.info( TAG, `[remote-debug] bootstrapRemoteDiscoverySongs seedCount=${seedSongs.length}, sample=${this.buildSongDebugLog(seedSongs)}` ) if (seedSongs.length === 0) { Logger.info(TAG, `发现页远程预热未拿到歌曲: account=${account.name}`) return } let savedCount = 0 for (let index = 0; index < seedSongs.length; index += 1) { const seedSong = cloneVideoItem(seedSongs[index]) if (!seedSong.webdav_account_id && account.id) { seedSong.webdav_account_id = account.id.toString() } if (StrUtil.isEmpty(seedSong.id)) { seedSong.id = seedSong.remote_rel_path || seedSong.filePath } const saved = await this.mediaTable.saveOrUpdateWebDavItem(seedSong) if (saved) { savedCount += 1 } else { Logger.warn( TAG, `[remote-debug] bootstrapRemoteDiscoverySongs save failed name=${this.getSongTitle(seedSong)}, ` + `type=${seedSong.type}, account=${seedSong.webdav_account_id ?? ''}, path=${this.sanitizeSongPath(seedSong.filePath)}` ) } } if (savedCount <= 0) { return } const refreshedRemoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync(REMOTE_POOL_COUNT)) if (refreshedRemoteSongs.length === 0) { return } this.applyRemoteDiscoverySongs(refreshedRemoteSongs, true) this.scheduleVisibleRemoteCoverRepair() Logger.info( TAG, `发现页远程预热完成: saved=${savedCount}, remote=${refreshedRemoteSongs.length}, ` + `sample=${this.buildSongDebugLog(refreshedRemoteSongs)}` ) } catch (error) { Logger.warn(TAG, `发现页远程预热失败: ${this.toErrorMessage(error)}`) } finally { this.isRemoteBootstrapLoading = false } } private queryTopPlayedSongs(limitCount: number): Promise { return new Promise((resolve) => { if (!this.mediaTable) { resolve([]) return } this.mediaTable.queryByPlayCountDesc(limitCount, (result: VideoItem[]) => { resolve(result) }) }) } private queryFavoriteSongs(): Promise { return new Promise((resolve) => { if (!this.mediaTable) { resolve([]) return } this.mediaTable.queryByisFav(1, (result: VideoItem[]) => { resolve(result) }) }) } private filterSongsWithCover(items: VideoItem[]): VideoItem[] { const result: VideoItem[] = [] const seen: Set = new Set() 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 = new Set() 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 pickPreferredSongs(items: VideoItem[], count: number): VideoItem[] { const songsWithCover = this.filterSongsWithCover(items) const result = this.pickRandomSongs(songsWithCover, count) if (result.length >= count) { return result } const seen: Set = new Set() for (let i = 0; i < result.length; i++) { seen.add(result[i].filePath) } const allSongs = this.pickRandomSongs(items, items.length) for (let i = 0; i < allSongs.length && result.length < count; i++) { const item = allSongs[i] if (seen.has(item.filePath)) { continue } seen.add(item.filePath) result.push(item) } return result } private isSameSongSelection(left: VideoItem[], right: VideoItem[]): boolean { if (left.length !== right.length) { return false } for (let i = 0; i < left.length; i++) { if (left[i].filePath !== right[i].filePath) { return false } } return true } private isSameLeadingSongs(left: VideoItem[], right: VideoItem[], count: number): boolean { const safeCount = Math.min(count, left.length, right.length) for (let i = 0; i < safeCount; i++) { if (left[i].filePath !== right[i].filePath) { return false } } return safeCount > 0 } private isSameAlbumSelection(left: FindAlbumGroup[], right: FindAlbumGroup[]): boolean { if (left.length !== right.length) { return false } for (let i = 0; i < left.length; i++) { if (left[i].id !== right[i].id) { return false } } return true } private isSameLeadingAlbums(left: FindAlbumGroup[], right: FindAlbumGroup[], count: number): boolean { const safeCount = Math.min(count, left.length, right.length) for (let i = 0; i < safeCount; i++) { if (left[i].id !== right[i].id) { return false } } return safeCount > 0 } private rotateSongs(items: VideoItem[], offset: number): VideoItem[] { if (items.length === 0) { return [] } const safeOffset = ((offset % items.length) + items.length) % items.length if (safeOffset === 0) { return items.slice() } return items.slice(safeOffset).concat(items.slice(0, safeOffset)) } private rotateAlbums(items: FindAlbumGroup[], offset: number): FindAlbumGroup[] { if (items.length === 0) { return [] } const safeOffset = ((offset % items.length) + items.length) % items.length if (safeOffset === 0) { return items.slice() } return items.slice(safeOffset).concat(items.slice(0, safeOffset)) } private ensureDifferentLeadingSongs(next: VideoItem[], current: VideoItem[]): VideoItem[] { const pageSize = this.getSectionPageSize(this.getDiscoverSectionColumns()) if (!this.isSameLeadingSongs(next, current, pageSize)) { return next } if (next.length <= pageSize) { return next } const rotated = this.rotateSongs(next, pageSize) if (!this.isSameLeadingSongs(rotated, current, pageSize)) { return rotated } return next } private ensureDifferentLeadingAlbums(next: FindAlbumGroup[], current: FindAlbumGroup[]): FindAlbumGroup[] { const pageSize = this.getSectionPageSize(this.getDiscoverSectionColumns()) if (!this.isSameLeadingAlbums(next, current, pageSize)) { return next } if (next.length <= pageSize) { return next } const rotated = this.rotateAlbums(next, pageSize) if (!this.isSameLeadingAlbums(rotated, current, pageSize)) { return rotated } return next } private pickDifferentPreferredSongs(items: VideoItem[], count: number, current: VideoItem[]): VideoItem[] { const next = this.pickPreferredSongs(items, count) if (!this.isSameSongSelection(next, current) || items.length <= current.length) { return next } const currentSet: Set = new Set() for (let i = 0; i < current.length; i++) { currentSet.add(current[i].filePath) } const differentItems: VideoItem[] = [] for (let i = 0; i < items.length; i++) { const item = items[i] if (currentSet.has(item.filePath)) { continue } differentItems.push(item) } if (differentItems.length === 0) { return next } const refreshed = this.pickPreferredSongs(differentItems, count) if (refreshed.length >= Math.min(count, items.length)) { return refreshed } const result: VideoItem[] = refreshed.slice() const seen: Set = new Set() for (let i = 0; i < result.length; i++) { seen.add(result[i].filePath) } for (let i = 0; i < next.length && result.length < count; i++) { const item = next[i] if (seen.has(item.filePath)) { continue } seen.add(item.filePath) result.push(item) } return result } private pickDifferentAlbumGroups(items: FindAlbumGroup[], count: number, current: FindAlbumGroup[]): FindAlbumGroup[] { const next = this.pickAlbumGroups(items, count) if (!this.isSameAlbumSelection(next, current) || items.length <= current.length) { return next } const currentSet: Set = new Set() for (let i = 0; i < current.length; i++) { currentSet.add(current[i].id) } const differentItems: FindAlbumGroup[] = [] for (let i = 0; i < items.length; i++) { const item = items[i] if (currentSet.has(item.id)) { continue } differentItems.push(item) } if (differentItems.length === 0) { return next } const refreshed = this.pickAlbumGroups(differentItems, count) if (refreshed.length >= Math.min(count, items.length)) { return refreshed } const result: FindAlbumGroup[] = refreshed.slice() const seen: Set = new Set() for (let i = 0; i < result.length; i++) { seen.add(result[i].id) } for (let i = 0; i < next.length && result.length < count; i++) { const item = next[i] if (seen.has(item.id)) { continue } seen.add(item.id) result.push(item) } return result } private shuffleAlbumGroups(items: FindAlbumGroup[]): FindAlbumGroup[] { const copy: FindAlbumGroup[] = 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 } private pickAlbumGroups(items: FindAlbumGroup[], count: number): FindAlbumGroup[] { const preferred: FindAlbumGroup[] = [] const fallback: FindAlbumGroup[] = [] for (let i = 0; i < items.length; i++) { const item = items[i] if (item.songCount >= PREFERRED_ALBUM_MIN_COUNT) { preferred.push(item) } else { fallback.push(item) } } const result: FindAlbumGroup[] = [] const shuffledPreferred = this.shuffleAlbumGroups(preferred) const shuffledFallback = this.shuffleAlbumGroups(fallback) for (let i = 0; i < shuffledPreferred.length && result.length < count; i++) { result.push(shuffledPreferred[i]) } for (let i = 0; i < shuffledFallback.length && result.length < count; i++) { result.push(shuffledFallback[i]) } return result } private buildAlbumGroups(items: VideoItem[], isRemote: boolean): FindAlbumGroup[] { const albumMap: Map = new Map() for (let i = 0; i < items.length; i++) { const item = items[i] const albumName = item.album?.trim() ?? '' if (albumName.length === 0) { continue } const artistName = item.artist?.trim() ?? '' const groupKey = `${isRemote ? item.type : CommonConstants.TYPE_LOCAL}_${albumName}_${artistName}` const groupSongs = albumMap.get(groupKey) if (groupSongs) { groupSongs.push(item) } else { albumMap.set(groupKey, [item]) } } const result: FindAlbumGroup[] = [] albumMap.forEach((songs: VideoItem[], key: string) => { const sortedSongs = this.sortAlbumSongs(songs) const firstSong = sortedSongs[0] const title = firstSong.album?.trim() ?? '' if (title.length === 0) { return } const coverSong = this.pickAlbumCoverSong(sortedSongs) result.push({ id: key, title: title, artist: this.resolveAlbumArtist(sortedSongs), coverPath: coverSong?.pixelMapPath ?? '', songCount: sortedSongs.length, songs: sortedSongs, isRemote: isRemote, sourceLabel: isRemote ? this.getCloudTypeLabel(firstSong) : '本地' }) }) result.sort((left: FindAlbumGroup, right: FindAlbumGroup) => { if (right.songCount !== left.songCount) { return right.songCount - left.songCount } return left.title.localeCompare(right.title) }) return result } private sortAlbumSongs(items: VideoItem[]): VideoItem[] { return buildSortedDiscoverySongs(items, FindCollectionSortType.TRACK_ASC) } private pickAlbumCoverSong(items: VideoItem[]): VideoItem | undefined { for (let i = 0; i < items.length; i++) { if (StrUtil.isNotEmpty(items[i].pixelMapPath)) { return items[i] } } return items[0] } private resolveAlbumArtist(items: VideoItem[]): string { for (let i = 0; i < items.length; i++) { const artistName = items[i].artist?.trim() ?? '' if (artistName.length > 0) { return artistName } } return '未知歌手' } private syncFindCanBackState(): void { AppStorage.setOrCreate('findCanBack', this.isSearchMode || this.isAlbumMode) } private handleInnerBack(): boolean { if (this.isSearchMode) { this.exitSearchMode() return true } if (this.isAlbumMode) { this.exitAlbumMode() return true } return false } private emitPlaylistPlay(playlistId: string, playlistName: string, songs: VideoItem[], startIndex: number, playType?: number): void { if (songs.length === 0) { ToastUtil.showToast('暂无可播放歌曲') return } const safeIndex = Math.min(Math.max(startIndex, 0), songs.length - 1) Logger.info( TAG, `[remote-debug] emitPlaylistPlay id=${playlistId}, name=${playlistName}, count=${songs.length}, ` + `startIndex=${safeIndex}, playType=${playType ?? -1}, sample=${this.buildSongDebugLog(songs)}` ) if (playlistId === 'find-album-playlist' || playlistId.indexOf('find-') === 0) { setFindPlaylist(playlistId, playlistName, songs, safeIndex) } const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY } const playlistData = new PlaylistPlayRequest( playlistId, playlistName, songs.length, safeIndex, songs.map((item: VideoItem): string => item.filePath), false, playType ) savePendingPlaylistPlay(playlistData) emitter.emit(eventPlaylistPlay, { data: playlistData }) } private getCurrentPlaybackQueue(): VideoItem[] { const queue = AppStorage.get('songList') as VideoItem[] | undefined return Array.isArray(queue) ? queue : [] } private getCurrentPlaybackIndex(): number { const currentIndex = AppStorage.get('currIndex') as number | undefined return typeof currentIndex === 'number' ? currentIndex : -1 } private canFavoriteSong(item: VideoItem): boolean { return StrUtil.isNotEmpty(item.filePath) } private canDeleteSong(item: VideoItem): boolean { return item.type === CommonConstants.TYPE_LOCAL && StrUtil.isNotEmpty(item.filePath) } private canQueueSongForNextPlay(item: VideoItem): boolean { return StrUtil.isNotEmpty(item.filePath) } private isSongFavorite(item: VideoItem): boolean { return item.isFav === 1 } private syncPlaybackQueueStores(previousQueue: VideoItem[], nextQueue: VideoItem[], currentIndex: number): void { const findQueue = getFindVideoItems() if (findQueue.length > 0 && this.isSameSongSelection(previousQueue, findQueue)) { const playlistId = getFindPlaylistId().length > 0 ? getFindPlaylistId() : 'find-search' const playlistName = getFindPlaylistName().length > 0 ? getFindPlaylistName() : '发现搜索' setFindPlaylist(playlistId, playlistName, nextQueue, currentIndex) } const webdavQueue = getWebdavVideoItems() if (webdavQueue.length > 0 && this.isSameSongSelection(previousQueue, webdavQueue)) { setWebdavPlaylist(nextQueue, currentIndex) } const navidromeQueue = getNavidromeVideoItems() if (navidromeQueue.length > 0 && this.isSameSongSelection(previousQueue, navidromeQueue)) { setNavidromePlaylist(nextQueue, currentIndex) } } private handleAddSongToNextPlay(song: VideoItem): void { if (!this.canQueueSongForNextPlay(song)) { ToastUtil.showToast('当前歌曲暂不支持加入下一首播放') return } const currentQueue = this.getCurrentPlaybackQueue() const result = insertSongToNextPlayQueue( currentQueue, this.currentSong?.filePath ?? '', this.getCurrentPlaybackIndex(), song ) if (result.status === QueueInsertStatus.INVALID_SONG) { ToastUtil.showToast('当前歌曲暂不支持加入下一首播放') return } if (result.status === QueueInsertStatus.ALREADY_PLAYING) { ToastUtil.showToast('当前正在播放这首歌') return } if (result.status === QueueInsertStatus.START_PLAY) { this.emitPlaylistPlay('find-search-next', '发现搜索', [song], 0) ToastUtil.showToast('已开始播放') return } AppStorage.setOrCreate('songList', result.queue) AppStorage.setOrCreate('currIndex', result.currentIndex) PreferencesUtil.putSync('LastMusicList', result.queue) this.syncPlaybackQueueStores(currentQueue, result.queue, result.currentIndex) ToastUtil.showToast('已添加到下一首播放') } @Builder private SongContextMenuBuilder(item: VideoItem) { Menu() { MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.forward_end_fill')), content: '下一首播放' }) .visibility(this.canQueueSongForNextPlay(item) ? Visibility.Visible : Visibility.None) .onClick(() => { this.handleAddSongToNextPlay(item) }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier(this.isSongFavorite(item) ? $r('sys.symbol.heart_fill') : $r('sys.symbol.heart')), content: this.isSongFavorite(item) ? '取消收藏' : '收藏' }) .visibility(this.canFavoriteSong(item) ? Visibility.Visible : Visibility.None) .onClick(() => { void this.handleAlbumFavoriteSong(item) }) MenuItem({ symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trash')), content: '删除' }) .visibility(this.canDeleteSong(item) ? Visibility.Visible : Visibility.None) .onClick(() => { this.openDeleteSongDialog(item) }) } .attributeModifier(new MenuModifier()) } private async getFullLocalSongsPool(): Promise { if (this.localSongsPool.length > 0) { return this.localSongsPool } if (!this.mediaTable) { return [] } const localSongs = await this.mediaTable.queryAllVideos() this.localSongsPool = this.filterUniqueSongs(localSongs) return this.localSongsPool } private async getFullRemoteSongsPool(): Promise { if (this.remotePlaybackPool.length > 0) { Logger.info( TAG, `[remote-debug] getFullRemoteSongsPool cacheHit=${this.remotePlaybackPool.length}, ` + `sample=${this.buildSongDebugLog(this.remotePlaybackPool)}` ) return this.remotePlaybackPool } if (!this.mediaTable) { return [] } let remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync()) const fallbackSongs = remoteSongs.length > 0 ? remoteSongs : this.remoteSongsPool const indexedSongs = await this.queryIndexedRemotePlaybackSongs(fallbackSongs) if (indexedSongs.length === 0 && remoteSongs.length === 0) { await this.ensureRemoteDiscoverySongsAvailable() remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync()) } Logger.info( TAG, `[remote-debug] getFullRemoteSongsPool beforeSelect indexed=${indexedSongs.length}, db=${remoteSongs.length}, ` + `indexedSample=${this.buildSongDebugLog(indexedSongs)}, dbSample=${this.buildSongDebugLog(remoteSongs)}` ) this.remotePlaybackPool = buildPreferredRemotePlaybackPool(indexedSongs, remoteSongs) Logger.info( TAG, `发现页全量云端播放池已就绪: indexed=${indexedSongs.length}, db=${remoteSongs.length}, ` + `selected=${this.remotePlaybackPool.length}, source=${indexedSongs.length > 0 ? 'indexed' : 'db'}, ` + `sample=${this.buildSongDebugLog(this.remotePlaybackPool)}` ) return this.remotePlaybackPool } private async playFromFullRemoteQueue(playlistId: string, playlistName: string, fallbackSongs: VideoItem[], targetSong?: VideoItem, randomStart: boolean = false): Promise { const fullQueue = await this.getFullRemoteSongsPool() if (randomStart) { const queue = fullQueue.length > 0 ? fullQueue : fallbackSongs if (queue.length === 0) { ToastUtil.showToast('需要先配置网盘并加载歌曲后才能播放') return } const startIndex = Math.floor(Math.random() * queue.length) Logger.info( TAG, `[remote-debug] playFromFullRemoteQueue random playlist=${playlistId}, fullQueue=${fullQueue.length}, ` + `fallback=${fallbackSongs.length}, selectedQueue=${queue.length}, source=${fullQueue.length > 0 ? 'full' : 'fallback'}, ` + `startIndex=${startIndex}, startSong=${this.buildSongDebugItem(queue[startIndex])}` ) this.emitPlaylistPlay(playlistId, playlistName, queue, startIndex, 3) return } if (!targetSong) { const queue = fullQueue.length > 0 ? fullQueue : fallbackSongs if (queue.length === 0) { ToastUtil.showToast('暂无可播放歌曲') return } this.emitPlaylistPlay(playlistId, playlistName, queue, 0) return } const targetFilePath = targetSong.filePath || '' Logger.info( TAG, `[remote-debug] playFromFullRemoteQueue precise playlist=${playlistId}, target=${this.buildSongDebugItem(targetSong)}, ` + `fullQueue=${fullQueue.length}, fallback=${fallbackSongs.length}` ) const fullIndex = resolveQueueStartIndex(fullQueue, targetFilePath) if (fullIndex >= 0) { this.emitPlaylistPlay(playlistId, playlistName, fullQueue, fullIndex) return } const fallbackIndex = resolveQueueStartIndex(fallbackSongs, targetFilePath) if (fallbackIndex >= 0) { Logger.warn(TAG, `全量云端队列未命中当前歌曲,回退到当前卡片队列: ${targetFilePath}`) this.emitPlaylistPlay(playlistId, playlistName, fallbackSongs, fallbackIndex) return } const queue = fullQueue.length > 0 ? fullQueue : fallbackSongs if (queue.length === 0) { ToastUtil.showToast('暂无可播放歌曲') return } this.emitPlaylistPlay(playlistId, playlistName, queue, 0) } private async handleActionButtonTap(type: string): Promise { if (type === 'top') { this.emitPlaylistPlay('find-top-played', '最近爱听', this.topPlayedSongsPool, 0) return } if (type === 'local-random') { const allLocalSongs = await this.getFullLocalSongsPool() if (allLocalSongs.length === 0) { ToastUtil.showToast('本地歌曲为空') return } const startIndex = Math.floor(Math.random() * allLocalSongs.length) this.emitPlaylistPlay('find-local-random-all', '随心所欲', allLocalSongs, startIndex, 3) return } const allRemoteSongs = await this.getFullRemoteSongsPool() Logger.info( TAG, `[remote-debug] handleActionButtonTap cloud-random remotePlaybackPool=${allRemoteSongs.length}, ` + `remoteSongsPool=${this.remoteSongsPool.length}, remoteSongs=${this.remoteSongs.length}, cloudMoodSongs=${this.cloudMoodSongs.length}` ) if (allRemoteSongs.length === 0) { ToastUtil.showToast('需要先配置网盘并加载歌曲后才能播放') return } await this.playFromFullRemoteQueue('find-random-cloud', '云端漫游', this.remoteSongsPool, undefined, true) } private handleSwiperTap(index: number): void { this.emitPlaylistPlay('find-swiper', '发现推荐', this.swiperSongs, index) } private handleRemoteSongTap(index: number): void { const targetSong = this.remoteSongs[index] void this.playFromFullRemoteQueue('find-cloud', '漫步云端', this.remoteSongs, targetSong) } private handleHotSongTap(index: number): void { this.emitPlaylistPlay('find-local-random', '本地随机', this.hotSongs, index) } private handleCloudMoodSongTap(index: number): void { const targetSong = this.cloudMoodSongs[index] void this.playFromFullRemoteQueue('find-cloud-mood', '云卷云舒', this.cloudMoodSongs, targetSong) } private handleRecentSongTap(index: number): void { this.emitPlaylistPlay('find-recent', '最近播放', this.recentSongs, index) } private handlePopularSongTap(index: number): void { this.emitPlaylistPlay('find-popular', '热门歌曲', this.popularSongs, index) } private handleFavoriteSongTap(index: number): void { this.emitPlaylistPlay('find-favorite', '我的收藏', this.favoriteSongs, index) } private refreshLocalRandomSongs(): void { this.hotSongs = this.pickDifferentPreferredSongs(this.localSongsPool, HOT_SECTION_COUNT, this.hotSongs) } private refreshCloudMoodSongs(): void { this.cloudMoodSongs = this.pickDifferentPreferredSongs(this.remoteSongsPool, HOT_SECTION_COUNT, this.cloudMoodSongs) } private refreshCloudSongs(): void { const current = this.remoteSongs.slice() const next = this.pickDifferentPreferredSongs(this.remoteSongsPool, CLOUD_SECTION_COUNT, this.remoteSongs) const updated = this.ensureDifferentLeadingSongs(next, this.remoteSongs) Logger.info( TAG, `FindView refreshCloudSongs pool=${this.remoteSongsPool.length}, current=${this.buildSongSelectionLog(current)}, next=${this.buildSongSelectionLog(next)}, updated=${this.buildSongSelectionLog(updated)}` ) this.remoteSongs = updated this.cloudSectionPageIndex = 0 this.updateCloudSectionPages() } private refreshRecentSongs(): void { this.recentSongs = this.recentSongsPool.slice(0, Math.min(RECENT_SECTION_COUNT, this.recentSongsPool.length)) this.recentSectionPageIndex = 0 } private refreshPopularSongs(): void { const current = this.popularSongs.slice() const next = this.pickDifferentPreferredSongs(this.topPlayedSongsPool, POPULAR_SECTION_COUNT, this.popularSongs) const updated = this.ensureDifferentLeadingSongs(next, this.popularSongs) Logger.info( TAG, `FindView refreshPopularSongs pool=${this.topPlayedSongsPool.length}, current=${this.buildSongSelectionLog(current)}, next=${this.buildSongSelectionLog(next)}, updated=${this.buildSongSelectionLog(updated)}` ) this.popularSongs = updated this.popularSectionPageIndex = 0 this.updatePopularSectionPages() } private refreshFeaturedAlbums(): void { const current = this.featuredAlbums.slice() const next = this.pickDifferentAlbumGroups(this.featuredAlbumsPool, FEATURED_ALBUM_COUNT, this.featuredAlbums) const updated = this.ensureDifferentLeadingAlbums(next, this.featuredAlbums) Logger.info( TAG, `FindView refreshFeaturedAlbums pool=${this.featuredAlbumsPool.length}, current=${this.buildAlbumSelectionLog(current)}, next=${this.buildAlbumSelectionLog(next)}, updated=${this.buildAlbumSelectionLog(updated)}` ) this.featuredAlbums = updated this.featuredAlbumPageIndex = 0 this.updateFeaturedAlbumPages() } private refreshCloudAlbums(): void { const current = this.cloudAlbums.slice() const next = this.pickDifferentAlbumGroups(this.cloudAlbumsPool, CLOUD_ALBUM_COUNT, this.cloudAlbums) const updated = this.ensureDifferentLeadingAlbums(next, this.cloudAlbums) Logger.info( TAG, `FindView refreshCloudAlbums pool=${this.cloudAlbumsPool.length}, current=${this.buildAlbumSelectionLog(current)}, next=${this.buildAlbumSelectionLog(next)}, updated=${this.buildAlbumSelectionLog(updated)}` ) this.cloudAlbums = updated this.cloudAlbumPageIndex = 0 this.updateCloudAlbumPages() } private openLocalSpecialList(target: string): void { if (target === 'recent') { this.currentAlbumDefaultSortType = FindCollectionSortType.RECENT_DESC this.currentAlbumSupportRecentPlayedSort = true this.openSongCollectionDetail('find-recent-collection', '最近播放', '', this.recentSongsPool) return } if (target === 'favorite') { this.currentAlbumDefaultSortType = FindCollectionSortType.NAME_ASC this.currentAlbumSupportRecentPlayedSort = false this.openSongCollectionDetail('find-favorite-collection', '我的收藏', '', this.favoriteSongsPool) return } } private openSongCollectionDetail(id: string, title: string, sourceLabel: string, songs: VideoItem[]): void { if (songs.length === 0) { ToastUtil.showToast('暂无可展示歌曲') return } const coverSong = this.pickAlbumCoverSong(songs) this.currentAlbumId = id this.currentAlbumTitle = title this.currentAlbumArtist = '' this.currentAlbumCoverPath = coverSong?.pixelMapPath ?? '' this.currentAlbumSourceLabel = sourceLabel this.currentAlbumSongs = songs.slice() this.isSearchMode = false this.getUIContext()?.animateTo({ duration: 500 }, () => { this.isAlbumMode = true }) } private openAlbumDetail(album: FindAlbumGroup): void { this.currentAlbumDefaultSortType = FindCollectionSortType.TRACK_ASC this.currentAlbumSupportRecentPlayedSort = false this.currentAlbumId = album.id this.currentAlbumTitle = album.title this.currentAlbumArtist = album.artist this.currentAlbumCoverPath = album.coverPath this.currentAlbumSourceLabel = album.isRemote ? '云端专辑' : '精选专辑' this.currentAlbumSongs = album.songs.slice() this.isAlbumMode = true this.isSearchMode = false } private getPlaylistCover(playlist: Playlist): string | Resource { if (StrUtil.isNotEmpty(playlist.coverPath)) { return playlist.coverPath as string } const cachedSongs = this.playlistSongsCache.get(playlist.id) const coverSong = cachedSongs ? this.pickAlbumCoverSong(cachedSongs) : undefined if (coverSong && StrUtil.isNotEmpty(coverSong.pixelMapPath)) { return coverSong.pixelMapPath as string } return $r('app.media.alt') } private getPlaylistSubtitle(playlist: Playlist): string { if (StrUtil.isNotEmpty(playlist.description)) { return playlist.description as string } return `${playlist.songCount} 首歌曲` } private presentPlaylistDetail(playlist: Playlist, songs: VideoItem[]): void { if (songs.length === 0) { ToastUtil.showToast('歌单里还没有可展示歌曲') return } this.currentAlbumDefaultSortType = FindCollectionSortType.TRACK_ASC this.currentAlbumSupportRecentPlayedSort = false const coverSong = this.pickAlbumCoverSong(songs) this.currentAlbumId = `find-playlist-${playlist.id}` this.currentAlbumTitle = playlist.name this.currentAlbumArtist = '心动歌单' this.currentAlbumCoverPath = StrUtil.isNotEmpty(playlist.coverPath) ? playlist.coverPath as string : (coverSong?.pixelMapPath ?? '') this.currentAlbumSourceLabel = '心动歌单' this.currentAlbumSongs = songs.slice() this.isAlbumMode = true this.isSearchMode = false } private async openPlaylistDetail(playlist: Playlist): Promise { if (!this.playlistTable) { ToastUtil.showToast('歌单加载失败') return } if (playlist.songCount <= 0) { ToastUtil.showToast('歌单里还没有歌曲') return } const cachedSongs = this.playlistSongsCache.get(playlist.id) if (cachedSongs && cachedSongs.length > 0) { this.presentPlaylistDetail(playlist, cachedSongs) return } try { const playlistSongs = await this.playlistTable.queryPlaylistSongs(playlist.id) if (playlistSongs.length === 0) { ToastUtil.showToast('歌单里还没有歌曲') return } const songs = await convertPlaylistSongsToVideoItems(getContext(this) as common.Context, playlistSongs) if (songs.length === 0) { ToastUtil.showToast('歌单里还没有可播放歌曲') return } this.playlistSongsCache.set(playlist.id, songs) this.presentPlaylistDetail(playlist, songs) } catch (error) { Logger.error(TAG, `发现页打开歌单失败: ${this.toErrorMessage(error as Object)}`) ToastUtil.showToast('歌单加载失败') } } private async resolveHeartPlaylistCovers(ticket: number, playlists: Playlist[]): Promise { if (!this.playlistTable || playlists.length === 0) { return } const context = getContext(this) as common.Context for (let index = 0; index < playlists.length; index += 1) { if (ticket !== this.heartPlaylistCoverTicket) { return } const playlist = playlists[index] if (StrUtil.isNotEmpty(playlist.coverPath) || playlist.songCount <= 0) { continue } try { let songs = this.playlistSongsCache.get(playlist.id) if (!songs || songs.length === 0) { const playlistSongs = await this.playlistTable.queryPlaylistSongs(playlist.id) if (playlistSongs.length === 0) { continue } songs = await convertPlaylistSongsToVideoItems(context, playlistSongs) if (songs.length > 0) { this.playlistSongsCache.set(playlist.id, songs) } } if (!songs || songs.length === 0) { continue } const coverSong = this.pickAlbumCoverSong(songs) if (!coverSong || StrUtil.isEmpty(coverSong.pixelMapPath)) { continue } playlist.coverPath = coverSong.pixelMapPath as string this.heartPlaylists = this.heartPlaylists.slice() } catch (error) { Logger.warn(TAG, `发现页补全歌单封面失败: ${playlist.name}, ${this.toErrorMessage(error as Object)}`) } } } private exitAlbumMode(): void { this.getUIContext()?.animateTo({ duration: 500 }, () => { this.isAlbumMode = false }) this.currentAlbumId = '' this.currentAlbumTitle = '' this.currentAlbumArtist = '' this.currentAlbumCoverPath = '' this.currentAlbumSourceLabel = '' this.currentAlbumSongs = [] this.currentAlbumDefaultSortType = FindCollectionSortType.TRACK_ASC this.currentAlbumSupportRecentPlayedSort = false } private canDeleteCurrentAlbumSongs(): boolean { if (this.currentAlbumSongs.length <= 0) { return false } return this.currentAlbumSongs.every((item: VideoItem) => item.type === CommonConstants.TYPE_LOCAL && StrUtil.isNotEmpty(item.filePath)) } private canFavoriteCurrentAlbumSong(song: VideoItem): boolean { return StrUtil.isNotEmpty(song.filePath) } private updateAlbumSongFavoriteState(filePath: string, isFav: number): void { this.currentAlbumSongs.forEach((item: VideoItem) => { if (item.filePath === filePath) { item.isFav = isFav } }) } private async handleAlbumFavoriteSong(song: VideoItem): Promise { if (!this.mediaTable) { ToastUtil.showToast('收藏功能暂不可用') return } if (!this.canFavoriteCurrentAlbumSong(song)) { ToastUtil.showToast('当前歌曲暂不支持收藏') return } const nextFav: number = song.isFav === 1 ? 0 : 1 this.mediaTable.updateIsFavByFilePath(song.filePath, nextFav, async (result: boolean, error?: string) => { if (!result) { Logger.warn(TAG, `发现页专辑详情收藏更新失败: ${song.filePath}, error=${error ?? ''}`) ToastUtil.showToast(nextFav === 1 ? '收藏失败' : '取消收藏失败') return } song.isFav = nextFav this.updateAlbumSongFavoriteState(song.filePath, nextFav) await this.loadDiscoveryContent(false) this.syncCurrentAlbumDetailFromState() ToastUtil.showToast(nextFav === 1 ? '收藏成功' : '取消收藏成功') }) } private closeDeleteDialog(afterClose?: () => void): void { if (this.deleteComponentId > 0) { try { this.getUIContext().getPromptAction().closeCustomDialog(this.deleteComponentId) } catch (_error) { } this.deleteComponentId = 0 } if (afterClose) { setTimeout(() => { afterClose() }, 16) } } private filterPlaylistCachesByDeletedKeys(deletedKeys: Set): void { this.playlistSongsCache.forEach((songs: VideoItem[], key: string) => { this.playlistSongsCache.set(key, songs.filter((item: VideoItem) => !deletedKeys.has(item.filePath))) }) } private syncCurrentAlbumDetailFromState(): void { if (StrUtil.isEmpty(this.currentAlbumId)) { return } if (this.currentAlbumId === 'find-recent-collection') { this.currentAlbumSongs = this.recentSongsPool.slice() this.currentAlbumCoverPath = this.pickAlbumCoverSong(this.currentAlbumSongs)?.pixelMapPath ?? '' } else if (this.currentAlbumId === 'find-favorite-collection') { this.currentAlbumSongs = this.favoriteSongsPool.slice() this.currentAlbumCoverPath = this.pickAlbumCoverSong(this.currentAlbumSongs)?.pixelMapPath ?? '' } else if (this.currentAlbumId.startsWith('find-playlist-')) { const playlistId = this.currentAlbumId.replace('find-playlist-', '') const playlist = this.heartPlaylists.find((item: Playlist) => item.id === playlistId) const songs = this.playlistSongsCache.get(playlistId) ?? [] this.currentAlbumSongs = songs.slice() if (playlist) { this.currentAlbumTitle = playlist.name this.currentAlbumArtist = '心动歌单' const coverSong = this.pickAlbumCoverSong(songs) this.currentAlbumCoverPath = StrUtil.isNotEmpty(playlist.coverPath) ? playlist.coverPath as string : (coverSong?.pixelMapPath ?? '') } else { this.currentAlbumCoverPath = '' } } else { const album = this.featuredAlbumsPool.find((item: FindAlbumGroup) => item.id === this.currentAlbumId) ?? this.cloudAlbumsPool.find((item: FindAlbumGroup) => item.id === this.currentAlbumId) if (album) { this.currentAlbumTitle = album.title this.currentAlbumArtist = album.artist this.currentAlbumCoverPath = album.coverPath this.currentAlbumSourceLabel = album.isRemote ? '云端专辑' : '精选专辑' this.currentAlbumSongs = album.songs.slice() } else { this.currentAlbumCoverPath = '' this.currentAlbumSongs = [] } } } private async handleAlbumDeleteSuccess(deletedItems: VideoItem[]): Promise { if (deletedItems.length <= 0) { return } const deletedKeys: Set = new Set() deletedItems.forEach((item: VideoItem) => { if (StrUtil.isNotEmpty(item.filePath)) { deletedKeys.add(item.filePath) } }) if (deletedKeys.size <= 0) { return } this.currentAlbumSongs = this.currentAlbumSongs.filter((item: VideoItem) => !deletedKeys.has(item.filePath)) this.searchResults = this.searchResults.filter((item: VideoItem) => !deletedKeys.has(item.filePath)) this.filterPlaylistCachesByDeletedKeys(deletedKeys) await this.loadDiscoveryContent(false) this.syncCurrentAlbumDetailFromState() if (this.isSearchMode && this.searchText.length > 0) { await this.onSearchInput(this.searchText) } ToastUtil.showToast('删除成功') } private openDeleteSongDialog(song: VideoItem): void { if (song.type !== CommonConstants.TYPE_LOCAL || StrUtil.isEmpty(song.filePath)) { ToastUtil.showToast('云端歌曲暂不支持删除') return } this.pendingDeleteSongs = [song] this.getUIContext().getPromptAction().openCustomDialog({ builder: () => { this.buildDeleteSongDialog() }, isModal: true, showInSubWindow: false, maskColor: Color.Transparent, dialogTransition: TransitionEffect.translate({ x: 0, y: 120, z: 0 }) .combine(TransitionEffect.opacity(0.01)) .animation({ duration: 260, curve: Curve.EaseOut }), maskTransition: TransitionEffect.opacity(0) .animation({ duration: 220, curve: Curve.EaseOut }) }).then((dialogId: number) => { this.deleteComponentId = dialogId }).catch((error: BusinessError) => { Logger.error(TAG, `发现页打开删除弹窗失败: ${error.message}`) }) } @Builder private buildDeleteSongDialog() { DeleteComptent({ selectedFiles: this.pendingDeleteSongs, onCancel: () => { this.closeDeleteDialog(() => { this.pendingDeleteSongs = [] }) }, onDeleteResult: (result: boolean) => { const deletedItems = this.pendingDeleteSongs.slice() this.closeDeleteDialog(() => { this.pendingDeleteSongs = [] if (result) { void this.handleAlbumDeleteSuccess(deletedItems) } else { ToastUtil.showToast('删除失败') } }) } }) } private playCurrentAlbum(startIndex: number, songs: VideoItem[] = this.currentAlbumSongs): void { if (songs.length === 0) { ToastUtil.showToast('专辑里还没有歌曲') return } this.emitPlaylistPlay( 'find-album-playlist', this.currentAlbumTitle.length > 0 ? this.currentAlbumTitle : '专辑', songs, startIndex ) } private handleAlbumPlayAll(songs?: VideoItem[]): void { const targetSongs = songs && songs.length > 0 ? songs : this.currentAlbumSongs this.playCurrentAlbum(0, targetSongs) } private handleAlbumRandomPlay(songs?: VideoItem[]): void { const targetSongs = songs && songs.length > 0 ? songs : this.currentAlbumSongs if (targetSongs.length === 0) { ToastUtil.showToast('专辑里还没有歌曲') return } const startIndex = Math.floor(Math.random() * targetSongs.length) this.playCurrentAlbum(startIndex, targetSongs) } private handleAlbumSongTap(index: number, songs?: VideoItem[]): void { const targetSongs = songs && songs.length > 0 ? songs : this.currentAlbumSongs this.playCurrentAlbum(index, targetSongs) } private loadSearchHistory(): void { this.searchHistoryItems = SearchHistoryUtil.load(this.searchHistoryScope) } private saveSearchHistory(keyword: string): void { this.searchHistoryItems = SearchHistoryUtil.save(this.searchHistoryScope, keyword) } private commitSearchHistory(keyword: string): void { const normalized = keyword.trim() if (normalized.length === 0) { return } this.saveSearchHistory(normalized) } private applySearchHistory(keyword: string): void { this.searchText = keyword this.searchResults = [] if (!this.isSearchMode) { this.isSearchMode = true } this.isSearchLoading = true this.searchController.stopEditing() setTimeout(() => { void this.onSearchInput(keyword) }, 16) } private exitSearchMode(): void { this.getUIContext()?.animateTo({ duration: 555 }, () => { this.isSearchMode = false }) this.searchText = '' this.searchResults = [] this.isSearchLoading = false this.searchController.stopEditing() } private async ensureSearchSourceReady(): Promise { if (!this.mediaTable) { return } if (this.localSongsPool.length === 0) { const localSongs = await this.mediaTable.queryAllVideos() this.localSongsPool = this.filterUniqueSongs(localSongs) } if (this.searchRemoteSongsPool.length === 0) { const remoteSongs = await this.mediaTable.queryRemoteSongsAsync() this.searchRemoteSongsPool = await this.resolveRemoteSongCovers(this.filterUniqueSongs(remoteSongs), true) } } private matchesSearchKeyword(item: VideoItem, keyword: string): boolean { const normalizedKeyword = keyword.toLowerCase() const fields: string[] = [ this.getSongTitle(item), this.getSongSubtitle(item), StrUtil.isNotEmpty(item.artist) ? item.artist as string : '', StrUtil.isNotEmpty(item.album) ? item.album as string : '', StrUtil.isNotEmpty(item.fileName) ? item.fileName as string : '' ] for (let i = 0; i < fields.length; i++) { const field = fields[i] if (field.toLowerCase().indexOf(normalizedKeyword) >= 0) { return true } } return false } private async onSearchInput(value: string): Promise { const currentTicket = ++this.searchTicket this.searchText = value.trim() if (this.searchText.length === 0) { this.isSearchLoading = false this.searchResults = [] this.loadSearchHistory() return } if (!this.isSearchMode) { this.isSearchMode = true } this.isSearchLoading = true await new Promise((resolve: () => void) => { setTimeout(() => { resolve() }, 16) }) await this.ensureSearchSourceReady() if (currentTicket !== this.searchTicket) { return } const result: VideoItem[] = [] for (let i = 0; i < this.localSongsPool.length; i++) { const item = this.localSongsPool[i] if (this.matchesSearchKeyword(item, this.searchText)) { result.push(item) } } for (let i = 0; i < this.searchRemoteSongsPool.length; i++) { const item = this.searchRemoteSongsPool[i] if (this.matchesSearchKeyword(item, this.searchText)) { result.push(item) } } this.searchResults = this.filterUniqueSongs(result) this.isSearchLoading = false } 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 resetPagedSectionIndices(): void { this.cloudSectionPageIndex = 0 this.featuredAlbumPageIndex = 0 this.cloudAlbumPageIndex = 0 this.recentSectionPageIndex = 0 this.popularSectionPageIndex = 0 this.favoriteSectionPageIndex = 0 } private getDiscoverSectionColumns(): number { return this.currentBreakpoint === BreakpointTypeEnum.SM ? 3 : 4 } private getSectionPageSize(columns: number): number { return columns * 2 } private getSafeSectionPageIndex(pageCount: number, currentIndex: number): number { if (pageCount <= 0) { return 0 } return Math.min(Math.max(currentIndex, 0), pageCount - 1) } private buildSongSelectionLog(items: VideoItem[], limit: number = 4): string { if (items.length === 0) { return '[]' } const parts: string[] = [] const maxCount = Math.min(limit, items.length) for (let i = 0; i < maxCount; i++) { parts.push(this.getSongTitle(items[i])) } return `[${parts.join(', ')}](${items.length})` } private buildSongDebugLog(items: VideoItem[], limit: number = 5): string { if (items.length === 0) { return '[]' } const parts: string[] = [] const maxCount = Math.min(limit, items.length) for (let i = 0; i < maxCount; i++) { parts.push(this.buildSongDebugItem(items[i])) } return `[${parts.join('; ')}](${items.length})` } private buildSongDebugItem(item?: VideoItem): string { if (!item) { return 'unknown' } return `${this.getSongTitle(item)}|type=${item.type}|acc=${item.webdav_account_id ?? ''}|path=${this.sanitizeSongPath(item.filePath)}|` + `cover=${this.sanitizeCoverValue(item.pixelMapPath)}` } private sanitizeSongPath(path?: string): string { if (StrUtil.isEmpty(path)) { return '' } const value = path as string if (value.length <= 48) { return value } return `...${value.substring(value.length - 48)}` } private sanitizeCoverValue(value?: string): string { if (StrUtil.isEmpty(value)) { return '' } const coverValue = value as string if (coverValue.length <= 72) { return coverValue } return `...${coverValue.substring(coverValue.length - 72)}` } private logRemoteDiscoveryCoverState(label: string, items: VideoItem[]): void { if (!items || items.length === 0) { Logger.info(TAG, `[cover-debug] ${label} items=0`) return } let coverCount = 0 let absoluteCoverCount = 0 let relativeCoverCount = 0 for (let i = 0; i < items.length; i++) { const coverPath = items[i].pixelMapPath ?? '' if (coverPath.length <= 0) { continue } coverCount += 1 if (coverPath.startsWith('http://') || coverPath.startsWith('https://') || coverPath.startsWith('file://')) { absoluteCoverCount += 1 } else { relativeCoverCount += 1 } } Logger.info( TAG, `[cover-debug] ${label} items=${items.length}, coverCount=${coverCount}, absoluteCover=${absoluteCoverCount}, ` + `relativeCover=${relativeCoverCount}, sample=${this.buildSongDebugLog(items)}` ) } private buildAlbumSelectionLog(items: FindAlbumGroup[], limit: number = 4): string { if (items.length === 0) { return '[]' } const parts: string[] = [] const maxCount = Math.min(limit, items.length) for (let i = 0; i < maxCount; i++) { parts.push(items[i].title) } return `[${parts.join(', ')}](${items.length})` } private buildSongPages(prefix: string, items: VideoItem[], columns: number): FindSongPage[] { const pages: FindSongPage[] = [] const pageSize = this.getSectionPageSize(columns) for (let start = 0, pageIndex = 0; start < items.length; start += pageSize, pageIndex++) { pages.push({ id: prefix + '_' + pageIndex, items: items.slice(start, start + pageSize) }) } return pages } private buildAlbumPages(prefix: string, items: FindAlbumGroup[], columns: number): FindAlbumPage[] { const pages: FindAlbumPage[] = [] const pageSize = this.getSectionPageSize(columns) for (let start = 0, pageIndex = 0; start < items.length; start += pageSize, pageIndex++) { pages.push({ id: prefix + '_' + pageIndex, items: items.slice(start, start + pageSize) }) } return pages } private rebuildPagedSectionSources(): void { this.updateCloudSectionPages() this.updateFeaturedAlbumPages() this.updateCloudAlbumPages() this.updatePopularSectionPages() Logger.info( TAG, `FindView rebuildPagedSectionSources columns=${this.getDiscoverSectionColumns()}, cloudPages=${this.cloudSectionPages.length}, featuredAlbumPages=${this.featuredAlbumPages.length}, cloudAlbumPages=${this.cloudAlbumPages.length}, popularPages=${this.popularSectionPages.length}` ) } private updateCloudSectionPages(): void { this.cloudSectionPageVersion++ this.cloudSectionPages = this.buildSongPages(`find_cloud_section_${this.cloudSectionPageVersion}`, this.remoteSongs, this.getDiscoverSectionColumns()) this.cloudSectionPageIndex = this.getSafeSectionPageIndex(this.cloudSectionPages.length, this.cloudSectionPageIndex) Logger.info( TAG, `FindView updateCloudSectionPages version=${this.cloudSectionPageVersion}, pageIndex=${this.cloudSectionPageIndex}, pages=${this.cloudSectionPages.length}, songs=${this.buildSongSelectionLog(this.remoteSongs)}` ) } private updateFeaturedAlbumPages(): void { this.featuredAlbumPageVersion++ this.featuredAlbumPages = this.buildAlbumPages(`find_featured_album_section_${this.featuredAlbumPageVersion}`, this.featuredAlbums, this.getDiscoverSectionColumns()) this.featuredAlbumPageIndex = this.getSafeSectionPageIndex(this.featuredAlbumPages.length, this.featuredAlbumPageIndex) Logger.info( TAG, `FindView updateFeaturedAlbumPages version=${this.featuredAlbumPageVersion}, pageIndex=${this.featuredAlbumPageIndex}, pages=${this.featuredAlbumPages.length}, albums=${this.buildAlbumSelectionLog(this.featuredAlbums)}` ) } private updateCloudAlbumPages(): void { this.cloudAlbumPageVersion++ this.cloudAlbumPages = this.buildAlbumPages(`find_cloud_album_section_${this.cloudAlbumPageVersion}`, this.cloudAlbums, this.getDiscoverSectionColumns()) this.cloudAlbumPageIndex = this.getSafeSectionPageIndex(this.cloudAlbumPages.length, this.cloudAlbumPageIndex) Logger.info( TAG, `FindView updateCloudAlbumPages version=${this.cloudAlbumPageVersion}, pageIndex=${this.cloudAlbumPageIndex}, pages=${this.cloudAlbumPages.length}, albums=${this.buildAlbumSelectionLog(this.cloudAlbums)}` ) } private updatePopularSectionPages(): void { this.popularSectionPageVersion++ this.popularSectionPages = this.buildSongPages(`find_popular_section_${this.popularSectionPageVersion}`, this.popularSongs, this.getDiscoverSectionColumns()) this.popularSectionPageIndex = this.getSafeSectionPageIndex(this.popularSectionPages.length, this.popularSectionPageIndex) Logger.info( TAG, `FindView updatePopularSectionPages version=${this.popularSectionPageVersion}, pageIndex=${this.popularSectionPageIndex}, pages=${this.popularSectionPages.length}, songs=${this.buildSongSelectionLog(this.popularSongs)}` ) } private getRecentSectionPages(): FindSongPage[] { return this.buildSongPages('find_recent_section', this.recentSongs, this.getDiscoverSectionColumns()) } private getFavoriteSectionPages(): FindSongPage[] { return this.buildSongPages('find_favorite_section', this.favoriteSongs, this.getDiscoverSectionColumns()) } 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 getSongQualityText(item: VideoItem): string { if (StrUtil.isNotEmpty(item.md5Str)) { const quality = item.md5Str as string return quality.includes('Lossless') ? '无损' : quality } const sampleRate = Number(item.sampleRate ?? '0') const bitDepth = Number(item.bits_per_raw_sample ?? '0') if (sampleRate >= 88200 || bitDepth >= 24) { return 'Hi-Res' } const mimeType = (item.mimeType ?? '').toLowerCase() const fileName = (item.fileName ?? item.name ?? '').toLowerCase() const isLosslessFormat = mimeType.includes('flac') || mimeType.includes('wav') || mimeType.includes('ape') || mimeType.includes('alac') || mimeType.includes('dsf') || mimeType.includes('dff') || fileName.endsWith('.flac') || fileName.endsWith('.wav') || fileName.endsWith('.ape') || fileName.endsWith('.alac') || fileName.endsWith('.dsf') || fileName.endsWith('.dff') return isLosslessFormat ? '无损' : '' } private getAlbumCover(coverPath: string): string | Resource { return StrUtil.isNotEmpty(coverPath) ? coverPath : $r('app.media.alt') } 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 getSearchSourceLabel(item: VideoItem): string { if (item.type === CommonConstants.TYPE_LOCAL) { return '本地' } return this.getCloudTypeLabel(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}` } private getPrimaryTextColor(): ResourceColor { return $r('app.color.text_color') } private getSecondaryTextColor(): ResourceColor { return $r('app.color.find_secondary_text') } private getCardBackgroundColor(): ResourceColor { return $r('app.color.find_card_background') } private getSongQualityBadgeTextColor(): ResourceColor { return $r('app.color.album_detail_song_quality_text') } private getSongQualityBadgeBackgroundColor(): ResourceColor { return $r('app.color.album_detail_song_quality_background') } @Builder private buildLoadingState() { Column({ space: 14 }) { LoadingProgress() .width(46) .height(46) .color(this.themeColor) Text(this.refreshText) .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor(this.getSecondaryTextColor()) } .width('100%') .layoutWeight(1) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } @Builder private buildTopErrorBanner() { if (StrUtil.isNotEmpty(this.refreshText)) { if (this.isRefreshing) { Row({ space: 8 }) { LoadingProgress() .width(14) .height(14) .color(this.themeColor) Text(this.refreshText) .fontSize(12) .fontColor(this.getSecondaryTextColor()) .textAlign(TextAlign.Center) .maxLines(1) } .width('100%') .padding({ left: 12, right: 12, top: 10, bottom: 10 }) .justifyContent(FlexAlign.Center) .alignItems(VerticalAlign.Center) } else { Row({ space: 6 }) { SymbolGlyph($r('sys.symbol.exclamationmark_circle')) .fontSize(15) .fontColor([$r('app.color.orange')]) Text(this.refreshText) .layoutWeight(1) .fontSize(12) .fontColor($r('app.color.orange')) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) } .width('100%') .padding({ left: 12, right: 12, top: 10, bottom: 10 }) .backgroundColor($r('app.color.timeColor_bg')) .borderRadius(16) } } } @Builder private topTitleBar() { Column() { Row({ space: 12 }) { if (!this.isSearchMode && !this.isAlbumMode) { TitleBarPointLightButton({ iconResource: $r('sys.symbol.sort'), iconSize: 25, pointColor: this.themeColor, clickScale: 0.8, clickHandler: () => { this.getUIContext().animateTo({ duration: 500 }, () => { this.isShowDrawer = !this.isShowDrawer this.offsetX = 0 }) } }) Text('发现页') .margin({ left: 3, right: 10 }) .fontColor($r('app.color.text_color')) .fontSize(19) .fontWeight(FontWeight.Bold) .maxLines(1) .textOverflow({ overflow: TextOverflow.MARQUEE }) .layoutWeight(1) } else { TitleBarPointLightButton({ iconResource: $r('sys.symbol.chevron_left'), iconSize: 24, pointColor: this.themeColor, clickScale: 0.8, clickHandler: () => { if (this.isSearchMode) { this.exitSearchMode() return } this.exitAlbumMode() } }) if (this.isAlbumMode) { Column({ space: 2 }) { Text(this.currentAlbumTitle) .fontColor($r('app.color.text_color')) .fontSize(18) .fontWeight(FontWeight.Bold) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Text(this.currentAlbumArtist + ' · ' + this.currentAlbumSongs.length + ' 首') .fontColor(this.getSecondaryTextColor()) .fontSize(11) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } .layoutWeight(1) .alignItems(HorizontalAlign.Start) .margin({ right: 10 }) } } if (this.isSearchMode) { Search({ controller: this.searchController, value: this.searchText, placeholder: '搜索本地和网盘歌曲...' }) .searchButton('搜索', { fontColor: this.themeColor }) .searchIcon({ src: $r('sys.media.ohos_ic_public_search_filled') }) .cancelButton({ style: CancelButtonStyle.CONSTANT, icon: { src: $r('sys.media.ohos_ic_public_cancel_filled') } }) .layoutWeight(1) .height(35) .maxLength(40) .placeholderColor(this.getSecondaryTextColor()) .placeholderFont({ size: 14, weight: 400 }) .textFont({ size: 14, weight: 400 }) .onSubmit((value: string) => { this.searchController.stopEditing() this.commitSearchHistory(value) void this.onSearchInput(value) }) .onChange((value: string) => { void this.onSearchInput(value) }) .animation({ duration: 300, curve: Curve.Ease }) } if (!this.isSearchMode && !this.isAlbumMode) { TitleBarPointLightButton({ iconResource: $r('sys.symbol.magnifyingglass'), iconSize: 25, pointColor: this.themeColor, clickScale: 0.6, clickHandler: () => { this.isAlbumMode = false this.getUIContext()?.animateTo({ duration: 500 }, () => { this.isSearchMode = true }) this.loadSearchHistory() void this.ensureSearchSourceReady() } }) } } } .padding({ top: this.topSafeHeight + 10, left: 10, right: 10, bottom: 6 }) .width('100%') } @Builder private buildSearchLoadingState() { Column({ space: 12 }) { LoadingProgress() .width(38) .height(38) .color(this.themeColor) Text('正在搜索歌曲...') .fontSize(14) .fontColor(this.getSecondaryTextColor()) } .width('100%') .padding({ top: 48, bottom: 48 }) .alignItems(HorizontalAlign.Center) } @Builder private SearchHistoryView() { Column({ space: 10 }) { Row() { Text('搜索历史') .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor($r('app.color.text_color')) Blank() Button('清空') .fontSize(12) .fontColor(this.themeColor) .backgroundColor(Color.Transparent) .padding({ left: 8, right: 8, top: 4, bottom: 4 }) .onClick(() => { SearchHistoryUtil.clear(this.searchHistoryScope) this.searchHistoryItems = [] }) } .width('100%') Flex({ wrap: FlexWrap.Wrap }) { ForEach(this.searchHistoryItems, (keyword: string) => { PointLightContentButton({ pointColor: this.themeColor, buttonColor: $r('app.color.find_history_background'), pointLightHeight: 40, builder: () => { this.buildSearchHistoryChipContent(keyword) } }) .margin({ right: 8, bottom: 8 }) .padding({ left: 12, right: 12, top: 8, bottom: 8 }) .onClick(() => { this.applySearchHistory(keyword) }) }) } .width('100%') } .layoutWeight(1) .width('100%') } @Builder private buildSearchHistoryChipContent(keyword: string) { Text(keyword) .fontSize(12) .fontColor($r('app.color.text_color')) .padding({ left: 8, right: 8, top: 5, bottom: 5 }) } @Builder private buildSearchResultCardContent(item: VideoItem) { Row({ space: 12 }) { Stack({ alignContent: Alignment.BottomStart }) { Image(this.getSongCover(item)) .width(58) .height(58) .borderRadius(8) .objectFit(ImageFit.Cover) Row() { Text(this.getSearchSourceLabel(item)) .fontColor($r('app.color.white')) .fontSize(8) .padding({ left: 5, right: 5, top: 2, bottom: 2 }) .backgroundColor('#7A000000') .borderRadius(999) } .width('100%') .padding({ left: 6, bottom: 6 }) } .width(58) .height(58) Column({ space: 4 }) { Text(this.getSongTitle(item)) .fontSize(14) .fontWeight(FontWeight.Bold) .fontColor(this.getPrimaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Row (){ if (StrUtil.isNotEmpty(this.getSongQualityText(item))) { Text(this.getSongQualityText(item)) .fontSize(10) .fontWeight(FontWeight.Medium) .fontColor(this.getSongQualityBadgeTextColor()) .padding({ left: 6, right: 6, top: 2, bottom: 2 }) .backgroundColor(this.getSongQualityBadgeBackgroundColor()) .borderRadius(4) } Text(this.getSongSubtitle(item)) .fontSize(11) .lineHeight(14) .fontColor(this.getSecondaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } } .layoutWeight(1) .alignItems(HorizontalAlign.Start) Column({ space: 8 }) { PlayingIndicator({ isActive: this.isPlaying && this.currentSong?.filePath === item.filePath, indicatorSize: 18, marginRight: 20, marginTop: 8, marginBottom: 8 }) } .alignItems(HorizontalAlign.End) } .width('100%') .padding(10) } @Builder private buildSwipeDeleteAction(item: VideoItem) { Row() { Button('删除') .width(72) .height(52) .fontSize(14) .fontColor(Color.White) .backgroundColor($r('app.color.btn_red')) .borderRadius(14) .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 }) .onClick(() => { this.openDeleteSongDialog(item) }) } .padding({ left: 8, right: 4 }) } @Builder private buildSearchContent() { List({ space: 12 }) { if (this.isSearchLoading) { ListItem() { this.buildSearchLoadingState() } } else if (this.searchText.length === 0 && this.searchHistoryItems.length > 0) { ListItem() { this.SearchHistoryView() } } else if (this.searchText.length === 0) { ListItem() { this.buildSectionEmptyState('搜索本地和网盘歌曲', '输入歌曲名、歌手或专辑名后即可开始搜索', false) } } else if (this.searchResults.length === 0) { ListItem() { this.buildSectionEmptyState('没有找到匹配歌曲', '试试其他关键词,结果会同时包含本地和网盘歌曲', false) } } else { ListItem() { Row() { Text('搜索结果') .fontSize(14) .fontWeight(FontWeight.Medium) .fontColor(this.getSecondaryTextColor()) Blank() Text(`${this.searchResults.length} 首`) .fontSize(12) .fontColor(this.getSecondaryTextColor()) } .width('100%') } ForEach(this.searchResults, (item: VideoItem, index: number) => { ListItem() { PointLightContentButton({ pointColor: this.themeColor, buttonColor: this.getCardBackgroundColor(), buttonRadius: 18, pointLightHeight: 132, useShadow: true, builder: () => { this.buildSearchResultCardContent(item) } }) .width('100%') .onClick(() => { this.emitPlaylistPlay('find-search', '发现搜索', this.searchResults, index) }) } .swipeAction(this.canDeleteSong(item) ? { end: this.buildSwipeDeleteAction(item), edgeEffect: SwipeEdgeEffect.None } : {}) .bindContextMenu(this.SongContextMenuBuilder(item), ResponseType.LongPress, { preview: MenuPreviewMode.IMAGE }) .bindContextMenu(this.SongContextMenuBuilder(item), ResponseType.RightClick, { preview: MenuPreviewMode.IMAGE }) }, getFindSongKey) } } .width('100%') .height('100%') .padding({ left: 12, right: 12, top: this.topSafeHeight + 65 }) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .layoutWeight(1) } @Builder private buildSwiperCardContent(item: VideoItem) { 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($r('app.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]] }) } .width('100%') .clip(true) } @Builder private buildSwiperSection() { if (this.swiperSongs.length > 0) { Swiper() { ForEach(this.swiperSongs, (item: VideoItem, index: number) => { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 0, pointLightHeight: 180, builder: () => { this.buildSwiperCardContent(item) } }) .width('100%') .onClick(() => { this.handleSwiperTap(index) }) }, getFindSongKey) } .width('100%') .clip(true) .displayCount(this.getTopSwiperDisplayCount()) .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) } } private getSwiperDisplayCount(): number { return new BreakpointType({ sm: 1, md: 1, lg: 1, xl: 2, xxl: 2 }).getValue(this.currentBreakpoint) ?? 1 } private getTopSwiperDisplayCount(): number { return new BreakpointType({ sm: 1, md: 2, lg: 2, xl: 3, xxl: 3 }).getValue(this.currentBreakpoint) ?? 1 } @Builder private buildActionButtons() { Row({ space: 10 }) { PointLightActionButton({ text: '最近爱听', iconResource: $r('sys.symbol.heart'), pointColor: this.themeColor, textColor: $r('app.color.text_color'), buttonColor: this.getCardBackgroundColor() }) .opacity(0.8) .layoutWeight(1) .onClick(() => { void this.handleActionButtonTap('top') }) PointLightActionButton({ text: '随心所欲', iconResource: $r('sys.symbol.shuffle'), pointColor: this.themeColor, textColor: $r('app.color.text_color'), buttonColor: this.getCardBackgroundColor() }) .opacity(0.8) .layoutWeight(1) .onClick(() => { void this.handleActionButtonTap('local-random') }) PointLightActionButton({ text: '云端漫游', iconResource: $r('sys.symbol.cloud'), pointColor: this.themeColor, textColor: $r('app.color.text_color'), buttonColor: this.getCardBackgroundColor() }) .opacity(0.8) .layoutWeight(1) .onClick(() => { void this.handleActionButtonTap('cloud-random') }) } .width('100%') } @Builder private buildCloudSongCardContent(item: VideoItem) { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getSongCover(item)) .width('100%') .aspectRatio(3 / 4) .borderRadius(16) .alt($r('app.media.alt')) .objectFit(ImageFit.Cover) Row() { Text(this.getCloudTypeLabel(item)) .fontColor($r('app.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(this.getPrimaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Text(this.getSongSubtitle(item)) .fontSize(11) .lineHeight(14) .fontColor(this.getSecondaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } .width('100%') } @Builder private buildCloudSection() { ConfigTitle({ text: '漫步云端', indicatorCount: this.cloudSectionPages.length, indicatorIndex: this.getSafeSectionPageIndex(this.cloudSectionPages.length, this.cloudSectionPageIndex), onActionClick: () => { Logger.info(TAG, `FindView clickChange cloudSection current=${this.buildSongSelectionLog(this.remoteSongs)}`) this.refreshCloudSongs() } }) if (this.remoteSongs.length === 0) { this.buildSectionEmptyState('还没有网盘歌曲', '先去网盘页或远程音乐页加载歌曲,这里会自动展示', false) } else { Swiper() { ForEach(this.cloudSectionPages, (page: FindSongPage, pageIndex: number) => { GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) { ForEach(page.items, (item: VideoItem, itemIndex: number) => { GridCol() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildCloudSongCardContent(item) } }) .width('100%') .onClick(() => { this.handleRemoteSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex) }) } }, getFindSongKey) } }, getFindSongPageKey) } .width('100%') .indicator(false) .autoPlay(false) .displayCount(this.getSwiperDisplayCount()) .loop(false) .onChange((index: number) => { this.cloudSectionPageIndex = index }) } } @Builder private buildAlbumCardContent(album: FindAlbumGroup) { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getAlbumCover(album.coverPath)) .width('100%') .aspectRatio(3 / 4) .borderRadius(18) .alt($r('app.media.alt')) .objectFit(ImageFit.Cover) Row() { Text(album.sourceLabel) .fontColor($r('app.color.white')) .fontWeight(FontWeight.Medium) .padding({ left: 6, right: 6, top: 3, bottom: 3 }) .fontSize(9) .backgroundColor('#7A000000') .borderRadius(999) .visibility(album.sourceLabel === '本地' ? Visibility.None : Visibility.Visible) Text(album.songCount + ' 首') .fontColor($r('app.color.white')) .fontSize(9) .padding({ left: 6, right: 6, top: 3, bottom: 3 }) .backgroundColor('#5C000000') .borderRadius(999) } .width('100%') .padding({ left: 8, right: 8, bottom: 8 }) .justifyContent(FlexAlign.SpaceBetween) } Text(album.title) .fontSize(14) .fontWeight(FontWeight.Bold) .lineHeight(18) .fontColor(this.getPrimaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Text(album.artist) .fontSize(11) .lineHeight(14) .fontColor(this.getSecondaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } .width('100%') } @Builder private buildFeaturedAlbumSection() { ConfigTitle({ text: '精选专辑', indicatorCount: this.featuredAlbumPages.length, indicatorIndex: this.getSafeSectionPageIndex(this.featuredAlbumPages.length, this.featuredAlbumPageIndex), onActionClick: () => { Logger.info(TAG, `FindView clickChange featuredAlbums current=${this.buildAlbumSelectionLog(this.featuredAlbums)}`) this.refreshFeaturedAlbums() } }) if (this.featuredAlbums.length === 0) { this.buildSectionEmptyState('还没有可展示的专辑', '带专辑信息的本地歌曲会自动整理到这里', false) } else { Swiper() { ForEach(this.featuredAlbumPages, (page: FindAlbumPage) => { GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) { ForEach(page.items, (album: FindAlbumGroup) => { GridCol() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 18, builder: () => { this.buildAlbumCardContent(album) } }) .width('100%') .onClick(() => { this.openAlbumDetail(album) }) } }, getFindAlbumKey) } }, getFindAlbumPageKey) } .width('100%') .indicator(false) .autoPlay(false) .loop(false) .displayCount(this.getSwiperDisplayCount()) .onChange((index: number) => { this.featuredAlbumPageIndex = index }) } } @Builder private buildCloudAlbumSection() { ConfigTitle({ text: '云端专辑', indicatorCount: this.cloudAlbumPages.length, indicatorIndex: this.getSafeSectionPageIndex(this.cloudAlbumPages.length, this.cloudAlbumPageIndex), onActionClick: () => { Logger.info(TAG, `FindView clickChange cloudAlbums current=${this.buildAlbumSelectionLog(this.cloudAlbums)}`) this.refreshCloudAlbums() } }) if (this.cloudAlbums.length === 0) { this.buildSectionEmptyState('云端歌曲还没有专辑信息', '网盘歌曲带有专辑标签后,这里会自动汇总', false) } else { Swiper() { ForEach(this.cloudAlbumPages, (page: FindAlbumPage) => { GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) { ForEach(page.items, (album: FindAlbumGroup) => { GridCol() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 18, builder: () => { this.buildAlbumCardContent(album) } }) .width('100%') .onClick(() => { this.openAlbumDetail(album) }) } }, getFindAlbumKey) } }, getFindAlbumPageKey) } .width('100%') .indicator(false) .autoPlay(false) .loop(false) .displayCount(this.getSwiperDisplayCount()) .onChange((index: number) => { this.cloudAlbumPageIndex = index }) } } @Builder private buildHotSongCardContent(item: VideoItem) { Column({ space: 5 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getSongCover(item)) .aspectRatio(3 / 4) .borderRadius(16) .width(160) .alt($r('app.media.alt')) .objectFit(ImageFit.Cover) Row() { Text(this.getSongSubtitle(item)) .fontColor($r('app.color.white')) .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(this.getPrimaryTextColor()) .fontSize(14) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.getRecentSongSubtitle(item)) .fontColor(this.getSecondaryTextColor()) .fontSize(10) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .lineHeight(11) .width(160) .textAlign(TextAlign.Start) } } @Builder private buildLocalRandomSection() { ConfigTitle({ text: '今日乐曲', onActionClick: () => { this.refreshLocalRandomSongs() } }) if (this.hotSongs.length === 0) { this.buildSectionEmptyState('本地随机还没有内容', '本地曲库加载完成后,这里会随机展示歌曲', false) } else { List({ space: 8 }) { ForEach(this.hotSongs, (item: VideoItem, index: number) => { ListItem() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildHotSongCardContent(item) } }) .onClick(() => { this.handleHotSongTap(index) }) } .margin({ left: index === 0 ? 2 : 0, right: index === this.hotSongs.length - 1 ? 2 : 0 }) }, getFindSongKey) } .listDirection(Axis.Horizontal) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) } } @Builder private buildHeartPlaylistCardContent(playlist: Playlist) { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getPlaylistCover(playlist)) .aspectRatio(3 / 4) .borderRadius(16) .width(160) .objectFit(ImageFit.Cover) Row() { Text('心动歌单') .fontColor($r('app.color.white')) .fontSize(9) .padding({ left: 6, right: 6, top: 3, bottom: 3 }) .backgroundColor('#7A000000') .borderRadius(999) Text(`${playlist.songCount} 首`) .fontColor($r('app.color.white')) .fontSize(9) .padding({ left: 6, right: 6, top: 3, bottom: 3 }) .backgroundColor('#5C000000') .borderRadius(999) } .justifyContent(FlexAlign.SpaceBetween) .padding({ left: 8, right: 8, bottom: 8 }) .width(160) } Text(playlist.name) .lineHeight(16) .maxLines(1) .width(160) .fontColor(this.getPrimaryTextColor()) .fontSize(14) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) Text(this.getPlaylistSubtitle(playlist)) .fontColor(this.getSecondaryTextColor()) .fontSize(10) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .lineHeight(11) .width(160) .textAlign(TextAlign.Start) } } @Builder private buildHeartPlaylistSection() { ConfigTitle({ text: '心动歌单', showAction: false }) List({ space: 8 }) { ForEach(this.heartPlaylists, (playlist: Playlist) => { ListItem() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildHeartPlaylistCardContent(playlist) } }) .onClick(() => { void this.openPlaylistDetail(playlist) }) } }, getFindPlaylistKey) } .listDirection(Axis.Horizontal) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) } @Builder private buildCloudMoodSection() { ConfigTitle({ text: '云卷云舒', onActionClick: () => { this.refreshCloudMoodSongs() } }) if (this.cloudMoodSongs.length === 0) { this.buildSectionEmptyState('网盘随机还没有内容', '网盘歌曲加载完成后,这里会随机展示歌曲', false) } else { List({ space: 8 }) { ForEach(this.cloudMoodSongs, (item: VideoItem, index: number) => { ListItem() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildHotSongCardContent(item) } }) .onClick(() => { this.handleCloudMoodSongTap(index) }) } .margin({ left: index === 0 ? 2 : 0, right: index === this.cloudMoodSongs.length - 1 ? 2 : 0 }) }, getFindSongKey) } .listDirection(Axis.Horizontal) .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring, { alwaysEnabled: true }) } } @Builder private buildRecentSongCardContent(item: VideoItem) { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getSongCover(item)) .width('100%') .aspectRatio(3 / 4) .borderRadius(16) .objectFit(ImageFit.Cover) Row() { Text(item.duration ? item.duration : '') .fontColor($r('app.color.white')) .fontSize(9) .visibility(item.duration ? 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(this.getPrimaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Text(this.getRecentSongSubtitle(item)) .fontSize(10) .lineHeight(13) .fontColor(this.getSecondaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } .width('100%') } @Builder private buildRecentSection() { ConfigTitle({ text: '最近播放', actionText: '更多', actionSymbol: $r('sys.symbol.chevron_right'), indicatorCount: this.getRecentSectionPages().length, indicatorIndex: this.getSafeSectionPageIndex(this.getRecentSectionPages().length, this.recentSectionPageIndex), onActionClick: () => { this.openLocalSpecialList('recent') } }) if (this.recentSongs.length === 0) { this.buildSectionEmptyState('最近播放还是空的', '播放过的歌曲会自动出现在这里', false) } else { Swiper() { ForEach(this.getRecentSectionPages(), (page: FindSongPage, pageIndex: number) => { GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) { ForEach(page.items, (item: VideoItem, itemIndex: number) => { GridCol() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildRecentSongCardContent(item) } }) .width('100%') .onClick(() => { this.handleRecentSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex) }) } }, getFindSongKey) } }, getFindSongPageKey) } .width('100%') .indicator(false) .autoPlay(false) .displayCount(this.getSwiperDisplayCount()) .loop(false) .onChange((index: number) => { this.recentSectionPageIndex = index }) } } @Builder private buildPopularSongCardContent(item: VideoItem) { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getSongCover(item)) .aspectRatio(0.8) .objectFit(ImageFit.Cover) .width('100%') .borderRadius(16) Row() { Text(item.playCount ? item.playCount + ' 次' : '') .fontColor($r('app.color.white')) .fontSize(9) .lineHeight(11) .visibility(item.playCount ? Visibility.Visible : Visibility.None) } .width('100%') .justifyContent(FlexAlign.SpaceBetween) .padding({ left: 8, right: 8, bottom: 8 }) } Text(this.getSongTitle(item)) .fontColor(this.getPrimaryTextColor()) .maxLines(1) .fontSize(14) .lineHeight(16) .fontWeight(FontWeight.Bold) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Text(this.getSongSubtitle(item)) .fontColor(this.getSecondaryTextColor()) .textAlign(TextAlign.Start) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .fontSize(10) .lineHeight(11) .width('100%') } .width('100%') } @Builder private buildPopularSection() { ConfigTitle({ text: '热门歌曲', indicatorCount: this.popularSectionPages.length, indicatorIndex: this.getSafeSectionPageIndex(this.popularSectionPages.length, this.popularSectionPageIndex), onActionClick: () => { Logger.info(TAG, `FindView clickChange popularSongs current=${this.buildSongSelectionLog(this.popularSongs)}`) this.refreshPopularSongs() } }) if (this.popularSongs.length === 0) { this.buildSectionEmptyState('热门歌曲还没生成', '播放次数高的歌曲会优先展示在这里', false) } else { Swiper() { ForEach(this.popularSectionPages, (page: FindSongPage, pageIndex: number) => { GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) { ForEach(page.items, (item: VideoItem, itemIndex: number) => { GridCol() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildPopularSongCardContent(item) } }) .width('100%') .onClick(() => { this.handlePopularSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex) }) } }, getFindSongKey) } }, getFindSongPageKey) } .width('100%') .indicator(false) .autoPlay(false) .loop(false) .displayCount(this.getSwiperDisplayCount()) .onChange((index: number) => { this.popularSectionPageIndex = index }) } } @Builder private buildFavoriteSongCardContent(item: VideoItem) { Column({ space: 6 }) { Stack({ alignContent: Alignment.Bottom }) { Image(this.getSongCover(item)) .width('100%') .aspectRatio(3 / 4) .borderRadius(16) .objectFit(ImageFit.Cover) Row() { Text(item.duration ? item.duration : '') .fontColor($r('app.color.white')) .fontSize(9) .visibility(item.duration ? 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(this.getPrimaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') Text(this.getSongSubtitle(item)) .fontSize(10) .lineHeight(13) .fontColor(this.getSecondaryTextColor()) .maxLines(1) .textOverflow({ overflow: TextOverflow.Ellipsis }) .width('100%') } .width('100%') } @Builder private buildFavoriteSection() { ConfigTitle({ text: '我的收藏', actionText: '更多', actionSymbol: $r('sys.symbol.chevron_right'), indicatorCount: this.getFavoriteSectionPages().length, indicatorIndex: this.getSafeSectionPageIndex(this.getFavoriteSectionPages().length, this.favoriteSectionPageIndex), onActionClick: () => { this.openLocalSpecialList('favorite') } }) if (this.favoriteSongs.length === 0) { this.buildSectionEmptyState('我的收藏还是空的', '', false) } else { Swiper() { ForEach(this.getFavoriteSectionPages(), (page: FindSongPage, pageIndex: number) => { GridRow({ columns: this.getDiscoverSectionColumns(), gutter: 8 }) { ForEach(page.items, (item: VideoItem, itemIndex: number) => { GridCol() { PointLightContentButton({ pointColor: this.themeColor, buttonRadius: 16, builder: () => { this.buildFavoriteSongCardContent(item) } }) .width('100%') .onClick(() => { this.handleFavoriteSongTap(pageIndex * this.getSectionPageSize(this.getDiscoverSectionColumns()) + itemIndex) }) } }, getFindSongKey) } }, getFindSongPageKey) } .width('100%') .indicator(false) .autoPlay(false) .displayCount(this.getSwiperDisplayCount()) .loop(false) .onChange((index: number) => { this.favoriteSectionPageIndex = index }) } } @Builder private buildSectionEmptyState(title: string, subtitle: string, useLargeCard: boolean) { Column({ space: 8 }) { SymbolGlyph($r('sys.symbol.music')) .attributeModifier(new SymbolGlyphFancyModifier(useLargeCard ?70 :42, '', '')) .opacity(0.72) Text(title) .fontSize(15) .fontWeight(FontWeight.Medium) .fontColor(this.getPrimaryTextColor()) Text(subtitle) .fontSize(12) .fontColor(this.getSecondaryTextColor()) .textAlign(TextAlign.Center) } .width('100%') .padding({ top: useLargeCard ? 42 : 26, bottom: useLargeCard ? 42 : 26, left: 12, right: 12 }) .borderRadius(20) .justifyContent(FlexAlign.Center) .alignItems(HorizontalAlign.Center) } @Builder private buildDiscoveryContent() { Scroll() { Column() { this.buildTopErrorBanner() this.buildSwiperSection() Column({ space: 6 }) { this.buildActionButtons() this.buildLocalRandomSection() if (this.favoriteSongsPool.length > 0 || this.favoriteSongs.length > 0) { this.buildFavoriteSection() } if (this.heartPlaylists.length > 0) { this.buildHeartPlaylistSection() } if (this.remoteSongsPool.length > 0 || this.remoteSongs.length > 0 || this.cloudMoodSongs.length > 0) { this.buildCloudSection() } this.buildFeaturedAlbumSection() if (this.remoteSongsPool.length > 0 || this.remoteSongs.length > 0 || this.cloudMoodSongs.length > 0) { this.buildCloudMoodSection() } if (this.remoteSongsPool.length > 0 || this.cloudAlbumsPool.length > 0 || this.cloudAlbums.length > 0) { this.buildCloudAlbumSection() } this.buildRecentSection() this.buildPopularSection() } .width('100%') .padding({ left: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20, right: this.currentBreakpoint === BreakpointTypeEnum.SM ? 12 : 20, top: 16, bottom: this.bottomSafeHeight + 96 }) .alignItems(HorizontalAlign.Start) } } .scrollBar(BarState.Off) .edgeEffect(EdgeEffect.Spring) .width('100%') .layoutWeight(1) } build() { Stack() { this.buildContentView(); // 顶部区域:标题栏在上,面包屑在下 Column() { this.topTitleBar() } .backgroundColor(Color.Transparent) .width('100%') .visibility(this.isAlbumMode ? Visibility.None : (this.isShowTitleBar ? Visibility.Visible : this.autoHideTitle ? Visibility.None : Visibility.Visible)) .animation({ duration: 500, curve: Curve.Friction // 可选动画曲线 }) .position({ top: 0, left: 0 }) // .backgroundColor($r('app.color.start_window_background')) } .width('100%') .height('100%') .alignContent(Alignment.Bottom) .backgroundImage(this.isMusicCoverHomeBackgroundActive() ? this.customizeMusicBgPath : (this.isCustomizeBg ? this.customizeBgPath : $r('app.color.start_window_background'))) .backgroundImageSize(this.isLandscape?{width:'100%'}:{ height: '100%'}) .backgroundImagePosition(Alignment.Center) .backdropBlur(this.blurValue) .backgroundBrightness({rate:this.isMusicCoverHomeBackgroundActive() || this.isCustomizeBg ? 0.1 : 0, lightUpDegree:this.bgBrightness}) .backgroundBlurStyle(this.isMusicCoverHomeBackgroundActive() ? BlurStyle.BACKGROUND_ULTRA_THICK : BlurStyle.NONE) .expandSafeArea([SafeAreaType.SYSTEM], [SafeAreaEdge.BOTTOM]) } @Builder buildContentView() { Column() { if (this.isSearchMode) { this.buildSearchContent() } else if (this.isAlbumMode) { FindAlbumDetail({ albumTitle: this.currentAlbumTitle, albumArtist: this.currentAlbumArtist, albumCoverPath: this.currentAlbumCoverPath, albumSourceLabel: this.currentAlbumSourceLabel, songs: this.currentAlbumSongs, useTransparentBackground: this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive(), defaultSortType: this.currentAlbumDefaultSortType, supportRecentPlayedSort: this.currentAlbumSupportRecentPlayedSort, allowDelete: this.canDeleteCurrentAlbumSongs(), onBack: () => { this.exitAlbumMode() }, topSafeHeight: this.topSafeHeight, bottomSafeHeight: this.bottomSafeHeight, themeColor: this.themeColor, onPlayAll: (songs?: VideoItem[]) => { this.handleAlbumPlayAll(songs) }, onRandomPlay: (songs?: VideoItem[]) => { this.handleAlbumRandomPlay(songs) }, onSongTap: (index: number, songs?: VideoItem[]) => { this.handleAlbumSongTap(index, songs) }, onPlayNextSong: (song: VideoItem) => { this.handleAddSongToNextPlay(song) }, onFavoriteSong: (song: VideoItem) => { void this.handleAlbumFavoriteSong(song) }, onDeleteSong: (song: VideoItem) => { this.openDeleteSongDialog(song) } }) } else { Refresh({ refreshing: $$this.isRefreshing }) { Column() { if (this.isPageLoading) { this.buildLoadingState() } else { this.buildDiscoveryContent() } } .width('100%') .height('100%') } .width('100%') .layoutWeight(1) .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%') .backgroundColor(this.isCustomizeBg || this.isMusicCoverHomeBackgroundActive() ? Color.Transparent : $r('app.color.start_window_background')) } }