Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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
Expand All @@ -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 或本地直读。<br>
* 同一实例的多次 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<TransferListener> 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<String> EPHEMERAL_QUERY_KEYS =
new java.util.HashSet<>(
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -19,13 +21,17 @@
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;
import java.io.OutputStream;
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;
Expand All @@ -44,6 +50,7 @@ public class AndroidDownloadPlugin extends Plugin {

private final ExecutorService executor = Executors.newFixedThreadPool(2);
private final ConcurrentHashMap<Long, PluginCall> activeDownloads = new ConcurrentHashMap<>();
private final ConcurrentHashMap<String, Object> coverWriteLocks = new ConcurrentHashMap<>();

@Override
protected void handleOnDestroy() {
Expand Down Expand Up @@ -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;
Expand All @@ -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();
Expand All @@ -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/<idHash>.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 {
Expand All @@ -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")
Expand Down
Loading