mirror of
https://github.com/antebudimir/tempus.git
synced 2025-12-31 09:33:33 +00:00
feat: Support user-defined download directory for media (#21)
This commit is contained in:
commit
78c4c89eca
38 changed files with 1439 additions and 74 deletions
|
|
@ -15,6 +15,9 @@ public interface DownloadDao {
|
||||||
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||||
LiveData<List<Download>> getAll();
|
LiveData<List<Download>> getAll();
|
||||||
|
|
||||||
|
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
|
||||||
|
List<Download> getAllSync();
|
||||||
|
|
||||||
@Query("SELECT * FROM download WHERE id = :id")
|
@Query("SELECT * FROM download WHERE id = :id")
|
||||||
Download getOne(String id);
|
Download getOne(String id);
|
||||||
|
|
||||||
|
|
@ -30,6 +33,9 @@ public interface DownloadDao {
|
||||||
@Query("DELETE FROM download WHERE id = :id")
|
@Query("DELETE FROM download WHERE id = :id")
|
||||||
void delete(String id);
|
void delete(String id);
|
||||||
|
|
||||||
|
@Query("DELETE FROM download WHERE id IN (:ids)")
|
||||||
|
void deleteByIds(List<String> ids);
|
||||||
|
|
||||||
@Query("DELETE FROM download")
|
@Query("DELETE FROM download")
|
||||||
void deleteAll();
|
void deleteAll();
|
||||||
}
|
}
|
||||||
|
|
@ -18,6 +18,20 @@ public class DownloadRepository {
|
||||||
return downloadDao.getAll();
|
return downloadDao.getAll();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public List<Download> getAllDownloads() {
|
||||||
|
GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao);
|
||||||
|
Thread thread = new Thread(getDownloads);
|
||||||
|
thread.start();
|
||||||
|
|
||||||
|
try {
|
||||||
|
thread.join();
|
||||||
|
} catch (InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
|
||||||
|
return getDownloads.getDownloads();
|
||||||
|
}
|
||||||
|
|
||||||
public Download getDownload(String id) {
|
public Download getDownload(String id) {
|
||||||
Download download = null;
|
Download download = null;
|
||||||
|
|
||||||
|
|
@ -35,6 +49,24 @@ public class DownloadRepository {
|
||||||
return download;
|
return download;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class GetAllDownloadsThreadSafe implements Runnable {
|
||||||
|
private final DownloadDao downloadDao;
|
||||||
|
private List<Download> downloads;
|
||||||
|
|
||||||
|
public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
|
||||||
|
this.downloadDao = downloadDao;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
downloads = downloadDao.getAllSync();
|
||||||
|
}
|
||||||
|
|
||||||
|
public List<Download> getDownloads() {
|
||||||
|
return downloads;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static class GetDownloadThreadSafe implements Runnable {
|
private static class GetDownloadThreadSafe implements Runnable {
|
||||||
private final DownloadDao downloadDao;
|
private final DownloadDao downloadDao;
|
||||||
private final String id;
|
private final String id;
|
||||||
|
|
@ -143,6 +175,12 @@ public class DownloadRepository {
|
||||||
thread.start();
|
thread.start();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void delete(List<String> ids) {
|
||||||
|
DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids);
|
||||||
|
Thread thread = new Thread(delete);
|
||||||
|
thread.start();
|
||||||
|
}
|
||||||
|
|
||||||
private static class DeleteThreadSafe implements Runnable {
|
private static class DeleteThreadSafe implements Runnable {
|
||||||
private final DownloadDao downloadDao;
|
private final DownloadDao downloadDao;
|
||||||
private final String id;
|
private final String id;
|
||||||
|
|
@ -157,4 +195,19 @@ public class DownloadRepository {
|
||||||
downloadDao.delete(id);
|
downloadDao.delete(id);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static class DeleteMultipleThreadSafe implements Runnable {
|
||||||
|
private final DownloadDao downloadDao;
|
||||||
|
private final List<String> ids;
|
||||||
|
|
||||||
|
public DeleteMultipleThreadSafe(DownloadDao downloadDao, List<String> ids) {
|
||||||
|
this.downloadDao = downloadDao;
|
||||||
|
this.ids = ids;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void run() {
|
||||||
|
downloadDao.deleteByIds(ids);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -223,6 +223,25 @@ public class MediaManager {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static void playDownloadedMediaItem(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, MediaItem mediaItem) {
|
||||||
|
if (mediaBrowserListenableFuture != null && mediaItem != null) {
|
||||||
|
mediaBrowserListenableFuture.addListener(() -> {
|
||||||
|
try {
|
||||||
|
if (mediaBrowserListenableFuture.isDone()) {
|
||||||
|
MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
|
||||||
|
mediaBrowser.clearMediaItems();
|
||||||
|
mediaBrowser.setMediaItem(mediaItem);
|
||||||
|
mediaBrowser.prepare();
|
||||||
|
mediaBrowser.play();
|
||||||
|
clearDatabase();
|
||||||
|
}
|
||||||
|
} catch (ExecutionException | InterruptedException e) {
|
||||||
|
e.printStackTrace();
|
||||||
|
}
|
||||||
|
}, MoreExecutors.directExecutor());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
public static void startRadio(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
|
public static void startRadio(ListenableFuture<MediaBrowser> mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
|
||||||
if (mediaBrowserListenableFuture != null) {
|
if (mediaBrowserListenableFuture != null) {
|
||||||
mediaBrowserListenableFuture.addListener(() -> {
|
mediaBrowserListenableFuture.addListener(() -> {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,14 @@
|
||||||
package com.cappielloantonio.tempo.ui.activity;
|
package com.cappielloantonio.tempo.ui.activity;
|
||||||
|
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
import android.content.IntentFilter;
|
import android.content.IntentFilter;
|
||||||
import android.net.ConnectivityManager;
|
import android.net.ConnectivityManager;
|
||||||
import android.net.NetworkInfo;
|
import android.net.NetworkInfo;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
import android.os.Handler;
|
||||||
|
import android.text.TextUtils;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
|
|
||||||
|
|
@ -13,7 +16,10 @@ import androidx.annotation.NonNull;
|
||||||
import androidx.core.splashscreen.SplashScreen;
|
import androidx.core.splashscreen.SplashScreen;
|
||||||
import androidx.fragment.app.FragmentManager;
|
import androidx.fragment.app.FragmentManager;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import androidx.media3.common.MediaMetadata;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
|
import androidx.media3.common.MimeTypes;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.navigation.NavController;
|
import androidx.navigation.NavController;
|
||||||
import androidx.navigation.fragment.NavHostFragment;
|
import androidx.navigation.fragment.NavHostFragment;
|
||||||
|
|
@ -56,6 +62,7 @@ public class MainActivity extends BaseActivity {
|
||||||
private BottomSheetBehavior bottomSheetBehavior;
|
private BottomSheetBehavior bottomSheetBehavior;
|
||||||
|
|
||||||
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
|
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
|
||||||
|
private Intent pendingDownloadPlaybackIntent;
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
protected void onCreate(Bundle savedInstanceState) {
|
protected void onCreate(Bundle savedInstanceState) {
|
||||||
|
|
@ -77,6 +84,8 @@ public class MainActivity extends BaseActivity {
|
||||||
checkConnectionType();
|
checkConnectionType();
|
||||||
getOpenSubsonicExtensions();
|
getOpenSubsonicExtensions();
|
||||||
checkTempoUpdate();
|
checkTempoUpdate();
|
||||||
|
|
||||||
|
maybeSchedulePlaybackIntent(getIntent());
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -84,6 +93,7 @@ public class MainActivity extends BaseActivity {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
pingServer();
|
pingServer();
|
||||||
initService();
|
initService();
|
||||||
|
consumePendingPlaybackIntent();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
|
|
@ -99,6 +109,14 @@ public class MainActivity extends BaseActivity {
|
||||||
bind = null;
|
bind = null;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void onNewIntent(Intent intent) {
|
||||||
|
super.onNewIntent(intent);
|
||||||
|
setIntent(intent);
|
||||||
|
maybeSchedulePlaybackIntent(intent);
|
||||||
|
consumePendingPlaybackIntent();
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onBackPressed() {
|
public void onBackPressed() {
|
||||||
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
|
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
|
||||||
|
|
@ -418,4 +436,68 @@ public class MainActivity extends BaseActivity {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void maybeSchedulePlaybackIntent(Intent intent) {
|
||||||
|
if (intent == null) return;
|
||||||
|
if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
|
||||||
|
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
|
||||||
|
pendingDownloadPlaybackIntent = new Intent(intent);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private void consumePendingPlaybackIntent() {
|
||||||
|
if (pendingDownloadPlaybackIntent == null) return;
|
||||||
|
Intent intent = pendingDownloadPlaybackIntent;
|
||||||
|
pendingDownloadPlaybackIntent = null;
|
||||||
|
playDownloadedMedia(intent);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void playDownloadedMedia(Intent intent) {
|
||||||
|
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
|
||||||
|
if (TextUtils.isEmpty(uriString)) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri uri = Uri.parse(uriString);
|
||||||
|
String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
|
||||||
|
if (TextUtils.isEmpty(mediaId)) {
|
||||||
|
mediaId = uri.toString();
|
||||||
|
}
|
||||||
|
|
||||||
|
String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
|
||||||
|
String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
|
||||||
|
String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
|
||||||
|
int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
|
||||||
|
|
||||||
|
Bundle extras = new Bundle();
|
||||||
|
extras.putString("id", mediaId);
|
||||||
|
extras.putString("title", title);
|
||||||
|
extras.putString("artist", artist);
|
||||||
|
extras.putString("album", album);
|
||||||
|
extras.putString("uri", uri.toString());
|
||||||
|
extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
|
||||||
|
extras.putInt("duration", duration);
|
||||||
|
|
||||||
|
MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
|
||||||
|
.setExtras(extras)
|
||||||
|
.setIsBrowsable(false)
|
||||||
|
.setIsPlayable(true);
|
||||||
|
|
||||||
|
if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
|
||||||
|
if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
|
||||||
|
if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
|
||||||
|
|
||||||
|
MediaItem mediaItem = new MediaItem.Builder()
|
||||||
|
.setMediaId(mediaId)
|
||||||
|
.setMediaMetadata(metadataBuilder.build())
|
||||||
|
.setUri(uri)
|
||||||
|
.setMimeType(MimeTypes.BASE_TYPE_AUDIO)
|
||||||
|
.setRequestMetadata(new MediaItem.RequestMetadata.Builder()
|
||||||
|
.setMediaUri(uri)
|
||||||
|
.setExtras(extras)
|
||||||
|
.build())
|
||||||
|
.build();
|
||||||
|
|
||||||
|
MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -11,6 +11,7 @@ import android.widget.Filterable;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.appcompat.content.res.AppCompatResources;
|
import androidx.appcompat.content.res.AppCompatResources;
|
||||||
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
import androidx.media3.session.MediaBrowser;
|
import androidx.media3.session.MediaBrowser;
|
||||||
import androidx.recyclerview.widget.RecyclerView;
|
import androidx.recyclerview.widget.RecyclerView;
|
||||||
|
|
@ -24,6 +25,8 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||||
import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
|
import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
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.MusicUtil;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
@ -89,7 +92,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
public SongHorizontalAdapter(ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
|
public SongHorizontalAdapter(LifecycleOwner lifecycleOwner, ClickCallback click, boolean showCoverArt, boolean showAlbum, AlbumID3 album) {
|
||||||
this.click = click;
|
this.click = click;
|
||||||
this.showCoverArt = showCoverArt;
|
this.showCoverArt = showCoverArt;
|
||||||
this.showAlbum = showAlbum;
|
this.showAlbum = showAlbum;
|
||||||
|
|
@ -98,6 +101,10 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||||
this.currentFilter = "";
|
this.currentFilter = "";
|
||||||
this.album = album;
|
this.album = album;
|
||||||
setHasStableIds(false);
|
setHasStableIds(false);
|
||||||
|
|
||||||
|
if (lifecycleOwner != null) {
|
||||||
|
MappingUtil.observeExternalAudioRefresh(lifecycleOwner, this::handleExternalAudioRefresh);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@NonNull
|
@NonNull
|
||||||
|
|
@ -135,10 +142,18 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||||
|
|
||||||
holder.item.trackNumberTextView.setText(MusicUtil.getReadableTrackNumber(holder.itemView.getContext(), song.getTrack()));
|
holder.item.trackNumberTextView.setText(MusicUtil.getReadableTrackNumber(holder.itemView.getContext(), song.getTrack()));
|
||||||
|
|
||||||
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
if (DownloadUtil.getDownloadTracker(holder.itemView.getContext()).isDownloaded(song.getId())) {
|
||||||
|
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
if (ExternalAudioReader.getUri(song) != null) {
|
||||||
|
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
holder.item.searchResultDownloadIndicatorImageView.setVisibility(View.GONE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showCoverArt) CustomGlideRequest.Builder
|
if (showCoverArt) CustomGlideRequest.Builder
|
||||||
|
|
@ -195,6 +210,12 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter<SongHorizontalAd
|
||||||
bindPlaybackState(holder, song);
|
bindPlaybackState(holder, song);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void handleExternalAudioRefresh() {
|
||||||
|
if (Preferences.getDownloadDirectoryUri() != null) {
|
||||||
|
notifyDataSetChanged();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) {
|
private void bindPlaybackState(@NonNull ViewHolder holder, @NonNull Child song) {
|
||||||
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
|
boolean isCurrent = currentPlayingId != null && currentPlayingId.equals(song.getId());
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,9 @@ package com.cappielloantonio.tempo.ui.dialog;
|
||||||
import android.app.Dialog;
|
import android.app.Dialog;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.widget.Button;
|
import android.widget.Button;
|
||||||
|
import android.net.Uri;
|
||||||
|
|
||||||
|
import androidx.documentfile.provider.DocumentFile;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.OptIn;
|
import androidx.annotation.OptIn;
|
||||||
|
|
@ -12,6 +15,9 @@ import androidx.media3.common.util.UnstableApi;
|
||||||
import com.cappielloantonio.tempo.R;
|
import com.cappielloantonio.tempo.R;
|
||||||
import com.cappielloantonio.tempo.databinding.DialogDeleteDownloadStorageBinding;
|
import com.cappielloantonio.tempo.databinding.DialogDeleteDownloadStorageBinding;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalDownloadMetadataStore;
|
||||||
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
|
|
||||||
@OptIn(markerClass = UnstableApi.class)
|
@OptIn(markerClass = UnstableApi.class)
|
||||||
|
|
@ -42,7 +48,21 @@ public class DeleteDownloadStorageDialog extends DialogFragment {
|
||||||
if (dialog != null) {
|
if (dialog != null) {
|
||||||
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
||||||
positiveButton.setOnClickListener(v -> {
|
positiveButton.setOnClickListener(v -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
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();
|
||||||
|
ExternalDownloadMetadataStore.clear();
|
||||||
|
}
|
||||||
dialog.dismiss();
|
dialog.dismiss();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,63 @@
|
||||||
|
package com.cappielloantonio.tempo.ui.dialog;
|
||||||
|
|
||||||
|
import android.content.Context;
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.os.Bundle;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
|
import androidx.fragment.app.DialogFragment;
|
||||||
|
|
||||||
|
import com.google.android.material.dialog.MaterialAlertDialogBuilder;
|
||||||
|
import com.cappielloantonio.tempo.R;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
|
|
||||||
|
public class DownloadDirectoryPickerDialog extends DialogFragment {
|
||||||
|
|
||||||
|
private ActivityResultLauncher<Intent> folderPickerLauncher;
|
||||||
|
|
||||||
|
@NonNull
|
||||||
|
@Override
|
||||||
|
public android.app.Dialog onCreateDialog(Bundle savedInstanceState) {
|
||||||
|
// Register launcher *before* button triggers
|
||||||
|
folderPickerLauncher = registerForActivityResult(
|
||||||
|
new ActivityResultContracts.StartActivityForResult(),
|
||||||
|
result -> {
|
||||||
|
if (result.getResultCode() == android.app.Activity.RESULT_OK) {
|
||||||
|
Intent data = result.getData();
|
||||||
|
if (data != null) {
|
||||||
|
Uri uri = data.getData();
|
||||||
|
if (uri != null) {
|
||||||
|
requireContext().getContentResolver().takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
);
|
||||||
|
|
||||||
|
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||||
|
ExternalAudioReader.refreshCache();
|
||||||
|
|
||||||
|
Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
return new MaterialAlertDialogBuilder(requireContext())
|
||||||
|
.setTitle("Set Download Directory")
|
||||||
|
.setMessage("Choose a folder where downloaded songs will be stored.")
|
||||||
|
.setPositiveButton("Choose Folder", (dialog, which) -> {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||||
|
| Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||||
|
folderPickerLauncher.launch(intent);
|
||||||
|
})
|
||||||
|
.setNegativeButton(android.R.string.cancel, null)
|
||||||
|
.create();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -34,6 +34,7 @@ public class DownloadStorageDialog extends DialogFragment {
|
||||||
.setTitle(R.string.download_storage_dialog_title)
|
.setTitle(R.string.download_storage_dialog_title)
|
||||||
.setPositiveButton(R.string.download_storage_external_dialog_positive_button, null)
|
.setPositiveButton(R.string.download_storage_external_dialog_positive_button, null)
|
||||||
.setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null)
|
.setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null)
|
||||||
|
.setNeutralButton(R.string.download_storage_directory_dialog_neutral_button, null)
|
||||||
.create();
|
.create();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -74,6 +75,20 @@ public class DownloadStorageDialog extends DialogFragment {
|
||||||
|
|
||||||
dialog.dismiss();
|
dialog.dismiss();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
|
||||||
|
neutralButton.setOnClickListener(v -> {
|
||||||
|
int currentPreference = Preferences.getDownloadStoragePreference();
|
||||||
|
int newPreference = 2;
|
||||||
|
|
||||||
|
if (currentPreference != newPreference) {
|
||||||
|
Preferences.setDownloadStoragePreference(newPreference);
|
||||||
|
DownloadUtil.getDownloadTracker(requireContext()).removeAll();
|
||||||
|
dialogClickCallback.onNeutralClick();
|
||||||
|
}
|
||||||
|
|
||||||
|
dialog.dismiss();
|
||||||
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -61,7 +61,7 @@ public class StarredSyncDialog extends DialogFragment {
|
||||||
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
|
||||||
positiveButton.setOnClickListener(v -> {
|
positiveButton.setOnClickListener(v -> {
|
||||||
starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> {
|
starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> {
|
||||||
if (songs != null) {
|
if (songs != null && Preferences.getDownloadDirectoryUri() == null) {
|
||||||
DownloadUtil.getDownloadTracker(context).download(
|
DownloadUtil.getDownloadTracker(context).download(
|
||||||
MappingUtil.mapDownloads(songs),
|
MappingUtil.mapDownloads(songs),
|
||||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||||
|
|
|
||||||
|
|
@ -39,6 +39,8 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||||
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
|
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
|
||||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
@ -130,7 +132,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||||
|
|
||||||
if (item.getItemId() == R.id.action_download_album) {
|
if (item.getItemId() == R.id.action_download_album) {
|
||||||
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).download(MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()));
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||||
|
MappingUtil.mapDownloads(songs),
|
||||||
|
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||||
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
@ -280,7 +289,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
|
||||||
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
bind.songRecyclerView.setHasFixedSize(true);
|
bind.songRecyclerView.setHasFixedSize(true);
|
||||||
|
|
||||||
songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
|
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
|
||||||
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
|
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
|
||||||
setMediaBrowserListenableFuture();
|
setMediaBrowserListenableFuture();
|
||||||
reapplyPlayback();
|
reapplyPlayback();
|
||||||
|
|
|
||||||
|
|
@ -178,7 +178,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
|
||||||
private void initTopSongsView() {
|
private void initTopSongsView() {
|
||||||
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
|
|
||||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null);
|
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, true, null);
|
||||||
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
|
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
|
||||||
setMediaBrowserListenableFuture();
|
setMediaBrowserListenableFuture();
|
||||||
reapplyPlayback();
|
reapplyPlayback();
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,9 @@ import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
|
||||||
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
|
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel;
|
import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
|
|
@ -109,10 +111,14 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
|
||||||
directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> {
|
directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> {
|
||||||
if (isVisible() && getActivity() != null) {
|
if (isVisible() && getActivity() != null) {
|
||||||
List<Child> songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList());
|
List<Child> songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList());
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
MappingUtil.mapDownloads(songs),
|
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||||
songs.stream().map(Download::new).collect(Collectors.toList())
|
MappingUtil.mapDownloads(songs),
|
||||||
);
|
songs.stream().map(Download::new).collect(Collectors.toList())
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,11 +28,17 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||||
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
import com.cappielloantonio.tempo.ui.activity.MainActivity;
|
||||||
import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter;
|
import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.cappielloantonio.tempo.viewmodel.DownloadViewModel;
|
import com.cappielloantonio.tempo.viewmodel.DownloadViewModel;
|
||||||
import com.google.android.material.appbar.MaterialToolbar;
|
import com.google.android.material.appbar.MaterialToolbar;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import android.app.Activity;
|
||||||
|
import android.net.Uri;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
import java.util.Objects;
|
||||||
|
|
@ -40,6 +46,7 @@ import java.util.Objects;
|
||||||
@UnstableApi
|
@UnstableApi
|
||||||
public class DownloadFragment extends Fragment implements ClickCallback {
|
public class DownloadFragment extends Fragment implements ClickCallback {
|
||||||
private static final String TAG = "DownloadFragment";
|
private static final String TAG = "DownloadFragment";
|
||||||
|
private static final int REQUEST_CODE_PICK_DIRECTORY = 1002;
|
||||||
|
|
||||||
private FragmentDownloadBinding bind;
|
private FragmentDownloadBinding bind;
|
||||||
private MainActivity activity;
|
private MainActivity activity;
|
||||||
|
|
@ -129,8 +136,27 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
downloadViewModel.getRefreshResult().observe(getViewLifecycleOwner(), count -> {
|
||||||
|
if (count == null || bind == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (count == -1) {
|
||||||
|
Toast.makeText(requireContext(), R.string.download_refresh_no_directory, Toast.LENGTH_SHORT).show();
|
||||||
|
} else if (count == 0) {
|
||||||
|
Toast.makeText(requireContext(), R.string.download_refresh_no_changes, Toast.LENGTH_SHORT).show();
|
||||||
|
} else {
|
||||||
|
Toast.makeText(
|
||||||
|
requireContext(),
|
||||||
|
getResources().getQuantityString(R.plurals.download_refresh_removed, count, count),
|
||||||
|
Toast.LENGTH_SHORT
|
||||||
|
).show();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu));
|
bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu));
|
||||||
bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack());
|
bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack());
|
||||||
|
bind.downloadedRefreshImageView.setOnClickListener(view -> downloadViewModel.refreshExternalDownloads());
|
||||||
}
|
}
|
||||||
|
|
||||||
private void finishDownloadView(List<Child> songs) {
|
private void finishDownloadView(List<Child> songs) {
|
||||||
|
|
@ -216,6 +242,10 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||||
downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null));
|
downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null));
|
||||||
Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR);
|
Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR);
|
||||||
return true;
|
return true;
|
||||||
|
} else if (menuItem.getItemId() == R.id.menu_download_set_directory) {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||||
|
startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
return false;
|
return false;
|
||||||
|
|
@ -267,4 +297,21 @@ public class DownloadFragment extends Fragment implements ClickCallback {
|
||||||
public void onDownloadGroupLongClick(Bundle bundle) {
|
public void onDownloadGroupLongClick(Bundle bundle) {
|
||||||
Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle);
|
Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onActivityResult(int requestCode, int resultCode, Intent data) {
|
||||||
|
super.onActivityResult(requestCode, resultCode, data);
|
||||||
|
if (requestCode == REQUEST_CODE_PICK_DIRECTORY && resultCode == Activity.RESULT_OK) {
|
||||||
|
Uri uri = data.getData();
|
||||||
|
if (uri != null) {
|
||||||
|
requireContext().getContentResolver().takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
);
|
||||||
|
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||||
|
ExternalAudioReader.refreshCache();
|
||||||
|
Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -66,6 +66,8 @@ import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.stream.Collectors;
|
import java.util.stream.Collectors;
|
||||||
|
|
@ -277,7 +279,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initSyncStarredView() {
|
private void initSyncStarredView() {
|
||||||
if (Preferences.isStarredSyncEnabled()) {
|
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||||
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
|
homeViewModel.getAllStarredTracks().observeForever(new Observer<List<Child>>() {
|
||||||
@Override
|
@Override
|
||||||
public void onChanged(List<Child> songs) {
|
public void onChanged(List<Child> songs) {
|
||||||
|
|
@ -598,7 +600,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||||
|
|
||||||
bind.topSongsRecyclerView.setHasFixedSize(true);
|
bind.topSongsRecyclerView.setHasFixedSize(true);
|
||||||
|
|
||||||
topSongAdapter = new SongHorizontalAdapter(this, true, false, null);
|
topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||||
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
|
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
|
||||||
setTopSongsMediaBrowserListenableFuture();
|
setTopSongsMediaBrowserListenableFuture();
|
||||||
reapplyTopSongsPlayback();
|
reapplyTopSongsPlayback();
|
||||||
|
|
@ -639,7 +641,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
|
||||||
|
|
||||||
bind.starredTracksRecyclerView.setHasFixedSize(true);
|
bind.starredTracksRecyclerView.setHasFixedSize(true);
|
||||||
|
|
||||||
starredSongAdapter = new SongHorizontalAdapter(this, true, false, null);
|
starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||||
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
|
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
|
||||||
setStarredSongsMediaBrowserListenableFuture();
|
setStarredSongsMediaBrowserListenableFuture();
|
||||||
reapplyStarredSongsPlayback();
|
reapplyStarredSongsPlayback();
|
||||||
|
|
|
||||||
|
|
@ -14,6 +14,7 @@ import java.util.ArrayList;
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
import androidx.fragment.app.Fragment;
|
import androidx.fragment.app.Fragment;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.common.MediaMetadata;
|
import androidx.media3.common.MediaMetadata;
|
||||||
import androidx.media3.common.Player;
|
import androidx.media3.common.Player;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
|
|
@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||||
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
|
||||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||||
import com.google.android.material.snackbar.Snackbar;
|
import com.google.android.material.snackbar.Snackbar;
|
||||||
|
|
@ -115,10 +117,14 @@ public class PlayerCoverFragment extends Fragment {
|
||||||
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> {
|
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> {
|
||||||
if (song != null && bind != null) {
|
if (song != null && bind != null) {
|
||||||
bind.innerButtonTopLeft.setOnClickListener(view -> {
|
bind.innerButtonTopLeft.setOnClickListener(view -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
MappingUtil.mapDownload(song),
|
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||||
new Download(song)
|
MappingUtil.mapDownload(song),
|
||||||
);
|
new Download(song)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
bind.innerButtonTopRight.setOnClickListener(view -> {
|
bind.innerButtonTopRight.setOnClickListener(view -> {
|
||||||
|
|
|
||||||
|
|
@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||||
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
|
||||||
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
|
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
@ -140,7 +142,8 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||||
if (item.getItemId() == R.id.action_download_playlist) {
|
if (item.getItemId() == R.id.action_download_playlist) {
|
||||||
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
|
||||||
if (isVisible() && getActivity() != null) {
|
if (isVisible() && getActivity() != null) {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||||
MappingUtil.mapDownloads(songs),
|
MappingUtil.mapDownloads(songs),
|
||||||
songs.stream().map(child -> {
|
songs.stream().map(child -> {
|
||||||
Download toDownload = new Download(child);
|
Download toDownload = new Download(child);
|
||||||
|
|
@ -148,7 +151,10 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||||
toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName());
|
toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName());
|
||||||
return toDownload;
|
return toDownload;
|
||||||
}).collect(Collectors.toList())
|
}).collect(Collectors.toList())
|
||||||
);
|
);
|
||||||
|
} else {
|
||||||
|
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
return true;
|
return true;
|
||||||
|
|
@ -258,7 +264,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
|
||||||
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
bind.songRecyclerView.setHasFixedSize(true);
|
bind.songRecyclerView.setHasFixedSize(true);
|
||||||
|
|
||||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
|
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||||
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
|
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
|
||||||
setMediaBrowserListenableFuture();
|
setMediaBrowserListenableFuture();
|
||||||
reapplyPlayback();
|
reapplyPlayback();
|
||||||
|
|
|
||||||
|
|
@ -121,7 +121,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
|
||||||
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
|
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
|
||||||
|
|
||||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
|
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||||
setMediaBrowserListenableFuture();
|
setMediaBrowserListenableFuture();
|
||||||
reapplyPlayback();
|
reapplyPlayback();
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,17 +1,19 @@
|
||||||
package com.cappielloantonio.tempo.ui.fragment;
|
package com.cappielloantonio.tempo.ui.fragment;
|
||||||
|
|
||||||
import android.content.ComponentName;
|
import android.app.Activity;
|
||||||
import android.content.Context;
|
import android.content.Context;
|
||||||
|
import android.content.ComponentName;
|
||||||
import android.content.Intent;
|
import android.content.Intent;
|
||||||
import android.content.ServiceConnection;
|
import android.content.ServiceConnection;
|
||||||
import android.media.audiofx.AudioEffect;
|
import android.media.audiofx.AudioEffect;
|
||||||
|
import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
import android.os.Handler;
|
|
||||||
import android.os.IBinder;
|
import android.os.IBinder;
|
||||||
import android.view.LayoutInflater;
|
import android.view.LayoutInflater;
|
||||||
import android.view.View;
|
import android.view.View;
|
||||||
import android.view.ViewGroup;
|
import android.view.ViewGroup;
|
||||||
import android.view.WindowManager;
|
import android.view.WindowManager;
|
||||||
|
import android.widget.Toast;
|
||||||
|
|
||||||
import androidx.activity.result.ActivityResultLauncher;
|
import androidx.activity.result.ActivityResultLauncher;
|
||||||
import androidx.activity.result.contract.ActivityResultContracts;
|
import androidx.activity.result.contract.ActivityResultContracts;
|
||||||
|
|
@ -47,6 +49,7 @@ import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
import com.cappielloantonio.tempo.util.UIUtil;
|
import com.cappielloantonio.tempo.util.UIUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
|
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
|
||||||
|
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
|
|
@ -59,7 +62,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
private MainActivity activity;
|
private MainActivity activity;
|
||||||
private SettingViewModel settingViewModel;
|
private SettingViewModel settingViewModel;
|
||||||
|
|
||||||
private ActivityResultLauncher<Intent> someActivityResultLauncher;
|
private ActivityResultLauncher<Intent> equalizerResultLauncher;
|
||||||
|
private ActivityResultLauncher<Intent> directoryPickerLauncher;
|
||||||
|
|
||||||
private MediaService.LocalBinder mediaServiceBinder;
|
private MediaService.LocalBinder mediaServiceBinder;
|
||||||
private boolean isServiceBound = false;
|
private boolean isServiceBound = false;
|
||||||
|
|
@ -68,9 +72,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
public void onCreate(@Nullable Bundle savedInstanceState) {
|
public void onCreate(@Nullable Bundle savedInstanceState) {
|
||||||
super.onCreate(savedInstanceState);
|
super.onCreate(savedInstanceState);
|
||||||
|
|
||||||
someActivityResultLauncher = registerForActivityResult(
|
equalizerResultLauncher = registerForActivityResult(
|
||||||
|
new ActivityResultContracts.StartActivityForResult(),
|
||||||
|
result -> {}
|
||||||
|
);
|
||||||
|
|
||||||
|
directoryPickerLauncher = registerForActivityResult(
|
||||||
new ActivityResultContracts.StartActivityForResult(),
|
new ActivityResultContracts.StartActivityForResult(),
|
||||||
result -> {
|
result -> {
|
||||||
|
if (result.getResultCode() == Activity.RESULT_OK) {
|
||||||
|
Intent data = result.getData();
|
||||||
|
if (data != null) {
|
||||||
|
Uri uri = data.getData();
|
||||||
|
if (uri != null) {
|
||||||
|
requireContext().getContentResolver().takePersistableUriPermission(
|
||||||
|
uri,
|
||||||
|
Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
|
||||||
|
);
|
||||||
|
|
||||||
|
Preferences.setDownloadDirectoryUri(uri.toString());
|
||||||
|
ExternalAudioReader.refreshCache();
|
||||||
|
Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show();
|
||||||
|
checkDownloadDirectory();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -102,6 +128,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
checkSystemEqualizer();
|
checkSystemEqualizer();
|
||||||
checkCacheStorage();
|
checkCacheStorage();
|
||||||
checkStorage();
|
checkStorage();
|
||||||
|
checkDownloadDirectory();
|
||||||
|
|
||||||
setStreamingCacheSize();
|
setStreamingCacheSize();
|
||||||
setAppLanguage();
|
setAppLanguage();
|
||||||
|
|
@ -114,6 +141,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
actionSyncStarredArtists();
|
actionSyncStarredArtists();
|
||||||
actionChangeStreamingCacheStorage();
|
actionChangeStreamingCacheStorage();
|
||||||
actionChangeDownloadStorage();
|
actionChangeDownloadStorage();
|
||||||
|
actionSetDownloadDirectory();
|
||||||
actionDeleteDownloadStorage();
|
actionDeleteDownloadStorage();
|
||||||
actionKeepScreenOn();
|
actionKeepScreenOn();
|
||||||
actionAutoDownloadLyrics();
|
actionAutoDownloadLyrics();
|
||||||
|
|
@ -152,7 +180,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
|
|
||||||
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
|
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
|
||||||
equalizer.setOnPreferenceClickListener(preference -> {
|
equalizer.setOnPreferenceClickListener(preference -> {
|
||||||
someActivityResultLauncher.launch(intent);
|
equalizerResultLauncher.launch(intent);
|
||||||
return true;
|
return true;
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -169,7 +197,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
if (requireContext().getExternalFilesDirs(null)[1] == null) {
|
if (requireContext().getExternalFilesDirs(null)[1] == null) {
|
||||||
storage.setVisible(false);
|
storage.setVisible(false);
|
||||||
} else {
|
} else {
|
||||||
storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
|
storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
|
||||||
}
|
}
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
storage.setVisible(false);
|
storage.setVisible(false);
|
||||||
|
|
@ -185,13 +213,46 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
if (requireContext().getExternalFilesDirs(null)[1] == null) {
|
if (requireContext().getExternalFilesDirs(null)[1] == null) {
|
||||||
storage.setVisible(false);
|
storage.setVisible(false);
|
||||||
} else {
|
} else {
|
||||||
storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
|
int pref = Preferences.getDownloadStoragePreference();
|
||||||
|
if (pref == 0) {
|
||||||
|
storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
|
||||||
|
} else if (pref == 1) {
|
||||||
|
storage.setSummary(R.string.download_storage_external_dialog_positive_button);
|
||||||
|
} else {
|
||||||
|
storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
storage.setVisible(false);
|
storage.setVisible(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void checkDownloadDirectory() {
|
||||||
|
Preference storage = findPreference("download_storage");
|
||||||
|
Preference directory = findPreference("set_download_directory");
|
||||||
|
|
||||||
|
if (directory == null) return;
|
||||||
|
|
||||||
|
String current = Preferences.getDownloadDirectoryUri();
|
||||||
|
if (current != null) {
|
||||||
|
if (storage != null) storage.setVisible(false);
|
||||||
|
directory.setVisible(true);
|
||||||
|
directory.setIcon(R.drawable.ic_close);
|
||||||
|
directory.setTitle("Clear download folder");
|
||||||
|
directory.setSummary(current);
|
||||||
|
} else {
|
||||||
|
if (storage != null) storage.setVisible(true);
|
||||||
|
if (Preferences.getDownloadStoragePreference() == 2) {
|
||||||
|
directory.setVisible(true);
|
||||||
|
directory.setIcon(R.drawable.ic_folder);
|
||||||
|
directory.setTitle("Set download folder");
|
||||||
|
directory.setSummary("Choose a folder for downloaded music files");
|
||||||
|
} else {
|
||||||
|
directory.setVisible(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void setStreamingCacheSize() {
|
private void setStreamingCacheSize() {
|
||||||
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
|
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
|
||||||
|
|
||||||
|
|
@ -339,11 +400,19 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
@Override
|
@Override
|
||||||
public void onPositiveClick() {
|
public void onPositiveClick() {
|
||||||
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
|
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
|
||||||
|
checkDownloadDirectory();
|
||||||
}
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onNegativeClick() {
|
public void onNegativeClick() {
|
||||||
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
|
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
|
||||||
|
checkDownloadDirectory();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onNeutralClick() {
|
||||||
|
findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
|
||||||
|
checkDownloadDirectory();
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
dialog.show(activity.getSupportFragmentManager(), null);
|
dialog.show(activity.getSupportFragmentManager(), null);
|
||||||
|
|
@ -351,6 +420,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private void actionSetDownloadDirectory() {
|
||||||
|
Preference pref = findPreference("set_download_directory");
|
||||||
|
if (pref != null) {
|
||||||
|
pref.setOnPreferenceClickListener(preference -> {
|
||||||
|
String current = Preferences.getDownloadDirectoryUri();
|
||||||
|
|
||||||
|
if (current != null) {
|
||||||
|
Preferences.setDownloadDirectoryUri(null);
|
||||||
|
Preferences.setDownloadStoragePreference(0);
|
||||||
|
ExternalAudioReader.refreshCache();
|
||||||
|
Toast.makeText(requireContext(), "Download folder cleared.", Toast.LENGTH_SHORT).show();
|
||||||
|
checkStorage();
|
||||||
|
checkDownloadDirectory();
|
||||||
|
} else {
|
||||||
|
Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
|
||||||
|
intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
|
||||||
|
| Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||||
|
| Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
|
||||||
|
directoryPickerLauncher.launch(intent);
|
||||||
|
}
|
||||||
|
return true;
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private void actionDeleteDownloadStorage() {
|
private void actionDeleteDownloadStorage() {
|
||||||
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
|
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
|
||||||
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
|
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
|
||||||
|
|
|
||||||
|
|
@ -201,7 +201,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
|
||||||
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
|
||||||
bind.songListRecyclerView.setHasFixedSize(true);
|
bind.songListRecyclerView.setHasFixedSize(true);
|
||||||
|
|
||||||
songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
|
songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
|
||||||
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
|
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
|
||||||
setMediaBrowserListenableFuture();
|
setMediaBrowserListenableFuture();
|
||||||
reapplyPlayback();
|
reapplyPlayback();
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
import android.widget.ToggleButton;
|
import android.widget.ToggleButton;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
|
|
@ -37,6 +38,8 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
|
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
|
||||||
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
|
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||||
|
|
@ -54,6 +57,10 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
private AlbumBottomSheetViewModel albumBottomSheetViewModel;
|
private AlbumBottomSheetViewModel albumBottomSheetViewModel;
|
||||||
private AlbumID3 album;
|
private AlbumID3 album;
|
||||||
|
|
||||||
|
private TextView removeAllTextView;
|
||||||
|
private List<Child> currentAlbumTracks = Collections.emptyList();
|
||||||
|
private List<MediaItem> currentAlbumMediaItems = Collections.emptyList();
|
||||||
|
|
||||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
|
|
@ -72,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onStart() {
|
public void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
|
|
@ -163,7 +176,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||||
|
|
||||||
downloadAll.setOnClickListener(v -> {
|
downloadAll.setOnClickListener(v -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
|
||||||
|
} else {
|
||||||
|
songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
|
||||||
|
}
|
||||||
dismissBottomSheet();
|
dismissBottomSheet();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
@ -182,19 +199,23 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
|
removeAllTextView = view.findViewById(R.id.remove_all_text_view);
|
||||||
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
|
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
|
||||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
currentAlbumTracks = songs != null ? songs : Collections.emptyList();
|
||||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks);
|
||||||
|
|
||||||
removeAll.setOnClickListener(v -> {
|
removeAllTextView.setOnClickListener(v -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
List<Download> downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList());
|
||||||
|
DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads);
|
||||||
|
} else {
|
||||||
|
currentAlbumTracks.forEach(ExternalAudioReader::delete);
|
||||||
|
}
|
||||||
dismissBottomSheet();
|
dismissBottomSheet();
|
||||||
});
|
});
|
||||||
|
updateRemoveAllVisibility();
|
||||||
});
|
});
|
||||||
|
|
||||||
initDownloadUI(removeAll);
|
|
||||||
|
|
||||||
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
|
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
|
||||||
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
|
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
|
||||||
if (artist != null) {
|
if (artist != null) {
|
||||||
|
|
@ -234,14 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
dismiss();
|
dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initDownloadUI(TextView removeAll) {
|
private void updateRemoveAllVisibility() {
|
||||||
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
|
if (removeAllTextView == null) {
|
||||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
|
if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) {
|
||||||
removeAll.setVisibility(View.VISIBLE);
|
removeAllTextView.setVisibility(View.GONE);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
List<MediaItem> mediaItems = currentAlbumMediaItems;
|
||||||
|
if (mediaItems == null || mediaItems.isEmpty()) {
|
||||||
|
removeAllTextView.setVisibility(View.GONE);
|
||||||
|
} else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
|
||||||
|
removeAllTextView.setVisibility(View.VISIBLE);
|
||||||
|
} else {
|
||||||
|
removeAllTextView.setVisibility(View.GONE);
|
||||||
}
|
}
|
||||||
});
|
} else {
|
||||||
|
boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null);
|
||||||
|
removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initializeMediaBrowser() {
|
private void initializeMediaBrowser() {
|
||||||
|
|
|
||||||
|
|
@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
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.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
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);
|
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
|
||||||
removeAll.setOnClickListener(v -> {
|
removeAll.setOnClickListener(v -> {
|
||||||
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
List<MediaItem> mediaItems = MappingUtil.mapDownloads(songs);
|
||||||
|
List<Download> downloads = songs.stream().map(Download::new).collect(Collectors.toList());
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
|
||||||
|
} else {
|
||||||
|
songs.forEach(ExternalAudioReader::delete);
|
||||||
|
}
|
||||||
|
|
||||||
dismissBottomSheet();
|
dismissBottomSheet();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -13,6 +13,7 @@ import android.widget.TextView;
|
||||||
import android.widget.Toast;
|
import android.widget.Toast;
|
||||||
import android.widget.ToggleButton;
|
import android.widget.ToggleButton;
|
||||||
|
|
||||||
|
import androidx.annotation.NonNull;
|
||||||
import androidx.annotation.Nullable;
|
import androidx.annotation.Nullable;
|
||||||
import androidx.lifecycle.ViewModelProvider;
|
import androidx.lifecycle.ViewModelProvider;
|
||||||
import androidx.media3.common.util.UnstableApi;
|
import androidx.media3.common.util.UnstableApi;
|
||||||
|
|
@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
|
||||||
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
|
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
|
||||||
import com.cappielloantonio.tempo.util.Constants;
|
import com.cappielloantonio.tempo.util.Constants;
|
||||||
import com.cappielloantonio.tempo.util.DownloadUtil;
|
import com.cappielloantonio.tempo.util.DownloadUtil;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
import com.cappielloantonio.tempo.util.MappingUtil;
|
import com.cappielloantonio.tempo.util.MappingUtil;
|
||||||
import com.cappielloantonio.tempo.util.MusicUtil;
|
import com.cappielloantonio.tempo.util.MusicUtil;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
|
|
@ -39,6 +41,10 @@ import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
|
||||||
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
|
||||||
import com.google.common.util.concurrent.ListenableFuture;
|
import com.google.common.util.concurrent.ListenableFuture;
|
||||||
|
|
||||||
|
import android.content.Intent;
|
||||||
|
import androidx.media3.common.MediaItem;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioWriter;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
import java.util.Collections;
|
import java.util.Collections;
|
||||||
|
|
||||||
|
|
@ -48,6 +54,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
private SongBottomSheetViewModel songBottomSheetViewModel;
|
private SongBottomSheetViewModel songBottomSheetViewModel;
|
||||||
private Child song;
|
private Child song;
|
||||||
|
|
||||||
|
private TextView downloadButton;
|
||||||
|
private TextView removeButton;
|
||||||
|
|
||||||
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
|
||||||
|
|
||||||
@Nullable
|
@Nullable
|
||||||
|
|
@ -66,6 +75,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
return view;
|
return view;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
|
||||||
|
super.onViewCreated(view, savedInstanceState);
|
||||||
|
MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
|
||||||
|
}
|
||||||
|
|
||||||
@Override
|
@Override
|
||||||
public void onStart() {
|
public void onStart() {
|
||||||
super.onStart();
|
super.onStart();
|
||||||
|
|
@ -157,25 +172,33 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
dismissBottomSheet();
|
dismissBottomSheet();
|
||||||
});
|
});
|
||||||
|
|
||||||
TextView download = view.findViewById(R.id.download_text_view);
|
downloadButton = view.findViewById(R.id.download_text_view);
|
||||||
download.setOnClickListener(v -> {
|
downloadButton.setOnClickListener(v -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).download(
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
MappingUtil.mapDownload(song),
|
DownloadUtil.getDownloadTracker(requireContext()).download(
|
||||||
new Download(song)
|
MappingUtil.mapDownload(song),
|
||||||
);
|
new Download(song)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
|
||||||
|
}
|
||||||
dismissBottomSheet();
|
dismissBottomSheet();
|
||||||
});
|
});
|
||||||
|
|
||||||
TextView remove = view.findViewById(R.id.remove_text_view);
|
removeButton = view.findViewById(R.id.remove_text_view);
|
||||||
remove.setOnClickListener(v -> {
|
removeButton.setOnClickListener(v -> {
|
||||||
DownloadUtil.getDownloadTracker(requireContext()).remove(
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
MappingUtil.mapDownload(song),
|
DownloadUtil.getDownloadTracker(requireContext()).remove(
|
||||||
new Download(song)
|
MappingUtil.mapDownload(song),
|
||||||
);
|
new Download(song)
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
ExternalAudioReader.delete(song);
|
||||||
|
}
|
||||||
dismissBottomSheet();
|
dismissBottomSheet();
|
||||||
});
|
});
|
||||||
|
|
||||||
initDownloadUI(download, remove);
|
updateDownloadButtons();
|
||||||
|
|
||||||
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
|
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
|
||||||
addToPlaylist.setOnClickListener(v -> {
|
addToPlaylist.setOnClickListener(v -> {
|
||||||
|
|
@ -243,12 +266,19 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
|
||||||
dismiss();
|
dismiss();
|
||||||
}
|
}
|
||||||
|
|
||||||
private void initDownloadUI(TextView download, TextView remove) {
|
private void updateDownloadButtons() {
|
||||||
if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) {
|
if (downloadButton == null || removeButton == null) {
|
||||||
remove.setVisibility(View.VISIBLE);
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Preferences.getDownloadDirectoryUri() == null) {
|
||||||
|
boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
|
||||||
|
downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
|
||||||
|
removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
|
||||||
} else {
|
} else {
|
||||||
download.setVisibility(View.VISIBLE);
|
boolean hasLocal = ExternalAudioReader.getUri(song) != null;
|
||||||
remove.setVisibility(View.GONE);
|
downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
|
||||||
|
removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -85,6 +85,13 @@ object Constants {
|
||||||
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
|
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
|
||||||
|
|
||||||
const val DOWNLOAD_URI = "rest/download"
|
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_TRACK = "download_type_track"
|
||||||
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
|
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
|
||||||
|
|
|
||||||
|
|
@ -187,19 +187,21 @@ public final class DownloadUtil {
|
||||||
|
|
||||||
private static synchronized File getDownloadDirectory(Context context) {
|
private static synchronized File getDownloadDirectory(Context context) {
|
||||||
if (downloadDirectory == null) {
|
if (downloadDirectory == null) {
|
||||||
if (Preferences.getDownloadStoragePreference() == 0) {
|
int pref = Preferences.getDownloadStoragePreference();
|
||||||
|
if (pref == 0) {
|
||||||
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
||||||
if (downloadDirectory == null) {
|
if (downloadDirectory == null) {
|
||||||
downloadDirectory = context.getFilesDir();
|
downloadDirectory = context.getFilesDir();
|
||||||
}
|
}
|
||||||
} else {
|
} else if (pref == 1) {
|
||||||
try {
|
try {
|
||||||
downloadDirectory = context.getExternalFilesDirs(null)[1];
|
downloadDirectory = context.getExternalFilesDirs(null)[1];
|
||||||
} catch (Exception exception) {
|
} catch (Exception exception) {
|
||||||
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
||||||
Preferences.setDownloadStoragePreference(0);
|
Preferences.setDownloadStoragePreference(0);
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
downloadDirectory = context.getExternalFilesDirs(null)[0];
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -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();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -4,6 +4,7 @@ import android.net.Uri;
|
||||||
import android.os.Bundle;
|
import android.os.Bundle;
|
||||||
|
|
||||||
import androidx.annotation.OptIn;
|
import androidx.annotation.OptIn;
|
||||||
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.media3.common.MediaItem;
|
import androidx.media3.common.MediaItem;
|
||||||
import androidx.media3.common.MediaMetadata;
|
import androidx.media3.common.MediaMetadata;
|
||||||
import androidx.media3.common.MimeTypes;
|
import androidx.media3.common.MimeTypes;
|
||||||
|
|
@ -226,12 +227,20 @@ public class MappingUtil {
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri getUri(Child media) {
|
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())
|
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
|
||||||
? getDownloadUri(media.getId())
|
? getDownloadUri(media.getId())
|
||||||
: MusicUtil.getStreamUri(media.getId());
|
: MusicUtil.getStreamUri(media.getId());
|
||||||
}
|
}
|
||||||
|
|
||||||
private static Uri getUri(PodcastEpisode podcastEpisode) {
|
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())
|
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
|
||||||
? getDownloadUri(podcastEpisode.getStreamId())
|
? getDownloadUri(podcastEpisode.getStreamId())
|
||||||
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
|
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
|
||||||
|
|
@ -241,4 +250,11 @@ public class MappingUtil {
|
||||||
Download download = new DownloadRepository().getDownload(id);
|
Download download = new DownloadRepository().getDownload(id);
|
||||||
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(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());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -52,6 +52,7 @@ object Preferences {
|
||||||
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
|
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
|
||||||
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
|
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
|
||||||
private const val DOWNLOAD_STORAGE = "download_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 DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type"
|
||||||
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
|
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
|
||||||
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
|
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
|
||||||
|
|
@ -463,6 +464,20 @@ object Preferences {
|
||||||
).apply()
|
).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
|
@JvmStatic
|
||||||
fun getDefaultDownloadViewType(): String {
|
fun getDefaultDownloadViewType(): String {
|
||||||
return App.getInstance().preferences.getString(
|
return App.getInstance().preferences.getString(
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
package com.cappielloantonio.tempo.viewmodel;
|
package com.cappielloantonio.tempo.viewmodel;
|
||||||
|
|
||||||
import android.app.Application;
|
import android.app.Application;
|
||||||
|
import android.net.Uri;
|
||||||
import android.util.Log;
|
import android.util.Log;
|
||||||
|
|
||||||
import androidx.annotation.NonNull;
|
import androidx.annotation.NonNull;
|
||||||
|
|
@ -8,10 +9,13 @@ import androidx.lifecycle.AndroidViewModel;
|
||||||
import androidx.lifecycle.LifecycleOwner;
|
import androidx.lifecycle.LifecycleOwner;
|
||||||
import androidx.lifecycle.LiveData;
|
import androidx.lifecycle.LiveData;
|
||||||
import androidx.lifecycle.MutableLiveData;
|
import androidx.lifecycle.MutableLiveData;
|
||||||
|
import androidx.documentfile.provider.DocumentFile;
|
||||||
|
|
||||||
|
import com.cappielloantonio.tempo.model.Download;
|
||||||
import com.cappielloantonio.tempo.model.DownloadStack;
|
import com.cappielloantonio.tempo.model.DownloadStack;
|
||||||
import com.cappielloantonio.tempo.repository.DownloadRepository;
|
import com.cappielloantonio.tempo.repository.DownloadRepository;
|
||||||
import com.cappielloantonio.tempo.subsonic.models.Child;
|
import com.cappielloantonio.tempo.subsonic.models.Child;
|
||||||
|
import com.cappielloantonio.tempo.util.ExternalAudioReader;
|
||||||
import com.cappielloantonio.tempo.util.Preferences;
|
import com.cappielloantonio.tempo.util.Preferences;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
import java.util.ArrayList;
|
||||||
|
|
@ -25,6 +29,7 @@ public class DownloadViewModel extends AndroidViewModel {
|
||||||
|
|
||||||
private final MutableLiveData<List<Child>> downloadedTrackSample = new MutableLiveData<>(null);
|
private final MutableLiveData<List<Child>> downloadedTrackSample = new MutableLiveData<>(null);
|
||||||
private final MutableLiveData<ArrayList<DownloadStack>> viewStack = new MutableLiveData<>(null);
|
private final MutableLiveData<ArrayList<DownloadStack>> viewStack = new MutableLiveData<>(null);
|
||||||
|
private final MutableLiveData<Integer> refreshResult = new MutableLiveData<>();
|
||||||
|
|
||||||
public DownloadViewModel(@NonNull Application application) {
|
public DownloadViewModel(@NonNull Application application) {
|
||||||
super(application);
|
super(application);
|
||||||
|
|
@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
|
||||||
return viewStack;
|
return viewStack;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public LiveData<Integer> getRefreshResult() {
|
||||||
|
return refreshResult;
|
||||||
|
}
|
||||||
|
|
||||||
public void initViewStack(DownloadStack level) {
|
public void initViewStack(DownloadStack level) {
|
||||||
ArrayList<DownloadStack> stack = new ArrayList<>();
|
ArrayList<DownloadStack> stack = new ArrayList<>();
|
||||||
stack.add(level);
|
stack.add(level);
|
||||||
|
|
@ -60,4 +69,59 @@ public class DownloadViewModel extends AndroidViewModel {
|
||||||
stack.remove(stack.size() - 1);
|
stack.remove(stack.size() - 1);
|
||||||
viewStack.setValue(stack);
|
viewStack.setValue(stack);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public void refreshExternalDownloads() {
|
||||||
|
new Thread(() -> {
|
||||||
|
String directoryUri = Preferences.getDownloadDirectoryUri();
|
||||||
|
if (directoryUri == null) {
|
||||||
|
refreshResult.postValue(-1);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
List<Download> downloads = downloadRepository.getAllDownloads();
|
||||||
|
if (downloads == null || downloads.isEmpty()) {
|
||||||
|
refreshResult.postValue(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
ArrayList<Download> toRemove = new ArrayList<>();
|
||||||
|
|
||||||
|
for (Download download : downloads) {
|
||||||
|
String uriString = download.getDownloadUri();
|
||||||
|
if (uriString == null || uriString.isEmpty()) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
Uri uri = Uri.parse(uriString);
|
||||||
|
if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("content")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
DocumentFile file;
|
||||||
|
try {
|
||||||
|
file = DocumentFile.fromSingleUri(getApplication(), uri);
|
||||||
|
} catch (SecurityException exception) {
|
||||||
|
file = null;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (file == null || !file.exists()) {
|
||||||
|
toRemove.add(download);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!toRemove.isEmpty()) {
|
||||||
|
ArrayList<String> ids = new ArrayList<>();
|
||||||
|
for (Download download : toRemove) {
|
||||||
|
ids.add(download.getId());
|
||||||
|
ExternalAudioReader.removeMetadata(download);
|
||||||
|
}
|
||||||
|
|
||||||
|
downloadRepository.delete(ids);
|
||||||
|
ExternalAudioReader.refreshCache();
|
||||||
|
refreshResult.postValue(ids.size());
|
||||||
|
} else {
|
||||||
|
refreshResult.postValue(0);
|
||||||
|
}
|
||||||
|
}).start();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -134,7 +134,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
|
||||||
|
|
||||||
media.setStarred(new Date());
|
media.setStarred(new Date());
|
||||||
|
|
||||||
if (Preferences.isStarredSyncEnabled()) {
|
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||||
DownloadUtil.getDownloadTracker(context).download(
|
DownloadUtil.getDownloadTracker(context).download(
|
||||||
MappingUtil.mapDownload(media),
|
MappingUtil.mapDownload(media),
|
||||||
new Download(media)
|
new Download(media)
|
||||||
|
|
|
||||||
|
|
@ -109,7 +109,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
|
||||||
|
|
||||||
media.setStarred(new Date());
|
media.setStarred(new Date());
|
||||||
|
|
||||||
if (Preferences.isStarredSyncEnabled()) {
|
if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
|
||||||
DownloadUtil.getDownloadTracker(context).download(
|
DownloadUtil.getDownloadTracker(context).download(
|
||||||
MappingUtil.mapDownload(media),
|
MappingUtil.mapDownload(media),
|
||||||
new Download(media)
|
new Download(media)
|
||||||
|
|
|
||||||
9
app/src/main/res/drawable/ic_folder.xml
Normal file
9
app/src/main/res/drawable/ic_folder.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/titleTextColor"
|
||||||
|
android:pathData="M10,4L12,6H20c1.1,0 2,0.9 2,2v10c0,1.1 -0.9,2 -2,2H4c-1.1,0 -2,-0.9 -2,-2V6c0,-1.1 0.9,-2 2,-2h6z"/>
|
||||||
|
</vector>
|
||||||
9
app/src/main/res/drawable/ic_refresh.xml
Normal file
9
app/src/main/res/drawable/ic_refresh.xml
Normal file
|
|
@ -0,0 +1,9 @@
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<path
|
||||||
|
android:fillColor="@color/titleTextColor"
|
||||||
|
android:pathData="M17.65,6.35C16.2,4.9 14.21,4 12,4c-4.42,0 -8,3.58 -8,8h2c0,-3.31 2.69,-6 6,-6 1.66,0 3.14,0.69 4.22,1.78L13,11h7V4l-2.35,2.35zM19,12c0,3.31 -2.69,6 -6,6 -1.66,0 -3.14,-0.69 -4.22,-1.78L11,13H4v7l2.35,-2.35C7.8,19.1 9.79,20 12,20c4.42,0 8,-3.58 8,-8h-2z" />
|
||||||
|
</vector>
|
||||||
|
|
@ -80,7 +80,7 @@
|
||||||
android:layout_width="0dp"
|
android:layout_width="0dp"
|
||||||
android:layout_height="wrap_content"
|
android:layout_height="wrap_content"
|
||||||
android:text="@string/download_title_section"
|
android:text="@string/download_title_section"
|
||||||
app:layout_constraintEnd_toStartOf="@+id/downloaded_go_back_image_view"
|
app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toTopOf="parent" />
|
app:layout_constraintTop_toTopOf="parent" />
|
||||||
|
|
||||||
|
|
@ -94,6 +94,19 @@
|
||||||
app:layout_constraintStart_toStartOf="parent"
|
app:layout_constraintStart_toStartOf="parent"
|
||||||
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
|
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
|
||||||
|
|
||||||
|
<ImageView
|
||||||
|
android:id="@+id/downloaded_refresh_image_view"
|
||||||
|
android:layout_width="24dp"
|
||||||
|
android:layout_height="24dp"
|
||||||
|
android:layout_marginStart="12dp"
|
||||||
|
android:layout_marginEnd="12dp"
|
||||||
|
android:background="@drawable/ic_refresh"
|
||||||
|
android:contentDescription="@string/download_refresh_button_content_description"
|
||||||
|
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
|
||||||
|
app:layout_constraintEnd_toStartOf="@id/downloaded_go_back_image_view"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/downloaded_text_view_refreshable"
|
||||||
|
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
android:id="@+id/downloaded_go_back_image_view"
|
android:id="@+id/downloaded_go_back_image_view"
|
||||||
android:layout_width="24dp"
|
android:layout_width="24dp"
|
||||||
|
|
@ -103,6 +116,7 @@
|
||||||
android:background="@drawable/ic_arrow_back"
|
android:background="@drawable/ic_arrow_back"
|
||||||
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
|
app:layout_constraintBottom_toBottomOf="@+id/downloaded_text_view_refreshable"
|
||||||
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
|
app:layout_constraintEnd_toStartOf="@id/downloaded_group_by_image_view"
|
||||||
|
app:layout_constraintStart_toEndOf="@id/downloaded_refresh_image_view"
|
||||||
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
|
app:layout_constraintTop_toTopOf="@+id/downloaded_text_view_refreshable" />
|
||||||
|
|
||||||
<ImageView
|
<ImageView
|
||||||
|
|
|
||||||
|
|
@ -16,4 +16,7 @@
|
||||||
<item
|
<item
|
||||||
android:id="@+id/menu_download_group_by_year"
|
android:id="@+id/menu_download_group_by_year"
|
||||||
android:title="@string/menu_group_by_year" />
|
android:title="@string/menu_group_by_year" />
|
||||||
|
<item
|
||||||
|
android:id="@+id/menu_download_set_directory"
|
||||||
|
android:title="@string/download_directory_set" />
|
||||||
</menu>
|
</menu>
|
||||||
|
|
@ -68,6 +68,7 @@
|
||||||
<string name="download_directory_dialog_positive_button">Download</string>
|
<string name="download_directory_dialog_positive_button">Download</string>
|
||||||
<string name="download_directory_dialog_summary">All tracks in this folder will be downloaded. Tracks present in subfolders will not be downloaded.</string>
|
<string name="download_directory_dialog_summary">All tracks in this folder will be downloaded. Tracks present in subfolders will not be downloaded.</string>
|
||||||
<string name="download_directory_dialog_title">Download the tracks</string>
|
<string name="download_directory_dialog_title">Download the tracks</string>
|
||||||
|
<string name="download_directory_set">Set where music is downloaded</string>
|
||||||
<string name="download_info_empty_subtitle">Once you download a song, you\'ll find it here</string>
|
<string name="download_info_empty_subtitle">Once you download a song, you\'ll find it here</string>
|
||||||
<string name="download_info_empty_title">No downloads yet!</string>
|
<string name="download_info_empty_title">No downloads yet!</string>
|
||||||
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s items</string>
|
<string name="download_item_multiple_subtitle_formatter">%1$s • %2$s items</string>
|
||||||
|
|
@ -78,7 +79,15 @@
|
||||||
<string name="download_storage_dialog_title">Select storage option</string>
|
<string name="download_storage_dialog_title">Select storage option</string>
|
||||||
<string name="download_storage_external_dialog_positive_button">External</string>
|
<string name="download_storage_external_dialog_positive_button">External</string>
|
||||||
<string name="download_storage_internal_dialog_negative_button">Internal</string>
|
<string name="download_storage_internal_dialog_negative_button">Internal</string>
|
||||||
|
<string name="download_storage_directory_dialog_neutral_button">Directory</string>
|
||||||
<string name="download_title_section">Downloads</string>
|
<string name="download_title_section">Downloads</string>
|
||||||
|
<string name="download_refresh_no_directory">Set a download folder to refresh your downloads.</string>
|
||||||
|
<string name="download_refresh_no_changes">No missing downloads found.</string>
|
||||||
|
<plurals name="download_refresh_removed">
|
||||||
|
<item quantity="one">Removed %d missing download.</item>
|
||||||
|
<item quantity="other">Removed %d missing downloads.</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="download_refresh_button_content_description">Refresh downloaded items</string>
|
||||||
<string name="downloaded_bottom_sheet_add_to_queue">Add to queue</string>
|
<string name="downloaded_bottom_sheet_add_to_queue">Add to queue</string>
|
||||||
<string name="downloaded_bottom_sheet_play_next">Play next</string>
|
<string name="downloaded_bottom_sheet_play_next">Play next</string>
|
||||||
<string name="downloaded_bottom_sheet_remove">Remove</string>
|
<string name="downloaded_bottom_sheet_remove">Remove</string>
|
||||||
|
|
|
||||||
|
|
@ -187,6 +187,14 @@
|
||||||
android:key="download_storage"
|
android:key="download_storage"
|
||||||
app:title="@string/settings_download_storage_title" />
|
app:title="@string/settings_download_storage_title" />
|
||||||
|
|
||||||
|
<Preference
|
||||||
|
android:key="set_download_directory"
|
||||||
|
android:title="Set download folder"
|
||||||
|
android:summary="Choose a folder for downloaded music files"
|
||||||
|
android:icon="@drawable/ic_folder"
|
||||||
|
android:order="104"
|
||||||
|
app:isPreferenceVisible="false" />
|
||||||
|
|
||||||
<Preference
|
<Preference
|
||||||
android:key="delete_download_storage"
|
android:key="delete_download_storage"
|
||||||
app:title="@string/settings_delete_download_storage_title"
|
app:title="@string/settings_delete_download_storage_title"
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue