diff --git a/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioCacheProvider.java b/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioCacheProvider.java index b3779577a..5649010f5 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioCacheProvider.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioCacheProvider.java @@ -366,13 +366,13 @@ public static String resolveCacheKey(@NonNull Uri uri) { // ========== prefetch ========== private static final String TAG = "AudioCachePrefetch"; - /** 默认预下载字节数:512 KB,足够 ExoPlayer 启播第一帧解码 + 内部缓冲。 */ - public static final long DEFAULT_PREFETCH_BYTES = 512L * 1024L; - /** 短预载单线程:供“下一首前 512KB”使用,低占用高响应。 */ + /** 默认预下载字节数:2 MB,避免无损 / Hi-Res 在开头数秒跨过 512 KB 缓存边界后阻塞。 */ + public static final long DEFAULT_PREFETCH_BYTES = 2L * 1024L * 1024L; + /** 短预载单线程:供“下一首前段音频”使用,低占用高响应。 */ private static volatile ExecutorService prefetchExecutor; /** * 全量下载独立单线程:全量一首 5-10MB 需背景跑,不能占着短预载走道。
- * 拆走后:load 下一首 → prefetchExecutor 立即跑 512KB;全量送到 fullDownloadExecutor。 + * 拆走后:load 下一首 → prefetchExecutor 立即跑短预载;全量送到 fullDownloadExecutor。 */ private static volatile ExecutorService fullDownloadExecutor; /** 全量下载任务取消标志;cancelAllPrefetch / clearAll 需要能同步中断。 */ @@ -380,9 +380,9 @@ public static String resolveCacheKey(@NonNull Uri uri) { /** 已在排队 / 进行中的 cacheKey 集合,幂等去重。 */ private static final Set inFlight = new HashSet<>(); /** - * 每个 cacheKey 独立写锁:保证同 cacheKey 的 CacheWriter 不会并发执行(短预载 + 全量任务跨 executor 时 + * 每个 cacheKey 独立写锁:保证同 cacheKey 的 CacheWriter 不会并发执行(短预载 + 预载任务跨 executor 时 * 都从位置 0 写同一组 span,并发会触发 SimpleCache 的 holeSpan 锁竞争 + CacheException)。
- * 短任务持锁 ~1s 完成;全量任务后到达直接接力——FLAG_BLOCK_ON_CACHE 会跳过已缓存字节只下剩余部分。
+ * 短任务持锁 ~1s 完成;预载任务后到达直接接力——FLAG_BLOCK_ON_CACHE 会跳过已缓存字节只下剩余部分。
* 用 ConcurrentHashMap 保证 computeIfAbsent 的原子性。 */ private static final Map cacheKeyWriterLocks = new ConcurrentHashMap<>(); @@ -408,7 +408,7 @@ private static ExecutorService getExecutor() { return prefetchExecutor; } - /** 全量下载独立 executor:与短预载不抢资源。 */ + /** 预载 独立 executor:与短预载不抢资源。 */ @NonNull private static ExecutorService getFullDownloadExecutor() { if (fullDownloadExecutor == null) { @@ -419,7 +419,7 @@ private static ExecutorService getFullDownloadExecutor() { r -> { Thread t = new Thread(r, "audio-cache-full-download"); t.setDaemon(true); - // 更低优先级:全量下载为后台作业,不应抢占短预载 / 当前播放。 + // 更低优先级:预载 为后台作业,不应抢占短预载 / 当前播放。 t.setPriority(Thread.NORM_PRIORITY - 2); return t; }); @@ -438,7 +438,7 @@ private static ExecutorService getFullDownloadExecutor() { *

幂等:同 cacheKey 已在队列或已 ready 时直接 no-op。 * * @param appContext app context - * @param url 要预下载的 http(s) url;非 http 直接忽略 + * @param url 要预 的 http(s) url;非 http 直接忽略 */ public static void prefetchUrl(@NonNull Context appContext, @Nullable String url) { prefetchUrlWithLength(appContext, url, DEFAULT_PREFETCH_BYTES, /* cancelPrev= */ true); @@ -453,7 +453,7 @@ public static void prefetchUrl(@NonNull Context appContext, @Nullable String url * 排到自己时若用户已经切歌,TTL 索引也会让本任务的字节仍然有效(promoted=true)。 */ public static void prefetchUrlFull(@NonNull Context appContext, @Nullable String url) { - // 幂等短路:已 promoted 跳过。全量下载的推进与最终 promote 标记都在 prefetchUrlWithLength 内负责。 + // 幂等短路:已 promoted 跳过。预载 的推进与最终 promote 标记都在 prefetchUrlWithLength 内负责。 if (url == null || url.isEmpty()) return; Uri uri = Uri.parse(url); // scheme 校验与短预载入口保持一致:非 http(s) 直接拒绝,避免误算 cacheKey 与日志噪声。 @@ -461,15 +461,15 @@ public static void prefetchUrlFull(@NonNull Context appContext, @Nullable String if (scheme == null || (!scheme.equals("http") && !scheme.equals("https"))) return; String cacheKey = resolveCacheKey(uri); if (AudioPrefetchTtlIndex.getInstance(appContext).isPromoted(cacheKey)) return; - // cancelPrev=false:完整下载不应抢占已经在跑的短预载 + // cancelPrev=false:完整 不应抢占已经在跑的短预载 prefetchUrlWithLength(appContext, url, /* length= */ Long.MAX_VALUE, /* cancelPrev= */ false); } - /** 公共实现:length 控制下载字节数;cancelPrev 控制是否取消上一首未完成的任务。 */ + /** 公共实现:length 控制 字节数;cancelPrev 控制是否取消上一首未完成的任务。 */ private static void prefetchUrlWithLength( @NonNull Context appContext, @Nullable String url, long length, boolean cancelPrev) { if (url == null || url.isEmpty()) return; - // 低磁盘:不启动任何 prefetch(短预载 / 全量下载都被拦)。在 PlaybackManager.schedulePromotion 调用前路拦截。 + // 低磁盘:不启动任何 prefetch(短预载 / 预载 都被拦)。在 PlaybackManager.schedulePromotion 调用前路拦截。 if (isLowDiskSpace(appContext)) { Log.d(TAG, "low disk: prefetch skipped for " + url); return; @@ -492,7 +492,7 @@ private static void prefetchUrlWithLength( // 已完整命中(cachedBytes ≥ length 阈值)则跳过 IO;但仍刷新 TTL 索引让缓存"续期" long probeLength = length == Long.MAX_VALUE ? DEFAULT_PREFETCH_BYTES : length; long cachedBytes = cache.getCachedBytes(cacheKey, 0, probeLength); - // 全量下载场景:还需要确认是否真的下完了(用 contentLength 判断更准但成本高,这里宽松判定) + // 预载 场景:还需要确认是否真的下完了(用 contentLength 判断更准但成本高,这里宽松判定) if (length != Long.MAX_VALUE && cachedBytes >= length) { ttlIndex.markAccess(cacheKey); synchronized (inFlight) { @@ -510,7 +510,7 @@ private static void prefetchUrlWithLength( currentCancelFlag = cancelFlag; } if (isFull) { - // 全量下载不抢带宽但要可被 cancelAll 中断;覆盖上次未完成的全量任务取消标。 + // 预载 不抢带宽但要可被 cancelAll 中断;覆盖上次未完成的预载任务取消标。 AtomicBoolean prevFull = currentFullCancelFlag; if (prevFull != null) prevFull.set(true); currentFullCancelFlag = cancelFlag; @@ -518,7 +518,7 @@ private static void prefetchUrlWithLength( final AtomicBoolean cancelFlagFinal = cancelFlag; final long lengthFinal = length; - // 路由:全量走后台 executor,短预载走响应 executor,互不阻塞。 + // 路由:预载走后台 executor,短预载走响应 executor,互不阻塞。 ExecutorService chosen = isFull ? getFullDownloadExecutor() : getExecutor(); chosen.execute(() -> { // 取每个 cacheKey 自己的 monitor:跨 executor 时同 cacheKey 串行,避免并发 CacheWriter 写同一组 span @@ -548,7 +548,7 @@ private static void prefetchUrlWithLength( if (cancelFlagFinal.get()) Thread.currentThread().interrupt(); }); writer.cache(); - // 全量下载(length=MAX_VALUE)成功返回才标 promoted;CacheWriter 中途抛异常会走到 catch,不会 promote。 + // 预载 (length=MAX_VALUE)成功返回才标 promoted;CacheWriter 中途抛异常会走到 catch,不会 promote。 // 这样 getPromotedAudioFile 看到 isPromoted=true 时,可认为文件完整;automix 不会读到截断字节。 // // 修复 #1:promote 前比对实际缓存字节数 vs upstream Content-Length(CacheDataSource 在 open 时 @@ -586,7 +586,7 @@ private static void prefetchUrlWithLength( }); } - /** 取消所有正在排队的 prefetch(应用关闭 / 清缓存场景);同时中断全量下载。 */ + /** 取消所有正在排队的 prefetch(应用关闭 / 清缓存场景);同时中断预载 。 */ public static void cancelAllPrefetch() { AtomicBoolean flag = currentCancelFlag; if (flag != null) flag.set(true); @@ -741,8 +741,7 @@ public static File getPromotedAudioFile(@NonNull Context appContext, @Nullable S String cacheKey = resolveCacheKey(uri); if (!AudioPrefetchTtlIndex.getInstance(appContext).isPromoted(cacheKey)) return null; - SimpleCache cache = simpleCache; - if (cache == null) return null; + SimpleCache cache = getOrCreate(appContext); NavigableSet spans; try { diff --git a/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioPrefetchTtlIndex.java b/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioPrefetchTtlIndex.java index 1ed0f848e..ccbf4c164 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioPrefetchTtlIndex.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/cache/AudioPrefetchTtlIndex.java @@ -97,7 +97,7 @@ public void markAccess(@NonNull String cacheKey) { * 升级为“正式缓存”:调用者判定用户真的听了这首歌(如播放 > 10s)。
* 从 prefetch 索引中移除,加入 promoted 集合;将不再被 50min TTL sweep 清。 * - * @return true 表示是首次 promote(调用者可据此决定是否启动全量下载) + * @return true 表示是首次 promote(调用者可据此决定是否启动预载) */ public boolean promote(@NonNull String cacheKey) { if (cacheKey.isEmpty()) return false; @@ -140,7 +140,7 @@ public int countPrefetchOnly() { * *

    *
  1. 过期(now - lastAccessAt > TTL_MILLIS)的条目,从 SimpleCache 中清除对应资源 - *
  2. SimpleCache 中已经不存在该 cacheKey 的"孤儿"索引项 + *
  3. SimpleCache 中已经不存在该 cacheKey 的"孤立"索引项 *
*/ public void sweep() { @@ -149,11 +149,9 @@ public void sweep() { Map all = prefs.getAll(); - SimpleCache cache; - try { - cache = AudioCacheProvider.getOrCreate(appContext); - } catch (Throwable e) { - Log.w(TAG, "sweep: SimpleCache not ready, skip"); + SimpleCache cache = AudioCacheProvider.peekSimpleCache(); + if (cache == null) { + Log.d(TAG, "sweep: SimpleCache not initialized, skip"); return; } Set liveKeys = new HashSet<>(cache.getKeys()); @@ -191,18 +189,22 @@ public void sweep() { editor.remove(cacheKey); continue; } + long latestTs = prefs.getLong(cacheKey, ts); + if (latestTs >= expireBefore) { + continue; + } try { cache.removeResource(cacheKey); + editor.remove(cacheKey); + expired++; } catch (Throwable e) { Log.w(TAG, "sweep: removeResource failed: " + cacheKey, e); } - editor.remove(cacheKey); - expired++; } } editor.apply(); - // 2) 扫 promoted 集合:清除 SimpleCache 已不存在的孤儿条目(被全局 LRU 配额删过) + // 2) 扫 promoted 集合:清除 SimpleCache 已不存在的孤立条目(被全局 LRU 配额删过) Map promotedAll = promotedPrefs.getAll(); SharedPreferences.Editor pEditor = promotedPrefs.edit(); int promotedOrphan = 0; diff --git a/android/app/src/main/java/top/imsyy/splayer/android/playback/AndroidNativePlaybackPlugin.java b/android/app/src/main/java/top/imsyy/splayer/android/playback/AndroidNativePlaybackPlugin.java index ffd0a0b01..79a309cde 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/playback/AndroidNativePlaybackPlugin.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/playback/AndroidNativePlaybackPlugin.java @@ -330,7 +330,9 @@ public void syncApiContext(PluginCall call) { .syncApiContext( call.getString("apiBaseUrl", ""), call.getString("cookie", ""), - call.getString("songLevel", "exhigh")); + call.getString("songLevel", "exhigh"), + Boolean.TRUE.equals(call.getBoolean("disableAiAudio", false)), + Boolean.TRUE.equals(call.getBoolean("playSongDemo", false))); call.resolve(); }); } @@ -484,6 +486,14 @@ public void prefetchAudio(PluginCall call) { call.resolve(); } + @PluginMethod + public void isPromotedAudioReady(PluginCall call) { + String url = call.getString("url", ""); + JSObject result = new JSObject(); + result.put("ready", AudioCacheProvider.isPromotedAudioReady(getContext(), url)); + call.resolve(result); + } + @PluginMethod public void enableVisualizer(PluginCall call) { boolean enable = call.getBoolean("enable", false); diff --git a/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackManager.java b/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackManager.java index 30fc5b389..93a2cc902 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackManager.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackManager.java @@ -123,6 +123,8 @@ public final class PlaybackManager { private String apiBaseUrl = ""; private String cookie = ""; private String songLevel = "exhigh"; + private boolean disableAiAudio = false; + private boolean playSongDemo = false; /** * 当前 promotion 任务 —— load() 时排 10s timer,到期仍是这首歌则把它从 prefetch 升级为 @@ -360,7 +362,7 @@ public synchronized JSObject load(String url, long positionMs, boolean autoPlay) final String sourceSnapshot = currentSource; AudioPrefetchTtlIndex ttlIndex = AudioPrefetchTtlIndex.getInstance(appContext); ttlIndex.markAccess(cacheKey); - // 已 promoted 的歌不再排 timer(已是正式缓存,全量下载也已经触发过) + // 已 promoted 的歌不再排 timer(已是正式缓存,预载也已经触发过) if (!ttlIndex.isPromoted(cacheKey)) { schedulePromotion(cacheKey, sourceSnapshot); } @@ -597,6 +599,7 @@ public synchronized void updateQueueContext( // 立即预解析前方未解析项,避免后台 ENDED 时才发现无 URL。 prefetchUpcomingUrls(); + requestUrlsIfWindowExhausted(); updateMediaSessionButtons(); updateNotification(); emitPlaybackState(false); @@ -649,13 +652,21 @@ public synchronized void setAllowMixWithOthers(boolean allow) { } } - public synchronized void syncApiContext(String baseUrl, String cookieValue, String level) { + public synchronized void syncApiContext( + String baseUrl, + String cookieValue, + String level, + boolean disableAiAudioState, + boolean playSongDemoState) { apiBaseUrl = baseUrl == null ? "" : baseUrl.trim(); cookie = cookieValue == null ? "" : cookieValue.trim(); + disableAiAudio = disableAiAudioState; + playSongDemo = playSongDemoState; if (level != null && !level.isEmpty()) { songLevel = level; } - urlResolver.updateContext(apiBaseUrl, cookie, songLevel); + urlResolver.updateContext(apiBaseUrl, cookie, songLevel, disableAiAudio, playSongDemo); + prefetchUpcomingUrls(); } /** @@ -1224,6 +1235,8 @@ private void resolveAndPlayAsync( } else if (!forward) { // 后退方向窗口耗尽:回落 JS prev(不应触发 requestUrls,那是右缘语义) emitCustomAction("previous", null, null, null, null, true, null); + } else if ("auto".equals(source)) { + emitCustomAction("next", null, null, null, null, true, null); } return; } @@ -1235,8 +1248,12 @@ private void resolveAndPlayAsync( if (remainAttempts <= 0) { // 连续失败(账号 / 解锁 / 区域屏蔽) if (forward) { - pendingResumeAfterRefill = true; - emitRequestUrls(); + if (shouldRequestUrlsForwardWrap()) { + pendingResumeAfterRefill = true; + emitRequestUrls(); + } else { + emitCustomAction("next", null, null, null, null, true, null); + } } else { emitCustomAction("previous", null, null, null, null, true, null); } diff --git a/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackUrlResolver.java b/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackUrlResolver.java index 4d93fdaab..0a35e7b87 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackUrlResolver.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackUrlResolver.java @@ -8,7 +8,9 @@ import java.net.URL; import java.net.URLEncoder; import java.nio.charset.StandardCharsets; +import java.util.ArrayList; import java.util.LinkedHashMap; +import java.util.List; import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; @@ -57,12 +59,70 @@ protected boolean removeEldestEntry(Map.Entry eldest) { private volatile String apiBaseUrl = ""; private volatile String cookie = ""; private volatile String songLevel = "exhigh"; + private volatile boolean disableAiAudio = false; + private volatile boolean playSongDemo = false; + + private static String normalizeLevel(@Nullable String level, boolean disableAiAudio) { + if (level == null || level.isEmpty()) return "exhigh"; + if (disableAiAudio + && ("jymaster".equals(level) + || "sky".equals(level) + || "jyeffect".equals(level) + || "vivid".equals(level))) { + return "hires"; + } + return level; + } + + private static String[] getRequestLevels(String level) { + if ("dolby".equals(level)) return new String[] {"dolby", "hires", "lossless", "exhigh"}; + if ("standard".equals(level)) return new String[] {level}; + if ("higher".equals(level)) return new String[] {"higher", "exhigh"}; + if ("hires".equals(level)) return new String[] {"hires", "lossless", "exhigh"}; + if ("lossless".equals(level)) return new String[] {"lossless", "exhigh"}; + if ("exhigh".equals(level)) return new String[] {"exhigh"}; + return new String[] {level, "hires", "lossless", "exhigh"}; + } + + private static String qualityKeyForLevel(String level) { + if ("standard".equals(level)) return "l"; + if ("higher".equals(level)) return "m"; + if ("exhigh".equals(level)) return "h"; + if ("lossless".equals(level)) return "sq"; + if ("hires".equals(level)) return "hr"; + if ("jyeffect".equals(level)) return "je"; + if ("sky".equals(level)) return "sk"; + if ("jymaster".equals(level)) return "jm"; + if ("dolby".equals(level)) return "db"; + return ""; + } + + private static boolean isPlayableQualityLevel(@Nullable JSONObject qualityData, String level) { + if (qualityData == null || "exhigh".equals(level)) return true; + String key = qualityKeyForLevel(level); + if (key.isEmpty()) return true; + JSONObject quality = qualityData.optJSONObject(key); + return quality != null && quality.optLong("br", 0L) > 0L; + } + + private static String[] filterRequestLevels(String[] levels, @Nullable JSONObject qualityData) { + if (qualityData == null) return levels; + List out = new ArrayList<>(); + for (String level : levels) { + if (isPlayableQualityLevel(qualityData, level)) out.add(level); + } + return out.toArray(new String[0]); + } public synchronized void updateContext( - @Nullable String baseUrl, @Nullable String cookieValue, @Nullable String level) { + @Nullable String baseUrl, + @Nullable String cookieValue, + @Nullable String level, + boolean disableAiAudioState, + boolean playSongDemoState) { String newBaseUrl = baseUrl == null ? "" : baseUrl.trim(); String newCookie = cookieValue == null ? "" : cookieValue.trim(); - String newLevel = level != null && !level.isEmpty() ? level : this.songLevel; + String newLevel = normalizeLevel(level != null && !level.isEmpty() ? level : this.songLevel, disableAiAudioState); // 任一上下文变化都要清缓存: // - level 变 → URL 文件/码率/endpoint 不同 @@ -71,11 +131,15 @@ public synchronized void updateContext( boolean contextChanged = !newBaseUrl.equals(this.apiBaseUrl) || !newCookie.equals(this.cookie) - || !newLevel.equals(this.songLevel); + || !newLevel.equals(this.songLevel) + || disableAiAudioState != this.disableAiAudio + || playSongDemoState != this.playSongDemo; this.apiBaseUrl = newBaseUrl; this.cookie = newCookie; this.songLevel = newLevel; + this.disableAiAudio = disableAiAudioState; + this.playSongDemo = playSongDemoState; if (contextChanged) { synchronized (cache) { @@ -102,9 +166,18 @@ public void clear(long songId) { @Nullable public String resolveSync(long songId) { if (songId <= 0) return null; - String level = songLevel; - // 缓存键:songId + level。level 差异 → URL 不同文件/码率/endpoint,不能合并。 + String level; + String baseUrl; + String cookieValue; + boolean allowTrial; + synchronized (this) { + level = normalizeLevel(songLevel, disableAiAudio); + baseUrl = apiBaseUrl; + cookieValue = cookie; + allowTrial = playSongDemo; + } String cacheKey = songId + ":" + level; + String originalCacheKey = cacheKey; synchronized (cache) { String hit = cache.get(cacheKey); if (hit != null) return hit; @@ -120,88 +193,120 @@ public String resolveSync(long songId) { negativeCache.remove(cacheKey); } } - String baseUrl = apiBaseUrl; - String cookieValue = cookie; if (baseUrl.isEmpty()) { Log.w(TAG, "resolveSync skipped: apiBaseUrl empty"); return null; } - HttpURLConnection connection = null; - String resolvedUrl = null; - boolean networkFailure = false; - try { - // dolby 走旧版接口,其余走 /song/url/v1 - String endpoint; - if ("dolby".equals(level)) { - endpoint = - baseUrl - + "/song/url?id=" - + songId - + "&br=999000&immerseType=c51×tamp=" - + System.currentTimeMillis(); - } else { - endpoint = - baseUrl - + "/song/url/v1?id=" - + songId - + "&level=" - + URLEncoder.encode(level, StandardCharsets.UTF_8.name()) - + "×tamp=" - + System.currentTimeMillis(); - } - if (!cookieValue.isEmpty()) { - endpoint += "&cookie=" + URLEncoder.encode(cookieValue, StandardCharsets.UTF_8.name()); + JSONObject qualityData = null; + if ("dolby".equals(level) + || "jymaster".equals(level) + || "sky".equals(level) + || "jyeffect".equals(level)) { + qualityData = fetchQualityData(baseUrl, cookieValue, songId); + if (qualityData != null && !isPlayableQualityLevel(qualityData, level)) { + level = "hires"; + cacheKey = songId + ":" + level; } + } - connection = (HttpURLConnection) new URL(endpoint).openConnection(); - connection.setRequestMethod("GET"); - connection.setConnectTimeout(CONNECT_TIMEOUT_MS); - connection.setReadTimeout(READ_TIMEOUT_MS); - connection.setRequestProperty("Accept", "application/json"); - if (!cookieValue.isEmpty()) { - connection.setRequestProperty("Cookie", cookieValue); + String resolvedUrl = null; + boolean hadNetworkFailure = false; + boolean hadBusinessResponse = false; + String resolvedLevel = level; + String[] requestLevels = filterRequestLevels(getRequestLevels(level), qualityData); + if (requestLevels.length == 0) { + synchronized (negativeCache) { + negativeCache.put(originalCacheKey, System.currentTimeMillis()); + negativeCache.put(cacheKey, System.currentTimeMillis()); } - connection.connect(); + return null; + } + for (String requestLevel : requestLevels) { + HttpURLConnection connection = null; + try { + // dolby 走旧版接口,其余走 /song/url/v1 + String endpoint; + if ("dolby".equals(requestLevel)) { + endpoint = + baseUrl + + "/song/url?id=" + + songId + + "&br=999000&immerseType=c51×tamp=" + + System.currentTimeMillis(); + } else { + endpoint = + baseUrl + + "/song/url/v1?id=" + + songId + + "&level=" + + URLEncoder.encode(requestLevel, StandardCharsets.UTF_8.name()) + + "×tamp=" + + System.currentTimeMillis(); + } + if (!cookieValue.isEmpty()) { + endpoint += "&cookie=" + URLEncoder.encode(cookieValue, StandardCharsets.UTF_8.name()); + } - int httpCode = connection.getResponseCode(); - if (httpCode != HttpURLConnection.HTTP_OK) { - Log.w(TAG, "resolveSync songId=" + songId + " http=" + httpCode); - networkFailure = true; - } else { - String body = readBody(connection); - JSONObject root = new JSONObject(body); - JSONArray data = root.optJSONArray("data"); - if (data != null && data.length() > 0) { - JSONObject first = data.optJSONObject(0); - if (first != null) { - String url = first.optString("url", ""); - if (!url.isEmpty() && !"null".equals(url)) { - resolvedUrl = url; + connection = (HttpURLConnection) new URL(endpoint).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestProperty("Accept", "application/json"); + if (!cookieValue.isEmpty()) { + connection.setRequestProperty("Cookie", cookieValue); + } + connection.connect(); + + int httpCode = connection.getResponseCode(); + if (httpCode != HttpURLConnection.HTTP_OK) { + Log.w(TAG, "resolveSync songId=" + songId + " level=" + requestLevel + " http=" + httpCode); + hadNetworkFailure = true; + } else { + hadBusinessResponse = true; + String body = readBody(connection); + JSONObject root = new JSONObject(body); + JSONArray data = root.optJSONArray("data"); + if (data != null && data.length() > 0) { + JSONObject first = data.optJSONObject(0); + if (first != null) { + String url = first.optString("url", ""); + if (!first.isNull("freeTrialInfo") && !allowTrial) { + Log.w(TAG, "resolveSync songId=" + songId + " level=" + requestLevel + " trial skipped"); + continue; + } + if (!url.isEmpty() && !"null".equals(url)) { + resolvedUrl = url; + resolvedLevel = requestLevel; + break; + } } } } + } catch (Exception e) { + Log.w(TAG, "resolveSync failed songId=" + songId + " level=" + requestLevel, e); + hadNetworkFailure = true; + } finally { + if (connection != null) connection.disconnect(); } - } catch (Exception e) { - Log.w(TAG, "resolveSync failed songId=" + songId, e); - networkFailure = true; - } finally { - if (connection != null) connection.disconnect(); } if (resolvedUrl != null) { synchronized (cache) { + cache.put(originalCacheKey, resolvedUrl); cache.put(cacheKey, resolvedUrl); + cache.put(songId + ":" + resolvedLevel, resolvedUrl); } - // 成功:清负缓存(防止之前临时失败后错过重试窗口) synchronized (negativeCache) { + negativeCache.remove(originalCacheKey); negativeCache.remove(cacheKey); + negativeCache.remove(songId + ":" + resolvedLevel); } return resolvedUrl; } - // 仅业务层“无可播 URL”(HTTP 200 但 data 为空)入负缓存;网络异常不入,让上层可重试。 - if (!networkFailure) { + if (hadBusinessResponse) { synchronized (negativeCache) { + negativeCache.put(originalCacheKey, System.currentTimeMillis()); negativeCache.put(cacheKey, System.currentTimeMillis()); } } @@ -258,9 +363,11 @@ public void prefetchAsync( } if (onResolved != null) onResolved.run(); } finally { - flagRef.set(false); synchronized (inFlight) { - inFlight.remove(songId); + flagRef.set(false); + if (inFlight.get(songId) == flagRef) { + inFlight.remove(songId); + } } } }); @@ -276,4 +383,37 @@ private static String readBody(HttpURLConnection connection) throws Exception { return sb.toString(); } } + + @Nullable + private static JSONObject fetchQualityData(String baseUrl, String cookieValue, long songId) { + HttpURLConnection connection = null; + try { + String endpoint = + baseUrl + + "/song/music/detail?id=" + + songId + + "×tamp=" + + System.currentTimeMillis(); + if (!cookieValue.isEmpty()) { + endpoint += "&cookie=" + URLEncoder.encode(cookieValue, StandardCharsets.UTF_8.name()); + } + connection = (HttpURLConnection) new URL(endpoint).openConnection(); + connection.setRequestMethod("GET"); + connection.setConnectTimeout(CONNECT_TIMEOUT_MS); + connection.setReadTimeout(READ_TIMEOUT_MS); + connection.setRequestProperty("Accept", "application/json"); + if (!cookieValue.isEmpty()) { + connection.setRequestProperty("Cookie", cookieValue); + } + connection.connect(); + if (connection.getResponseCode() != HttpURLConnection.HTTP_OK) return null; + JSONObject root = new JSONObject(readBody(connection)); + return root.optJSONObject("data"); + } catch (Exception e) { + Log.w(TAG, "fetchQualityData failed songId=" + songId, e); + return null; + } finally { + if (connection != null) connection.disconnect(); + } + } } diff --git a/src/components/Player/PlayerMeta/PlayerData.vue b/src/components/Player/PlayerMeta/PlayerData.vue index 0ee4f88a4..83e22fa7b 100644 --- a/src/components/Player/PlayerMeta/PlayerData.vue +++ b/src/components/Player/PlayerMeta/PlayerData.vue @@ -54,8 +54,22 @@ align="center" > - - {{ !statusStore.songQuality ? "未知音质" : statusStore.songQuality }} + + + {{ qualityText }} + + + + {{ qualityText }} (null); +const qualityLoading = ref(false); + +const qualityText = computed(() => getQualityName(statusStore.songQuality)); +const canQuickSelectQuality = computed(() => isPhonePortrait.value && isOnlineSong.value); + +const handleQualityClick = async () => { + if (qualityLoading.value) return; + if (showQualityPopover.value) { + showQualityPopover.value = false; + return; + } + qualityLoading.value = true; + try { + await loadQualities(); + if (qualityOptions.value.length > 0) { + showQualityPopover.value = true; + } else { + window.$message.warning("暂无可切换音质"); + } + } finally { + qualityLoading.value = false; + } +}; + +const handleQualitySelectAndClose = async (value: string) => { + try { + await handleQualitySelect(value); + showQualityPopover.value = false; + } catch (error) { + console.error("音质切换失败:", error); + window.$message.error("音质切换失败"); + } +}; + +const handleQualityClickOutside = (e: MouseEvent) => { + if (qualityTagRef.value && qualityTagRef.value.contains(e.target as Node)) { + return; + } + showQualityPopover.value = false; +}; + +watch( + () => musicStore.playSong.id, + () => { + statusStore.availableQualities = []; + showQualityPopover.value = false; + }, +); + +watch([() => settingStore.disableAiAudio], () => { + statusStore.availableQualities = []; + showQualityPopover.value = false; +}); // 显示名称 const displayName = computed(() => { diff --git a/src/components/Player/PlayerRightMenu.vue b/src/components/Player/PlayerRightMenu.vue index ffe975291..ace94aa37 100644 --- a/src/components/Player/PlayerRightMenu.vue +++ b/src/components/Player/PlayerRightMenu.vue @@ -198,22 +198,18 @@ const handleControls = (key: string) => { } }; -// 更新音质数据 watch( () => musicStore.playSong.id, - async () => { + () => { statusStore.availableQualities = []; - await loadQualities(); if (showQualityPopover.value && statusStore.availableQualities.length === 0) { showQualityPopover.value = false; } }, ); -// 监听 VIP 状态或设置变化,重新加载音质 -watch([() => dataStore.userData.vipType, () => settingStore.disableAiAudio], async () => { +watch([() => dataStore.userData.vipType, () => settingStore.disableAiAudio], () => { statusStore.availableQualities = []; - await loadQualities(); }); diff --git a/src/composables/useQualityControl.ts b/src/composables/useQualityControl.ts index 3f228fca3..b5bdea0a3 100644 --- a/src/composables/useQualityControl.ts +++ b/src/composables/useQualityControl.ts @@ -135,10 +135,16 @@ export const useQualityControl = () => { } const item = availableQualities.value.find((q) => q.level === key); if (!item) return; + const previousLevel = settingStore.songLevel; // 更新音质 settingStore.songLevel = key as typeof settingStore.songLevel; - // 切换音质,保持当前进度,不重新加载歌词 - await player.switchQuality(statusStore.currentTime); + try { + // 切换音质,保持当前进度,不重新加载歌词 + await player.switchQuality(statusStore.currentTime); + } catch (error) { + settingStore.songLevel = previousLevel; + throw error; + } // 获取实际切换后的音质项 const actualItem = availableQualities.value.find( (q) => handleSongQuality(q) === statusStore.songQuality, diff --git a/src/core/player/MediaSessionManager.ts b/src/core/player/MediaSessionManager.ts index 2eee5ec6c..dc9fe14a1 100644 --- a/src/core/player/MediaSessionManager.ts +++ b/src/core/player/MediaSessionManager.ts @@ -4,6 +4,7 @@ import { getCookie } from "@/utils/cookie"; import { EMBEDDED_API_BASE_URL } from "@/utils/embeddedApi"; import { isCapacitorAndroid, isElectron } from "@/utils/env"; import { getPlaySongData } from "@/utils/format"; +import { AI_AUDIO_LEVELS } from "@/utils/meta"; import { msToS } from "@/utils/time"; import type { SystemMediaEvent } from "@emi"; import { throttle } from "lodash-es"; @@ -25,6 +26,11 @@ import { updateDiscordConfig, } from "./PlayerIpc"; +const normalizeAndroidSongLevel = (level: string, disableAiAudio: boolean): string => { + if (disableAiAudio && AI_AUDIO_LEVELS.includes(level)) return "hires"; + return level; +}; + class MediaSessionManager { private metadataAbortController: AbortController | null = null; private currentRate: number = 1; @@ -220,7 +226,11 @@ class MediaSessionManager { const settingStore = useSettingStore(); const musicCookie = getCookie("MUSIC_U"); const cookie = musicCookie ? `MUSIC_U=${musicCookie};os=pc;` : ""; - const key = `${EMBEDDED_API_BASE_URL}|${cookie}|${settingStore.songLevel}`; + const songLevel = normalizeAndroidSongLevel( + settingStore.songLevel, + settingStore.disableAiAudio, + ); + const key = `${EMBEDDED_API_BASE_URL}|${cookie}|${songLevel}|${settingStore.disableAiAudio}|${settingStore.playSongDemo}`; if (!force && this.lastSyncedApiContextKey === key) return; // 并发去重:同 key 已有 IPC 在跑直接复用 Promise;force 时强制新发起 if (!force && this.pendingSyncApiContextKey === key && this.pendingSyncApiContextPromise) { @@ -233,7 +243,9 @@ class MediaSessionManager { await AndroidNativePlayback.syncApiContext({ apiBaseUrl: EMBEDDED_API_BASE_URL, cookie, - songLevel: settingStore.songLevel, + songLevel, + disableAiAudio: settingStore.disableAiAudio, + playSongDemo: settingStore.playSongDemo, }); this.lastSyncedApiContextKey = key; } finally { diff --git a/src/core/player/PlayerController.ts b/src/core/player/PlayerController.ts index 2717f55f3..c721e58c1 100644 --- a/src/core/player/PlayerController.ts +++ b/src/core/player/PlayerController.ts @@ -347,7 +347,16 @@ class PlayerController { if (requestToken === this.currentRequestToken) { if (audioManager.engineType === "android-native") audioManager.stop(); console.error("❌ 播放初始化失败:", error); - this.handlePlaybackError(undefined); + // 播放无权限,给出准确提示并跳过 + if (error instanceof Error && error.message === "AUDIO_SOURCE_EMPTY") { + console.warn(`⚠️ 歌曲暂无权限播放,已跳过`); + window.$message.warning("该歌曲暂无权限播放,请检查权限,现已跳过"); + statusStore.playLoading = false; + this.retryInfo.count = 0; + await this.skipToNextWithDelay(); + } else { + this.handlePlaybackError(undefined); + } } } } @@ -509,9 +518,9 @@ class PlayerController { const onSwitch = crossfadeOptions.onSwitch; const wrappedOnSwitch = shouldDeferStateSync ? () => { - onSwitch?.(); - updateSeekState(); - } + onSwitch?.(); + updateSeekState(); + } : onSwitch; await audioManager.crossfadeTo(url, { duration: crossfadeOptions.duration, @@ -603,18 +612,11 @@ class PlayerController { await this.parseLocalMusicInfo(song.path); } - // 预载下一首:fire-and-forget,让 playLoading=false 不被网络请求阻塞 1-3s - // syncAndroidPlaybackContext 内部 buildAndroidWindowTracks 同步遍历 41 首歌(5-30ms), - // 后续 4 个 Capacitor IPC 累计 100-300ms。这些都不应卡在切歌热路径上, - // 推到 idle frame 让 UI 把切歌动画 / 歌词加载先跑完,再幕后做队列同步。 - // Java 触发的 applyNativeTrackChanged / refreshAndroidQueueWindow 等仍走立即路径。 - const ric = (window as Window & { requestIdleCallback?: typeof requestIdleCallback }) - .requestIdleCallback; const runSync = () => void this.syncAndroidPlaybackContext(song); - if (typeof ric === "function") { - this.runIdleWithTimeout(runSync); + if (isCapacitorAndroid && useAudioManager().engineType === "android-native") { + runSync(); } else { - setTimeout(runSync, 0); + this.runIdleWithTimeout(runSync); } if (settingStore.useNextPrefetch) songManager.prefetchNextSong(); @@ -830,12 +832,9 @@ class PlayerController { if (isStaleSong()) return; - // 4 次 IPC 并发:之前串行累计 30-150ms × 4 = 200-600ms 主线程 microtask 排队, - // 改为 Promise.all 后单次切歌仅排队 30-150ms。 - // syncApiContext 通过 mediaSessionManager 共享 dedup(参数无变化时跳过实际 IPC)。 try { + await mediaSessionManager.syncAndroidApiContext(); await Promise.all([ - mediaSessionManager.syncAndroidApiContext(), AndroidNativePlayback.updateQueueContext({ liked: typeof song.id === "number" ? dataStore.isLikeSong(song.id) : false, canSkipPrevious: !statusStore.personalFmMode, @@ -1313,16 +1312,6 @@ class PlayerController { // 新歌曲,重置重试计数 this.retryInfo = { songId: currentSongId, count: 0 }; } - // 防止无限重试 - const ABSOLUTE_MAX_RETRY = 3; - if (this.retryInfo.count >= ABSOLUTE_MAX_RETRY) { - console.error(`❌ 歌曲 ${currentSongId} 已重试 ${this.retryInfo.count} 次,强制跳过`); - window.$message.error("播放失败,已自动跳过"); - statusStore.playLoading = false; - this.retryInfo.count = 0; - await this.skipToNextWithDelay(); - return; - } // 用户主动中止 if (errCode === AudioErrorCode.ABORTED || errCode === AudioErrorCode.DOM_ABORT) { this.retryInfo.count = 0; @@ -2132,7 +2121,7 @@ class PlayerController { AndroidNativePlayback.updateFloatingLyricData({ lrcData: JSON.stringify(lrcData), yrcData: JSON.stringify(yrcData), - }).catch(() => {}); + }).catch(() => { }); }; const ric = (window as Window & { requestIdleCallback?: typeof requestIdleCallback }) .requestIdleCallback; @@ -2167,7 +2156,7 @@ class PlayerController { AndroidNativePlayback.updateFloatingLyricSongInfo({ name: info?.name ?? "", artist: info?.artist ?? "", - }).catch(() => {}); + }).catch(() => { }); } /** @@ -2180,7 +2169,7 @@ class PlayerController { AndroidNativePlayback.updateFloatingLyricProgress({ timeMs, playing, - }).catch(() => {}); + }).catch(() => { }); } /** @@ -2205,7 +2194,7 @@ class PlayerController { fontSize: config.fontSize, fontWeight: config.fontWeight, position: config.position, - }).catch(() => {}); + }).catch(() => { }); } catch (e) { console.warn("[PlayerController] syncFloatingLyricConfig failed", e); } diff --git a/src/core/player/SongManager.ts b/src/core/player/SongManager.ts index 5bd7e5138..3ce9fba66 100644 --- a/src/core/player/SongManager.ts +++ b/src/core/player/SongManager.ts @@ -44,6 +44,55 @@ export type AudioSource = { source?: AudioSourceType; }; +type SongUrlLevel = NonNullable[1]>; + +const FALLBACK_SONG_URL_LEVELS: SongUrlLevel[] = ["hires", "lossless", "exhigh"]; + +const SONG_URL_LEVEL_QUALITY_KEYS: Partial> = { + standard: "l", + higher: "m", + exhigh: "h", + lossless: "sq", + hires: "hr", + jyeffect: "je", + sky: "sk", + jymaster: "jm", +}; + +const normalizeSongUrlLevel = (level: string, disableAiAudio: boolean): SongUrlLevel => { + if (disableAiAudio && AI_AUDIO_LEVELS.includes(level)) return "hires"; + return level as SongUrlLevel; +}; + +const isPlayableQualityLevel = ( + qualityData: Record | undefined, + level: SongUrlLevel, +): boolean => { + if (!qualityData || level === "exhigh") return true; + const key = SONG_URL_LEVEL_QUALITY_KEYS[level]; + if (!key) return true; + return Number(qualityData[key]?.br) > 0; +}; + +const getSongUrlRequestLevels = ( + level: SongUrlLevel, + qualityData?: Record, +): SongUrlLevel[] => { + if (level === "dolby") return [level]; + if (level === "standard") return [level]; + const levels: SongUrlLevel[] = (() => { + if (level === "higher") return [level, "exhigh"]; + if (level === "lossless") return [level, "exhigh"]; + if (level === "exhigh") return [level]; + return Array.from(new Set([level, ...FALLBACK_SONG_URL_LEVELS])); + })(); + return levels.filter((requestLevel) => isPlayableQualityLevel(qualityData, requestLevel)); +}; + +const getSongUrlData = (res: Awaited>) => { + return Array.isArray(res.data) ? res.data[0] : res.data?.[0]; +}; + /** * 歌曲管理器 * 负责歌曲的获取、缓存、预加载等操作 @@ -52,6 +101,34 @@ class SongManager { /** 预载下一首歌曲播放信息 */ private nextPrefetch: AudioSource | undefined; + private prefetchToken = 0; + + private readonly androidAudioUrlCacheKey = "android-audio-source-url-cache"; + + private readAndroidAudioUrlCache(): Record { + if (!isCapacitorAndroid) return {}; + try { + const raw = localStorage.getItem(this.androidAudioUrlCacheKey); + if (!raw) return {}; + const data = JSON.parse(raw); + return data && typeof data === "object" ? data : {}; + } catch { + return {}; + } + } + + private rememberAndroidAudioUrl(id: number, url: string | undefined | null) { + if (!isCapacitorAndroid || !id || !url || !url.startsWith("http")) return; + try { + const cache = this.readAndroidAudioUrlCache(); + cache[String(id)] = url; + const entries = Object.entries(cache).slice(-200); + localStorage.setItem(this.androidAudioUrlCacheKey, JSON.stringify(Object.fromEntries(entries))); + } catch { + return; + } + } + private encodeLocalFilePath(path: string): string { const safePath = path.replace(/%(?![0-9a-fA-F]{2})/g, "%25"); return encodeURI(safePath) @@ -134,10 +211,20 @@ class SongManager { * 返 null 让上层走原始 URL,底层 CacheDataSource 会自动命中本地缓存文件不重走网络。 */ private checkLocalCache = async ( - _id: number, + id: number, _quality?: QualityType, _md5?: string, ): Promise => { + if (isCapacitorAndroid) { + const cachedUrl = this.readAndroidAudioUrlCache()[String(id)]; + if (!cachedUrl) return null; + try { + const { ready } = await AndroidNativePlayback.isPromotedAudioReady({ url: cachedUrl }); + return ready ? cachedUrl : null; + } catch { + return null; + } + } return null; }; @@ -153,25 +240,32 @@ class SongManager { */ public getOnlineUrl = async (id: number, isPc: boolean = false): Promise => { const settingStore = useSettingStore(); - let level: string = isPc ? "exhigh" : settingStore.songLevel; + let level: SongUrlLevel = normalizeSongUrlLevel( + isPc ? "exhigh" : settingStore.songLevel, + settingStore.disableAiAudio, + ); - // Fuck AI Mode: 如果开启,且请求的 level 是 AI 音质,降级为 hires - if (settingStore.disableAiAudio && AI_AUDIO_LEVELS.includes(level)) { - level = "hires"; - } + let qualityData: Record | undefined; + const getQualityData = async () => { + if (!qualityData) { + const qualityRes = await songQuality(id); + qualityData = qualityRes.data; + } + return qualityData; + }; // 如果请求杜比音质,先检查歌曲是否支持 if (level === "dolby") { try { - const qualityRes = await songQuality(id); - const hasDb = qualityRes.data?.db && Number(qualityRes.data.db.br) > 0; + const quality = await getQualityData(); + const hasDb = quality?.db && Number(quality.db.br) > 0; // 如果不支持杜比,降级到最高可用音质 if (!hasDb) { console.log(`🔽 [${id}] 歌曲不支持杜比音质,自动降级`); // 按优先级降级:hires -> lossless -> exhigh - if (qualityRes.data?.hr && Number(qualityRes.data.hr.br) > 0) { + if (quality?.hr && Number(quality.hr.br) > 0) { level = "hires"; - } else if (qualityRes.data?.sq && Number(qualityRes.data.sq.br) > 0) { + } else if (quality?.sq && Number(quality.sq.br) > 0) { level = "lossless"; } else { level = "exhigh"; @@ -183,11 +277,38 @@ class SongManager { } } - const res = await songUrl(id, level as any); - console.log(`🌐 ${id} music data:`, res); + if (!qualityData && level !== "standard" && level !== "exhigh") { + try { + const quality = await getQualityData(); + if (!isPlayableQualityLevel(quality, level)) { + level = "hires"; + } + } catch (e) { + if (AI_AUDIO_LEVELS.includes(level)) { + console.warn(`检查 AI 音质支持失败,降级到 Hi-Res:`, e); + level = "hires"; + } else { + console.warn(`检查音质支持失败,继续按当前音质请求:`, e); + } + } + } - // 兼容新旧接口的数据结构 - const songData = Array.isArray(res.data) ? res.data[0] : res.data?.[0]; + let res: Awaited> | undefined; + let songData: ReturnType | undefined; + + for (const requestLevel of getSongUrlRequestLevels(level, qualityData)) { + try { + res = await songUrl(id, requestLevel); + console.log(`🌐 ${id} music data:`, res); + songData = getSongUrlData(res); + if (songData?.url) { + level = requestLevel; + break; + } + } catch (error) { + console.warn(`🔽 [${id}] ${requestLevel} 音质地址获取失败,尝试降级`, error); + } + } // 是否有播放地址 if (!songData || !songData?.url) return { id, url: undefined }; @@ -222,6 +343,7 @@ class SongManager { } // 缓存对应音质音乐 if (finalUrl) { + this.rememberAndroidAudioUrl(id, finalUrl); this.triggerCacheDownload(id, finalUrl, quality); } return { id, url: finalUrl, isTrial, quality }; @@ -296,6 +418,7 @@ class SongManager { if (r.status === "fulfilled" && r.value.success) { const unlockUrl = r.value?.result?.url; // 解锁成功后,触发下载 + this.rememberAndroidAudioUrl(songId, unlockUrl); this.triggerCacheDownload(songId, unlockUrl); // 推断音质 let quality = QualityType.HQ; @@ -320,6 +443,7 @@ class SongManager { * @returns 预载数据 */ public prefetchNextSong = async (): Promise => { + const token = ++this.prefetchToken; try { const dataStore = useDataStore(); const statusStore = useStatusStore(); @@ -348,6 +472,7 @@ class SongManager { this.prefetchCover(nextSong); lyricManager.prefetchLyric(nextSong); const { url, isTrial, quality } = await this.getOnlineUrl(nextSong.id, false); + if (token !== this.prefetchToken) return; if (url && !isTrial) { this.nextPrefetch = { id: nextSong.id, @@ -390,6 +515,7 @@ class SongManager { } // 流媒体歌曲 if (nextSong.type === "streaming" && nextSong.streamUrl) { + if (token !== this.prefetchToken) return; this.nextPrefetch = { id: nextSong.id, url: nextSong.streamUrl, @@ -406,6 +532,7 @@ class SongManager { const canUnlock = isElectron && nextSong.type !== "radio" && settingStore.useSongUnlock; // 先请求官方地址 const { url: officialUrl, isTrial, quality } = await this.getOnlineUrl(songId, false); + if (token !== this.prefetchToken) return; // Android 端主动预下载音频前 512KB:下一首切歌后 ExoPlayer setMediaItem 可不走网络手口 const triggerAudioPrefetch = (audioUrl: string | undefined) => { if (!isCapacitorAndroid || !audioUrl || !audioUrl.startsWith("http")) return; @@ -425,6 +552,7 @@ class SongManager { } else if (canUnlock) { // 官方失败或为试听时尝试解锁 const unlockUrl = await this.getUnlockSongUrl(nextSong); + if (token !== this.prefetchToken) return; if (unlockUrl.url) { this.nextPrefetch = { id: songId, url: unlockUrl.url, isUnlocked: true }; triggerAudioPrefetch(unlockUrl.url); @@ -453,6 +581,7 @@ class SongManager { * 清除预加载缓存 */ public clearPrefetch() { + this.prefetchToken++; this.nextPrefetch = undefined; console.log("🧹 已清除歌曲 URL 缓存"); } diff --git a/src/plugins/androidNativePlayback.ts b/src/plugins/androidNativePlayback.ts index f65407b5d..ac72740d9 100644 --- a/src/plugins/androidNativePlayback.ts +++ b/src/plugins/androidNativePlayback.ts @@ -99,6 +99,8 @@ export interface AndroidNativeApiContextPayload { cookie: string; /** 当前用户偏好音质等级(exhigh / lossless / hires / standard …),Java 端 UrlResolver 用于 /song/url/v1?level= */ songLevel?: string; + disableAiAudio?: boolean; + playSongDemo?: boolean; } export type AndroidNativePlaybackStateEvent = AndroidNativePlaybackState; @@ -251,6 +253,7 @@ export interface AndroidNativePlaybackPlugin { * 同 url 并发去重;切歌时未完成的预下载会自动取消让带宽。 */ prefetchAudio(options: { url: string }): Promise; + isPromotedAudioReady(options: { url: string }): Promise<{ ready: boolean }>; addListener( eventName: "playbackStateChanged", listenerFunc: (event: AndroidNativePlaybackStateEvent) => void,