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() {
*
*
* - 过期(now - lastAccessAt > TTL_MILLIS)的条目,从 SimpleCache 中清除对应资源
- *
- SimpleCache 中已经不存在该 cacheKey 的"孤儿"索引项
+ *
- 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,