diff --git a/.github/workflows/github_release.yml b/.github/workflows/github_release.yml index 7591281f..d026f23a 100644 --- a/.github/workflows/github_release.yml +++ b/.github/workflows/github_release.yml @@ -3,7 +3,7 @@ name: Github Release Workflow on: push: tags: - - '[0-9]+.[0-9]+.[0-9]+' + - 'v[0-9]+.[0-9]+.[0-9]+' jobs: build: diff --git a/.gitignore b/.gitignore index 1047677c..48ad5d8f 100644 --- a/.gitignore +++ b/.gitignore @@ -17,4 +17,5 @@ .vscode/settings.json # release / debug files tempus-release-key.jks -app/tempo/ \ No newline at end of file +app/tempo/ +app/notquitemy/ diff --git a/CHANGELOG.md b/CHANGELOG.md index 02e4e59a..76c83d17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,25 @@ ***This log is for this fork to detail updates since 3.9.0 from the main repo.*** +## [3.17.14](https://github.com/eddyizm/tempo/releases/tag/v3.17.14) (2025-10-16) +## What's Changed +* fix: General build warning and playback issues by @le-firehawk in https://github.com/eddyizm/tempo/pull/167 +* fix: persist album sort preference by @eddyizm in https://github.com/eddyizm/tempo/pull/168 +* Fix album parse empty date field by @eddyizm in https://github.com/eddyizm/tempo/pull/171 +* fix: Include shuffle/repeat controls in f-droid build's media notific… by @le-firehawk in https://github.com/eddyizm/tempo/pull/174 +* fix: limits image size to prevent widget crash #172 by @eddyizm in https://github.com/eddyizm/tempo/pull/175 + +**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.17.0...v3.17.14 + +## [3.17.0](https://github.com/eddyizm/tempo/releases/tag/v3.17.0) (2025-10-10) +## What's Changed +* chore: adding screenshot and docs for 4 icons/buttons in player control by @eddyizm in https://github.com/eddyizm/tempo/pull/162 +* Update Polish translation by @skajmer in https://github.com/eddyizm/tempo/pull/160 +* feat: Make all objects in Tempo references for quick access by @le-firehawk in https://github.com/eddyizm/tempo/pull/158 +* fix: Glide module incorrectly encoding IPv6 addresses by @le-firehawk in https://github.com/eddyizm/tempo/pull/159 + +**Full Changelog**: https://github.com/eddyizm/tempo/compare/v3.16.6...v3.17.0 + ## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08) ## What's Changed * chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151 diff --git a/README.md b/README.md index 44caa050..07bdc1ac 100644 --- a/README.md +++ b/README.md @@ -25,11 +25,21 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins ## Fork sha256 signing key fingerprint -`SHA256: B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D` +`B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D` This fork is my attempt to keep development moving forward and merge in PR's that have been sitting for a while in the main repo. Thankful to @CappielloAntonio for the amazing app and hopefully we can continue to build on top of it. I will only be releasing on github and if I am not able to merge back to the main repo, I plan to rename the app to be able to publish it to fdroid and possibly google play? We will see. -Moved details to [CHANGELOG.md](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md) +### Releases + +Please note the two variants in the release assets include release/debug and 32/64 bit flavors. + +`app-tempo` <- The github release with all the android auto/chromecast features + +`app-notquitemy*` <- The f-droid release that goes without any of the google stuff. It was last released at 3.8.1 from the original repo. Since I don't have access to that original repo, I am releasing the apk's here on github. + +As mentioned above, I am working towards a rebrand to get into app stores with a new name an icon. + +Moved details to [CHANGELOG.md](CHANGELOG.md) Fork [**sponsorship here**](https://ko-fi.com/eddyizm). @@ -49,13 +59,11 @@ Fork [**sponsorship here**](https://ko-fi.com/eddyizm). - **Podcasts and Radio**: If your Subsonic server supports it, listen to podcasts and radio shows directly within Tempo, expanding your audio entertainment options. - **Transcoding Support**: Activate transcoding of tracks on your Subsonic server, allowing you to set a transcoding profile for optimized streaming directly from the app. This feature requires support from your Subsonic server. - **Android Auto Support**: Enjoy your favorite music on the go with full Android Auto integration, allowing you to seamlessly control and listen to your tracks directly from your mobile device while driving. +- **Multiple Libraries**: Tempo handles multi-library setups gracefully. They are displayed as Library folders. -## Sponsors +## Credits Thanks to the original repo/creator [CappielloAntonio](https://github.com/CappielloAntonio) (3.9.0) -Tempo is an open-source project developed and maintained solely by me. I would like to express my heartfelt thanks to all the users who have shown their love and support for Tempo. Your contributions and encouragement mean a lot to me, and they help drive the development and improvement of the app. - - ## Screenshot

@@ -90,6 +98,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l

+## Contributing + +Please fork and open PR's against the development branch. Make sure your PR builds successfully. + +If there is an UI change, please include a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR. + +Currently there are no tests but I would love to start on some unit tests. + +Not a hard requirement but any new feature/change should ideally include an update to the nacent documention. + ## License Tempo is released under the [GNU General Public License v3.0](LICENSE). Feel free to modify, distribute, and use the app in accordance with the terms of the license. Contributions to the project are also welcome. diff --git a/USAGE.md b/USAGE.md index 7cf5c25b..9ce9496b 100644 --- a/USAGE.md +++ b/USAGE.md @@ -57,10 +57,30 @@ This app works with any service that implements the Subsonic API, including: ## Main Features ### Library View -**TODO** + +**Multi-library** + +Tempo handles multi-library setups gracefully. They are displayed as Library folders. + +However, if you want to limit or change libraries you could use a workaround, if your server supports it. + +You can create multiple users , one for each library, and save each of them in Tempo app. ### Now Playing Screen -**TODO** + +On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons. +

+ +

