From 3ba19be4d93b447454b41e7e5b3143788a32335e Mon Sep 17 00:00:00 2001 From: le-firehawk Date: Mon, 15 Sep 2025 23:24:20 +0930 Subject: [PATCH] feat: Load media downloaded as file for offline use --- .../ui/adapter/SongHorizontalAdapter.java | 15 ++- .../dialog/DeleteDownloadStorageDialog.java | 15 +++ .../tempo/ui/fragment/SettingsFragment.java | 2 + .../DownloadedBottomSheetDialog.java | 13 ++- .../SongBottomSheetDialog.java | 30 ++++-- .../tempo/util/ExternalAudioReader.java | 102 ++++++++++++++++++ .../tempo/util/MappingUtil.java | 8 ++ 7 files changed, 170 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java index cb10ab4e..dd26443a 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java @@ -24,6 +24,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.DiscTitle; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.google.common.util.concurrent.ListenableFuture; @@ -135,10 +136,18 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter { DownloadUtil.getDownloadTracker(requireContext()).removeAll(); + String uriString = Preferences.getDownloadDirectoryUri(); + if (uriString != null) { + DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString)); + if (directory != null && directory.canWrite()) { + for (DocumentFile file : directory.listFiles()) { + file.delete(); + } + } + ExternalAudioReader.refreshCache(); + } dialog.dismiss(); }); 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 724e77d0..963e7753 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 @@ -49,6 +49,7 @@ import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.UIUtil; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.viewmodel.SettingViewModel; import java.util.Locale; @@ -90,6 +91,7 @@ public class SettingsFragment extends PreferenceFragmentCompat { ); Preferences.setDownloadDirectoryUri(uri.toString()); + ExternalAudioReader.refreshCache(); Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show(); checkDownloadDirectory(); } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java index ee339342..4a720640 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java @@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; +import com.cappielloantonio.tempo.util.ExternalAudioReader; +import com.cappielloantonio.tempo.util.Preferences; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; import com.google.common.util.concurrent.ListenableFuture; @@ -117,10 +119,13 @@ public class DownloadedBottomSheetDialog extends BottomSheetDialogFragment imple TextView removeAll = view.findViewById(R.id.remove_all_text_view); removeAll.setOnClickListener(v -> { - List mediaItems = MappingUtil.mapDownloads(songs); - List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); - - DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + if (Preferences.getDownloadDirectoryUri() == null) { + List mediaItems = MappingUtil.mapDownloads(songs); + List downloads = songs.stream().map(Download::new).collect(Collectors.toList()); + DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads); + } else { + songs.forEach(ExternalAudioReader::delete); + } dismissBottomSheet(); }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java index 8cb3369f..612a3696 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java @@ -31,6 +31,7 @@ import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; +import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; @@ -178,10 +179,14 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView remove = view.findViewById(R.id.remove_text_view); remove.setOnClickListener(v -> { - DownloadUtil.getDownloadTracker(requireContext()).remove( - MappingUtil.mapDownload(song), - new Download(song) - ); + if (Preferences.getDownloadDirectoryUri() == null) { + DownloadUtil.getDownloadTracker(requireContext()).remove( + MappingUtil.mapDownload(song), + new Download(song) + ); + } else { + ExternalAudioReader.delete(song); + } dismissBottomSheet(); }); @@ -254,11 +259,20 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements } private void initDownloadUI(TextView download, TextView remove) { - if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) { - remove.setVisibility(View.VISIBLE); + if (Preferences.getDownloadDirectoryUri() == null) { + if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) { + remove.setVisibility(View.VISIBLE); + } else { + download.setVisibility(View.VISIBLE); + remove.setVisibility(View.GONE); + } } else { - download.setVisibility(View.VISIBLE); - remove.setVisibility(View.GONE); + if (ExternalAudioReader.getUri(song) != null) { + remove.setVisibility(View.VISIBLE); + } else { + download.setVisibility(View.VISIBLE); + remove.setVisibility(View.GONE); + } } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java new file mode 100644 index 00000000..dd298377 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java @@ -0,0 +1,102 @@ +package com.cappielloantonio.tempo.util; + +import android.net.Uri; + +import androidx.documentfile.provider.DocumentFile; + +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.Locale; +import java.util.Map; + +public class ExternalAudioReader { + + private static final Map cache = new HashMap<>(); + private static String cachedDirUri; + + 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 synchronized void ensureCache() { + String uriString = Preferences.getDownloadDirectoryUri(); + if (uriString == null) { + cache.clear(); + cachedDirUri = null; + return; + } + + if (uriString.equals(cachedDirUri)) return; + + cache.clear(); + DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString)); + if (directory != null && directory.canRead()) { + for (DocumentFile file : directory.listFiles()) { + String existing = file.getName(); + if (existing != null) { + String base = existing.replaceFirst("\\.[^\\.]+$", ""); + cache.put(normalizeForComparison(base), file); + } + } + } + + cachedDirUri = uriString; + } + + /** Rebuilds the cache on next access. */ + public static synchronized void refreshCache() { + cachedDirUri = null; + cache.clear(); + } + + 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 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); + } + return deleted; + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java index f8f15f07..3f32ea44 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -217,12 +217,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());