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 9736ce1f6..39f91fa11 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 @@ -5,10 +5,13 @@ import android.util.Log; import androidx.annotation.NonNull; import androidx.annotation.Nullable; +import androidx.media3.common.C; import androidx.media3.database.StandaloneDatabaseProvider; import androidx.media3.datasource.DataSource; import androidx.media3.datasource.DataSpec; +import androidx.media3.datasource.DefaultDataSource; import androidx.media3.datasource.DefaultHttpDataSource; +import androidx.media3.datasource.TransferListener; import androidx.media3.datasource.cache.CacheDataSink; import androidx.media3.datasource.cache.CacheDataSource; import androidx.media3.datasource.cache.CacheSpan; @@ -128,24 +131,36 @@ public static void release() { public static DataSource.Factory buildCachedDataSourceFactory(@NonNull Context appContext) { SimpleCache cache = getOrCreate(appContext); - DataSource.Factory upstreamFactory = + DataSource.Factory httpFactory = new DefaultHttpDataSource.Factory() .setAllowCrossProtocolRedirects(true) .setConnectTimeoutMs(15_000) - .setReadTimeoutMs(15_000) - .setUserAgent("SPlayer-Android/1.0"); + .setReadTimeoutMs(15_000); - // 写入器:把 upstream 拉到的字节落到 SimpleCache。fragmentSize=MAX 让单首歌只产生 1 个文件 + // http(s) 走 CacheDataSource;本地 file:// / content:// 直接 DefaultDataSource, + // 避免本地文件被复制一份到 cacheDir/exo/。 + DataSource.Factory httpCacheFactory = buildHttpCacheFactory(appContext, httpFactory, cache); + DataSource.Factory localFactory = new DefaultDataSource.Factory(appContext); + + return () -> new SchemeRoutingDataSource(httpCacheFactory, localFactory); + } + + /** + * 构造仅用于 http(s) 的 CacheDataSource 工厂;prefetch 路径直接使用此工厂, + * 以便 {@code (CacheDataSource) ds} 强转保持有效。 + */ + @NonNull + private static DataSource.Factory buildHttpCacheFactory( + @NonNull Context appContext, + @NonNull DataSource.Factory httpFactory, + @NonNull SimpleCache cache) { CacheDataSink.Factory cacheSinkFactory = new CacheDataSink.Factory().setCache(cache).setFragmentSize(Long.MAX_VALUE); - - // 包装为动态 Factory:每次 createDataSource 重新判断是否低磁盘, - // 低磁盘时不传 sinkFactory → CacheDataSource 只读不写,已缓存仍可命中,新字节不落盘。 return () -> { CacheDataSource.Factory cf = new CacheDataSource.Factory() .setCache(cache) - .setUpstreamDataSourceFactory(upstreamFactory) + .setUpstreamDataSourceFactory(httpFactory) .setCacheKeyFactory(spec -> resolveCacheKey(spec.uri)) .setFlags( CacheDataSource.FLAG_IGNORE_CACHE_ON_ERROR @@ -159,6 +174,104 @@ public static DataSource.Factory buildCachedDataSourceFactory(@NonNull Context a }; } + /** + * 内部使用:prefetch / CacheWriter 路径专用的 CacheDataSource 工厂。 + * 始终返回 CacheDataSource 实例(http 上游 + cache 落盘),不做 scheme 路由。 + */ + @NonNull + static DataSource.Factory buildHttpCacheFactoryForPrefetch(@NonNull Context appContext) { + SimpleCache cache = getOrCreate(appContext); + DataSource.Factory httpFactory = + new DefaultHttpDataSource.Factory() + .setAllowCrossProtocolRedirects(true) + .setConnectTimeoutMs(15_000) + .setReadTimeoutMs(15_000); + return buildHttpCacheFactory(appContext, httpFactory, cache); + } + + /** + * 根据首次 open 的 DataSpec scheme 选择 http cache 或本地直读。
+ * 同一实例的多次 open 不应跨 scheme(ExoPlayer 不会复用 DataSource 跨 MediaItem), + * 这里仍按每次 open 重新选择以稳健处理边界情况。 + */ + private static final class SchemeRoutingDataSource implements DataSource { + private final DataSource.Factory httpCacheFactory; + private final DataSource.Factory localFactory; + private final java.util.List pendingListeners = new java.util.ArrayList<>(); + private final Object routeLock = new Object(); + @Nullable private volatile DataSource current; + + SchemeRoutingDataSource( + @NonNull DataSource.Factory httpCacheFactory, @NonNull DataSource.Factory localFactory) { + this.httpCacheFactory = httpCacheFactory; + this.localFactory = localFactory; + } + + @Override + public void addTransferListener(@NonNull TransferListener transferListener) { + DataSource ds; + synchronized (routeLock) { + ds = current; + if (ds == null) { + pendingListeners.add(transferListener); + } + } + if (ds != null) ds.addTransferListener(transferListener); + } + + @Override + public long open(@NonNull DataSpec dataSpec) throws java.io.IOException { + DataSource previous; + synchronized (routeLock) { + previous = current; + current = null; + } + if (previous != null) { + try { + previous.close(); + } catch (java.io.IOException ignored) { + } + } + String scheme = dataSpec.uri.getScheme(); + boolean isHttp = "http".equals(scheme) || "https".equals(scheme); + DataSource ds = isHttp ? httpCacheFactory.createDataSource() : localFactory.createDataSource(); + synchronized (routeLock) { + for (TransferListener listener : pendingListeners) { + ds.addTransferListener(listener); + } + pendingListeners.clear(); + current = ds; + } + return ds.open(dataSpec); + } + + @Override + public int read(@NonNull byte[] buffer, int offset, int length) throws java.io.IOException { + DataSource ds = current; + if (ds == null) return C.RESULT_END_OF_INPUT; + return ds.read(buffer, offset, length); + } + + @Nullable + @Override + public Uri getUri() { + DataSource ds = current; + return ds == null ? null : ds.getUri(); + } + + @Override + public void close() throws java.io.IOException { + DataSource ds; + synchronized (routeLock) { + ds = current; + current = null; + } + if (ds != null) { + ds.close(); + } + } + } + /** 已知的临时签名 / 过期参数:仅这些会被剔除以保证同曲目缓存命中。其余 query 参与 key 防碰撞。 */ private static final java.util.Set EPHEMERAL_QUERY_KEYS = new java.util.HashSet<>( @@ -384,7 +497,7 @@ private static void prefetchUrlWithLength( final Object writerLock = cacheKeyWriterLocks.computeIfAbsent(cacheKey, k -> new Object()); try { synchronized (writerLock) { - DataSource.Factory factory = buildCachedDataSourceFactory(appContext); + DataSource.Factory factory = buildHttpCacheFactoryForPrefetch(appContext); DataSource ds = factory.createDataSource(); DataSpec.Builder specBuilder = new DataSpec.Builder().setUri(uri).setKey(cacheKey).setPosition(0); diff --git a/android/app/src/main/java/top/imsyy/splayer/android/download/AndroidDownloadPlugin.java b/android/app/src/main/java/top/imsyy/splayer/android/download/AndroidDownloadPlugin.java index f61707eb8..2cd49be91 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/download/AndroidDownloadPlugin.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/download/AndroidDownloadPlugin.java @@ -4,6 +4,8 @@ import android.content.ContentResolver; import android.content.Context; import android.database.Cursor; +import android.graphics.Bitmap; +import android.graphics.BitmapFactory; import android.media.MediaMetadataRetriever; import android.net.Uri; import android.os.Environment; @@ -19,6 +21,9 @@ import com.getcapacitor.annotation.ActivityCallback; import android.content.Intent; import java.io.BufferedReader; +import java.io.ByteArrayOutputStream; +import java.io.File; +import java.io.FileOutputStream; import java.io.IOException; import java.io.InputStream; import java.io.InputStreamReader; @@ -26,6 +31,7 @@ import java.net.HttpURLConnection; import java.net.URL; import java.nio.charset.StandardCharsets; +import java.security.MessageDigest; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import java.util.concurrent.ConcurrentHashMap; @@ -44,6 +50,7 @@ public class AndroidDownloadPlugin extends Plugin { private final ExecutorService executor = Executors.newFixedThreadPool(2); private final ConcurrentHashMap activeDownloads = new ConcurrentHashMap<>(); + private final ConcurrentHashMap coverWriteLocks = new ConcurrentHashMap<>(); @Override protected void handleOnDestroy() { @@ -416,6 +423,8 @@ private JSObject buildSongMetadata(DocumentFile file, String fileName) { String artist = fallbackArtist; String album = "未知专辑"; long duration = 0L; + long bitrate = 0L; + String cover = ""; // 尝试用 MediaMetadataRetriever 读取标签信息 MediaMetadataRetriever retriever = null; @@ -427,6 +436,7 @@ private JSObject buildSongMetadata(DocumentFile file, String fileName) { String mArtist = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ARTIST); String mAlbum = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_ALBUM); String mDuration = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_DURATION); + String mBitrate = retriever.extractMetadata(MediaMetadataRetriever.METADATA_KEY_BITRATE); if (mTitle != null && !mTitle.trim().isEmpty()) title = mTitle.trim(); if (mArtist != null && !mArtist.trim().isEmpty()) artist = mArtist.trim(); @@ -437,6 +447,19 @@ private JSObject buildSongMetadata(DocumentFile file, String fileName) { } catch (NumberFormatException ignored) { } } + if (mBitrate != null) { + try { + bitrate = Long.parseLong(mBitrate); + } catch (NumberFormatException ignored) { + } + } + + // 提取嵌入封面,压缩后写入 cacheDir/local-covers/.jpg 并返回 file:// URI。 + // 用 file:// 而非 base64 data URL:避免上千首歌每首 ~30KB 字符串经 Capacitor 桥响应造成 IPC 肨胀 / OOM。 + byte[] embeddedPicture = retriever.getEmbeddedPicture(); + if (embeddedPicture != null) { + cover = writeEmbeddedCoverToCache(uri, embeddedPicture); + } } catch (Exception ignored) { // 文件不可解析,使用文件名兜底 } finally { @@ -458,13 +481,118 @@ private JSObject buildSongMetadata(DocumentFile file, String fileName) { song.put("album", album); song.put("duration", duration); song.put("size", size); + song.put("quality", bitrate); song.put("path", uri); song.put("fileName", fileName); song.put("ext", extension); song.put("lastModified", lastModified); + song.put("cover", cover); return song; } + /** + * 嵌入封面落盘:按源 URI hash 成名,写入 {@code cacheDir/local-covers/}。返回 file:// URI。 + * 已存在则复用。压缩到 1024px 内 JPEG quality 80。完全失败返回 ""。 + */ + private String writeEmbeddedCoverToCache(String sourceUri, byte[] pictureBytes) { + Context ctx = getContext(); + if (ctx == null) return ""; + Bitmap original = null; + Bitmap scaled = null; + try { + File coverDir = new File(ctx.getCacheDir(), "local-covers"); + if (!coverDir.exists() && !coverDir.mkdirs()) { + return ""; + } + String idHash = sha256Hex(sourceUri); + if (idHash.isEmpty()) return ""; + File coverFile = new File(coverDir, idHash + ".jpg"); + Object writeLock = coverWriteLocks.computeIfAbsent(idHash, key -> new Object()); + try { + synchronized (writeLock) { + if (coverFile.isFile() && coverFile.length() > 0) { + return "file://" + coverFile.getAbsolutePath(); + } + BitmapFactory.Options boundsOptions = new BitmapFactory.Options(); + boundsOptions.inJustDecodeBounds = true; + BitmapFactory.decodeByteArray(pictureBytes, 0, pictureBytes.length, boundsOptions); + int maxSize = 1024; + BitmapFactory.Options decodeOptions = new BitmapFactory.Options(); + decodeOptions.inSampleSize = calculateInSampleSize(boundsOptions, maxSize); + original = BitmapFactory.decodeByteArray(pictureBytes, 0, pictureBytes.length, decodeOptions); + if (original == null) return ""; + int w = original.getWidth(); + int h = original.getHeight(); + float scale = Math.min((float) maxSize / w, (float) maxSize / h); + scaled = original; + if (scale < 1.0f) { + scaled = Bitmap.createScaledBitmap(original, Math.round(w * scale), Math.round(h * scale), true); + } + try (ByteArrayOutputStream baos = new ByteArrayOutputStream()) { + scaled.compress(Bitmap.CompressFormat.JPEG, 80, baos); + byte[] jpegBytes = baos.toByteArray(); + // 原子写:tmp → rename,避免进程被杀产生半截断文件 + File tmp = new File(coverDir, idHash + ".jpg.tmp"); + try (FileOutputStream fos = new FileOutputStream(tmp)) { + fos.write(jpegBytes); + fos.getFD().sync(); + } catch (IOException e) { + // noinspection ResultOfMethodCallIgnored + tmp.delete(); + return ""; + } + if (coverFile.exists()) { + // noinspection ResultOfMethodCallIgnored + coverFile.delete(); + } + if (!tmp.renameTo(coverFile)) { + // noinspection ResultOfMethodCallIgnored + tmp.delete(); + return ""; + } + } + } + } finally { + coverWriteLocks.remove(idHash, writeLock); + } + return "file://" + coverFile.getAbsolutePath(); + } catch (Throwable ignored) { + return ""; + } finally { + if (scaled != null && scaled != original) { + scaled.recycle(); + } + if (original != null) { + original.recycle(); + } + } + } + + private String sha256Hex(String value) { + try { + MessageDigest digest = MessageDigest.getInstance("SHA-256"); + byte[] hash = digest.digest(value.getBytes(StandardCharsets.UTF_8)); + StringBuilder builder = new StringBuilder(hash.length * 2); + for (byte b : hash) { + builder.append(String.format("%02x", b)); + } + return builder.toString(); + } catch (Exception ignored) { + return ""; + } + } + + private int calculateInSampleSize(BitmapFactory.Options options, int maxSize) { + int height = options.outHeight; + int width = options.outWidth; + if (height <= 0 || width <= 0) return maxSize; + int inSampleSize = 1; + while (height / inSampleSize > maxSize || width / inSampleSize > maxSize) { + inSampleSize *= 2; + } + return inSampleSize; + } + private boolean isAudioFile(String name) { String lower = name.toLowerCase(); return lower.endsWith(".mp3") 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 f35e463b8..a6a27c91d 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 @@ -4,6 +4,7 @@ import android.app.NotificationChannel; import android.app.NotificationManager; import android.app.PendingIntent; +import android.content.ContentResolver; import android.content.Context; import android.content.Intent; import android.graphics.Bitmap; @@ -18,6 +19,7 @@ import android.os.Handler; import android.os.Looper; import android.os.PowerManager; +import android.util.Base64; import android.util.Log; import android.view.KeyEvent; import androidx.annotation.NonNull; @@ -60,7 +62,10 @@ import java.net.URLEncoder; import java.nio.charset.StandardCharsets; import java.util.ArrayList; +import java.util.Collections; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import java.util.concurrent.ExecutorService; import java.util.concurrent.Executors; import top.imsyy.splayer.android.MainActivity; @@ -83,6 +88,7 @@ public final class PlaybackManager { private static final long FAVORITE_REQUEST_RETRY_DELAY_MS = 350L; private static final long SEEK_STATE_GRACE_MS = 4000L; private static final long SEEK_POSITION_TOLERANCE_MS = 1500L; + private static final int CONTENT_MIME_CACHE_MAX_SIZE = 1024; private static volatile PlaybackManager instance; private final Context appContext; @@ -101,7 +107,7 @@ public final class PlaybackManager { /** 暴露给 MediaSession 的包装 Player:覆写 availableCommands,让系统媒体面板始终展示上一/下一首。 */ private Player sessionPlayer; private MediaSession mediaSession; - private PlaybackService service; + private volatile PlaybackService service; /** PlaybackService 是否已通过 onCreate→attachService 启动并保持运行;用于 ensureServiceRunning 节流。 */ private volatile boolean serviceStarted; private AndroidNativePlaybackPlugin plugin; @@ -130,6 +136,9 @@ public final class PlaybackManager { /** Java 端 URL 解析器:WebView 冻结时仍可自治取地址。 */ private final PlaybackUrlResolver urlResolver = new PlaybackUrlResolver(); private TrackMetadata currentMetadata = new TrackMetadata(); + private static final int NATIVE_ERROR_RECOVERY_MAX_ATTEMPTS = 2; + private long nativeRecoverySongId = 0L; + private int nativeRecoveryAttempts = 0; /** * Java 端自治播放队列(滑动窗口)。 * @@ -582,6 +591,8 @@ public synchronized void updateQueueContext( windowResetFromWrap ? playbackQueue.current() : playbackQueue.advanceRaw(false); if (resume != null) { resolveAndPlayAsync(resume, "auto", true, 5); + } else { + emitCustomAction("next", null, null, null, null, true, null); } } } @@ -899,6 +910,9 @@ public void onPositionDiscontinuity( @Override public void onPlayerError(PlaybackException error) { + if (recoverCurrentTrackAfterError(error)) { + return; + } emitError(error.errorCode, error.getMessage()); updateNotification(); } @@ -1026,12 +1040,46 @@ private void ensureServiceRunning() { private MediaItem buildMediaItem(String url) { MediaItem.Builder builder = new MediaItem.Builder(); if (url != null && !url.isEmpty()) { - builder.setUri(Uri.parse(url)); + Uri uri = Uri.parse(url); + builder.setUri(uri); + if ("content".equals(uri.getScheme())) { + builder.setMimeType(resolveContentMimeType(uri)); + } } builder.setMediaMetadata(buildMediaMetadata()); return builder.build(); } + /** content:// MIME 缓存:避免 buildMediaItem 在主线程对未冷启的 ContentProvider 做同步 IPC。 */ + private final Map contentMimeCache = + Collections.synchronizedMap( + new LinkedHashMap(CONTENT_MIME_CACHE_MAX_SIZE, 0.75f, true) { + @Override + protected boolean removeEldestEntry(Map.Entry eldest) { + return size() > CONTENT_MIME_CACHE_MAX_SIZE; + } + }); + + @Nullable + private String resolveContentMimeType(Uri uri) { + String key = uri.toString(); + String cached = contentMimeCache.get(key); + if (cached != null) { + // 空字符串 sentinel:曾经查到过 null(未知 MIME),不再重复查 + return cached.isEmpty() ? null : cached; + } + try { + ContentResolver resolver = appContext.getContentResolver(); + String mime = resolver.getType(uri); + contentMimeCache.put(key, mime == null ? "" : mime); + return mime; + } catch (Exception error) { + Log.w(TAG, "resolveContentMimeType failed", error); + contentMimeCache.put(key, ""); + return null; + } + } + private MediaMetadata buildMediaMetadata() { MediaMetadata.Builder builder = new MediaMetadata.Builder(); @@ -1247,6 +1295,8 @@ private void playFromQueue(PlaybackQueue.Track track, String source) { // 切歌即失效旧 async 回调 + 清 pending,防止旧 URL 覆盖 / 补窗误消费 resolveTokenCounter.incrementAndGet(); pendingResumeAfterRefill = false; + nativeRecoverySongId = 0L; + nativeRecoveryAttempts = 0; TrackMetadata metadata = trackToMetadata(track); startTrackFromState(track.url, metadata, track.liked, true); @@ -1267,6 +1317,72 @@ private void playFromQueue(PlaybackQueue.Track track, String source) { requestUrlsIfWindowExhausted(); } + private boolean recoverCurrentTrackAfterError(PlaybackException error) { + long songId = currentMetadata.songId; + if (songId <= 0 || !currentMetadata.canLike) return false; + if (currentSource == null || currentSource.isEmpty()) return false; + if (!isRecoverablePlaybackError(error)) return false; + if (nativeRecoverySongId != songId) { + nativeRecoverySongId = songId; + nativeRecoveryAttempts = 0; + } + if (nativeRecoveryAttempts >= NATIVE_ERROR_RECOVERY_MAX_ATTEMPTS) return false; + nativeRecoveryAttempts++; + long positionMs = Math.max(0L, getPositionMs()); + TrackMetadata metadataSnapshot = currentMetadata.copy(); + boolean likedSnapshot = liked; + Log.w(TAG, "native recover playback error code=" + error.errorCode + " songId=" + songId); + final long myToken = resolveTokenCounter.incrementAndGet(); + urlResolver.clear(songId); + urlResolver.submitResolve( + songId, + url -> + mainHandler.post( + () -> { + if (player == null) return; + if (myToken != resolveTokenCounter.get()) return; + if (url == null || url.isEmpty()) { + emitError(error.errorCode, error.getMessage()); + updateNotification(); + return; + } + metadataSnapshot.url = url; + currentSource = url; + currentMetadata = metadataSnapshot.copy(); + playbackQueue.updateTrackUrl(songId, url); + liked = likedSnapshot; + clearPendingSeek(); + durationCalibratedForSource = ""; + player.setMediaItem(buildMediaItem(url)); + player.prepare(); + if (positionMs > 0) { + player.seekTo(positionMs); + beginPendingSeek(positionMs); + } + player.play(); + updateNotification(); + emitPlaybackState(true); + emitProgressChanged(); + nativeRecoverySongId = 0L; + nativeRecoveryAttempts = 0; + prefetchUpcomingUrls(); + })); + return true; + } + + private boolean isRecoverablePlaybackError(PlaybackException error) { + int code = error.errorCode; + return code == PlaybackException.ERROR_CODE_IO_UNSPECIFIED + || code == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED + || code == PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT + || code == PlaybackException.ERROR_CODE_IO_INVALID_HTTP_CONTENT_TYPE + || code == PlaybackException.ERROR_CODE_IO_BAD_HTTP_STATUS + || code == PlaybackException.ERROR_CODE_IO_FILE_NOT_FOUND + || code == PlaybackException.ERROR_CODE_IO_NO_PERMISSION + || code == PlaybackException.ERROR_CODE_IO_CLEARTEXT_NOT_PERMITTED + || code == PlaybackException.ERROR_CODE_IO_READ_POSITION_OUT_OF_RANGE; + } + /** 转换 PlaybackQueue.Track → TrackMetadata(复用现有切歌路径) */ private TrackMetadata trackToMetadata(PlaybackQueue.Track track) { TrackMetadata m = new TrackMetadata(); @@ -1452,12 +1568,22 @@ private void updateNotification() { } catch (IllegalStateException error) { // Android 12+ ForegroundServiceStartNotAllowedException 继承 IllegalStateException, // 在 Doze 边缘 / 系统极限场景偶发。仅记录,让通知降级为普通通知,避免崩溃。 + if (!isForegroundServiceStartNotAllowed(error)) { + throw error; + } Log.w(TAG, "Failed to enter foreground service from background", error); NotificationManagerCompat.from(appContext) .notify(PlaybackConstants.NOTIFICATION_ID, notification); } } + private boolean isForegroundServiceStartNotAllowed(IllegalStateException error) { + String className = error.getClass().getName(); + String message = error.getMessage(); + return className.contains("ForegroundServiceStartNotAllowedException") + || (message != null && message.contains("ForegroundService")); + } + private Notification buildNotification() { NotificationCompat.Builder builder = new NotificationCompat.Builder(appContext, PlaybackConstants.CHANNEL_ID) @@ -1867,7 +1993,23 @@ private void loadCoverBitmapAsync(String coverUrl) { HttpURLConnection connection = null; try { - if (coverUrl.startsWith("http://") || coverUrl.startsWith("https://")) { + if (coverUrl.startsWith("data:")) { + // 仅支持 data:image/*;base64,xxx 形式;非 base64 / 非 image 的 data URL 直接忽略 + int commaIdx = coverUrl.indexOf(','); + int base64MarkerIdx = coverUrl.indexOf(";base64"); + if (commaIdx > 0 + && base64MarkerIdx > 0 + && base64MarkerIdx < commaIdx + && coverUrl.startsWith("data:image/")) { + String base64Data = coverUrl.substring(commaIdx + 1); + try { + byte[] decoded = Base64.decode(base64Data, Base64.DEFAULT); + bitmap = BitmapFactory.decodeByteArray(decoded, 0, decoded.length); + } catch (IllegalArgumentException ignored) { + // 非法 base64:忽略 + } + } + } else if (coverUrl.startsWith("http://") || coverUrl.startsWith("https://")) { connection = (HttpURLConnection) new URL(coverUrl).openConnection(); connection.setConnectTimeout(8000); connection.setReadTimeout(8000); @@ -1875,6 +2017,12 @@ private void loadCoverBitmapAsync(String coverUrl) { connection.connect(); inputStream = connection.getInputStream(); bitmap = BitmapFactory.decodeStream(inputStream); + } else if (coverUrl.startsWith("content://")) { + ContentResolver resolver = appContext.getContentResolver(); + inputStream = resolver.openInputStream(Uri.parse(coverUrl)); + if (inputStream != null) { + bitmap = BitmapFactory.decodeStream(inputStream); + } } else if (coverUrl.startsWith("file://")) { bitmap = BitmapFactory.decodeFile(Uri.parse(coverUrl).getPath()); } diff --git a/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackQueue.java b/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackQueue.java index a4873c991..a553ff69a 100644 --- a/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackQueue.java +++ b/android/app/src/main/java/top/imsyy/splayer/android/playback/PlaybackQueue.java @@ -46,7 +46,7 @@ public static RepeatMode fromString(@Nullable String value) { /** 单首曲目元数据 + 已解析 URL。 */ public static final class Track { - /** 网易云 songId。必须 long:2024+ 部分 ID 超过 Integer.MAX_VALUE,int 会溢出为负。 */ + /** 内部 songId。必须 long:2024+ 部分 ID 超过 Integer.MAX_VALUE,int 会溢出为负。 */ public long songId; public long durationMs; public boolean canLike; 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 d885b56c4..4d93fdaab 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 @@ -87,6 +87,17 @@ public synchronized void updateContext( } } + public void clear(long songId) { + if (songId <= 0) return; + String prefix = songId + ":"; + synchronized (cache) { + cache.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + } + synchronized (negativeCache) { + negativeCache.entrySet().removeIf(entry -> entry.getKey().startsWith(prefix)); + } + } + /** 阻塞解析 songId 的 URL;命中缓存 / 失败 / 未配置返 null。 */ @Nullable public String resolveSync(long songId) { diff --git a/src/components/Card/SongCard.vue b/src/components/Card/SongCard.vue index 97dc44689..87ad11113 100644 --- a/src/components/Card/SongCard.vue +++ b/src/components/Card/SongCard.vue @@ -1,530 +1,530 @@ - - - - - + + + + + diff --git a/src/components/List/CoverList.vue b/src/components/List/CoverList.vue index 03503845d..8e1555c1e 100644 --- a/src/components/List/CoverList.vue +++ b/src/components/List/CoverList.vue @@ -469,6 +469,9 @@ const getListData = async (id: number | string): Promise => { border: 2px solid rgba(var(--primary), 0.12); padding: 0; overflow: hidden; + @media (max-width: 767px) and (orientation: portrait) { + min-height: 72px; + } &:hover { border-color: rgba(var(--primary), 0.58); } @@ -479,6 +482,12 @@ const getListData = async (id: number | string): Promise => { font-size: 18px; font-weight: bold; } + @media (max-width: 767px) and (orientation: portrait) { + padding: 10px 12px; + .name { + font-size: 15px; + } + } } } } diff --git a/src/components/List/SongList.vue b/src/components/List/SongList.vue index f911fc3e9..d10a147df 100644 --- a/src/components/List/SongList.vue +++ b/src/components/List/SongList.vue @@ -552,6 +552,9 @@ onBeforeUnmount(() => { + + + + + + diff --git a/src/composables/useCoverCache.ts b/src/composables/useCoverCache.ts index e206540c6..5fc3026cc 100644 --- a/src/composables/useCoverCache.ts +++ b/src/composables/useCoverCache.ts @@ -1,342 +1,357 @@ -import { ref, watch, onBeforeUnmount, type Ref } from "vue"; -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; - } - // 非 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; + } + } }