Ver código fonte

修复云端漫游播放 全局网盘的音乐随机播放
播放条的美化
发现页搜索的长按 下一首播放 收藏 删除左滑删除等功能

onecold 4 meses atrás
pai
commit
e45f5e6839

+ 40 - 2
entry/src/main/ets/common/util/MediaTable.ets

@@ -82,6 +82,33 @@ function normalizeFilePath(filePath: string): string {
   return filePath;
 }
 
+function buildSongDebugPath(path?: string): string {
+  if (!path) {
+    return '';
+  }
+  return path.length > 48 ? `...${path.substring(path.length - 48)}` : path;
+}
+
+function buildSongDebugItem(item?: VideoItem): string {
+  if (!item) {
+    return 'unknown';
+  }
+  const title = item.name || item.fileName || '未知歌曲';
+  return `${title}|type=${item.type}|acc=${item.webdav_account_id || ''}|path=${buildSongDebugPath(item.filePath)}`;
+}
+
+function buildSongDebugLog(items: VideoItem[], limit: number = 5): 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(buildSongDebugItem(items[i]));
+  }
+  return `[${parts.join('; ')}](${items.length})`;
+}
+
 /**
  * 分页查询接口 - 支持条件查询、排序、分页
  */
@@ -215,6 +242,7 @@ export default  class MediaTable {
         return false;
       }
       item.filePath = normalizeFilePath(item.filePath);
+      Logger.info('heanup MediaTable', `[remote-debug] saveOrUpdateWebDavItem start ${buildSongDebugItem(item)}`);
       if (!item.remote_rel_path) {
         item.remote_rel_path = item.filePath;
       }
@@ -233,10 +261,17 @@ export default  class MediaTable {
           const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
           predicates.equalTo(DB_COLUMNS.FILE_PATH, item.filePath);
           const bucket = generateBucket(item);
-          this.accountTable.updateData(predicates, bucket, (success: boolean) => resolve(success));
+          this.accountTable.updateData(predicates, bucket, (success: boolean) => {
+            Logger.info('heanup MediaTable',
+              `[remote-debug] saveOrUpdateWebDavItem update success=${success} ${buildSongDebugItem(item)}`);
+            resolve(success);
+          });
         });
       }
