diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java index 99abcbdc..0fd3fc5e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java @@ -30,6 +30,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog; import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog; import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog; +import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.UIUtil; import com.cappielloantonio.tempo.viewmodel.SettingViewModel; @@ -113,6 +114,22 @@ public class SettingsFragment extends PreferenceFragmentCompat { return true; }); } + + ListPreference streamingCachePreference = findPreference("streaming_cache_size"); + if (streamingCachePreference != null) { + streamingCachePreference.setSummaryProvider(new Preference.SummaryProvider() { + @Nullable + @Override + public CharSequence provideSummary(@NonNull ListPreference preference) { + CharSequence entry = preference.getEntry(); + if (entry == null) { + return null; + } + long currentSizeMb = DownloadUtil.getStreamingCacheSize(requireActivity()) / (1024 * 1024); + return entry + "\nCurrently in use: " + + currentSizeMb + " MiB\nRestarting is required if changed."; + } + }); + } } private void checkEqualizer() { diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java index 433a4efd..06109f53 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java @@ -8,10 +8,13 @@ import androidx.media3.common.util.UnstableApi; import androidx.media3.database.DatabaseProvider; 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.ResolvingDataSource; import androidx.media3.datasource.cache.Cache; import androidx.media3.datasource.cache.CacheDataSource; +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor; import androidx.media3.datasource.cache.NoOpCacheEvictor; import androidx.media3.datasource.cache.SimpleCache; import androidx.media3.exoplayer.DefaultRenderersFactory; @@ -42,6 +45,7 @@ public final class DownloadUtil { private static DatabaseProvider databaseProvider; private static File downloadDirectory; private static Cache downloadCache; + private static SimpleCache streamingCache; private static DownloadManager downloadManager; private static DownloaderManager downloaderManager; private static DownloadNotificationHelper downloadNotificationHelper; @@ -75,7 +79,27 @@ public final class DownloadUtil { if (dataSourceFactory == null) { context = context.getApplicationContext(); DefaultDataSource.Factory upstreamFactory = new DefaultDataSource.Factory(context, getHttpDataSourceFactory()); - dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + + if (Preferences.getStreamingCacheSize() > 0) { + // Cache enabled + 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 { + // Cache disabled + dataSourceFactory = buildReadOnlyCacheDataSource(upstreamFactory, getDownloadCache(context)); + } } return dataSourceFactory; @@ -108,6 +132,18 @@ public final class DownloadUtil { return downloadCache; } + private static synchronized SimpleCache getStreamingCache(Context context) { + if (streamingCache == null) { + File streamingCacheDirectory = new File(context.getCacheDir(), "streamingCache"); + streamingCache = new SimpleCache( + streamingCacheDirectory, + new LeastRecentlyUsedCacheEvictor(Preferences.getStreamingCacheSize() * 1024 * 1024), + getDatabaseProvider(context) + ); + } + return streamingCache; + } + private static synchronized void ensureDownloadManagerInitialized(Context context) { if (downloadManager == null) { downloadManager = new DownloadManager( @@ -187,6 +223,10 @@ public final class DownloadUtil { return files; } + public static synchronized long getStreamingCacheSize(Context context) { + return getStreamingCache(context).getCacheSpace(); + } + public static Notification buildGroupSummaryNotification(Context context, String channelId, String groupId, int icon, String title) { return new NotificationCompat.Builder(context, channelId) .setContentTitle(title) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 5e622cf4..82878b5b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -22,6 +22,7 @@ object Preferences { private const val PLAYBACK_SPEED = "playback_speed" private const val SKIP_SILENCE = "skip_silence" private const val IMAGE_CACHE_SIZE = "image_cache_size" + private const val STREAMING_CACHE_SIZE = "streaming_cache_size" private const val IMAGE_SIZE = "image_size" private const val MAX_BITRATE_WIFI = "max_bitrate_wifi" private const val MAX_BITRATE_MOBILE = "max_bitrate_mobile" @@ -187,6 +188,14 @@ object Preferences { return App.getInstance().preferences.getString(IMAGE_SIZE, "-1")!!.toInt() } + /** + * Size of streaming cache in MiB. + */ + @JvmStatic + fun getStreamingCacheSize(): Long { + return App.getInstance().preferences.getString(STREAMING_CACHE_SIZE, "256")!!.toLong() + } + @JvmStatic fun getMaxBitrateWifi(): String { return App.getInstance().preferences.getString(MAX_BITRATE_WIFI, "0")!! diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/StreamingCacheDataSource.kt b/app/src/main/java/com/cappielloantonio/tempo/util/StreamingCacheDataSource.kt new file mode 100644 index 00000000..bdb613e7 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/StreamingCacheDataSource.kt @@ -0,0 +1,61 @@ +package com.cappielloantonio.tempo.util + +import android.net.Uri +import android.util.Log +import androidx.media3.common.C +import androidx.media3.common.util.UnstableApi +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DataSpec +import androidx.media3.datasource.TransferListener +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.ContentMetadata + +@UnstableApi +class StreamingCacheDataSource private constructor( + private val cacheDataSource: CacheDataSource, +): DataSource { + private val TAG = "StreamingCacheDataSource" + + private var currentDataSpec: DataSpec? = null + + class Factory(private val cacheDatasourceFactory: CacheDataSource.Factory): DataSource.Factory { + override fun createDataSource(): DataSource { + return StreamingCacheDataSource(cacheDatasourceFactory.createDataSource()) + } + } + + override fun read(buffer: ByteArray, offset: Int, length: Int): Int { + return cacheDataSource.read(buffer, offset, length) + } + + override fun addTransferListener(transferListener: TransferListener) { + return cacheDataSource.addTransferListener(transferListener) + } + + override fun open(dataSpec: DataSpec): Long { + val ret = cacheDataSource.open(dataSpec) + currentDataSpec = dataSpec + Log.d(TAG, "Opened $currentDataSpec") + return ret + } + + override fun getUri(): Uri? { + return cacheDataSource.uri + } + + override fun close() { + cacheDataSource.close() + Log.d(TAG, "Closed $currentDataSpec") + val dataSpec = currentDataSpec + if (dataSpec != null) { + val cacheKey = cacheDataSource.cacheKeyFactory.buildCacheKey(dataSpec) + val contentLength = ContentMetadata.getContentLength(cacheDataSource.cache.getContentMetadata(cacheKey)); + if (contentLength == C.LENGTH_UNSET.toLong()) { + Log.d(TAG, "Removing partial cache for $cacheKey") + cacheDataSource.cache.removeResource(cacheKey) + } else { + Log.d(TAG, "Key $cacheKey has been fully cached") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index 6ef0a129..b5713e03 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -32,6 +32,21 @@ 300 + + Disabled + 128 MiB + 256 MiB + 512 MiB + 1024 MiB + + + 0 + 128 + 256 + 512 + 1024 + + Original 32 kbps diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 11b45255..abeb0517 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -367,4 +367,5 @@ unDraw A special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful. https://undraw.co/ + Size of streaming cache diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml index 40077d99..b0a69cc1 100644 --- a/app/src/main/res/xml/global_preferences.xml +++ b/app/src/main/res/xml/global_preferences.xml @@ -95,6 +95,14 @@ app:title="@string/settings_image_size" app:useSimpleSummaryProvider="true" /> + +