Merge branch 'development' into skip-duplicates

This commit is contained in:
eddyizm 2025-10-06 22:22:52 -07:00 committed by GitHub
commit 717f95a04a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
87 changed files with 6098 additions and 428 deletions

View file

@ -85,6 +85,13 @@ object Constants {
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
const val DOWNLOAD_URI = "rest/download"
const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD"
const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI"
const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID"
const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE"
const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST"
const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM"
const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION"
const val DOWNLOAD_TYPE_TRACK = "download_type_track"
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
@ -116,4 +123,13 @@ object Constants {
const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED"
const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS"
const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED"
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON"
const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
}

View file

@ -78,32 +78,26 @@ public final class DownloadUtil {
return httpDataSourceFactory;
}
public static synchronized DataSource.Factory getDataSourceFactory(Context context) {
if (dataSourceFactory == null) {
context = context.getApplicationContext();
public static synchronized DataSource.Factory getUpstreamDataSourceFactory(Context context) {
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
return dataSourceFactory;
}
DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory());
if (Preferences.getStreamingCacheSize() > 0) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))
.setUpstreamDataSourceFactory(upstreamFactory);
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
new StreamingCacheDataSource.Factory(streamCacheFactory),
dataSpec -> {
DataSpec.Builder builder = dataSpec.buildUpon();
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
return builder.build();
}
);
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
} else {
dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context));
}
}
public static synchronized DataSource.Factory getCacheDataSourceFactory(Context context) {
CacheDataSource.Factory streamCacheFactory = new CacheDataSource.Factory()
.setCache(getStreamingCache(context))
.setUpstreamDataSourceFactory(getUpstreamDataSourceFactory(context));
ResolvingDataSource.Factory resolvingFactory = new ResolvingDataSource.Factory(
new StreamingCacheDataSource.Factory(streamCacheFactory),
dataSpec -> {
DataSpec.Builder builder = dataSpec.buildUpon();
builder.setFlags(dataSpec.flags & ~DataSpec.FLAG_DONT_CACHE_IF_LENGTH_UNKNOWN);
return builder.build();
}
);
dataSourceFactory = buildReadOnlyCacheDataSource(resolvingFactory, getDownloadCache(context));
return dataSourceFactory;
}
@ -193,19 +187,21 @@ public final class DownloadUtil {
private static synchronized File getDownloadDirectory(Context context) {
if (downloadDirectory == null) {
if (Preferences.getDownloadStoragePreference() == 0) {
int pref = Preferences.getDownloadStoragePreference();
if (pref == 0) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
if (downloadDirectory == null) {
downloadDirectory = context.getFilesDir();
}
} else {
} else if (pref == 1) {
try {
downloadDirectory = context.getExternalFilesDirs(null)[1];
} catch (Exception exception) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
Preferences.setDownloadStoragePreference(0);
}
} else {
downloadDirectory = context.getExternalFilesDirs(null)[0];
}
}

View file

@ -0,0 +1,69 @@
package com.cappielloantonio.tempo.util
import android.content.Context
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.MimeTypes
import androidx.media3.common.util.UnstableApi
import androidx.media3.datasource.DataSource
import androidx.media3.exoplayer.drm.DrmSessionManagerProvider
import androidx.media3.exoplayer.hls.HlsMediaSource
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.ProgressiveMediaSource
import androidx.media3.exoplayer.upstream.LoadErrorHandlingPolicy
import androidx.media3.extractor.DefaultExtractorsFactory
import androidx.media3.extractor.ExtractorsFactory
@UnstableApi
class DynamicMediaSourceFactory(
private val context: Context
) : MediaSource.Factory {
override fun createMediaSource(mediaItem: MediaItem): MediaSource {
val mediaType: String? = mediaItem.mediaMetadata.extras?.getString("type", "")
val streamingCacheSize = Preferences.getStreamingCacheSize()
val bypassCache = mediaType == Constants.MEDIA_TYPE_RADIO
val useUpstream = when {
streamingCacheSize.toInt() == 0 -> true
streamingCacheSize > 0 && bypassCache -> true
streamingCacheSize > 0 && !bypassCache -> false
else -> true
}
val dataSourceFactory: DataSource.Factory = if (useUpstream) {
DownloadUtil.getUpstreamDataSourceFactory(context)
} else {
DownloadUtil.getCacheDataSourceFactory(context)
}
return when {
mediaItem.localConfiguration?.mimeType == MimeTypes.APPLICATION_M3U8 ||
mediaItem.localConfiguration?.uri?.lastPathSegment?.endsWith(".m3u8", ignoreCase = true) == true -> {
HlsMediaSource.Factory(dataSourceFactory).createMediaSource(mediaItem)
}
else -> {
val extractorsFactory: ExtractorsFactory = DefaultExtractorsFactory()
ProgressiveMediaSource.Factory(dataSourceFactory, extractorsFactory)
.createMediaSource(mediaItem)
}
}
}
override fun setDrmSessionManagerProvider(drmSessionManagerProvider: DrmSessionManagerProvider): MediaSource.Factory {
TODO("Not yet implemented")
}
override fun setLoadErrorHandlingPolicy(loadErrorHandlingPolicy: LoadErrorHandlingPolicy): MediaSource.Factory {
TODO("Not yet implemented")
}
override fun getSupportedTypes(): IntArray {
return intArrayOf(
C.CONTENT_TYPE_HLS,
C.CONTENT_TYPE_OTHER
)
}
}

View file

@ -0,0 +1,244 @@
package com.cappielloantonio.tempo.util;
import android.net.Uri;
import android.os.Looper;
import android.os.SystemClock;
import androidx.documentfile.provider.DocumentFile;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import java.text.Normalizer;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Locale;
import java.util.Map;
import java.util.Set;
import java.util.concurrent.ConcurrentHashMap;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExternalAudioReader {
private static final Map<String, DocumentFile> cache = new ConcurrentHashMap<>();
private static final Object LOCK = new Object();
private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
private static final MutableLiveData<Long> refreshEvents = new MutableLiveData<>();
private static volatile String cachedDirUri;
private static volatile boolean refreshInProgress = false;
private static volatile boolean refreshQueued = false;
private static String sanitizeFileName(String name) {
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
sanitized = sanitized.replaceAll("\\s+", " ").trim();
return sanitized;
}
private static String normalizeForComparison(String name) {
String s = sanitizeFileName(name);
s = Normalizer.normalize(s, Normalizer.Form.NFKD);
s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
return s.toLowerCase(Locale.ROOT);
}
private static void ensureCache() {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
synchronized (LOCK) {
cache.clear();
cachedDirUri = null;
}
ExternalDownloadMetadataStore.clear();
return;
}
if (uriString.equals(cachedDirUri)) {
return;
}
boolean runSynchronously = false;
synchronized (LOCK) {
if (refreshInProgress) {
return;
}
if (Looper.myLooper() == Looper.getMainLooper()) {
scheduleRefreshLocked();
return;
}
refreshInProgress = true;
runSynchronously = true;
}
if (runSynchronously) {
try {
rebuildCache();
} finally {
onRefreshFinished();
}
}
}
public static void refreshCache() {
refreshCacheAsync();
}
public static void refreshCacheAsync() {
synchronized (LOCK) {
cachedDirUri = null;
cache.clear();
}
requestRefresh();
}
public static LiveData<Long> getRefreshEvents() {
return refreshEvents;
}
private static String buildKey(String artist, String title, String album) {
String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title;
if (album != null && !album.isEmpty()) name += " (" + album + ")";
return normalizeForComparison(name);
}
private static Uri findUri(String artist, String title, String album) {
ensureCache();
if (cachedDirUri == null) return null;
DocumentFile file = cache.get(buildKey(artist, title, album));
return file != null && file.exists() ? file.getUri() : null;
}
public static Uri getUri(Child media) {
return findUri(media.getArtist(), media.getTitle(), media.getAlbum());
}
public static Uri getUri(PodcastEpisode episode) {
return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum());
}
public static synchronized void removeMetadata(Child media) {
if (media == null) {
return;
}
String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
cache.remove(key);
ExternalDownloadMetadataStore.remove(key);
}
public static boolean delete(Child media) {
ensureCache();
if (cachedDirUri == null) return false;
String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
DocumentFile file = cache.get(key);
boolean deleted = false;
if (file != null && file.exists()) {
deleted = file.delete();
}
if (deleted) {
cache.remove(key);
ExternalDownloadMetadataStore.remove(key);
}
return deleted;
}
private static void requestRefresh() {
synchronized (LOCK) {
scheduleRefreshLocked();
}
}
private static void scheduleRefreshLocked() {
if (refreshInProgress) {
refreshQueued = true;
return;
}
refreshInProgress = true;
REFRESH_EXECUTOR.execute(() -> {
try {
rebuildCache();
} finally {
onRefreshFinished();
}
});
}
private static void rebuildCache() {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
synchronized (LOCK) {
cache.clear();
cachedDirUri = null;
}
ExternalDownloadMetadataStore.clear();
return;
}
DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
Map<String, Long> expectedSizes = ExternalDownloadMetadataStore.snapshot();
Set<String> verifiedKeys = new HashSet<>();
Map<String, DocumentFile> newEntries = new HashMap<>();
if (directory != null && directory.canRead()) {
for (DocumentFile file : directory.listFiles()) {
if (file == null || file.isDirectory()) continue;
String existing = file.getName();
if (existing == null) continue;
String base = existing.replaceFirst("\\.[^\\.]+$", "");
String key = normalizeForComparison(base);
Long expected = expectedSizes.get(key);
long actualLength = file.length();
if (expected != null && expected > 0 && actualLength == expected) {
newEntries.put(key, file);
verifiedKeys.add(key);
} else {
ExternalDownloadMetadataStore.remove(key);
}
}
}
if (!expectedSizes.isEmpty()) {
if (verifiedKeys.isEmpty()) {
ExternalDownloadMetadataStore.clear();
} else {
for (String key : expectedSizes.keySet()) {
if (!verifiedKeys.contains(key)) {
ExternalDownloadMetadataStore.remove(key);
}
}
}
}
synchronized (LOCK) {
cache.clear();
cache.putAll(newEntries);
cachedDirUri = uriString;
}
}
private static void onRefreshFinished() {
boolean runAgain;
synchronized (LOCK) {
refreshInProgress = false;
runAgain = refreshQueued;
refreshQueued = false;
}
refreshEvents.postValue(SystemClock.elapsedRealtime());
if (runAgain) {
requestRefresh();
}
}
}