-      return await this.insertWebDavItem(item);
+      const inserted = await this.insertWebDavItem(item);
+      Logger.info('heanup MediaTable',
+        `[remote-debug] saveOrUpdateWebDavItem insert success=${inserted} ${buildSongDebugItem(item)}`);
+      return inserted;
     } catch (error) {
       Logger.error(RdbUtils.RDB_TAG, 'saveOrUpdateWebDavItem 失败: ' + (error as Error).message);
       return false;
@@ -1723,6 +1758,7 @@ export default  class MediaTable {
 
   public queryRemoteSongs(limitCount: number = 0, callback: (result: VideoItem[]) => void): void {
     try {
+      Logger.info('heanup MediaTable', `[remote-debug] queryRemoteSongs start limit=${limitCount}`);
       const predicates = new relationalStore.RdbPredicates(RdbUtils.MEDIA_TABLE.tableName);
       predicates.beginWrap();
       predicates.equalTo(DB_COLUMNS.TYPE, CommonConstants.TYPE_WEBDAV);
@@ -1753,6 +1789,8 @@ export default  class MediaTable {
 
       this.accountTable.query(predicates, (resultSet: relationalStore.ResultSet) => {
         const result = this.parseResultSetToVideoItems(resultSet);
+        Logger.info('heanup MediaTable',
+          `[remote-debug] queryRemoteSongs done limit=${limitCount}, count=${result.length}, sample=${buildSongDebugLog(result)}`);
         callback(result);
       });
     } catch (err) {

+ 83 - 0
entry/src/main/ets/common/util/PlayQueueHelper.ets

@@ -0,0 +1,83 @@
+import { StrUtil } from '@pura/harmony-utils'
+import { VideoItem } from '../../viewmodel/VideoItem'
+
+export enum QueueInsertStatus {
+  INVALID_SONG = 0,
+  START_PLAY = 1,
+  INSERTED = 2,
+  ALREADY_PLAYING = 3
+}
+
+export interface QueueInsertResult {
+  status: QueueInsertStatus
+  queue: VideoItem[]
+  currentIndex: number
+  insertedIndex: number
+}
+
+function resolveCurrentQueueIndex(queue: VideoItem[], currentFilePath: string, fallbackIndex: number): number {
+  if (queue.length <= 0) {
+    return -1
+  }
+  if (StrUtil.isNotEmpty(currentFilePath)) {
+    const queueIndex = queue.findIndex((item: VideoItem) => item.filePath === currentFilePath)
+    if (queueIndex >= 0) {
+      return queueIndex
+    }
+  }
+  if (fallbackIndex >= 0 && fallbackIndex < queue.length) {
+    return fallbackIndex
+  }
+  return 0
+}
+
+export function insertSongToNextPlayQueue(queue: VideoItem[], currentFilePath: string, fallbackIndex: number,
+  song: VideoItem): QueueInsertResult {
+  if (!song || StrUtil.isEmpty(song.filePath)) {
+    return {
+      status: QueueInsertStatus.INVALID_SONG,
+      queue: queue.slice(),
+      currentIndex: resolveCurrentQueueIndex(queue, currentFilePath, fallbackIndex),
+      insertedIndex: -1
+    }
+  }
+
+  if (queue.length <= 0) {
+    return {
+      status: QueueInsertStatus.START_PLAY,
+      queue: [song],
+      currentIndex: 0,
+      insertedIndex: 0
+    }
+  }
+
+  const nextQueue = queue.slice()
+  let currentIndex = resolveCurrentQueueIndex(nextQueue, currentFilePath, fallbackIndex)
+  const currentPath = currentIndex >= 0 && currentIndex < nextQueue.length ? nextQueue[currentIndex].filePath : ''
+  if (song.filePath === currentPath) {
+    return {
+      status: QueueInsertStatus.ALREADY_PLAYING,
+      queue: nextQueue,
+      currentIndex,
+      insertedIndex: currentIndex
+    }
+  }
+
+  const existingIndex = nextQueue.findIndex((item: VideoItem) => item.filePath === song.filePath)
+  if (existingIndex >= 0) {
+    const movedSong = nextQueue.splice(existingIndex, 1)[0]
+    if (existingIndex < currentIndex) {
+      currentIndex -= 1
+    }
+    nextQueue.splice(currentIndex + 1, 0, movedSong)
+  } else {
+    nextQueue.splice(currentIndex + 1, 0, song)
+  }
+
+  return {
+    status: QueueInsertStatus.INSERTED,
+    queue: nextQueue,
+    currentIndex,
+    insertedIndex: currentIndex + 1
+  }
+}

+ 134 - 3
entry/src/main/ets/common/util/RemoteDriveManager.ets

@@ -32,9 +32,37 @@ 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';
 
 const TAG = 'heanup RemoteDriveManager';
 
+function buildRemoteSongDebugPath(path?: string): string {
+  if (!path) {
+    return '';
+  }
+  return path.length > 48 ? `...${path.substring(path.length - 48)}` : path;
+}
+
+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)}`;
+}
+
+function buildRemoteSongDebugLog(items: VideoItem[], limit: number = 5): 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(buildRemoteSongDebugItem(items[i]));
+  }
+  return `[${parts.join('; ')}](${items.length})`;
+}
+
 function mergeCoverWithPriorityLocal(target?: VideoItem | null, source?: VideoItem | null): void {
   if (!target || !source) {
     return;
@@ -396,6 +424,7 @@ export class RemoteDriveManager {
   private lastAccountId: number | null = null;
   private readonly GLOBAL_SEARCH_PROGRESS_NOTIFY_INTERVAL: number = 4;
   private readonly GLOBAL_SEARCH_YIELD_INTERVAL: number = 6;
+  private readonly STREAMING_GLOBAL_SEARCH_PAGE_SIZE: number = 200;
   private readonly DIRECTORY_METADATA_ENRICH_LIMIT: number = 80;
   private readonly DIRECTORY_LOAD_YIELD_INTERVAL: number = 40;
   private directoryLoadRequestVersion: number = 0;
@@ -1289,8 +1318,10 @@ export class RemoteDriveManager {
 
   // 获取激活的账户
   public getActivatedWebDavAccount(): WebDavAccount | null {
-    if(this.currentAccount)
+    if (this.currentAccount && this.currentAccount.id !== undefined && this.currentAccount.id !== null
+      && this.currentAccount.id > 0) {
       return this.currentAccount;
+    }
     for (let i = 0; i < this.webDavAccounts.length; i++) {
       const account = this.webDavAccounts[i];
       if (account.isActivate) {
@@ -2859,6 +2890,7 @@ export class RemoteDriveManager {
       case RemoteDriveType.Navidrome:
       case RemoteDriveType.Jellyfin:
       case RemoteDriveType.Emby:
+      case RemoteDriveType.DaoLiYu:
         return true;
       default:
         return false;
@@ -3250,16 +3282,31 @@ export class RemoteDriveManager {
       return [];
     }
     const index = this.getOrCreateGlobalSearchIndex(account);
+    Logger.info(
+      TAG,
+      `[remote-debug] getGlobalSearchIndexSongs start account=${account.name}, type=${account.webType}, ` +
+      `cached=${index.songs.length}, isBuilding=${index.isBuilding}, lastBuiltAt=${index.lastBuiltAt}`
+    );
     if (this.currentAccount && this.currentAccount.id === account.id) {
       this.seedCurrentDirectoryIntoGlobalSearchIndex(account);
     }
     if (index.isBuilding && index.buildPromise) {
       await index.buildPromise;
+      Logger.info(
+        TAG,
+        `[remote-debug] getGlobalSearchIndexSongs awaited account=${account.name}, count=${index.songs.length}, ` +
+        `sample=${buildRemoteSongDebugLog(index.songs)}`
+      );
       return index.songs.slice();
     }
     if (index.lastBuiltAt === 0 && !index.lastError) {
       await this.ensureGlobalSearchIndex(account);
     }
+    Logger.info(
+      TAG,
+      `[remote-debug] getGlobalSearchIndexSongs done account=${account.name}, count=${index.songs.length}, ` +
+      `lastError=${index.lastError || ''}, sample=${buildRemoteSongDebugLog(index.songs)}`
+    );
     return index.songs.slice();
   }
 
@@ -3313,7 +3360,13 @@ export class RemoteDriveManager {
       if (index.songs.length === 0 || index.lastBuiltAt === 0) {
         await this.ensureGlobalSearchIndex(account);
       }
-      return this.buildDiscoverySeedSongs(index.songs, limitCount);
+      const result = this.buildDiscoverySeedSongs(index.songs, limitCount);
+      Logger.info(
+        TAG,
+        `[remote-debug] getDiscoverySeedSongs source=index account=${account.name}, indexSongs=${index.songs.length}, ` +
+        `limit=${limitCount}, result=${result.length}, sample=${buildRemoteSongDebugLog(result)}`
+      );
+      return result;
     }
     const accountId = account.id ? account.id.toString() : '';
     const currentSongs = this.webDavSongs.filter((song: VideoItem) => {
@@ -3325,7 +3378,13 @@ export class RemoteDriveManager {
       }
       return song.webdav_account_id === accountId;
     });
-    return this.buildDiscoverySeedSongs(currentSongs, limitCount);
+    const result = this.buildDiscoverySeedSongs(currentSongs, limitCount);
+    Logger.info(
+      TAG,
+      `[remote-debug] getDiscoverySeedSongs source=memory account=${account.name}, currentSongs=${currentSongs.length}, ` +
+      `limit=${limitCount}, result=${result.length}, sample=${buildRemoteSongDebugLog(result)}`
+    );
+    return result;
   }
 
   private async buildGlobalSearchIndex(account: WebDavAccount, index: RemoteDriveGlobalSearchIndex,
@@ -3348,6 +3407,9 @@ export class RemoteDriveManager {
       case RemoteDriveType.Emby:
         await this.buildEmbyGlobalSearchIndex(account, index, buildVersion);
         return;
+      case RemoteDriveType.DaoLiYu:
+        await this.buildDaoLiYuGlobalSearchIndex(account, index, buildVersion);
+        return;
       default:
         return;
     }
@@ -3601,6 +3663,34 @@ export class RemoteDriveManager {
     }
   }
 
+  private async buildDaoLiYuGlobalSearchIndex(account: WebDavAccount, index: RemoteDriveGlobalSearchIndex,
+    buildVersion: number): Promise<void> {
+    let nextStart: number | null = 0;
+    let processedCount = 0;
+    index.scannedPaths.add('/');
+    while (nextStart !== null) {
+      if (index.buildVersion !== buildVersion) {
+        return;
+      }
+      const response: DaoLiYuPagedResponse<DaoLiYuTrack> =
+        await daoLiYuApi.getTracksPage(account, nextStart, this.STREAMING_GLOBAL_SEARCH_PAGE_SIZE);
+      const songs: VideoItem[] = response.items.map((song: DaoLiYuTrack) => this.buildDaoLiYuVideoItem(song, account));
+      this.mergeSnapshotIntoGlobalSearchIndex(account, index, {
+        files: [],
+        folders: [],
+        songs
+      });
+      nextStart = response.nextStart;
+      processedCount += 1;
+      if (processedCount === 1 || processedCount % this.GLOBAL_SEARCH_PROGRESS_NOTIFY_INTERVAL === 0) {
+        this.notifyObservers(RemoteDriveManagerStates.GlobalSearchIndexUpdated);
+      }
+      if (processedCount % this.GLOBAL_SEARCH_YIELD_INTERVAL === 0) {
+        await this.yieldGlobalSearchBuild();
+      }
+    }
+  }
+
   private async yieldGlobalSearchBuild(): Promise<void> {
     await new Promise<void>((resolve) => {
       setTimeout(() => resolve(), 0);
@@ -4241,6 +4331,47 @@ export class RemoteDriveManager {
     return videoItem;
   }
 
+  private buildDaoLiYuVideoItem(song: DaoLiYuTrack, account: WebDavAccount): VideoItem {
+    const title = song.title ?? '未知曲目';
+    const suffix = song.suffix ?? '';
+    const fileName = `${title}${suffix ? '.' + suffix : ''}`;
+    const videoItem = new VideoItem(
+      title,
+      song.id,
+      `daoliyu://${account.id ?? 0}/${song.id}`,
+      CommonConstants.TYPE_DAOLIYU,
+      song.size ?? 0,
+      song.createdAt ?? Utility.getFormatDateStr(Date.now(), 'yyyy-MM-dd HH:mm'),
+      Utility.formatFSize(song.size ?? 0),
+      song.coverArtUrl,
+      song.artist ?? Constants.UNKNOWN_ARTIST,
+      song.album,
+      fileName
+    );
+    videoItem.size = Utility.formatFSize(song.size ?? 0);
+    videoItem.webdav_account_id = account.id?.toString();
+    videoItem.remote_rel_path = song.id;
+    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.lyricContent = song.lyrics;
+    this.applyInitialRemoteSongQuality(videoItem, suffix || song.mimeType || '', fileName, song.bitRate, song.sampleRate);
+    if (song.albumId && song.albumId.length > 0) {
+      videoItem.parentPath = this.normalizeFullPath(`/album/${song.albumId}`);
+    }
+    if (song.artistId && song.artistId.length > 0) {
+      videoItem.navArtistId = song.artistId;
+    }
+    if (song.track !== undefined && song.track !== null) {
+      videoItem.track = song.track.toString();
+    }
+    if (song.year !== undefined && song.year !== null) {
+      videoItem.year = song.year.toString();
+    }
+    return videoItem;
+  }
+
   private registerPathLabel(path: string, label: string): void {
     if (!path || label === undefined) {
       return;

+ 4 - 8
entry/src/main/ets/pages/NewIndex.ets

@@ -406,7 +406,7 @@ struct NewIndex {
   private enterFindPage(): void {
     const wasFindPage: boolean = this.mType === 8
     this.mType = 8
-    this.modeType = 1
+    this.modeType = 5
     if (!wasFindPage) {
       this.handleFindPageEntry()
     }
@@ -516,7 +516,7 @@ struct NewIndex {
       } else {
         this.mType = 0
       }
-    },200)
+    },150)
 
   }
 
