;
-
-/** url → 仅 ASCII 的安全 key(取末段路径,附加 hash 防同名冲突)。 */
-const buildKey = (url: string): string => {
- // 简单 hash:dj2b 算法,碰撞概率极低且 deterministic
- let h = 5381;
- for (let i = 0; i < url.length; i++) h = ((h << 5) + h + url.charCodeAt(i)) | 0;
- const hashHex = (h >>> 0).toString(16);
- // path tail 提供可读性(调试时方便看出是哪首歌的封面)
- let tail = "";
- try {
- const u = new URL(url);
- const seg = u.pathname.split("/").filter(Boolean).pop() || "";
- tail = seg.replace(/[^A-Za-z0-9._-]/g, "_").slice(-32);
- } catch {
- /* not a valid URL: 走 hash 兜底 */
- }
- return tail ? `${tail}_${hashHex}` : hashHex;
-};
-
-/**
- * 内存级 blob URL LRU:上限 200 张;超出时尝试弹出最旧条目并 revokeObjectURL。
- *
- * 引入引用计数:useCoverCache 组件挂载时 retain,卸载时 release;refCount > 0 的条目
- * **不会被 LRU 立即 revoke**,而是标记成 pending revoke,等最后一个使用者 release 时再清。
- * 这样可以解决「LRU 顶掉的 blob 仍被某个活动
引用导致破图」的问题。
- *
- *
refCount=0 且未被引用的条目正常按 LRU 淘汰;超限但仍被引用的条目暂留,count 释放时再清。
- */
-const MEMORY_HIT_LIMIT = 200;
-type CoverEntry = { blobUrl: string; refCount: number; pendingRevoke: boolean };
-const memoryHit = new Map(); // url → entry,LRU(Map 保留插入顺序)
-
-/**
- * 入档新条目。
- * 关键修复 #2:新条目以 refCount=1 入档(pre-retain),
- * 调用方在用完 blobUrl 后必须配对调一次 memoryHitRelease 释放。
- * 旧实现 refCount=0 入档,从 resolveCachedCover 返回到 useCoverCache 的 watch 处理器
- * 调 memoryHitRetain 之间存在异步窗口(await microtask),期间若并发触发 memoryHitPut
- * 把上限顶爆,本条目(refCount=0)就是第一个被 LRU 选中 revoke 的候选,
- * 等调用方拿到 blobUrl 时已是失效 URL,浏览器渲染为破图。
- */
-const memoryHitPut = (url: string, blobUrl: string): void => {
- if (memoryHit.has(url)) memoryHit.delete(url);
- memoryHit.set(url, { blobUrl, refCount: 1, pendingRevoke: false });
- // 超限淘汰:跳过仍被引用的条目(含本次新条目),给它们打上 pendingRevoke 标记延迟到 release 时清
- while (memoryHit.size > MEMORY_HIT_LIMIT) {
- let evicted = false;
- for (const [k, v] of memoryHit) {
- if (v.refCount > 0) {
- // 暂不能 revoke:等 release 收尾
- v.pendingRevoke = true;
- continue;
- }
- memoryHit.delete(k);
- URL.revokeObjectURL(v.blobUrl);
- evicted = true;
- break;
- }
- // 全部仍被引用:跳出避免死循环;超额暂存留,等 release 自然清
- if (!evicted) break;
- }
-};
-
-/**
- * 命中返 blobUrl 同时 refCount+1(所有权移交调用方)。
- * 配合 memoryHitPut 的 pre-retain 语义统一:无论 HIT 还是 MISS,调用方都拿到「已 retain」的 url,
- * 用完必须配对调用 memoryHitRelease(修复 #2)。
- */
-const memoryHitGet = (url: string): string | undefined => {
- const e = memoryHit.get(url);
- if (e !== undefined) {
- // LRU touch:删除后重新 set,挪到 Map 末尾
- memoryHit.delete(url);
- memoryHit.set(url, e);
- e.refCount++;
- return e.blobUrl;
- }
- return undefined;
-};
-
-/** 引用计数 -1;refCount=0 且 pendingRevoke 时立刻 revoke 并清出 Map。 */
-const memoryHitRelease = (url: string): void => {
- const e = memoryHit.get(url);
- if (!e) return;
- e.refCount = Math.max(0, e.refCount - 1);
- if (e.refCount === 0 && e.pendingRevoke) {
- memoryHit.delete(url);
- URL.revokeObjectURL(e.blobUrl);
- }
-};
-
-/** type|url → 解析中的 Promise,仅活到 cm.get 完成,去重首次解析并发。type 隔离防串话。 */
-const inFlight = new Map>();
-/** type|url → 后台下载 Promise,活到 fetch + cm.set 写盘完成;防止 #4 同 url 重复网络请求。 */
-const downloadInFlight = new Map>();
-
-/**
- * 解析 url:命中本地缓存返 blob URL;未命中返 undefined(调用方应回退到原 url,
- * 同时本 helper 会在后台异步下载并写入缓存,下次进入直接命中)。
- */
-const resolveCachedCover = async (
- url: string,
- type: CoverCacheType,
-): Promise => {
- if (!url || !url.startsWith("http")) return undefined;
- const cm = useCacheManager();
- const key = buildKey(url);
- const flightKey = `${type}|${url}`;
- // 内存级 hit 直接返(带 LRU touch + refCount++)
- const hit = memoryHitGet(url);
- if (hit) return hit;
- // 并发 dedup(按 type+url 隔离,避免 covers 与 list-covers 串话)。
- // 修复 #4:pending 命中时不能直接返 await 结果——首次调用方在 memoryHitPut 已拿走那 1 个引用,
- // 后续 waiter 必须各自再过一次 memoryHitGet 拿到自己的 retain,否则卸载时多次 release 会让
- // refCount 错误归零,触发 LRU pendingRevoke 把仍被使用的 blob URL revoke 掉(破图)。
- const pending = inFlight.get(flightKey);
- if (pending) {
- return pending.then((firstResult) => {
- if (firstResult && firstResult.startsWith("blob:")) {
- // 走 memoryHitGet 拿本调用方的 retain;若 entry 已被 evict 则降级返原值(调用方走原 url 兜底)
- const ownRef = memoryHitGet(url);
- return ownRef ?? firstResult;
- }
- return firstResult;
- });
- }
-
- const task = (async (): Promise => {
- try {
- const r = await cm.get(type, key);
- if (r.success && r.data) {
- // Blob 构造在 TS 5.x 对 Uint8Array.buffer (ArrayBufferLike) 推断过严,断言为 ArrayBuffer 兜底
- const ab = r.data.buffer.slice(
- r.data.byteOffset,
- r.data.byteOffset + r.data.byteLength,
- ) as ArrayBuffer;
- const blob = new Blob([ab]);
- const blobUrl = URL.createObjectURL(blob);
- memoryHitPut(url, blobUrl);
- return blobUrl;
- }
- } catch {
- /* miss:走未命中分支 */
- }
- // 未命中:后台异步下载并写入;不阻塞返回,让调用方先用原 url 显示
- void downloadAndCache(url, key, type);
- return undefined;
- })();
-
- inFlight.set(flightKey, task);
- try {
- return await task;
- } finally {
- inFlight.delete(flightKey);
- }
-};
-
-/**
- * 后台抓取并写入缓存(fire-and-forget)。同 url 期间已有下载在跑则直接复用,
- * 防止 resolveCachedCover 在 cm.get miss 后多次触发同一 url 的网络请求(#4 修复)。
- */
-const downloadAndCache = (url: string, key: string, type: CoverCacheType): Promise => {
- const flightKey = `${type}|${url}`;
- const existing = downloadInFlight.get(flightKey);
- if (existing) return existing;
-
- // AbortController 兜底:30s 还没完成则主动取消 fetch,配合 finally 清 inflight
- const ctrl = new AbortController();
- // 包成 holder:正常完成时立刻 clearTimeout,避免闭包延寿 30s(ctrl/job/flightKey 占内存)
- const timer: { id?: ReturnType } = {};
- const job = (async () => {
- try {
- const settingStore = useSettingStore();
- if (!settingStore.cacheEnabled) return;
- const resp = await fetch(url, { signal: ctrl.signal });
- if (!resp.ok) return;
- const buf = await resp.arrayBuffer();
- if (buf.byteLength === 0) return;
- const cm = useCacheManager();
- await cm.set(type, key, new Uint8Array(buf));
- } catch (e) {
- // 网络失败 / abort 不致命:下次访问仍可能命中或重试
- console.warn("[useCoverCache] download failed:", url, e);
- } finally {
- downloadInFlight.delete(flightKey);
- if (timer.id !== undefined) clearTimeout(timer.id);
- }
- })();
-
- downloadInFlight.set(flightKey, job);
- // 30s 兜底超时:abort fetch + 清 inflight;正常完成时由上面 finally clearTimeout 取消
- timer.id = setTimeout(() => {
- if (downloadInFlight.get(flightKey) === job) {
- ctrl.abort();
- downloadInFlight.delete(flightKey);
- }
- }, 30_000);
- return job;
-};
-
-/**
- * 预下载封面到本地缓存:供 SongManager.prefetchNextSong 等场景主动调用。
- *
- * 已在缓存命中或正在下载时直接跳过;并发安全(同 url 只发一次请求)。
- * 与 resolveCachedCover 共享 inFlight / memoryHit map,去重彻底。
- *
- * @param url 远端封面 url(http/https)
- * @param type 默认 "covers";列表场景传 "list-covers"
- */
-export const prefetchCoverToCache = async (
- url: string | undefined,
- type: CoverCacheType = "covers",
-): Promise => {
- if (!url || !url.startsWith("http")) return;
- if (!isCapacitorAndroid) return;
- // 复用 resolveCachedCover:命中直接返,未命中触发后台下载。
- // 拿到 blob URL 后立即 release(修复 #2 pre-retain 副作用):
- // prefetch 仅是「写入缓存」语义,本身不持有 blob URL 引用。
- const cached = await resolveCachedCover(url, type);
- if (cached && cached.startsWith("blob:")) {
- memoryHitRelease(url);
- }
-};
-
-/** 列表项最小形态:从 cover / coverSize 提取首选 url。 */
-type CoverLike = { cover?: string; coverSize?: { s?: string; m?: string; l?: string; xl?: string } };
-
-/**
- * 从一条列表项按尺寸偏好提取 url。
- * 关键:必须与实际 使用的 url 一致,否则 prefetch 写入和组件读取不在同一缓存条目上,浪费下载。
- *
- * - "s"(小图):SongCard、SongList 行封面
- * - "m"(中图):CoverList、ArtistList、Local/Streaming/HomeMobile 列表卡片
- */
-const pickListCoverUrl = (
- item: CoverLike,
- sizePref: "s" | "m" = "m",
-): string | undefined => {
- if (sizePref === "s") return item?.coverSize?.s || item?.cover;
- return item?.coverSize?.m || item?.coverSize?.s || item?.cover;
-};
-
-/**
- * 批量后台 prefetch 列表中前 N 张封面,进入详情页 / 首页加载完成后调用。
- *
- * fire-and-forget;并发由 useCoverCache 内的 inFlight 去重,不会重复请求。
- * 仅 Android 走本地缓存路径;其他平台 noop。
- *
- * @param items 列表项(含 cover / coverSize)
- * @param type "list-covers"(默认) / "covers"
- * @param limit 预热的前 N 条;默认 20
- * @param sizePref 尺寸偏好:与 实际请求的尺寸保持一致才能命中
- */
-export const prefetchListCovers = (
- items: readonly CoverLike[] | undefined,
- type: CoverCacheType = "list-covers",
- limit = 20,
- sizePref: "s" | "m" = "m",
-): void => {
- if (!items || items.length === 0) return;
- if (!isCapacitorAndroid) return;
- const max = Math.min(limit, items.length);
- for (let i = 0; i < max; i++) {
- const url = pickListCoverUrl(items[i], sizePref);
- if (url) void prefetchCoverToCache(url, type);
- }
-};
-
-/**
- * 把远端封面 url 映射为「优先本地、回退远端」的反应式 src。
- *
- * - 仅 Android 启用;其他平台直接透传原 url(项目仅安卓运行,但保留兜底)
- * - 卸载时释放 blob URL,避免内存泄漏
- *
- * @param srcRef 原始 url ref(来自 props.src 等)
- * @param type 默认 "covers";列表场景传 "list-covers"
- * @returns 处理后的 src ref
- */
-export const useCoverCache = (
- srcRef: Ref,
- type: CoverCacheType = "covers",
-): Ref => {
- const resolved = ref(srcRef.value);
- /** 当前组件 retain 的源 url 列表(不是 blob URL,是原始 http url 作为 memoryHit 的 key)。 */
- const retainedUrls: string[] = [];
-
- watch(
- srcRef,
- async (url, prevUrl) => {
- // 切换 src:先 release 旧 url 的引用计数
- if (prevUrl && retainedUrls.includes(prevUrl)) {
- memoryHitRelease(prevUrl);
- const idx = retainedUrls.indexOf(prevUrl);
- if (idx >= 0) retainedUrls.splice(idx, 1);
- }
- if (!url) {
- resolved.value = undefined;
- return;
- }
- // 非 http(s) 直接透传:本地路径 / data URI / blob URL / capacitor://
- if (!url.startsWith("http")) {
- resolved.value = url;
- return;
- }
- if (!isCapacitorAndroid) {
- resolved.value = url;
- return;
- }
- // 先用原始 url 显示,避免等待 IPC(命中时立即升级)
- resolved.value = url;
- const cached = await resolveCachedCover(url, type);
- if (cached && srcRef.value === url) {
- resolved.value = cached;
- if (cached.startsWith("blob:")) {
- // resolveCachedCover 已 pre-retain(修复 #2),这里只需记录 url 等卸载时 release
- retainedUrls.push(url);
- } else if (cached.startsWith("blob:") === false) {
- // 极少见:返回非 blob URL(透传场景),不持有引用
- }
- } else if (cached && cached.startsWith("blob:")) {
- // src 已切换:本次拿到的 blob 没用上,立即释放所有权避免泄漏
- memoryHitRelease(url);
- }
- },
- { immediate: true },
- );
-
- onBeforeUnmount(() => {
- // 释放所有 retain 的 blob URL;refCount 归零且 LRU 已超额则真正 revoke。
- for (const u of retainedUrls) memoryHitRelease(u);
- retainedUrls.length = 0;
- });
-
- return resolved;
-};
+import { ref, watch, onBeforeUnmount, type Ref } from "vue";
+import { Capacitor } from "@capacitor/core";
+import { useCacheManager, type CacheResourceType } from "@/core/resource/CacheManager";
+import { isCapacitorAndroid } from "@/utils/env";
+import { useSettingStore } from "@/stores";
+
+/** 封面缓存 type;其他类型不进入此 helper。 */
+export type CoverCacheType = Extract;
+
+/** url → 仅 ASCII 的安全 key(取末段路径,附加 hash 防同名冲突)。 */
+const buildKey = (url: string): string => {
+ // 简单 hash:dj2b 算法,碰撞概率极低且 deterministic
+ let h = 5381;
+ for (let i = 0; i < url.length; i++) h = ((h << 5) + h + url.charCodeAt(i)) | 0;
+ const hashHex = (h >>> 0).toString(16);
+ // path tail 提供可读性(调试时方便看出是哪首歌的封面)
+ let tail = "";
+ try {
+ const u = new URL(url);
+ const seg = u.pathname.split("/").filter(Boolean).pop() || "";
+ tail = seg.replace(/[^A-Za-z0-9._-]/g, "_").slice(-32);
+ } catch {
+ /* not a valid URL: 走 hash 兜底 */
+ }
+ return tail ? `${tail}_${hashHex}` : hashHex;
+};
+
+/**
+ * 内存级 blob URL LRU:上限 200 张;超出时尝试弹出最旧条目并 revokeObjectURL。
+ *
+ * 引入引用计数:useCoverCache 组件挂载时 retain,卸载时 release;refCount > 0 的条目
+ * **不会被 LRU 立即 revoke**,而是标记成 pending revoke,等最后一个使用者 release 时再清。
+ * 这样可以解决「LRU 顶掉的 blob 仍被某个活动
引用导致破图」的问题。
+ *
+ *
refCount=0 且未被引用的条目正常按 LRU 淘汰;超限但仍被引用的条目暂留,count 释放时再清。
+ */
+const MEMORY_HIT_LIMIT = 200;
+type CoverEntry = { blobUrl: string; refCount: number; pendingRevoke: boolean };
+const memoryHit = new Map(); // url → entry,LRU(Map 保留插入顺序)
+
+/**
+ * 入档新条目。
+ * 关键修复 #2:新条目以 refCount=1 入档(pre-retain),
+ * 调用方在用完 blobUrl 后必须配对调一次 memoryHitRelease 释放。
+ * 旧实现 refCount=0 入档,从 resolveCachedCover 返回到 useCoverCache 的 watch 处理器
+ * 调 memoryHitRetain 之间存在异步窗口(await microtask),期间若并发触发 memoryHitPut
+ * 把上限顶爆,本条目(refCount=0)就是第一个被 LRU 选中 revoke 的候选,
+ * 等调用方拿到 blobUrl 时已是失效 URL,浏览器渲染为破图。
+ */
+const memoryHitPut = (url: string, blobUrl: string): void => {
+ if (memoryHit.has(url)) memoryHit.delete(url);
+ memoryHit.set(url, { blobUrl, refCount: 1, pendingRevoke: false });
+ // 超限淘汰:跳过仍被引用的条目(含本次新条目),给它们打上 pendingRevoke 标记延迟到 release 时清
+ while (memoryHit.size > MEMORY_HIT_LIMIT) {
+ let evicted = false;
+ for (const [k, v] of memoryHit) {
+ if (v.refCount > 0) {
+ // 暂不能 revoke:等 release 收尾
+ v.pendingRevoke = true;
+ continue;
+ }
+ memoryHit.delete(k);
+ URL.revokeObjectURL(v.blobUrl);
+ evicted = true;
+ break;
+ }
+ // 全部仍被引用:跳出避免死循环;超额暂存留,等 release 自然清
+ if (!evicted) break;
+ }
+};
+
+/**
+ * 命中返 blobUrl 同时 refCount+1(所有权移交调用方)。
+ * 配合 memoryHitPut 的 pre-retain 语义统一:无论 HIT 还是 MISS,调用方都拿到「已 retain」的 url,
+ * 用完必须配对调用 memoryHitRelease(修复 #2)。
+ */
+const memoryHitGet = (url: string): string | undefined => {
+ const e = memoryHit.get(url);
+ if (e !== undefined) {
+ // LRU touch:删除后重新 set,挪到 Map 末尾
+ memoryHit.delete(url);
+ memoryHit.set(url, e);
+ e.refCount++;
+ return e.blobUrl;
+ }
+ return undefined;
+};
+
+/** 引用计数 -1;refCount=0 且 pendingRevoke 时立刻 revoke 并清出 Map。 */
+const memoryHitRelease = (url: string): void => {
+ const e = memoryHit.get(url);
+ if (!e) return;
+ e.refCount = Math.max(0, e.refCount - 1);
+ if (e.refCount === 0 && e.pendingRevoke) {
+ memoryHit.delete(url);
+ URL.revokeObjectURL(e.blobUrl);
+ }
+};
+
+/** type|url → 解析中的 Promise,仅活到 cm.get 完成,去重首次解析并发。type 隔离防串话。 */
+const inFlight = new Map>();
+/** type|url → 后台下载 Promise,活到 fetch + cm.set 写盘完成;防止 #4 同 url 重复网络请求。 */
+const downloadInFlight = new Map>();
+
+/**
+ * 解析 url:命中本地缓存返 blob URL;未命中返 undefined(调用方应回退到原 url,
+ * 同时本 helper 会在后台异步下载并写入缓存,下次进入直接命中)。
+ */
+const resolveCachedCover = async (
+ url: string,
+ type: CoverCacheType,
+): Promise => {
+ if (!url || !url.startsWith("http")) return undefined;
+ const cm = useCacheManager();
+ const key = buildKey(url);
+ const flightKey = `${type}|${url}`;
+ // 内存级 hit 直接返(带 LRU touch + refCount++)
+ const hit = memoryHitGet(url);
+ if (hit) return hit;
+ // 并发 dedup(按 type+url 隔离,避免 covers 与 list-covers 串话)。
+ // 修复 #4:pending 命中时不能直接返 await 结果——首次调用方在 memoryHitPut 已拿走那 1 个引用,
+ // 后续 waiter 必须各自再过一次 memoryHitGet 拿到自己的 retain,否则卸载时多次 release 会让
+ // refCount 错误归零,触发 LRU pendingRevoke 把仍被使用的 blob URL revoke 掉(破图)。
+ const pending = inFlight.get(flightKey);
+ if (pending) {
+ return pending.then((firstResult) => {
+ if (firstResult && firstResult.startsWith("blob:")) {
+ // 走 memoryHitGet 拿本调用方的 retain;若 entry 已被 evict 则降级返原值(调用方走原 url 兜底)
+ const ownRef = memoryHitGet(url);
+ return ownRef ?? firstResult;
+ }
+ return firstResult;
+ });
+ }
+
+ const task = (async (): Promise => {
+ try {
+ const r = await cm.get(type, key);
+ if (r.success && r.data) {
+ // Blob 构造在 TS 5.x 对 Uint8Array.buffer (ArrayBufferLike) 推断过严,断言为 ArrayBuffer 兜底
+ const ab = r.data.buffer.slice(
+ r.data.byteOffset,
+ r.data.byteOffset + r.data.byteLength,
+ ) as ArrayBuffer;
+ const blob = new Blob([ab]);
+ const blobUrl = URL.createObjectURL(blob);
+ memoryHitPut(url, blobUrl);
+ return blobUrl;
+ }
+ } catch {
+ /* miss:走未命中分支 */
+ }
+ // 未命中:后台异步下载并写入;不阻塞返回,让调用方先用原 url 显示
+ void downloadAndCache(url, key, type);
+ return undefined;
+ })();
+
+ inFlight.set(flightKey, task);
+ try {
+ return await task;
+ } finally {
+ inFlight.delete(flightKey);
+ }
+};
+
+/**
+ * 后台抓取并写入缓存(fire-and-forget)。同 url 期间已有下载在跑则直接复用,
+ * 防止 resolveCachedCover 在 cm.get miss 后多次触发同一 url 的网络请求(#4 修复)。
+ */
+const downloadAndCache = (url: string, key: string, type: CoverCacheType): Promise => {
+ const flightKey = `${type}|${url}`;
+ const existing = downloadInFlight.get(flightKey);
+ if (existing) return existing;
+
+ // AbortController 兜底:30s 还没完成则主动取消 fetch,配合 finally 清 inflight
+ const ctrl = new AbortController();
+ // 包成 holder:正常完成时立刻 clearTimeout,避免闭包延寿 30s(ctrl/job/flightKey 占内存)
+ const timer: { id?: ReturnType } = {};
+ const job = (async () => {
+ try {
+ const settingStore = useSettingStore();
+ if (!settingStore.cacheEnabled) return;
+ const resp = await fetch(url, { signal: ctrl.signal });
+ if (!resp.ok) return;
+ const buf = await resp.arrayBuffer();
+ if (buf.byteLength === 0) return;
+ const cm = useCacheManager();
+ await cm.set(type, key, new Uint8Array(buf));
+ } catch (e) {
+ // 网络失败 / abort 不致命:下次访问仍可能命中或重试
+ console.warn("[useCoverCache] download failed:", url, e);
+ } finally {
+ downloadInFlight.delete(flightKey);
+ if (timer.id !== undefined) clearTimeout(timer.id);
+ }
+ })();
+
+ downloadInFlight.set(flightKey, job);
+ // 30s 兜底超时:abort fetch + 清 inflight;正常完成时由上面 finally clearTimeout 取消
+ timer.id = setTimeout(() => {
+ if (downloadInFlight.get(flightKey) === job) {
+ ctrl.abort();
+ downloadInFlight.delete(flightKey);
+ }
+ }, 30_000);
+ return job;
+};
+
+/**
+ * 预下载封面到本地缓存:供 SongManager.prefetchNextSong 等场景主动调用。
+ *
+ * 已在缓存命中或正在下载时直接跳过;并发安全(同 url 只发一次请求)。
+ * 与 resolveCachedCover 共享 inFlight / memoryHit map,去重彻底。
+ *
+ * @param url 远端封面 url(http/https)
+ * @param type 默认 "covers";列表场景传 "list-covers"
+ */
+export const prefetchCoverToCache = async (
+ url: string | undefined,
+ type: CoverCacheType = "covers",
+): Promise => {
+ if (!url || !url.startsWith("http")) return;
+ if (!isCapacitorAndroid) return;
+ // 复用 resolveCachedCover:命中直接返,未命中触发后台下载。
+ // 拿到 blob URL 后立即 release(修复 #2 pre-retain 副作用):
+ // prefetch 仅是「写入缓存」语义,本身不持有 blob URL 引用。
+ const cached = await resolveCachedCover(url, type);
+ if (cached && cached.startsWith("blob:")) {
+ memoryHitRelease(url);
+ }
+};
+
+/** 列表项最小形态:从 cover / coverSize 提取首选 url。 */
+type CoverLike = { cover?: string; coverSize?: { s?: string; m?: string; l?: string; xl?: string } };
+
+/**
+ * 从一条列表项按尺寸偏好提取 url。
+ * 关键:必须与实际 使用的 url 一致,否则 prefetch 写入和组件读取不在同一缓存条目上,浪费下载。
+ *
+ * - "s"(小图):SongCard、SongList 行封面
+ * - "m"(中图):CoverList、ArtistList、Local/Streaming/HomeMobile 列表卡片
+ */
+const pickListCoverUrl = (
+ item: CoverLike,
+ sizePref: "s" | "m" = "m",
+): string | undefined => {
+ if (sizePref === "s") return item?.coverSize?.s || item?.cover;
+ return item?.coverSize?.m || item?.coverSize?.s || item?.cover;
+};
+
+/**
+ * 批量后台 prefetch 列表中前 N 张封面,进入详情页 / 首页加载完成后调用。
+ *
+ * fire-and-forget;并发由 useCoverCache 内的 inFlight 去重,不会重复请求。
+ * 仅 Android 走本地缓存路径;其他平台 noop。
+ *
+ * @param items 列表项(含 cover / coverSize)
+ * @param type "list-covers"(默认) / "covers"
+ * @param limit 预热的前 N 条;默认 20
+ * @param sizePref 尺寸偏好:与 实际请求的尺寸保持一致才能命中
+ */
+export const prefetchListCovers = (
+ items: readonly CoverLike[] | undefined,
+ type: CoverCacheType = "list-covers",
+ limit = 20,
+ sizePref: "s" | "m" = "m",
+): void => {
+ if (!items || items.length === 0) return;
+ if (!isCapacitorAndroid) return;
+ const max = Math.min(limit, items.length);
+ for (let i = 0; i < max; i++) {
+ const url = pickListCoverUrl(items[i], sizePref);
+ if (url) void prefetchCoverToCache(url, type);
+ }
+};
+
+/**
+ * 把远端封面 url 映射为「优先本地、回退远端」的反应式 src。
+ *
+ * - 仅 Android 启用;其他平台直接透传原 url(项目仅安卓运行,但保留兜底)
+ * - 卸载时释放 blob URL,避免内存泄漏
+ *
+ * @param srcRef 原始 url ref(来自 props.src 等)
+ * @param type 默认 "covers";列表场景传 "list-covers"
+ * @returns 处理后的 src ref
+ */
+export const useCoverCache = (
+ srcRef: Ref,
+ type: CoverCacheType = "covers",
+): Ref => {
+ const resolved = ref(srcRef.value);
+ /** 当前组件 retain 的源 url 列表(不是 blob URL,是原始 http url 作为 memoryHit 的 key)。 */
+ const retainedUrls: string[] = [];
+
+ watch(
+ srcRef,
+ async (url, prevUrl) => {
+ // 切换 src:先 release 旧 url 的引用计数
+ if (prevUrl && retainedUrls.includes(prevUrl)) {
+ memoryHitRelease(prevUrl);
+ const idx = retainedUrls.indexOf(prevUrl);
+ if (idx >= 0) retainedUrls.splice(idx, 1);
+ }
+ if (!url) {
+ resolved.value = undefined;
+ return;
+ }
+ // Capacitor WebView 禁止
,先做代理转换
+ if (isCapacitorAndroid && (url.startsWith("file://") || url.startsWith("content://"))) {
+ try {
+ resolved.value = Capacitor.convertFileSrc(url);
+ } catch {
+ resolved.value = url;
+ }
+ return;
+ }
+ // 已是代理 URL 的直接透传,不需要再走缓存 IPC
+ if (url.includes("_capacitor_file_")) {
+ resolved.value = url;
+ return;
+ }
+ // 非 http(s) 直接透传:data URI / blob URL / 其他 scheme
+ if (!url.startsWith("http")) {
+ resolved.value = url;
+ return;
+ }
+ if (!isCapacitorAndroid) {
+ resolved.value = url;
+ return;
+ }
+ // 先用原始 url 显示,避免等待 IPC(命中时立即升级)
+ resolved.value = url;
+ const cached = await resolveCachedCover(url, type);
+ if (cached && srcRef.value === url) {
+ resolved.value = cached;
+ if (cached.startsWith("blob:")) {
+ // resolveCachedCover 已 pre-retain(修复 #2),这里只需记录 url 等卸载时 release
+ retainedUrls.push(url);
+ } else {
+ // 极少见:返回非 blob URL(透传场景),不持有引用
+ }
+ } else if (cached && cached.startsWith("blob:")) {
+ // src 已切换:本次拿到的 blob 没用上,立即释放所有权避免泄漏
+ memoryHitRelease(url);
+ }
+ },
+ { immediate: true },
+ );
+
+ onBeforeUnmount(() => {
+ // 释放所有 retain 的 blob URL;refCount 归零且 LRU 已超额则真正 revoke。
+ for (const u of retainedUrls) memoryHitRelease(u);
+ retainedUrls.length = 0;
+ });
+
+ return resolved;
+};
diff --git a/src/core/audio-player/AndroidNativeAudioPlayer.ts b/src/core/audio-player/AndroidNativeAudioPlayer.ts
index 8f7203967..58dc02d90 100644
--- a/src/core/audio-player/AndroidNativeAudioPlayer.ts
+++ b/src/core/audio-player/AndroidNativeAudioPlayer.ts
@@ -356,7 +356,8 @@ export class AndroidNativeAudioPlayer extends EventTarget implements IPlaybackEn
) {
return;
}
- this.lastEndedSrc = this._src;
+ const endedSrc = this._src;
+ this.lastEndedSrc = endedSrc;
this.lastEndedEventAt = now;
const endDuration = Math.max(0, event.durationMs) / 1000;
@@ -365,6 +366,9 @@ export class AndroidNativeAudioPlayer extends EventTarget implements IPlaybackEn
this._paused = true;
this.lastTimeSyncAt = performance.now();
this.dispatchEvent(new Event(AUDIO_EVENTS.TIME_UPDATE));
+ // 同步比对 _src 与事件捕获的 endedSrc:若已切换说明 ENDED 来自旧轨,丢弃。
+ // 不再走异步 getState(),避免 IPC 期间用户切歌导致 legitimate 自然终止被吞掉。
+ if (this._src !== endedSrc) return;
this.dispatchEvent(new Event(AUDIO_EVENTS.ENDED));
}),
);
diff --git a/src/core/player/LyricManager.ts b/src/core/player/LyricManager.ts
index bd89f3076..e32558e83 100644
--- a/src/core/player/LyricManager.ts
+++ b/src/core/player/LyricManager.ts
@@ -1,4 +1,5 @@
import { qqMusicMatch } from "@/api/qqmusic";
+import { searchResult, SearchTypes } from "@/api/search";
import { songLyric, songLyricTTML } from "@/api/song";
import { keywords as defaultKeywords, regexes as defaultRegexes } from "@/assets/data/exclude";
import { useCacheManager } from "@/core/resource/CacheManager";
@@ -31,6 +32,35 @@ interface LyricFetchResult {
};
}
+interface SearchSongArtist {
+ name?: string;
+}
+
+interface SearchSongAlbum {
+ name?: string;
+}
+
+interface SearchSongCandidate {
+ id?: number;
+ name?: string;
+ ar?: SearchSongArtist[];
+ artists?: SearchSongArtist[];
+ al?: SearchSongAlbum;
+ album?: SearchSongAlbum;
+ dt?: number;
+ duration?: number;
+}
+
+interface ElectronLyricResult {
+ lyric?: string;
+ format?: "lrc" | "ttml" | "yrc";
+}
+
+interface LocalLyricOverrideResult {
+ lrc?: unknown;
+ ttml?: unknown;
+}
+
/**
* 歌词管理器
* 负责歌词的获取、缓存、预加载等操作
@@ -76,6 +106,16 @@ class LyricManager {
* @param type 缓存类型
* @returns 缓存数据
*/
+ private getArtistsText(song: SongType): string {
+ return Array.isArray(song.artists)
+ ? song.artists.map((artist) => artist.name).join("/")
+ : String(song.artists || "");
+ }
+
+ private getAlbumText(song: SongType): string {
+ return typeof song.album === "string" ? song.album : song.album?.name || "";
+ }
+
private async getRawLyricCache(id: number, type: "lrc" | "ttml" | "qrc"): Promise {
const settingStore = useSettingStore();
const cacheManager = useCacheManager();
@@ -119,9 +159,7 @@ class LyricManager {
*/
private async fetchQQMusicLyric(song: SongType): Promise {
// 构建歌手字符串
- const artistsStr = Array.isArray(song.artists)
- ? song.artists.map((a) => a.name).join("/")
- : String(song.artists || "");
+ const artistsStr = this.getArtistsText(song);
// 判断本地/在线,生成缓存 key
const isLocal = Boolean(song.path);
const cacheKey = isLocal ? `local_${song.id}` : String(song.id);
@@ -211,6 +249,218 @@ class LyricManager {
return result;
}
+ /** 本地歌曲在线匹配缓存 TTL:成功 30 天 / 失败 1 天,避免反复打云搜索。 */
+ private static readonly LOCAL_MATCH_TTL_OK_MS = 30 * 24 * 60 * 60 * 1000;
+ private static readonly LOCAL_MATCH_TTL_NEG_MS = 24 * 60 * 60 * 1000;
+
+ /** 进程内 in-flight 去重:同一首歌的并发查询合并。 */
+ private localMatchInFlight = new Map>();
+
+ private async readLocalMatchCache(cacheKey: string): Promise {
+ const settingStore = useSettingStore();
+ const cacheManager = useCacheManager();
+ if (!cacheManager.isAvailable() || !settingStore.cacheEnabled) return undefined;
+ try {
+ const result = await cacheManager.get("lyrics", `${cacheKey}.match.v2.json`);
+ if (!result.success || !result.data) return undefined;
+ const decoder = new TextDecoder();
+ const parsed = JSON.parse(decoder.decode(result.data));
+ const ts = Number(parsed?.ts || 0);
+ const onlineId = parsed?.onlineId;
+ const ttl =
+ onlineId === null
+ ? LyricManager.LOCAL_MATCH_TTL_NEG_MS
+ : LyricManager.LOCAL_MATCH_TTL_OK_MS;
+ if (Date.now() - ts > ttl) return undefined;
+ return typeof onlineId === "number" ? onlineId : null;
+ } catch {
+ return undefined;
+ }
+ }
+
+ private async writeLocalMatchCache(cacheKey: string, onlineId: number | null): Promise {
+ const settingStore = useSettingStore();
+ const cacheManager = useCacheManager();
+ if (!cacheManager.isAvailable() || !settingStore.cacheEnabled) return;
+ try {
+ await cacheManager.set(
+ "lyrics",
+ `${cacheKey}.match.v2.json`,
+ JSON.stringify({ onlineId, ts: Date.now() }),
+ );
+ } catch {
+ // 忽略写入失败
+ }
+ }
+
+ /** 本地歌曲常见的占位元数据:识别后视为"无元数据"。 */
+ private static readonly METADATA_SENTINELS = new Set([
+ "未知歌手",
+ "未知艺术家",
+ "未知专辑",
+ "未知",
+ "unknown",
+ "unknownartist",
+ "unknownalbum",
+ "various",
+ "variousartists",
+ "n/a",
+ "na",
+ ]);
+
+ private static normalizeMetadataField(value: string): string {
+ const normalized = value
+ .toLowerCase()
+ .replace(/[((].*?[))]/g, "")
+ .replace(/\s+/g, "")
+ .trim();
+ if (!normalized) return "";
+ if (LyricManager.METADATA_SENTINELS.has(normalized)) return "";
+ return normalized;
+ }
+
+ private async findOnlineSongIdForLocal(song: SongType): Promise {
+ const artistsText = this.getArtistsText(song);
+ const targetName = LyricManager.normalizeMetadataField(song.name || "");
+ const targetArtists = LyricManager.normalizeMetadataField(artistsText);
+ const targetAlbum = LyricManager.normalizeMetadataField(this.getAlbumText(song));
+ const duration = Number(song.duration || 0);
+ const hasReliableDuration = duration > 5_000;
+
+ // 元数据过弱:title 太短直接放弃;
+ // 缺 artist 时,需要 title 稳定 + 时长可比对,否则极易错配
+ if (!targetName || targetName.length < 2) return null;
+ if (!targetArtists && (!hasReliableDuration || targetName.length < 4)) return null;
+
+ const cacheKey = `local_${song.id}`;
+ const cached = await this.readLocalMatchCache(cacheKey);
+ if (cached !== undefined) return cached;
+
+ const inFlight = this.localMatchInFlight.get(cacheKey);
+ if (inFlight) return inFlight;
+
+ const task = (async (): Promise => {
+ const keyword = artistsText && targetArtists ? `${song.name} ${artistsText}` : song.name;
+ try {
+ const response = await searchResult(keyword, 8, 0, SearchTypes.Single);
+ const songs = response?.result?.songs as SearchSongCandidate[] | undefined;
+ if (!Array.isArray(songs)) {
+ await this.writeLocalMatchCache(cacheKey, null);
+ return null;
+ }
+ const sorted = songs
+ .map((candidate) => {
+ const candidateName = LyricManager.normalizeMetadataField(candidate.name || "");
+ const candidateArtists = LyricManager.normalizeMetadataField(
+ Array.isArray(candidate.ar)
+ ? candidate.ar.map((artist) => artist?.name).join("/")
+ : Array.isArray(candidate.artists)
+ ? candidate.artists.map((artist) => artist?.name).join("/")
+ : "",
+ );
+ const candidateAlbum = LyricManager.normalizeMetadataField(
+ candidate.al?.name || candidate.album?.name || "",
+ );
+ const candidateDuration = Number(candidate.dt || candidate.duration || 0);
+
+ // title 评分
+ let titleScore = 0;
+ if (candidateName === targetName) titleScore = 5;
+ else if (candidateName.includes(targetName) || targetName.includes(candidateName)) {
+ titleScore = 2;
+ }
+
+ // artist 评分:仅 target 与 candidate 都非空时才参与
+ let artistScore = 0;
+ let artistAgrees = false;
+ if (targetArtists && candidateArtists) {
+ if (candidateArtists === targetArtists) {
+ artistScore = 4;
+ artistAgrees = true;
+ } else if (
+ candidateArtists.includes(targetArtists) ||
+ targetArtists.includes(candidateArtists)
+ ) {
+ artistScore = 2;
+ artistAgrees = true;
+ }
+ }
+
+ // album 评分
+ let albumScore = 0;
+ if (targetAlbum && candidateAlbum && targetAlbum === candidateAlbum) albumScore = 2;
+
+ // duration 评分
+ let durationScore = 0;
+ let durationAgrees = false;
+ if (duration > 0 && candidateDuration > 0) {
+ const diff = Math.abs(candidateDuration - duration);
+ if (diff <= 3000) {
+ durationScore = 3;
+ durationAgrees = true;
+ } else if (diff > 8000) durationScore = -4;
+ }
+
+ const score = titleScore + artistScore + albumScore + durationScore;
+ return {
+ id: candidate.id,
+ score,
+ titleScore,
+ artistAgrees,
+ durationAgrees,
+ candidateArtists,
+ };
+ })
+ .filter(
+ (
+ candidate,
+ ): candidate is {
+ id: number;
+ score: number;
+ titleScore: number;
+ artistAgrees: boolean;
+ durationAgrees: boolean;
+ candidateArtists: string;
+ } => typeof candidate.id === "number",
+ )
+ .sort((a, b) => b.score - a.score);
+
+ const best = sorted[0];
+ // 命中条件分两类:
+ // (a) 有可靠 artist 元数据:要求 title 至少有重合 + artist 互相包含 + score>=7
+ // (b) artist 缺失但 title 长度>=4 + 时长接近:score>=8 且 title 是精确匹配
+ let ok = false;
+ if (best) {
+ if (targetArtists) {
+ ok = best.score >= 7 && best.titleScore > 0 && best.artistAgrees;
+ } else {
+ ok = best.score >= 8 && best.titleScore === 5 && best.durationAgrees;
+ }
+ }
+ const onlineId = ok && best ? best.id : null;
+ await this.writeLocalMatchCache(cacheKey, onlineId);
+ return onlineId;
+ } catch (error) {
+ console.warn("本地歌曲在线歌词匹配失败:", error);
+ await this.writeLocalMatchCache(cacheKey, null);
+ return null;
+ } finally {
+ this.localMatchInFlight.delete(cacheKey);
+ }
+ })();
+ this.localMatchInFlight.set(cacheKey, task);
+ return task;
+ }
+
+ private async fetchMatchedOnlineLyricForLocal(song: SongType): Promise {
+ const onlineId = await this.findOnlineSongIdForLocal(song);
+ if (!onlineId) return null;
+ const matchedSong: SongType = { ...song, id: onlineId, path: undefined };
+ const result = await this.fetchOnlineLyric(matchedSong);
+ if (!result.data.lrcData.length && !result.data.yrcData.length) return null;
+ return result;
+ }
+
/**
* 切换歌词源优先级
* @param source 优先级标识
@@ -344,7 +594,7 @@ class LyricManager {
yrcLines = alignLyrics(yrcLines, parseLrc(data.yromalrc.lyric), "romanLyric");
}
if (lrcLines.length) result.lrcData = lrcLines;
- // 如果没有 TTML 且没有 QM YRC,则采用 网易云 YRC
+ // 如果没有 TTML 且没有 QM YRC,则采用在线 YRC
if (!result.yrcData.length && yrcLines.length) {
// 再次确认优先级,如果是 TTML 优先但 TTML 没结果,这里可以用 YRC
result.yrcData = yrcLines;
@@ -460,7 +710,9 @@ class LyricManager {
// sidecar 查找失败,继续后续流程
}
- // 无本地歌词,尝试在线 QQ 匹配
+ // 无本地歌词,尝试在线匹配
+ const matchedOnline = await this.fetchMatchedOnlineLyricForLocal(song);
+ if (matchedOnline) return matchedOnline;
if (settingStore.localLyricQQMusicMatch && song) {
const qqLyric = await this.fetchQQMusicLyric(song);
if (qqLyric && (qqLyric.lrcData.length > 0 || qqLyric.yrcData.length > 0)) {
@@ -474,9 +726,16 @@ class LyricManager {
}
// Electron 端:使用原有 IPC 逻辑
- const { lyric, format }: { lyric?: string; format?: "lrc" | "ttml" | "yrc" } =
- await window.electron.ipcRenderer.invoke("get-music-lyric", song.path);
- if (!lyric) return defaultResult;
+ const electron = window.electron;
+ if (!electron) return defaultResult;
+ const { lyric, format } = await electron.ipcRenderer.invoke(
+ "get-music-lyric",
+ song.path,
+ );
+ if (!lyric) {
+ const matchedOnline = await this.fetchMatchedOnlineLyricForLocal(song);
+ return matchedOnline || defaultResult;
+ }
// YRC 直接解析
if (format === "yrc") {
let lines: LyricLine[] = [];
@@ -558,7 +817,9 @@ class LyricManager {
const lyricDirs = Array.isArray(localLyricPath) ? localLyricPath.map((p) => String(p)) : [];
// 读取本地歌词
- const { lrc, ttml } = await window.electron.ipcRenderer.invoke(
+ const electron = window.electron;
+ if (!electron) return defaultResult;
+ const { lrc, ttml } = await electron.ipcRenderer.invoke(
"read-local-lyric",
lyricDirs,
id,
@@ -798,7 +1059,7 @@ class LyricManager {
return false;
}
// ttml 特有属性
- if (newLine.isBG !== oldLine.isBG) return false;
+ if (!!newLine.isBG !== !!oldLine.isBG) return false;
}
return true;
};
@@ -861,8 +1122,9 @@ class LyricManager {
// 仅更新加载状态,不更新歌词数据
statusStore.lyricLoading = false;
// 单曲循环时,歌词数据未变,需通知桌面歌词取消加载状态
- if (isElectron) {
- window.electron.ipcRenderer.send("desktop-lyric:update-data", {
+ const electron = window.electron;
+ if (isElectron && electron) {
+ electron.ipcRenderer.send("desktop-lyric:update-data", {
lyricLoading: false,
});
}
@@ -965,7 +1227,7 @@ class LyricManager {
try {
// 判断歌词来源
- const isLocal = Boolean(song.path) || false;
+ const isLocal = Boolean(song.path);
if (isStreaming) {
fetchResult = await this.fetchStreamingLyric(song);
} else {
diff --git a/src/core/player/MediaSessionManager.ts b/src/core/player/MediaSessionManager.ts
index 88a496644..157a30b67 100644
--- a/src/core/player/MediaSessionManager.ts
+++ b/src/core/player/MediaSessionManager.ts
@@ -332,12 +332,20 @@ class MediaSessionManager {
if (isCapacitorAndroid) {
await this.syncAndroidApiContext();
+ // 本地歌曲封面:JS 侧 metadata.coverUrl 已经被 Capacitor.convertFileSrc 转成
+ // https://localhost/_capacitor_file_/...,原生 HttpURLConnection 拿不到自签证书。
+ // 这里优先取 song.cover 的原始 file:// 路径交给 Java,Java 侧的 file:// 分支可直接 decodeFile。
+ const rawCover = typeof song.cover === "string" ? song.cover : "";
+ const nativeCoverUrl =
+ song.path && (rawCover.startsWith("file://") || rawCover.startsWith("content://"))
+ ? rawCover
+ : metadata.coverUrl;
await AndroidNativePlayback.updateMetadata({
songId: typeof song.id === "number" ? song.id : undefined,
title: metadata.title,
artist: metadata.artist,
album: metadata.album,
- coverUrl: metadata.coverUrl,
+ coverUrl: nativeCoverUrl,
durationMs: song.duration || 0,
canLike: !song.path && song.type !== "streaming",
});
diff --git a/src/core/player/PlayerController.ts b/src/core/player/PlayerController.ts
index 964ddf005..6798cb3df 100644
--- a/src/core/player/PlayerController.ts
+++ b/src/core/player/PlayerController.ts
@@ -1,7 +1,7 @@
import { toRaw } from "vue";
import { AudioErrorCode } from "@/core/audio-player/BaseAudioPlayer";
import { useDataStore, useMusicStore, useSettingStore, useStatusStore } from "@/stores";
-import type { AudioSourceType, QualityType, SongType } from "@/types/main";
+import { QualityType, type AudioSourceType, type SongType } from "@/types/main";
import type { RepeatModeType, ShuffleModeType } from "@/types/shared/play-mode";
import { type AudioAnalysis } from "@/types/audio/automix";
import { calculateLyricIndex } from "@/utils/calc";
@@ -613,7 +613,7 @@ class PlayerController {
).requestIdleCallback;
const runSync = () => void this.syncAndroidPlaybackContext(song);
if (typeof ric === "function") {
- ric(runSync, { timeout: 500 });
+ this.runIdleWithTimeout(runSync);
} else {
setTimeout(runSync, 0);
}
@@ -1044,6 +1044,8 @@ class PlayerController {
if (musicStore.playSong.type === "streaming") return;
// Android: 没有 Electron IPC,跳过封面/元数据 IPC,仅做媒体会话刷新
if (typeof window === "undefined" || !window.electron?.ipcRenderer) {
+ const statusStore = useStatusStore();
+ statusStore.songQuality = musicStore.playSong.quality;
getCoverColor(musicStore.playSong.cover);
mediaSessionManager.updateMetadata();
await this.syncAndroidPlaybackContext(musicStore.playSong);
@@ -1153,11 +1155,11 @@ class PlayerController {
// 同步状态到 Android 通知栏(仅在非原生 ExoPlayer 引擎下)
if (isCapacitorAndroid) {
if (useAudioManager().engineType !== "android-native") {
- // statusStore 单位为秒,原生 API 用 ms
+ // statusStore 单位为 ms,原生 API 用 ms
void AndroidNativePlayback.syncRemoteState({
playing: true,
- positionMs: Math.max(0, Math.round(statusStore.currentTime * 1000)),
- durationMs: Math.max(0, Math.round(statusStore.duration * 1000)),
+ positionMs: Math.max(0, Math.round(statusStore.currentTime)),
+ durationMs: Math.max(0, Math.round(statusStore.duration)),
});
}
this.syncFloatingLyricProgress(statusStore.currentTime, true);
@@ -1179,11 +1181,11 @@ class PlayerController {
// 同步状态到 Android 通知栏(仅在非原生 ExoPlayer 引擎下)
if (isCapacitorAndroid) {
if (useAudioManager().engineType !== "android-native") {
- // statusStore 单位为秒,原生 API 用 ms
+ // statusStore 单位为 ms,原生 API 用 ms
void AndroidNativePlayback.syncRemoteState({
playing: false,
- positionMs: Math.max(0, Math.round(statusStore.currentTime * 1000)),
- durationMs: Math.max(0, Math.round(statusStore.duration * 1000)),
+ positionMs: Math.max(0, Math.round(statusStore.currentTime)),
+ durationMs: Math.max(0, Math.round(statusStore.duration)),
});
}
this.syncFloatingLyricProgress(statusStore.currentTime, false);
@@ -2133,12 +2135,25 @@ class PlayerController {
const ric = (window as Window & { requestIdleCallback?: typeof requestIdleCallback })
.requestIdleCallback;
if (typeof ric === "function") {
- ric(() => run(), { timeout: 500 });
+ this.runIdleWithTimeout(run);
} else {
setTimeout(run, 0);
}
}
+ private runIdleWithTimeout(callback: () => void, timeout = 500) {
+ let done = false;
+ const runOnce = () => {
+ if (done) return;
+ done = true;
+ callback();
+ };
+ const ric = (window as Window & { requestIdleCallback?: typeof requestIdleCallback })
+ .requestIdleCallback;
+ ric?.(runOnce, { timeout });
+ setTimeout(runOnce, timeout);
+ }
+
/**
* 同步歌曲信息到 Android 悬浮歌词
*/
diff --git a/src/core/player/SongManager.ts b/src/core/player/SongManager.ts
index 365e40661..6f0d1ba73 100644
--- a/src/core/player/SongManager.ts
+++ b/src/core/player/SongManager.ts
@@ -52,6 +52,15 @@ class SongManager {
/** 预载下一首歌曲播放信息 */
private nextPrefetch: AudioSource | undefined;
+ private encodeLocalFilePath(path: string): string {
+ const safePath = path.replace(/%(?![0-9a-fA-F]{2})/g, "%25");
+ return encodeURI(safePath)
+ .replace(/#/g, "%23")
+ .replace(/\?/g, "%3F")
+ .replace(/\[/g, "%5B")
+ .replace(/\]/g, "%5D");
+ }
+
public peekPrefetch(id: number): AudioSource | undefined {
if (!this.nextPrefetch) return;
if (this.nextPrefetch.id !== id) return;
@@ -464,8 +473,21 @@ class SongManager {
// 本地文件直接返回
if (song.path && song.type !== "streaming") {
// Android SAF URI 直接交给 ExoPlayer,无需 file:// 前缀
- if (song.path.startsWith("content://")) {
- return { id: song.id, url: song.path, source: "local" };
+ if (isCapacitorAndroid) {
+ if (song.path.startsWith("content://")) {
+ return { id: song.id, url: song.path, quality: song.quality, source: "local" };
+ }
+ if (song.path.startsWith("file://")) {
+ // file:// 路径仍需转义 # / ?,否则 Uri.parse 会截断为 fragment/query
+ const rawPath = song.path.slice("file://".length);
+ const encodedPath = this.encodeLocalFilePath(rawPath);
+ return {
+ id: song.id,
+ url: `file://${encodedPath}`,
+ quality: song.quality,
+ source: "local",
+ };
+ }
}
// 检查本地文件是否存在
const result = await window.electron.ipcRenderer.invoke("file-exists", song.path);
@@ -474,8 +496,8 @@ class SongManager {
console.error("❌ 本地文件不存在");
return { id: song.id, url: undefined };
}
- const encodedPath = song.path.replace(/#/g, "%23").replace(/\?/g, "%3F");
- return { id: song.id, url: `file://${encodedPath}`, source: "local" };
+ const encodedPath = this.encodeLocalFilePath(song.path);
+ return { id: song.id, url: `file://${encodedPath}`, quality: song.quality, source: "local" };
}
// Stream songs (Subsonic / Jellyfin)
diff --git a/src/stores/music.ts b/src/stores/music.ts
index d21f89d38..c046c63c2 100644
--- a/src/stores/music.ts
+++ b/src/stores/music.ts
@@ -1,153 +1,158 @@
-import { defineStore } from "pinia";
-import type { SongType } from "@/types/main";
-import { isCapacitorAndroid, isElectron } from "@/utils/env";
-import { cloneDeep } from "lodash-es";
-import { SongLyric } from "@/types/lyric";
-import { sendTaskbarLyrics } from "@/core/player/PlayerIpc";
+import { defineStore } from "pinia";
+import { Capacitor } from "@capacitor/core";
+import type { SongType } from "@/types/main";
+import { isCapacitorAndroid, isElectron } from "@/utils/env";
+import { cloneDeep } from "lodash-es";
+import { SongLyric } from "@/types/lyric";
+import { sendTaskbarLyrics } from "@/core/player/PlayerIpc";
+
+interface MusicState {
+ playSong: SongType;
+ playPlaylistId: number;
+ songLyric: SongLyric;
+ personalFM: {
+ playIndex: number;
+ list: SongType[];
+ };
+ dailySongsData: {
+ timestamp: number | null;
+ list: SongType[];
+ };
+}
+
+// 默认音乐数据
+const defaultMusicData: SongType = {
+ id: 0,
+ name: "未播放歌曲",
+ artists: "未知歌手",
+ album: "未知专辑",
+ cover: "/images/song.jpg?asset",
+ duration: 0,
+ free: 0,
+ mv: null,
+ type: "song",
+};
+
+export const useMusicStore = defineStore("music", {
+ state: (): MusicState => ({
+ // 当前播放歌曲
+ playSong: { ...defaultMusicData },
+ // 当前播放歌单
+ playPlaylistId: 0,
+ // 当前歌曲歌词
+ songLyric: {
+ lrcData: [], // 普通歌词
+ yrcData: [], // 逐字歌词
+ },
+ // 私人FM数据
+ personalFM: {
+ playIndex: 0,
+ list: [],
+ },
+ // 每日推荐
+ dailySongsData: {
+ timestamp: null, // 更新时间
+ list: [], // 歌曲数据
+ },
+ }),
+ getters: {
+ // 是否具有歌词
+ isHasLrc(state): boolean {
+ return state.songLyric.lrcData.length > 0 && state.playSong.type !== "radio";
+ },
+ // 是否具有逐字歌词
+ isHasYrc(state): boolean {
+ return state.songLyric.yrcData.length > 0;
+ },
+ // 是否有播放器
+ isHasPlayer(state): boolean {
+ return state.playSong?.id !== 0;
+ },
+ /** 歌曲封面 */
+ songCover(state): string {
+ return resolveCoverForWebView(state.playSong.coverSize?.s || state.playSong.cover);
+ },
+ // 私人FM播放歌曲
+ personalFMSong(state): SongType {
+ return state.personalFM.list?.[state.personalFM.playIndex] || defaultMusicData;
+ },
+ },
+ actions: {
+ /** 重置音乐数据 */
+ resetMusicData() {
+ this.playSong = { ...defaultMusicData };
+ this.playPlaylistId = 0;
+ this.setSongLyric({ lrcData: [], yrcData: [] }, true);
+ if (isElectron) {
+ window.electron.ipcRenderer.send("play-song-change", null);
+ }
+ },
+ /**
+ * 设置/更新歌曲歌词数据
+ * @param updates 部分或完整歌词数据
+ * @param replace 是否覆盖(true:用提供的数据覆盖并为缺省字段置空;false:合并更新)
+ */
+ setSongLyric(updates: Partial, replace: boolean = false) {
+ if (replace) {
+ this.songLyric = {
+ lrcData: updates.lrcData ?? [],
+ yrcData: updates.yrcData ?? [],
+ };
+ } else {
+ this.songLyric = {
+ lrcData: updates.lrcData ?? this.songLyric.lrcData,
+ yrcData: updates.yrcData ?? this.songLyric.yrcData,
+ };
+ }
+ // 更新歌词窗口数据
+ if (isElectron) {
+ // 桌面歌词
+ window.electron.ipcRenderer.send(
+ "play-lyric-change",
+ cloneDeep({
+ songId: this.playSong?.id,
+ lyricLoading: false,
+ lrcData: this.songLyric.lrcData ?? [],
+ yrcData: this.songLyric.yrcData ?? [],
+ }),
+ );
+ // 状态栏歌词
+ sendTaskbarLyrics(this.songLyric);
+ }
+ // Android 悬浮歌词同步
+ if (isCapacitorAndroid) {
+ import("@/core/player/PlayerController").then(({ usePlayerController }) => {
+ try {
+ const player = usePlayerController();
+ player.syncFloatingLyricData();
+ } catch (error) {
+ console.warn("同步 Android 悬浮歌词失败:", error);
+ }
+ });
+ }
+ },
+ // 获取歌曲封面
+ getSongCover(size: "s" | "m" | "l" | "xl" | "cover" = "s") {
+ return resolveCoverForWebView(size === "cover" ? this.playSong.cover : this.playSong.coverSize?.[size] || this.playSong.cover);
+ },
+ },
+ // 持久化
+ // songLyric 不进持久化:YRC/TTML 逐字歌词序列化可达 100-500KB,
+ // 每次切歌 setSongLyric 都会触发同步 localStorage 写入,手机 WebView 上单次 50-200ms 阻塞主线程,
+ // 进度事件与 UI 交互全被锁住。歌词随播放重新拉取/缓存,无需持久化。
+ persist: {
+ key: "music-store",
+ storage: localStorage,
+ pick: ["playSong", "playPlaylistId", "personalFM", "dailySongsData"],
+ },
+});
-interface MusicState {
- playSong: SongType;
- playPlaylistId: number;
- songLyric: SongLyric;
- personalFM: {
- playIndex: number;
- list: SongType[];
- };
- dailySongsData: {
- timestamp: number | null;
- list: SongType[];
- };
-}
-
-// 默认音乐数据
-const defaultMusicData: SongType = {
- id: 0,
- name: "未播放歌曲",
- artists: "未知歌手",
- album: "未知专辑",
- cover: "/images/song.jpg?asset",
- duration: 0,
- free: 0,
- mv: null,
- type: "song",
+const resolveCoverForWebView = (url?: string) => {
+ if (!url) return "";
+ if (!isCapacitorAndroid || (!url.startsWith("file://") && !url.startsWith("content://"))) return url;
+ try {
+ return Capacitor.convertFileSrc(url);
+ } catch {
+ return url;
+ }
};
-
-export const useMusicStore = defineStore("music", {
- state: (): MusicState => ({
- // 当前播放歌曲
- playSong: { ...defaultMusicData },
- // 当前播放歌单
- playPlaylistId: 0,
- // 当前歌曲歌词
- songLyric: {
- lrcData: [], // 普通歌词
- yrcData: [], // 逐字歌词
- },
- // 私人FM数据
- personalFM: {
- playIndex: 0,
- list: [],
- },
- // 每日推荐
- dailySongsData: {
- timestamp: null, // 更新时间
- list: [], // 歌曲数据
- },
- }),
- getters: {
- // 是否具有歌词
- isHasLrc(state): boolean {
- return state.songLyric.lrcData.length > 0 && state.playSong.type !== "radio";
- },
- // 是否具有逐字歌词
- isHasYrc(state): boolean {
- return state.songLyric.yrcData.length > 0;
- },
- // 是否有播放器
- isHasPlayer(state): boolean {
- return state.playSong?.id !== 0;
- },
- /** 歌曲封面 */
- songCover(state): string {
- return state.playSong.path
- ? state.playSong.cover
- : state.playSong.coverSize?.s || state.playSong.cover;
- },
- // 私人FM播放歌曲
- personalFMSong(state): SongType {
- return state.personalFM.list?.[state.personalFM.playIndex] || defaultMusicData;
- },
- },
- actions: {
- /** 重置音乐数据 */
- resetMusicData() {
- this.playSong = { ...defaultMusicData };
- this.playPlaylistId = 0;
- this.setSongLyric({ lrcData: [], yrcData: [] }, true);
- if (isElectron) {
- window.electron.ipcRenderer.send("play-song-change", null);
- }
- },
- /**
- * 设置/更新歌曲歌词数据
- * @param updates 部分或完整歌词数据
- * @param replace 是否覆盖(true:用提供的数据覆盖并为缺省字段置空;false:合并更新)
- */
- setSongLyric(updates: Partial, replace: boolean = false) {
- if (replace) {
- this.songLyric = {
- lrcData: updates.lrcData ?? [],
- yrcData: updates.yrcData ?? [],
- };
- } else {
- this.songLyric = {
- lrcData: updates.lrcData ?? this.songLyric.lrcData,
- yrcData: updates.yrcData ?? this.songLyric.yrcData,
- };
- }
- // 更新歌词窗口数据
- if (isElectron) {
- // 桌面歌词
- window.electron.ipcRenderer.send(
- "play-lyric-change",
- cloneDeep({
- songId: this.playSong?.id,
- lyricLoading: false,
- lrcData: this.songLyric.lrcData ?? [],
- yrcData: this.songLyric.yrcData ?? [],
- }),
- );
- // 状态栏歌词
- sendTaskbarLyrics(this.songLyric);
- }
- // Android 悬浮歌词同步
- if (isCapacitorAndroid) {
- import("@/core/player/PlayerController").then(({ usePlayerController }) => {
- try {
- const player = usePlayerController();
- player.syncFloatingLyricData();
- } catch (error) {
- console.warn("同步 Android 悬浮歌词失败:", error);
- }
- });
- }
- },
- // 获取歌曲封面
- getSongCover(size: "s" | "m" | "l" | "xl" | "cover" = "s") {
- return this.playSong.path
- ? this.playSong.cover
- : size === "cover"
- ? this.playSong.cover
- : this.playSong.coverSize?.[size] || this.playSong.cover;
- },
- },
- // 持久化
- // songLyric 不进持久化:YRC/TTML 逐字歌词序列化可达 100-500KB,
- // 每次切歌 setSongLyric 都会触发同步 localStorage 写入,手机 WebView 上单次 50-200ms 阻塞主线程,
- // 进度事件与 UI 交互全被锁住。歌词随播放重新拉取/缓存,无需持久化。
- persist: {
- key: "music-store",
- storage: localStorage,
- pick: ["playSong", "playPlaylistId", "personalFM", "dailySongsData"],
- },
-});
diff --git a/src/utils/format.ts b/src/utils/format.ts
index 8ca594f8b..85abf4041 100644
--- a/src/utils/format.ts
+++ b/src/utils/format.ts
@@ -1,426 +1,438 @@
-import { useDataStore, useMusicStore, useStatusStore } from "@/stores";
-import type { ArtistType, CatType, CommentType, CoverType, MetaData, SongType } from "@/types/main";
-import { flatMap, isArray, uniqBy } from "lodash-es";
-import { handleSongQuality } from "./helper";
-import { msToTime } from "./time";
-
-/**
- * 格式化评论数量
- * @param count 评论数量
- * @returns 格式化后的评论数量
- */
-export const formatCommentCount = (count: number): string | number => {
- if (count >= 10000) {
- const val = Math.floor(count / 1000) / 10;
- return `${val % 1 === 0 ? val.toFixed(0) : val}W+`;
- }
- if (count >= 1000) {
- const val = Math.floor(count / 100) / 10;
- return `${val % 1 === 0 ? val.toFixed(0) : val}K+`;
- }
- return count;
-};
-
-/**
- * 移除文本中的括号内容(支持中英文括号)
- * @param text 原始文本
- * @returns 处理后的文本
- */
-export const removeBrackets = (text: string | undefined): string => {
- if (!text) return "";
- return text.replace(/[((][^))]*[))]/g, "").trim();
-};
-
-type CoverDataType = {
- cover: string;
- coverSize?: {
- s: string;
- m: string;
- l: string;
- xl: string;
- };
-};
-
-/**
- * 格式化歌曲列表
- * @param data 歌曲数据
- * @returns 格式化后的歌曲列表
- */
-export const formatSongsList = (data: any[]): SongType[] => {
- if (!data) return [];
- data = isArray(data) ? data : [data];
- return data.filter(Boolean).map((item) => {
- // 特殊处理
- item = item?.simpleSong ? { ...item.simpleSong, pc: true } : item?.songInfo || item;
- // 歌手数据
- const artist = (): MetaData[] | string => {
- const artistData = item.artist ?? item.artists ?? item.ar;
- if (!artistData) return "";
- if (typeof artistData === "string") return artistData;
- const artistArr = [item.artist, item.artists, item.ar].flat().filter(Boolean);
- if (!artistArr.length) return "";
- return artistArr.map((ar) => ({
- id: ar?.id,
- name: typeof ar === "string" ? ar : ar.name,
- cover: ar?.img1v1Url || ar?.picUrl,
- alias: ar?.alias,
- }));
- };
- return {
- id: item.id,
- name: item.name,
- artists: artist(),
- album:
- typeof item.album === "string"
- ? item.album
- : {
- id: (item.album || item.al)?.id,
- name: (item.album || item.al)?.name,
- cover: (item.album || item.al)?.picUrl,
- },
- alia: isArray(item.alia || item.alias || item.transNames || item.tns)
- ? item.alia?.[0] || item.alias?.[0] || item.transNames?.[0] || item.tns?.[0]
- : item.alia,
- dj: item.dj
- ? {
- id: item.mainTrackId || item.id,
- radioId: item.radio?.id,
- name: item.dj?.brand,
- creator: item.dj?.nickname,
- }
- : undefined,
- ...getCoverUrl(item),
- duration: Number(item.duration || item.dt || 0),
- originCoverType: item?.originCoverType,
- free: item.fee || 0,
- mv: item.mv,
- mark: item.mark,
- size: Number(item.size || 0),
- path: item.path,
- pc: !!item.pc,
- quality: item?.path
- ? handleSongQuality(item.quality, "local")
- : handleSongQuality(item, "online"),
- playCount: Number(item.playCount || item.listenerCount || 0),
- createTime: Number(item.createTime || item.publishTime) || undefined,
- updateTime: Number(item.lastProgramCreateTime || item.scheduledPublishTime) || undefined,
- type: item?.dj ? "radio" : "song",
- };
- });
-};
-
-/**
- * 格式化封面列表
- * @param data 封面数据
- * @returns 格式化后的封面列表
- */
-export const formatCoverList = (data: any[]): CoverType[] => {
- if (!data) return [];
- data = isArray(data) ? data : [data];
- return data.filter(Boolean).map((item) => {
- // 处理数据
- const creator = isArray(item.creator) ? item.creator[0] : item.creator;
- // 获取歌手信息
- const artists = (): string | MetaData[] => {
- const artistData = uniqBy(
- flatMap([item.artist, item.artists, item.ar]).filter(Boolean),
- "id",
- );
- if (artistData.length === 0) return "";
- return artistData.map((artist) => ({
- id: artist?.id,
- name: artist?.name,
- cover: artist?.img1v1Url || artist?.picUrl,
- alias: artist?.alias,
- }));
- };
- return {
- id: item.id || item.vid,
- name: item.name || item.title,
- ...getCoverUrl(item),
- description: item.description || item.desc,
- updateTip: item.updateFrequency,
- creator: {
- id: creator?.userId || item.dj?.userId || 0,
- name: creator?.nickname || creator?.name || creator?.userName || item.dj?.nickname || "",
- avatarUrl: creator?.avatarUrl || item.dj?.avatarUrl || "",
- },
- artists: artists(),
- count: item.trackCount ?? item.size ?? item.programCount ?? 0,
- tags:
- item.tags ||
- item.algTags ||
- item.videoGroup?.map((tag: any) => tag.name) ||
- (item.category ? [item.category] : []),
- userId: item.userId,
- playCount: item.playCount,
- commentCount: item.commentCount,
- shareCount: item.shareCount,
- subCount: item.subCount,
- privacy: item.privacy,
- liked: item.liked,
- likedCount: item.likedCount,
- duration: msToTime(item.duration || item.dt || item.playTime),
- createTime: item.createTime || item.publishTime,
- updateTime: item.updateTime || item.trackNumberUpdateTime || item.trackUpdateTime,
- // 热榜特殊数据
- tracks: item.tracks,
- };
- });
-};
-
-/**
- * 格式化歌手列表
- * @param data 歌手数据
- * @returns 格式化后的歌手列表
- */
-export const formatArtistsList = (data: any[]): ArtistType[] => {
- if (!data) return [];
- data = isArray(data) ? data : [data];
- return data.filter(Boolean).map((item) => ({
- id: item.id,
- name: item.name,
- ...getCoverUrl(item),
- alia: item.alias?.[0],
- identify: item?.identifyTag?.[0],
- description: item.description || item.briefDesc,
- albumSize: item.albumSize,
- musicSize: item.musicSize,
- mvSize: item.mvSize,
- fansSize: item.fans,
- }));
-};
-
-/**
- * 格式化评论列表
- * @param data 评论数据
- * @returns 格式化后的评论列表
- */
-export const formatCommentList = (data: any[]): CommentType[] => {
- if (!data) return [];
- data = isArray(data) ? data : [data];
- return data.filter(Boolean).map((item) => ({
- id: item.commentId,
- content: item.content,
- beReplied:
- item.beReplied?.length > 0
- ? {
- content: item.beReplied[0]?.content,
- user: {
- id: item.beReplied[0]?.user.userId,
- name: item.beReplied[0]?.user.nickname,
- avatarUrl: item.beReplied[0]?.user.avatarUrl,
- },
- }
- : undefined,
- time: item.time,
- likedCount: item.likedCount,
- liked: item.liked,
- user: {
- id: item.user.userId,
- name: item.user.nickname,
- avatarUrl: item.user.avatarUrl,
- vipType: item.user.vipType,
- vipLevel: item.user.vipRights?.redVipLevel,
- vipIconUrl: item.user.vipRights?.associator?.iconUrl,
- isAnnualCount: item.user.vipRights?.redVipAnnualCount > 0,
- },
- ip: item?.ip
- ? {
- ip: item.ip,
- location: item.location,
- }
- : undefined,
- }));
-};
-
-/**
- * 格式化分类列表
- * @param data 分类数据
- * @returns 格式化后的分类列表
- */
-export const formatCategoryList = (data: any[]): CatType[] => {
- if (!data) return [];
- data = isArray(data) ? data : [data];
- return data.filter(Boolean).map((item) => ({
- name: item.name,
- category: item.category,
- hot: item.hot,
- count: item.resourceCount,
- }));
-};
-
-/**
- * 获取封面图片 URL
- * @param item 封面数据项
- * @returns 格式化后的封面数据
- */
-const getCoverUrl = (item: any): CoverDataType => {
- const cover =
- item.cover ||
- item.picUrl ||
- item.coverUrl ||
- item.coverImgUrl ||
- item.imgurl ||
- item.img1v1Url ||
- (item.album || item.al)?.picUrl ||
- item.al?.xInfo?.picUrl;
- const coverSize = {
- s: getCoverSizeUrl(cover, 100),
- m: getCoverSizeUrl(cover, 300),
- l: getCoverSizeUrl(cover, 1024),
- xl: getCoverSizeUrl(cover, 1920),
- };
- return { cover, coverSize };
-};
-
-/**
- * 获取封面图片不同尺寸 URL
- * @param url 封面图片 URL
- * @param size 尺寸参数(可选)
- * @returns 格式化后的封面图片 URL
- */
-const getCoverSizeUrl = (url: string, size: number | null = null) => {
- try {
- if (!url) return "/images/song.jpg?asset";
- const sizeUrl = size
- ? typeof size === "number"
- ? `?param=${size}y${size}`
- : `?param=${size}`
- : "";
- const imageUrl = url?.replace(/^http:/, "https:");
- if (imageUrl.endsWith(".jpg")) {
- return imageUrl + sizeUrl;
- }
- if (imageUrl.endsWith("&")) {
- const url = imageUrl + "cl";
- return url.replace(/(thumbnail=[0-9]+y[0-9]+&cl)/, `thumbnail=${size}y${size}&`);
- }
- return imageUrl;
- } catch (error) {
- console.error("图片链接处理出错:", error);
- return "/images/song.jpg?asset";
- }
-};
-
-/**
- * 检测歌词语言
- * @param lyric 歌词内容
- * @returns 语言代码("ja" | "zh-CN" | "en")
- */
-export const getLyricLanguage = (lyric: string): "ja" | "ko" | "zh-CN" | "en" => {
- if (!lyric || typeof lyric !== "string") return "en";
- // 判断日语 根据平假名和片假名
- if (/[\u3040-\u309F\u30A0-\u30FF]/.test(lyric)) return "ja";
- // 判断韩语 根据韩文音节
- if (/[\uAC00-\uD7AF]/.test(lyric)) return "ko";
- // 判断简体中文 根据中日韩统一表意文字基本区
- if (/[\u4E00-\u9FFF]/.test(lyric)) return "zh-CN";
- // 默认英语
- return "en";
-};
-
-/**
- * 获取当前播放歌曲
- * @returns 当前播放歌曲
- */
-export const getPlaySongData = (): SongType | null => {
- const dataStore = useDataStore();
- const musicStore = useMusicStore();
- const statusStore = useStatusStore();
- // 若为私人FM
- if (statusStore.personalFmMode) {
- return musicStore.personalFMSong;
- }
- // 播放列表
- const playlist = dataStore.playList;
- if (!playlist.length) return null;
- return playlist[statusStore.playIndex];
-};
-
-/**
- * 获取播放信息对象
- * @param song 歌曲
- * @param sep 分隔符
- * @returns 播放信息对象
- */
-export const getPlayerInfoObj = (
- song?: SongType,
- sep: string = "/",
-): { name: string; artist: string; album: string } | null => {
- const musicStore = useMusicStore();
- const playSongData = song || getPlaySongData() || musicStore.playSong;
-
- if (!playSongData) return null;
-
- // 标题
- const name = `${playSongData.name || "未知歌曲"}`;
-
- // 歌手
- const artist =
- playSongData.type === "radio"
- ? playSongData.dj?.creator || "未知播客"
- : Array.isArray(playSongData.artists)
- ? playSongData.artists.map((artists: { name: string }) => artists.name).join(sep)
- : String(playSongData?.artists || "未知歌手");
-
- // 专辑
- const album =
- playSongData.type === "radio"
- ? playSongData.dj?.name || "未知播客"
- : typeof playSongData.album === "object"
- ? playSongData.album.name
- : String(playSongData.album || "未知专辑");
-
- return { name, artist, album };
-};
-
-/**
- * 获取播放信息
- * @param song 歌曲
- * @param sep 分隔符
- * @returns 播放信息
- */
-export const getPlayerInfo = (song?: SongType, sep: string = "/"): string | null => {
- const info = getPlayerInfoObj(song, sep);
- if (!info) return null;
- return `${info.name} - ${info.artist}`;
-};
-
-/**
- * 检测所有输入行的共同最小缩进,将其从每一行中删除,如果第一行和最后一行是空白行,也将其删除
- * @param string 字符串
- * @param lineSplit 分割时的换行符
- * @param lineJoin 连接时的换行符
- * @returns 去除缩进后的字符串
- */
-export const trimIndentString = (
- string: string,
- lineSplit: string = "\n",
- lineJoin: string = lineSplit,
-): string => {
- if (!string) return "";
- const lines = string.split(lineSplit);
- // 删除第一行和最后一行的空白行
- const relevantLines = lines.filter(
- (line, index) => (index !== 0 && index !== lines.length - 1) || line.trim() !== "",
- );
- // 移除每行的最小缩进
- const minIndent = relevantLines
- .filter((line) => line.trim() !== "")
- .map((line) => line.match(/^\s*/)?.[0].length ?? 0)
- .reduce((min, indent) => Math.min(min, indent), Infinity);
- const trimmedLines = relevantLines.map((line) => line.slice(minIndent));
- return trimmedLines.join(lineJoin);
-};
-
-/**
- * 设置中多行描述的模板标签功能
- * 删除最小公共缩进并将换行符转换为 HTML
标记
- *
- * @see trimIndentString
- */
-export const descMultiline = (strings: TemplateStringsArray, ...values: any[]): string => {
- const fullString = String.raw(strings, ...values);
- return trimIndentString(fullString, "\n", "
");
-};
+import { Capacitor } from "@capacitor/core";
+import { useDataStore, useMusicStore, useStatusStore } from "@/stores";
+import type { ArtistType, CatType, CommentType, CoverType, MetaData, SongType } from "@/types/main";
+import { flatMap, isArray, uniqBy } from "lodash-es";
+import { handleSongQuality } from "./helper";
+import { msToTime } from "./time";
+
+/**
+ * 格式化评论数量
+ * @param count 评论数量
+ * @returns 格式化后的评论数量
+ */
+export const formatCommentCount = (count: number): string | number => {
+ if (count >= 10000) {
+ const val = Math.floor(count / 1000) / 10;
+ return `${val % 1 === 0 ? val.toFixed(0) : val}W+`;
+ }
+ if (count >= 1000) {
+ const val = Math.floor(count / 100) / 10;
+ return `${val % 1 === 0 ? val.toFixed(0) : val}K+`;
+ }
+ return count;
+};
+
+/**
+ * 移除文本中的括号内容(支持中英文括号)
+ * @param text 原始文本
+ * @returns 处理后的文本
+ */
+export const removeBrackets = (text: string | undefined): string => {
+ if (!text) return "";
+ return text.replace(/[((][^))]*[))]/g, "").trim();
+};
+
+type CoverDataType = {
+ cover: string;
+ coverSize?: {
+ s: string;
+ m: string;
+ l: string;
+ xl: string;
+ };
+};
+
+/**
+ * 格式化歌曲列表
+ * @param data 歌曲数据
+ * @returns 格式化后的歌曲列表
+ */
+export const formatSongsList = (data: any[]): SongType[] => {
+ if (!data) return [];
+ data = isArray(data) ? data : [data];
+ return data.filter(Boolean).map((item) => {
+ // 特殊处理
+ item = item?.simpleSong ? { ...item.simpleSong, pc: true } : item?.songInfo || item;
+ // 歌手数据
+ const artist = (): MetaData[] | string => {
+ const artistData = item.artist ?? item.artists ?? item.ar;
+ if (!artistData) return "";
+ if (typeof artistData === "string") return artistData;
+ const artistArr = [item.artist, item.artists, item.ar].flat().filter(Boolean);
+ if (!artistArr.length) return "";
+ return artistArr.map((ar) => ({
+ id: ar?.id,
+ name: typeof ar === "string" ? ar : ar.name,
+ cover: ar?.img1v1Url || ar?.picUrl,
+ alias: ar?.alias,
+ }));
+ };
+ return {
+ id: item.id,
+ name: item.name,
+ artists: artist(),
+ album:
+ typeof item.album === "string"
+ ? item.album
+ : {
+ id: (item.album || item.al)?.id,
+ name: (item.album || item.al)?.name,
+ cover: (item.album || item.al)?.picUrl,
+ },
+ alia: isArray(item.alia || item.alias || item.transNames || item.tns)
+ ? item.alia?.[0] || item.alias?.[0] || item.transNames?.[0] || item.tns?.[0]
+ : item.alia,
+ dj: item.dj
+ ? {
+ id: item.mainTrackId || item.id,
+ radioId: item.radio?.id,
+ name: item.dj?.brand,
+ creator: item.dj?.nickname,
+ }
+ : undefined,
+ ...getCoverUrl(item),
+ duration: Number(item.duration || item.dt || 0),
+ originCoverType: item?.originCoverType,
+ free: item.fee || 0,
+ mv: item.mv,
+ mark: item.mark,
+ size: Number(item.size || 0),
+ path: item.path,
+ pc: !!item.pc,
+ quality: item?.path
+ ? handleSongQuality(item.quality, "local")
+ : handleSongQuality(item, "online"),
+ playCount: Number(item.playCount || item.listenerCount || 0),
+ createTime: Number(item.createTime || item.publishTime) || undefined,
+ updateTime: Number(item.lastProgramCreateTime || item.scheduledPublishTime) || undefined,
+ type: item?.dj ? "radio" : "song",
+ };
+ });
+};
+
+/**
+ * 格式化封面列表
+ * @param data 封面数据
+ * @returns 格式化后的封面列表
+ */
+export const formatCoverList = (data: any[]): CoverType[] => {
+ if (!data) return [];
+ data = isArray(data) ? data : [data];
+ return data.filter(Boolean).map((item) => {
+ // 处理数据
+ const creator = isArray(item.creator) ? item.creator[0] : item.creator;
+ // 获取歌手信息
+ const artists = (): string | MetaData[] => {
+ const artistData = uniqBy(
+ flatMap([item.artist, item.artists, item.ar]).filter(Boolean),
+ "id",
+ );
+ if (artistData.length === 0) return "";
+ return artistData.map((artist) => ({
+ id: artist?.id,
+ name: artist?.name,
+ cover: artist?.img1v1Url || artist?.picUrl,
+ alias: artist?.alias,
+ }));
+ };
+ return {
+ id: item.id || item.vid,
+ name: item.name || item.title,
+ ...getCoverUrl(item),
+ description: item.description || item.desc,
+ updateTip: item.updateFrequency,
+ creator: {
+ id: creator?.userId || item.dj?.userId || 0,
+ name: creator?.nickname || creator?.name || creator?.userName || item.dj?.nickname || "",
+ avatarUrl: creator?.avatarUrl || item.dj?.avatarUrl || "",
+ },
+ artists: artists(),
+ count: item.trackCount ?? item.size ?? item.programCount ?? 0,
+ tags:
+ item.tags ||
+ item.algTags ||
+ item.videoGroup?.map((tag: any) => tag.name) ||
+ (item.category ? [item.category] : []),
+ userId: item.userId,
+ playCount: item.playCount,
+ commentCount: item.commentCount,
+ shareCount: item.shareCount,
+ subCount: item.subCount,
+ privacy: item.privacy,
+ liked: item.liked,
+ likedCount: item.likedCount,
+ duration: msToTime(item.duration || item.dt || item.playTime),
+ createTime: item.createTime || item.publishTime,
+ updateTime: item.updateTime || item.trackNumberUpdateTime || item.trackUpdateTime,
+ // 热榜特殊数据
+ tracks: item.tracks,
+ };
+ });
+};
+
+/**
+ * 格式化歌手列表
+ * @param data 歌手数据
+ * @returns 格式化后的歌手列表
+ */
+export const formatArtistsList = (data: any[]): ArtistType[] => {
+ if (!data) return [];
+ data = isArray(data) ? data : [data];
+ return data.filter(Boolean).map((item) => ({
+ id: item.id,
+ name: item.name,
+ ...getCoverUrl(item),
+ alia: item.alias?.[0],
+ identify: item?.identifyTag?.[0],
+ description: item.description || item.briefDesc,
+ albumSize: item.albumSize,
+ musicSize: item.musicSize,
+ mvSize: item.mvSize,
+ fansSize: item.fans,
+ }));
+};
+
+/**
+ * 格式化评论列表
+ * @param data 评论数据
+ * @returns 格式化后的评论列表
+ */
+export const formatCommentList = (data: any[]): CommentType[] => {
+ if (!data) return [];
+ data = isArray(data) ? data : [data];
+ return data.filter(Boolean).map((item) => ({
+ id: item.commentId,
+ content: item.content,
+ beReplied:
+ item.beReplied?.length > 0
+ ? {
+ content: item.beReplied[0]?.content,
+ user: {
+ id: item.beReplied[0]?.user.userId,
+ name: item.beReplied[0]?.user.nickname,
+ avatarUrl: item.beReplied[0]?.user.avatarUrl,
+ },
+ }
+ : undefined,
+ time: item.time,
+ likedCount: item.likedCount,
+ liked: item.liked,
+ user: {
+ id: item.user.userId,
+ name: item.user.nickname,
+ avatarUrl: item.user.avatarUrl,
+ vipType: item.user.vipType,
+ vipLevel: item.user.vipRights?.redVipLevel,
+ vipIconUrl: item.user.vipRights?.associator?.iconUrl,
+ isAnnualCount: item.user.vipRights?.redVipAnnualCount > 0,
+ },
+ ip: item?.ip
+ ? {
+ ip: item.ip,
+ location: item.location,
+ }
+ : undefined,
+ }));
+};
+
+/**
+ * 格式化分类列表
+ * @param data 分类数据
+ * @returns 格式化后的分类列表
+ */
+export const formatCategoryList = (data: any[]): CatType[] => {
+ if (!data) return [];
+ data = isArray(data) ? data : [data];
+ return data.filter(Boolean).map((item) => ({
+ name: item.name,
+ category: item.category,
+ hot: item.hot,
+ count: item.resourceCount,
+ }));
+};
+
+/**
+ * 获取封面图片 URL
+ * @param item 封面数据项
+ * @returns 格式化后的封面数据
+ */
+const getCoverUrl = (item: any): CoverDataType => {
+ const cover =
+ item.cover ||
+ item.picUrl ||
+ item.coverUrl ||
+ item.coverImgUrl ||
+ item.imgurl ||
+ item.img1v1Url ||
+ (item.album || item.al)?.picUrl ||
+ item.al?.xInfo?.picUrl;
+ const coverSize = {
+ s: getCoverSizeUrl(cover, 100),
+ m: getCoverSizeUrl(cover, 300),
+ l: getCoverSizeUrl(cover, 1024),
+ xl: getCoverSizeUrl(cover, 1920),
+ };
+ return { cover, coverSize };
+};
+
+/**
+ * 获取封面图片不同尺寸 URL
+ * @param url 封面图片 URL
+ * @param size 尺寸参数(可选)
+ * @returns 格式化后的封面图片 URL
+ */
+const getCoverSizeUrl = (url: string, size: number | null = null) => {
+ try {
+ if (!url) return "/images/song.jpg?asset";
+ // 本地 / SAF 来源的封面:Capacitor WebView 不允许直接 file:// / content:// 加载,
+ // 需经 Capacitor.convertFileSrc 转成 https://localhost/_capacitor_file_/... 代理。
+ // 同时不能拼 ?param= 参数(仅在线服务 CDN 支持)。data: URL 直接返回。
+ if (url.startsWith("data:")) return url;
+ if (url.startsWith("file://") || url.startsWith("content://")) {
+ try {
+ return Capacitor.convertFileSrc(url);
+ } catch {
+ return url;
+ }
+ }
+ const sizeUrl = size
+ ? typeof size === "number"
+ ? `?param=${size}y${size}`
+ : `?param=${size}`
+ : "";
+ const imageUrl = url?.replace(/^http:/, "https:");
+ if (imageUrl.endsWith(".jpg")) {
+ return imageUrl + sizeUrl;
+ }
+ if (imageUrl.endsWith("&")) {
+ const url = imageUrl + "cl";
+ return url.replace(/(thumbnail=[0-9]+y[0-9]+&cl)/, `thumbnail=${size}y${size}&`);
+ }
+ return imageUrl;
+ } catch (error) {
+ console.error("图片链接处理出错:", error);
+ return "/images/song.jpg?asset";
+ }
+};
+
+/**
+ * 检测歌词语言
+ * @param lyric 歌词内容
+ * @returns 语言代码("ja" | "zh-CN" | "en")
+ */
+export const getLyricLanguage = (lyric: string): "ja" | "ko" | "zh-CN" | "en" => {
+ if (!lyric || typeof lyric !== "string") return "en";
+ // 判断日语 根据平假名和片假名
+ if (/[\u3040-\u309F\u30A0-\u30FF]/.test(lyric)) return "ja";
+ // 判断韩语 根据韩文音节
+ if (/[\uAC00-\uD7AF]/.test(lyric)) return "ko";
+ // 判断简体中文 根据中日韩统一表意文字基本区
+ if (/[\u4E00-\u9FFF]/.test(lyric)) return "zh-CN";
+ // 默认英语
+ return "en";
+};
+
+/**
+ * 获取当前播放歌曲
+ * @returns 当前播放歌曲
+ */
+export const getPlaySongData = (): SongType | null => {
+ const dataStore = useDataStore();
+ const musicStore = useMusicStore();
+ const statusStore = useStatusStore();
+ // 若为私人FM
+ if (statusStore.personalFmMode) {
+ return musicStore.personalFMSong;
+ }
+ // 播放列表
+ const playlist = dataStore.playList;
+ if (!playlist.length) return null;
+ return playlist[statusStore.playIndex];
+};
+
+/**
+ * 获取播放信息对象
+ * @param song 歌曲
+ * @param sep 分隔符
+ * @returns 播放信息对象
+ */
+export const getPlayerInfoObj = (
+ song?: SongType,
+ sep: string = "/",
+): { name: string; artist: string; album: string } | null => {
+ const musicStore = useMusicStore();
+ const playSongData = song || getPlaySongData() || musicStore.playSong;
+
+ if (!playSongData) return null;
+
+ // 标题
+ const name = `${playSongData.name || "未知歌曲"}`;
+
+ // 歌手
+ const artist =
+ playSongData.type === "radio"
+ ? playSongData.dj?.creator || "未知播客"
+ : Array.isArray(playSongData.artists)
+ ? playSongData.artists.map((artists: { name: string }) => artists.name).join(sep)
+ : String(playSongData?.artists || "未知歌手");
+
+ // 专辑
+ const album =
+ playSongData.type === "radio"
+ ? playSongData.dj?.name || "未知播客"
+ : typeof playSongData.album === "object"
+ ? playSongData.album.name
+ : String(playSongData.album || "未知专辑");
+
+ return { name, artist, album };
+};
+
+/**
+ * 获取播放信息
+ * @param song 歌曲
+ * @param sep 分隔符
+ * @returns 播放信息
+ */
+export const getPlayerInfo = (song?: SongType, sep: string = "/"): string | null => {
+ const info = getPlayerInfoObj(song, sep);
+ if (!info) return null;
+ return `${info.name} - ${info.artist}`;
+};
+
+/**
+ * 检测所有输入行的共同最小缩进,将其从每一行中删除,如果第一行和最后一行是空白行,也将其删除
+ * @param string 字符串
+ * @param lineSplit 分割时的换行符
+ * @param lineJoin 连接时的换行符
+ * @returns 去除缩进后的字符串
+ */
+export const trimIndentString = (
+ string: string,
+ lineSplit: string = "\n",
+ lineJoin: string = lineSplit,
+): string => {
+ if (!string) return "";
+ const lines = string.split(lineSplit);
+ // 删除第一行和最后一行的空白行
+ const relevantLines = lines.filter(
+ (line, index) => (index !== 0 && index !== lines.length - 1) || line.trim() !== "",
+ );
+ // 移除每行的最小缩进
+ const minIndent = relevantLines
+ .filter((line) => line.trim() !== "")
+ .map((line) => line.match(/^\s*/)?.[0].length ?? 0)
+ .reduce((min, indent) => Math.min(min, indent), Infinity);
+ const trimmedLines = relevantLines.map((line) => line.slice(minIndent));
+ return trimmedLines.join(lineJoin);
+};
+
+/**
+ * 设置中多行描述的模板标签功能
+ * 删除最小公共缩进并将换行符转换为 HTML
标记
+ *
+ * @see trimIndentString
+ */
+export const descMultiline = (strings: TemplateStringsArray, ...values: any[]): string => {
+ const fullString = String.raw(strings, ...values);
+ return trimIndentString(fullString, "\n", "
");
+};
diff --git a/src/views/Local/albums.vue b/src/views/Local/albums.vue
index 5d2599b3a..fec56a2b3 100644
--- a/src/views/Local/albums.vue
+++ b/src/views/Local/albums.vue
@@ -117,8 +117,11 @@ watch(
.local-albums {
display: flex;
height: calc((var(--layout-height) - 80) * 1px);
+ min-height: 0;
:deep(.album-list) {
+ flex: 0 0 260px;
width: 260px;
+ height: 100%;
.n-scrollbar-content {
padding: 0 5px 0 0 !important;
}
@@ -183,7 +186,55 @@ watch(
.song-list {
width: 100%;
flex: 1;
+ min-width: 0;
margin-left: 15px;
}
+ @media (max-width: 767px) and (orientation: portrait) {
+ flex-direction: column;
+ height: auto;
+ min-height: 100%;
+ :deep(.album-list) {
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ max-height: 260px;
+ margin-bottom: 12px;
+ .n-scrollbar-content {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ padding: 0 0 4px 0 !important;
+ }
+ }
+ .album-item {
+ margin-bottom: 0;
+ :deep(.n-card__content) {
+ padding: 10px;
+ }
+ &:last-child {
+ margin-bottom: 0;
+ }
+ .cover {
+ width: 42px;
+ height: 42px;
+ margin-right: 10px;
+ border-radius: 10px;
+ }
+ .data {
+ min-width: 0;
+ }
+ .name {
+ font-size: 14px;
+ }
+ .num {
+ font-size: 12px;
+ }
+ }
+ .song-list {
+ flex: 1;
+ width: 100%;
+ margin-left: 0;
+ }
+ }
}
diff --git a/src/views/Local/artists.vue b/src/views/Local/artists.vue
index 2c4c46aa0..904ad3bf6 100644
--- a/src/views/Local/artists.vue
+++ b/src/views/Local/artists.vue
@@ -116,8 +116,11 @@ watch(
.local-artists {
display: flex;
height: calc((var(--layout-height) - 80) * 1px);
+ min-height: 0;
:deep(.artist-list) {
+ flex: 0 0 200px;
width: 200px;
+ height: 100%;
.n-scrollbar-content {
padding: 0 5px 0 0 !important;
}
@@ -159,7 +162,46 @@ watch(
.song-list {
width: 100%;
flex: 1;
+ min-width: 0;
margin-left: 15px;
}
+ @media (max-width: 767px) and (orientation: portrait) {
+ flex-direction: column;
+ height: auto;
+ min-height: 100%;
+ :deep(.artist-list) {
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ max-height: 220px;
+ margin-bottom: 12px;
+ .n-scrollbar-content {
+ display: grid;
+ grid-template-columns: repeat(2, minmax(0, 1fr));
+ gap: 8px;
+ padding: 0 0 4px 0 !important;
+ }
+ }
+ .artist-item {
+ margin-bottom: 0;
+ :deep(.n-card__content) {
+ padding: 10px 12px;
+ }
+ &:last-child {
+ margin-bottom: 0;
+ }
+ .name {
+ font-size: 14px;
+ }
+ .num {
+ font-size: 12px;
+ }
+ }
+ .song-list {
+ flex: 1;
+ width: 100%;
+ margin-left: 0;
+ }
+ }
}
diff --git a/src/views/Local/folders.vue b/src/views/Local/folders.vue
index a03a3faf6..d3bed5e01 100644
--- a/src/views/Local/folders.vue
+++ b/src/views/Local/folders.vue
@@ -99,14 +99,18 @@ const treeData = computed(() => {
sortedPaths.forEach((fullPath) => {
const isWindows = fullPath.includes("\\");
const sep = isWindows ? "\\" : "/";
- const segments = fullPath.split(/[/\\]/).filter(Boolean);
+ // 提取 scheme://(如 content://、file://),避免 filter(Boolean) 吃掉空串导致双斜杠变单斜杠
+ const schemeMatch = fullPath.match(/^([a-zA-Z][a-zA-Z0-9+.-]*:\/+)/);
+ const scheme = schemeMatch ? schemeMatch[1] : "";
+ const rest = scheme ? fullPath.slice(scheme.length) : fullPath;
+ const segments = rest.split(/[/\\]/).filter(Boolean);
- let currentPath = "";
- if (fullPath.startsWith(sep)) currentPath = sep;
+ let currentPath = scheme;
+ if (!scheme && fullPath.startsWith(sep)) currentPath = sep;
segments.forEach((segment, index) => {
const prevPath = currentPath;
- if (index === 0 && !fullPath.startsWith(sep)) {
+ if (index === 0 && !scheme && !fullPath.startsWith(sep)) {
currentPath = segment;
} else {
currentPath = currentPath.endsWith(sep)
@@ -288,8 +292,10 @@ onDeactivated(() => {
.local-folders {
display: flex;
height: calc((var(--layout-height) - 80) * 1px);
+ min-height: 0;
:deep(.folder-list) {
+ flex: 0 0 280px;
width: 280px;
height: 100%;
background-color: var(--surface-container-hex);
@@ -305,7 +311,29 @@ onDeactivated(() => {
.song-list {
width: 100%;
flex: 1;
+ min-width: 0;
margin-left: 15px;
}
+
+ @media (max-width: 767px) and (orientation: portrait) {
+ flex-direction: column;
+ height: auto;
+ min-height: 100%;
+
+ :deep(.folder-list) {
+ flex: 0 0 auto;
+ width: 100%;
+ height: auto;
+ max-height: 240px;
+ margin-bottom: 12px;
+ padding: 8px;
+ }
+
+ .song-list {
+ flex: 1;
+ width: 100%;
+ margin-left: 0;
+ }
+ }
}
diff --git a/src/views/Local/layout.vue b/src/views/Local/layout.vue
index 3add89cd1..5b6caee76 100644
--- a/src/views/Local/layout.vue
+++ b/src/views/Local/layout.vue
@@ -135,13 +135,22 @@
-
+
@@ -188,6 +197,7 @@ const localEventBus = useEventBus("local");
// 本地歌曲路由
const localType = ref((router.currentRoute.value?.name as string) || "local-songs");
+const routeKey = computed(() => (router.currentRoute.value?.name as string) || "local-songs");
// 选中的文件夹
const selectedFolder = ref("all");
@@ -385,6 +395,14 @@ interface SyncCompleteData {
tracks?: Record[];
}
+const isSyncCompleteData = (value: unknown): value is SyncCompleteData => {
+ return (
+ typeof value === "object" &&
+ value !== null &&
+ typeof (value as { success?: unknown }).success === "boolean"
+ );
+};
+
// 获取全部路径歌曲(流式接收)
const getAllLocalMusic = debounce(
async (showTip: boolean = false) => {
@@ -513,7 +531,7 @@ const getAllLocalMusic = debounce(
// 触发同步
const res = await window.electron.ipcRenderer.invoke("local-music-sync", allPath);
// 检查返回值,如果是扫描正在进行中
- if (res && !res.success) {
+ if (isSyncCompleteData(res) && !res.success) {
isCompleted = true;
loading.value = false;
loadingMsg.value?.destroy();
@@ -705,7 +723,10 @@ onUnmounted(() => {
overflow: hidden;
max-height: calc((var(--layout-height) - 132) * 1px);
}
- @media (max-width: 768px) {
+ @media (max-width: 767px) and (orientation: portrait) {
+ height: 100%;
+ min-height: calc(100dvh - var(--app-header-height) - var(--phone-nav-total-height) - 16px);
+
.title {
margin-top: 8px;
margin-bottom: 16px;
@@ -749,8 +770,8 @@ onUnmounted(() => {
}
.router-view {
- max-height: none;
min-height: 0;
+ max-height: none;
}
}
@media (max-width: 512px) {
diff --git a/src/views/Local/playlists.vue b/src/views/Local/playlists.vue
index 09721e054..a9c5747a2 100644
--- a/src/views/Local/playlists.vue
+++ b/src/views/Local/playlists.vue
@@ -49,5 +49,19 @@ const playlistData = computed(() => {
.empty {
margin-top: 100px;
}
+ @media (max-width: 767px) and (orientation: portrait) {
+ max-height: none;
+ min-height: 100%;
+ overflow: visible;
+ :deep(.n-scrollbar) {
+ max-height: none;
+ }
+ .cover-list {
+ padding: 0 2px 12px;
+ }
+ .empty {
+ margin-top: 48px;
+ }
+ }
}