Merge branch 'development' into main

This commit is contained in:
sebaFlame 2025-10-18 01:48:47 +02:00 committed by GitHub
commit 442fe1ea01
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
66 changed files with 1856 additions and 327 deletions

View file

@ -0,0 +1,188 @@
package com.cappielloantonio.tempo.util;
import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.MutableLiveData;
import androidx.lifecycle.Observer;
import androidx.lifecycle.ViewModelProvider;
import androidx.navigation.NavController;
import androidx.navigation.NavOptions;
import com.cappielloantonio.tempo.BuildConfig;
import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
import com.cappielloantonio.tempo.subsonic.models.Genre;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.fragment.bottomsheetdialog.SongBottomSheetDialog;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
public final class AssetLinkNavigator {
private final MainActivity activity;
private final SongRepository songRepository = new SongRepository();
private final AlbumRepository albumRepository = new AlbumRepository();
private final ArtistRepository artistRepository = new ArtistRepository();
private final PlaylistRepository playlistRepository = new PlaylistRepository();
public AssetLinkNavigator(@NonNull MainActivity activity) {
this.activity = activity;
}
public void open(@Nullable AssetLinkUtil.AssetLink assetLink) {
if (assetLink == null) {
return;
}
switch (assetLink.type) {
case AssetLinkUtil.TYPE_SONG:
openSong(assetLink.id);
break;
case AssetLinkUtil.TYPE_ALBUM:
openAlbum(assetLink.id);
break;
case AssetLinkUtil.TYPE_ARTIST:
openArtist(assetLink.id);
break;
case AssetLinkUtil.TYPE_PLAYLIST:
openPlaylist(assetLink.id);
break;
case AssetLinkUtil.TYPE_GENRE:
openGenre(assetLink.id);
break;
case AssetLinkUtil.TYPE_YEAR:
openYear(assetLink.id);
break;
default:
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
break;
}
}
private void openSong(@NonNull String id) {
MutableLiveData<Child> liveData = songRepository.getSong(id);
Observer<Child> observer = new Observer<Child>() {
@Override
public void onChanged(Child child) {
liveData.removeObserver(this);
if (child == null) {
Toast.makeText(activity, R.string.asset_link_error_song, Toast.LENGTH_SHORT).show();
return;
}
SongBottomSheetViewModel viewModel = new ViewModelProvider(activity).get(SongBottomSheetViewModel.class);
viewModel.setSong(child);
SongBottomSheetDialog dialog = new SongBottomSheetDialog();
Bundle args = new Bundle();
args.putParcelable(Constants.TRACK_OBJECT, child);
dialog.setArguments(args);
dialog.show(activity.getSupportFragmentManager(), null);
}
};
liveData.observe(activity, observer);
}
private void openAlbum(@NonNull String id) {
MutableLiveData<AlbumID3> liveData = albumRepository.getAlbum(id);
Observer<AlbumID3> observer = new Observer<AlbumID3>() {
@Override
public void onChanged(AlbumID3 album) {
liveData.removeObserver(this);
if (album == null) {
Toast.makeText(activity, R.string.asset_link_error_album, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.ALBUM_OBJECT, album);
navigateSafely(R.id.albumPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openArtist(@NonNull String id) {
MutableLiveData<ArtistID3> liveData = artistRepository.getArtist(id);
Observer<ArtistID3> observer = new Observer<ArtistID3>() {
@Override
public void onChanged(ArtistID3 artist) {
liveData.removeObserver(this);
if (artist == null) {
Toast.makeText(activity, R.string.asset_link_error_artist, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.ARTIST_OBJECT, artist);
navigateSafely(R.id.artistPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openPlaylist(@NonNull String id) {
MutableLiveData<Playlist> liveData = playlistRepository.getPlaylist(id);
Observer<Playlist> observer = new Observer<Playlist>() {
@Override
public void onChanged(Playlist playlist) {
liveData.removeObserver(this);
if (playlist == null) {
Toast.makeText(activity, R.string.asset_link_error_playlist, Toast.LENGTH_SHORT).show();
return;
}
Bundle args = new Bundle();
args.putParcelable(Constants.PLAYLIST_OBJECT, playlist);
navigateSafely(R.id.playlistPageFragment, args);
}
};
liveData.observe(activity, observer);
}
private void openGenre(@NonNull String genreName) {
String trimmed = genreName.trim();
if (trimmed.isEmpty()) {
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
return;
}
Genre genre = new Genre();
genre.setGenre(trimmed);
genre.setSongCount(0);
genre.setAlbumCount(0);
Bundle args = new Bundle();
args.putParcelable(Constants.GENRE_OBJECT, genre);
args.putString(Constants.MEDIA_BY_GENRE, Constants.MEDIA_BY_GENRE);
navigateSafely(R.id.songListPageFragment, args);
}
private void openYear(@NonNull String yearValue) {
try {
int year = Integer.parseInt(yearValue.trim());
Bundle args = new Bundle();
args.putInt("year_object", year);
args.putString(Constants.MEDIA_BY_YEAR, Constants.MEDIA_BY_YEAR);
navigateSafely(R.id.songListPageFragment, args);
} catch (NumberFormatException ex) {
Toast.makeText(activity, R.string.asset_link_error_unsupported, Toast.LENGTH_SHORT).show();
}
}
private void navigateSafely(int destinationId, @Nullable Bundle args) {
activity.runOnUiThread(() -> {
NavController navController = activity.navController;
if (navController == null) {
return;
}
if (navController.getCurrentDestination() != null
&& navController.getCurrentDestination().getId() == destinationId) {
navController.navigate(destinationId, args, new NavOptions.Builder().setLaunchSingleTop(true).build());
} else {
navController.navigate(destinationId, args);
}
});
}
}

View file

@ -0,0 +1,188 @@
package com.cappielloantonio.tempo.util;
import android.content.ClipData;
import android.content.ClipboardManager;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import android.view.View;
import android.widget.TextView;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.annotation.StringRes;
import androidx.core.content.ContextCompat;
import com.cappielloantonio.tempo.R;
import java.util.Objects;
import com.google.android.material.color.MaterialColors;
public final class AssetLinkUtil {
public static final String SCHEME = "tempo";
public static final String HOST_ASSET = "asset";
public static final String TYPE_SONG = "song";
public static final String TYPE_ALBUM = "album";
public static final String TYPE_ARTIST = "artist";
public static final String TYPE_PLAYLIST = "playlist";
public static final String TYPE_GENRE = "genre";
public static final String TYPE_YEAR = "year";
private AssetLinkUtil() {
}
@Nullable
public static AssetLink parse(@Nullable Intent intent) {
if (intent == null) return null;
return parse(intent.getData());
}
@Nullable
public static AssetLink parse(@Nullable Uri uri) {
if (uri == null) {
return null;
}
if (!SCHEME.equalsIgnoreCase(uri.getScheme())) {
return null;
}
String host = uri.getHost();
if (!HOST_ASSET.equalsIgnoreCase(host)) {
return null;
}
if (uri.getPathSegments().size() < 2) {
return null;
}
String type = uri.getPathSegments().get(0);
String id = uri.getPathSegments().get(1);
if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id)) {
return null;
}
if (!isSupportedType(type)) {
return null;
}
return new AssetLink(type, id, uri);
}
public static boolean isSupportedType(@Nullable String type) {
if (type == null) return false;
switch (type) {
case TYPE_SONG:
case TYPE_ALBUM:
case TYPE_ARTIST:
case TYPE_PLAYLIST:
case TYPE_GENRE:
case TYPE_YEAR:
return true;
default:
return false;
}
}
@NonNull
public static Uri buildUri(@NonNull String type, @NonNull String id) {
return new Uri.Builder()
.scheme(SCHEME)
.authority(HOST_ASSET)
.appendPath(type)
.appendPath(id)
.build();
}
@Nullable
public static String buildLink(@Nullable String type, @Nullable String id) {
if (TextUtils.isEmpty(type) || TextUtils.isEmpty(id) || !isSupportedType(type)) {
return null;
}
return buildUri(Objects.requireNonNull(type), Objects.requireNonNull(id)).toString();
}
@Nullable
public static AssetLink buildAssetLink(@Nullable String type, @Nullable String id) {
String link = buildLink(type, id);
return parseLinkString(link);
}
@Nullable
public static AssetLink parseLinkString(@Nullable String link) {
if (TextUtils.isEmpty(link)) {
return null;
}
return parse(Uri.parse(link));
}
public static void copyToClipboard(@NonNull Context context, @NonNull AssetLink assetLink) {
ClipboardManager clipboardManager = (ClipboardManager) context.getSystemService(Context.CLIPBOARD_SERVICE);
if (clipboardManager == null) {
return;
}
ClipData clipData = ClipData.newPlainText(context.getString(R.string.asset_link_clipboard_label), assetLink.uri.toString());
clipboardManager.setPrimaryClip(clipData);
}
@StringRes
public static int getLabelRes(@NonNull String type) {
switch (type) {
case TYPE_SONG:
return R.string.asset_link_label_song;
case TYPE_ALBUM:
return R.string.asset_link_label_album;
case TYPE_ARTIST:
return R.string.asset_link_label_artist;
case TYPE_PLAYLIST:
return R.string.asset_link_label_playlist;
case TYPE_GENRE:
return R.string.asset_link_label_genre;
case TYPE_YEAR:
return R.string.asset_link_label_year;
default:
return R.string.asset_link_label_unknown;
}
}
public static void applyLinkAppearance(@NonNull View view) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
if (textView.getTag(R.id.tag_link_original_color) == null) {
textView.setTag(R.id.tag_link_original_color, textView.getCurrentTextColor());
}
int accent = MaterialColors.getColor(view, com.google.android.material.R.attr.colorPrimary,
ContextCompat.getColor(view.getContext(), android.R.color.holo_blue_light));
textView.setTextColor(accent);
}
}
public static void clearLinkAppearance(@NonNull View view) {
if (view instanceof TextView) {
TextView textView = (TextView) view;
Object original = textView.getTag(R.id.tag_link_original_color);
if (original instanceof Integer) {
textView.setTextColor((Integer) original);
} else {
int defaultColor = MaterialColors.getColor(view, com.google.android.material.R.attr.colorOnSurface,
ContextCompat.getColor(view.getContext(), android.R.color.primary_text_light));
textView.setTextColor(defaultColor);
}
}
}
public static final class AssetLink {
public final String type;
public final String id;
public final Uri uri;
AssetLink(@NonNull String type, @NonNull String id, @NonNull Uri uri) {
this.type = type;
this.id = id;
this.uri = uri;
}
}
}

View file

@ -17,6 +17,9 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import java.io.File;
import java.io.FileInputStream;
import java.io.IOException;
import java.io.InputStream;
import java.io.OutputStream;
import java.net.HttpURLConnection;
@ -102,35 +105,76 @@ public class ExternalAudioWriter {
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;
}
String scheme = mediaUri.getScheme() != null ? mediaUri.getScheme().toLowerCase(Locale.ROOT) : "";
HttpURLConnection connection = null;
DocumentFile sourceDocument = null;
File sourceFile = null;
long remoteLength = -1;
String mimeType = 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);
try {
if (scheme.equals("http") || scheme.equals("https")) {
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;
}
mimeType = connection.getContentType();
remoteLength = connection.getContentLengthLong();
} else if (scheme.equals("content")) {
sourceDocument = DocumentFile.fromSingleUri(context, mediaUri);
mimeType = context.getContentResolver().getType(mediaUri);
if (sourceDocument != null) {
remoteLength = sourceDocument.length();
}
} else if (scheme.equals("file")) {
String path = mediaUri.getPath();
if (path != null) {
sourceFile = new File(path);
if (sourceFile.exists()) {
remoteLength = sourceFile.length();
}
}
String ext = MimeTypeMap.getFileExtensionFromUrl(mediaUri.toString());
if (ext != null && !ext.isEmpty()) {
mimeType = MimeTypeMap.getSingleton().getMimeTypeFromExtension(ext);
}
} else {
notifyFailure(context, "Unsupported media URI.");
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()) && sourceDocument != null && sourceDocument.getName() != null) {
String name = sourceDocument.getName();
int dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length() - 1) {
extension = name.substring(dot + 1);
}
}
if ((extension == null || extension.isEmpty()) && sourceFile != null) {
String name = sourceFile.getName();
int dot = name.lastIndexOf('.');
if (dot >= 0 && dot < name.length() - 1) {
extension = name.substring(dot + 1);
}
}
if (extension == null || extension.isEmpty()) {
String suffix = child.getSuffix();
if (suffix != null && !suffix.isEmpty()) {
@ -146,7 +190,6 @@ public class ExternalAudioWriter {
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();
@ -175,7 +218,7 @@ public class ExternalAudioWriter {
}
Uri targetUri = targetFile.getUri();
try (InputStream in = connection.getInputStream();
try (InputStream in = openInputStream(context, mediaUri, scheme, connection, sourceFile);
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
if (out == null) {
notifyFailure(context, "Cannot open output stream.");
@ -319,4 +362,32 @@ public class ExternalAudioWriter {
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
);
}
private static InputStream openInputStream(Context context,
Uri mediaUri,
String scheme,
HttpURLConnection connection,
File sourceFile) throws IOException {
switch (scheme) {
case "http":
case "https":
if (connection == null) {
throw new IOException("Connection not initialized");
}
return connection.getInputStream();
case "content":
InputStream contentStream = context.getContentResolver().openInputStream(mediaUri);
if (contentStream == null) {
throw new IOException("Cannot open content stream");
}
return contentStream;
case "file":
if (sourceFile == null || !sourceFile.exists()) {
throw new IOException("Missing source file");
}
return new FileInputStream(sourceFile);
default:
throw new IOException("Unsupported scheme " + scheme);
}
}
}

View file

@ -74,6 +74,12 @@ public class MappingUtil {
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString());
bundle.putString("assetLinkSong", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, media.getId()));
bundle.putString("assetLinkAlbum", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, media.getAlbumId()));
bundle.putString("assetLinkArtist", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, media.getArtistId()));
bundle.putString("assetLinkGenre", AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_GENRE, media.getGenre()));
Integer year = media.getYear();
bundle.putString("assetLinkYear", year != null && year != 0 ? AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)) : null);
return new MediaItem.Builder()
.setMediaId(media.getId())

View file

@ -77,6 +77,8 @@ object Preferences {
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
private const val ALBUM_DETAIL = "album_detail"
private const val ALBUM_SORT_ORDER = "album_sort_order"
private const val DEFAULT_ALBUM_SORT_ORDER = Constants.ALBUM_ORDER_BY_NAME
@JvmStatic
fun getServer(): String? {
@ -644,4 +646,14 @@ object Preferences {
fun showAlbumDetail(): Boolean {
return App.getInstance().preferences.getBoolean(ALBUM_DETAIL, false)
}
@JvmStatic
fun getAlbumSortOrder(): String {
return App.getInstance().preferences.getString(ALBUM_SORT_ORDER, DEFAULT_ALBUM_SORT_ORDER) ?: DEFAULT_ALBUM_SORT_ORDER
}
@JvmStatic
fun setAlbumSortOrder(sortOrder: String) {
App.getInstance().preferences.edit().putString(ALBUM_SORT_ORDER, sortOrder).apply()
}
}