Эх сурвалжийг харах

优化发现页的云盘歌曲的加载问题

onecold 4 сар өмнө
parent
commit
1d0bb80882

+ 56 - 3
entry/src/main/ets/common/network/DaoLiYuApi.ets

@@ -198,6 +198,42 @@ export interface DaoLiYuPagedResponse<T> {
   total?: number;
 }
 
+function buildDaoLiYuCoverDebugValue(value?: string): string {
+  if (!value) {
+    return '';
+  }
+  return value.length > 72 ? `...${value.substring(value.length - 72)}` : value;
+}
+
+function buildDaoLiYuTrackDebugItem(track?: DaoLiYuTrack): string {
+  if (!track) {
+    return 'unknown';
+  }
+  return `${track.title ?? track.id}|album=${track.album ?? ''}|cover=${buildDaoLiYuCoverDebugValue(track.coverArtUrl)}`;
+}
+
+function buildDaoLiYuTrackDebugLog(items: DaoLiYuTrack[], limit: number = 3): string {
+  if (!items || items.length === 0) {
+    return '[]';
+  }
+  const parts: string[] = [];
+  const maxCount = Math.min(limit, items.length);
+  for (let i = 0; i < maxCount; i++) {
+    parts.push(buildDaoLiYuTrackDebugItem(items[i]));
+  }
+  return `[${parts.join('; ')}](${items.length})`;
+}
+
+export function normalizeDaoLiYuTrackCoverArtUrl(account: WebDavAccount, coverArtUrl?: string): string | undefined {
+  const normalized = daoLiYuApi.buildImageUrl(account, coverArtUrl);
+  if (coverArtUrl || normalized) {
+    void ServerLogUtil.info(TAG,
+      `[cover-debug] normalizeTrackCover account=${account.name || account.host}, raw=${buildDaoLiYuCoverDebugValue(coverArtUrl)}, ` +
+      `normalized=${buildDaoLiYuCoverDebugValue(normalized)}`);
+  }
+  return normalized;
+}
+
 export class DaoLiYuApi {
   private authCache: Map<string, DaoLiYuAuthContext> = new Map();
 
@@ -252,9 +288,17 @@ export class DaoLiYuApi {
     const data = await this.get<DaoLiYuPagedData<DaoLiYuTrackEntry>>(account, '/api/tracks', params);
     const items = (data.items ?? [])
       .filter(item => item.id)
-      .map(item => this.mapTrack(item));
+      .map(item => {
+        const track = this.mapTrack(item);
+        track.coverArtUrl = normalizeDaoLiYuTrackCoverArtUrl(account, track.coverArtUrl);
+        return track;
+      });
     const nextStart = this.resolveNextStart(data, start, items.length);
     const total = typeof data.total === 'number' ? data.total : undefined;
+    const coverCount = items.filter(item => !!item.coverArtUrl && item.coverArtUrl.length > 0).length;
+    void ServerLogUtil.info(TAG,
+      `[cover-debug] getTracksPage account=${account.name || account.host}, start=${start}, size=${size}, ` +
+      `items=${items.length}, coverCount=${coverCount}, sample=${buildDaoLiYuTrackDebugLog(items)}`);
     return { items, nextStart, total };
   }
 
@@ -289,10 +333,19 @@ export class DaoLiYuApi {
     if (!detail?.tracks || detail.tracks.length === 0) {
       return [];
     }
-    return detail.tracks
+    const tracks = detail.tracks
       .map(track => track.track)
       .filter(track => track && track.id)
-      .map(track => this.mapTrack(track as DaoLiYuTrackEntry));
+      .map(track => {
+        const mappedTrack = this.mapTrack(track as DaoLiYuTrackEntry);
+        mappedTrack.coverArtUrl = normalizeDaoLiYuTrackCoverArtUrl(account, mappedTrack.coverArtUrl);
+        return mappedTrack;
+      });
+    const coverCount = tracks.filter(item => !!item.coverArtUrl && item.coverArtUrl.length > 0).length;
+    void ServerLogUtil.info(TAG,
+      `[cover-debug] getPlaylistTracks account=${account.name || account.host}, playlistId=${playlistId}, ` +
+      `items=${tracks.length}, coverCount=${coverCount}, sample=${buildDaoLiYuTrackDebugLog(tracks)}`);
+    return tracks;
   }
 
   async getTracksByAlbumId(account: WebDavAccount, albumId: string, albumName?: string, limit: number = 0): Promise<DaoLiYuTrack[]> {

+ 41 - 0
entry/src/main/ets/common/util/FindDiscoveryHelper.ets

@@ -120,6 +120,47 @@ export function buildPreferredRemotePlaybackPool(indexedSongs: VideoItem[], fall
   return filterUniquePlaybackSongs(fallbackSongs)
 }
 
+export function shouldWaitForIndexedRemotePlayback(isIndexReady: boolean, fallbackSongCount: number): boolean {
+  if (isIndexReady) {
+    return true
+  }
+  return fallbackSongCount <= 0
+}
+
+export function findPreferredRemotePlaybackAccountId(fallbackSongs: VideoItem[], preferredAccountId?: string): string {
+  if (!fallbackSongs || fallbackSongs.length === 0) {
+    return preferredAccountId ?? ''
+  }
+  const accountOrder: string[] = []
+  const accountCount: Map<string, number> = new Map<string, number>()
+  for (let index = 0; index < fallbackSongs.length; index += 1) {
+    const accountId = fallbackSongs[index].webdav_account_id ?? ''
+    if (StrUtil.isEmpty(accountId)) {
+      continue
+    }
+    if (!accountCount.has(accountId)) {
+      accountOrder.push(accountId)
+      accountCount.set(accountId, 1)
+    } else {
+      accountCount.set(accountId, (accountCount.get(accountId) ?? 0) + 1)
+    }
+  }
+  if (StrUtil.isNotEmpty(preferredAccountId) && accountCount.has(preferredAccountId as string)) {
+    return preferredAccountId as string
+  }
+  let bestAccountId = ''
+  let bestCount = -1
+  for (let index = 0; index < accountOrder.length; index += 1) {
+    const accountId = accountOrder[index]
+    const count = accountCount.get(accountId) ?? 0
+    if (count > bestCount) {
+      bestAccountId = accountId
+      bestCount = count
+    }
+  }
+  return bestAccountId
+}
+
 export function resolveQueueStartIndex(queue: VideoItem[], filePath: string): number {
   if (StrUtil.isEmpty(filePath)) {
     return -1

+ 17 - 0
entry/src/main/ets/common/util/MiniPlayerOrbTapHelper.ets

@@ -0,0 +1,17 @@
+export enum MiniPlayerOrbTapAction {
+  WAIT_SECOND_TAP = 0,
+  TRIGGER_DOUBLE_TAP = 1,
+}
+
+export function resolveMiniPlayerOrbTapAction(hasPendingTap: boolean, lastTapAt: number, now: number,
+  doubleTapWindowMs: number): MiniPlayerOrbTapAction {
+  if (hasPendingTap && lastTapAt > 0 && now - lastTapAt <= doubleTapWindowMs) {
+    return MiniPlayerOrbTapAction.TRIGGER_DOUBLE_TAP
+  }
+  return MiniPlayerOrbTapAction.WAIT_SECOND_TAP
+}
+
+export function shouldTriggerMiniPlayerOrbSingleTap(lastTapAt: number, now: number, doubleTapWindowMs: number):
+  boolean {
+  return lastTapAt > 0 && now - lastTapAt >= doubleTapWindowMs
+}

+ 130 - 0
entry/src/main/ets/common/util/RemoteCoverResolver.ets

@@ -0,0 +1,130 @@
+import { StrUtil } from '@pura/harmony-utils'
+import { VideoItem } from '../../viewmodel/VideoItem'
+import { WebDavAccount } from '../../viewmodel/WebDavAccount'
+import { CommonConstants } from '../constants/CommonConstants'
+import { RemoteDriveType } from '../enums/RemoteDriveType'
+import { daoLiYuApi } from '../network/DaoLiYuApi'
+import { plexApi } from '../network/PlexApi'
+import { navidromeApi } from '../network/NavidromeApi'
+import { jellyfinApi } from '../network/JellyfinApi'
+import { embyApi } from '../network/EmbyApi'
+import { audioStationApi } from '../network/AudioStationApi'
+
+function buildRemoteBaseUrl(account: WebDavAccount): string | undefined {
+  const host = account.isUseLocalHost && account.localHost ? account.localHost : account.host
+  if (StrUtil.isEmpty(host)) {
+    return undefined
+  }
+  const scheme = account.enableHttps ? 'https' : 'http'
+  const port = account.port || 80
+  return `${scheme}://${host}:${port}`
+}
+
+function normalizeRelativePath(path: string): string {
+  return path.startsWith('/') ? path : `/${path}`
+}
+
+function isOpaqueCoverIdentifier(value: string): boolean {
+  return !value.startsWith('/')
+}
+
+function stripAudioStationSongCoverId(value: string): string {
+  return value.replace(/^as-song:/, '')
+}
+
+function resolveCoverIdentity(song: VideoItem): string | undefined {
+  if (StrUtil.isNotEmpty(song.pixelMapPath)) {
+    const coverPath = song.pixelMapPath as string
+    if (!isAbsoluteRemoteCoverPath(coverPath) && isOpaqueCoverIdentifier(coverPath)) {
+      return coverPath
+    }
+  }
+  if (song.type === CommonConstants.TYPE_AUDIOSTATION && StrUtil.isNotEmpty(song.remote_rel_path)) {
+    return `as-song:${song.remote_rel_path as string}`
+  }
+  if (StrUtil.isNotEmpty(song.navAlbumId)) {
+    return song.navAlbumId as string
+  }
+  if (StrUtil.isNotEmpty(song.remote_rel_path)) {
+    return song.remote_rel_path as string
+  }
+  if (StrUtil.isNotEmpty(song.id)) {
+    return song.id
+  }
+  return undefined
+}
+
+export function isAbsoluteRemoteCoverPath(path?: string): boolean {
+  if (StrUtil.isEmpty(path)) {
+    return false
+  }
+  const value = path as string
+  return value.startsWith('http://') ||
+    value.startsWith('https://') ||
+    value.startsWith('file://') ||
+    value.startsWith('resource://') ||
+    value.startsWith('/data/storage')
+}
+
+export function normalizeStoredRemoteCoverPath(account: WebDavAccount, coverPath?: string): string | undefined {
+  if (!account || StrUtil.isEmpty(coverPath)) {
+    return undefined
+  }
+  const value = coverPath as string
+  if (isAbsoluteRemoteCoverPath(value)) {
+    return value
+  }
+  if (!value.startsWith('/')) {
+    return undefined
+  }
+  if (account.webType === RemoteDriveType.DaoLiYu) {
+    return daoLiYuApi.buildImageUrl(account, value)
+  }
+  if (account.webType === RemoteDriveType.Plex) {
+    return plexApi.buildImageUrl(account, value) ?? undefined
+  }
+  const baseUrl = buildRemoteBaseUrl(account)
+  if (!baseUrl) {
+    return undefined
+  }
+  return `${baseUrl}${normalizeRelativePath(value)}`
+}
+
+export async function resolveRemoteCoverForSong(song: VideoItem, account?: WebDavAccount): Promise<string | undefined> {
+  if (!song || !account) {
+    return song?.pixelMapPath
+  }
+  const normalizedStoredCover = normalizeStoredRemoteCoverPath(account, song.pixelMapPath)
+  if (normalizedStoredCover) {
+    return normalizedStoredCover
+  }
+  if (isAbsoluteRemoteCoverPath(song.pixelMapPath)) {
+    return song.pixelMapPath
+  }
+
+  const identity = resolveCoverIdentity(song)
+  if (StrUtil.isEmpty(identity)) {
+    return song.pixelMapPath
+  }
+  const resolvedIdentity = identity as string
+
+  switch (account.webType) {
+    case RemoteDriveType.Navidrome:
+      return await navidromeApi.buildCoverArtUrl(account, resolvedIdentity, 300)
+    case RemoteDriveType.Jellyfin:
+      return await jellyfinApi.buildPrimaryImageUrl(account, resolvedIdentity, 300, 300)
+    case RemoteDriveType.Emby:
+      return await embyApi.buildPrimaryImageUrl(account, resolvedIdentity, 300, 300)
+    case RemoteDriveType.AudioStation:
+      if (resolvedIdentity.startsWith('as-song:')) {
+        return await audioStationApi.buildSongCoverUrl(account, stripAudioStationSongCoverId(resolvedIdentity))
+      }
+      return await audioStationApi.buildAlbumCoverUrl(account, song.album, song.artist ?? song.ALBUMARTIST)
+    case RemoteDriveType.Plex:
+      return plexApi.buildImageUrl(account, resolvedIdentity) ?? song.pixelMapPath
+    case RemoteDriveType.DaoLiYu:
+      return normalizeStoredRemoteCoverPath(account, song.pixelMapPath) ?? song.pixelMapPath
+    default:
+      return song.pixelMapPath
+  }
+}

+ 18 - 4
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -32,7 +32,7 @@ import { JSON } from '@kit.ArkTS';
 import { simpleCalculateMD5, simplePrecreateFile, simpleUploadFilePart, simpleCreateFile, TaskPrecreateRequest, TaskPrecreateResponse, TaskUploadPartResponse, TaskCreateFileRequest, TaskCreateFileResponse, convertToBaiduPath, simpleLocateUploadServer } from './TaskPoolHelper';
 import { JellyfinApi, JellyfinAlbum, JellyfinArtist, JellyfinSong } from '../network/JellyfinApi';
 import { EmbyApi, EmbyAlbum, EmbyArtist, EmbySong } from '../network/EmbyApi';
-import { daoLiYuApi, DaoLiYuPagedResponse, DaoLiYuTrack } from '../network/DaoLiYuApi';
+import { daoLiYuApi, DaoLiYuPagedResponse, DaoLiYuTrack, normalizeDaoLiYuTrackCoverArtUrl } from '../network/DaoLiYuApi';
 
 const TAG = 'heanup RemoteDriveManager';
 
@@ -43,12 +43,20 @@ function buildRemoteSongDebugPath(path?: string): string {
   return path.length > 48 ? `...${path.substring(path.length - 48)}` : path;
 }
 
+function buildRemoteSongDebugCover(cover?: string): string {
+  if (!cover) {
+    return '';
+  }
+  return cover.length > 72 ? `...${cover.substring(cover.length - 72)}` : cover;
+}
+
 function buildRemoteSongDebugItem(item?: VideoItem): string {
   if (!item) {
     return 'unknown';
   }
   const title = item.name || item.fileName || '未知歌曲';
-  return `${title}|type=${item.type}|acc=${item.webdav_account_id || ''}|path=${buildRemoteSongDebugPath(item.filePath)}`;
+  return `${title}|type=${item.type}|acc=${item.webdav_account_id || ''}|path=${buildRemoteSongDebugPath(item.filePath)}|` +
+    `cover=${buildRemoteSongDebugCover(item.pixelMapPath)}`;
 }
 
 function buildRemoteSongDebugLog(items: VideoItem[], limit: number = 5): string {
@@ -4335,6 +4343,8 @@ export class RemoteDriveManager {
     const title = song.title ?? '未知曲目';
     const suffix = song.suffix ?? '';
     const fileName = `${title}${suffix ? '.' + suffix : ''}`;
+    const rawCoverUrl = song.coverArtUrl;
+    const coverUrl = normalizeDaoLiYuTrackCoverArtUrl(account, rawCoverUrl);
     const videoItem = new VideoItem(
       title,
       song.id,
@@ -4343,7 +4353,7 @@ export class RemoteDriveManager {
       song.size ?? 0,
       song.createdAt ?? Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
       Utility.formatFSize(song.size ?? 0),
-      song.coverArtUrl,
+      coverUrl,
       song.artist ?? Constants.UNKNOWN_ARTIST,
       song.album,
       fileName
@@ -4354,7 +4364,7 @@ export class RemoteDriveManager {
     videoItem.bit_rate = song.bitRate ? song.bitRate.toString() : undefined;
     videoItem.sampleRate = song.sampleRate ? song.sampleRate.toString() : undefined;
     videoItem.mimeType = song.mimeType;
-    videoItem.pixelMapPath = song.coverArtUrl;
+    videoItem.pixelMapPath = coverUrl;
     videoItem.lyricContent = song.lyrics;
     this.applyInitialRemoteSongQuality(videoItem, suffix || song.mimeType || '', fileName, song.bitRate, song.sampleRate);
     if (song.albumId && song.albumId.length > 0) {
@@ -4369,6 +4379,10 @@ export class RemoteDriveManager {
     if (song.year !== undefined && song.year !== null) {
       videoItem.year = song.year.toString();
     }
+    Logger.info(TAG,
+      `[cover-debug] buildDaoLiYuVideoItem title=${title}, id=${song.id}, albumId=${song.albumId ?? ''}, ` +
+      `rawCover=${buildRemoteSongDebugCover(rawCoverUrl)}, normalizedCover=${buildRemoteSongDebugCover(coverUrl)}, ` +
+      `storagePath=${buildRemoteSongDebugPath(videoItem.remote_rel_path || videoItem.filePath)}`);
     return videoItem;
   }
 

+ 1 - 1
entry/src/main/ets/pages/NewIndex.ets

@@ -1135,7 +1135,7 @@ struct NewIndex {
       .backgroundImage(StrUtil.isEmpty(this.cover) ? $r('app.media.alt') : this.cover)
       .backgroundImageSize({ width: '100%' })
       .scale({ x: this.miniPlayerScaleX, y: this.miniPlayerScaleY, centerX: '50%', centerY: '50%' })
-      .opacity(Math.min(0.99, this.miniPlayerOpacity))
+      .opacity(Math.min(0.98, this.miniPlayerOpacity))
       .clip(true)
       .clickEffect({ level: ClickEffectLevel.HEAVY })
     }

+ 223 - 9
entry/src/main/ets/view/FindView.ets

@@ -36,10 +36,13 @@ import { getNavidromeVideoItems, setNavidromePlaylist } from '../common/util/Nav
 import {
   buildPreferredRemotePlaybackPool,
   buildSortedDiscoverySongs,
+  findPreferredRemotePlaybackAccountId,
   FindCollectionSortType,
-  resolveQueueStartIndex
+  resolveQueueStartIndex,
+  shouldWaitForIndexedRemotePlayback
 } from '../common/util/FindDiscoveryHelper'
 import { insertSongToNextPlayQueue, QueueInsertStatus } from '../common/util/PlayQueueHelper'
+import { resolveRemoteCoverForSong } from '../common/util/RemoteCoverResolver'
 
 @Builder
 export function FindViewBuilder() {
@@ -50,6 +53,7 @@ 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
@@ -213,6 +217,9 @@ export struct FindView {
   private playlistSongsCache: Map<string, VideoItem[]> = new Map<string, VideoItem[]>()
   private heartPlaylistCoverTicket: number = 0
   private deleteComponentId: number = 0
+  private remoteAccountMap: Map<string, WebDavAccount> = new Map<string, WebDavAccount>()
+  private remoteCoverRepairTicket: number = 0
+  private remoteCoverRepairTimer: number = -1
 
   aboutToAppear(): void {
     this.initSetting()
@@ -231,6 +238,7 @@ export struct FindView {
   aboutToDisappear(): void {
     AppStorage.setOrCreate('findCanBack', false)
     emitter.off(EventConstants.EVENT_FIND_VIEW_BACK)
+    this.clearPendingRemoteCoverRepair()
   }
 
   initSetting(){
@@ -372,7 +380,10 @@ export struct FindView {
       this.refreshText = ''
       this.heartPlaylistCoverTicket += 1
       void this.resolveHeartPlaylistCovers(this.heartPlaylistCoverTicket, playlists)
-      if (uniqueRemoteSongs.length === 0) {
+      if (uniqueRemoteSongs.length > 0) {
+        void this.prepareRemoteAccounts()
+        this.scheduleVisibleRemoteCoverRepair(false, 1200)
+      } else {
         void this.bootstrapRemoteDiscoverySongsIfNeeded()
       }
       Logger.info(
@@ -438,6 +449,9 @@ export struct FindView {
     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
@@ -446,6 +460,134 @@ export struct FindView {
     }
   }
 
+  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<WebDavAccount[]> {
+    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<Map<string, WebDavAccount>> {
+    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<VideoItem[]> {
+    if (!items || items.length === 0) {
+      return items
+    }
+    const accountMap = await this.ensureRemoteAccountMapReady()
+    const tasks: Promise<VideoItem>[] = items.map(async (item: VideoItem): Promise<VideoItem> => {
+      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<void> {
     if (!this.mediaTable) {
       return
@@ -461,12 +603,13 @@ export struct FindView {
     )
     if (remoteSongs.length > 0) {
       this.applyRemoteDiscoverySongs(remoteSongs, true)
+      this.scheduleVisibleRemoteCoverRepair()
       return
     }
     await this.bootstrapRemoteDiscoverySongsIfNeeded()
   }
 
-  private async prepareActiveRemoteAccount(): Promise<WebDavAccount | undefined> {
+  private async prepareActiveRemoteAccount(fallbackSongs?: VideoItem[]): Promise<WebDavAccount | undefined> {
     try {
       const context = getContext(this) as common.Context
       this.remoteDriveManager.setContext(context)
@@ -476,15 +619,33 @@ export struct FindView {
       if (accounts.length === 0) {
         return undefined
       }
-      return this.remoteDriveManager.getActivatedWebDavAccount() || accounts[0]
+      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(): Promise<VideoItem[]> {
-    const account = await this.prepareActiveRemoteAccount()
+  private async queryIndexedRemotePlaybackSongs(fallbackSongs: VideoItem[] = []): Promise<VideoItem[]> {
+    const account = await this.prepareActiveRemoteAccount(fallbackSongs)
     if (!account) {
       Logger.warn(TAG, '[remote-debug] queryIndexedRemotePlaybackSongs skip: no active account')
       return []
@@ -498,6 +659,15 @@ export struct FindView {
     )
     if (supportsGlobalIndex && !isIndexReady) {
       ToastUtil.showToast('云端加载,请稍候')
+      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))
@@ -555,6 +725,7 @@ export struct FindView {
         return
       }
       this.applyRemoteDiscoverySongs(refreshedRemoteSongs, true)
+      this.scheduleVisibleRemoteCoverRepair()
       Logger.info(
         TAG,
         `发现页远程预热完成: saved=${savedCount}, remote=${refreshedRemoteSongs.length}, ` +
@@ -1137,8 +1308,9 @@ export struct FindView {
     if (!this.mediaTable) {
       return []
     }
-    const indexedSongs = await this.queryIndexedRemotePlaybackSongs()
     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())
@@ -1769,7 +1941,7 @@ export struct FindView {
     }
     if (this.searchRemoteSongsPool.length === 0) {
       const remoteSongs = await this.mediaTable.queryRemoteSongsAsync()
-      this.searchRemoteSongsPool = this.filterUniqueSongs(remoteSongs)
+      this.searchRemoteSongsPool = await this.resolveRemoteSongCovers(this.filterUniqueSongs(remoteSongs), true)
     }
   }
 
@@ -1903,7 +2075,8 @@ export struct FindView {
     if (!item) {
       return 'unknown'
     }
-    return `${this.getSongTitle(item)}|type=${item.type}|acc=${item.webdav_account_id ?? ''}|path=${this.sanitizeSongPath(item.filePath)}`
+    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 {
@@ -1917,6 +2090,44 @@ export struct FindView {
     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 '[]'
@@ -2706,6 +2917,7 @@ export struct FindView {
           .width('100%')
           .aspectRatio(3 / 4)
           .borderRadius(16)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {
@@ -2797,6 +3009,7 @@ export struct FindView {
           .width('100%')
           .aspectRatio(3 / 4)
           .borderRadius(18)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {
@@ -2941,6 +3154,7 @@ export struct FindView {
           .aspectRatio(3 / 4)
           .borderRadius(16)
           .width(160)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {

+ 41 - 1
entry/src/ohosTest/ets/test/FindDiscoveryHelper.test.ets

@@ -3,8 +3,10 @@ import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
 import {
   buildPreferredRemotePlaybackPool,
   buildSortedDiscoverySongs,
+  findPreferredRemotePlaybackAccountId,
   FindCollectionSortType,
-  resolveQueueStartIndex
+  resolveQueueStartIndex,
+  shouldWaitForIndexedRemotePlayback
 } from '../../../main/ets/common/util/FindDiscoveryHelper'
 
 function createSong(name: string, filePath: string, options?: {
@@ -89,5 +91,43 @@ export default function findDiscoveryHelperTest() {
       const queue = buildPreferredRemotePlaybackPool([], dbSongs)
       expect(queue.map(item => item.filePath).join(',')).assertEqual('/db/b.flac,/db/a.flac')
     })
+
+    it('doNotWaitForIndexWhenDatabaseRemoteSongsAlreadyExist', 0, () => {
+      expect(shouldWaitForIndexedRemotePlayback(false, 32)).assertFalse()
+      expect(shouldWaitForIndexedRemotePlayback(false, 1)).assertFalse()
+    })
+
+    it('waitForIndexWhenNoDatabaseRemoteSongsExist', 0, () => {
+      expect(shouldWaitForIndexedRemotePlayback(false, 0)).assertTrue()
+      expect(shouldWaitForIndexedRemotePlayback(true, 32)).assertTrue()
+    })
+
+    it('preferCurrentAccountWhenItAlreadyHasRemoteSongs', 0, () => {
+      const songs = [
+        createSong('A1', '/cloud/a1.flac'),
+        createSong('B1', '/cloud/b1.flac'),
+        createSong('A2', '/cloud/a2.flac')
+      ]
+      songs[0].webdav_account_id = '11'
+      songs[1].webdav_account_id = '22'
+      songs[2].webdav_account_id = '11'
+
+      expect(findPreferredRemotePlaybackAccountId(songs, '22')).assertEqual('22')
+    })
+
+    it('fallbackToAccountWithMostDiscoverySongsWhenCurrentAccountHasNoSongs', 0, () => {
+      const songs = [
+        createSong('A1', '/cloud/a1.flac'),
+        createSong('B1', '/cloud/b1.flac'),
+        createSong('A2', '/cloud/a2.flac'),
+        createSong('C1', '/cloud/c1.flac')
+      ]
+      songs[0].webdav_account_id = '11'
+      songs[1].webdav_account_id = '22'
+      songs[2].webdav_account_id = '11'
+      songs[3].webdav_account_id = '33'
+
+      expect(findPreferredRemotePlaybackAccountId(songs, '99')).assertEqual('11')
+    })
   })
 }