View file

@ -0,0 +1,322 @@
package com.cappielloantonio.tempo.util;
import android.app.NotificationManager;
import android.app.PendingIntent;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.provider.Settings;
import android.webkit.MimeTypeMap;
import androidx.core.app.NotificationCompat;
import androidx.documentfile.provider.DocumentFile;
import androidx.media3.common.MediaItem;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
import java.net.URL;
import java.text.Normalizer;
import java.util.Locale;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
public class ExternalAudioWriter {
private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
private static final int BUFFER_SIZE = 8192;
private static final int CONNECT_TIMEOUT_MS = 15_000;
private static final int READ_TIMEOUT_MS = 60_000;
private ExternalAudioWriter() {
}
private static String sanitizeFileName(String name) {
String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
sanitized = sanitized.replaceAll("\\s+", " ").trim();
return sanitized;
}
private static String normalizeForComparison(String name) {
String s = sanitizeFileName(name);
s = Normalizer.normalize(s, Normalizer.Form.NFKD);
s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
return s.toLowerCase(Locale.ROOT);
}
private static DocumentFile findFile(DocumentFile dir, String fileName) {
String normalized = normalizeForComparison(fileName);
for (DocumentFile file : dir.listFiles()) {
if (file.isDirectory()) continue;
String existing = file.getName();
if (existing != null && normalizeForComparison(existing).equals(normalized)) {
return file;
}
}
return null;
}
public static void downloadToUserDirectory(Context context, Child child) {
if (context == null || child == null) {
return;
}
Context appContext = context.getApplicationContext();
MediaItem mediaItem = MappingUtil.mapDownload(child);
String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId();
EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child));
}
private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) {
String uriString = Preferences.getDownloadDirectoryUri();
if (uriString == null) {
notifyUnavailable(context);
return;
}
DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
if (directory == null || !directory.canWrite()) {
notifyFailure(context, "Cannot write to folder.");
return;
}
String artist = child.getArtist() != null ? child.getArtist() : "";
String title = child.getTitle() != null ? child.getTitle() : fallbackName;
String album = child.getAlbum() != null ? child.getAlbum() : "";
String baseName = artist.isEmpty() ? title : artist + " - " + title;
if (!album.isEmpty()) baseName += " (" + album + ")";
if (baseName.isEmpty()) {
baseName = fallbackName != null ? fallbackName : "download";
}
String metadataKey = normalizeForComparison(baseName);
Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null
? mediaItem.requestMetadata.mediaUri
: null;
if (mediaUri == null) {
notifyFailure(context, "Invalid media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
String scheme = mediaUri.getScheme();
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
notifyFailure(context, "Unsupported media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
HttpURLConnection connection = null;
DocumentFile targetFile = null;
try {
connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
connection.setReadTimeout(READ_TIMEOUT_MS);
connection.setRequestProperty("Accept-Encoding", "identity");
connection.connect();
int responseCode = connection.getResponseCode();
if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
notifyFailure(context, "Server returned " + responseCode);
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
String mimeType = connection.getContentType();
if (mimeType == null || mimeType.isEmpty()) {
mimeType = "application/octet-stream";
}
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
if (extension == null || extension.isEmpty()) {
String suffix = child.getSuffix();
if (suffix != null && !suffix.isEmpty()) {
extension = suffix;
} else {
extension = "bin";
}
}
String sanitized = sanitizeFileName(baseName);
if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName);
if (sanitized.isEmpty()) sanitized = "download";
String fileName = sanitized + "." + extension;
DocumentFile existingFile = findFile(directory, fileName);
long remoteLength = connection.getContentLengthLong();
Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
if (existingFile != null && existingFile.exists()) {
long localLength = existingFile.length();
boolean matches = false;
if (remoteLength > 0 && localLength == remoteLength) {
matches = true;
} else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) {
matches = true;
}
if (matches) {
ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
recordDownload(child, existingFile.getUri());
ExternalAudioReader.refreshCacheAsync();
notifyExists(context, fileName);
return;
} else {
existingFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
}
}
targetFile = directory.createFile(mimeType, fileName);
if (targetFile == null) {
notifyFailure(context, "Failed to create file.");
return;
}
Uri targetUri = targetFile.getUri();
try (InputStream in = connection.getInputStream();
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
if (out == null) {
notifyFailure(context, "Cannot open output stream.");
targetFile.delete();
return;
}
byte[] buffer = new byte[BUFFER_SIZE];
int len;
long total = 0;
while ((len = in.read(buffer)) != -1) {
out.write(buffer, 0, len);
total += len;
}
out.flush();
if (total <= 0) {
targetFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, "Empty download.");
return;
}
if (remoteLength > 0 && total != remoteLength) {
targetFile.delete();
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, "Incomplete download.");
return;
}
ExternalDownloadMetadataStore.recordSize(metadataKey, total);
recordDownload(child, targetUri);
notifySuccess(context, fileName, child, targetUri);
ExternalAudioReader.refreshCacheAsync();
}
} catch (Exception e) {
if (targetFile != null) {
targetFile.delete();
}
ExternalDownloadMetadataStore.remove(metadataKey);
notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed");
} finally {
if (connection != null) {
connection.disconnect();
}
}
}
private static void notifyUnavailable(Context context) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
Uri.fromParts("package", context.getPackageName(), null));
PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("No download folder set")
.setContentText("Tap to set one in settings")
.setSmallIcon(android.R.drawable.stat_notify_error)
.setPriority(NotificationCompat.PRIORITY_LOW)
.setSilent(true)
.setContentIntent(openSettings)
.setAutoCancel(true);
manager.notify(1011, builder.build());
}
private static void notifyFailure(Context context, String message) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Download failed")
.setContentText(message)
.setSmallIcon(android.R.drawable.stat_notify_error)
.setAutoCancel(true);
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static void notifySuccess(Context context, String name, Child child, Uri fileUri) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Download complete")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_download_done)
.setAutoCancel(true);
PendingIntent playIntent = buildPlayIntent(context, child, fileUri);
if (playIntent != null) {
builder.setContentIntent(playIntent);
}
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static void recordDownload(Child child, Uri fileUri) {
if (child == null) {
return;
}
Download download = new Download(child);
download.setDownloadState(1);
if (fileUri != null) {
download.setDownloadUri(fileUri.toString());
}
new DownloadRepository().insert(download);
}
private static void notifyExists(Context context, String name) {
NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
.setContentTitle("Already downloaded")
.setContentText(name)
.setSmallIcon(android.R.drawable.stat_sys_warning)
.setAutoCancel(true);
manager.notify((int) System.currentTimeMillis(), builder.build());
}
private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) {
if (fileUri == null) return null;
Intent intent = new Intent(context, MainActivity.class)
.setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD)
.putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString())
.putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId())
.putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle())
.putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist())
.putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum())
.putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0)
.addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
int requestCode;
if (child.getId() != null) {
requestCode = Math.abs(child.getId().hashCode());
} else {
requestCode = Math.abs(fileUri.toString().hashCode());
}
return PendingIntent.getActivity(
context,
requestCode,
intent,
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
}