@@ -652,7 +652,7 @@ struct NewIndex {
           this.PlayController()
         }
         .width('90%')
-        .borderRadius(15)
+        .borderRadius(200)
         .margin({ bottom:DeviceUtil.getDeviceType() === resourceManager.DeviceType.DEVICE_TYPE_2IN1||this.curDisplayIsHiCar
           ? 30 :this.bottomSafeHeight })
         .backgroundBlurStyle(BlurStyle.BACKGROUND_ULTRA_THICK)
@@ -799,6 +799,7 @@ struct NewIndex {
         .objectFit(ImageFit.Contain)
         .alt( $r('app.media.alt'))
         .fillColor(this.themeColor)
+        .margin({left:5})
         .borderRadius(8)
         .shadow({
           radius: 15,
@@ -923,7 +924,6 @@ struct NewIndex {
         .width(44)
         .height(44)
         .visibility(this.isShowPrecious ? Visibility.Visible : Visibility.None)
-        .margin({ left: 5, right: 5 })
         .displayPriority(2)
         .onClick(() => {
           this.getUIContext().getHostContext()!.eventHub.emit('playPrevious');
@@ -956,10 +956,6 @@ struct NewIndex {
       })
         .width(44)
         .height(44)
-        .margin({
-          right: 5,
-          left: 5
-        })
         .displayPriority(2)
         .onClick(() => {
           this.getUIContext().getHostContext()!.eventHub.emit('playNext');

+ 5 - 0
entry/src/main/ets/pages/WebDavMainPage.ets

@@ -78,6 +78,11 @@ export function getWebdavCurrentPlayIndex(): number {
   return globalWebdavCurrentPlayIndex;
 }
 
+export function setWebdavPlaylist(items: VideoItem[], startIndex: number): void {
+  globalWebdavVideoItems = items.slice();
+  globalWebdavCurrentPlayIndex = startIndex;
+}
+
 export function clearWebdavVideoItems(): void {
   globalWebdavVideoItems = [];
   globalWebdavCurrentPlayIndex = 0;

+ 14 - 0
entry/src/main/ets/view/FindAlbumDetail.ets

@@ -43,6 +43,7 @@ export struct FindAlbumDetail {
   onRandomPlay: (songs?: VideoItem[]) => void = (_songs?: VideoItem[]) => {}
   onBack: () => void = () => {}
   onSongTap: (index: number, songs?: VideoItem[]) => void = (_index: number, _songs?: VideoItem[]) => {}
+  onPlayNextSong: (song: VideoItem) => void = (_song: VideoItem) => {}
   onFavoriteSong: (song: VideoItem) => void = (_song: VideoItem) => {}
   onDeleteSong: (song: VideoItem) => void = (_song: VideoItem) => {}
 
@@ -157,6 +158,10 @@ export struct FindAlbumDetail {
     return this.allowDelete && item.type === CommonConstants.TYPE_LOCAL && StrUtil.isNotEmpty(item.filePath)
   }
 
+  private canQueueSongForNextPlay(item: VideoItem): boolean {
+    return StrUtil.isNotEmpty(item.filePath)
+  }
+
   private isSongFavorite(item: VideoItem): boolean {
     return item.isFav === 1
   }
@@ -164,6 +169,15 @@ export struct FindAlbumDetail {
   @Builder
   private SongContextMenuBuilder(item: VideoItem) {
     Menu() {
+      MenuItem({
+        symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.forward_end_fill')),
+        content: '下一首播放'
+      })
+        .visibility(this.canQueueSongForNextPlay(item) ? Visibility.Visible : Visibility.None)
+        .onClick(() => {
+          this.onPlayNextSong(item)
+        })
+
       MenuItem({
         symbolStartIcon: new SymbolGlyphModifier(this.isSongFavorite(item) ?
           $r('sys.symbol.heart_fill') : $r('sys.symbol.heart')),

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

@@ -1,10 +1,11 @@
+import { SymbolGlyphModifier } from '@kit.ArkUI'
 import { PreferencesUtil, StrUtil, ToastUtil } from '@pura/harmony-utils'
 import { BusinessError, emitter } from '@kit.BasicServicesKit'
 import { common } from '@kit.AbilityKit'
 import { CommonConstants } from '../common/constants/CommonConstants'
 import { EventConstants } from '../common/constants/EventConstants'
 import MediaTable from '../common/util/MediaTable'
-import { SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil'
+import { MenuModifier, SymbolGlyphFancyModifier } from '../common/util/AttributeModifierUtil'
 import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSystem'
 import Logger from '../common/util/Logger'
 import { RemoteDriveManager } from '../common/util/RemoteDriveManager'
@@ -21,16 +22,24 @@ import { DeleteComptent } from './DeleteComptent'
 import { SettingPage } from '../pages/SettingPage'
 import { FindAlbumDetail } from './FindAlbumDetail'
 import { PlayingIndicator } from './PlayingIndicator'
-import { setFindPlaylist } from '../common/util/FindPlaylistStore'
+import {
+  getFindPlaylistId,
+  getFindPlaylistName,
+  getFindVideoItems,
+  setFindPlaylist
+} from '../common/util/FindPlaylistStore'
 import { PlaylistPlayRequest, savePendingPlaylistPlay } from '../common/util/PlaylistPlayRequestStore'
 import PlaylistTable from '../common/util/PlaylistTable'
 import { convertPlaylistSongsToVideoItems } from '../pages/PlaylistDetailPage'
+import { getWebdavVideoItems, setWebdavPlaylist } from '../pages/WebDavMainPage'
+import { getNavidromeVideoItems, setNavidromePlaylist } from '../common/util/NavidromePlaylistStore'
 import {
   buildPreferredRemotePlaybackPool,
   buildSortedDiscoverySongs,
   FindCollectionSortType,
   resolveQueueStartIndex
 } from '../common/util/FindDiscoveryHelper'
+import { insertSongToNextPlayQueue, QueueInsertStatus } from '../common/util/PlayQueueHelper'
 
 @Builder
 export function FindViewBuilder() {
@@ -322,6 +331,11 @@ export struct FindView {
       if (uniqueRemoteSongs.length === 0) {
         void this.bootstrapRemoteDiscoverySongsIfNeeded()
       }
+      Logger.info(
+        TAG,
+        `[remote-debug] loadDiscoveryContent rawRemote=${remoteSongs.length}, uniqueRemote=${uniqueRemoteSongs.length}, ` +
+        `sample=${this.buildSongDebugLog(uniqueRemoteSongs)}`
+      )
 
       Logger.info(
         TAG,
@@ -396,6 +410,11 @@ export struct FindView {
       return
     }
     const remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync(REMOTE_POOL_COUNT))
+    Logger.info(
+      TAG,
+      `[remote-debug] ensureRemoteDiscoverySongsAvailable dbRemote=${remoteSongs.length}, ` +
+      `sample=${this.buildSongDebugLog(remoteSongs)}`
+    )
     if (remoteSongs.length > 0) {
       this.applyRemoteDiscoverySongs(remoteSongs, true)
       return
@@ -423,17 +442,24 @@ export struct FindView {
   private async queryIndexedRemotePlaybackSongs(): Promise<VideoItem[]> {
     const account = await this.prepareActiveRemoteAccount()
     if (!account) {
+      Logger.warn(TAG, '[remote-debug] queryIndexedRemotePlaybackSongs skip: no active account')
       return []
     }
     const supportsGlobalIndex = this.remoteDriveManager.supportsGlobalSearchIndexForAccount(account)
     const isIndexReady = supportsGlobalIndex && this.remoteDriveManager.isGlobalSearchIndexReady(account)
+    Logger.info(
+      TAG,
+      `[remote-debug] queryIndexedRemotePlaybackSongs account=${account.name}, type=${account.webType}, ` +
+      `supportsGlobalIndex=${supportsGlobalIndex}, isIndexReady=${isIndexReady}`
+    )
     if (supportsGlobalIndex && !isIndexReady) {
-      ToastUtil.showToast('首次云端漫游正在建立全量歌曲索引,请稍候')
+      ToastUtil.showToast('云端加载,请稍候')
     }
     const indexedSongs = await this.remoteDriveManager.getGlobalSearchIndexSongs(account)
     const clonedSongs = indexedSongs.map((item: VideoItem) => cloneVideoItem(item))
     Logger.info(TAG,
-      `发现页全量云端索引池加载完成: account=${account.name}, type=${account.webType}, indexed=${clonedSongs.length}`)
+      `发现页全量云端索引池加载完成: account=${account.name}, type=${account.webType}, indexed=${clonedSongs.length}, ` +
+      `sample=${this.buildSongDebugLog(clonedSongs)}`)
     return this.filterUniqueSongs(clonedSongs)
   }
 
@@ -449,6 +475,10 @@ export struct FindView {
       }
       Logger.info(TAG, `发现页开始预热远程歌曲: account=${account.name}, type=${account.webType}`)
       const seedSongs = await this.remoteDriveManager.getDiscoverySeedSongs(account, REMOTE_POOL_COUNT)
+      Logger.info(
+        TAG,
+        `[remote-debug] bootstrapRemoteDiscoverySongs seedCount=${seedSongs.length}, sample=${this.buildSongDebugLog(seedSongs)}`
+      )
       if (seedSongs.length === 0) {
         Logger.info(TAG, `发现页远程预热未拿到歌曲: account=${account.name}`)
         return
@@ -465,6 +495,12 @@ export struct FindView {
         const saved = await this.mediaTable.saveOrUpdateWebDavItem(seedSong)
         if (saved) {
           savedCount += 1
+        } else {
+          Logger.warn(
+            TAG,
+            `[remote-debug] bootstrapRemoteDiscoverySongs save failed name=${this.getSongTitle(seedSong)}, ` +
+            `type=${seedSong.type}, account=${seedSong.webdav_account_id ?? ''}, path=${this.sanitizeSongPath(seedSong.filePath)}`
+          )
         }
       }
       if (savedCount <= 0) {
@@ -475,7 +511,11 @@ export struct FindView {
         return
       }
       this.applyRemoteDiscoverySongs(refreshedRemoteSongs, true)
-      Logger.info(TAG, `发现页远程预热完成: saved=${savedCount}, remote=${refreshedRemoteSongs.length}`)
+      Logger.info(
+        TAG,
+        `发现页远程预热完成: saved=${savedCount}, remote=${refreshedRemoteSongs.length}, ` +
+        `sample=${this.buildSongDebugLog(refreshedRemoteSongs)}`
+      )
     } catch (error) {
       Logger.warn(TAG, `发现页远程预热失败: ${this.toErrorMessage(error)}`)
     } finally {
@@ -893,6 +933,11 @@ export struct FindView {
       return
     }
     const safeIndex = Math.min(Math.max(startIndex, 0), songs.length - 1)
+    Logger.info(
+      TAG,
+      `[remote-debug] emitPlaylistPlay id=${playlistId}, name=${playlistName}, count=${songs.length}, ` +
+      `startIndex=${safeIndex}, playType=${playType ?? -1}, sample=${this.buildSongDebugLog(songs)}`
+    )
     if (playlistId === 'find-album-playlist' || playlistId.indexOf('find-') === 0) {
       setFindPlaylist(playlistId, playlistName, songs, safeIndex)
     }
@@ -910,6 +955,120 @@ export struct FindView {
     emitter.emit(eventPlaylistPlay, { data: playlistData })
   }
 
+  private getCurrentPlaybackQueue(): VideoItem[] {
+    const queue = AppStorage.get('songList') as VideoItem[] | undefined
+    return Array.isArray(queue) ? queue : []
+  }
+
+  private getCurrentPlaybackIndex(): number {
+    const currentIndex = AppStorage.get('currIndex') as number | undefined
+    return typeof currentIndex === 'number' ? currentIndex : -1
+  }
+
+  private canFavoriteSong(item: VideoItem): boolean {
+    return StrUtil.isNotEmpty(item.filePath)
+  }
+
+  private canDeleteSong(item: VideoItem): boolean {
+    return item.type === CommonConstants.TYPE_LOCAL && StrUtil.isNotEmpty(item.filePath)
+  }
+
+  private canQueueSongForNextPlay(item: VideoItem): boolean {
+    return StrUtil.isNotEmpty(item.filePath)
+  }
+
+  private isSongFavorite(item: VideoItem): boolean {
+    return item.isFav === 1
+  }
+
+  private syncPlaybackQueueStores(previousQueue: VideoItem[], nextQueue: VideoItem[], currentIndex: number): void {
+    const findQueue = getFindVideoItems()
+    if (findQueue.length > 0 && this.isSameSongSelection(previousQueue, findQueue)) {
+      const playlistId = getFindPlaylistId().length > 0 ? getFindPlaylistId() : 'find-search'
+      const playlistName = getFindPlaylistName().length > 0 ? getFindPlaylistName() : '发现搜索'
+      setFindPlaylist(playlistId, playlistName, nextQueue, currentIndex)
+    }
+
+    const webdavQueue = getWebdavVideoItems()
+    if (webdavQueue.length > 0 && this.isSameSongSelection(previousQueue, webdavQueue)) {
+      setWebdavPlaylist(nextQueue, currentIndex)
+    }
+
+    const navidromeQueue = getNavidromeVideoItems()
+    if (navidromeQueue.length > 0 && this.isSameSongSelection(previousQueue, navidromeQueue)) {
+      setNavidromePlaylist(nextQueue, currentIndex)
+    }
+  }
+
+  private handleAddSongToNextPlay(song: VideoItem): void {
+    if (!this.canQueueSongForNextPlay(song)) {
+      ToastUtil.showToast('当前歌曲暂不支持加入下一首播放')
+      return
+    }
+
+    const currentQueue = this.getCurrentPlaybackQueue()
+    const result = insertSongToNextPlayQueue(
+      currentQueue,
+      this.currentSong?.filePath ?? '',
+      this.getCurrentPlaybackIndex(),
+      song
+    )
+
+    if (result.status === QueueInsertStatus.INVALID_SONG) {
+      ToastUtil.showToast('当前歌曲暂不支持加入下一首播放')
+      return
+    }
+    if (result.status === QueueInsertStatus.ALREADY_PLAYING) {
+      ToastUtil.showToast('当前正在播放这首歌')
+      return
+    }
+    if (result.status === QueueInsertStatus.START_PLAY) {
+      this.emitPlaylistPlay('find-search-next', '发现搜索', [song], 0)
+      ToastUtil.showToast('已开始播放')
+      return
+    }
+
+    AppStorage.setOrCreate('songList', result.queue)
+    AppStorage.setOrCreate('currIndex', result.currentIndex)
+    PreferencesUtil.putSync('LastMusicList', result.queue)
+    this.syncPlaybackQueueStores(currentQueue, result.queue, result.currentIndex)
+    ToastUtil.showToast('已添加到下一首播放')
+  }
+
+  @Builder
+  private SongContextMenuBuilder(item: VideoItem) {
+    Menu() {
+      MenuItem({
+        symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.forward_end_fill')),
+        content: '下一首播放'
+      })
+        .visibility(this.canQueueSongForNextPlay(item) ? Visibility.Visible : Visibility.None)
+        .onClick(() => {
+          this.handleAddSongToNextPlay(item)
+        })
+
+      MenuItem({
+        symbolStartIcon: new SymbolGlyphModifier(this.isSongFavorite(item) ?
+          $r('sys.symbol.heart_fill') : $r('sys.symbol.heart')),
+        content: this.isSongFavorite(item) ? '取消收藏' : '收藏'
+      })
+        .visibility(this.canFavoriteSong(item) ? Visibility.Visible : Visibility.None)
+        .onClick(() => {
+          void this.handleAlbumFavoriteSong(item)
+        })
+
+      MenuItem({
+        symbolStartIcon: new SymbolGlyphModifier($r('sys.symbol.trash')),
+        content: '删除'
+      })
+        .visibility(this.canDeleteSong(item) ? Visibility.Visible : Visibility.None)
+        .onClick(() => {
+          this.openDeleteSongDialog(item)
+        })
+    }
+    .attributeModifier(new MenuModifier())
+  }
+
   private async getFullLocalSongsPool(): Promise<VideoItem[]> {
     if (this.localSongsPool.length > 0) {
       return this.localSongsPool
@@ -924,6 +1083,11 @@ export struct FindView {
 
   private async getFullRemoteSongsPool(): Promise<VideoItem[]> {
     if (this.remotePlaybackPool.length > 0) {
+      Logger.info(
+        TAG,
+        `[remote-debug] getFullRemoteSongsPool cacheHit=${this.remotePlaybackPool.length}, ` +
+        `sample=${this.buildSongDebugLog(this.remotePlaybackPool)}`
+      )
       return this.remotePlaybackPool
     }
     if (!this.mediaTable) {
@@ -935,8 +1099,18 @@ export struct FindView {
       await this.ensureRemoteDiscoverySongsAvailable()
       remoteSongs = this.filterUniqueSongs(await this.mediaTable.queryRemoteSongsAsync())
     }
+    Logger.info(
+      TAG,
+      `[remote-debug] getFullRemoteSongsPool beforeSelect indexed=${indexedSongs.length}, db=${remoteSongs.length}, ` +
+      `indexedSample=${this.buildSongDebugLog(indexedSongs)}, dbSample=${this.buildSongDebugLog(remoteSongs)}`
+    )
     this.remotePlaybackPool = buildPreferredRemotePlaybackPool(indexedSongs, remoteSongs)
-    Logger.info(TAG, `发现页全量云端播放池已就绪: indexed=${indexedSongs.length}, db=${remoteSongs.length}, selected=${this.remotePlaybackPool.length}`)
+    Logger.info(
+      TAG,
+      `发现页全量云端播放池已就绪: indexed=${indexedSongs.length}, db=${remoteSongs.length}, ` +
+      `selected=${this.remotePlaybackPool.length}, source=${indexedSongs.length > 0 ? 'indexed' : 'db'}, ` +
+      `sample=${this.buildSongDebugLog(this.remotePlaybackPool)}`
+    )
     return this.remotePlaybackPool
   }
 
@@ -950,6 +1124,12 @@ export struct FindView {
         return
       }
       const startIndex = Math.floor(Math.random() * queue.length)
+      Logger.info(
+        TAG,
+        `[remote-debug] playFromFullRemoteQueue random playlist=${playlistId}, fullQueue=${fullQueue.length}, ` +
+        `fallback=${fallbackSongs.length}, selectedQueue=${queue.length}, source=${fullQueue.length > 0 ? 'full' : 'fallback'}, ` +
+        `startIndex=${startIndex}, startSong=${this.buildSongDebugItem(queue[startIndex])}`
+      )
       this.emitPlaylistPlay(playlistId, playlistName, queue, startIndex, 3)
       return
     }
@@ -965,6 +1145,11 @@ export struct FindView {
     }
 
     const targetFilePath = targetSong.filePath || ''
+    Logger.info(
+      TAG,
+      `[remote-debug] playFromFullRemoteQueue precise playlist=${playlistId}, target=${this.buildSongDebugItem(targetSong)}, ` +
+      `fullQueue=${fullQueue.length}, fallback=${fallbackSongs.length}`
+    )
     const fullIndex = resolveQueueStartIndex(fullQueue, targetFilePath)
     if (fullIndex >= 0) {
       this.emitPlaylistPlay(playlistId, playlistName, fullQueue, fullIndex)
@@ -1002,6 +1187,11 @@ export struct FindView {
       return
     }
     const allRemoteSongs = await this.getFullRemoteSongsPool()
+    Logger.info(
+      TAG,
+      `[remote-debug] handleActionButtonTap cloud-random remotePlaybackPool=${allRemoteSongs.length}, ` +
+      `remoteSongsPool=${this.remoteSongsPool.length}, remoteSongs=${this.remoteSongs.length}, cloudMoodSongs=${this.cloudMoodSongs.length}`
+    )
     if (allRemoteSongs.length === 0) {
       ToastUtil.showToast('需要先配置网盘并加载歌曲后才能播放')
       return
@@ -1395,9 +1585,13 @@ export struct FindView {
       return
     }
     this.currentAlbumSongs = this.currentAlbumSongs.filter((item: VideoItem) => !deletedKeys.has(item.filePath))
+    this.searchResults = this.searchResults.filter((item: VideoItem) => !deletedKeys.has(item.filePath))
     this.filterPlaylistCachesByDeletedKeys(deletedKeys)
     await this.loadDiscoveryContent(false)
     this.syncCurrentAlbumDetailFromState()
+    if (this.isSearchMode && this.searchText.length > 0) {
+      await this.onSearchInput(this.searchText)
+    }
     ToastUtil.showToast('删除成功')
   }
 
@@ -1649,6 +1843,36 @@ export struct FindView {
     return `[${parts.join(', ')}](${items.length})`
   }
 
+  private buildSongDebugLog(items: VideoItem[], limit: number = 5): string {
+    if (items.length === 0) {
+      return '[]'
+    }
+    const parts: string[] = []
+    const maxCount = Math.min(limit, items.length)
+    for (let i = 0; i < maxCount; i++) {
+      parts.push(this.buildSongDebugItem(items[i]))
+    }
+    return `[${parts.join('; ')}](${items.length})`
+  }
+
+  private buildSongDebugItem(item?: VideoItem): string {
+    if (!item) {
+      return 'unknown'
+    }
+    return `${this.getSongTitle(item)}|type=${item.type}|acc=${item.webdav_account_id ?? ''}|path=${this.sanitizeSongPath(item.filePath)}`
+  }
+
+  private sanitizeSongPath(path?: string): string {
+    if (StrUtil.isEmpty(path)) {
+      return ''
+    }
+    const value = path as string
+    if (value.length <= 48) {
+      return value
+    }
+    return `...${value.substring(value.length - 48)}`
+  }
+
   private buildAlbumSelectionLog(items: FindAlbumGroup[], limit: number = 4): string {
     if (items.length === 0) {
       return '[]'
@@ -2190,34 +2414,62 @@ export struct FindView {
     .padding(10)
   }
 
+  @Builder
+  private buildSwipeDeleteAction(item: VideoItem) {
+    Row() {
+      Button('删除')
+        .width(72)
+        .height(52)
+        .fontSize(14)
+        .fontColor(Color.White)
+        .backgroundColor($r('app.color.btn_red'))
+        .borderRadius(14)
+        .clickEffect({ level: ClickEffectLevel.MIDDLE, scale: 0.8 })
+        .onClick(() => {
+          this.openDeleteSongDialog(item)
+        })
+    }
+    .padding({ left: 8, right: 4 })
+  }
+
 
 
   @Builder
   private buildSearchContent() {
-    Scroll() {
-      Column({ space: 12 }) {
-        if (this.isSearchLoading) {
+    List({ space: 12 }) {
+      if (this.isSearchLoading) {
+        ListItem() {
           this.buildSearchLoadingState()
-        } else if (this.searchText.length === 0 && this.searchHistoryItems.length > 0) {
+        }
+      } else if (this.searchText.length === 0 && this.searchHistoryItems.length > 0) {
+        ListItem() {
           this.SearchHistoryView()
-        } else if (this.searchText.length === 0) {
+        }
+      } else if (this.searchText.length === 0) {
+        ListItem() {
           this.buildSectionEmptyState('搜索本地和网盘歌曲', '输入歌曲名、歌手或专辑名后即可开始搜索', false)
-        } else if (this.searchResults.length === 0) {
+        }
+      } else if (this.searchResults.length === 0) {
+        ListItem() {
           this.buildSectionEmptyState('没有找到匹配歌曲', '试试其他关键词,结果会同时包含本地和网盘歌曲', false)
-        } else {
+        }
+      } else {
+        ListItem() {
           Row() {
             Text('搜索结果')
-              .fontSize(15)
-              .fontWeight(FontWeight.Bold)
-              .fontColor(this.getPrimaryTextColor())
+              .fontSize(14)
+              .fontWeight(FontWeight.Medium)
+              .fontColor(this.getSecondaryTextColor())
             Blank()
             Text(`${this.searchResults.length} 首`)
               .fontSize(12)
               .fontColor(this.getSecondaryTextColor())
           }
           .width('100%')
+        }
 
-          ForEach(this.searchResults, (item: VideoItem, index: number) => {
+        ForEach(this.searchResults, (item: VideoItem, index: number) => {
+          ListItem() {
             PointLightContentButton({
               pointColor: this.themeColor,
               buttonColor: this.getCardBackgroundColor(),
@@ -2232,15 +2484,29 @@ export struct FindView {
               .onClick(() => {
                 this.emitPlaylistPlay('find-search', '发现搜索', this.searchResults, index)
               })
-          }, getFindSongKey)
-        }
+          }
+          .swipeAction(this.canDeleteSong(item)
+            ? {
+                end: this.buildSwipeDeleteAction(item),
+                edgeEffect: SwipeEdgeEffect.None
+              }
+            : {})
+          .bindContextMenu(this.SongContextMenuBuilder(item), ResponseType.LongPress,
+            {
+              preview: MenuPreviewMode.IMAGE
+            })
+          .bindContextMenu(this.SongContextMenuBuilder(item), ResponseType.RightClick,
+            {
+              preview: MenuPreviewMode.IMAGE
+            })
+        }, getFindSongKey)
       }
-      .width('100%')
-      .padding({ left: 12, right: 12, top: this.topSafeHeight +65, bottom: this.bottomSafeHeight + 96 })
     }
+    .width('100%')
+    .height('100%')
+    .padding({ left: 12, right: 12, top: this.topSafeHeight + 65, bottom: this.bottomSafeHeight + 96 })
     .scrollBar(BarState.Off)
     .edgeEffect(EdgeEffect.Spring)
-    .width('100%')
     .layoutWeight(1)
   }
 
@@ -3211,6 +3477,9 @@ export struct FindView {
           onSongTap: (index: number, songs?: VideoItem[]) => {
             this.handleAlbumSongTap(index, songs)
           },
+          onPlayNextSong: (song: VideoItem) => {
+            this.handleAddSongToNextPlay(song)
+          },
           onFavoriteSong: (song: VideoItem) => {
             void this.handleAlbumFavoriteSong(song)
           },

+ 55 - 0
entry/src/main/ets/view/LocalMusic.ets

@@ -17095,8 +17095,53 @@ export struct LocalMusic {
     return sanitized;
   }
 
+  private sanitizePlaylistDebugPath(path?: string): string {
+    if (!path) {
+      return '';
+    }
+    return path.length > 48 ? `...${path.substring(path.length - 48)}` : path;
+  }
+
+  private buildSongDebugItem(item?: VideoItem): string {
+    if (!item) {
+      return 'unknown';
+    }
+    const title = item.name || item.fileName || '未知歌曲';
+    return `${title}|type=${item.type}|acc=${item.webdav_account_id || ''}|path=${this.sanitizePlaylistDebugPath(item.filePath)}`;
+  }
+
+  private buildSongListDebugLog(items: VideoItem[], limit: number = 5): 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(this.buildSongDebugItem(items[i]));
+    }
+    return `[${parts.join('; ')}](${items.length})`;
+  }
+
+  private buildPathListDebugLog(paths: string[], limit: number = 5): string {
+    if (!paths || paths.length === 0) {
+      return '[]';
+    }
+    const parts: string[] = [];
+    const maxCount = Math.min(limit, paths.length);
+    for (let i = 0; i < maxCount; i++) {
+      parts.push(this.sanitizePlaylistDebugPath(paths[i]));
+    }
+    return `[${parts.join('; ')}](${paths.length})`;
+  }
+
   private async dispatchPlaylistPlayRequest(playlistData: PlaylistPlayRequest): Promise<void> {
     clearPendingPlaylistPlay();
+    Logger.info(
+      TAG,
+      `[remote-debug] dispatchPlaylistPlayRequest id=${playlistData.playlistId}, name=${playlistData.playlistName}, ` +
+      `songCount=${playlistData.songCount}, startIndex=${playlistData.startIndex}, playType=${playlistData.playType ?? -1}, ` +
+      `paths=${this.buildPathListDebugLog(playlistData.songFilePaths)}`
+    );
     if (playlistData.playType !== undefined) {
       this.applyRequestedPlayType(playlistData.playType);
     }
@@ -20534,6 +20579,11 @@ export struct LocalMusic {
         const videoItems = getFindVideoItems();
         const currentPlayIndex = getFindCurrentPlayIndex();
         Logger.info(`heanup 发现页歌曲列表长度: ${videoItems.length}, 索引: ${currentPlayIndex}`)
+        Logger.info(
+          TAG,
+          `[remote-debug] handlePlaylistPlayRequest find-memory playlist=${playlistId}, loaded=${videoItems.length}, ` +
+          `declaredTotal=${totalCount}, sample=${this.buildSongListDebugLog(videoItems)}`
+        )
         if (videoItems && videoItems.length > 0) {
           if (isJump) {
             this.setShowPlayTrue()
@@ -20654,6 +20704,11 @@ export struct LocalMusic {
     }
 
     Logger.info('heanup finishLoadingPlaylist', `保存歌单歌曲路径列表,已加载=${this.currentPlaylistSongFilePaths.length}, 服务端总数=${actualTotal}`);
+    Logger.info(
+      TAG,
+      `[remote-debug] finishLoadingPlaylist playlist=${playlistName}, loaded=${songs.length}, actualTotal=${actualTotal}, ` +
+      `startIndex=${startIndex}, sample=${this.buildSongListDebugLog(songs)}`
+    );
 
     if (songs[startIndex]) {
       // 确保当前播放的歌曲也更新到存储

+ 54 - 0
entry/src/ohosTest/ets/test/PlayQueueHelper.test.ets

@@ -0,0 +1,54 @@
+import { describe, it, expect } from '@ohos/hypium'
+import { VideoItem } from '../../../main/ets/viewmodel/VideoItem'
+import { insertSongToNextPlayQueue, QueueInsertStatus } from '../../../main/ets/common/util/PlayQueueHelper'
+
+function createSong(name: string, filePath: string): VideoItem {
+  const song = new VideoItem(name, filePath, filePath, 0, 0, '2026-03-01 00:00:00')
+  song.fileName = `${name}.flac`
+  return song
+}
+
+export default function playQueueHelperTest() {
+  describe('PlayQueueHelperTest', () => {
+    it('startPlaybackWhenQueueEmpty', 0, () => {
+      const nextSong = createSong('Next', '/music/next.flac')
+
+      const result = insertSongToNextPlayQueue([], '', -1, nextSong)
+
+      expect(result.status).assertEqual(QueueInsertStatus.START_PLAY)
+      expect(result.queue.map(item => item.filePath).join(',')).assertEqual('/music/next.flac')
+      expect(result.currentIndex).assertEqual(0)
+      expect(result.insertedIndex).assertEqual(0)
+    })
+
+    it('moveExistingSongBehindCurrentSong', 0, () => {
+      const queue = [
+        createSong('A', '/music/a.flac'),
+        createSong('B', '/music/b.flac'),
+        createSong('C', '/music/c.flac'),
+        createSong('D', '/music/d.flac')
+      ]
+
+      const result = insertSongToNextPlayQueue(queue, '/music/c.flac', 2, queue[0])
+
+      expect(result.status).assertEqual(QueueInsertStatus.INSERTED)
+      expect(result.queue.map(item => item.filePath).join(',')).assertEqual('/music/b.flac,/music/c.flac,/music/a.flac,/music/d.flac')
+      expect(result.currentIndex).assertEqual(1)
+      expect(result.insertedIndex).assertEqual(2)
+    })
+
+    it('keepQueueUnchangedWhenSongAlreadyPlaying', 0, () => {
+      const queue = [
+        createSong('A', '/music/a.flac'),
+        createSong('B', '/music/b.flac')
+      ]
+
+      const result = insertSongToNextPlayQueue(queue, '/music/b.flac', 1, queue[1])
+
+      expect(result.status).assertEqual(QueueInsertStatus.ALREADY_PLAYING)
+      expect(result.queue.map(item => item.filePath).join(',')).assertEqual('/music/a.flac,/music/b.flac')
+      expect(result.currentIndex).assertEqual(1)
+      expect(result.insertedIndex).assertEqual(1)
+    })
+  })
+}