Selaa lähdekoodia

Merge branch 'master' of https://git.ss5.xyz/onecold/TTMusic

onecold 4 kuukautta sitten
vanhempi
sitoutus
ed1db73aea

+ 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

+ 32 - 1
entry/src/main/ets/common/util/FindPlaylistStore.ets

@@ -1,15 +1,41 @@
 import { VideoItem } from '../../viewmodel/VideoItem';
 
+export interface FindPlaylistMeta {
+  // 发现页“随心所欲”本地随机分页队列的附加元数据。
+  isPagedLocalQueue?: boolean;
+  pageIndex?: number;
+  pageSize?: number;
+  totalCount?: number;
+  sortType?: number;
+}
+
 let findPlaylistId: string = '';
 let findPlaylistName: string = '';
 let findVideoItems: VideoItem[] = [];
 let findCurrentPlayIndex: number = 0;
+let findPlaylistMeta: FindPlaylistMeta = {};
+
+function cloneFindPlaylistMeta(meta?: FindPlaylistMeta): FindPlaylistMeta {
+  if (!meta) {
+    return {};
+  }
+  return {
+    isPagedLocalQueue: meta.isPagedLocalQueue,
+    pageIndex: meta.pageIndex,
+    pageSize: meta.pageSize,
+    totalCount: meta.totalCount,
+    sortType: meta.sortType
+  };
+}
 
-export function setFindPlaylist(playlistId: string, playlistName: string, items: VideoItem[], startIndex: number): void {
+export function setFindPlaylist(playlistId: string, playlistName: string, items: VideoItem[], startIndex: number,
+  meta?: FindPlaylistMeta): void {
   findPlaylistId = playlistId;
   findPlaylistName = playlistName;
   findVideoItems = items.slice();
   findCurrentPlayIndex = startIndex;
+  // 发现页播放请求会先落到这里,播放器再按 playlistId 读取这些内存数据恢复队列。
+  findPlaylistMeta = cloneFindPlaylistMeta(meta);
 }
 
 export function getFindPlaylistId(): string {
@@ -28,9 +54,14 @@ export function getFindCurrentPlayIndex(): number {
   return findCurrentPlayIndex;
 }
 
+export function getFindPlaylistMeta(): FindPlaylistMeta {
+  return cloneFindPlaylistMeta(findPlaylistMeta);
+}
+
 export function clearFindPlaylist(): void {
   findPlaylistId = '';
   findPlaylistName = '';
   findVideoItems = [];
   findCurrentPlayIndex = 0;
+  findPlaylistMeta = {};
 }

+ 54 - 0
entry/src/main/ets/common/util/LocalRandomPagedQueueHelper.ets

@@ -0,0 +1,54 @@
+import { VideoItem } from '../../viewmodel/VideoItem'
+
+export const FIND_LOCAL_RANDOM_PAGED_PLAYLIST_ID = 'find-local-random-paged'
+export const DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE = 53
+
+export interface LocalRandomPagedQueueSeed {
+  items: VideoItem[]
+  totalCount: number
+  pageIndex: number
+  pageSize: number
+  startIndex: number
+  globalIndex: number
+}
+
+function normalizePageSize(pageSize: number): number {
+  if (pageSize <= 0) {
+    return DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE
+  }
+  return Math.max(1, Math.floor(pageSize))
+}
+
+export function isFindLocalRandomPagedPlaylist(playlistId: string): boolean {
+  return playlistId === FIND_LOCAL_RANDOM_PAGED_PLAYLIST_ID
+}
+
+export function buildLocalRandomPagedQueueSeed(allSongs: VideoItem[], randomIndex: number,
+  pageSize: number = DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE): LocalRandomPagedQueueSeed {
+  const normalizedPageSize = normalizePageSize(pageSize)
+  if (!allSongs || allSongs.length === 0) {
+    return {
+      items: [],
+      totalCount: 0,
+      pageIndex: 0,
+      pageSize: normalizedPageSize,
+      startIndex: 0,
+      globalIndex: 0
+    }
+  }
+
+  const safeIndex = Math.min(Math.max(randomIndex, 0), allSongs.length - 1)
+  // 以“随机命中的那首歌所在页”作为初始队列,避免首次就把全量歌曲塞进播放列表。
+  const pageIndex = Math.floor(safeIndex / normalizedPageSize)
+  const pageStart = pageIndex * normalizedPageSize
+  const pageItems = allSongs.slice(pageStart, Math.min(pageStart + normalizedPageSize, allSongs.length))
+
+  return {
+    items: pageItems,
+    totalCount: allSongs.length,
+    pageIndex,
+    pageSize: normalizedPageSize,
+    startIndex: safeIndex - pageStart,
+    globalIndex: safeIndex
+  }
+}

+ 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
+}

+ 23 - 0
entry/src/main/ets/common/util/PlaylistPlayDispatchHelper.ets

@@ -0,0 +1,23 @@
+import { VideoItem } from '../../viewmodel/VideoItem';
+
+function isFindPlaylistRequest(playlistId: string): boolean {
+  return playlistId === 'find-album-playlist' || playlistId.indexOf('find-') === 0;
+}
+
+export function shouldUseInMemoryPlaylistPayload(playlistId: string): boolean {
+  return playlistId === 'webdav-playlist' ||
+    playlistId === 'navidrome-playlist' ||
+    isFindPlaylistRequest(playlistId);
+}
+
+export function buildPlaylistDispatchFilePaths(playlistId: string, songs: VideoItem[]): string[] {
+  // 这些播放请求会直接从内存仓库恢复完整队列,不需要额外复制全量路径数组。
+  if (shouldUseInMemoryPlaylistPayload(playlistId)) {
+    return [];
+  }
+  return songs.map((item: VideoItem): string => item.filePath);
+}
+
+export function resolvePlaylistDisplayCount(currentSongListLength: number, songListLength: number): number {
+  return currentSongListLength > 0 ? currentSongListLength : songListLength;
+}

+ 47 - 0
entry/src/main/ets/common/util/PlaylistSearchHelper.ets

@@ -0,0 +1,47 @@
+import { VideoItem } from '../../viewmodel/VideoItem'
+
+function normalizeSearchText(value: string | undefined): string {
+  return value ? value.trim().toLowerCase() : ''
+}
+
+function buildSearchCandidateText(song: VideoItem): string {
+  const rawFragments: Array<string | undefined> = [
+    song.name,
+    song.artist,
+    song.album,
+    song.fileName
+  ]
+  const fragments: string[] = []
+
+  rawFragments.forEach((item: string | undefined): void => {
+    if (item !== undefined && item.length > 0) {
+      fragments.push(item)
+    }
+  })
+
+  return fragments
+    .join('\n')
+    .toLowerCase()
+}
+
+// 歌单搜索只在当前歌单队列里过滤,保持原始歌单顺序不变。
+export function filterPlaylistSongsByKeyword(songs: VideoItem[], keyword: string): VideoItem[] {
+  const normalizedKeyword = normalizeSearchText(keyword)
+  if (normalizedKeyword.length === 0) {
+    return [...songs]
+  }
+
+  return songs.filter((song: VideoItem): boolean => {
+    return buildSearchCandidateText(song).includes(normalizedKeyword)
+  })
+}
+
+// 播放队列定位未命中时必须返回 -1,避免错误回落到队列第一首。
+export function findSongIndexByFilePath(queue: VideoItem[], filePath: string): number {
+  for (let index = 0; index < queue.length; index++) {
+    if (queue[index].filePath === filePath) {
+      return index
+    }
+  }
+  return -1
+}

+ 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;
   }
 

+ 5 - 0
entry/src/main/ets/common/util/WebDavListRenderHelper.ets

@@ -0,0 +1,5 @@
+export function buildWebDavSongItemRenderKey(filePath: string, index: number, pixelMapPath?: string): string {
+  const safeFilePath: string = filePath && filePath.length > 0 ? filePath : 'unknown'
+  const safeCoverKey: string = pixelMapPath && pixelMapPath.length > 0 ? pixelMapPath : 'no-cover'
+  return `${safeFilePath}_${index}_${safeCoverKey}`
+}

+ 43 - 0
entry/src/main/ets/common/util/WebDavThumbPrefetchHelper.ets

@@ -0,0 +1,43 @@
+export interface WebDavThumbPrefetchRange {
+  start: number;
+  endExclusive: number;
+}
+
+export interface WebDavVisibleThumbPrefetchWindow {
+  hasVisibleSongs: boolean;
+  range: WebDavThumbPrefetchRange;
+}
+
+const EMPTY_WEBDAV_THUMB_RANGE: WebDavThumbPrefetchRange = {
+  start: 0,
+  endExclusive: 0
+}
+
+const EMPTY_WEBDAV_VISIBLE_WINDOW: WebDavVisibleThumbPrefetchWindow = {
+  hasVisibleSongs: false,
+  range: EMPTY_WEBDAV_THUMB_RANGE
+}
+
+// List 的可见索引会把顶部文件夹项也算进去,这里统一换算成歌曲数组范围。
+export function buildWebDavVisibleThumbPrefetchWindow(totalSongCount: number, folderCount: number,
+  visibleListStart: number, visibleListEnd: number): WebDavVisibleThumbPrefetchWindow {
+  if (totalSongCount <= 0) {
+    return EMPTY_WEBDAV_VISIBLE_WINDOW
+  }
+  const safeFolderCount: number = Math.max(folderCount, 0)
+  const safeVisibleStart: number = Math.max(visibleListStart, 0)
+  const safeVisibleEnd: number = Math.max(visibleListEnd, safeVisibleStart)
+  const songStart: number = Math.max(0, safeVisibleStart - safeFolderCount)
+  const songEndInclusive: number = Math.min(totalSongCount - 1, safeVisibleEnd - safeFolderCount)
+  if (songEndInclusive < songStart) {
+    return EMPTY_WEBDAV_VISIBLE_WINDOW
+  }
+  const range: WebDavThumbPrefetchRange = {
+    start: songStart,
+    endExclusive: songEndInclusive + 1
+  }
+  return {
+    hasVisibleSongs: true,
+    range: range
+  }
+}

+ 96 - 100
entry/src/main/ets/pages/NewIndex.ets

@@ -62,6 +62,7 @@ import { PointLightContentButton } from '../view/PointLight/PointLightContentBut
 import { PlayingIndicator } from '../view/PlayingIndicator';
 import { FindView } from '../view/FindView';
 import { hdsEffect } from '@kit.UIDesignKit';
+import { MiniPlayerBar } from '../view/MiniPlayerBar';
 import {
   resolveMiniPlayerMorphTarget,
 } from '../common/util/PlayerDismissHelper';
@@ -443,11 +444,11 @@ struct NewIndex {
       this.loadPlaylistList()
     });
 
-    if(Utility.isOpenTime()&&!PreferencesUtil.getBooleanSync('isShowHaoPingDialog17', false)){
+    if(Utility.isOpenTime()&&!PreferencesUtil.getBooleanSync('isShowHaoPingDialog18', false)){
       setTimeout(()=>{
         Utility.showHaoPingDialog(this.getUIContext(),this.context,this.appName,this.bundleName)
-        PreferencesUtil.putSync('isShowHaoPingDialog17', true)
-      },25000)
+        PreferencesUtil.putSync('isShowHaoPingDialog18', true)
+      },50000)
     }
     this.initDefalutType()
   }