View file

@ -0,0 +1,123 @@
package com.cappielloantonio.tempo.util;
import android.content.SharedPreferences;
import androidx.annotation.Nullable;
import com.cappielloantonio.tempo.App;
import org.json.JSONException;
import org.json.JSONObject;
import java.util.Collections;
import java.util.HashMap;
import java.util.HashSet;
import java.util.Iterator;
import java.util.Map;
import java.util.Set;
public final class ExternalDownloadMetadataStore {
private static final String PREF_KEY = "external_download_metadata";
private ExternalDownloadMetadataStore() {
}
private static SharedPreferences preferences() {
return App.getInstance().getPreferences();
}
private static JSONObject readAll() {
String raw = preferences().getString(PREF_KEY, "{}");
try {
return new JSONObject(raw);
} catch (JSONException e) {
return new JSONObject();
}
}
private static void writeAll(JSONObject object) {
preferences().edit().putString(PREF_KEY, object.toString()).apply();
}
public static synchronized void clear() {
writeAll(new JSONObject());
}
public static synchronized void recordSize(String key, long size) {
if (key == null || size <= 0) {
return;
}
JSONObject object = readAll();
try {
object.put(key, size);
} catch (JSONException ignored) {
}
writeAll(object);
}
public static synchronized void remove(String key) {
if (key == null) {
return;
}
JSONObject object = readAll();
object.remove(key);
writeAll(object);
}
@Nullable
public static synchronized Long getSize(String key) {
if (key == null) {
return null;
}
JSONObject object = readAll();
if (!object.has(key)) {
return null;
}
long size = object.optLong(key, -1L);
return size > 0 ? size : null;
}
public static synchronized Map<String, Long> snapshot() {
JSONObject object = readAll();
if (object.length() == 0) {
return Collections.emptyMap();
}
Map<String, Long> sizes = new HashMap<>();
Iterator<String> keys = object.keys();
while (keys.hasNext()) {
String key = keys.next();
long size = object.optLong(key, -1L);
if (size > 0) {
sizes.put(key, size);
}
}
return sizes;
}
public static synchronized void retainOnly(Set<String> keysToKeep) {
if (keysToKeep == null || keysToKeep.isEmpty()) {
clear();
return;
}
JSONObject object = readAll();
if (object.length() == 0) {
return;
}
Set<String> keys = new HashSet<>();
Iterator<String> iterator = object.keys();
while (iterator.hasNext()) {
keys.add(iterator.next());
}
boolean changed = false;
for (String key : keys) {
if (!keysToKeep.contains(key)) {
object.remove(key);
changed = true;
}
}
if (changed) {
writeAll(object);
}
}
}

