Bläddra i källkod

实现网盘的边缓存边播放功能

onecold 7 månader sedan
förälder
incheckning
444a4294d7

+ 244 - 84
entry/src/main/ets/common/network/EmbyFileCache.ets

@@ -1,113 +1,273 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
 import Logger from '../util/Logger';
-import { ServerLogUtil } from '../util/ServerLogUtil';
+import FileManager from '../util/FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
 import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
-import { findExistingCacheFile, RemoteCacheType, resolveCacheFilePath, writeBufferToFile } from './RemoteSongCache';
-import { RcpSocket } from '../util/RcpSocketUtil';
-import { EmbyApi } from './EmbyApi';
-
-const TAG = 'EmbyFileCache';
-const embyApi = new EmbyApi();
-
-async function findEmbyCache(request: RemoteCacheRequest): Promise<string | null> {
-  const accountId = request.account.id;
-  if (!accountId) {
-    await ServerLogUtil.warn(TAG, 'Emby账号ID缺失,无法查找缓存');
-    return null;
-  }
-  const remotePath = request.remotePath;
-  if (!remotePath || remotePath.length === 0) {
-    await ServerLogUtil.warn(TAG, 'Emby远程路径为空,无法查找缓存');
-    return null;
+import { embyApi } from './EmbyApi';
+import { fileIo } from '@kit.CoreFileKit';
+import { util } from '@kit.ArkTS';
+
+const TAG = 'heanup EmbyFileCache';
+const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB per chunk
+
+interface EmbyCacheOptions extends RemoteCacheRequest {
+  account: WebDavAccount;
+  itemId: string;
+  streamUrl: string;
+  fileSize?: number; // 添加文件大小字段
+}
+
+class EmbyCacheStrategy implements RemoteCacheStrategy {
+  readonly type: RemoteCacheType = RemoteCacheType.EMBY;
+
+  async ensure(options: RemoteCacheRequest): Promise<string> {
+    const embyOptions = options as EmbyCacheOptions;
+    return ensureEmbyFileCachedInternal(
+      embyOptions.account,
+      embyOptions.itemId,
+      embyOptions.streamUrl,
+      embyOptions.fileSize
+    );
   }
-  return findExistingCacheFile(RemoteCacheType.EMBY, accountId, remotePath);
 }
 
-async function triggerEmbyDownload(request: RemoteCacheRequest): Promise<string | null> {
-  const account = request.account;
-  const accountId = account.id;
-  if (!accountId) {
-    await ServerLogUtil.error(TAG, 'Emby账号ID缺失,无法触发下载');
-    return null;
+async function ensureEmbyFileCachedInternal(
+  account: WebDavAccount,
+  itemId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  // 使用 itemId 作为相对路径
+  const normalizedRelative = normalizeCacheRelativePath(itemId);
+  const pathInfo = await resolveCacheFilePath(
+    RemoteCacheType.EMBY,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = pathInfo.cachePath;
+
+  // 检查缓存是否存在
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    } else {
+      Logger.info(TAG, `Emby 文件已缓存: ${cachePath}, size=${size}`);
+      return cachePath;
+    }
   }
-  const itemId = request.remotePath;
-  if (!itemId || itemId.length === 0) {
-    await ServerLogUtil.error(TAG, 'Emby项目ID为空,无法触发下载');
-    return null;
+
+  if (!exists) {
+    try {
+      Logger.info(TAG, `开始下载 Emby 文件: itemId=${itemId}, 到 ${cachePath}`);
+
+      // 使用传入的文件大小,如果没有则尝试通过HEAD获取
+      let finalFileSize = fileSize || 0;
+      if (!finalFileSize || finalFileSize <= 0) {
+        try {
+          const headRequest = http.createHttp();
+          const headResponse = await headRequest.request(streamUrl, {
+            method: http.RequestMethod.HEAD,
+            connectTimeout: 30000,
+            readTimeout: 30000,
+            expectDataType: http.HttpDataType.STRING
+          });
+          const contentLength = headResponse.header['Content-Length'] as string;
+          if (contentLength) {
+            finalFileSize = parseInt(contentLength, 10);
+            Logger.info(TAG, `从HEAD获取Emby文件大小: ${finalFileSize} 字节 (${(finalFileSize / 1024 / 1024).toFixed(2)}MB)`);
+          }
+          headRequest.destroy();
+        } catch (error) {
+          Logger.warn(TAG, `HEAD请求获取Emby文件大小失败,将使用分片下载: ${(error as Error).message}`);
+        }
+      } else {
+        Logger.info(TAG, `使用传入的Emby文件大小: ${finalFileSize} 字节 (${(finalFileSize / 1024 / 1024).toFixed(2)}MB)`);
+      }
+
+      // 使用分片下载
+      await downloadEmbyFileChunked(streamUrl, cachePath, finalFileSize, account);
+      Logger.info(TAG, `Emby 文件下载完成: ${cachePath}`);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `Emby 文件下载失败: ${err.message}`);
+      throw err;
+    }
   }
 
-  await ServerLogUtil.info(TAG, `开始下载Emby文件: itemId=${itemId}, accountId=${accountId}`);
+  return cachePath;
+}
 
+async function downloadEmbyFileChunked(
+  streamUrl: string,
+  localPath: string,
+  totalSize: number,
+  account?: WebDavAccount
+): Promise<void> {
   try {
-    const pathInfo = await resolveCacheFilePath(RemoteCacheType.EMBY, accountId, itemId);
-    const cachePath = pathInfo.cachePath;
-
-    // 构建流媒体URL
-    const streamUrl = await embyApi.buildStreamUrl(account, itemId);
-    await ServerLogUtil.info(TAG, `Emby流媒体URL: ${streamUrl}`);
-
-    // 获取认证头
-    const authHeaders = await embyApi.getAuthHeaders(account);
-    const headers: Record<string, string> = {};
-    authHeaders.forEach((value, key) => {
-      headers[key] = value;
-    });
-
-    // 使用 RcpSocket 下载文件
-    const rcpSocket = new RcpSocket();
-    const arrayBuffer = await rcpSocket.RcpSendGetStream(
-      streamUrl,
-      account.account,
-      account.password,
-      account.enableHttps,
-      undefined
-    );
+    Logger.info(TAG, `开始分片下载 Emby 文件: 文件大小: ${totalSize} 字节`);
 
-    if (!arrayBuffer || arrayBuffer.byteLength === 0) {
-      await ServerLogUtil.error(TAG, 'Emby下载失败:未获取到数据');
-      return null;
-    }
+    const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+    try {
+      if (totalSize === 0) {
+        Logger.info(TAG, `文件大小未知,使用一次性下载`);
+        await downloadSingleEmbyChunk(streamUrl, 0, -1, file, account);
+      } else {
+        let downloadedSize: number = 0;
+        let chunkIndex: number = 0;
+
+        while (downloadedSize < totalSize) {
+          const rangeStart = downloadedSize;
+          const rangeEnd = Math.min(downloadedSize + CHUNK_SIZE - 1, totalSize - 1);
+          const chunkSizeBytes = rangeEnd - rangeStart + 1;
+          const progressPercent = Math.round((downloadedSize / totalSize) * 100);
+
+          Logger.info(TAG, `Emby 下载进度: ${progressPercent}% (${downloadedSize}/${totalSize} 字节) 分片 ${chunkIndex + 1}`);
+
+          const chunkStartTime = Date.now();
+          await downloadSingleEmbyChunk(streamUrl, rangeStart, rangeEnd, file, account);
+          const chunkEndTime = Date.now();
+          const chunkDuration = (chunkEndTime - chunkStartTime) / 1000;
 
-    // 写入缓存文件
-    await writeBufferToFile(arrayBuffer, cachePath);
-    await ServerLogUtil.info(TAG, `Emby文件下载并缓存成功: ${cachePath}, 大小: ${arrayBuffer.byteLength} 字节`);
+          downloadedSize = rangeEnd + 1;
+          chunkIndex++;
 
-    return cachePath;
+          Logger.info(TAG, `Emby 分片 ${chunkIndex} 下载完成,耗时 ${chunkDuration.toFixed(2)} 秒`);
+        }
+      }
+
+      // 关闭文件后再获取大小
+      fileIo.closeSync(file);
+      const finalSize = await FileManager.getFileSize(localPath);
+      Logger.info(TAG, `Emby 下载完成,最终文件大小: ${finalSize} 字节`);
+    } finally {
+      // 文件已在上面关闭,这里不需要再关闭
+    }
   } catch (error) {
     const err = error as Error;
-    await ServerLogUtil.error(TAG, `Emby下载失败: ${err.message}`);
-    Logger.error(TAG, `下载Emby文件时发生错误: ${err.message}`);
-    return null;
+    Logger.error(TAG, `Emby 分片下载失败: ${err.message}`);
+    throw err;
   }
 }
 
-class EmbyFileCacheStrategy implements RemoteCacheStrategy {
-  readonly type = RemoteCacheType.EMBY;
+async function downloadSingleEmbyChunk(
+  streamUrl: string,
+  rangeStart: number,
+  rangeEnd: number,
+  file: fileIo.File,
+  account?: WebDavAccount
+): Promise<void> {
+  const httpRequest = http.createHttp();
+
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000, // 10分钟超时,适应慢速网络
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+
+    // 如果有 account,添加 Emby 认证头
+    if (account) {
+      try {
+        const authHeaders = await embyApi.getAuthHeaders(account);
+        const headerMap: Record<string, string> = {};
+        authHeaders.forEach((value: string, key: string) => {
+          headerMap[key] = value;
+        });
+        options.header = headerMap;
+      } catch (error) {
+        Logger.warn(TAG, `获取Emby认证头失败: ${(error as Error).message}`);
+      }
+    }
+
+    if (rangeEnd >= 0) {
+      if (!options.header) {
+        options.header = {};
+      }
+      options.header['Range'] = `bytes=${rangeStart}-${rangeEnd}`;
+      const rangeSize = rangeEnd - rangeStart + 1;
+      Logger.info(TAG, `开始下载 Emby 分片: 范围 ${rangeStart}-${rangeEnd} (${(rangeSize / 1024 / 1024).toFixed(2)}MB)`);
+    } else {
+      Logger.info(TAG, `开始一次性下载完整 Emby 文件`);
+    }
 
-  async ensure(request: RemoteCacheRequest): Promise<string> {
-    await ServerLogUtil.info(TAG, `Emby缓存策略: 查找或下载文件`);
+    const startTime = Date.now();
+    const response = await httpRequest.request(streamUrl, options);
+    const endTime = Date.now();
+    const duration = (endTime - startTime) / 1000;
+
+    if (response.responseCode !== 200 && response.responseCode !== 206) {
+      throw new Error(`Emby 下载失败 HTTP ${response.responseCode}`);
+    }
 
-    // 1. 查找已存在的缓存
-    const existingCache = await findEmbyCache(request);
-    if (existingCache) {
-      await ServerLogUtil.info(TAG, `使用已有Emby缓存: ${existingCache}`);
-      return existingCache;
+    if (response.result instanceof ArrayBuffer) {
+      const arrayBuffer = response.result as ArrayBuffer;
+      if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+        throw new Error('Emby 下载的文件为空');
+      }
+      // 指定写入位置,确保每个分片写入到正确的位置
+      await fileIo.write(file.fd, arrayBuffer, {
+        offset: rangeStart,
+        length: arrayBuffer.byteLength
+      });
+      const sizeMB = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2);
+      Logger.info(TAG, `Emby 分片数据写入完成: ${arrayBuffer.byteLength} 字节 (${sizeMB}MB),偏移量 ${rangeStart},耗时 ${duration.toFixed(2)} 秒`);
     }
+  } catch (error) {
+    const err = error as Error;
+    Logger.error(TAG, `Emby 分片下载失败 (${rangeStart}-${rangeEnd}): ${err.message}`);
+    throw err;
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+async function downloadEmbyFile(streamUrl: string, localPath: string): Promise<void> {
+  const httpRequest = http.createHttp();
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000, // 10分钟超时,适合大文件下载
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
 
-    // 2. 缓存不存在,触发下载
-    await ServerLogUtil.info(TAG, `Emby缓存未命中,触发后台下载`);
-    const downloadedPath = await triggerEmbyDownload(request);
+    const response = await httpRequest.request(streamUrl, options);
 
-    if (!downloadedPath) {
-      // 下载失败,返回原始流媒体URL
-      await ServerLogUtil.warn(TAG, 'Emby下载失败,回退到流式播放');
-      return await embyApi.buildStreamUrl(request.account, request.remotePath);
+    if (response.responseCode !== 200) {
+      throw new Error(`Emby 下载失败 HTTP ${response.responseCode}`);
     }
 
-    return downloadedPath;
+    const arrayBuffer = response.result as ArrayBuffer;
+    if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+      throw new Error('Emby 下载的文件为空');
+    }
+
+    const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+    await fileIo.write(file.fd, arrayBuffer);
+    fileIo.closeSync(file);
+  } finally {
+    httpRequest.destroy();
   }
 }
 
-export const embyFileCacheStrategy = new EmbyFileCacheStrategy();
+RemoteCacheManager.registerStrategy(new EmbyCacheStrategy());
 
-// 注册策略
-RemoteCacheManager.registerStrategy(embyFileCacheStrategy);
+export async function ensureEmbyFileCached(
+  account: WebDavAccount,
+  itemId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  return RemoteCacheManager.ensureCached(RemoteCacheType.EMBY, {
+    account,
+    itemId,
+    streamUrl,
+    fileSize
+  } as EmbyCacheOptions);
+}

+ 273 - 0
entry/src/main/ets/common/network/JellyfinFileCache.ets

@@ -0,0 +1,273 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+import FileManager from '../util/FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
+import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { jellyfinApi } from './JellyfinApi';
+import { fileIo } from '@kit.CoreFileKit';
+import { util } from '@kit.ArkTS';
+
+const TAG = 'heanup JellyfinFileCache';
+const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB per chunk
+
+interface JellyfinCacheOptions extends RemoteCacheRequest {
+  account: WebDavAccount;
+  itemId: string;
+  streamUrl: string;
+  fileSize?: number; // 添加文件大小字段
+}
+
+class JellyfinCacheStrategy implements RemoteCacheStrategy {
+  readonly type: RemoteCacheType = RemoteCacheType.JELLYFIN;
+
+  async ensure(options: RemoteCacheRequest): Promise<string> {
+    const jellyOptions = options as JellyfinCacheOptions;
+    return ensureJellyfinFileCachedInternal(
+      jellyOptions.account,
+      jellyOptions.itemId,
+      jellyOptions.streamUrl,
+      jellyOptions.fileSize
+    );
+  }
+}
+
+async function ensureJellyfinFileCachedInternal(
+  account: WebDavAccount,
+  itemId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  // 使用 itemId 作为相对路径
+  const normalizedRelative = normalizeCacheRelativePath(itemId);
+  const pathInfo = await resolveCacheFilePath(
+    RemoteCacheType.JELLYFIN,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = pathInfo.cachePath;
+
+  // 检查缓存是否存在
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    } else {
+      Logger.info(TAG, `Jellyfin 文件已缓存: ${cachePath}, size=${size}`);
+      return cachePath;
+    }
+  }
+
+  if (!exists) {
+    try {
+      Logger.info(TAG, `开始下载 Jellyfin 文件: itemId=${itemId}, 到 ${cachePath}`);
+
+      // 使用传入的文件大小,如果没有则尝试通过HEAD获取
+      let finalFileSize = fileSize || 0;
+      if (!finalFileSize || finalFileSize <= 0) {
+        try {
+          const headRequest = http.createHttp();
+          const headResponse = await headRequest.request(streamUrl, {
+            method: http.RequestMethod.HEAD,
+            connectTimeout: 30000,
+            readTimeout: 30000,
+            expectDataType: http.HttpDataType.STRING
+          });
+          const contentLength = headResponse.header['Content-Length'] as string;
+          if (contentLength) {
+            finalFileSize = parseInt(contentLength, 10);
+            Logger.info(TAG, `从HEAD获取Jellyfin文件大小: ${finalFileSize} 字节 (${(finalFileSize / 1024 / 1024).toFixed(2)}MB)`);
+          }
+          headRequest.destroy();
+        } catch (error) {
+          Logger.warn(TAG, `HEAD请求获取Jellyfin文件大小失败,将使用分片下载: ${(error as Error).message}`);
+        }
+      } else {
+        Logger.info(TAG, `使用传入的Jellyfin文件大小: ${finalFileSize} 字节 (${(finalFileSize / 1024 / 1024).toFixed(2)}MB)`);
+      }
+
+      // 使用分片下载
+      await downloadJellyfinFileChunked(streamUrl, cachePath, finalFileSize, account);
+      Logger.info(TAG, `Jellyfin 文件下载完成: ${cachePath}`);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `Jellyfin 文件下载失败: ${err.message}`);
+      throw err;
+    }
+  }
+
+  return cachePath;
+}
+
+async function downloadJellyfinFileChunked(
+  streamUrl: string,
+  localPath: string,
+  totalSize: number,
+  account?: WebDavAccount
+): Promise<void> {
+  try {
+    Logger.info(TAG, `开始分片下载 Jellyfin 文件: 文件大小: ${totalSize} 字节`);
+
+    const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+    try {
+      if (totalSize === 0) {
+        Logger.info(TAG, `文件大小未知,使用一次性下载`);
+        await downloadSingleJellyfinChunk(streamUrl, 0, -1, file, account);
+      } else {
+        let downloadedSize: number = 0;
+        let chunkIndex: number = 0;
+
+        while (downloadedSize < totalSize) {
+          const rangeStart = downloadedSize;
+          const rangeEnd = Math.min(downloadedSize + CHUNK_SIZE - 1, totalSize - 1);
+          const chunkSizeBytes = rangeEnd - rangeStart + 1;
+          const progressPercent = Math.round((downloadedSize / totalSize) * 100);
+
+          Logger.info(TAG, `Jellyfin 下载进度: ${progressPercent}% (${downloadedSize}/${totalSize} 字节) 分片 ${chunkIndex + 1}`);
+
+          const chunkStartTime = Date.now();
+          await downloadSingleJellyfinChunk(streamUrl, rangeStart, rangeEnd, file, account);
+          const chunkEndTime = Date.now();
+          const chunkDuration = (chunkEndTime - chunkStartTime) / 1000;
+
+          downloadedSize = rangeEnd + 1;
+          chunkIndex++;
+
+          Logger.info(TAG, `Jellyfin 分片 ${chunkIndex} 下载完成,耗时 ${chunkDuration.toFixed(2)} 秒`);
+        }
+      }
+
+      // 关闭文件后再获取大小
+      fileIo.closeSync(file);
+      const finalSize = await FileManager.getFileSize(localPath);
+      Logger.info(TAG, `Jellyfin 下载完成,最终文件大小: ${finalSize} 字节`);
+    } finally {
+      // 文件已在上面关闭,这里不需要再关闭
+    }
+  } catch (error) {
+    const err = error as Error;
+    Logger.error(TAG, `Jellyfin 分片下载失败: ${err.message}`);
+    throw err;
+  }
+}
+
+async function downloadSingleJellyfinChunk(
+  streamUrl: string,
+  rangeStart: number,
+  rangeEnd: number,
+  file: fileIo.File,
+  account?: WebDavAccount
+): Promise<void> {
+  const httpRequest = http.createHttp();
+
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000, // 10分钟超时,适应慢速网络
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+
+    // 如果有 account,添加 Jellyfin 认证头
+    if (account) {
+      try {
+        const authHeaders = await jellyfinApi.getAuthHeaders(account);
+        const headerMap: Record<string, string> = {};
+        authHeaders.forEach((value: string, key: string) => {
+          headerMap[key] = value;
+        });
+        options.header = headerMap;
+      } catch (error) {
+        Logger.warn(TAG, `获取Jellyfin认证头失败: ${(error as Error).message}`);
+      }
+    }
+
+    if (rangeEnd >= 0) {
+      if (!options.header) {
+        options.header = {};
+      }
+      options.header['Range'] = `bytes=${rangeStart}-${rangeEnd}`;
+      const rangeSize = rangeEnd - rangeStart + 1;
+      Logger.info(TAG, `开始下载 Jellyfin 分片: 范围 ${rangeStart}-${rangeEnd} (${(rangeSize / 1024 / 1024).toFixed(2)}MB)`);
+    } else {
+      Logger.info(TAG, `开始一次性下载完整 Jellyfin 文件`);
+    }
+
+    const startTime = Date.now();
+    const response = await httpRequest.request(streamUrl, options);
+    const endTime = Date.now();
+    const duration = (endTime - startTime) / 1000;
+
+    if (response.responseCode !== 200 && response.responseCode !== 206) {
+      throw new Error(`Jellyfin 下载失败 HTTP ${response.responseCode}`);
+    }
+
+    if (response.result instanceof ArrayBuffer) {
+      const arrayBuffer = response.result as ArrayBuffer;
+      if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+        throw new Error('Jellyfin 下载的文件为空');
+      }
+      // 指定写入位置,确保每个分片写入到正确的位置
+      await fileIo.write(file.fd, arrayBuffer, {
+        offset: rangeStart,
+        length: arrayBuffer.byteLength
+      });
+      const sizeMB = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2);
+      Logger.info(TAG, `Jellyfin 分片数据写入完成: ${arrayBuffer.byteLength} 字节 (${sizeMB}MB),偏移量 ${rangeStart},耗时 ${duration.toFixed(2)} 秒`);
+    }
+  } catch (error) {
+    const err = error as Error;
+    Logger.error(TAG, `Jellyfin 分片下载失败 (${rangeStart}-${rangeEnd}): ${err.message}`);
+    throw err;
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+async function downloadJellyfinFile(streamUrl: string, localPath: string): Promise<void> {
+  const httpRequest = http.createHttp();
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000, // 10分钟超时,适合大文件下载
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+
+    const response = await httpRequest.request(streamUrl, options);
+
+    if (response.responseCode !== 200) {
+      throw new Error(`Jellyfin 下载失败 HTTP ${response.responseCode}`);
+    }
+
+    const arrayBuffer = response.result as ArrayBuffer;
+    if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+      throw new Error('Jellyfin 下载的文件为空');
+    }
+
+    const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+    await fileIo.write(file.fd, arrayBuffer);
+    fileIo.closeSync(file);
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+RemoteCacheManager.registerStrategy(new JellyfinCacheStrategy());
+
+export async function ensureJellyfinFileCached(
+  account: WebDavAccount,
+  itemId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  return RemoteCacheManager.ensureCached(RemoteCacheType.JELLYFIN, {
+    account,
+    itemId,
+    streamUrl,
+    fileSize
+  } as JellyfinCacheOptions);
+}

+ 253 - 0
entry/src/main/ets/common/network/NavidromeFileCache.ets

@@ -0,0 +1,253 @@
+import { http } from '@kit.NetworkKit';
+import { WebDavAccount } from '../../viewmodel/WebDavAccount';
+import Logger from '../util/Logger';
+import FileManager from '../util/FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from './RemoteSongCache';
+import { RemoteCacheManager, RemoteCacheRequest, RemoteCacheStrategy } from './RemoteCacheManager';
+import { navidromeApi } from './NavidromeApi';
+import { fileIo } from '@kit.CoreFileKit';
+import { util } from '@kit.ArkTS';
+
+const TAG = 'heanup NavidromeFileCache';
+const CHUNK_SIZE = 4 * 1024 * 1024; // 4MB per chunk
+
+interface NavidromeCacheOptions extends RemoteCacheRequest {
+  account: WebDavAccount;
+  songId: string;
+  streamUrl: string;
+  fileSize?: number; // 添加文件大小字段
+}
+
+class NavidromeCacheStrategy implements RemoteCacheStrategy {
+  readonly type: RemoteCacheType = RemoteCacheType.NAVIDROME;
+
+  async ensure(options: RemoteCacheRequest): Promise<string> {
+    const navOptions = options as NavidromeCacheOptions;
+    return ensureNavidromeFileCachedInternal(
+      navOptions.account,
+      navOptions.songId,
+      navOptions.streamUrl,
+      navOptions.fileSize
+    );
+  }
+}
+
+async function ensureNavidromeFileCachedInternal(
+  account: WebDavAccount,
+  songId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  // 使用 songId 作为相对路径
+  const normalizedRelative = normalizeCacheRelativePath(songId);
+  const pathInfo = await resolveCacheFilePath(
+    RemoteCacheType.NAVIDROME,
+    account.id?.toString(),
+    normalizedRelative
+  );
+  const cachePath = pathInfo.cachePath;
+
+  // 检查缓存是否存在
+  let exists = await FileManager.isExist(cachePath);
+  if (exists) {
+    const size = await FileManager.getFileSize(cachePath);
+    if (size <= 0) {
+      await FileManager.deleteFile(cachePath);
+      exists = false;
+    } else {
+      Logger.info(TAG, `Navidrome 文件已缓存: ${cachePath}, size=${size}`);
+      return cachePath;
+    }
+  }
+
+  if (!exists) {
+    try {
+      Logger.info(TAG, `开始下载 Navidrome 文件: songId=${songId}, 到 ${cachePath}`);
+
+      // 使用传入的文件大小,如果没有则尝试通过HEAD获取
+      let finalFileSize = fileSize || 0;
+      if (!finalFileSize || finalFileSize <= 0) {
+        try {
+          const headRequest = http.createHttp();
+          const headResponse = await headRequest.request(streamUrl, {
+            method: http.RequestMethod.HEAD,
+            connectTimeout: 30000,
+            readTimeout: 30000,
+            expectDataType: http.HttpDataType.STRING
+          });
+          const contentLength = headResponse.header['Content-Length'] as string;
+          if (contentLength) {
+            finalFileSize = parseInt(contentLength, 10);
+            Logger.info(TAG, `从HEAD获取文件大小: ${finalFileSize} 字节 (${(finalFileSize / 1024 / 1024).toFixed(2)}MB)`);
+          }
+          headRequest.destroy();
+        } catch (error) {
+          Logger.warn(TAG, `HEAD请求获取文件大小失败,将使用分片下载: ${(error as Error).message}`);
+        }
+      } else {
+        Logger.info(TAG, `使用传入的文件大小: ${finalFileSize} 字节 (${(finalFileSize / 1024 / 1024).toFixed(2)}MB)`);
+      }
+
+      // 使用分片下载
+      await downloadNavidromeFileChunked(streamUrl, cachePath, finalFileSize);
+      Logger.info(TAG, `Navidrome 文件下载完成: ${cachePath}`);
+    } catch (error) {
+      await FileManager.deleteFile(cachePath);
+      const err = error as Error;
+      Logger.error(TAG, `Navidrome 文件下载失败: ${err.message}`);
+      throw err;
+    }
+  }
+
+  return cachePath;
+}
+
+async function downloadNavidromeFileChunked(streamUrl: string, localPath: string, totalSize: number): Promise<void> {
+  try {
+    Logger.info(TAG, `开始分片下载 Navidrome 文件: 文件大小: ${totalSize} 字节`);
+
+    const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+    try {
+      if (totalSize === 0) {
+        Logger.info(TAG, `文件大小未知,使用一次性下载`);
+        await downloadSingleNavidromeChunk(streamUrl, 0, -1, file);
+      } else {
+        let downloadedSize: number = 0;
+        let chunkIndex: number = 0;
+
+        while (downloadedSize < totalSize) {
+          const rangeStart = downloadedSize;
+          const rangeEnd = Math.min(downloadedSize + CHUNK_SIZE - 1, totalSize - 1);
+          const chunkSizeBytes = rangeEnd - rangeStart + 1;
+          const progressPercent = Math.round((downloadedSize / totalSize) * 100);
+
+          Logger.info(TAG, `Navidrome 下载进度: ${progressPercent}% (${downloadedSize}/${totalSize} 字节) 分片 ${chunkIndex + 1}`);
+
+          const chunkStartTime = Date.now();
+          await downloadSingleNavidromeChunk(streamUrl, rangeStart, rangeEnd, file);
+          const chunkEndTime = Date.now();
+          const chunkDuration = (chunkEndTime - chunkStartTime) / 1000;
+
+          downloadedSize = rangeEnd + 1;
+          chunkIndex++;
+
+          Logger.info(TAG, `Navidrome 分片 ${chunkIndex} 下载完成,耗时 ${chunkDuration.toFixed(2)} 秒`);
+        }
+      }
+
+      // 关闭文件后再获取大小
+      fileIo.closeSync(file);
+      const finalSize = await FileManager.getFileSize(localPath);
+      Logger.info(TAG, `Navidrome 下载完成,最终文件大小: ${finalSize} 字节`);
+    } finally {
+      // 文件已在上面关闭,这里不需要再关闭
+    }
+  } catch (error) {
+    const err = error as Error;
+    Logger.error(TAG, `Navidrome 分片下载失败: ${err.message}`);
+    throw err;
+  }
+}
+
+async function downloadSingleNavidromeChunk(
+  streamUrl: string,
+  rangeStart: number,
+  rangeEnd: number,
+  file: fileIo.File
+): Promise<void> {
+  const httpRequest = http.createHttp();
+
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000, // 10分钟超时,适应慢速网络
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+
+    if (rangeEnd >= 0) {
+      if (!options.header) {
+        options.header = {};
+      }
+      options.header['Range'] = `bytes=${rangeStart}-${rangeEnd}`;
+      const rangeSize = rangeEnd - rangeStart + 1;
+      Logger.info(TAG, `开始下载 Navidrome 分片: 范围 ${rangeStart}-${rangeEnd} (${(rangeSize / 1024 / 1024).toFixed(2)}MB)`);
+    } else {
+      Logger.info(TAG, `开始一次性下载完整 Navidrome 文件`);
+    }
+
+    const startTime = Date.now();
+    const response = await httpRequest.request(streamUrl, options);
+    const endTime = Date.now();
+    const duration = (endTime - startTime) / 1000;
+
+    if (response.responseCode !== 200 && response.responseCode !== 206) {
+      throw new Error(`Navidrome 下载失败 HTTP ${response.responseCode}`);
+    }
+
+    if (response.result instanceof ArrayBuffer) {
+      const arrayBuffer = response.result as ArrayBuffer;
+      if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+        throw new Error('Navidrome 下载的文件为空');
+      }
+      // 指定写入位置,确保每个分片写入到正确的位置
+      await fileIo.write(file.fd, arrayBuffer, {
+        offset: rangeStart,
+        length: arrayBuffer.byteLength
+      });
+      const sizeMB = (arrayBuffer.byteLength / 1024 / 1024).toFixed(2);
+      Logger.info(TAG, `Navidrome 分片数据写入完成: ${arrayBuffer.byteLength} 字节 (${sizeMB}MB),偏移量 ${rangeStart},耗时 ${duration.toFixed(2)} 秒`);
+    }
+  } catch (error) {
+    const err = error as Error;
+    Logger.error(TAG, `Navidrome 分片下载失败 (${rangeStart}-${rangeEnd}): ${err.message}`);
+    throw err;
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+async function downloadNavidromeFile(streamUrl: string, localPath: string): Promise<void> {
+  const httpRequest = http.createHttp();
+  try {
+    const options: http.HttpRequestOptions = {
+      method: http.RequestMethod.GET,
+      connectTimeout: 30000,
+      readTimeout: 600000, // 10分钟超时,适合大文件下载
+      expectDataType: http.HttpDataType.ARRAY_BUFFER
+    };
+
+    const response = await httpRequest.request(streamUrl, options);
+
+    if (response.responseCode !== 200) {
+      throw new Error(`Navidrome 下载失败 HTTP ${response.responseCode}`);
+    }
+
+    const arrayBuffer = response.result as ArrayBuffer;
+    if (!arrayBuffer || arrayBuffer.byteLength === 0) {
+      throw new Error('Navidrome 下载的文件为空');
+    }
+
+    const file = fileIo.openSync(localPath, fileIo.OpenMode.CREATE | fileIo.OpenMode.READ_WRITE | fileIo.OpenMode.TRUNC);
+    await fileIo.write(file.fd, arrayBuffer);
+    fileIo.closeSync(file);
+  } finally {
+    httpRequest.destroy();
+  }
+}
+
+RemoteCacheManager.registerStrategy(new NavidromeCacheStrategy());
+
+export async function ensureNavidromeFileCached(
+  account: WebDavAccount,
+  songId: string,
+  streamUrl: string,
+  fileSize?: number
+): Promise<string> {
+  return RemoteCacheManager.ensureCached(RemoteCacheType.NAVIDROME, {
+    account,
+    songId,
+    streamUrl,
+    fileSize
+  } as NavidromeCacheOptions);
+}

+ 2 - 0
entry/src/main/ets/common/network/RemoteSongCache.ets

@@ -16,6 +16,7 @@ export enum RemoteCacheType {
   SMB = 'smb',
   FTP = 'ftp',
   BAIDU = 'baidu',
+  NAVIDROME = 'navidrome',
   JELLYFIN = 'jellyfin',
   EMBY = 'emby'
 }
@@ -193,6 +194,7 @@ export async function clearAllRemoteCaches(): Promise<void> {
   await clearRemoteCacheByAccount(RemoteCacheType.SMB);
   await clearRemoteCacheByAccount(RemoteCacheType.FTP);
   await clearRemoteCacheByAccount(RemoteCacheType.BAIDU);
+  await clearRemoteCacheByAccount(RemoteCacheType.NAVIDROME);
   await clearRemoteCacheByAccount(RemoteCacheType.JELLYFIN);
   await clearRemoteCacheByAccount(RemoteCacheType.EMBY);
 }

+ 799 - 0
entry/src/main/ets/common/util/RemotePlayerUtil.ets

@@ -0,0 +1,799 @@
+import { Context } from '@kit.AbilityKit';
+import { http } from '@kit.NetworkKit';
+import { fileIo } from '@kit.CoreFileKit';
+import Logger from './Logger';
+import { PreferencesUtil, ArrayUtil } from '@pura/harmony-utils';
+import { VideoItem } from '../../viewmodel/VideoItem';
+import { RemoteDriveManager } from './RemoteDriveManager';
+import { navidromeApi } from '../network/NavidromeApi';
+import { jellyfinApi } from '../network/JellyfinApi';
+import { embyApi } from '../network/EmbyApi';
+import { ensureNavidromeFileCached } from '../network/NavidromeFileCache';
+import { ensureJellyfinFileCached } from '../network/JellyfinFileCache';
+import { ensureEmbyFileCached } from '../network/EmbyFileCache';
+import FileManager from './FileManager';
+import { RemoteCacheType, resolveCacheFilePath, normalizeCacheRelativePath } from '../network/RemoteSongCache';
+import { RemoteCacheManager } from '../network/RemoteCacheManager';
+import { ensureSmbFileStreaming, SmbStreamingMeta } from '../network/SmbFileCache';
+import { ensureBaiduFileCached } from '../network/BaiduFileCache';
+import { findWebDavCacheIfExists, triggerWebDavCacheDownload } from '../network/WebDavFileCache';
+import { WebDavUrlUtil } from './WebDavUrlUtil';
+import { Song } from '../../viewmodel/Song';
+import { repairAudioMetadata } from './MusicTagUtils';
+import { ServerLogUtil } from './ServerLogUtil';
+import { CommonConstants } from '../constants/CommonConstants';
+
+const TAG = 'heanup RemotePlayerUtil';
+
+export interface MetadataExtractionOptions {
+  context?: Context;
+  autoParseMusicName?: boolean;
+  extractCover?: boolean;
+  extractLyric?: boolean;
+  extractAudioInfo?: boolean;
+}
+
+export interface WorkerEditMusicResult {
+  success: boolean;
+  error?: string;
+  coverResult?: boolean;
+  needRefreshList?: boolean;
+  metadataResult?: boolean;
+}
+
+export interface LocalEditMusicPayload {
+  context: Context;
+  item: VideoItem;
+  titleStr: string;
+  artistStr: string;
+  ablumStr: string;
+  yearStr: string;
+  genreStr: string;
+  trackStr: string;
+  albumArtistStr: string;
+  composerStr: string;
+  lyricistStr: string;
+  commentStr: string;
+  discStr: string;
+  lyricConStr: string;
+  imagePathStr: string;
+  currentPath: string;
+  packName: string;
+  modeType: number;
+  autoParseMusicName: boolean;
+}
+
+
+
+
+export interface WebDavMetadataUpdatePayload {
+  filePath: string;
+  pixelMapPath?: string;
+  name?: string;
+  artist?: string;
+}
+
+
+
+
+/**
+ * 克隆 VideoItem 对象
+ * @param item 要克隆的 VideoItem
+ * @returns 新的 VideoItem 副本
+ */
+export function cloneVideoItem(item: VideoItem): VideoItem {
+  return JSON.parse(JSON.stringify(item)) as VideoItem;
+}
+
+/**
+ * 提前缓存下一首歌曲
+ * @param songList 当前播放列表
+ * @param currentIndex 当前播放索引
+ * @param playType 播放模式 (0:连续播放 1:单片重复播放 2:正常播放 3:随机播放)
+ */
+export async function preloadNextSongIfNeeded(
+  songList: VideoItem[],
+  currentIndex: number,
+  playType: number
+): Promise<void> {
+  const preloadEnabled = PreferencesUtil.getBooleanSync('preload_next_song', true);
+  if (!preloadEnabled) {
+    Logger.info(TAG, '提前缓存下一首功能未启用');
+    return;
+  }
+
+  if (!ArrayUtil.isNotEmpty(songList) || songList.length <= 1) {
+    Logger.info(TAG, '歌曲列表为空或只有一首,无需提前缓存');
+    return;
+  }
+
+  // 计算下一首索引
+  let nextIndex = 0;
+  if (playType === 3) {
+    // 随机播放模式,随机选择一首
+    nextIndex = Math.floor(Math.random() * songList.length);
+  } else {
+    // 顺序或循环播放模式
+    if (currentIndex === songList.length - 1) {
+      nextIndex = 0; // 列表末尾,回到开头
+    } else {
+      nextIndex = currentIndex + 1;
+    }
+  }
+
+  // 如果下一首就是当前正在播放的(单曲循环),则不缓存
+  if (nextIndex === currentIndex && playType === 1) {
+    Logger.info(TAG, '单曲循环模式,无需提前缓存');
+    return;
+  }
+
+  const nextSong = songList[nextIndex];
+  Logger.info(TAG, `准备提前缓存下一首: ${nextSong.name}, type: ${nextSong.type}`);
+
+  try {
+    // Navidrome
+    if (isNavidromeType(nextSong.type) && nextSong.webdav_account_id) {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
+      if (account) {
+        const navSongId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
+        const streamUrl = await navidromeApi.buildStreamUrl(account, navSongId);
+
+        void (async () => {
+          try {
+            Logger.info(TAG, `后台提前缓存Navidrome下一首: ${navSongId}`);
+            await ensureNavidromeFileCached(account, navSongId, streamUrl, nextSong.videoSize);
+            Logger.info(TAG, `Navidrome下一首提前缓存完成`);
+          } catch (error) {
+            const err = error as Error;
+            Logger.warn(TAG, `Navidrome下一首提前缓存失败: ${err.message}`);
+          }
+        })();
+      }
+    }
+
+    // Jellyfin
+    if (isJellyfinType(nextSong.type) && nextSong.webdav_account_id) {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
+      if (account) {
+        const itemId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
+        const streamUrl = await jellyfinApi.buildStreamUrl(account, itemId);
+
+        void (async () => {
+          try {
+            Logger.info(TAG, `后台提前缓存Jellyfin下一首: ${itemId}`);
+            await ensureJellyfinFileCached(account, itemId, streamUrl, nextSong.videoSize);
+            Logger.info(TAG, `Jellyfin下一首提前缓存完成`);
+          } catch (error) {
+            const err = error as Error;
+            Logger.warn(TAG, `Jellyfin下一首提前缓存失败: ${err.message}`);
+          }
+        })();
+      }
+    }
+
+    // Emby
+    if (isEmbyType(nextSong.type) && nextSong.webdav_account_id) {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(nextSong.webdav_account_id);
+      if (account) {
+        const itemId = nextSong.remote_rel_path || nextSong.id || nextSong.filePath;
+        const streamUrl = await embyApi.buildStreamUrl(account, itemId);
+
+        void (async () => {
+          try {
+            Logger.info(TAG, `后台提前缓存Emby下一首: ${itemId}`);
+            await ensureEmbyFileCached(account, itemId, streamUrl, nextSong.videoSize);
+            Logger.info(TAG, `Emby下一首提前缓存完成`);
+          } catch (error) {
+            const err = error as Error;
+            Logger.warn(TAG, `Emby下一首提前缓存失败: ${err.message}`);
+          }
+        })();
+      }
+    }
+  } catch (error) {
+    const err = error as Error;
+    Logger.warn(TAG, `提前缓存下一首失败: ${err.message}`);
+  }
+}
+
+/**
+ * 为歌曲设置播放URL
+ * @param song 歌曲对象
+ * @param options 元数据提取选项
+ * @returns 播放URL
+ */
+export async function setVideoUrlForSong(
+  song: VideoItem,
+  options?: MetadataExtractionOptions
+): Promise<string> {
+  const metadataOptions = options ?? {};
+  Logger.info(TAG, `setVideoUrlForSong start -> name=${song.name}, type=${song.type}, path=${song.filePath}, fsId=${song.baiduFsId || song.id}`);
+
+  const scheduleMetadataExtractionFromCache = async (
+    targetSong: VideoItem,
+    cachePath: string,
+    opts: MetadataExtractionOptions,
+    expectedSize?: number
+  ): Promise<void> => {
+    try {
+      const size = await FileManager.getFileSize(cachePath);
+      if (expectedSize && size < expectedSize * 0.95) {
+        Logger.warn(TAG, `缓存文件大小不匹配,跳过元数据提取: ${cachePath}`);
+        return;
+      }
+      Logger.info(TAG, `从缓存提取元数据: ${cachePath}`);
+    } catch (error) {
+      const err = error as Error;
+      Logger.warn(TAG, `从缓存提取元数据失败: ${err.message}`);
+    }
+  };
+
+  // WebDAV 类型处理
+  if (isWebDavType(song.type) && song.webdav_account_id) {
+    try {
+      Logger.info(TAG, `WebDAV 路径信息 remote_rel_path: ${song.remote_rel_path}`);
+      Logger.info(TAG, `WebDAV 路径信息 filePath: ${song.filePath}`);
+      const relativePath = song.remote_rel_path || song.filePath;
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+
+      if (account) {
+        const cachePath = await findWebDavCacheIfExists(account, relativePath);
+        if (cachePath) {
+          Logger.info(TAG, `WebDAV 缓存命中,直接播放: ${cachePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
+          return cachePath;
+        }
+
+        const fullUrlFromAccount = WebDavUrlUtil.buildFullUrl(account, relativePath);
+        if (fullUrlFromAccount) {
+          const sanitizedUrl = sanitizePlaybackUrl(fullUrlFromAccount);
+          const downloadPromise = triggerWebDavCacheDownload(account, relativePath, sanitizedUrl);
+          if (downloadPromise) {
+            downloadPromise.then((completedPath?: string | null) => {
+              if (completedPath) {
+                void scheduleMetadataExtractionFromCache(song, completedPath, metadataOptions, song.videoSize);
+              }
+            }).catch((error: Error) => {
+              Logger.error(TAG, `WebDAV 缓存后台下载失败: ${error.message}`);
+            });
+          }
+          Logger.info(TAG, `WebDAV 启动异步缓存: ${relativePath}`);
+          return sanitizedUrl;
+        }
+      }
+
+      const fallbackUrl = await WebDavUrlUtil.buildFullUrlByAccountId(song.webdav_account_id, relativePath);
+      if (fallbackUrl) {
+        Logger.warn(TAG, `WebDAV 使用全局查询到的URL: ${fallbackUrl}`);
+        return sanitizePlaybackUrl(fallbackUrl);
+      }
+
+      Logger.error(TAG, `WebDAV URL构建失败,使用原始路径: ${relativePath}`);
+      return sanitizePlaybackUrl(relativePath);
+    } catch (error) {
+      Logger.error(TAG, `WebDAV URL构建出错: ${(error as Error).message}`);
+      const fallbackPath = song.remote_rel_path || song.filePath;
+      return sanitizePlaybackUrl(fallbackPath);
+    }
+  }
+
+  // Navidrome 类型处理
+  if (isNavidromeType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('Navidrome账号不可用');
+      }
+      const navSongId = song.remote_rel_path || song.id || song.filePath;
+
+      // 检查缓存是否存在
+      const normalizedRelative = normalizeCacheRelativePath(navSongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.NAVIDROME,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const cachePath = pathInfo.cachePath;
+      const exists = await FileManager.isExist(cachePath);
+      if (exists) {
+        const size = await FileManager.getFileSize(cachePath);
+        if (size > 0) {
+          // 检查文件是否完整(允许5%的误差,因为分片边界可能导致实际大小略有不同)
+          if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+            Logger.warn(TAG, `Navidrome 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
+            await FileManager.deleteFile(cachePath);
+          } else {
+            Logger.info(TAG, `Navidrome 缓存命中,直接播放: ${cachePath}, size=${size}`);
+            void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
+            return cachePath;
+          }
+        }
+      }
+
+      // 缓存不存在,获取流媒体URL并开始后台缓存
+      const streamUrl = await navidromeApi.buildStreamUrl(account, navSongId);
+      void ServerLogUtil.info(TAG, `流地址构建成功: ${streamUrl}`);
+      void ServerLogUtil.debug(TAG, `播放songId=${navSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
+
+      // 立即返回流媒体URL,同时后台异步缓存
+      const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
+      void (async () => {
+        try {
+          Logger.info(TAG, `后台开始缓存Navidrome文件: ${navSongId}`);
+          void ServerLogUtil.info('NavidromeStream', `开始缓存: songId=${navSongId}`);
+          const cachedFilePath = await ensureNavidromeFileCached(
+            account,
+            navSongId,
+            streamUrl,
+            song.videoSize
+          );
+          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('NavidromeStream', `缓存完成: ${cachedFilePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
+        } catch (cacheError) {
+          const cacheErr = cacheError as Error;
+          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('NavidromeStream', `缓存失败: ${cacheErr.message}`);
+        }
+      })();
+
+      return sanitizedUrl;
+    } catch (error) {
+      const err = error as Error;
+      void ServerLogUtil.error(TAG, `URL构建失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
+  // Jellyfin 类型处理
+  if (isJellyfinType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('Jellyfin账号不可用');
+      }
+      const jellyfinSongId = song.remote_rel_path || song.id || song.filePath;
+
+      // 检查缓存是否存在
+      const normalizedRelative = normalizeCacheRelativePath(jellyfinSongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.JELLYFIN,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const cachePath = pathInfo.cachePath;
+      const exists = await FileManager.isExist(cachePath);
+      if (exists) {
+        const size = await FileManager.getFileSize(cachePath);
+        if (size > 0) {
+          // 检查文件是否完整(允许5%的误差)
+          if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+            Logger.warn(TAG, `Jellyfin 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
+            await FileManager.deleteFile(cachePath);
+          } else {
+            Logger.info(TAG, `Jellyfin 缓存命中,直接播放: ${cachePath}, size=${size}`);
+            void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
+            return cachePath;
+          }
+        }
+      }
+
+      // 缓存不存在,获取流媒体URL并开始后台缓存
+      const streamUrl = await jellyfinApi.buildStreamUrl(account, jellyfinSongId);
+      void ServerLogUtil.info(TAG, `Jellyfin 流地址构建成功: ${streamUrl}`);
+      void ServerLogUtil.debug(TAG, `播放songId=${jellyfinSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
+
+      // 立即返回流媒体URL,同时后台异步缓存
+      const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
+      void (async () => {
+        try {
+          Logger.info(TAG, `后台开始缓存Jellyfin文件: ${jellyfinSongId}`);
+          void ServerLogUtil.info('JellyfinStream', `开始缓存: itemId=${jellyfinSongId}`);
+          const cachedFilePath = await ensureJellyfinFileCached(
+            account,
+            jellyfinSongId,
+            streamUrl,
+            song.videoSize
+          );
+          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('JellyfinStream', `缓存完成: ${cachedFilePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
+        } catch (cacheError) {
+          const cacheErr = cacheError as Error;
+          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('JellyfinStream', `缓存失败: ${cacheErr.message}`);
+        }
+      })();
+
+      return sanitizedUrl;
+    } catch (error) {
+      const err = error as Error;
+      void ServerLogUtil.error(TAG, `Jellyfin URL构建失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
+  // Emby 类型处理
+  if (isEmbyType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('Emby账号不可用');
+      }
+      const embySongId = song.remote_rel_path || song.id || song.filePath;
+
+      // 检查缓存是否存在
+      const normalizedRelative = normalizeCacheRelativePath(embySongId);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.EMBY,
+        account.id?.toString(),
+        normalizedRelative
+      );
+      const cachePath = pathInfo.cachePath;
+      const exists = await FileManager.isExist(cachePath);
+      if (exists) {
+        const size = await FileManager.getFileSize(cachePath);
+        if (size > 0) {
+          // 检查文件是否完整(允许5%的误差)
+          if (song.videoSize > 0 && size < song.videoSize * 0.95) {
+            Logger.warn(TAG, `Emby 缓存文件不完整,删除重试: ${cachePath}, size=${size}, expect=${song.videoSize}`);
+            await FileManager.deleteFile(cachePath);
+          } else {
+            Logger.info(TAG, `Emby 缓存命中,直接播放: ${cachePath}, size=${size}`);
+            void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
+            return cachePath;
+          }
+        }
+      }
+
+      // 缓存不存在,获取流媒体URL并开始后台缓存
+      const streamUrl = await embyApi.buildStreamUrl(account, embySongId);
+      void ServerLogUtil.info(TAG, `Emby 流地址构建成功: ${streamUrl}`);
+      void ServerLogUtil.debug(TAG, `播放songId=${embySongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
+
+      // 立即返回流媒体URL,同时后台异步缓存
+      const sanitizedUrl = sanitizePlaybackUrl(streamUrl);
+      void (async () => {
+        try {
+          Logger.info(TAG, `后台开始缓存Emby文件: ${embySongId}`);
+          void ServerLogUtil.info('EmbyStream', `开始缓存: itemId=${embySongId}`);
+          const cachedFilePath = await ensureEmbyFileCached(
+            account,
+            embySongId,
+            streamUrl,
+            song.videoSize
+          );
+          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('EmbyStream', `缓存完成: ${cachedFilePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
+        } catch (cacheError) {
+          const cacheErr = cacheError as Error;
+          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('EmbyStream', `缓存失败: ${cacheErr.message}`);
+        }
+      })();
+
+      return sanitizedUrl;
+    } catch (error) {
+      const err = error as Error;
+      void ServerLogUtil.error(TAG, `Emby URL构建失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
+  // SMB 类型处理
+  if (isSmbType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('SMB账号不可用');
+      }
+      const relativePath = extractSmbRelativePath(song, account.smbShare);
+      const pathInfo = await resolveCacheFilePath(
+        RemoteCacheType.SMB,
+        account.id?.toString(),
+        relativePath
+      );
+      const streamMeta: SmbStreamingMeta = {
+        fileSize: song.videoSize,
+        mimeType: song.mimeType,
+        fileName: song.fileName || song.name
+      };
+      const streamingUrl = await ensureSmbFileStreaming(account, pathInfo.normalizedRelative, streamMeta);
+      Logger.info(TAG, `SMB 流地址: ${streamingUrl}`);
+      void scheduleMetadataExtractionFromCache(song, pathInfo.cachePath, metadataOptions, song.videoSize);
+      const shouldBypassSanitize = streamingUrl.startsWith('http://127.0.0.1:');
+      return shouldBypassSanitize ? streamingUrl : sanitizePlaybackUrl(streamingUrl);
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `SMB 流式播放失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
+  // FTP 类型处理
+  if (isFtpType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('FTP账号不可用');
+      }
+      const relativePath = extractFtpRelativePath(song);
+      const cachedPath = await RemoteCacheManager.ensureCached(RemoteCacheType.FTP, {
+        account,
+        remotePath: relativePath
+      });
+      Logger.info(TAG, `FTP 缓存路径: ${cachedPath}`);
+      void scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions, song.videoSize);
+      void scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions, song.videoSize);
+      return cachedPath;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `FTP 缓存失败: ${err.message}`);
+      throw new Error(err.message);
+    }
+  }
+
+  // 百度网盘类型处理
+  if (isBaiduType(song.type) && song.webdav_account_id) {
+    try {
+      const manager = RemoteDriveManager.getInstance();
+      const account = await manager.getWebDavAccountById(song.webdav_account_id);
+      if (!account) {
+        throw new Error('百度网盘账户不可用');
+      }
+      Logger.info(TAG, `百度网盘播放准备 - accountId: ${account.id}, fsId: ${song.baiduFsId || song.id}`);
+      const sanitizedAccount = ServerLogUtil.sanitizeAccount(account);
+      void ServerLogUtil.info('BaiduStream', `准备播放百度歌曲: account=${sanitizedAccount}, fsId=${song.baiduFsId || song.id}`);
+
+      const fsId = song.baiduFsId || song.id;
+      if (!fsId) {
+        throw new Error('缺少百度网盘文件fs_id');
+      }
+
+      // 优先使用remote_rel_path,回退到文件名
+      const relativePath = song.remote_rel_path || song.name || song.fileName || song.filePath;
+      const streamPath = song.remote_rel_path;
+      const cachedPath = await resolveCacheFilePath(
+        RemoteCacheType.BAIDU,
+        account.id?.toString(),
+        relativePath
+      );
+
+      const fileExists = await FileManager.isExist(cachedPath.cachePath);
+      if (fileExists) {
+        const fileSize = await FileManager.getFileSize(cachedPath.cachePath);
+        if (fileSize > 0) {
+          if (song.videoSize > 0 && fileSize < song.videoSize * 0.9) {
+            Logger.warn(TAG, `百度网盘缓存文件不完整,准备删除重试: ${cachedPath.cachePath}, size=${fileSize}, expect=${song.videoSize}`);
+            await FileManager.deleteFile(cachedPath.cachePath);
+          } else {
+            Logger.info(TAG, `百度网盘文件已缓存: ${cachedPath.cachePath}`);
+            void scheduleMetadataExtractionFromCache(song, cachedPath.cachePath, metadataOptions, song.videoSize);
+            return cachedPath.cachePath;
+          }
+        }
+      }
+
+      // 缓存不存在或无效,获取文件信息(包含大小和下载链接)
+      Logger.info(TAG, `百度网盘文件未缓存,准备获取文件信息: ${relativePath}`);
+      const fileInfo = await manager.getBaiduFileInfo(account, fsId, streamPath);
+      Logger.info(TAG, `百度网盘文件信息获取成功: 大小=${fileInfo.size}字节,流地址已获取`);
+      void ServerLogUtil.info('BaiduStream', `音频流URL获取成功: ${fileInfo.streamUrl}`);
+
+      // 立即返回媒体流地址,开始播放
+      Logger.info(TAG, `返回音频流链接进行播放,同时后台缓存: ${fileInfo.streamUrl}`);
+
+      // 异步进行缓存,不阻塞播放
+      void (async () => {
+        try {
+          Logger.info(TAG, `后台开始缓存百度网盘文件: ${relativePath},大小: ${fileInfo.size}字节`);
+          void ServerLogUtil.info('BaiduStream', `开始缓存百度文件: account=${sanitizedAccount}, path=${relativePath}`);
+          const cachedFilePath = await ensureBaiduFileCached(
+            account,
+            fsId,
+            relativePath,
+            fileInfo.dlink,
+            fileInfo.size
+          );
+          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
+          void ServerLogUtil.info('BaiduStream', `缓存完成: ${cachedFilePath}`);
+          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
+        } catch (cacheError) {
+          const cacheErr = cacheError as Error;
+          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
+          void ServerLogUtil.error('BaiduStream', `缓存失败: ${cacheErr.message}`);
+        }
+      })();
+
+      return fileInfo.streamUrl;
+    } catch (error) {
+      const err = error as Error;
+      Logger.error(TAG, `百度网盘播放链接获取失败: ${err.message}`);
+      throw err;
+    }
+  }
+
+  // 检查缺少账号ID的各种类型
+  if (isWebDavType(song.type)) {
+    Logger.warn(TAG, `WebDAV歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
+    return sanitizePlaybackUrl(song.filePath);
+  }
+  if (isSmbType(song.type)) {
+    Logger.warn(TAG, `SMB歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
+    return song.filePath;
+  }
+  if (isFtpType(song.type)) {
+    Logger.warn(TAG, `FTP歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
+    return song.filePath;
+  }
+  if (isBaiduType(song.type)) {
+    throw new Error('百度网盘歌曲缺少webdav_account_id,无法构建播放链接');
+  }
+  if (isNavidromeType(song.type)) {
+    throw new Error('Navidrome歌曲缺少webdav_account_id,无法构建播放链接');
+  }
+  if (isJellyfinType(song.type)) {
+    throw new Error('Jellyfin歌曲缺少webdav_account_id,无法构建播放链接');
+  }
+  if (isEmbyType(song.type)) {
+    throw new Error('Emby歌曲缺少webdav_account_id,无法构建播放链接');
+  }
+
+  Logger.info(TAG, `setVideoUrlForSong fallback直接返回原始路径: ${song.filePath}`);
+  return song.filePath;
+}
+
+export function sanitizePlaybackUrl(rawUrl: string | null | undefined): string {
+  if (!rawUrl) {
+    return '';
+  }
+  try {
+    const decoded = decodeURI(rawUrl);
+    const encoded = encodeURI(decoded);
+    // encodeURI 不会处理 #,手动转义避免播放器把剩余内容当作片段
+    return encoded.replace(/#/g, '%23');
+  } catch (error) {
+    const err = error as Error;
+    Logger.warn(TAG, `URL 编码失败,使用降级方案: ${err?.message}`);
+    return rawUrl.replace(/ /g, '%20').replace(/#/g, '%23');
+  }
+}
+
+
+//排序类型
+export function getTypeOrder(type: number) {
+  switch (type) {
+    case CommonConstants.TYPE_IS_DIR:
+      return 1; // First
+    case CommonConstants.TYPE_IS_CSJAD:
+      return 2; // Middle
+    case CommonConstants.TYPE_LOCAL:
+      return 3; // Last
+    case CommonConstants.TYPE_WEBDAV:
+    case CommonConstants.TYPE_SMB:
+    case CommonConstants.TYPE_NAVIDROME:
+    case CommonConstants.TYPE_FTP:
+    case CommonConstants.TYPE_BAIDU:
+    case CommonConstants.TYPE_JELLYFIN:
+    case CommonConstants.TYPE_EMBY:
+      return 3;
+    default:
+      return 4; // Unknown types, if any, go last
+  }
+}
+
+export function isWebDavType(type: number): boolean {
+  return type === CommonConstants.TYPE_WEBDAV;
+}
+
+export function isSmbType(type: number): boolean {
+  return type === CommonConstants.TYPE_SMB;
+}
+
+export function isNavidromeType(type: number | undefined): boolean {
+  return type !== undefined && type === CommonConstants.TYPE_NAVIDROME;
+}
+
+export function isFtpType(type: number): boolean {
+  return type === CommonConstants.TYPE_FTP;
+}
+
+export function isBaiduType(type: number): boolean {
+  return type === CommonConstants.TYPE_BAIDU;
+}
+
+
+
+export function isJellyfinType(type: number | undefined): boolean {
+  return type !== undefined && type === CommonConstants.TYPE_JELLYFIN;
+}
+
+export function isEmbyType(type: number | undefined): boolean {
+  return type !== undefined && type === CommonConstants.TYPE_EMBY;
+}
+
+export function isRemoteCloudType(type: number): boolean {
+  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type) || isFtpType(type) || isBaiduType(type) || isJellyfinType(type) || isEmbyType(type);
+}
+
+export function getShareNameFromFilePath(filePath?: string): string | undefined {
+  if (!filePath) {
+    return undefined;
+  }
+  const match = filePath.match(/^smb:\/\/[^/]+\/([^/]+)/i);
+  return match && match[1] ? match[1] : undefined;
+}
+
+export function removeSharePrefixFromPath(path: string, primaryShare?: string, secondaryShare?: string): string {
+  const uniqueShares: string[] = [];
+  [primaryShare, secondaryShare].forEach((name?: string) => {
+    const cleaned = name ? name.replace(/^\/+|\/+$/g, '') : '';
+    if (cleaned.length > 0 && !uniqueShares.some(item => item.toLowerCase() === cleaned.toLowerCase())) {
+      uniqueShares.push(cleaned);
+    }
+  });
+  if (uniqueShares.length === 0) {
+    return path;
+  }
+  let result = path;
+  for (let i = 0; i < uniqueShares.length; i++) {
+    const share = uniqueShares[i];
+    const lowerPath = result.toLowerCase();
+    const lowerShare = share.toLowerCase();
+    if (lowerPath === lowerShare) {
+      result = '';
+      break;
+    }
+    const prefix = `${lowerShare}/`;
+    if (lowerPath.startsWith(prefix)) {
+      result = result.substring(share.length + 1);
+      break;
+    }
+  }
+  return result;
+}
+
+export function extractSmbRelativePath(song: VideoItem, shareNameOverride?: string): string {
+  const shareFromFilePath = getShareNameFromFilePath(song.filePath);
+  const shareName = shareNameOverride ?? shareFromFilePath;
+  if (song.remote_rel_path && song.remote_rel_path.length > 0) {
+    const cleaned = song.remote_rel_path.replace(/^\/+/, '');
+    return removeSharePrefixFromPath(cleaned, shareName, shareFromFilePath);
+  }
+  if (!song.filePath) {
+    return '';
+  }
+  const match = song.filePath.match(/^smb:\/\/[^/]+\/([^/]+)(\/.*)?$/i);
+  if (!match) {
+    return song.filePath.replace(/^\/+/, '');
+  }
+  const remainder = match[2] ? match[2].replace(/^\/+/, '') : '';
+  if (remainder.length === 0) {
+    return '';
+  }
+  return remainder;
+}
+
+export function extractFtpRelativePath(song: VideoItem): string {
+  if (song.remote_rel_path && song.remote_rel_path.length > 0) {
+    return song.remote_rel_path.replace(/^\/+/, '');
+  }
+  if (!song.filePath) {
+    return '';
+  }
+  const match = song.filePath.match(/^ftp:\/\/[^/]+(?::\d+)?(\/.*)$/i);
+  if (match && match[1]) {
+    return match[1].replace(/^\/+/, '');
+  }
+  return song.filePath.replace(/^\/+/, '');
+}

+ 32 - 0
entry/src/main/ets/pages/SettingPage.ets

@@ -52,6 +52,7 @@ export struct SettingPage {
   static readonly WEBDAV_UPLOAD_RETRY_COUNT: string = 'webdavUploadRetryCount';
   @State fastForwardSeconds: string = '10'
   @State isShowBackFast: boolean = true//快进快退按钮
+  @State preloadNextSong: boolean = true // 提前缓存下一首
   @State isClearingCache: boolean = false
   @Consume isShowDrawer: boolean;
   @Consume offsetX: number;
@@ -300,6 +301,7 @@ export struct SettingPage {
     this.isLongNameRoLL = PreferencesUtil.getBooleanSync('isLongNameRoLL', true)
     this.isShowBackFast  = PreferencesUtil.getBooleanSync('isShowBackFast', false)
     this.fastForwardSeconds  = PreferencesUtil.getStringSync('fastForwardSeconds', '10')
+    this.preloadNextSong = PreferencesUtil.getBooleanSync('preload_next_song', true)
     this.isCopyFileToDownLoad = PreferencesUtil.getBooleanSync(SettingPage.IS_COPYFILE_TO_DOWNLOAD, false)
     this.isNoJumpToHome = PreferencesUtil.getBooleanSync('isNoJumpToHome', true)
     this.webdavUploadDuplicateAction = PreferencesUtil.getStringSync(SettingPage.WEBDAV_UPLOAD_DUPLICATE_ACTION, 'skip')
@@ -1573,6 +1575,36 @@ export struct SettingPage {
             .clickEffect({ level: ClickEffectLevel.HEAVY })
             Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
 
+            // 提前缓存下一首
+            Row() {
+              SymbolGlyph($r('sys.symbol.arrow_down_circle'))
+                .fontSize(20)
+                .fontColor([this.themeColor])
+                .alignSelf(ItemAlign.Center)
+                .margin({ left: 15 })
+              Text('提前缓存下一首')
+                .margin({ left: 8 })
+                .fontSize(15)
+                .fontColor(Color.Gray)
+                .fontWeight(480)
+                .layoutWeight(1)
+              Toggle({ type: ToggleType.Switch, isOn: this.preloadNextSong })
+                .selectedColor(this.themeColor)
+                .switchPointColor(Color.White)
+                .margin({ right: 18 })
+                .onChange((checked: boolean) => {
+                  this.preloadNextSong = checked;
+                  PreferencesUtil.put('preload_next_song', this.preloadNextSong)
+                  this.sendChangeEvent()
+                  ToastUtil.showToast(checked ? '已开启提前缓存下一首' : '已关闭提前缓存下一首')
+                })
+                .width(50)
+                .height(30);
+            }
+            .height(55)
+            .clickEffect({ level: ClickEffectLevel.HEAVY })
+            Line().width('100%').height(0.3).backgroundColor($r('app.color.index_tab_unselected_font_color'))
+
             // 长按默认倍数
             Row() {
               SymbolGlyph($r('sys.symbol.timer'))

+ 44 - 597
entry/src/main/ets/view/LocalMusic.ets

@@ -80,24 +80,29 @@ import  MediaTable  from '../common/util/MediaTable';
 import FileDeletionWatcher from '../common/util/FileDeletionWatcher';
 import LyricUtil from '../common/util/LyricUtil';
 import NetAxiosUtil from '../common/util/NetAxiosUtil';
-import ImageUtils from '../common/util/ImageUtils';
 import { ringtone } from '@kit.RingtoneKit';
 import { BreakpointType, BreakpointTypeEnum } from '../common/util/BreakpointSystem';
 import { resourceManager } from '@kit.LocalizationKit';
 import { deviceInfo } from '@kit.BasicServicesKit';
-import { RemoteCacheManager } from '../common/network/RemoteCacheManager';
 import '../common/network/RemoteCacheRegistry';
-import { RemoteCacheType, resolveCacheFilePath } from '../common/network/RemoteSongCache';
-import { findWebDavCacheIfExists, triggerWebDavCacheDownload } from '../common/network/WebDavFileCache';
-import { navidromeApi } from '../common/network/NavidromeApi';
-import { navidromeRestApi } from '../common/network/NavidromeRestApi';
-import { jellyfinApi } from '../common/network/JellyfinApi';
-import { embyApi } from '../common/network/EmbyApi';
 import { ServerLogUtil } from '../common/util/ServerLogUtil';
-import { ensureSmbFileStreaming, SmbStreamingMeta } from '../common/network/SmbFileCache';
-import { ensureBaiduFileCached } from '../common/network/BaiduFileCache';
-import FileManager from '../common/util/FileManager';
 import { lyricService, SongData } from '../common/service/LyricService';
+import {
+  cloneVideoItem,
+  preloadNextSongIfNeeded,
+  setVideoUrlForSong,
+  getTypeOrder,
+  isWebDavType,
+  isSmbType,
+  isNavidromeType,
+  isFtpType,
+  isBaiduType,
+  isJellyfinType,
+  isEmbyType,
+  isRemoteCloudType,
+  WorkerEditMusicResult,
+  WebDavMetadataUpdatePayload
+} from '../common/util/RemotePlayerUtil';
 
 import { HSBColorPicker, HSBColorPickerLayout } from '@keke/color-picker'
 import app from '@system.app';
@@ -146,189 +151,7 @@ function logHiCarWindow(message: string): void {
   void ServerLogUtil.info(HICAR_LOG_TAG, message);
 }
 
-/**
- * 歌单播放事件数据
- */
-interface PlaylistEventData {
-  playlistId: string;
-  playlistName: string;
-  songCount: number;
-  startIndex: number;
-  songFilePaths: string[];
-  isJump: boolean;
-  webDavAuthInfo?: WebDavAuthItem; // 新增WebDAV认证信息
-}
-
-
-
-const DEFAULT_INDEX =
-  ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
-    'X', 'Y', 'Z']
-
-//排序类型
-function getTypeOrder(type: number) {
-  switch (type) {
-    case CommonConstants.TYPE_IS_DIR:
-      return 1; // First
-    case CommonConstants.TYPE_IS_CSJAD:
-      return 2; // Middle
-    case CommonConstants.TYPE_LOCAL:
-      return 3; // Last
-    case CommonConstants.TYPE_WEBDAV:
-    case CommonConstants.TYPE_SMB:
-    case CommonConstants.TYPE_NAVIDROME:
-    case CommonConstants.TYPE_FTP:
-    case CommonConstants.TYPE_BAIDU:
-    case CommonConstants.TYPE_JELLYFIN:
-    case CommonConstants.TYPE_EMBY:
-      return 3;
-    default:
-      return 4; // Unknown types, if any, go last
-  }
-}
-
-function isWebDavType(type: number): boolean {
-  return type === CommonConstants.TYPE_WEBDAV;
-}
-
-function isSmbType(type: number): boolean {
-  return type === CommonConstants.TYPE_SMB;
-}
-
-function isNavidromeType(type: number | undefined): boolean {
-  return type !== undefined && type === CommonConstants.TYPE_NAVIDROME;
-}
-
-function isFtpType(type: number): boolean {
-  return type === CommonConstants.TYPE_FTP;
-}
-
-function isBaiduType(type: number): boolean {
-  return type === CommonConstants.TYPE_BAIDU;
-}
-
-function isJellyfinType(type: number | undefined): boolean {
-  return type !== undefined && type === CommonConstants.TYPE_JELLYFIN;
-}
-
-function isEmbyType(type: number | undefined): boolean {
-  return type !== undefined && type === CommonConstants.TYPE_EMBY;
-}
-
-function isRemoteCloudType(type: number): boolean {
-  return isWebDavType(type) || isSmbType(type) || isNavidromeType(type) || isFtpType(type) || isBaiduType(type) || isJellyfinType(type) || isEmbyType(type);
-}
-
-function getShareNameFromFilePath(filePath?: string): string | undefined {
-  if (!filePath) {
-    return undefined;
-  }
-  const match = filePath.match(/^smb:\/\/[^/]+\/([^/]+)/i);
-  return match && match[1] ? match[1] : undefined;
-}
-
-function removeSharePrefixFromPath(path: string, primaryShare?: string, secondaryShare?: string): string {
-  const uniqueShares: string[] = [];
-  [primaryShare, secondaryShare].forEach((name?: string) => {
-    const cleaned = name ? name.replace(/^\/+|\/+$/g, '') : '';
-    if (cleaned.length > 0 && !uniqueShares.some(item => item.toLowerCase() === cleaned.toLowerCase())) {
-      uniqueShares.push(cleaned);
-    }
-  });
-  if (uniqueShares.length === 0) {
-    return path;
-  }
-  let result = path;
-  for (let i = 0; i < uniqueShares.length; i++) {
-    const share = uniqueShares[i];
-    const lowerPath = result.toLowerCase();
-    const lowerShare = share.toLowerCase();
-    if (lowerPath === lowerShare) {
-      result = '';
-      break;
-    }
-    const prefix = `${lowerShare}/`;
-    if (lowerPath.startsWith(prefix)) {
-      result = result.substring(share.length + 1);
-      break;
-    }
-  }
-  return result;
-}
-
-function extractSmbRelativePath(song: VideoItem, shareNameOverride?: string): string {
-  const shareFromFilePath = getShareNameFromFilePath(song.filePath);
-  const shareName = shareNameOverride ?? shareFromFilePath;
-  if (song.remote_rel_path && song.remote_rel_path.length > 0) {
-    const cleaned = song.remote_rel_path.replace(/^\/+/, '');
-    return removeSharePrefixFromPath(cleaned, shareName, shareFromFilePath);
-  }
-  if (!song.filePath) {
-    return '';
-  }
-  const match = song.filePath.match(/^smb:\/\/[^/]+\/([^/]+)(\/.*)?$/i);
-  if (!match) {
-    return song.filePath.replace(/^\/+/, '');
-  }
-  const remainder = match[2] ? match[2].replace(/^\/+/, '') : '';
-  if (remainder.length === 0) {
-    return '';
-  }
-  return remainder;
-}
-
-function extractFtpRelativePath(song: VideoItem): string {
-  if (song.remote_rel_path && song.remote_rel_path.length > 0) {
-    return song.remote_rel_path.replace(/^\/+/, '');
-  }
-  if (!song.filePath) {
-    return '';
-  }
-  const match = song.filePath.match(/^ftp:\/\/[^/]+(?::\d+)?(\/.*)$/i);
-  if (match && match[1]) {
-    return match[1].replace(/^\/+/, '');
-  }
-  return song.filePath.replace(/^\/+/, '');
-}
-
-const workerInstance = new worker.ThreadWorker("entry/ets/workers/Worker.ets");
-
-interface MetadataExtractionOptions {
-  context?: common.Context;
-  autoParseMusicName?: boolean;
-}
-
-interface WorkerEditMusicResult {
-  success: boolean;
-  error?: string;
-  coverResult?: boolean;
-  needRefreshList?: boolean;
-  metadataResult?: boolean;
-}
-
-interface LocalEditMusicPayload {
-  context: Context;
-  item: VideoItem;
-  titleStr: string;
-  artistStr: string;
-  ablumStr: string;
-  yearStr: string;
-  genreStr: string;
-  trackStr: string;
-  albumArtistStr: string;
-  composerStr: string;
-  lyricistStr: string;
-  commentStr: string;
-  discStr: string;
-  lyricConStr: string;
-  imagePathStr: string;
-  currentPath: string;
-  packName: string;
-  modeType: number;
-  autoParseMusicName: boolean;
-}
-
-interface WorkerMetadataPayload {
+export interface WorkerMetadataPayload {
   originFilePath: string;
   originId?: string;
   bit_rate?: string;
@@ -361,422 +184,36 @@ interface WorkerMetadataPayload {
   error?: string;
 }
 
-interface WebDavMetadataUpdatePayload {
-  filePath: string;
-  pixelMapPath?: string;
-  name?: string;
-  artist?: string;
-}
-
-const metadataExtractionInFlight: Set<string> = new Set();
-const METADATA_RETRY_DELAY_MS = 1500;
-const METADATA_MAX_RETRY = 120;
-
-async function scheduleMetadataExtractionFromCache(
-  song: VideoItem,
-  cachePath: string,
-  options?: MetadataExtractionOptions,
-  expectedSize?: number,
-  retryCount: number = 0
-): Promise<void> {
-  if (!song || !cachePath || !song.filePath || !options || !options.context) {
-    return;
-  }
-  if (!isRemoteCloudType(song.type ?? -1)) {
-    return;
-  }
-  if (metadataExtractionInFlight.has(song.filePath)) {
-    return;
-  }
-  if (song.bit_rate && song.sampleRate && song.duration) {
-    return;
-  }
-  const requiredSize = expectedSize && expectedSize > 0 ? expectedSize : undefined;
-  try {
-    const stat = await fileIo.stat(cachePath);
-    const ready = requiredSize !== undefined ? stat.size >= requiredSize : stat.size > 0;
-    if (!ready) {
-      if (retryCount >= METADATA_MAX_RETRY) {
-        Logger.warn(TAG, `缓存尚未完成,放弃解析: ${cachePath}`);
-        return;
-      }
-      setTimeout(() => {
-        void scheduleMetadataExtractionFromCache(song, cachePath, options, expectedSize, retryCount + 1);
-      }, METADATA_RETRY_DELAY_MS);
-      return;
-    }
-  } catch (error) {
-    if (retryCount >= METADATA_MAX_RETRY) {
-      Logger.warn(TAG, `无法读取缓存文件,放弃解析: ${(error as Error).message}`);
-      return;
-    }
-    setTimeout(() => {
-      void scheduleMetadataExtractionFromCache(song, cachePath, options, expectedSize, retryCount + 1);
-    }, METADATA_RETRY_DELAY_MS);
-    return;
-  }
-
-  metadataExtractionInFlight.add(song.filePath);
-  workerInstance.postMessage({
-    code: 5,
-    data: options.context,
-    data2: cachePath,
-    data3: song.type,
-    data4: {
-      name: song.name,
-      originFilePath: song.filePath,
-      originId: song.id,
-      autoParseMusicName: options.autoParseMusicName ?? false
-    }
-  });
-}
-
-function sanitizePlaybackUrl(rawUrl: string | null | undefined): string {
-  if (!rawUrl) {
-    return '';
-  }
-  try {
-    const decoded = decodeURI(rawUrl);
-    const encoded = encodeURI(decoded);
-    // encodeURI 不会处理 #,手动转义避免播放器把剩余内容当作片段
-    return encoded.replace(/#/g, '%23');
-  } catch (error) {
-    const err = error as Error;
-    Logger.warn(TAG, `URL 编码失败,使用降级方案: ${err?.message}`);
-    return rawUrl.replace(/ /g, '%20').replace(/#/g, '%23');
-  }
-}
-
-function cloneVideoItem(item: VideoItem): VideoItem {
-  const copy = 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
-  );
-  copy.parentPath = item.parentPath;
-  copy.isFav = item.isFav;
-  copy.size = item.size;
-  copy.pixelMapPath = item.pixelMapPath;
-  copy.artist = item.artist;
-  copy.album = item.album;
-  copy.duration = item.duration;
-  copy.mimeType = item.mimeType;
-  copy.sampleRate = item.sampleRate;
-  copy.trackCount = item.trackCount;
-  copy.lastPlayedStr = item.lastPlayedStr;
-  copy.playCount = item.playCount;
-  copy.lyricContent = item.lyricContent;
-  copy.md5Str = item.md5Str;
-  copy.extra_json = item.extra_json;
-  copy.pyStr = item.pyStr;
-  copy.bit_rate = item.bit_rate;
-  copy.probe_score = item.probe_score;
-  copy.year = item.year;
-  copy.nb_streams = item.nb_streams;
-  copy.nb_programs = item.nb_programs;
-  copy.genre = item.genre;
-  copy.track = item.track;
-  copy.bits_per_raw_sample = item.bits_per_raw_sample;
-  copy.channels = item.channels;
-  copy.channel_layout = item.channel_layout;
-  copy.start_time = item.start_time;
-  copy.ALBUMARTIST = item.ALBUMARTIST;
-  copy.COMPOSER = item.COMPOSER;
-  copy.LYRICIST = item.LYRICIST;
-  copy.COMMENT = item.COMMENT;
-  copy.disc = item.disc;
-  copy.webdav_account_id = item.webdav_account_id;
-  copy.remote_rel_path = item.remote_rel_path;
-  return copy;
-}
-
 /**
- * 异步设置videoUrl的辅助方法,处理WebDAV URL的构建
- * @param song 歌曲对象
- * @returns Promise<string> 完整的URL
- */
-
-/**
- * 设置videoUrl,通过webdav_account_id设置完整的 URL
- * @param song 歌曲对象
- * @returns Promise<string> 完整的URL
+ * 歌单播放事件数据
  */
-async function setVideoUrlForSong(song: VideoItem, options?: MetadataExtractionOptions): Promise<string> {
-  const metadataOptions = options ?? {};
-  Logger.info(TAG, `setVideoUrlForSong start -> name=${song.name}, type=${song.type}, path=${song.filePath}, fsId=${song.baiduFsId || song.id}`);
-  if (isWebDavType(song.type) && song.webdav_account_id) {
-    try {
-      Logger.info(TAG, `WebDAV 路径信息 remote_rel_path: ${song.remote_rel_path}`);
-      Logger.info(TAG, `WebDAV 路径信息 filePath: ${song.filePath}`);
-      const relativePath = song.remote_rel_path || song.filePath;
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-
-      if (account) {
-        const cachePath = await findWebDavCacheIfExists(account, relativePath);
-        if (cachePath) {
-          Logger.info(TAG, `WebDAV 缓存命中,直接播放: ${cachePath}`);
-        void scheduleMetadataExtractionFromCache(song, cachePath, metadataOptions, song.videoSize);
-          return cachePath;
-        }
-
-        const fullUrlFromAccount = WebDavUrlUtil.buildFullUrl(account, relativePath);
-        if (fullUrlFromAccount) {
-          const sanitizedUrl = sanitizePlaybackUrl(fullUrlFromAccount);
-          const downloadPromise = triggerWebDavCacheDownload(account, relativePath, sanitizedUrl);
-          if (downloadPromise) {
-            downloadPromise.then((completedPath?: string | null) => {
-              if (completedPath) {
-                void scheduleMetadataExtractionFromCache(song, completedPath, metadataOptions, song.videoSize);
-              }
-            }).catch((error: Error) => {
-              Logger.error(TAG, `WebDAV 缓存后台下载失败: ${error.message}`);
-            });
-          }
-          Logger.info(TAG, `WebDAV 启动异步缓存: ${relativePath}`);
-          return sanitizedUrl;
-        }
-      }
-
-      const fallbackUrl = await WebDavUrlUtil.buildFullUrlByAccountId(song.webdav_account_id, relativePath);
-      if (fallbackUrl) {
-        Logger.warn(TAG, `WebDAV 使用全局查询到的URL: ${fallbackUrl}`);
-        return sanitizePlaybackUrl(fallbackUrl);
-      }
-
-      Logger.error(TAG, `WebDAV URL构建失败,使用原始路径: ${relativePath}`);
-      return sanitizePlaybackUrl(relativePath);
-    } catch (error) {
-      Logger.error(TAG, `WebDAV URL构建出错: ${(error as Error).message}`);
-      const fallbackPath = song.remote_rel_path || song.filePath;
-      return sanitizePlaybackUrl(fallbackPath);
-    }
-  }
+interface PlaylistEventData {
+  playlistId: string;
+  playlistName: string;
+  songCount: number;
+  startIndex: number;
+  songFilePaths: string[];
+  isJump: boolean;
+  webDavAuthInfo?: WebDavAuthItem; // 新增WebDAV认证信息
+}
+const DEFAULT_INDEX =
+  ['A', 'B', 'C', 'D', 'E', 'F', 'G', 'H', 'I', 'J', 'K', 'L', 'M', 'N', 'O', 'P', 'Q', 'R', 'S', 'T', 'U', 'V', 'W',
+    'X', 'Y', 'Z']
 
-  if (isNavidromeType(song.type) && song.webdav_account_id) {
-    try {
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-      if (!account) {
-        throw new Error('Navidrome账号不可用');
-      }
-      const navSongId = song.remote_rel_path || song.id || song.filePath;
-      const streamUrl = await navidromeApi.buildStreamUrl(account, navSongId);
-      void ServerLogUtil.info(TAG, `流地址构建成功: ${streamUrl}`);
-      void ServerLogUtil.debug(TAG, `播放songId=${navSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
-      return sanitizePlaybackUrl(streamUrl);
-    } catch (error) {
-      const err = error as Error;
-      void ServerLogUtil.error(TAG, `URL构建失败: ${err.message}`);
-      throw new Error(err.message);
-    }
-  }
 
-  if (isJellyfinType(song.type) && song.webdav_account_id) {
-    try {
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-      if (!account) {
-        throw new Error('Jellyfin账号不可用');
-      }
-      const jellyfinSongId = song.remote_rel_path || song.id || song.filePath;
-      const streamUrl = await jellyfinApi.buildStreamUrl(account, jellyfinSongId);
-      void ServerLogUtil.info(TAG, `Jellyfin 流地址构建成功: ${streamUrl}`);
-      void ServerLogUtil.debug(TAG, `播放songId=${jellyfinSongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
-      return sanitizePlaybackUrl(streamUrl);
-    } catch (error) {
-      const err = error as Error;
-      void ServerLogUtil.error(TAG, `Jellyfin URL构建失败: ${err.message}`);
-      throw new Error(err.message);
-    }
-  }
 
-  if (isEmbyType(song.type) && song.webdav_account_id) {
-    try {
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-      if (!account) {
-        throw new Error('Emby账号不可用');
-      }
-      const embySongId = song.remote_rel_path || song.id || song.filePath;
-      const streamUrl = await embyApi.buildStreamUrl(account, embySongId);
-      void ServerLogUtil.info(TAG, `Emby 流地址构建成功: ${streamUrl}`);
-      void ServerLogUtil.debug(TAG, `播放songId=${embySongId}, account=${ServerLogUtil.sanitizeAccount(account)}`);
-      return sanitizePlaybackUrl(streamUrl);
-    } catch (error) {
-      const err = error as Error;
-      void ServerLogUtil.error(TAG, `Emby URL构建失败: ${err.message}`);
-      throw new Error(err.message);
-    }
-  }
+const workerInstance = new worker.ThreadWorker("entry/ets/workers/Worker.ets");
 
-  if (isSmbType(song.type) && song.webdav_account_id) {
-    try {
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-      if (!account) {
-        throw new Error('SMB账号不可用');
-      }
-      const relativePath = extractSmbRelativePath(song, account.smbShare);
-      const pathInfo = await resolveCacheFilePath(
-        RemoteCacheType.SMB,
-        account.id?.toString(),
-        relativePath
-      );
-      const streamMeta: SmbStreamingMeta = {
-        fileSize: song.videoSize,
-        mimeType: song.mimeType,
-        fileName: song.fileName || song.name
-      };
-      const streamingUrl = await ensureSmbFileStreaming(account, pathInfo.normalizedRelative, streamMeta);
-      Logger.info(TAG, `SMB 流地址: ${streamingUrl}`);
-      void scheduleMetadataExtractionFromCache(song, pathInfo.cachePath, metadataOptions, song.videoSize);
-      const shouldBypassSanitize = streamingUrl.startsWith('http://127.0.0.1:');
-      return shouldBypassSanitize ? streamingUrl : sanitizePlaybackUrl(streamingUrl);
-    } catch (error) {
-      const err = error as Error;
-      Logger.error(TAG, `SMB 流式播放失败: ${err.message}`);
-      throw new Error(err.message);
-    }
-  }
 
-  if (isFtpType(song.type) && song.webdav_account_id) {
-    try {
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-      if (!account) {
-        throw new Error('FTP账号不可用');
-      }
-      const relativePath = extractFtpRelativePath(song);
-      const cachedPath = await RemoteCacheManager.ensureCached(RemoteCacheType.FTP, {
-        account,
-        remotePath: relativePath
-      });
-      Logger.info(TAG, `FTP 缓存路径: ${cachedPath}`);
-      void scheduleMetadataExtractionFromCache(song, cachedPath, metadataOptions, song.videoSize);
-      return cachedPath;
-    } catch (error) {
-      const err = error as Error;
-      Logger.error(TAG, `FTP 缓存失败: ${err.message}`);
-      throw new Error(err.message);
-    }
-  }
 
-  if (isBaiduType(song.type) && song.webdav_account_id) {
-    try {
-      const manager = RemoteDriveManager.getInstance();
-      const account = await manager.getWebDavAccountById(song.webdav_account_id);
-      if (!account) {
-        throw new Error('百度网盘账户不可用');
-      }
-      Logger.info(TAG, `百度网盘播放准备 - accountId: ${account.id}, fsId: ${song.baiduFsId || song.id}`);
-      const sanitizedAccount = ServerLogUtil.sanitizeAccount(account);
-      void ServerLogUtil.info('BaiduStream', `准备播放百度歌曲: account=${sanitizedAccount}, fsId=${song.baiduFsId || song.id}`);
-
-      const fsId = song.baiduFsId || song.id;
-      if (!fsId) {
-        throw new Error('缺少百度网盘文件fs_id');
-      }
-
-      // 优先使用remote_rel_path,回退到文件名
-      const relativePath = song.remote_rel_path || song.name || song.fileName || song.filePath;
-      const streamPath = song.remote_rel_path;
-      const cachedPath = await resolveCacheFilePath(
-        RemoteCacheType.BAIDU,
-        account.id?.toString(),
-        relativePath
-      );
+// 元数据提取状态追踪
+const metadataExtractionInFlight: Map<string, Promise<void>> = new Map();
 
-      const fileExists = await FileManager.isExist(cachedPath.cachePath);
-      if (fileExists) {
-        const fileSize = await FileManager.getFileSize(cachedPath.cachePath);
-        if (fileSize > 0) {
-          if (song.videoSize > 0 && fileSize < song.videoSize * 0.9) {
-            Logger.warn(TAG, `百度网盘缓存文件不完整,准备删除重试: ${cachedPath.cachePath}, size=${fileSize}, expect=${song.videoSize}`);
-            await FileManager.deleteFile(cachedPath.cachePath);
-          } else {
-            Logger.info(TAG, `百度网盘文件已缓存: ${cachedPath.cachePath}`);
-            void scheduleMetadataExtractionFromCache(song, cachedPath.cachePath, metadataOptions, song.videoSize);
-            return cachedPath.cachePath;
-          }
-        }
-      }
 
-      // 缓存不存在或无效,获取文件信息(包含大小和下载链接)
-      Logger.info(TAG, `百度网盘文件未缓存,准备获取文件信息: ${relativePath}`);
-      const fileInfo = await manager.getBaiduFileInfo(account, fsId, streamPath);
-      Logger.info(TAG, `百度网盘文件信息获取成功: 大小=${fileInfo.size}字节,流地址已获取`);
-      void ServerLogUtil.info('BaiduStream', `音频流URL获取成功: ${fileInfo.streamUrl}`);
 
-      // 立即返回媒体流地址,开始播放
-      Logger.info(TAG, `返回音频流链接进行播放,同时后台缓存: ${fileInfo.streamUrl}`);
 
-      // 异步进行缓存,不阻塞播放
-      void (async () => {
-        try {
-          Logger.info(TAG, `后台开始缓存百度网盘文件: ${relativePath},大小: ${fileInfo.size}字节`);
-          void ServerLogUtil.info('BaiduStream', `开始缓存百度文件: account=${sanitizedAccount}, path=${relativePath}`);
-          const cachedFilePath = await ensureBaiduFileCached(
-            account,
-            fsId,
-            relativePath,
-            fileInfo.dlink,
-            fileInfo.size
-          );
-          Logger.info(TAG, `后台缓存完成: ${cachedFilePath}`);
-          void ServerLogUtil.info('BaiduStream', `缓存完成: ${cachedFilePath}`);
-          void scheduleMetadataExtractionFromCache(song, cachedFilePath, metadataOptions, song.videoSize);
-        } catch (cacheError) {
-          const cacheErr = cacheError as Error;
-          Logger.warn(TAG, `后台缓存失败: ${cacheErr.message}`);
-          void ServerLogUtil.error('BaiduStream', `缓存失败: ${cacheErr.message}`);
-        }
-      })();
-
-      return fileInfo.streamUrl;
-    } catch (error) {
-      const err = error as Error;
-      Logger.error(TAG, `百度网盘播放链接获取失败: ${err.message}`);
-      throw err;
-    }
-  }
 
-  if (isWebDavType(song.type)) {
-    Logger.warn(TAG, `WebDAV歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
-    return sanitizePlaybackUrl(song.filePath);
-  }
-  if (isSmbType(song.type)) {
-    Logger.warn(TAG, `SMB歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
-    return song.filePath;
-  }
-  if (isFtpType(song.type)) {
-    Logger.warn(TAG, `FTP歌曲缺少webdav_account_id,使用原始路径: ${song.filePath}`);
-    return song.filePath;
-  }
-  if (isBaiduType(song.type)) {
-    throw new Error('百度网盘歌曲缺少webdav_account_id,无法构建播放链接');
-  }
-  if (isNavidromeType(song.type)) {
-    throw new Error('Navidrome歌曲缺少webdav_account_id,无法构建播放链接');
-  }
-  if (isJellyfinType(song.type)) {
-    throw new Error('Jellyfin歌曲缺少webdav_account_id,无法构建播放链接');
-  }
-  if (isEmbyType(song.type)) {
-    throw new Error('Emby歌曲缺少webdav_account_id,无法构建播放链接');
-  }
-  Logger.info(TAG, `setVideoUrlForSong fallback直接返回原始路径: ${song.filePath}`);
-  return song.filePath;
-}
+// setVideoUrlForSong 函数已移至 MusicPlayerUtil.ets
 
 // 定义接口
 interface HiCarAspectRatio {
@@ -887,6 +324,7 @@ export struct LocalMusic {
   @State isCanAuto:boolean = true
   @State opacityValueImage: number = 1;
   @State tipPopup:boolean = false
+  @State preloadNextSong: boolean = PreferencesUtil.getBooleanSync('preload_next_song', true) // 是否提前缓存下一首
   @Consume mType: number;
   @StorageProp('themeColor') themeColor: string =
     PreferencesUtil.getStringSync('THEME_COLOR', CommonConstants.DEFAULT_THEME_COLOR);
@@ -14735,15 +14173,24 @@ export struct LocalMusic {
           this.opacityValueImage = 1;
           this.scaleValueImage = 1 // 图标恢复到原始大小
           this.startPlayOrResumePlay()
+          // 提前缓存下一首
+          void this.preloadNextSongInternal();
         });
       }, 500);
     }else{
       this.cover = this.songList[this.curIndex].pixelMapPath
       this.startPlayOrResumePlay()
+      // 提前缓存下一首
+      void this.preloadNextSongInternal();
     }
 
   }
 
+  // 内部方法:提前缓存下一首
+  private async preloadNextSongInternal(): Promise<void> {
+    await preloadNextSongIfNeeded(this.songList, this.curIndex, this.playType);
+  }
+
   // 存储已播放的歌曲索引
   private playedIndices: Set<number> = new Set();
 

+ 8 - 4
lib/src/main/ets/view/LyricView2.ets

@@ -297,6 +297,8 @@ export struct LyricView2 {
                     y: index == this.currentIndex ? this.controller.getHighlightScale() : 1,
                     centerX: this.alignMode == 'center' ? '50%' : 0
                 })
+                .maxLines(1)
+                .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
                 .fontColor(index == this.currentIndex ? this.textHighlightColor : this.textColor)
                 .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
                 .animation({
@@ -310,7 +312,7 @@ export struct LyricView2 {
                 .visibility(this.isSingleLine?
                     (index == this.currentIndex ?Visibility.Visible:Visibility.None)
                     :Visibility.Visible)
-                .width(this.alignMode == 'center' ? '100%' : '76%')
+                .width(this.alignMode == 'center' ? '95%' : '90%')
 
             // 中文翻译(整行显示)
             if (item.translation)  {
@@ -321,6 +323,8 @@ export struct LyricView2 {
                             index == this.currentIndex ? this.textHighlightColor : this.textColor : this.textColor)
                         .fontWeight(index == this.currentIndex && this.isHighlightBold ? FontWeight.Bold : this.textWeight)
                         .margin({ top: 4 })
+                        .maxLines(1)
+                        .textOverflow({ overflow: TextOverflow.MARQUEE })//超长滚动
                         .animation({
                             duration: 150,
                             curve: Curve.Linear
@@ -331,7 +335,7 @@ export struct LyricView2 {
                         )
                 }
                 .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
-                .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%')
+                .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '90%')
             }
         }
         // 在 Row 上应用渐变
@@ -476,7 +480,7 @@ export struct LyricView2 {
                 })
             }
             .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
-            .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%')
+            .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '90%')
 
             // 中文翻译(整行显示)
             if (item.translation)  {
@@ -489,7 +493,7 @@ export struct LyricView2 {
                         .margin({ top: 4 })
                 }
                 .justifyContent(this.alignMode === 'center' ? FlexAlign.Center : FlexAlign.Start)
-                .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '85%')
+                .width(this.alignMode == 'center' ? '100%' :index == this.currentIndex ?'95%': '90%')
             }
         }