@@ -542,6 +543,7 @@ struct NewIndex {
   }
 
   private applyDefaultHomeState(): void {
+    this.mType = 0
     this.defalut_home_type = PreferencesUtil.getNumberSync('defalut_home_type', 0)
     const configuredAccount = this.getConfiguredHomeAccount()
 
@@ -549,7 +551,7 @@ struct NewIndex {
     this.currentSongListName = ''
     this.modeType = 0
     this.tabSelectedIndexes = [0]
-    this.mType = 0
+
 
     if(this.defalut_home_type==1){
       this.mType = 0
@@ -1023,13 +1025,8 @@ struct NewIndex {
   @Builder
   ContentBuild() {
     Stack() {
-      Stack() {
-        LocalMusic()
-      }
-      .width('100%')
-      .height('100%')
-      .visibility(this.mType === 0 || this.isShowPlay ? Visibility.Visible : Visibility.Hidden)
-      .zIndex(this.isShowPlay ? 100 : 0)
+      LocalMusic()
+        .visibility(this.mType === 0 ? Visibility.Visible : Visibility.None)
       // 本地音乐内容区
       if(this.mType === 1 ){
         UserCenter()
@@ -1101,56 +1098,56 @@ struct NewIndex {
   // 迷你播放条挂载后统一走这个 Builder,避免 build() 里堆叠过多手机/HiCar/orb 分支。
   @Builder
   private MiniPlayerBarBuilder() {
-    Row() {
-      Stack() {
-        // 完整播放条层在收拢时向右退出,展开时再从右侧拉回。
-        if (!this.isMiniPlayerOrbMode || this.miniPlayerFullContentOpacity > 0.02) {
-          Stack() {
-            if (this.curDisplayIsHiCar) {
-              this.HiCarPlayController()
-            } else {
-              this.PlayController()
-            }
-          }
-          .width('100%')
-          .height('100%')
-          .opacity(this.miniPlayerFullContentOpacity)
-          .translate({ x: this.miniPlayerFullContentTranslateX })
-        }
-
-        // orb 层固定贴右侧,负责接住收拢后的封面并作为展开入口。
-        if (this.isMiniPlayerOrbMode || this.miniPlayerOrbOpacity > 0.02) {
-          Row() {
-            this.MiniPlayerOrbControl()
-          }
-          .width('100%')
-          .height('100%')
-          .justifyContent(FlexAlign.End)
-          .alignItems(VerticalAlign.Center)
-        }
-
-        if (this.miniPlayerProxySize > 0) {
-          this.MiniPlayerMorphProxyDot()
-        }
+    MiniPlayerBar({
+      bottomBarHeight: this.bottomBarHeight,
+      bottomSafeHeight: this.bottomSafeHeight,
+      curDisplayIsHiCar: this.curDisplayIsHiCar,
+      cover: this.cover,
+      currentSong: this.currentSong,
+      themeColor: this.themeColor,
+      progressValue: this.progressValue,
+      controlPlayStatus: this.CONTROL_PlayStatus,
+      isShowPrecious: this.isShowPrecious,
+      isMiniPlayerOrbMode: this.isMiniPlayerOrbMode,
+      isMiniPlayerModeTransitioning: this.isMiniPlayerModeTransitioning,
+      miniPlayerScaleX: this.miniPlayerScaleX,
+      miniPlayerScaleY: this.miniPlayerScaleY,
+      miniPlayerOpacity: this.miniPlayerOpacity,
+      miniPlayerBorderRadius: this.miniPlayerBorderRadius,
+      miniPlayerProxySize: this.miniPlayerProxySize,
+      miniPlayerProxyOpacity: this.miniPlayerProxyOpacity,
+      miniPlayerContentScaleY: this.miniPlayerContentScaleY,
+      miniPlayerContentTranslateY: this.miniPlayerContentTranslateY,
+      miniPlayerFullContentTranslateX: this.miniPlayerFullContentTranslateX,
+      miniPlayerTranslateY: this.miniPlayerTranslateY,
+      miniPlayerSurfaceWidth: this.miniPlayerSurfaceWidth,
+      miniPlayerSurfaceHeight: this.miniPlayerSurfaceHeight,
+      miniPlayerFullContentOpacity: this.miniPlayerFullContentOpacity,
+      miniPlayerOrbOpacity: this.miniPlayerOrbOpacity,
+      miniPlayerOrbScale: this.miniPlayerOrbScale,
+      miniPlayerOrbTranslateX: this.miniPlayerOrbTranslateX,
+      onOpenPlayer: (): void => {
+        this.setShowPlayTrue()
+      },
+      onCollapseToOrb: (): void => {
+        this.collapseMiniPlayerToOrb()
+      },
+      onOrbTap: (): void => {
+        this.handleMiniPlayerOrbTap()
+      },
+      onPlayPrevious: (): void => {
+        this.getUIContext().getHostContext()!.eventHub.emit('playPrevious');
+      },
+      onPlayOrPause: (): void => {
+        this.getUIContext().getHostContext()!.eventHub.emit('playOrPause');
+      },
+      onPlayNext: (): void => {
+        this.getUIContext().getHostContext()!.eventHub.emit('playNext');
+      },
+      onOpenPlayList: (): void => {
+        this.getUIContext().getHostContext()!.eventHub.emit('openPlayList');
       }
-      .width(this.miniPlayerSurfaceWidth)
-      .height(this.miniPlayerSurfaceHeight)
-      .borderRadius(this.miniPlayerBorderRadius)
-      .backgroundBlurStyle(BlurStyle.BACKGROUND_ULTRA_THICK)
-      .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))
-      .clip(true)
-      .clickEffect({ level: ClickEffectLevel.HEAVY })
-    }
-    .width('90%')
-    .height(this.bottomBarHeight)
-    .justifyContent(FlexAlign.End)
-    .alignItems(VerticalAlign.Center)
-    .translate({ y: this.miniPlayerTranslateY })
-    .margin({ bottom: DeviceUtil.getDeviceType() === resourceManager.DeviceType.DEVICE_TYPE_2IN1 || this.curDisplayIsHiCar
-      ? 30 : this.bottomSafeHeight })
+    })
   }
 
   build() {
@@ -1379,32 +1376,53 @@ struct NewIndex {
     .alignItems(VerticalAlign.Center)
   }
 
+  // 迷你播放条与 HiCar 模式复用同一个封面按钮,统一点击反馈与发光效果。
   @Builder
-  playConLeft() {
-    Row() {
-      Stack({ alignContent: Alignment.Center }) {
-        Image( StrUtil.isNotEmpty(this.currentSong?.pixelMapPath)?
-          this.currentSong?.pixelMapPath:$r('app.media.alt'))
-          .width(48)
-          .height(48)
-          .objectFit(ImageFit.Contain)
-          .alt( $r('app.media.alt'))
-          .fillColor(this.themeColor)
-          .borderRadius(8)
-          .shadow({
-            radius: 15,
-            type: ShadowType.BLUR,
-            color: 'on_primary'
-          })
+  private buildMiniPlayerCoverContent() {
+    Stack({ alignContent: Alignment.Center }) {
+      Image(StrUtil.isNotEmpty(this.currentSong?.pixelMapPath) ?
+        this.currentSong?.pixelMapPath : $r('app.media.alt'))
+        .width(48)
+        .height(48)
+        .objectFit(ImageFit.Contain)
+        .alt($r('app.media.alt'))
+        .fillColor(this.themeColor)
+        .borderRadius(8)
+        .shadow({
+          radius: 15,
+          type: ShadowType.BLUR,
+          color: 'on_primary'
+        })
+    }
+    .width(48)
+    .height(48)
+  }
+
+  @Builder
+  private miniPlayerCoverButton() {
+    PointLightContentButton({
+      pointColor: this.themeColor,
+      buttonRadius: 10,
+      pointLightHeight: 88,
+      pressScale: 0.92,
+      useShadow: false,
+      builder: () => {
+        this.buildMiniPlayerCoverContent()
       }
+    })
       .width(48)
       .height(48)
       .margin({ left: 5 })
       .zIndex(3)
-      .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.9 })
       .onClick((): void => {
         this.collapseMiniPlayerToOrb()
       })
+  }
+
+  @Builder
+  playConLeft() {
+    Row() {
+      this.miniPlayerCoverButton()
 
       Column() {
         Text(this.currentSong?.name)
@@ -1442,29 +1460,7 @@ struct NewIndex {
 
   @Builder
   private hiCarCoverControl() {
-    Stack({ alignContent: Alignment.Center }) {
-      Image(StrUtil.isNotEmpty(this.currentSong?.pixelMapPath)?
-        this.currentSong?.pixelMapPath:$r('app.media.alt'))
-        .width(48)
-        .height(48)
-        .objectFit(ImageFit.Contain)
-        .alt($r('app.media.alt'))
-        .fillColor(this.themeColor)
-        .borderRadius(8)
-        .shadow({
-          radius: 15,
-          type: ShadowType.BLUR,
-          color: 'on_primary'
-        })
-    }
-    .width(48)
-    .height(48)
-    .margin({ left: 5 })
-    .zIndex(3)
-    .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.9 })
-    .onClick((): void => {
-      this.collapseMiniPlayerToOrb()
-    })
+    this.miniPlayerCoverButton()
   }
 
   @Builder

+ 212 - 193
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -1,4 +1,4 @@
-import { BreadcrumbItem, RemoteDirectorySnapshot, RemoteDriveGlobalSearchResult, RemoteDriveManager } from '../common/util/RemoteDriveManager';
+import { BreadcrumbItem, RemoteDriveGlobalSearchResult, RemoteDriveManager } from '../common/util/RemoteDriveManager';
 import { WebDavAccount } from '../viewmodel/WebDavAccount';
 import { Song } from '../viewmodel/Song';
 import { RemoteDriveManagerStates } from '../common/enums/RemoteDriveManagerStates';
@@ -37,6 +37,8 @@ import { WebDavUrlUtil } from '../common/util/WebDavUrlUtil';
 import { SearchHistoryUtil } from '../common/util/SearchHistoryUtil';
 import { PlayingIndicator } from '../view/PlayingIndicator';
 import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore';
+import { buildWebDavVisibleThumbPrefetchWindow, WebDavThumbPrefetchRange } from '../common/util/WebDavThumbPrefetchHelper';
+import { buildWebDavSongItemRenderKey } from '../common/util/WebDavListRenderHelper';
 
 interface WebDavMetadataUpdatePayload {
   filePath: string;
@@ -54,15 +56,12 @@ interface RemoteThumbSource {
 
 const TAG = 'heanup WebDavMainPage';
 const REMOTE_THUMB_CACHE_DIR: string = 'remote_thumbs';
-const REMOTE_THUMB_MAX_TASK_COUNT: number = 12;
-const REMOTE_THUMB_LARGE_LIST_THRESHOLD: number = 120;
-const REMOTE_THUMB_LARGE_LIST_PREFETCH_COUNT: number = 6;
-const REMOTE_THUMB_LARGE_LIST_DELAY_MS: number = 900;
 const REMOTE_THUMB_CAPTURE_SECONDS: string = '1.2';
 const WEBDAV_PROGRESSIVE_RELOAD_THRESHOLD: number = 180;
 const WEBDAV_PROGRESSIVE_RELOAD_INITIAL_COUNT: number = 20;
 const WEBDAV_PROGRESSIVE_RELOAD_BATCH_SIZE: number = 20;
 const WEBDAV_PROGRESSIVE_RELOAD_DELAY_MS: number = 32;
+const WEBDAV_VISIBLE_THUMB_PREFETCH_DELAY_MS: number = 120;
 
 // WebDAV歌曲数据全局内存存储
 let globalWebdavVideoItems: VideoItem[] = [];
@@ -132,6 +131,7 @@ export struct WebDavMainPage {
   @Link isShowDrawer: boolean;
   @State isLoading: boolean = false;
   @State isRefreshingCache: boolean = false;
+  @State isDirectoryNavigationLoading: boolean = false;
   private webDavFiles: FileInfo[] = []; // 原始目录数据保持为普通字段,避免大数组响应式代理卡顿
   @StorageProp('themeColor') themeColor: string = CommonConstants.DEFAULT_THEME_COLOR;
   @StorageProp('isDarkMode') isDarkMode: boolean = false;
@@ -144,7 +144,6 @@ export struct WebDavMainPage {
   @State isShowFileName: boolean = false//是否显示文件名
   @State isLongNameRoLL: boolean = true//长歌名滚动
   @State sortType: number = 0 //默认排序方式
-  @State listRefreshKey: number = 0 // 列表刷新标识
   @Consume isMultiSelect: boolean
   @State selectedSongs: VideoItem[] = []
   @State selectedFolders: FileInfo[] = []
@@ -164,13 +163,17 @@ export struct WebDavMainPage {
   private readonly metadataUpdateEvent: emitter.InnerEvent = { eventId: EventConstants.EVENT_WEBDAV_METADATA_UPDATED };
   private thumbnailTaskToken: number = 0;
   private thumbnailRunningKeys: Set<string> = new Set<string>();
+  private visibleListStartIndex: number = -1;
+  private visibleListEndIndex: number = -1;
+  private scheduledThumbPrefetchTimer: number = -1;
+  private lastVisibleThumbStart: number = -1;
+  private lastVisibleThumbEndExclusive: number = -1;
   private readonly downloadCenterManager: DownloadCenterManager = DownloadCenterManager.getInstance();
   private readonly downloadCenterListener: () => void = (): void => {
     this.scheduleDownloadCenterRefresh();
   };
   private downloadCenterRefreshTimer: number = -1;
   private displayReloadToken: number = 0;
-  private pendingDirectoryRefreshTimer: number = -1;
   private pendingDirectoryRefreshToken: number = 0;
 
   private async runSerializedThumbnailFfmpeg(task: () => Promise<void>): Promise<void> {
@@ -186,8 +189,7 @@ export struct WebDavMainPage {
   async onSwitchAccount(){
     console.log('heanup 切换账户:', this.selectedAccount.name);
     this.cancelPendingDirectoryRefresh();
-    this.thumbnailTaskToken += 1;
-    this.thumbnailRunningKeys.clear();
+    this.resetRemoteThumbPrefetchState();
     this.searchTicket++;
     this.searchText = '';
     this.filteredList = [];
@@ -220,6 +222,7 @@ export struct WebDavMainPage {
 
   private refreshDisplaySongs(mList: Array<VideoItem>, shouldScrollToTop: boolean = true): void {
     const reloadToken: number = ++this.displayReloadToken;
+    this.invalidateRemoteThumbPrefetchRange()
     const displayItems: Array<VideoItem> = mList ? [...mList] : [];
     if (displayItems.length <= WEBDAV_PROGRESSIVE_RELOAD_THRESHOLD) {
       this.dataSource.pushArrayData(displayItems)
@@ -268,19 +271,23 @@ export struct WebDavMainPage {
   }
 
   private cancelPendingDirectoryRefresh(): void {
-    if (this.pendingDirectoryRefreshTimer >= 0) {
-      clearTimeout(this.pendingDirectoryRefreshTimer)
-      this.pendingDirectoryRefreshTimer = -1
-    }
     this.pendingDirectoryRefreshToken += 1
   }
 
-  private scheduleDirectoryRefresh(previewPath: string | undefined, loadAction: () => Promise<void>,
+  private startDirectoryNavigationLoading(): void {
+    this.isDirectoryNavigationLoading = true
+  }
+
+  private stopDirectoryNavigationLoading(): void {
+    this.isDirectoryNavigationLoading = false
+  }
+
+  private scheduleDirectoryRefresh(_targetPath: string | undefined, loadAction: () => Promise<void>,
     errorPrefix: string): void {
     this.cancelPendingDirectoryRefresh()
+    this.startDirectoryNavigationLoading()
     const refreshToken: number = this.pendingDirectoryRefreshToken
-    const hasPreview: boolean = previewPath !== undefined ? this.showDirectoryPreviewCache(previewPath) : false
-    this.isLoading = !hasPreview
+    this.isLoading = true
     this.isRefreshingCache = false
 
     const executeLoad = (): void => {
@@ -294,18 +301,11 @@ export struct WebDavMainPage {
         Logger.error(TAG, `${errorPrefix}: ${error.message}`)
         this.isLoading = false
         this.isRefreshingCache = false
+        this.stopDirectoryNavigationLoading()
       })
     }
 
-    if (!hasPreview) {
-      executeLoad()
-      return
-    }
-
-    this.pendingDirectoryRefreshTimer = setTimeout(() => {
-      this.pendingDirectoryRefreshTimer = -1
-      executeLoad()
-    }, 220) as number
+    executeLoad()
   }
 
   private buildThumbnailIdentity(item: VideoItem): string {
@@ -552,6 +552,39 @@ export struct WebDavMainPage {
     }
   }
 
+  private getDisplayedFolderCount(): number {
+    return this.isSearchMode ? this.filteredFolderList.length : this.visibleFoldersState.length
+  }
+
+  private stopScheduledRemoteThumbPrefetch(): void {
+    if (this.scheduledThumbPrefetchTimer >= 0) {
+      clearTimeout(this.scheduledThumbPrefetchTimer)
+      this.scheduledThumbPrefetchTimer = -1
+    }
+  }
+
+  // 保留当前视口索引,但让新的列表内容可以重新触发同一可见区间的封面补全。
+  private invalidateRemoteThumbPrefetchRange(): void {
+    this.stopScheduledRemoteThumbPrefetch()
+    this.thumbnailTaskToken += 1
+    this.thumbnailRunningKeys.clear()
+    this.lastVisibleThumbStart = -1
+    this.lastVisibleThumbEndExclusive = -1
+  }
+
+  // 列表数据源切换时需要显式取消旧任务,避免后台继续为已经离开的可见区生成封面。
+  private resetRemoteThumbPrefetchState(): void {
+    this.invalidateRemoteThumbPrefetchRange()
+    this.visibleListStartIndex = -1
+    this.visibleListEndIndex = -1
+  }
+
+  private updateVisibleRemoteThumbRange(start: number, end: number): void {
+    this.visibleListStartIndex = start
+    this.visibleListEndIndex = end
+    this.scheduleRemoteThumbPrefetch()
+  }
+
   private async prepareRemoteThumbSource(item: VideoItem): Promise<RemoteThumbSource> {
     let playUrl: string = item.filePath;
     if (StrUtil.isNotEmpty(playUrl)) {
@@ -669,22 +702,44 @@ export struct WebDavMainPage {
   }
 
   private scheduleRemoteThumbPrefetch(): void {
-    const token: number = this.thumbnailTaskToken + 1;
-    this.thumbnailTaskToken = token;
-    this.thumbnailRunningKeys.clear();
-    const mediaItems: VideoItem[] = this.songs.slice();
-    if (mediaItems.length <= 0) {
-      return;
+    if (this.isLoading || this.isRefreshingCache) {
+      this.stopScheduledRemoteThumbPrefetch()
+      this.thumbnailTaskToken += 1
+      this.thumbnailRunningKeys.clear()
+      return
+    }
+    const totalSongs: number = this.dataSource.totalCount()
+    const visibleWindow = buildWebDavVisibleThumbPrefetchWindow(totalSongs, this.getDisplayedFolderCount(),
+      this.visibleListStartIndex, this.visibleListEndIndex)
+    if (!visibleWindow.hasVisibleSongs) {
+      this.stopScheduledRemoteThumbPrefetch()
+      this.thumbnailTaskToken += 1
+      this.thumbnailRunningKeys.clear()
+      return
     }
-    const isLargeList: boolean = mediaItems.length >= REMOTE_THUMB_LARGE_LIST_THRESHOLD;
-    const limitCount: number = isLargeList ?
-      Math.min(mediaItems.length, REMOTE_THUMB_LARGE_LIST_PREFETCH_COUNT) :
-      Math.min(mediaItems.length, REMOTE_THUMB_MAX_TASK_COUNT);
-    const targetItems: VideoItem[] = mediaItems.slice(0, limitCount);
-    const delayMs: number = isLargeList ? REMOTE_THUMB_LARGE_LIST_DELAY_MS : 120;
-    setTimeout((): void => {
-      void this.runRemoteThumbPrefetchQueue(targetItems, token);
-    }, delayMs);
+    const range: WebDavThumbPrefetchRange = visibleWindow.range
+    const isSameVisibleRange: boolean = range.start === this.lastVisibleThumbStart &&
+      range.endExclusive === this.lastVisibleThumbEndExclusive
+    if (isSameVisibleRange) {
+      return
+    }
+    this.lastVisibleThumbStart = range.start
+    this.lastVisibleThumbEndExclusive = range.endExclusive
+    this.stopScheduledRemoteThumbPrefetch()
+    const token: number = this.thumbnailTaskToken + 1
+    this.thumbnailTaskToken = token
+    this.thumbnailRunningKeys.clear()
+    this.scheduledThumbPrefetchTimer = setTimeout((): void => {
+      this.scheduledThumbPrefetchTimer = -1
+      if (token !== this.thumbnailTaskToken) {
+        return
+      }
+      const mediaItems: VideoItem[] = this.dataSource.dataArray.slice(range.start, range.endExclusive)
+      if (mediaItems.length <= 0) {
+        return
+      }
+      void this.runRemoteThumbPrefetchQueue(mediaItems, token)
+    }, WEBDAV_VISIBLE_THUMB_PREFETCH_DELAY_MS)
   }
 
   private async runRemoteThumbPrefetchQueue(items: VideoItem[], token: number): Promise<void> {
@@ -1115,9 +1170,9 @@ export struct WebDavMainPage {
     if (this.isSearchMode && this.searchText.length > 0) {
       this.syncSelectionAfterRefresh()
     } else {
-      this.listRefreshKey += 1
       this.syncSelectionAfterRefresh()
     }
+    this.invalidateRemoteThumbPrefetchRange()
     this.scheduleRemoteThumbPrefetch()
   }
 
@@ -2159,8 +2214,8 @@ export struct WebDavMainPage {
   aboutToDisappear(): void {
     // 取消订阅
     this.cancelPendingDirectoryRefresh();
-    this.thumbnailTaskToken += 1;
-    this.thumbnailRunningKeys.clear();
+    this.resetRemoteThumbPrefetchState();
+    this.stopDirectoryNavigationLoading();
     this.webdavManager.unsubscribe(this.eventHandler);
     this.downloadCenterManager.unsubscribe(this.downloadCenterListener);
     this.stopDownloadCenterRefresh();
@@ -2171,6 +2226,8 @@ export struct WebDavMainPage {
   private handleWebdavEvent(event: string): void {
     switch (event) {
       case RemoteDriveManagerStates.LoadFilesInfoStart:
+        this.resetRemoteThumbPrefetchState();
+        this.startDirectoryNavigationLoading();
         if (this.dataSource.totalCount() > 0 || this.visibleFoldersState.length > 0) {
           this.isLoading = false;
           this.isRefreshingCache = true;
@@ -2181,18 +2238,19 @@ export struct WebDavMainPage {
         break;
       case RemoteDriveManagerStates.LoadFilesInfoSucceed:
         const shouldScrollToTop: boolean = !this.isRefreshingCache;
+        this.resetRemoteThumbPrefetchState();
         this.songs = this.webdavManager.webDavSongs;
         // 直接引用webdavManager的数组,避免@Observed序列化问题
         this.webDavFiles = this.webdavManager.webDavFiles;
         this.currentDirectoryPath = this.webdavManager.currentPath || '/';
         this.isLoading = false;
         this.isRefreshingCache = false;
+        this.stopDirectoryNavigationLoading();
 
         // 更新可见文件夹列表
         this.updateVisibleFolders();
         this.breadcrumbs = this.webdavManager.getBreadcrumbs();
         this.syncSelectionAfterRefresh();
-        this.scheduleRemoteThumbPrefetch();
         if (this.isSearchMode && this.searchText.length > 0) {
           void this.applyGlobalSearch(this.searchText, ++this.searchTicket);
         } else {
@@ -2206,6 +2264,7 @@ export struct WebDavMainPage {
       case RemoteDriveManagerStates.LoadFilesInfoFailed:
         this.isLoading = false;
         this.isRefreshingCache = false;
+        this.stopDirectoryNavigationLoading();
         // this.getUIContext().getPromptAction().showToast({ message: '加载失败' });
         break;
       case RemoteDriveManagerStates.InsertAccountSucceed:
@@ -2243,36 +2302,42 @@ export struct WebDavMainPage {
       return;
     }
     Logger.info(TAG, 'handleWebDavMetadataUpdates start');
-    let hasChanges = false;
+    const changedIndices: number[] = [];
     for (let i = 0; i < payloads.length; i++) {
       const payload = payloads[i];
       if (!payload || !payload.filePath) {
         continue;
       }
       Logger.info(TAG, `WebDav metadata detail path=${payload.pixelMapPath ?? 'null'}`);
-      const songUpdated = this.updateSongMetadata(payload);
-      if (songUpdated) {
-        hasChanges = true;
+      const changedIndex = this.updateSongMetadata(payload);
+      if (changedIndex >= 0) {
+        if (changedIndices.indexOf(changedIndex) < 0) {
+          changedIndices.push(changedIndex);
+        }
         Logger.info(TAG, `metadata updated for ${payload.filePath}`);
       }
     }
-    if (!hasChanges) {
+    if (changedIndices.length === 0) {
       Logger.info(TAG, 'handleWebDavMetadataUpdates no changes detected');
       return;
     }
-    // 更新dataSource的数据,不滚动,只触发刷新
-    this.dataSource.pushArrayData(this.songs);
-    this.listRefreshKey++;
+    // 元数据回填只刷新变更行,避免后台补封面时整列表反复重建导致卡顿。
+    for (let index = 0; index < changedIndices.length; index += 1) {
+      const changedIndex = changedIndices[index];
+      if (changedIndex >= 0 && changedIndex < this.dataSource.totalCount()) {
+        this.dataSource.notifyDataChange(changedIndex);
+      }
+    }
     Logger.info(TAG, 'handleWebDavMetadataUpdates trigger refresh');
   }
 
-  private updateSongMetadata(payload: WebDavMetadataUpdatePayload): boolean {
+  private updateSongMetadata(payload: WebDavMetadataUpdatePayload): number {
     if (!payload.filePath) {
-      return false;
+      return -1;
     }
     const targetIndex = this.findSongIndex(payload.filePath);
     if (targetIndex < 0) {
-      return false;
+      return -1;
     }
     const targetSong = this.songs[targetIndex];
     let mutated = false;
@@ -2291,7 +2356,7 @@ export struct WebDavMainPage {
       targetSong.artist = payload.artist;
       mutated = true;
     }
-    return mutated;
+    return mutated ? targetIndex : -1;
   }
 
   private findSongIndex(filePath: string): number {
@@ -2303,103 +2368,6 @@ export struct WebDavMainPage {
     return -1;
   }
 
-  private cloneSong(item: VideoItem): VideoItem {
-    const clone = new VideoItem(
-      item.name,
-      item.id,
-      item.filePath,
-    item.type,
-    item.videoSize,
-    item.cTime,
-    item.size,
-    item.pixelMapPath,
-    item.artist,
-    item.album,
-    item.fileName,
-      item.lastPlayed
-    );
-    clone.duration = item.duration;
-    clone.mimeType = item.mimeType;
-    clone.trackCount = item.trackCount;
-    clone.sampleRate = item.sampleRate;
-    clone.size = item.size;
-    clone.webdav_account_id = item.webdav_account_id;
-    clone.remote_rel_path = item.remote_rel_path;
-    clone.artist = item.artist;
-    clone.album = item.album;
-    clone.lyricContent = item.lyricContent;
-    clone.pixelMapPath = item.pixelMapPath;
-    clone.isCustomCover = item.isCustomCover;
-    clone.cTime = item.cTime;
-    clone.fileName = item.fileName;
-    clone.md5Str = item.md5Str;
-    clone.bit_rate = item.bit_rate;
-    clone.probe_score = item.probe_score;
-    clone.year = item.year;
-    clone.nb_streams = item.nb_streams;
-    clone.nb_programs = item.nb_programs;
-    clone.genre = item.genre;
-    clone.track = item.track;
-    clone.disc = item.disc;
-    clone.channels = item.channels;
-    clone.channel_layout = item.channel_layout;
-    clone.start_time = item.start_time;
-    clone.ALBUMARTIST = item.ALBUMARTIST;
-    clone.COMPOSER = item.COMPOSER;
-    clone.COMMENT = item.COMMENT;
-    clone.LYRICIST = item.LYRICIST;
-    clone.pyStr = item.pyStr;
-    clone.extra_json = item.extra_json;
-    clone.parentPath = item.parentPath;
-    clone.isFav = item.isFav;
-    clone.playCount = item.playCount;
-    clone.lastPlayed = item.lastPlayed;
-    clone.videoSize = item.videoSize;
-    clone.isFav = item.isFav;
-    return clone;
-  }
-
-  private cloneFolderInfo(item: FileInfo): FileInfo {
-    const clone = new FileInfo(item.rootpath, item.name, item.totalSize, item.time);
-    clone.readOnly = item.readOnly;
-    clone.fileName = item.fileName;
-    clone.href = item.href;
-    clone.contentLength = item.contentLength;
-    clone.isDirectory = item.isDirectory;
-    return clone;
-  }
-
-  private applyDirectoryPreviewSnapshot(snapshot: RemoteDirectorySnapshot, targetPath: string): void {
-    const previewSongs: VideoItem[] = snapshot.songs.map((item: VideoItem) => this.cloneSong(item));
-    const previewFiles: FileInfo[] = snapshot.files.map((item: FileInfo) => this.cloneFolderInfo(item));
-    this.songs = previewSongs;
-    this.webDavFiles = previewFiles;
-    this.currentDirectoryPath = targetPath;
-    this.webdavManager.webDavSongs = previewSongs.slice();
-    this.webdavManager.webDavFiles = previewFiles.slice();
-    this.webdavManager.currentPath = targetPath;
-    this.updateVisibleFolders();
-    this.breadcrumbs = this.webdavManager.getBreadcrumbsForPreview(this.selectedAccount, targetPath);
-    if (this.isSearchMode && this.searchText.length > 0) {
-      void this.applyGlobalSearch(this.searchText, ++this.searchTicket);
-      return;
-    }
-    this.restoreCurrentDirectorySearchView();
-  }
-
-  private showDirectoryPreviewCache(targetPath?: string): boolean {
-    if (!this.selectedAccount) {
-      return false;
-    }
-    const resolvedPath = targetPath && targetPath.length > 0 ? targetPath : (this.selectedAccount.filepath || '/');
-    const preview = this.webdavManager.getDirectoryPreview(this.selectedAccount, resolvedPath);
-    if (!preview) {
-      return false;
-    }
-    this.applyDirectoryPreviewSnapshot(preview, resolvedPath);
-    return true;
-  }
-
   // 加载账户列表
   private loadAccounts(): void {
     this.accounts = this.webdavManager.getAllWebDavAccounts();
@@ -3469,54 +3437,100 @@ export struct WebDavMainPage {
     return this.topSafeHeight + 85
   }
 
+  // 目录切换时统一在当前可见的账户图标外层显示转圈,避免页面中间闪大 loading。
+  @Builder
+  private buildDirectoryNavigationIndicator(iconSize: number, containerSize: number) {
+    Stack({ alignContent: Alignment.Center }) {
+      if (this.isDirectoryNavigationLoading) {
+        // 使用环形进度外圈承载目录切换态,避免在标题栏或页面中央再出现第二套 loading。
+        Progress({ value: 0, total: 100, type: ProgressType.Ring })
+          .width(containerSize + 8)
+          .height(containerSize + 8)
+          .color(this.themeColor)
+          .style({ strokeWidth: 2.5, status: ProgressStatus.LOADING, shadow: false })
+          .scale({ x: -1, y: 1 })
+      }
+
+      Image(this.selectedAccount.coverPath ?
+        this.selectedAccount.coverPath : getCloudDiskIcon(this.selectedAccount.webType))
+        .width(iconSize)
+        .height(iconSize)
+        .alt($r('app.media.cloudDisk'))
+    }
+    .width(containerSize)
+    .height(containerSize)
+    .borderRadius(containerSize / 2)
+    .backgroundColor('#EFEFEF')
+  }
+
   @Builder
   breaker() {
     // 面包屑导航
     Column({ space: 8 }) {
-      // 面包屑导航
-      if (this.currentDirectoryPath !== '') {
+      if (!this.isSearchMode || this.webdavManager.canGoBack()) {
         Row({ space: 8 }) {
-          Button({ type: ButtonType.Circle }) {
-            Image(this.selectedAccount.coverPath?
-              this.selectedAccount.coverPath:getCloudDiskIcon(this.selectedAccount.webType))
-              .width(18)
-              .height(18)
-              .alt($r('app.media.cloudDisk'))
+          // 根目录只展示账号图标和加载态,不再承载返回动作;子目录才点击返回上级。
+          if (this.webdavManager.canGoBack()) {
+            Row() {
+              this.buildDirectoryNavigationIndicator(18, 24)
+            }
+            .width(24)
+            .height(24)
+            .margin({left:5})
+            .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.92 })
+            .onClick(() => this.goBack())
+            .bindContentCover($$this.isShowDownloadCenter, this.DownloadCenterBuilder(),
+              {
+                modalTransition:ModalTransition.DEFAULT,
+                onWillDisappear: () => {
+                  this.isShowDownloadCenter = false
+                },
+              })
+          } else {
+            Row() {
+              this.buildDirectoryNavigationIndicator(18, 24)
+            }
+            .width(24)
+            .height(24)
+            .margin({left:5})
+            .bindContentCover($$this.isShowDownloadCenter, this.DownloadCenterBuilder(),
+              {
+                modalTransition:ModalTransition.DEFAULT,
+                onWillDisappear: () => {
+                  this.isShowDownloadCenter = false
+                },
+              })
           }
-          .width(24)
-          .height(24)
-          .backgroundColor('#EFEFEF')
-          .margin({left:5})
-          .onClick(() => this.goBack())
-          .bindContentCover($$this.isShowDownloadCenter, this.DownloadCenterBuilder(),
-            {
-              modalTransition:ModalTransition.DEFAULT,
-              onWillDisappear: () => {
-                this.isShowDownloadCenter = false
-              },
-            })
 
           Row({ space: 4 }) {
-            ForEach(this.breadcrumbs, (crumb: BreadcrumbItem, index: number) => {
-              Row() {
-                Text(crumb.label)
-                  .fontSize(15)
-                  .fontColor($r('app.color.text_color'))
-                  .maxLines(1)
-                  .textOverflow({ overflow: TextOverflow.Ellipsis })
-              }
-              .onClick(() => {
-                this.navigateToBreadcrumb(crumb);
+            if (this.breadcrumbs.length === 0) {
+              Text('根目录')
+                .fontSize(15)
+                .fontColor($r('app.color.text_color'))
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.Ellipsis })
+            } else {
+              ForEach(this.breadcrumbs, (crumb: BreadcrumbItem, index: number) => {
+                Row() {
+                  Text(crumb.label)
+                    .fontSize(15)
+                    .fontColor($r('app.color.text_color'))
+                    .maxLines(1)
+                    .textOverflow({ overflow: TextOverflow.Ellipsis })
+                }
+                .onClick(() => {
+                  this.navigateToBreadcrumb(crumb);
+                })
+
+                // 添加分隔符(除了最后一个元素)
+                if (index < this.breadcrumbs.length - 1) {
+                  Text('/')
+                    .fontSize(15)
+                    .fontColor($r('app.color.index_tab_font_color'))
+                    .opacity(0.6)
+                }
               })
-
-              // 添加分隔符(除了最后一个元素)
-              if (index < this.breadcrumbs.length - 1) {
-                Text('/')
-                  .fontSize(15)
-                  .fontColor($r('app.color.index_tab_font_color'))
-                  .opacity(0.6)
-              }
-            })
+            }
           }
           .layoutWeight(1)
 
@@ -3636,9 +3650,14 @@ export struct WebDavMainPage {
                 edgeEffect: SwipeEdgeEffect.None
               })
             }
-          }, (item: VideoItem, index: number) =>  item.filePath + '_' + index+this.listRefreshKey)
+          }, (item: VideoItem, index: number) => {
+            return buildWebDavSongItemRenderKey(item.filePath, index, item.pixelMapPath)
+          })
         }
         .scrollBar(BarState.Off)
+        .onScrollIndex((start: number, end: number) => {
+          this.updateVisibleRemoteThumbRange(start, end)
+        })
         .onScrollFrameBegin((offset: number) => {
           // 获取当前滚动偏移量
           if(this.autoHideTitle){

+ 268 - 22
entry/src/main/ets/view/FindView.ets

@@ -23,6 +23,7 @@ import { SettingPage } from '../pages/SettingPage'
 import { FindAlbumDetail } from './FindAlbumDetail'
 import { PlayingIndicator } from './PlayingIndicator'
 import {
+  FindPlaylistMeta,
   getFindPlaylistId,
   getFindPlaylistName,
   getFindVideoItems,
@@ -36,10 +37,18 @@ import { getNavidromeVideoItems, setNavidromePlaylist } from '../common/util/Nav
 import {
   buildPreferredRemotePlaybackPool,
   buildSortedDiscoverySongs,
+  findPreferredRemotePlaybackAccountId,
   FindCollectionSortType,
-  resolveQueueStartIndex
+  resolveQueueStartIndex,
+  shouldWaitForIndexedRemotePlayback
 } from '../common/util/FindDiscoveryHelper'
+import { buildPlaylistDispatchFilePaths } from '../common/util/PlaylistPlayDispatchHelper'
 import { insertSongToNextPlayQueue, QueueInsertStatus } from '../common/util/PlayQueueHelper'
+import { resolveRemoteCoverForSong } from '../common/util/RemoteCoverResolver'
+import {
+  buildLocalRandomPagedQueueSeed,
+  FIND_LOCAL_RANDOM_PAGED_PLAYLIST_ID
+} from '../common/util/LocalRandomPagedQueueHelper'
 
 @Builder
 export function FindViewBuilder() {
@@ -47,9 +56,12 @@ export function FindViewBuilder() {
 }
 
 const TAG = 'FindView'
-const SWIPER_MAX_COUNT = 5
-const HOT_SECTION_COUNT = 10
+const SWIPER_MAX_COUNT = 6
+// “今日乐曲”和“云卷云舒”拆开计数,避免一个区块调数量时影响另一个区块。
+const LOCAL_RANDOM_SECTION_COUNT = 20
+const CLOUD_MOOD_SECTION_COUNT = 20
 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 +225,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 +246,7 @@ export struct FindView {
   aboutToDisappear(): void {
     AppStorage.setOrCreate('findCanBack', false)
     emitter.off(EventConstants.EVENT_FIND_VIEW_BACK)
+    this.clearPendingRemoteCoverRepair()
   }
 
   initSetting(){
@@ -361,7 +377,7 @@ export struct FindView {
       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.hotSongs = this.pickPreferredSongs(this.localSongsPool, LOCAL_RANDOM_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))
@@ -372,7 +388,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(
@@ -435,9 +454,12 @@ export struct FindView {
   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.cloudMoodSongs = this.pickPreferredSongs(this.remoteSongsPool, CLOUD_MOOD_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 +468,135 @@ 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 +612,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 +628,34 @@ 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 +669,16 @@ export struct FindView {
     )
     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))
@@ -555,6 +736,7 @@ export struct FindView {
         return
       }
       this.applyRemoteDiscoverySongs(refreshedRemoteSongs, true)
+      this.scheduleVisibleRemoteCoverRepair()
       Logger.info(
         TAG,
         `发现页远程预热完成: saved=${savedCount}, remote=${refreshedRemoteSongs.length}, ` +
@@ -971,27 +1153,30 @@ export struct FindView {
   }
 
   private emitPlaylistPlay(playlistId: string, playlistName: string, songs: VideoItem[], startIndex: number,
-    playType?: number): void {
+    playType?: number, meta?: FindPlaylistMeta): void {
     if (songs.length === 0) {
       ToastUtil.showToast('暂无可播放歌曲')
       return
     }
     const safeIndex = Math.min(Math.max(startIndex, 0), songs.length - 1)
+    const dispatchCount = meta?.totalCount && meta.totalCount > 0 ? meta.totalCount : songs.length
     Logger.info(
       TAG,
       `[remote-debug] emitPlaylistPlay id=${playlistId}, name=${playlistName}, count=${songs.length}, ` +
-      `startIndex=${safeIndex}, playType=${playType ?? -1}, sample=${this.buildSongDebugLog(songs)}`
+      `dispatchCount=${dispatchCount}, startIndex=${safeIndex}, playType=${playType ?? -1}, ` +
+      `sample=${this.buildSongDebugLog(songs)}`
     )
     if (playlistId === 'find-album-playlist' || playlistId.indexOf('find-') === 0) {
-      setFindPlaylist(playlistId, playlistName, songs, safeIndex)
+      // 发现页队列统一走内存仓库,避免事件负载里重复塞大数组。
+      setFindPlaylist(playlistId, playlistName, songs, safeIndex, meta)
     }
     const eventPlaylistPlay: emitter.InnerEvent = { eventId: EventConstants.EVENT_PLAYLIST_PLAY }
     const playlistData = new PlaylistPlayRequest(
       playlistId,
       playlistName,
-      songs.length,
+      dispatchCount,
       safeIndex,
-      songs.map((item: VideoItem): string => item.filePath),
+      buildPlaylistDispatchFilePaths(playlistId, songs),
       false,
       playType
     )
@@ -1137,8 +1322,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())
@@ -1226,8 +1412,24 @@ export struct FindView {
         ToastUtil.showToast('本地歌曲为空')
         return
       }
-      const startIndex = Math.floor(Math.random() * allLocalSongs.length)
-      this.emitPlaylistPlay('find-local-random-all', '随心所欲', allLocalSongs, startIndex, 3)
+      const randomIndex = Math.floor(Math.random() * allLocalSongs.length)
+      // “随心所欲”仍然按全库随机,但首次只下发命中歌曲所在页,后续由播放器继续分页补齐。
+      const queueSeed = buildLocalRandomPagedQueueSeed(allLocalSongs, randomIndex)
+      this.emitPlaylistPlay(
+        FIND_LOCAL_RANDOM_PAGED_PLAYLIST_ID,
+        '随心所欲',
+        queueSeed.items,
+        queueSeed.startIndex,
+        3,
+        {
+          // queryAllVideos 默认按名称升序,这里把分页查询所需的顺序信息一并带给播放器。
+          isPagedLocalQueue: true,
+          pageIndex: queueSeed.pageIndex,
+          pageSize: queueSeed.pageSize,
+          totalCount: queueSeed.totalCount,
+          sortType: 4
+        }
+      )
       return
     }
     const allRemoteSongs = await this.getFullRemoteSongsPool()
@@ -1274,11 +1476,12 @@ export struct FindView {
   }
 
   private refreshLocalRandomSongs(): void {
-    this.hotSongs = this.pickDifferentPreferredSongs(this.localSongsPool, HOT_SECTION_COUNT, this.hotSongs)
+    this.hotSongs = this.pickDifferentPreferredSongs(this.localSongsPool, LOCAL_RANDOM_SECTION_COUNT, this.hotSongs)
   }
 
   private refreshCloudMoodSongs(): void {
-    this.cloudMoodSongs = this.pickDifferentPreferredSongs(this.remoteSongsPool, HOT_SECTION_COUNT, this.cloudMoodSongs)
+    this.cloudMoodSongs = this.pickDifferentPreferredSongs(this.remoteSongsPool, CLOUD_MOOD_SECTION_COUNT,
+      this.cloudMoodSongs)
   }
 
   private refreshCloudSongs(): void {
@@ -1769,7 +1972,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 +2106,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 +2121,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 +2948,7 @@ export struct FindView {
           .width('100%')
           .aspectRatio(3 / 4)
           .borderRadius(16)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {
@@ -2797,6 +3040,7 @@ export struct FindView {
           .width('100%')
           .aspectRatio(3 / 4)
           .borderRadius(18)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {
@@ -2941,6 +3185,7 @@ export struct FindView {
           .aspectRatio(3 / 4)
           .borderRadius(16)
           .width(160)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {
@@ -3021,6 +3266,7 @@ export struct FindView {
           .aspectRatio(3 / 4)
           .borderRadius(16)
           .width(160)
+          .alt($r('app.media.alt'))
           .objectFit(ImageFit.Cover)
 
         Row() {

+ 274 - 144
entry/src/main/ets/view/LocalMusic.ets

@@ -14,8 +14,12 @@ import {
   getNavidromeTotalCount,
   setNavidromeTotalCount
 } from '../common/util/NavidromePlaylistStore';
-import { getFindVideoItems, getFindCurrentPlayIndex } from '../common/util/FindPlaylistStore';
+import { FindPlaylistMeta, getFindVideoItems, getFindCurrentPlayIndex, getFindPlaylistMeta } from '../common/util/FindPlaylistStore';
 import { triggerLoadMoreSongs, hasMoreData } from '../common/util/NavidromeRandomLoader';
+import {
+  DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE,
+  isFindLocalRandomPagedPlaylist
+} from '../common/util/LocalRandomPagedQueueHelper';
 import {
   ImplOnBufferingUpdateListener,
   ImplOnCompletionListener,
@@ -51,6 +55,7 @@ import { CommonConstants } from '../common/constants/CommonConstants';
 import { BaiduConstants } from '../common/constants/BaiduConstants';
 import { EventConstants } from '../common/constants/EventConstants';
 import Logger from '../common/util/Logger';
+import { filterPlaylistSongsByKeyword, findSongIndexByFilePath } from '../common/util/PlaylistSearchHelper';
 import { http } from '@kit.NetworkKit';
 import { photoAccessHelper } from '@kit.MediaLibraryKit';
 import { BusinessError, emitter } from '@kit.BasicServicesKit';
@@ -140,9 +145,9 @@ import {
 import {
   PlaylistPlayRequest,
   consumePendingPlaylistPlay,
-  clearPendingPlaylistPlay,
-  setPlaylistPlayConsumerReady
+  clearPendingPlaylistPlay
 } from '../common/util/PlaylistPlayRequestStore';
+import { resolvePlaylistDisplayCount } from '../common/util/PlaylistPlayDispatchHelper'
 
 import { HSBColorPicker, HSBColorPickerLayout } from '@keke/color-picker'
 import app from '@system.app';
@@ -471,6 +476,7 @@ export struct LocalMusic {
   @State maxRefreshingHeight: number = 100.0;
   @State isPlayerLoading: boolean = false; // 播放器加载状态
   private contentNode?: ComponentContent<Object> = undefined;
+  private skipNextPlaylistPersist: boolean = false
 
   // WebDAV相关实例变量(现在改为从内存读取,不再需要存储)
   @State ratioL: number = 1;
@@ -617,8 +623,16 @@ export struct LocalMusic {
   private coverThumbCache: CoverThumbCache = new CoverThumbCache(1, 960)
   private coverThumbCacheReady: boolean = false
   private pendingCoverThumbRefresh: boolean = false
-  private playQueueScope: 'view' | 'history' | 'fav' | 'playlist' | 'single' = 'view'
+  private playQueueScope: 'view' | 'history' | 'fav' | 'playlist' | 'single' | 'find_paged_local' = 'view'
   private isHydratingPlayList: boolean = false
+  private preserveExistingQueueOnNextPlay: boolean = false
+  private nextPlayQueueScope: 'view' | 'history' | 'fav' | 'playlist' | 'single' | 'find_paged_local' = 'view'
+  private findPagedLocalQueuePageIndex: number = 0
+  private findPagedLocalQueuePageSize: number = DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE
+  private findPagedLocalQueueTotalCount: number = 0
+  private findPagedLocalQueueHasMore: boolean = false
+  private findPagedLocalQueueSortType: number = 4
+  private isLoadingFindPagedLocalQueue: boolean = false
   @StorageProp('isLandscape') @Watch('onIsLandscapeChange')  isLandscape: boolean = false;
   @StorageProp('topSafeHeight') topSafeHeight: number = 0;
   @StorageProp('bottomSafeHeight') bottomSafeHeight: number = 0;
@@ -1098,7 +1112,6 @@ export struct LocalMusic {
 
       Logger.error('heanup eventPlaylistPlay: 接收到无效的数据结构')
     });
-    setPlaylistPlayConsumerReady(true);
     Promise.resolve().then(async (): Promise<void> => {
       const pendingPlaylistRequest = consumePendingPlaylistPlay();
       if (!pendingPlaylistRequest) {
@@ -1307,7 +1320,7 @@ export struct LocalMusic {
         ToastUtil.showToast('当前播放列表为空,请先导入音乐。');
         return
       }
-      this.setShowPlayTrue(true)
+      this.setShowPlayTrue()
       this.showPlayerView();
     });
     this.getUIContext().getHostContext()!.eventHub.on('dismissPlayerView', () => {
@@ -2288,7 +2301,6 @@ export struct LocalMusic {
     this.knockController?.immersiveDisableListening();
     this.curIndex = 0
     this.isAppForeground = false;
-    setPlaylistPlayConsumerReady(false);
     emitter.off(EventConstants.EVENT_AUDIO_OPEN);
     emitter.off(EventConstants.EVENT_SCAN_UPDATE);
     emitter.off(EventConstants.EVENT_SWIPE_BACK_UPDATE);
@@ -2753,6 +2765,12 @@ export struct LocalMusic {
     if (this.playQueueScope === 'fav') {
       return this.favList.length;
     }
+    if (this.playQueueScope === 'find_paged_local') {
+      const pagedCount = this.findPagedLocalQueueTotalCount > 0 ? this.findPagedLocalQueueTotalCount : this.totalCount;
+      if (pagedCount > 0) {
+        return pagedCount;
+      }
+    }
     if (this.playQueueScope === 'playlist') {
       // 检测是否是流媒体播放(Navidrome/Jellyfin/Emby/AudioStation/Plex/道理鱼等)
       // 如果是,优先返回服务端总数
@@ -2769,8 +2787,10 @@ export struct LocalMusic {
         Logger.info('heanup getPlayListDisplayCount', `返回服务端总数: ${navidromeTotalCount}`);
         return navidromeTotalCount;
       }
-      Logger.info('heanup getPlayListDisplayCount', `返回 currentSongList.length: ${this.currentSongList.length}`);
-      return this.currentSongList.length;
+      const displayCount = resolvePlaylistDisplayCount(this.currentSongList.length, this.songList.length);
+      Logger.info('heanup getPlayListDisplayCount',
+        `返回歌单数量: currentSongList=${this.currentSongList.length}, songList=${this.songList.length}, display=${displayCount}`);
+      return displayCount;
     }
     if (this.playQueueScope === 'view' && this.modeType === 1 && this.totalCount > 0) {
       return this.totalCount;
@@ -2781,7 +2801,8 @@ export struct LocalMusic {
 
   private restoreLastPlayContext(): void {
     const scope = PreferencesUtil.getStringSync('LastPlayQueueScope', 'view') as string;
-    if (scope === 'history' || scope === 'fav' || scope === 'playlist' || scope === 'single') {
+    if (scope === 'history' || scope === 'fav' || scope === 'playlist' || scope === 'single' ||
+      scope === 'find_paged_local') {
       this.playQueueScope = scope;
     } else {
       this.playQueueScope = 'view';
@@ -2791,6 +2812,49 @@ export struct LocalMusic {
     if (lastModeType === 1 && this.totalCount === 0 && lastTotalCount > 0) {
       this.totalCount = lastTotalCount;
     }
+    if (this.playQueueScope === 'find_paged_local' && lastTotalCount > 0) {
+      this.findPagedLocalQueueTotalCount = lastTotalCount;
+      this.findPagedLocalQueueHasMore = this.songList.length < lastTotalCount;
+    }
+  }
+
+  private resetFindPagedLocalQueueState(): void {
+    // 退出“随心所欲”分页队列时,避免旧分页状态污染后续普通播放队列。
+    this.findPagedLocalQueuePageIndex = 0;
+    this.findPagedLocalQueuePageSize = DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE;
+    this.findPagedLocalQueueTotalCount = 0;
+    this.findPagedLocalQueueHasMore = false;
+    this.findPagedLocalQueueSortType = 4;
+    this.isLoadingFindPagedLocalQueue = false;
+  }
+
+  private applyFindPagedLocalQueueState(meta?: FindPlaylistMeta, totalCount?: number): void {
+    // 播放器只持有当前页数据,但总数、页码和排序要单独记住,后面才能继续按页补队列。
+    this.findPagedLocalQueuePageIndex = meta?.pageIndex ?? 0;
+    this.findPagedLocalQueuePageSize = meta?.pageSize && meta.pageSize > 0
+      ? Math.max(1, Math.floor(meta.pageSize))
+      : DEFAULT_LOCAL_RANDOM_QUEUE_PAGE_SIZE;
+    this.findPagedLocalQueueSortType = meta?.sortType !== undefined ? meta.sortType : 4;
+    const actualTotalCount = meta?.totalCount && meta.totalCount > 0
+      ? meta.totalCount
+      : (totalCount ?? 0);
+    this.findPagedLocalQueueTotalCount = actualTotalCount;
+    this.findPagedLocalQueueHasMore =
+      (this.findPagedLocalQueuePageIndex + 1) * this.findPagedLocalQueuePageSize < actualTotalCount;
+    if (actualTotalCount > 0) {
+      this.totalCount = actualTotalCount;
+    }
+  }
+
+  private isFindPagedLocalQueueActive(): boolean {
+    return this.playQueueScope === 'find_paged_local';
+  }
+
+  private shouldHydrateFullMediaPlayListOnOpen(): boolean {
+    // 特殊分页队列禁止打开播放列表时自动补全全量歌曲,否则会回到 3000+ 首卡顿问题。
+    return !!this.currentSong &&
+      this.currentSong.type === CommonConstants.TYPE_LOCAL &&
+      !this.isFindPagedLocalQueueActive();
   }
 
   private async hydrateFullMediaPlayListIfNeeded(isSonList:boolean = true): Promise<void> {
@@ -3811,9 +3875,7 @@ export struct LocalMusic {
 
   build() {
     Stack() {
-      if (this.mType === 0) {
-        this.ContentBuild()
-      }
+      this.ContentBuild()
       Stack() {
         Column()
           .bindSheet($$this.isShowSheet, this.PlayListSheet(), {
@@ -3829,16 +3891,12 @@ export struct LocalMusic {
             }
           })
       }
-      if (this.isShowPlay) {
-        Stack() {
-          this.MusicPlayBuilder()
-        }
-        .width('100%')
-        .height('100%')
-        .zIndex(99)
-      }
-
-
+      .bindContentCover($$this.isShowPlay, this.MusicPlayBuilder(), {
+        modalTransition: ModalTransition.DEFAULT,
+        onWillDisappear: () => {
+          this.setShowPlayFalse()
+        },
+      })
     }
     .alignContent(Alignment.Bottom)
     .width('100%')
@@ -5913,22 +5971,11 @@ export struct LocalMusic {
   private DirItem(item: VideoItem, index?: number) {
     Button({ type: ButtonType.Capsule, stateEffect: true }) {
       Row() {
-        Image(this.modeType !== 0 && StrUtil.isNotEmpty(item.pixelMapPath) ?
-          this.getListImageSource(item, index ?? -1, this.coverThumbVersion) :
-          $r('app.media.music_group'))
-          .height(this.twoFingerType == 3 ? 55 : this.twoFingerType == 2 ? 50 : 40)
-          .width(this.twoFingerType == 3 ? 55 : this.twoFingerType == 2 ? 50 : 40)
-          .fillColor(this.themeColor)// .fontSize(33)
-          .alt($r('app.media.music_group'))
-          .borderRadius(this.isCoverRectangle?9:'100%')
-          .clip(true)
-          .shadow({
-            radius: 2,
-            type: ShadowType.BLUR,
-            color: 'on_primary'
-          })
-          .sourceSize({width:38, height:38})
-          .margin({ left: 20, top: 8, bottom: 8 })
+        SymbolGlyph($r('sys.symbol.folder'))
+          .fontSize(this.twoFingerType==3?55:this.twoFingerType==2?50:45)
+          .fontColor([this.themeColor])
+          .alignSelf(ItemAlign.Center)
+          .margin({ left: 20 ,top:8,bottom:8})
         Column() {
           Text(item.name.startsWith('.') ? item.name.replace(/\./g, '') : item.name)
             .fontSize(this.twoFingerType == 1 ? 12 : this.twoFingerType == 2 ? 15 : 18)
@@ -6329,6 +6376,27 @@ export struct LocalMusic {
 
     Logger.info('heanup LocalMusic', `onSearchInput: keyword="${this.searchText}", mode=${this.modeType}`);
 
+    // 歌单模式不走媒体库分页查询,直接在当前歌单中筛选,避免混入非歌单歌曲并打乱顺序。
+    if (this.modeType === 4) {
+      if (this.searchText === '') {
+        this.loadSearchHistory()
+        this.filteredList = []
+        this.updateListData(this.currentSongList, true, true)
+        Logger.info('heanup LocalMusic', `onSearchInput: restore playlist list, count=${this.currentSongList.length}`)
+        return
+      }
+
+      if (!this.isSearchMode) {
+        this.isSearchMode = true
+      }
+
+      const filteredSongs = filterPlaylistSongsByKeyword(this.currentSongList, this.searchText)
+      this.filteredList = filteredSongs
+      this.updateListData(filteredSongs, true, true)
+      Logger.info('heanup LocalMusic', `onSearchInput: playlist filter applied, keyword="${this.searchText}", count=${filteredSongs.length}`)
+      return
+    }
+
     // 清空搜索文本时保持搜索态,只重置为不带关键词的结果列表
     if (this.searchText === '') {
       this.loadSearchHistory()
@@ -8108,8 +8176,13 @@ export struct LocalMusic {
           console.info('kkMusic doPlay stop =' )
           this.stop();
         }
+        const useExistingQueueForPlayback = this.preserveExistingQueueOnNextPlay;
+        const nextQueueScope = this.nextPlayQueueScope;
+        this.preserveExistingQueueOnNextPlay = false;
+        this.nextPlayQueueScope = 'view';
 
         if (isOpen) {
+          this.resetFindPagedLocalQueueState();
           this.currentSong = item
           if (index !== undefined) {
             this.curIndex = index
@@ -8123,17 +8196,32 @@ export struct LocalMusic {
           if (index !== undefined) {
             this.curIndex = index
           }
-          // 确保播放列表是完整的歌单歌曲列表(已在finishLoadingPlaylist中设置)
           Logger.info(`heanup isFromSonPlayList分支 - 当前播放列表长度: ${this.songList.length}, 当前索引: ${this.curIndex}`)
-          this.playQueueScope = 'playlist'
-          // 确保歌单路径列表已设置(从songList重新构建,以防被清空)
-          if (this.currentPlaylistSongFilePaths.length === 0 && this.songList.length > 0) {
-            this.currentPlaylistSongFilePaths = this.songList.map(song => song.filePath);
-            Logger.info(`heanup isFromSonPlayList - 重新构建歌单路径列表,数量=${this.currentPlaylistSongFilePaths.length}`);
+          if (this.isFindPagedLocalQueueActive()) {
+            // 这类队列来自发现页分页随机,点击播放列表里的歌也要继续保持“分页队列”身份。
+            this.playQueueScope = 'find_paged_local'
+            this.currentPlaylistSongFilePaths = [];
+          } else {
+            this.resetFindPagedLocalQueueState();
+            this.playQueueScope = 'playlist'
+            if (this.currentPlaylistSongFilePaths.length === 0 && this.songList.length > 0) {
+              this.currentPlaylistSongFilePaths = this.songList.map(song => song.filePath);
+              Logger.info(`heanup isFromSonPlayList - 重新构建歌单路径列表,数量=${this.currentPlaylistSongFilePaths.length}`);
+            }
+          }
+        }else if (useExistingQueueForPlayback) {
+          // 特殊分页队列初始化时,直接复用 finishLoadingPlaylist 准备好的当前页,不再按视图重建全量队列。
+          this.currentSong = item
+          if (index !== undefined) {
+            this.curIndex = index
           }
+          this.playQueueScope = nextQueueScope
+          this.currentPlaylistSongFilePaths = [];
+          this.sonDataSource.pushArrayData(this.songList)
         }else {
+          this.resetFindPagedLocalQueueState();
           const globalVideoList = this.buildPlayQueueFromView();
-          this.curIndex = Utility.getCurIndexFromGlobalList(globalVideoList, item.filePath)
+          this.curIndex = findSongIndexByFilePath(globalVideoList, item.filePath)
           if (this.curIndex < 0) {
             globalVideoList.push(item);
             this.curIndex = globalVideoList.length - 1;
@@ -8189,6 +8277,9 @@ export struct LocalMusic {
         if (this.CONTROL_PlayStatus !== PlayStatus.INIT) {
           this.stop();
         }
+        this.resetFindPagedLocalQueueState();
+        this.preserveExistingQueueOnNextPlay = false;
+        this.nextPlayQueueScope = 'view';
 
         if (isOpen) {
           this.currentSong = item
@@ -9119,54 +9210,24 @@ export struct LocalMusic {
     })
   }
 
-  setShowPlayTrue(fromMiniBar: boolean = false){
-    this.clearPlayDismissTimer()
-    this.clearPlayOpenTimer()
-    if (this.isShowPlay) {
-      return
-    }
-    if (fromMiniBar) {
-      this.preparePlayerViewOpenFromMiniBar()
-    } else {
-      this.resetMusicPlayDismissState()
-    }
-    this.isShowPlay = true;
-    this.showCoverScaleGuideIfNeeded()
-  }
-
-  private closePlayerViewWithMiniBarAnimation(): void {
-    if (!this.isShowPlay || this.playDismissOpacity < 1) {
-      return
-    }
-    const target = this.resolveMiniBarDismissTarget()
-
-    this.clearPlayDismissTimer()
-    this.clearPlayOpenTimer()
+  setShowPlayTrue(){
     this.getUIContext()?.animateTo({
-      duration: this.playDismissContentDuration + 40,
-      curve: Curve.Sharp
+      duration: 500,
+      curve: Curve.Friction
     }, () => {
-      this.translateY = target.translateY
-      this.playDismissTranslateX = 0
-      this.playDismissScale = 0.9
-      this.playDismissScaleY = 0.8
-      this.playDismissOpacity = 0.04
-      this.playDismissBorderRadius = 48
-      this.playDismissProxySize = 0
-      this.playDismissProxyOpacity = 0
-      this.playDragUiOpacity = 0
-      this.scaleValueImage = 0.8
-      this.scaleValueText = 0.86
-    })
-    this.playDismissTimer = setTimeout(() => {
-      this.playDismissTimer = -1
-      this.isShowPlay = false
-      this.isShowCoverScaleGuide = false
-    }, this.playDismissContentDuration + 56)
+      this.isShowPlay = true;
+      this.showCoverScaleGuideIfNeeded()
+    });
   }
 
   setShowPlayFalse(){
-    this.closePlayerViewWithMiniBarAnimation()
+    this.getUIContext()?.animateTo({
+      duration: 500,
+      curve: Curve.Friction
+    }, () => {
+      this.isShowPlay = false;
+    });
+    this.isShowCoverScaleGuide = false
   }
   //打开播放页
   showPlayerView() {
@@ -9190,8 +9251,7 @@ export struct LocalMusic {
       LogUtil.info('onecold scrollToIndex = ' + this.curIndex)
 
       this.scrollIndex()
-      // 只有在播放本地音乐时才自动加载完整列表
-      if (this.currentSong && this.currentSong.type === CommonConstants.TYPE_LOCAL) {
+      if (this.shouldHydrateFullMediaPlayListOnOpen()) {
         void this.hydrateFullMediaPlayListIfNeeded();
       }
 
@@ -9782,6 +9842,11 @@ export struct LocalMusic {
   }
 
   private async tryLoadNextPageForPlayback(): Promise<void> {
+    if (this.playQueueScope === 'find_paged_local') {
+      // 发现页的本地随机分页队列单独补页,不去动主媒体库列表状态。
+      await this.loadMoreFindPagedLocalSongs();
+      return;
+    }
     if (this.playQueueScope !== 'view') {
       return;
     }
@@ -9799,6 +9864,49 @@ export struct LocalMusic {
     }
   }
 
+  private async loadMoreFindPagedLocalSongs(): Promise<void> {
+    if (!this.findPagedLocalQueueHasMore || this.isLoadingFindPagedLocalQueue) {
+      return;
+    }
+    this.isLoadingFindPagedLocalQueue = true;
+    const nextPage = this.findPagedLocalQueuePageIndex + 1;
+    Logger.info(TAG,
+      `loadMoreFindPagedLocalSongs: page=${nextPage}, size=${this.findPagedLocalQueuePageSize}, ` +
+      `loaded=${this.songList.length}, total=${this.findPagedLocalQueueTotalCount}`);
+    try {
+      // 继续按发现页建立队列时的排序规则查询下一页,保证后续页和初始页顺序一致。
+      const result = await this.table.queryMediaLibraryPaged({
+        pageIndex: nextPage,
+        pageSize: this.findPagedLocalQueuePageSize,
+        sortType: this.findPagedLocalQueueSortType,
+        isShowFileName: this.isShowFileName
+      });
+      this.findPagedLocalQueuePageIndex = nextPage;
+      this.findPagedLocalQueueHasMore = result.hasMore;
+      if (result.totalCount > 0) {
+        this.findPagedLocalQueueTotalCount = result.totalCount;
+        this.totalCount = result.totalCount;
+      }
+      if (!result.items || result.items.length === 0) {
+        return;
+      }
+      const existingPaths = new Set(this.songList.map((item: VideoItem) => item.filePath));
+      // 随机播放过程中可能已经把“未加载页里的歌曲”临时插进队列,这里要去重后再追加。
+      const newItems = result.items.filter((item: VideoItem) => !existingPaths.has(item.filePath));
+      if (newItems.length === 0) {
+        return;
+      }
+      this.songList.push(...newItems);
+      this.currentSongList = this.songList;
+      this.sonDataSource.appendArrayData(newItems);
+      PreferencesUtil.putSync('LastMusicList', this.songList);
+    } catch (error) {
+      Logger.error(TAG, `loadMoreFindPagedLocalSongs failed: ${(error as Error).message}`);
+    } finally {
+      this.isLoadingFindPagedLocalQueue = false;
+    }
+  }
+
   /**
    * 加载更多流媒体歌曲(Navidrome/Jellyfin/Emby/AudioStation/Plex)
    * 当播放列表弹窗滚动到底部时调用
@@ -12167,7 +12275,7 @@ export struct LocalMusic {
     .width('100%')
     .height('100%')
     .onReachEnd(() => {
-      if(this.modeType === 4 )
+      if(this.modeType === 4 && !this.isFindPagedLocalQueueActive())
         return;
       // 检测是否是流媒体类型(Navidrome/Jellyfin/Emby/AudioStation/Plex),如果是则调用网盘加载更多逻辑
       const isStreamingSong = this.currentSong && (
@@ -12353,47 +12461,35 @@ export struct LocalMusic {
 
   @Builder
   MusicPlayBuilder() {
-    Stack({ alignContent: Alignment.Center }) {
-      Column() {
-        this.IjkMusicPlayerView()
-      }
-      .visualEffect(deviceInfo.sdkApiVersion>=20&&this.bgController&&this.playDismissOpacity>=1
-        && this.playDismissProxySize<=0 && this.playDismissBorderRadius<=0 ?
-        new hdsEffect.HdsEffectBuilder()
-          .shaderEffect({
-            effectType: hdsEffect.EffectType.UV_BACKGROUND_FLOW_LIGHT,
-            animation: {
-              duration: 10000,
-              iterations: -1,
-              autoPlay: true,
-              onFinish: ()=> {
-                console.info('Succeeded in finishing');
-              }
-            },
-            controller: this.bgController,
-          })
-          .buildEffect():null)
-
-      .height('100%')
-      .width('100%')
-      .borderRadius(this.playDismissBorderRadius)
-      .clip(true)
-      .translate({ x: this.playDismissTranslateX, y: this.translateY })
-      .scale({ x: this.playDismissScale, y: this.playDismissScaleY })
-      .opacity(this.playDismissOpacity)
-
-      if (this.playDismissProxySize > 0) {
-        this.MusicPlayDismissProxyDot()
-      }
-    }
+    Column() {
+      this.IjkMusicPlayerView()
+    }
+    .transition(TransitionEffect.asymmetric(
+      TransitionEffect.opacity(1),
+      TransitionEffect.OPACITY
+    ))
+    .visualEffect(deviceInfo.sdkApiVersion>=20&&this.bgController?
+      new hdsEffect.HdsEffectBuilder()
+        .shaderEffect({
+          effectType: hdsEffect.EffectType.UV_BACKGROUND_FLOW_LIGHT,
+          animation: {
+            duration: 10000,
+            iterations: -1,
+            autoPlay: true,
+            onFinish: ()=> {
+              console.info('Succeeded in finishing');
+            }
+          },
+          controller: this.bgController,
+        })
+        .buildEffect():null)
     .height('100%')
     .width('100%')
-    .onAppear(() => {
-      this.startPlayerViewOpenAnimationIfNeeded()
-    })
     .onDisAppear(() => {
-      this.resetMusicPlayDismissState()
+      this.translateY = 0;
+      this.playDragUiOpacity = 1
     })
+    .translate({ y: this.translateY })
     .gesture(
       PanGesture(this.panOption)
         .onActionUpdate((event?: GestureEvent) => {
@@ -12420,7 +12516,7 @@ export struct LocalMusic {
                 duration: 500,
                 curve: Curve.Sharp
               }, () => {
-                this.scaleValueImage = Math.min(1, Math.max(0.46, 1 - this.translateY / 900));
+                this.scaleValueImage = Math.min(1, Math.max(0.5, 1 - this.translateY / 880));
                 console.info('onecold scaleValueImage:', this.scaleValueImage)
                 this.scaleValueText = Math.min(1, Math.max(0.88, 1 - this.translateY / 2000));
                 this.playDragUiOpacity = Math.min(1, Math.max(0, 1 - this.translateY / 300));
@@ -12457,7 +12553,17 @@ export struct LocalMusic {
             const shouldClose = this.translateY > minDistance || velocity > minVelocity;
 
             if (shouldClose) {
-              this.closePlayerViewWithMiniBarAnimation()
+              // 添加关闭动画
+              this.getUIContext().animateTo({
+                duration: 600,
+                curve: Curve.Friction
+              }, () => {
+                this.isShowPlay = false;
+                this.scaleValueImage = 1
+                this.scaleValueText = 1
+                this.playDragUiOpacity = 1
+                this.translateY = 0;
+              })
             } else {
               // 回弹动画
               this.getUIContext().animateTo({
@@ -14145,8 +14251,7 @@ export struct LocalMusic {
           this.isFrontWhite = false
         }
         this.scrollIndex()
-        // 只有在播放本地音乐时才自动加载完整列表
-        if (this.currentSong && this.currentSong.type === CommonConstants.TYPE_LOCAL) {
+        if (this.shouldHydrateFullMediaPlayListOnOpen()) {
           void this.hydrateFullMediaPlayListIfNeeded();
         }
       })
@@ -14646,7 +14751,7 @@ export struct LocalMusic {
     .width(this.isLandscape?'83%':'90%')
     .scale({ x: this.scaleValueText, y: this.scaleValueText }) // 添加缩放效果
     .opacity(this.playDragUiOpacity)
-    .margin({ top:this.isCoverRectangle?10: 0 })
+    .margin({ top:this.isCoverRectangle?15: 0 })
     .visibility(this.isCoverOpacity() || isHidden ? Visibility.None : Visibility.Visible)
   }
 
@@ -18416,7 +18521,12 @@ export struct LocalMusic {
   //保存最后播放的那首歌和已经对应的播放列表
   saveLastPlayList() {
     PreferencesUtil.putSync('LastMusicInfo', this.currentSong)
-    PreferencesUtil.putSync('LastMusicList', this.songList)
+    // 新队列刚刚完成落盘时,首帧 prepared 会立刻再进一次这里,跳过这次重复写入即可。
+    if (this.skipNextPlaylistPersist) {
+      this.skipNextPlaylistPersist = false
+    } else {
+      PreferencesUtil.putSync('LastMusicList', this.songList)
+    }
     PreferencesUtil.putSync('LastPlayQueueScope', this.playQueueScope)
     PreferencesUtil.putSync('LastPlayModeType', this.modeType)
     PreferencesUtil.putSync('LastPlayTotalCount', this.totalCount)
@@ -20731,6 +20841,7 @@ export struct LocalMusic {
       this.currentPlaylistSongFilePaths = this.songList.map(song => song.filePath);
       this.curIndex = targetIndex;
       PreferencesUtil.putSync('LastMusicList', this.songList);
+      this.skipNextPlaylistPersist = true
       Logger.info(TAG, `WebDAV播放队列已刷新,当前索引=${this.curIndex}, 队列长度=${this.songList.length}`);
     } catch (error) {
       Logger.error(TAG, `刷新WebDAV播放队列失败: ${(error as Error).message}`);
@@ -20788,6 +20899,7 @@ export struct LocalMusic {
         Logger.info(`heanup 检测到发现页播放请求,从内存读取videoItems`)
         const videoItems = getFindVideoItems();
         const currentPlayIndex = getFindCurrentPlayIndex();
+        const findPlaylistMeta = getFindPlaylistMeta();
         Logger.info(`heanup 发现页歌曲列表长度: ${videoItems.length}, 索引: ${currentPlayIndex}`)
         Logger.info(
           TAG,
@@ -20798,7 +20910,11 @@ export struct LocalMusic {
           if (isJump) {
             this.setShowPlayTrue()
           }
-          this.finishLoadingPlaylist(videoItems, currentPlayIndex, playlistName, totalCount)
+          // 只有“随心所欲分页随机”才读取分页元数据,其他发现页队列仍按原有歌单模式处理。
+          const findMeta = isFindLocalRandomPagedPlaylist(playlistId) && findPlaylistMeta.isPagedLocalQueue
+            ? findPlaylistMeta
+            : undefined;
+          this.finishLoadingPlaylist(videoItems, currentPlayIndex, playlistName, totalCount, findMeta)
           return
         } else {
           Logger.warn('heanup 发现页播放请求缺少歌曲数据')
@@ -20880,27 +20996,33 @@ export struct LocalMusic {
   /**
    * 完成歌单加载并开始播放
    */
-  private finishLoadingPlaylist(songs: VideoItem[], startIndex: number, playlistName: string, totalCount?: number) {
+  private finishLoadingPlaylist(songs: VideoItem[], startIndex: number, playlistName: string, totalCount?: number,
+    findMeta?: FindPlaylistMeta) {
     if (songs.length === 0) {
       Logger.error('heanup 没有找到任何可播放的歌曲')
       ToastUtil.showToast('没有找到可播放的歌曲')
       return
     }
+    const isFindPagedLocalQueue = !!findMeta?.isPagedLocalQueue;
     this.songList = songs
     this.currentSongList = songs  // 同步更新 currentSongList
 
     this.sonDataSource.pushArrayData(songs)
 
-    this.sonDataSource.notifyDataReload()
-
     this.curIndex = startIndex
 
-    // 保存歌单的歌曲路径列表,用于随机播放
-    this.currentPlaylistSongFilePaths = songs.map(song => song.filePath);
     const actualTotal = totalCount ?? songs.length;
+    if (isFindPagedLocalQueue) {
+      // 这类队列只保存当前页歌曲,总数和分页游标放到独立状态里继续滚动加载。
+      this.applyFindPagedLocalQueueState(findMeta, actualTotal);
+      this.currentPlaylistSongFilePaths = [];
+    } else {
+      this.resetFindPagedLocalQueueState();
+      this.currentPlaylistSongFilePaths = songs.map(song => song.filePath);
+    }
 
     // 如果是网盘播放,保存服务端总数到全局存储
-    if (totalCount && totalCount > 0 && songs.length > 0) {
+    if (!isFindPagedLocalQueue && totalCount && totalCount > 0 && songs.length > 0) {
       const firstSong = songs[0];
       if (firstSong && (isNavidromeType(firstSong.type) ||
       isJellyfinType(firstSong.type) ||
@@ -20923,11 +21045,19 @@ export struct LocalMusic {
     if (songs[startIndex]) {
       // 确保当前播放的歌曲也更新到存储
       AppStorage.setOrCreate('currentSong', songs[startIndex])
-      // 设置isFromSonPlayList=true,避免doPlay方法重新从全局列表构建播放列表
-      this.doPlay(songs[startIndex], startIndex, true)
+      if (isFindPagedLocalQueue) {
+        // 不要把发现页分页随机队列降级成普通歌单,否则后面又会触发全量 hydrate。
+        this.preserveExistingQueueOnNextPlay = true
+        this.nextPlayQueueScope = 'find_paged_local'
+        this.doPlay(songs[startIndex], startIndex, false)
+      } else {
+        // 设置isFromSonPlayList=true,避免doPlay方法重新从全局列表构建播放列表
+        this.doPlay(songs[startIndex], startIndex, true)
+      }
     }
 
     PreferencesUtil.putSync('LastMusicList', this.songList)
+    this.skipNextPlaylistPersist = true
 
     // ToastUtil.showToast(`开始播放歌单: ${playlistName}`)
   }

+ 626 - 0
entry/src/main/ets/view/MiniPlayerBar.ets

@@ -0,0 +1,626 @@
+import { DeviceUtil, StrUtil } from '@pura/harmony-utils'
+import { resourceManager } from '@kit.LocalizationKit'
+import { deviceInfo } from '@kit.BasicServicesKit'
+import { hdsEffect } from '@kit.UIDesignKit'
+import { VideoItem } from '../viewmodel/VideoItem'
+import { PlayStatus } from '../common/PlayStatus'
+import { CommonConstants } from '../common/constants/CommonConstants'
+import { PointLightContentButton } from './PointLight/PointLightContentButton'
+import { PlayingIndicator } from './PlayingIndicator'
+
+@Component
+export struct MiniPlayerBar {
+  @Prop bottomBarHeight: number = 64
+  @Prop bottomSafeHeight: number = 0
+  @Prop curDisplayIsHiCar: boolean = false
+  @Prop cover: string | undefined = ''
+  @Prop currentSong: VideoItem | undefined = undefined
+  @Prop themeColor: string = CommonConstants.DEFAULT_THEME_COLOR
+  @Prop progressValue: number = 0
+  @Prop controlPlayStatus: number = PlayStatus.INIT
+  @Prop isShowPrecious: boolean = false
+  @Prop isMiniPlayerOrbMode: boolean = false
+  @Prop isMiniPlayerModeTransitioning: boolean = false
+  @Prop miniPlayerScaleX: number = 1
+  @Prop miniPlayerScaleY: number = 1
+  @Prop miniPlayerOpacity: number = 1
+  @Prop miniPlayerBorderRadius: number = 200
+  @Prop miniPlayerProxySize: number = 0
+  @Prop miniPlayerProxyOpacity: number = 0
+  @Prop miniPlayerContentScaleY: number = 1
+  @Prop miniPlayerContentTranslateY: number = 0
+  @Prop miniPlayerFullContentTranslateX: number = 0
+  @Prop miniPlayerTranslateY: number = 0
+  @Prop miniPlayerSurfaceWidth: number = 0
+  @Prop miniPlayerSurfaceHeight: number = 0
+  @Prop miniPlayerFullContentOpacity: number = 1
+  @Prop miniPlayerOrbOpacity: number = 0
+  @Prop miniPlayerOrbScale: number = 1
+  @Prop miniPlayerOrbTranslateX: number = 0
+  onOpenPlayer: () => void = () => {}
+  onCollapseToOrb: () => void = () => {}
+  onOrbTap: () => void = () => {}
+  onPlayPrevious: () => void = () => {}
+  onPlayOrPause: () => void = () => {}
+  onPlayNext: () => void = () => {}
+  onOpenPlayList: () => void = () => {}
+
+  @State private pointLightOptions: hdsEffect.PointLightOptions | undefined = undefined
+
+  private resolveMiniPlayerOrbSize(): number {
+    const barHeightVp = this.bottomBarHeight > 0 ? this.bottomBarHeight : 70
+    return Math.max(54, Math.min(60, barHeightVp - 8))
+  }
+
+  private shouldBlockTransportAction(): boolean {
+    return this.isMiniPlayerModeTransitioning
+  }
+
+  @Builder
+  private buildMiniPlayerMorphProxyDot() {
+    Stack({ alignContent: Alignment.Center }) {
+      Image(StrUtil.isNotEmpty(this.cover) ? this.cover as string : $r('app.media.alt'))
+        .width('100%')
+        .height('100%')
+        .objectFit(ImageFit.Cover)
+        .alt($r('app.media.alt'))
+    }
+    .width(this.miniPlayerProxySize)
+    .height(this.miniPlayerProxySize)
+    .borderRadius(this.miniPlayerProxySize)
+    .clip(true)
+    .opacity(this.miniPlayerProxyOpacity)
+    .shadow({
+      radius: 18,
+      type: ShadowType.BLUR,
+      color: 'on_primary'
+    })
+  }
+
+  @Builder
+  private buildMiniPlayerOrbButtonContent(orbSize: number) {
+    Stack({ alignContent: Alignment.Center }) {
+      Image(StrUtil.isNotEmpty(this.cover) ? this.cover as string : $r('app.media.alt'))
+        .width('100%')
+        .height('100%')
+        .objectFit(ImageFit.Cover)
+        .alt($r('app.media.alt'))
+        .borderRadius(100)
+      Column()
+        .width('100%')
+        .height('100%')
+        .backgroundColor('#66000000')
+
+      Progress({
+        value: Math.floor(this.progressValue),
+        total: 100,
+        type: ProgressType.Ring,
+      })
+        .color(Color.White)
+        .width('100%')
+        .height('100%')
+        .style({ strokeWidth: 3 })
+
+      if (this.controlPlayStatus === PlayStatus.PLAY) {
+        PlayingIndicator({
+          isActive: true,
+          indicatorSize: Math.max(16, Math.floor(orbSize * 0.34)),
+          indicatorColor: '#FFFFFF'
+        })
+      }
+    }
+    .width('100%')
+    .height('100%')
+    .clip(true)
+    .borderRadius(100)
+  }
+
+  @Builder
+  private miniPlayerOrbControl() {
+    PointLightContentButton({
+      pointColor: this.themeColor,
+      buttonRadius: this.resolveMiniPlayerOrbSize() / 2,
+      pointLightHeight: 96,
+      pressScale: 0.94,
+      useShadow: true,
+      builder: (): void => {
+        this.buildMiniPlayerOrbButtonContent(this.resolveMiniPlayerOrbSize())
+      }
+    })
+      .width(this.resolveMiniPlayerOrbSize())
+      .height(this.resolveMiniPlayerOrbSize())
+      .opacity(this.miniPlayerOrbOpacity)
+      .borderRadius(this.resolveMiniPlayerOrbSize() / 2)
+      .scale({ x: this.miniPlayerOrbScale, y: this.miniPlayerOrbScale, centerX: '50%', centerY: '50%' })
+      .translate({ x: this.miniPlayerOrbTranslateX })
+      .onClick((): void => {
+        this.onOrbTap()
+      })
+  }
+
+  @Builder
+  private buildMiniPlayerCoverContent() {
+    Stack({ alignContent: Alignment.Center }) {
+      Image(StrUtil.isNotEmpty(this.currentSong?.pixelMapPath) ?
+        this.currentSong?.pixelMapPath : $r('app.media.alt'))
+        .width(48)
+        .height(48)
+        .objectFit(ImageFit.Contain)
+        .alt($r('app.media.alt'))
+        .fillColor(this.themeColor)
+        .borderRadius(8)
+        .shadow({
+          radius: 15,
+          type: ShadowType.BLUR,
+          color: 'on_primary'
+        })
+    }
+    .width(48)
+    .height(48)
+  }
+
+  @Builder
+  private miniPlayerCoverButton() {
+    PointLightContentButton({
+      pointColor: this.themeColor,
+      buttonRadius: 10,
+      pointLightHeight: 88,
+      pressScale: 0.92,
+      useShadow: false,
+      builder: (): void => {
+        this.buildMiniPlayerCoverContent()
+      }
+    })
+      .width(48)
+      .height(48)
+      .margin({ left: 5 })
+      .zIndex(3)
+      .onClick((): void => {
+        this.onCollapseToOrb()
+      })
+  }
+
+  @Builder
+  private buildPlayPreviousControlContent() {
+    Row() {
+      SymbolGlyph($r('sys.symbol.backward_end_fill'))
+        .fontSize(28)
+        .fontColor([Color.White])
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private buildPlayToggleControlContent() {
+    Row() {
+      Stack() {
+        Progress({
+          value: Math.floor(this.progressValue),
+          type: ProgressType.Ring,
+        })
+          .color(this.themeColor)
+          .height(38)
+          .aspectRatio(CommonConstants.ASPECT_RATIO)
+        Image(this.controlPlayStatus === PlayStatus.PLAY ? $r('app.media.hm_pause') : $r('app.media.hm_play2'))
+          .height(36)
+          .width(36)
+          .fillColor(Color.White)
+      }
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private buildPlayNextControlContent() {
+    Row() {
+      SymbolGlyph($r('sys.symbol.forward_end_fill'))
+        .fontSize(28)
+        .fontColor([Color.White])
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private buildPlayListControlContent() {
+    Row() {
+      SymbolGlyph($r('sys.symbol.music_note_list'))
+        .fontSize(28)
+        .fontColor([Color.White])
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Center)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private playConLeft() {
+    Row() {
+      this.miniPlayerCoverButton()
+
+      Column() {
+        Text(this.currentSong?.name)
+          .fontSize(16)
+          .textOverflow({ overflow: TextOverflow.MARQUEE })
+          .maxLines(1)
+          .fontColor(Color.White)
+          .fontWeight(FontWeight.Bolder)
+        Row() {
+          Text(this.currentSong?.artist)
+            .margin({ top: 2 })
+            .fontSize(13)
+            .textAlign(TextAlign.Start)
+            .maxLines(1)
+            .fontWeight(FontWeight.Bold)
+            .textOverflow({ overflow: TextOverflow.MARQUEE })
+            .fontColor(Color.White)
+        }
+        .visibility(this.currentSong?.artist ? Visibility.Visible : Visibility.None)
+      }
+      .padding({ left: 8 })
+      .alignItems(HorizontalAlign.Start)
+      .margin({ right: 22 })
+      .onClick((): void => {
+        this.onOpenPlayer()
+      })
+    }
+    .padding({ right: 18 })
+    .layoutWeight(1)
+    .alignItems(VerticalAlign.Center)
+    .justifyContent(FlexAlign.Start)
+  }
+
+  @Builder
+  private playConRigth() {
+    Row() {
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 22,
+        pointLightHeight: 56,
+        pressScale: 0.88,
+        builder: (): void => {
+          this.buildPlayPreviousControlContent()
+        }
+      })
+        .width(44)
+        .height(44)
+        .visibility(this.isShowPrecious ? Visibility.Visible : Visibility.None)
+        .displayPriority(2)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onPlayPrevious()
+        })
+
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 23,
+        pointLightHeight: 62,
+        pressScale: 0.9,
+        builder: (): void => {
+          this.buildPlayToggleControlContent()
+        }
+      })
+        .width(46)
+        .height(46)
+        .displayPriority(3)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onPlayOrPause()
+        })
+
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 22,
+        pointLightHeight: 56,
+        pressScale: 0.88,
+        builder: (): void => {
+          this.buildPlayNextControlContent()
+        }
+      })
+        .width(44)
+        .height(44)
+        .displayPriority(2)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onPlayNext()
+        })
+
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 21,
+        pointLightHeight: 52,
+        pressScale: 0.88,
+        builder: (): void => {
+          this.buildPlayListControlContent()
+        }
+      })
+        .width(42)
+        .height(42)
+        .displayPriority(1)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onOpenPlayList()
+        })
+    }
+    .margin({ left: 5 })
+    .justifyContent(FlexAlign.End)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private hiCarLeadingCluster() {
+    Row() {
+      this.miniPlayerCoverButton()
+      this.hiCarPlayConRigth()
+      Row()
+        .layoutWeight(1)
+    }
+    .width('100%')
+    .height('100%')
+    .justifyContent(FlexAlign.Start)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private hiCarCenterInfo() {
+    Column() {
+      Text(this.currentSong?.name)
+        .fontSize(16)
+        .textOverflow({ overflow: TextOverflow.MARQUEE })
+        .maxLines(1)
+        .textAlign(TextAlign.Center)
+        .fontColor(Color.White)
+        .fontWeight(FontWeight.Bolder)
+      Row() {
+        Text(this.currentSong?.artist)
+          .margin({ top: 2 })
+          .fontSize(13)
+          .textAlign(TextAlign.Center)
+          .maxLines(1)
+          .fontWeight(FontWeight.Bold)
+          .textOverflow({ overflow: TextOverflow.MARQUEE })
+          .fontColor(Color.White)
+      }
+      .visibility(this.currentSong?.artist ? Visibility.Visible : Visibility.None)
+    }
+    .width('42%')
+    .alignItems(HorizontalAlign.Center)
+    .onClick((): void => {
+      this.onOpenPlayer()
+    })
+  }
+
+  @Builder
+  private hiCarPlayConRigth() {
+    Row({ space: 8 }) {
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 22,
+        pointLightHeight: 56,
+        pressScale: 0.88,
+        builder: (): void => {
+          this.buildPlayPreviousControlContent()
+        }
+      })
+        .width(44)
+        .height(44)
+        .visibility(this.isShowPrecious ? Visibility.Visible : Visibility.None)
+        .displayPriority(2)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onPlayPrevious()
+        })
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 23,
+        pointLightHeight: 62,
+        pressScale: 0.9,
+        builder: (): void => {
+          this.buildPlayToggleControlContent()
+        }
+      })
+        .width(46)
+        .height(46)
+        .displayPriority(3)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onPlayOrPause()
+        })
+
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 22,
+        pointLightHeight: 56,
+        pressScale: 0.88,
+        builder: (): void => {
+          this.buildPlayNextControlContent()
+        }
+      })
+        .width(44)
+        .height(44)
+        .displayPriority(2)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onPlayNext()
+        })
+
+      PointLightContentButton({
+        pointColor: this.themeColor,
+        buttonRadius: 21,
+        pointLightHeight: 52,
+        pressScale: 0.88,
+        builder: (): void => {
+          this.buildPlayListControlContent()
+        }
+      })
+        .width(42)
+        .height(42)
+        .displayPriority(1)
+        .onClick((): void => {
+          if (this.shouldBlockTransportAction()) {
+            return
+          }
+          this.onOpenPlayList()
+        })
+    }
+    .margin({ left: 16 })
+    .justifyContent(FlexAlign.Start)
+    .alignItems(VerticalAlign.Center)
+  }
+
+  @Builder
+  private playController() {
+    Row() {
+      this.playConLeft()
+      this.playConRigth()
+    }
+    .width('100%')
+    .height(this.bottomBarHeight)
+    .scale({ x: 1, y: this.miniPlayerContentScaleY, centerX: '50%', centerY: '50%' })
+    .translate({ y: this.miniPlayerContentTranslateY })
+    .hitTestBehavior(HitTestMode.Transparent)
+    .zIndex(2)
+    .opacity(0.9)
+    .onClick((): void => {
+      this.onOpenPlayer()
+    })
+    .onTouch((event: TouchEvent): void => {
+      if (event.type === TouchType.Down) {
+        this.pointLightOptions = {
+          color: this.themeColor,
+          intensity: 1,
+          height: 60
+        }
+      } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
+        this.pointLightOptions = undefined
+      }
+    })
+    .visualEffect(deviceInfo.sdkApiVersion >= 20
+      ? new hdsEffect.HdsEffectBuilder()
+        .pointLight({
+          options: this.pointLightOptions,
+          illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT
+        })
+        .buildEffect()
+      : undefined)
+    .padding({
+      left: 16,
+      right: 16
+    })
+  }
+
+  @Builder
+  private hiCarPlayController() {
+    Stack({ alignContent: Alignment.Center }) {
+      this.hiCarLeadingCluster()
+      this.hiCarCenterInfo()
+    }
+    .opacity(0.9)
+    .width('100%')
+    .height(this.bottomBarHeight)
+    .hitTestBehavior(HitTestMode.Transparent)
+    .zIndex(2)
+    .onClick((): void => {
+      this.onOpenPlayer()
+    })
+    .onTouch((event: TouchEvent): void => {
+      if (event.type === TouchType.Down) {
+        this.pointLightOptions = {
+          color: this.themeColor,
+          intensity: 1,
+          height: 60
+        }
+      } else if (event.type === TouchType.Up || event.type === TouchType.Cancel) {
+        this.pointLightOptions = undefined
+      }
+    })
+    .visualEffect(deviceInfo.sdkApiVersion >= 20
+      ? new hdsEffect.HdsEffectBuilder()
+        .pointLight({
+          options: this.pointLightOptions,
+          illuminatedType: hdsEffect.PointLightIlluminatedType.BORDER_CONTENT
+        })
+        .buildEffect()
+      : undefined)
+    .padding({
+      left: 16,
+      right: 16
+    })
+  }
+
+  build() {
+    Row() {
+      Stack() {
+        if (!this.isMiniPlayerOrbMode || this.miniPlayerFullContentOpacity > 0.02) {
+          Stack() {
+            if (this.curDisplayIsHiCar) {
+              this.hiCarPlayController()
+            } else {
+              this.playController()
+            }
+          }
+          .width('100%')
+          .height('100%')
+          .opacity(this.miniPlayerFullContentOpacity)
+          .translate({ x: this.miniPlayerFullContentTranslateX })
+        }
+
+        if (this.isMiniPlayerOrbMode || this.miniPlayerOrbOpacity > 0.02) {
+          Row() {
+            this.miniPlayerOrbControl()
+          }
+          .width('100%')
+          .height('100%')
+          .justifyContent(FlexAlign.End)
+          .alignItems(VerticalAlign.Center)
+        }
+
+        if (this.miniPlayerProxySize > 0) {
+          this.buildMiniPlayerMorphProxyDot()
+        }
+      }
+      .width(this.miniPlayerSurfaceWidth)
+      .height(this.miniPlayerSurfaceHeight)
+      .borderRadius(this.miniPlayerBorderRadius)
+      .backgroundBlurStyle(BlurStyle.BACKGROUND_ULTRA_THICK)
+      .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.98, this.miniPlayerOpacity))
+      .clip(true)
+      .clickEffect({ level: ClickEffectLevel.HEAVY })
+    }
+    .width('90%')
+    .height(this.bottomBarHeight)
+    .justifyContent(FlexAlign.End)
+    .alignItems(VerticalAlign.Center)
+    .translate({ y: this.miniPlayerTranslateY })
+    .margin({
+      bottom: DeviceUtil.getDeviceType() === resourceManager.DeviceType.DEVICE_TYPE_2IN1 || this.curDisplayIsHiCar
+        ? 30 : this.bottomSafeHeight
+    })
+  }
+}

+ 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')
+    })
   })
 }