View file

@ -4,10 +4,12 @@ import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.OptIn;
import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
@ -16,6 +18,7 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
@ -83,6 +86,13 @@ public class MappingUtil {
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
.setUserRating(new HeartRating(media.getStarred() != null))
.setSupportedCommands(
ImmutableList.of(
Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
)
)
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@ -217,12 +227,20 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(media);
return local != null ? local : MusicUtil.getStreamUri(media.getId());
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
if (Preferences.getDownloadDirectoryUri() != null) {
Uri local = ExternalAudioReader.getUri(podcastEpisode);
return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId());
}
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
? getDownloadUri(podcastEpisode.getStreamId())
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
@ -232,4 +250,11 @@ public class MappingUtil {
Download download = new DownloadRepository().getDownload(id);
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
}
public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
if (owner == null || onRefresh == null) {
return;
}
ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
}
}

View file

@ -37,6 +37,7 @@ object Preferences {
private const val WIFI_ONLY = "wifi_only"
private const val DATA_SAVING_MODE = "data_saving_mode"
private const val SERVER_UNREACHABLE = "server_unreachable"
private const val SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE = "sync_starred_artists_for_offline_use"
private const val SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE = "sync_starred_albums_for_offline_use"
private const val SYNC_STARRED_TRACKS_FOR_OFFLINE_USE = "sync_starred_tracks_for_offline_use"
private const val QUEUE_SYNCING = "queue_syncing"
@ -45,11 +46,13 @@ object Preferences {
private const val ROUNDED_CORNER_SIZE = "rounded_corner_size"
private const val PODCAST_SECTION_VISIBILITY = "podcast_section_visibility"
private const val RADIO_SECTION_VISIBILITY = "radio_section_visibility"
private const val AUTO_DOWNLOAD_LYRICS = "auto_download_lyrics"
private const val MUSIC_DIRECTORY_SECTION_VISIBILITY = "music_directory_section_visibility"
private const val REPLAY_GAIN_MODE = "replay_gain_mode"
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
private const val DOWNLOAD_STORAGE = "download_storage"
private const val DOWNLOAD_DIRECTORY_URI = "download_directory_uri"
private const val DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type"
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
@ -71,6 +74,9 @@ object Preferences {
private const val LAST_INSTANT_MIX = "last_instant_mix"
private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates"
private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
@JvmStatic
fun getServer(): String? {
@ -162,6 +168,24 @@ object Preferences {
App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply()
}
@JvmStatic
fun isAutoDownloadLyricsEnabled(): Boolean {
val preferences = App.getInstance().preferences
if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) {
return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false)
}
return false
}
@JvmStatic
fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) {
App.getInstance().preferences.edit()
.putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled)
.apply()
}
@JvmStatic
fun getLocalAddress(): String? {
return App.getInstance().preferences.getString(LOCAL_ADDRESS, null)
@ -303,6 +327,18 @@ object Preferences {
.apply()
}
@JvmStatic
fun isStarredArtistsSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, false)
}
@JvmStatic
fun setStarredArtistsSyncEnabled(isStarredSyncEnabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(
SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, isStarredSyncEnabled
).apply()
}
@JvmStatic
fun isStarredAlbumsSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, false)
@ -327,6 +363,16 @@ object Preferences {
).apply()
}
@JvmStatic
fun showShuffleInsteadOfHeart(): Boolean {
return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
}
@JvmStatic
fun setShuffleInsteadOfHeart(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
}
@JvmStatic
fun showServerUnreachableDialog(): Boolean {
return App.getInstance().preferences.getLong(
@ -420,6 +466,20 @@ object Preferences {
).apply()
}
@JvmStatic
fun getDownloadDirectoryUri(): String? {
return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
}
@JvmStatic
fun setDownloadDirectoryUri(uri: String?) {
val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
if (current != uri) {
ExternalDownloadMetadataStore.clear()
}
App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
}
@JvmStatic
fun getDefaultDownloadViewType(): String {
return App.getInstance().preferences.getString(
@ -551,5 +611,29 @@ object Preferences {
@JvmStatic
fun allowPlaylistDuplicates(): Boolean {
return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
}
@JvmStatic
fun isEqualizerEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(EQUALIZER_ENABLED, false)
}
@JvmStatic
fun setEqualizerBandLevels(bandLevels: ShortArray) {
val asString = bandLevels.joinToString(",")
App.getInstance().preferences.edit().putString(EQUALIZER_BAND_LEVELS, asString).apply()
}
@JvmStatic
fun getEqualizerBandLevels(bandCount: Short): ShortArray {
val str = App.getInstance().preferences.getString(EQUALIZER_BAND_LEVELS, null)
if (str.isNullOrBlank()) {
return ShortArray(bandCount.toInt())
}
val parts = str.split(",")
if (parts.size < bandCount) return ShortArray(bandCount.toInt())
return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
}
}