+ +*marked the icons with numbers for clarity* + +1. Downloads the track (there is a notification if the android screen but not a pop toast currently ) +2. Adds track to playlist - pops up playlist dialog. +3. Adds tracks to the queue via instant mix function +4. Saves play queue (if the feature is enabled in the settings) + * if the setting is not enabled, it toggles a view of the lyrics if available (slides to the right) + ## Navigation diff --git a/app/build.gradle b/app/build.gradle index 43bbc967..6d9e1884 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -10,8 +10,8 @@ android { minSdkVersion 24 targetSdk 35 - versionCode 34 - versionName '3.16.6' + versionCode 36 + versionName '3.17.14' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' javaCompileOptions { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 816683ca..b8d72d8b 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -42,6 +42,16 @@ + + + + + + + + { + private static final int DEFAULT_TIMEOUT_MS = 2500; + + @Override + public boolean handles(@NonNull String model) { + return model.startsWith("http://") || model.startsWith("https://"); + } + + @Override + public LoadData buildLoadData(@NonNull String model, int width, int height, @NonNull Options options) { + if (!handles(model)) { + return null; + } + return new LoadData<>(new ObjectKey(model), new IPv6StreamFetcher(model)); + } + + private static class IPv6StreamFetcher implements DataFetcher { + private final String model; + private InputStream stream; + private HttpURLConnection connection; + + IPv6StreamFetcher(String model) { + this.model = model; + } + + @Override + public void loadData(@NonNull Priority priority, @NonNull DataCallback callback) { + try { + URL url = new URL(model); + connection = (HttpURLConnection) url.openConnection(); + connection.setConnectTimeout(DEFAULT_TIMEOUT_MS); + connection.setReadTimeout(DEFAULT_TIMEOUT_MS); + connection.setUseCaches(true); + connection.setDoInput(true); + connection.connect(); + + if (connection.getResponseCode() / 100 != 2) { + callback.onLoadFailed(new IOException("Request failed with status code: " + connection.getResponseCode())); + return; + } + + stream = connection.getInputStream(); + callback.onDataReady(stream); + } catch (IOException e) { + callback.onLoadFailed(e); + } + } + + @Override + public void cleanup() { + if (stream != null) { + try { + stream.close(); + } catch (IOException ignored) { + } + } + if (connection != null) { + connection.disconnect(); + } + } + + @Override + public void cancel() { + // HttpURLConnection does not provide a direct cancel mechanism. + } + + @NonNull + @Override + public Class getDataClass() { + return InputStream.class; + } + + @NonNull + @Override + public DataSource getDataSource() { + return DataSource.REMOTE; + } + } + + public static class Factory implements ModelLoaderFactory { + @NonNull + @Override + public ModelLoader build(@NonNull MultiModelLoaderFactory multiFactory) { + return new IPv6StringLoader(); + } + + @Override + public void teardown() { + // No-op + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt index 18a77bfa..a3b142e2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Chronology.kt @@ -8,18 +8,18 @@ import androidx.room.PrimaryKey import com.cappielloantonio.tempo.subsonic.models.Child import com.cappielloantonio.tempo.util.Preferences import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize @Entity(tableName = "chronology") -class Chronology(@PrimaryKey override val id: String) : Child(id) { +class Chronology( + @PrimaryKey override val id: String, @ColumnInfo(name = "timestamp") - var timestamp: Long = System.currentTimeMillis() - + var timestamp: Long = System.currentTimeMillis(), @ColumnInfo(name = "server") - var server: String? = null - + var server: String? = null, +) : Child(id) { constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) { parentId = mediaItem.mediaMetadata.extras!!.getString("parentId") isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir") diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt index e5cc34b3..0c54e1cd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Download.kt @@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize @Entity(tableName = "download") -class Download(@PrimaryKey override val id: String) : Child(id) { +class Download( + @PrimaryKey override val id: String, @ColumnInfo(name = "playlist_id") - var playlistId: String? = null - + var playlistId: String? = null, @ColumnInfo(name = "playlist_name") - var playlistName: String? = null - + var playlistName: String? = null, @ColumnInfo(name = "download_state", defaultValue = "1") - var downloadState: Int = 0 - + var downloadState: Int = 0, @ColumnInfo(name = "download_uri", defaultValue = "") - var downloadUri: String? = null - + var downloadUri: String? = null, +) : Child(id) { constructor(child: Child) : this(child.id) { parentId = child.parentId isDir = child.isDir @@ -62,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) { @Keep data class DownloadStack( var id: String, - var view: String? + var view: String?, ) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt b/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt index ca2300c2..87840178 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/model/Queue.kt @@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize @Entity(tableName = "queue") -class Queue(override val id: String) : Child(id) { +class Queue( + override val id: String, @PrimaryKey @ColumnInfo(name = "track_order") - var trackOrder: Int = 0 - + var trackOrder: Int = 0, @ColumnInfo(name = "last_play") - var lastPlay: Long = 0 - + var lastPlay: Long = 0, @ColumnInfo(name = "playing_changed") - var playingChanged: Long = 0 - + var playingChanged: Long = 0, @ColumnInfo(name = "stream_id") - var streamId: String? = null - + var streamId: String? = null, +) : Child(id) { constructor(child: Child) : this(child.id) { parentId = child.parentId isDir = child.isDir diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java index 6dc8d3e3..bcc358b5 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/AlbumRepository.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.repository; import androidx.annotation.NonNull; import androidx.lifecycle.MutableLiveData; +import android.util.Log; import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.interfaces.DecadesCallback; @@ -31,14 +32,22 @@ public class AlbumRepository { .enqueue(new Callback() { @Override public void onResponse(@NonNull Call call, @NonNull Response response) { - if (response.isSuccessful() && response.body() != null && response.body().getSubsonicResponse().getAlbumList2() != null && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) { + if (response.isSuccessful() + && response.body() != null + && response.body().getSubsonicResponse().getAlbumList2() != null + && response.body().getSubsonicResponse().getAlbumList2().getAlbums() != null) { + listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums()); + } else { + Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code()); + listLiveAlbums.setValue(new ArrayList<>()); } } @Override public void onFailure(@NonNull Call call, @NonNull Throwable t) { - + Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage()); + listLiveAlbums.setValue(new ArrayList<>()); } }); diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java index f87cd577..66d0a185 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java @@ -80,6 +80,33 @@ public class PlaylistRepository { return listLivePlaylistSongs; } + public MutableLiveData getPlaylist(String id) { + MutableLiveData playlistLiveData = new MutableLiveData<>(); + + App.getSubsonicClientInstance(false) + .getPlaylistClient() + .getPlaylist(id) + .enqueue(new Callback() { + @Override + public void onResponse(@NonNull Call call, @NonNull Response response) { + if (response.isSuccessful() + && response.body() != null + && response.body().getSubsonicResponse().getPlaylist() != null) { + playlistLiveData.setValue(response.body().getSubsonicResponse().getPlaylist()); + } else { + playlistLiveData.setValue(null); + } + } + + @Override + public void onFailure(@NonNull Call call, @NonNull Throwable t) { + playlistLiveData.setValue(null); + } + }); + + return playlistLiveData; + } + public void addSongToPlaylist(String playlistId, ArrayList songsId) { if (songsId.isEmpty()) { Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show(); diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt index f05238ce..3f9868bd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/RetrofitClient.kt @@ -2,22 +2,28 @@ package com.cappielloantonio.tempo.subsonic import com.cappielloantonio.tempo.App import com.cappielloantonio.tempo.subsonic.utils.CacheUtil +import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter import com.google.gson.GsonBuilder import okhttp3.Cache import okhttp3.OkHttpClient import okhttp3.logging.HttpLoggingInterceptor import retrofit2.Retrofit import retrofit2.converter.gson.GsonConverterFactory +import java.util.Date import java.util.concurrent.TimeUnit class RetrofitClient(subsonic: Subsonic) { var retrofit: Retrofit init { + val gson = GsonBuilder() + .registerTypeAdapter(Date::class.java, EmptyDateTypeAdapter()) + .setLenient() + .create() + retrofit = Retrofit.Builder() .baseUrl(subsonic.url) - .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create())) - .addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create())) + .addConverterFactory(GsonConverterFactory.create(gson)) .client(getOkHttpClient()) .build() } diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt index 3072a3a4..95c67da2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumID3.kt @@ -4,38 +4,36 @@ import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize -import java.time.Instant -import java.time.LocalDate -import java.util.* +import java.util.Date @Keep @Parcelize -open class AlbumID3 : Parcelable { - var id: String? = null - var name: String? = null - var artist: String? = null - var artistId: String? = null +open class AlbumID3( + var id: String? = null, + var name: String? = null, + var artist: String? = null, + var artistId: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var songCount: Int? = 0 - var duration: Int? = 0 - var playCount: Long? = 0 - var created: Date? = null - var starred: Date? = null - var year: Int = 0 - var genre: String? = null - var played: Date? = Date(0) - var userRating: Int? = 0 - var recordLabels: List? = null - var musicBrainzId: String? = null - var genres: List? = null - var artists: List? = null - var displayArtist: String? = null - var releaseTypes: List? = null - var moods: List? = null - var sortName: String? = null - var originalReleaseDate: ItemDate? = null - var releaseDate: ItemDate? = null - var isCompilation: Boolean? = null - var discTitles: List? = null -} \ No newline at end of file + var coverArtId: String? = null, + var songCount: Int? = 0, + var duration: Int? = 0, + var playCount: Long? = 0, + var created: Date? = null, + var starred: Date? = null, + var year: Int = 0, + var genre: String? = null, + var played: Date? = Date(0), + var userRating: Int? = 0, + var recordLabels: List? = null, + var musicBrainzId: String? = null, + var genres: List? = null, + var artists: List? = null, + var displayArtist: String? = null, + var releaseTypes: List? = null, + var moods: List? = null, + var sortName: String? = null, + var originalReleaseDate: ItemDate? = null, + var releaseDate: ItemDate? = null, + var isCompilation: Boolean? = null, + var discTitles: List? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt index 79c56092..8498e777 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/AlbumWithSongsID3.kt @@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class AlbumWithSongsID3 : AlbumID3(), Parcelable { +class AlbumWithSongsID3( @SerializedName("song") - var songs: List? = null -} \ No newline at end of file + var songs: List? = null, +) : AlbumID3(), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt index a13d169d..22aa527b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Artist.kt @@ -7,10 +7,10 @@ import java.util.Date @Keep @Parcelize -class Artist : Parcelable { - var id: String? = null - var name: String? = null - var starred: Date? = null - var userRating: Int? = null - var averageRating: Double? = null -} \ No newline at end of file +class Artist( + var id: String? = null, + var name: String? = null, + var starred: Date? = null, + var userRating: Int? = null, + var averageRating: Double? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt index a17f4aa3..ccf4ee7e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistID3.kt @@ -4,15 +4,15 @@ import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize -open class ArtistID3 : Parcelable { - var id: String? = null - var name: String? = null +open class ArtistID3( + var id: String? = null, + var name: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var albumCount = 0 - var starred: Date? = null -} \ No newline at end of file + var coverArtId: String? = null, + var albumCount: Int = 0, + var starred: Date? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt index c22c8207..2e21e111 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ArtistWithAlbumsID3.kt @@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class ArtistWithAlbumsID3 : ArtistID3(), Parcelable { +class ArtistWithAlbumsID3( @SerializedName("album") - var albums: List? = null -} \ No newline at end of file + var albums: List? = null, +) : ArtistID3(), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt index 45395ede..f189589e 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Directory.kt @@ -8,15 +8,15 @@ import java.util.Date @Keep @Parcelize -class Directory : Parcelable { +class Directory( @SerializedName("child") - var children: List? = null - var id: String? = null + var children: List? = null, + var id: String? = null, @SerializedName("parent") - var parentId: String? = null - var name: String? = null - var starred: Date? = null - var userRating: Int? = null - var averageRating: Double? = null - var playCount: Long? = null -} \ No newline at end of file + var parentId: String? = null, + var name: String? = null, + var starred: Date? = null, + var userRating: Int? = null, + var averageRating: Double? = null, + var playCount: Long? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt index 2910d4bf..32caa8cc 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/DiscTitle.kt @@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -open class DiscTitle : Parcelable { - var disc: Int? = null - var title: String? = null -} \ No newline at end of file +open class DiscTitle( + var disc: Int? = null, + var title: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt index cb1b7719..1db88c40 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Genre.kt @@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class Genre : Parcelable { +class Genre( @SerializedName("value") - var genre: String? = null - var songCount = 0 - var albumCount = 0 -} \ No newline at end of file + var genre: String? = null, + var songCount: Int = 0, + var albumCount: Int = 0, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt index 0d312ce0..07f00c5b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/InternetRadioStation.kt @@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class InternetRadioStation : Parcelable { - var id: String? = null - var name: String? = null - var streamUrl: String? = null - var homePageUrl: String? = null -} \ No newline at end of file +class InternetRadioStation( + var id: String? = null, + var name: String? = null, + var streamUrl: String? = null, + var homePageUrl: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt index 5de2fbc6..385b7fd7 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemDate.kt @@ -9,11 +9,11 @@ import java.util.Locale @Keep @Parcelize -open class ItemDate : Parcelable { - var year: Int? = null - var month: Int? = null - var day: Int? = null - +open class ItemDate( + var year: Int? = null, + var month: Int? = null, + var day: Int? = null, +) : Parcelable { fun getFormattedDate(): String? { if (year == null && month == null && day == null) return null @@ -22,8 +22,7 @@ open class ItemDate : Parcelable { SimpleDateFormat("yyyy", Locale.getDefault()) } else if (day == null) { SimpleDateFormat("MMMM yyyy", Locale.getDefault()) - } - else{ + } else { SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) } diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt index ce164fb6..971809ff 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/ItemGenre.kt @@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -open class ItemGenre : Parcelable { - var name: String? = null -} \ No newline at end of file +open class ItemGenre( + var name: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt index 7c277df4..e31bf7c8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/MusicFolder.kt @@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize @Keep @Parcelize -class MusicFolder : Parcelable { - var id: String? = null - var name: String? = null -} \ No newline at end of file +class MusicFolder( + var id: String? = null, + var name: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt index e5391872..bd69808c 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/NowPlayingEntry.kt @@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize @Parcelize class NowPlayingEntry( @SerializedName("_id") - override val id: String -) : Child(id) { - var username: String? = null - var minutesAgo = 0 - var playerId = 0 - var playerName: String? = null -} \ No newline at end of file + override val id: String, + var username: String? = null, + var minutesAgo: Int = 0, + var playerId: Int = 0, + var playerName: String? = null, +) : Child(id) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt index 2d85271e..926c2390 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlist.kt @@ -7,8 +7,9 @@ import androidx.room.Entity import androidx.room.Ignore import androidx.room.PrimaryKey import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize @@ -16,27 +17,56 @@ import java.util.* open class Playlist( @PrimaryKey @ColumnInfo(name = "id") - open var id: String -) : Parcelable { + open var id: String, @ColumnInfo(name = "name") - var name: String? = null + var name: String? = null, + @ColumnInfo(name = "duration") + var duration: Long = 0, + @ColumnInfo(name = "coverArt") + var coverArtId: String? = null, +) : Parcelable { @Ignore + @IgnoredOnParcel var comment: String? = null @Ignore + @IgnoredOnParcel var owner: String? = null @Ignore + @IgnoredOnParcel @SerializedName("public") var isUniversal: Boolean? = null @Ignore + @IgnoredOnParcel var songCount: Int = 0 - @ColumnInfo(name = "duration") - var duration: Long = 0 @Ignore + @IgnoredOnParcel var created: Date? = null @Ignore + @IgnoredOnParcel var changed: Date? = null - @ColumnInfo(name = "coverArt") - var coverArtId: String? = null @Ignore + @IgnoredOnParcel var allowedUsers: List? = null + @Ignore + constructor( + id: String, + name: String?, + comment: String?, + owner: String?, + isUniversal: Boolean?, + songCount: Int, + duration: Long, + created: Date?, + changed: Date?, + coverArtId: String?, + allowedUsers: List?, + ) : this(id, name, duration, coverArtId) { + this.comment = comment + this.owner = owner + this.isUniversal = isUniversal + this.songCount = songCount + this.created = created + this.changed = changed + this.allowedUsers = allowedUsers + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt index 92e194c8..350dcbd4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PlaylistWithSongs.kt @@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize @Parcelize class PlaylistWithSongs( @SerializedName("_id") - override var id: String -) : Playlist(id), Parcelable { + override var id: String, @SerializedName("entry") - var entries: List? = null -} \ No newline at end of file + var entries: List? = null, +) : Playlist(id), Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt index 8aa271a4..34079c76 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Playlists.kt @@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName @Keep class Playlists( @SerializedName("playlist") - var playlists: List? = null + var playlists: List? = null, ) \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt index 088ed97e..b4d124ff 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastChannel.kt @@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Keep @Parcelize -class PodcastChannel : Parcelable { +class PodcastChannel( @SerializedName("episode") - var episodes: List? = null - var id: String? = null - var url: String? = null - var title: String? = null - var description: String? = null + var episodes: List? = null, + var id: String? = null, + var url: String? = null, + var title: String? = null, + var description: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var originalImageUrl: String? = null - var status: String? = null - var errorMessage: String? = null -} \ No newline at end of file + var coverArtId: String? = null, + var originalImageUrl: String? = null, + var status: String? = null, + var errorMessage: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt index f8893224..fc3fab21 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/PodcastEpisode.kt @@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize import java.util.* @Keep @Parcelize -class PodcastEpisode : Parcelable { - var id: String? = null +class PodcastEpisode( + var id: String? = null, @SerializedName("parent") - var parentId: String? = null - var isDir = false - var title: String? = null - var album: String? = null - var artist: String? = null - var year: Int? = null - var genre: String? = null + var parentId: String? = null, + var isDir: Boolean = false, + var title: String? = null, + var album: String? = null, + var artist: String? = null, + var year: Int? = null, + var genre: String? = null, @SerializedName("coverArt") - var coverArtId: String? = null - var size: Long? = null - var contentType: String? = null - var suffix: String? = null - var duration: Int? = null + var coverArtId: String? = null, + var size: Long? = null, + var contentType: String? = null, + var suffix: String? = null, + var duration: Int? = null, @SerializedName("bitRate") - var bitrate: Int? = null - var path: String? = null - var isVideo: Boolean = false - var created: Date? = null - var artistId: String? = null - var type: String? = null - var streamId: String? = null - var channelId: String? = null - var description: String? = null - var status: String? = null - var publishDate: Date? = null -} \ No newline at end of file + var bitrate: Int? = null, + var path: String? = null, + var isVideo: Boolean = false, + var created: Date? = null, + var artistId: String? = null, + var type: String? = null, + var streamId: String? = null, + var channelId: String? = null, + var description: String? = null, + var status: String? = null, + var publishDate: Date? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt index 687f3fd7..52531d90 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/RecordLabel.kt @@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize @Keep @Parcelize -open class RecordLabel : Parcelable { - var name: String? = null -} \ No newline at end of file +open class RecordLabel( + var name: String? = null, +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt index 193cefc8..986e4b50 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/models/Share.kt @@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models import android.os.Parcelable import androidx.annotation.Keep import com.google.gson.annotations.SerializedName +import kotlinx.parcelize.IgnoredOnParcel import kotlinx.parcelize.Parcelize -import java.util.* +import java.util.Date @Keep @Parcelize -class Share : Parcelable { +data class Share( @SerializedName("entry") - var entries: List? = null - var id: String? = null - var url: String? = null - var description: String? = null - var username: String? = null - var created: Date? = null - var expires: Date? = null - var lastVisited: Date? = null - var visitCount = 0 -} \ No newline at end of file + var entries: List? = null, + var id: String? = null, + var url: String? = null, + var description: String? = null, + var username: String? = null, + var created: Date? = null, + var expires: Date? = null, + var lastVisited: Date? = null, + var visitCount: Int = 0 +) : Parcelable \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java index 047b0010..69dc9dd4 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/CacheUtil.java @@ -38,21 +38,36 @@ public class CacheUtil { return chain.proceed(request); }; + private boolean isConnected() { ConnectivityManager connectivityManager = (ConnectivityManager) App.getContext().getSystemService(Context.CONNECTIVITY_SERVICE); - - if (connectivityManager != null) { - Network network = connectivityManager.getActiveNetwork(); - - if (network != null) { - NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network); - - if (capabilities != null) { - return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); - } - } + if (connectivityManager == null) { + return false; } - return false; + Network network = connectivityManager.getActiveNetwork(); + if (network == null) { + return false; + } + + NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network); + if (capabilities == null) { + return false; + } + + boolean hasInternet = capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET); + if (!hasInternet) { + return false; + } + + boolean hasAppropriateTransport = capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) + || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_ETHERNET); + if (!hasAppropriateTransport) { + return false; + } + + return true; } + } diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/EmptyDateTypeAdapter.kt b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/EmptyDateTypeAdapter.kt new file mode 100644 index 00000000..bcdd5ee8 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/utils/EmptyDateTypeAdapter.kt @@ -0,0 +1,42 @@ +package com.cappielloantonio.tempo.subsonic.utils + +import com.google.gson.JsonDeserializationContext +import com.google.gson.JsonDeserializer +import com.google.gson.JsonElement +import com.google.gson.JsonParseException +import java.lang.reflect.Type +import java.text.ParseException +import java.text.SimpleDateFormat +import java.util.Date +import java.util.Locale +import java.util.TimeZone + +// This adapter handles Date objects, returning null if the JSON string is empty or unparsable. +class EmptyDateTypeAdapter : JsonDeserializer { + + // Define the date formats expected from the Subsonic server. + private val dateFormats: List = listOf( + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }, + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss'Z'", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") }, + SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss", Locale.US).apply { timeZone = TimeZone.getTimeZone("UTC") } + ) + + @Throws(JsonParseException::class) + override fun deserialize(json: JsonElement, typeOfT: Type, context: JsonDeserializationContext): Date? { + val jsonString = json.asString.trim() + + if (jsonString.isEmpty()) { + return null + } + + for (format in dateFormats) { + try { + return format.parse(jsonString) + } catch (e: ParseException) { + // Ignore and try the next format + } + } + + return null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java index db76b98d..9aa558f8 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java @@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog; import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog; import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog; import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment; +import com.cappielloantonio.tempo.util.AssetLinkNavigator; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.MainViewModel; @@ -60,6 +62,8 @@ public class MainActivity extends BaseActivity { private BottomNavigationView bottomNavigationView; public NavController navController; private BottomSheetBehavior bottomSheetBehavior; + private AssetLinkNavigator assetLinkNavigator; + private AssetLinkUtil.AssetLink pendingAssetLink; ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver; private Intent pendingDownloadPlaybackIntent; @@ -76,6 +80,7 @@ public class MainActivity extends BaseActivity { setContentView(view); mainViewModel = new ViewModelProvider(this).get(MainViewModel.class); + assetLinkNavigator = new AssetLinkNavigator(this); connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this); connectivityStatusReceiverManager(true); @@ -311,6 +316,24 @@ public class MainActivity extends BaseActivity { public void goFromLogin() { setBottomSheetInPeek(mainViewModel.isQueueLoaded()); goToHome(); + consumePendingAssetLink(); + } + + public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink) { + openAssetLink(assetLink, true); + } + + public void openAssetLink(@NonNull AssetLinkUtil.AssetLink assetLink, boolean collapsePlayer) { + if (!isUserAuthenticated()) { + pendingAssetLink = assetLink; + return; + } + if (collapsePlayer) { + setBottomSheetInPeek(true); + } + if (assetLinkNavigator != null) { + assetLinkNavigator.open(assetLink); + } } public void quit() { @@ -443,6 +466,7 @@ public class MainActivity extends BaseActivity { || intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) { pendingDownloadPlaybackIntent = new Intent(intent); } + handleAssetLinkIntent(intent); } private void consumePendingPlaybackIntent() { @@ -452,6 +476,35 @@ public class MainActivity extends BaseActivity { playDownloadedMedia(intent); } + private void handleAssetLinkIntent(Intent intent) { + AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.parse(intent); + if (assetLink == null) { + return; + } + if (!isUserAuthenticated()) { + pendingAssetLink = assetLink; + intent.setData(null); + return; + } + if (assetLinkNavigator != null) { + assetLinkNavigator.open(assetLink); + } + intent.setData(null); + } + + private boolean isUserAuthenticated() { + return Preferences.getPassword() != null + || (Preferences.getToken() != null && Preferences.getSalt() != null); + } + + private void consumePendingAssetLink() { + if (pendingAssetLink == null || assetLinkNavigator == null) { + return; + } + assetLinkNavigator.open(pendingAssetLink); + pendingAssetLink = null; + } + private void playDownloadedMedia(Intent intent) { String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI); if (TextUtils.isEmpty(uriString)) { @@ -500,4 +553,4 @@ public class MainActivity extends BaseActivity { MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java index 1f360c80..69583a40 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/AlbumCatalogueAdapter.java @@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil; import java.util.ArrayList; import java.util.Collections; import java.util.Comparator; +import java.util.Date; import java.util.List; public class AlbumCatalogueAdapter extends RecyclerView.Adapter implements Filterable { @@ -152,12 +153,20 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter album.getName() != null ? album.getName() : "", + String.CASE_INSENSITIVE_ORDER + )); break; case Constants.ALBUM_ORDER_BY_ARTIST: - albums.sort(Comparator.comparing(AlbumID3::getArtist, Comparator.nullsLast(Comparator.naturalOrder()))); + albums.sort(Comparator.comparing( + album -> album.getArtist() != null ? album.getArtist() : "", + String.CASE_INSENSITIVE_ORDER + )); break; case Constants.ALBUM_ORDER_BY_YEAR: albums.sort(Comparator.comparing(AlbumID3::getYear)); @@ -166,15 +175,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter album.getCreated() != null ? album.getCreated() : new Date(0), + Comparator.nullsLast(Date::compareTo) + )); Collections.reverse(albums); break; case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED: - albums.sort(Comparator.comparing(AlbumID3::getPlayed)); + albums.sort(Comparator.comparing( + album -> album.getPlayed() != null ? album.getPlayed() : new Date(0), + Comparator.nullsLast(Date::compareTo) + )); Collections.reverse(albums); break; case Constants.ALBUM_ORDER_BY_MOST_PLAYED: - albums.sort(Comparator.comparing(AlbumID3::getPlayCount)); + albums.sort(Comparator.comparing( + album -> album.getPlayCount() != null ? album.getPlayCount() : 0L + )); Collections.reverse(albums); break; } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java index f866c250..e6b91f01 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/TrackInfoDialog.java @@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.dialog; import android.app.Dialog; import android.os.Bundle; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.fragment.app.DialogFragment; @@ -10,6 +11,7 @@ import androidx.media3.common.MediaMetadata; import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding; import com.cappielloantonio.tempo.glide.CustomGlideRequest; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; @@ -21,6 +23,11 @@ public class TrackInfoDialog extends DialogFragment { private DialogTrackInfoBinding bind; private final MediaMetadata mediaMetadata; + private AssetLinkUtil.AssetLink songLink; + private AssetLinkUtil.AssetLink albumLink; + private AssetLinkUtil.AssetLink artistLink; + private AssetLinkUtil.AssetLink genreLink; + private AssetLinkUtil.AssetLink yearLink; public TrackInfoDialog(MediaMetadata mediaMetadata) { this.mediaMetadata = mediaMetadata; @@ -52,6 +59,8 @@ public class TrackInfoDialog extends DialogFragment { } private void setTrackInfo() { + genreLink = null; + yearLink = null; bind.trakTitleInfoTextView.setText(mediaMetadata.title); bind.trakArtistInfoTextView.setText( mediaMetadata.artist != null @@ -61,17 +70,41 @@ public class TrackInfoDialog extends DialogFragment { : ""); if (mediaMetadata.extras != null) { + songLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_SONG, mediaMetadata.extras.getString("id")); + albumLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ALBUM, mediaMetadata.extras.getString("albumId")); + artistLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, mediaMetadata.extras.getString("artistId")); + genreLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkGenre")); + yearLink = AssetLinkUtil.parseLinkString(mediaMetadata.extras.getString("assetLinkYear")); + CustomGlideRequest.Builder .from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song) .build() .into(bind.trackCoverInfoImageView); - bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder))); - bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder))); - bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder))); + bindAssetLink(bind.trackCoverInfoImageView, albumLink != null ? albumLink : songLink); + bindAssetLink(bind.trakTitleInfoTextView, songLink); + bindAssetLink(bind.trakArtistInfoTextView, artistLink != null ? artistLink : songLink); + + String titleValue = mediaMetadata.extras.getString("title", getString(R.string.label_placeholder)); + String albumValue = mediaMetadata.extras.getString("album", getString(R.string.label_placeholder)); + String artistValue = mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder)); + String genreValue = mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder)); + int yearValue = mediaMetadata.extras.getInt("year", 0); + + if (genreLink == null && genreValue != null && !genreValue.isEmpty() && !getString(R.string.label_placeholder).contentEquals(genreValue)) { + genreLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_GENRE, genreValue); + } + + if (yearLink == null && yearValue != 0) { + yearLink = AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(yearValue)); + } + + bind.titleValueSector.setText(titleValue); + bind.albumValueSector.setText(albumValue); + bind.artistValueSector.setText(artistValue); bind.trackNumberValueSector.setText(mediaMetadata.extras.getInt("track", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("track", 0)) : getString(R.string.label_placeholder)); - bind.yearValueSector.setText(mediaMetadata.extras.getInt("year", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("year", 0)) : getString(R.string.label_placeholder)); - bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", getString(R.string.label_placeholder))); + bind.yearValueSector.setText(yearValue != 0 ? String.valueOf(yearValue) : getString(R.string.label_placeholder)); + bind.genreValueSector.setText(genreValue); bind.sizeValueSector.setText(mediaMetadata.extras.getLong("size", 0) != 0 ? MusicUtil.getReadableByteCount(mediaMetadata.extras.getLong("size", 0)) : getString(R.string.label_placeholder)); bind.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder))); bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", getString(R.string.label_placeholder))); @@ -83,6 +116,12 @@ public class TrackInfoDialog extends DialogFragment { bind.bitDepthValueSector.setText(mediaMetadata.extras.getInt("bitDepth", 0) != 0 ? mediaMetadata.extras.getInt("bitDepth", 0) + " bits" : getString(R.string.label_placeholder)); bind.pathValueSector.setText(mediaMetadata.extras.getString("path", getString(R.string.label_placeholder))); bind.discNumberValueSector.setText(mediaMetadata.extras.getInt("discNumber", 0) != 0 ? String.valueOf(mediaMetadata.extras.getInt("discNumber", 0)) : getString(R.string.label_placeholder)); + + bindAssetLink(bind.titleValueSector, songLink); + bindAssetLink(bind.albumValueSector, albumLink); + bindAssetLink(bind.artistValueSector, artistLink); + bindAssetLink(bind.genreValueSector, genreLink); + bindAssetLink(bind.yearValueSector, yearLink); } } @@ -135,4 +174,31 @@ public class TrackInfoDialog extends DialogFragment { bind.trakTranscodingInfoTextView.setText(info); } } + + private void bindAssetLink(android.view.View view, AssetLinkUtil.AssetLink assetLink) { + if (view == null) return; + if (assetLink == null) { + AssetLinkUtil.clearLinkAppearance(view); + view.setOnClickListener(null); + view.setOnLongClickListener(null); + view.setClickable(false); + view.setLongClickable(false); + return; + } + + view.setClickable(true); + view.setLongClickable(true); + AssetLinkUtil.applyLinkAppearance(view); + view.setOnClickListener(v -> { + dismissAllowingStateLoss(); + boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type); + ((com.cappielloantonio.tempo.ui.activity.MainActivity) requireActivity()).openAssetLink(assetLink, collapse); + }); + view.setOnLongClickListener(v -> { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show(); + return true; + }); + } + } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java index bb62e939..4061cccd 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumCatalogueFragment.java @@ -32,17 +32,19 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; import com.cappielloantonio.tempo.util.Constants; +import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel; @OptIn(markerClass = UnstableApi.class) public class AlbumCatalogueFragment extends Fragment implements ClickCallback { - private static final String TAG = "ArtistCatalogueFragment"; + private static final String TAG = "AlbumCatalogueFragment"; private FragmentAlbumCatalogueBinding bind; private MainActivity activity; private AlbumCatalogueViewModel albumCatalogueViewModel; private AlbumCatalogueAdapter albumAdapter; + private String currentSortOrder; @Override public void onCreate(@Nullable Bundle savedInstanceState) { @@ -115,7 +117,10 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback { albumAdapter = new AlbumCatalogueAdapter(this, true); albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); bind.albumCatalogueRecyclerView.setAdapter(albumAdapter); - albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> albumAdapter.setItems(albums)); + albumCatalogueViewModel.getAlbumList().observe(getViewLifecycleOwner(), albums -> { + albumAdapter.setItems(albums); + applySavedSortOrder(); + }); bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> { hideKeyboard(v); @@ -137,6 +142,44 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback { }); } + private void applySavedSortOrder() { + String savedSortOrder = Preferences.getAlbumSortOrder(); + currentSortOrder = savedSortOrder; + albumAdapter.sort(savedSortOrder); + updateSortIndicator(); + } + + private void updateSortIndicator() { + if (bind == null) return; + + String sortText = getSortDisplayText(currentSortOrder); + bind.albumListSortTextView.setText(sortText); + bind.albumListSortTextView.setVisibility(View.VISIBLE); + } + + private String getSortDisplayText(String sortOrder) { + if (sortOrder == null) return ""; + + switch (sortOrder) { + case Constants.ALBUM_ORDER_BY_NAME: + return getString(R.string.menu_sort_name); + case Constants.ALBUM_ORDER_BY_ARTIST: + return getString(R.string.menu_group_by_artist); + case Constants.ALBUM_ORDER_BY_YEAR: + return getString(R.string.menu_sort_year); + case Constants.ALBUM_ORDER_BY_RANDOM: + return getString(R.string.menu_sort_random); + case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED: + return getString(R.string.menu_sort_recently_added); + case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED: + return getString(R.string.menu_sort_recently_played); + case Constants.ALBUM_ORDER_BY_MOST_PLAYED: + return getString(R.string.menu_sort_most_played); + default: + return ""; + } + } + @Override public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { inflater.inflate(R.menu.toolbar_menu, menu); @@ -172,26 +215,29 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback { popup.getMenuInflater().inflate(menuResource, popup.getMenu()); popup.setOnMenuItemClickListener(menuItem -> { + String newSortOrder = null; + if (menuItem.getItemId() == R.id.menu_album_sort_name) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_NAME); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_NAME; } else if (menuItem.getItemId() == R.id.menu_album_sort_artist) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_ARTIST); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_ARTIST; } else if (menuItem.getItemId() == R.id.menu_album_sort_year) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_YEAR); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_YEAR; } else if (menuItem.getItemId() == R.id.menu_album_sort_random) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_RANDOM); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_RANDOM; } else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_ADDED); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_ADDED; } else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED); - return true; + newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED; } else if (menuItem.getItemId() == R.id.menu_album_sort_most_played) { - albumAdapter.sort(Constants.ALBUM_ORDER_BY_MOST_PLAYED); + newSortOrder = Constants.ALBUM_ORDER_BY_MOST_PLAYED; + } + + if (newSortOrder != null) { + currentSortOrder = newSortOrder; + albumAdapter.sort(newSortOrder); + Preferences.setAlbumSortOrder(newSortOrder); + updateSortIndicator(); return true; } diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java index d8fe82cc..9bf95803 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java @@ -35,6 +35,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; @@ -177,8 +178,35 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { bind.albumNameLabel.setText(album.getName()); bind.albumArtistLabel.setText(album.getArtist()); + AssetLinkUtil.applyLinkAppearance(bind.albumArtistLabel); + AssetLinkUtil.AssetLink artistLink = buildArtistLink(album); + bind.albumArtistLabel.setOnLongClickListener(v -> { + if (artistLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), artistLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, artistLink.id), Toast.LENGTH_SHORT).show(); + return true; + } + return false; + }); bind.albumReleaseYearLabel.setText(album.getYear() != 0 ? String.valueOf(album.getYear()) : ""); - bind.albumReleaseYearLabel.setVisibility(album.getYear() != 0 ? View.VISIBLE : View.GONE); + if (album.getYear() != 0) { + bind.albumReleaseYearLabel.setVisibility(View.VISIBLE); + AssetLinkUtil.applyLinkAppearance(bind.albumReleaseYearLabel); + bind.albumReleaseYearLabel.setOnClickListener(v -> openYearLink(album.getYear())); + bind.albumReleaseYearLabel.setOnLongClickListener(v -> { + AssetLinkUtil.AssetLink yearLink = buildYearLink(album.getYear()); + if (yearLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), yearLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, yearLink.id), Toast.LENGTH_SHORT).show(); + } + return true; + }); + } else { + bind.albumReleaseYearLabel.setVisibility(View.GONE); + bind.albumReleaseYearLabel.setOnClickListener(null); + bind.albumReleaseYearLabel.setOnLongClickListener(null); + AssetLinkUtil.clearLinkAppearance(bind.albumReleaseYearLabel); + } bind.albumSongCountDurationTextview.setText(getString(R.string.album_page_tracks_count_and_duration, album.getSongCount(), album.getDuration() != null ? album.getDuration() / 60 : 0)); if (album.getGenre() != null && !album.getGenre().isEmpty()) { bind.albumGenresTextview.setText(album.getGenre()); @@ -351,4 +379,23 @@ public class AlbumPageFragment extends Fragment implements ClickCallback { private void setMediaBrowserListenableFuture() { songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); } -} \ No newline at end of file + + private void openYearLink(int year) { + AssetLinkUtil.AssetLink link = buildYearLink(year); + if (link != null) { + activity.openAssetLink(link); + } + } + + private AssetLinkUtil.AssetLink buildYearLink(int year) { + if (year <= 0) return null; + return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_YEAR, String.valueOf(year)); + } + + private AssetLinkUtil.AssetLink buildArtistLink(AlbumID3 album) { + if (album == null || album.getArtistId() == null || album.getArtistId().isEmpty()) { + return null; + } + return AssetLinkUtil.buildAssetLink(AssetLinkUtil.TYPE_ARTIST, album.getArtistId()); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java index 6841f247..e2bca343 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerBottomSheetFragment.java @@ -195,6 +195,7 @@ public class PlayerBottomSheetFragment extends Fragment { } } + private void setMediaControllerUI(MediaBrowser mediaBrowser) { if (mediaBrowser.getMediaMetadata().extras != null) { switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) { diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java index e63436c7..e3155b56 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerControllerFragment.java @@ -13,9 +13,10 @@ import android.view.ViewGroup; import android.widget.Button; import android.widget.ImageButton; import android.widget.LinearLayout; +import android.widget.RatingBar; import android.widget.TextView; import android.widget.ToggleButton; -import android.widget.RatingBar; +import android.widget.Toast; import androidx.annotation.NonNull; import androidx.constraintlayout.widget.ConstraintLayout; @@ -41,12 +42,14 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog; import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.RatingViewModel; import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; import com.google.android.material.elevation.SurfaceColors; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -76,6 +79,10 @@ public class PlayerControllerFragment extends Fragment { private ImageButton playerTrackInfo; private LinearLayout ratingContainer; private ImageButton equalizerButton; + private ChipGroup assetLinkChipGroup; + private Chip playerSongLinkChip; + private Chip playerAlbumLinkChip; + private Chip playerArtistLinkChip; private MainActivity activity; private PlayerBottomSheetViewModel playerBottomSheetViewModel; @@ -139,6 +146,10 @@ public class PlayerControllerFragment extends Fragment { songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); ratingContainer = bind.getRoot().findViewById(R.id.rating_container); equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); + assetLinkChipGroup = bind.getRoot().findViewById(R.id.asset_link_chip_group); + playerSongLinkChip = bind.getRoot().findViewById(R.id.asset_link_song_chip); + playerAlbumLinkChip = bind.getRoot().findViewById(R.id.asset_link_album_chip); + playerArtistLinkChip = bind.getRoot().findViewById(R.id.asset_link_artist_chip); checkAndSetRatingContainerVisibility(); } @@ -219,6 +230,8 @@ public class PlayerControllerFragment extends Fragment { || mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null ? View.VISIBLE : View.GONE); + + updateAssetLinkChips(mediaMetadata); } private void setMediaInfo(MediaMetadata mediaMetadata) { @@ -259,6 +272,110 @@ public class PlayerControllerFragment extends Fragment { }); } + private void updateAssetLinkChips(MediaMetadata mediaMetadata) { + if (assetLinkChipGroup == null) return; + String mediaType = mediaMetadata.extras != null ? mediaMetadata.extras.getString("type", Constants.MEDIA_TYPE_MUSIC) : Constants.MEDIA_TYPE_MUSIC; + if (!Constants.MEDIA_TYPE_MUSIC.equals(mediaType)) { + clearAssetLinkChip(playerSongLinkChip); + clearAssetLinkChip(playerAlbumLinkChip); + clearAssetLinkChip(playerArtistLinkChip); + syncAssetLinkGroupVisibility(); + return; + } + + String songId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("id") : null; + String albumId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("albumId") : null; + String artistId = mediaMetadata.extras != null ? mediaMetadata.extras.getString("artistId") : null; + + AssetLinkUtil.AssetLink songLink = bindAssetLinkChip(playerSongLinkChip, AssetLinkUtil.TYPE_SONG, songId); + AssetLinkUtil.AssetLink albumLink = bindAssetLinkChip(playerAlbumLinkChip, AssetLinkUtil.TYPE_ALBUM, albumId); + AssetLinkUtil.AssetLink artistLink = bindAssetLinkChip(playerArtistLinkChip, AssetLinkUtil.TYPE_ARTIST, artistId); + bindAssetLinkView(playerMediaTitleLabel, songLink); + bindAssetLinkView(playerArtistNameLabel, artistLink != null ? artistLink : songLink); + bindAssetLinkView(playerMediaCoverViewPager, songLink); + syncAssetLinkGroupVisibility(); + } + + private AssetLinkUtil.AssetLink bindAssetLinkChip(Chip chip, String type, String id) { + if (chip == null) return null; + if (TextUtils.isEmpty(id)) { + clearAssetLinkChip(chip); + return null; + } + + String label = getString(AssetLinkUtil.getLabelRes(type)); + AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id); + if (assetLink == null) { + clearAssetLinkChip(chip); + return null; + } + + chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id)); + chip.setVisibility(View.VISIBLE); + + chip.setOnClickListener(v -> { + if (assetLink != null) { + activity.openAssetLink(assetLink); + } + }); + + chip.setOnLongClickListener(v -> { + if (assetLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show(); + } + return true; + }); + + return assetLink; + } + + private void clearAssetLinkChip(Chip chip) { + if (chip == null) return; + chip.setVisibility(View.GONE); + chip.setText(""); + chip.setOnClickListener(null); + chip.setOnLongClickListener(null); + } + + private void bindAssetLinkView(View view, AssetLinkUtil.AssetLink assetLink) { + if (view == null) return; + if (assetLink == null) { + AssetLinkUtil.clearLinkAppearance(view); + view.setOnClickListener(null); + view.setOnLongClickListener(null); + view.setClickable(false); + view.setLongClickable(false); + return; + } + + view.setClickable(true); + view.setLongClickable(true); + AssetLinkUtil.applyLinkAppearance(view); + view.setOnClickListener(v -> { + boolean collapse = !AssetLinkUtil.TYPE_SONG.equals(assetLink.type); + activity.openAssetLink(assetLink, collapse); + }); + view.setOnLongClickListener(v -> { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show(); + return true; + }); + } + + private void syncAssetLinkGroupVisibility() { + if (assetLinkChipGroup == null) return; + boolean hasVisible = false; + for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) { + View child = assetLinkChipGroup.getChildAt(i); + if (child.getVisibility() == View.VISIBLE) { + hasVisible = true; + break; + } + } + assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE); + } + private void setMediaControllerUI(MediaBrowser mediaBrowser) { initPlaybackSpeedButton(mediaBrowser); @@ -548,4 +665,4 @@ public class PlayerControllerFragment extends Fragment { isServiceBound = false; } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java index 90e32793..39ba4394 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java +++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java @@ -30,6 +30,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.ExternalAudioReader; @@ -39,6 +40,8 @@ import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel; import com.google.android.material.bottomsheet.BottomSheetDialogFragment; +import com.google.android.material.chip.Chip; +import com.google.android.material.chip.ChipGroup; import com.google.common.util.concurrent.ListenableFuture; import android.content.Intent; @@ -56,6 +59,13 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private TextView downloadButton; private TextView removeButton; + private ChipGroup assetLinkChipGroup; + private Chip songLinkChip; + private Chip albumLinkChip; + private Chip artistLinkChip; + private AssetLinkUtil.AssetLink currentSongLink; + private AssetLinkUtil.AssetLink currentAlbumLink; + private AssetLinkUtil.AssetLink currentArtistLink; private ListenableFuture mediaBrowserListenableFuture; @@ -109,6 +119,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements TextView artistSong = view.findViewById(R.id.song_artist_text_view); artistSong.setText(songBottomSheetViewModel.getSong().getArtist()); + initAssetLinkChips(view); + bindAssetLinkView(coverSong, currentSongLink); + bindAssetLinkView(titleSong, currentSongLink); + bindAssetLinkView(artistSong, currentArtistLink != null ? currentArtistLink : currentSongLink); + ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite); favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null); favoriteToggle.setOnClickListener(v -> { @@ -282,6 +297,95 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements } } + private void initAssetLinkChips(View root) { + assetLinkChipGroup = root.findViewById(R.id.asset_link_chip_group); + songLinkChip = root.findViewById(R.id.asset_link_song_chip); + albumLinkChip = root.findViewById(R.id.asset_link_album_chip); + artistLinkChip = root.findViewById(R.id.asset_link_artist_chip); + + currentSongLink = bindAssetLinkChip(songLinkChip, AssetLinkUtil.TYPE_SONG, song.getId()); + currentAlbumLink = bindAssetLinkChip(albumLinkChip, AssetLinkUtil.TYPE_ALBUM, song.getAlbumId()); + currentArtistLink = bindAssetLinkChip(artistLinkChip, AssetLinkUtil.TYPE_ARTIST, song.getArtistId()); + syncAssetLinkGroupVisibility(); + } + + private AssetLinkUtil.AssetLink bindAssetLinkChip(@Nullable Chip chip, String type, @Nullable String id) { + if (chip == null) return null; + if (id == null || id.isEmpty()) { + clearAssetLinkChip(chip); + return null; + } + + String label = getString(AssetLinkUtil.getLabelRes(type)); + AssetLinkUtil.AssetLink assetLink = AssetLinkUtil.buildAssetLink(type, id); + if (assetLink == null) { + clearAssetLinkChip(chip); + return null; + } + + chip.setText(getString(R.string.asset_link_chip_text, label, assetLink.id)); + chip.setVisibility(View.VISIBLE); + + chip.setOnClickListener(v -> { + if (assetLink != null) { + ((MainActivity) requireActivity()).openAssetLink(assetLink); + } + }); + + chip.setOnLongClickListener(v -> { + if (assetLink != null) { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, id), Toast.LENGTH_SHORT).show(); + } + return true; + }); + + return assetLink; + } + + private void clearAssetLinkChip(@Nullable Chip chip) { + if (chip == null) return; + chip.setVisibility(View.GONE); + chip.setText(""); + chip.setOnClickListener(null); + chip.setOnLongClickListener(null); + } + + private void syncAssetLinkGroupVisibility() { + if (assetLinkChipGroup == null) return; + boolean hasVisible = false; + for (int i = 0; i < assetLinkChipGroup.getChildCount(); i++) { + View child = assetLinkChipGroup.getChildAt(i); + if (child.getVisibility() == View.VISIBLE) { + hasVisible = true; + break; + } + } + assetLinkChipGroup.setVisibility(hasVisible ? View.VISIBLE : View.GONE); + } + + private void bindAssetLinkView(@Nullable View view, @Nullable AssetLinkUtil.AssetLink assetLink) { + if (view == null) return; + if (assetLink == null) { + AssetLinkUtil.clearLinkAppearance(view); + view.setOnClickListener(null); + view.setOnLongClickListener(null); + view.setClickable(false); + view.setLongClickable(false); + return; + } + + view.setClickable(true); + view.setLongClickable(true); + AssetLinkUtil.applyLinkAppearance(view); + view.setOnClickListener(v -> ((MainActivity) requireActivity()).openAssetLink(assetLink, !AssetLinkUtil.TYPE_SONG.equals(assetLink.type))); + view.setOnLongClickListener(v -> { + AssetLinkUtil.copyToClipboard(requireContext(), assetLink); + Toast.makeText(requireContext(), getString(R.string.asset_link_copied_toast, assetLink.id), Toast.LENGTH_SHORT).show(); + return true; + }); + } + private void initializeMediaBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } @@ -293,4 +397,4 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements private void refreshShares() { homeViewModel.refreshShares(requireActivity()); } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java new file mode 100644 index 00000000..9d3ba966 --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkNavigator.java @@ -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 liveData = songRepository.getSong(id); + Observer observer = new Observer() { + @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 liveData = albumRepository.getAlbum(id); + Observer observer = new Observer() { + @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 liveData = artistRepository.getArtist(id); + Observer observer = new Observer() { + @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 liveData = playlistRepository.getPlaylist(id); + Observer observer = new Observer() { + @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); + } + }); + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java new file mode 100644 index 00000000..1609a88a --- /dev/null +++ b/app/src/main/java/com/cappielloantonio/tempo/util/AssetLinkUtil.java @@ -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; + } + } +} diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java index cf0f768e..efd97350 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java @@ -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); + } + } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java index 9bd7fcad..558aa218 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java +++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java @@ -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()) diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt index 9baaca1c..4815fb83 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt @@ -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() + } } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java index 06986145..93a1a7e2 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java @@ -5,17 +5,20 @@ import android.appwidget.AppWidgetManager; import android.appwidget.AppWidgetProvider; import android.content.Context; import android.content.Intent; +import android.net.Uri; +import android.text.TextUtils; import android.widget.RemoteViews; import com.cappielloantonio.tempo.R; import android.app.TaskStackBuilder; -import android.app.PendingIntent; import com.cappielloantonio.tempo.ui.activity.MainActivity; import android.util.Log; +import androidx.annotation.Nullable; + public class WidgetProvider extends AppWidgetProvider { private static final String TAG = "TempoWidget"; public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE"; @@ -28,7 +31,7 @@ public class WidgetProvider extends AppWidgetProvider { public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) { for (int id : ids) { RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id); - attachIntents(ctx, rv, id); + attachIntents(ctx, rv, id, null, null, null); mgr.updateAppWidget(id, rv); } } @@ -50,16 +53,23 @@ public class WidgetProvider extends AppWidgetProvider { public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) { super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId); - attachIntents(context, rv, appWidgetId); + attachIntents(context, rv, appWidgetId, null, null, null); appWidgetManager.updateAppWidget(appWidgetId, rv); WidgetUpdateManager.refreshFromController(context); } public static void attachIntents(Context ctx, RemoteViews rv) { - attachIntents(ctx, rv, 0); + attachIntents(ctx, rv, 0, null, null, null); } public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) { + attachIntents(ctx, rv, requestCodeBase, null, null, null); + } + + public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase, + String songLink, + String albumLink, + String artistLink) { PendingIntent playPause = PendingIntent.getBroadcast( ctx, requestCodeBase + 0, @@ -97,9 +107,31 @@ public class WidgetProvider extends AppWidgetProvider { rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle); rv.setOnClickPendingIntent(R.id.btn_repeat, repeat); - PendingIntent launch = TaskStackBuilder.create(ctx) - .addNextIntentWithParentStack(new Intent(ctx, MainActivity.class)) - .getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); + PendingIntent launch = buildMainActivityPendingIntent(ctx, requestCodeBase + 10, null); rv.setOnClickPendingIntent(R.id.root, launch); + + PendingIntent songPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 20, songLink); + PendingIntent artistPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 21, artistLink); + PendingIntent albumPending = buildMainActivityPendingIntent(ctx, requestCodeBase + 22, albumLink); + + PendingIntent fallback = launch; + rv.setOnClickPendingIntent(R.id.album_art, songPending != null ? songPending : fallback); + rv.setOnClickPendingIntent(R.id.title, songPending != null ? songPending : fallback); + rv.setOnClickPendingIntent(R.id.subtitle, + artistPending != null ? artistPending : (songPending != null ? songPending : fallback)); + rv.setOnClickPendingIntent(R.id.album, albumPending != null ? albumPending : fallback); + } + + private static PendingIntent buildMainActivityPendingIntent(Context ctx, int requestCode, @Nullable String link) { + Intent intent; + if (!TextUtils.isEmpty(link)) { + intent = new Intent(Intent.ACTION_VIEW, Uri.parse(link), ctx, MainActivity.class); + } else { + intent = new Intent(ctx, MainActivity.class); + } + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + TaskStackBuilder stackBuilder = TaskStackBuilder.create(ctx); + stackBuilder.addNextIntentWithParentStack(intent); + return stackBuilder.getPendingIntent(requestCode, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT); } } diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java index 4132511e..f159c526 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java +++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java @@ -4,8 +4,9 @@ import android.appwidget.AppWidgetManager; import android.content.ComponentName; import android.content.Context; import android.graphics.Bitmap; -import android.text.TextUtils; import android.graphics.drawable.Drawable; +import android.os.Bundle; +import android.text.TextUtils; import com.bumptech.glide.request.target.CustomTarget; import com.bumptech.glide.request.transition.Transition; @@ -17,6 +18,7 @@ import androidx.media3.session.MediaController; import androidx.media3.session.SessionToken; import com.cappielloantonio.tempo.service.MediaService; +import com.cappielloantonio.tempo.util.AssetLinkUtil; import com.cappielloantonio.tempo.util.MusicUtil; import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.MoreExecutors; @@ -25,6 +27,8 @@ import java.util.concurrent.ExecutionException; public final class WidgetUpdateManager { + private static final int WIDGET_SAFE_ART_SIZE = 512; + public static void updateFromState(Context ctx, String title, String artist, @@ -34,7 +38,10 @@ public final class WidgetUpdateManager { boolean shuffleEnabled, int repeatMode, long positionMs, - long durationMs) { + long durationMs, + String songLink, + String albumLink, + String artistLink) { if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing); if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle); if (TextUtils.isEmpty(album)) album = ""; @@ -46,7 +53,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing, timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id); - WidgetProvider.attachIntents(ctx, rv, id); + WidgetProvider.attachIntents(ctx, rv, id, songLink, albumLink, artistLink); mgr.updateAppWidget(id, rv); } } @@ -56,7 +63,7 @@ public final class WidgetUpdateManager { int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); for (int id : ids) { android.widget.RemoteViews rv = chooseBuild(ctx, id); - WidgetProvider.attachIntents(ctx, rv, id); + WidgetProvider.attachIntents(ctx, rv, id, null, null, null); mgr.updateAppWidget(id, rv); } } @@ -70,7 +77,10 @@ public final class WidgetUpdateManager { boolean shuffleEnabled, int repeatMode, long positionMs, - long durationMs) { + long durationMs, + String songLink, + String albumLink, + String artistLink) { final Context appCtx = ctx.getApplicationContext(); final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title; final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist; @@ -79,12 +89,15 @@ public final class WidgetUpdateManager { final boolean sh = shuffleEnabled; final int rep = repeatMode; final TimingInfo timing = createTimingInfo(positionMs, durationMs); + final String songLinkFinal = songLink; + final String albumLinkFinal = albumLink; + final String artistLinkFinal = artistLink; if (!TextUtils.isEmpty(coverArtId)) { CustomGlideRequest.loadAlbumArtBitmap( appCtx, coverArtId, - com.cappielloantonio.tempo.util.Preferences.getImageSize(), + WIDGET_SAFE_ART_SIZE, new CustomTarget() { @Override public void onResourceReady(Bitmap resource, Transition transition) { @@ -93,7 +106,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p, timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); - WidgetProvider.attachIntents(appCtx, rv, id); + WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal); mgr.updateAppWidget(id, rv); } } @@ -105,7 +118,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); - WidgetProvider.attachIntents(appCtx, rv, id); + WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal); mgr.updateAppWidget(id, rv); } } @@ -117,7 +130,7 @@ public final class WidgetUpdateManager { for (int id : ids) { android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); - WidgetProvider.attachIntents(appCtx, rv, id); + WidgetProvider.attachIntents(appCtx, rv, id, songLinkFinal, albumLinkFinal, artistLinkFinal); mgr.updateAppWidget(id, rv); } } @@ -133,6 +146,7 @@ public final class WidgetUpdateManager { MediaController c = future.get(); androidx.media3.common.MediaItem mi = c.getCurrentMediaItem(); String title = null, artist = null, album = null, coverId = null; + String songLink = null, albumLink = null, artistLink = null; if (mi != null && mi.mediaMetadata != null) { if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString(); if (mi.mediaMetadata.artist != null) @@ -140,10 +154,26 @@ public final class WidgetUpdateManager { if (mi.mediaMetadata.albumTitle != null) album = mi.mediaMetadata.albumTitle.toString(); if (mi.mediaMetadata.extras != null) { + Bundle extras = mi.mediaMetadata.extras; if (title == null) title = mi.mediaMetadata.extras.getString("title"); if (artist == null) artist = mi.mediaMetadata.extras.getString("artist"); if (album == null) album = mi.mediaMetadata.extras.getString("album"); - coverId = mi.mediaMetadata.extras.getString("coverArtId"); + coverId = extras.getString("coverArtId"); + + songLink = extras.getString("assetLinkSong"); + if (songLink == null) { + songLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras.getString("id")); + } + + albumLink = extras.getString("assetLinkAlbum"); + if (albumLink == null) { + albumLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras.getString("albumId")); + } + + artistLink = extras.getString("assetLinkArtist"); + if (artistLink == null) { + artistLink = AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras.getString("artistId")); + } } } long position = c.getCurrentPosition(); @@ -159,7 +189,10 @@ public final class WidgetUpdateManager { c.getShuffleModeEnabled(), c.getRepeatMode(), position, - duration); + duration, + songLink, + albumLink, + artistLink); c.release(); } catch (ExecutionException | InterruptedException ignored) { } @@ -273,4 +306,4 @@ public final class WidgetUpdateManager { } } -} +} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_link.xml b/app/src/main/res/drawable/ic_link.xml new file mode 100644 index 00000000..5592db28 --- /dev/null +++ b/app/src/main/res/drawable/ic_link.xml @@ -0,0 +1,10 @@ + + + + diff --git a/app/src/main/res/layout/bottom_sheet_song_dialog.xml b/app/src/main/res/layout/bottom_sheet_song_dialog.xml index 2105f941..7cf98440 100644 --- a/app/src/main/res/layout/bottom_sheet_song_dialog.xml +++ b/app/src/main/res/layout/bottom_sheet_song_dialog.xml @@ -68,6 +68,14 @@ + + - \ No newline at end of file + diff --git a/app/src/main/res/layout/fragment_album_catalogue.xml b/app/src/main/res/layout/fragment_album_catalogue.xml index c0928866..71977634 100644 --- a/app/src/main/res/layout/fragment_album_catalogue.xml +++ b/app/src/main/res/layout/fragment_album_catalogue.xml @@ -41,23 +41,40 @@ app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintEnd_toEndOf="parent" /> -