Merge branch 'development' into main

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

View file

@ -3,7 +3,7 @@ name: Github Release Workflow
on: on:
push: push:
tags: tags:
- '[0-9]+.[0-9]+.[0-9]+' - 'v[0-9]+.[0-9]+.[0-9]+'
jobs: jobs:
build: build:

1
.gitignore vendored
View file

@ -18,3 +18,4 @@
# release / debug files # release / debug files
tempus-release-key.jks tempus-release-key.jks
app/tempo/ app/tempo/
app/notquitemy/

View file

@ -2,6 +2,25 @@
***This log is for this fork to detail updates since 3.9.0 from the main repo.*** ***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) ## [3.16.6](https://github.com/eddyizm/tempo/releases/tag/v3.16.6) (2025-10-08)
## What's Changed ## What's Changed
* chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151 * chore(i18n): Update Spanish translation by @jaime-grj in https://github.com/eddyizm/tempo/pull/151

View file

@ -25,11 +25,21 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
## Fork ## Fork
sha256 signing key fingerprint 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. 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). 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. - **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. - **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. - **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) 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 ## Screenshot
<p align="center"> <p align="center">
@ -90,6 +98,16 @@ Tempo is an open-source project developed and maintained solely by me. I would l
<img src="mockup/dark/8_screenshot.png" width=200> <img src="mockup/dark/8_screenshot.png" width=200>
</p> </p>
## 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 ## 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. 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.

View file

@ -57,10 +57,30 @@ This app works with any service that implements the Subsonic API, including:
## Main Features ## Main Features
### Library View ### 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 ### Now Playing Screen
**TODO**
On the main player control screen, tapping on the artwork will reveal a small collection of 4 buttons/icons.
<p align="left">
<img src="mockup/usage/player_icons.png" width=159>
</p>
*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 ## Navigation

View file

@ -10,8 +10,8 @@ android {
minSdkVersion 24 minSdkVersion 24
targetSdk 35 targetSdk 35
versionCode 34 versionCode 36
versionName '3.16.6' versionName '3.17.14'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner' testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions { javaCompileOptions {

View file

@ -42,6 +42,16 @@
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data
android:host="asset"
android:scheme="tempo" />
</intent-filter>
</activity> </activity>
<service <service

View file

@ -4,14 +4,18 @@ import android.content.Context;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import com.bumptech.glide.Glide;
import com.bumptech.glide.GlideBuilder; import com.bumptech.glide.GlideBuilder;
import com.bumptech.glide.annotation.GlideModule; import com.bumptech.glide.annotation.GlideModule;
import com.bumptech.glide.load.DecodeFormat; import com.bumptech.glide.load.DecodeFormat;
import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory; import com.bumptech.glide.load.engine.cache.InternalCacheDiskCacheFactory;
import com.bumptech.glide.Registry;
import com.bumptech.glide.module.AppGlideModule; import com.bumptech.glide.module.AppGlideModule;
import com.bumptech.glide.request.RequestOptions; import com.bumptech.glide.request.RequestOptions;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import java.io.InputStream;
@GlideModule @GlideModule
public class CustomGlideModule extends AppGlideModule { public class CustomGlideModule extends AppGlideModule {
@Override @Override
@ -20,4 +24,9 @@ public class CustomGlideModule extends AppGlideModule {
builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize)); builder.setDiskCache(new InternalCacheDiskCacheFactory(context, "cache", diskCacheSize));
builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565)); builder.setDefaultRequestOptions(new RequestOptions().format(DecodeFormat.PREFER_RGB_565));
} }
@Override
public void registerComponents(@NonNull Context context, @NonNull Glide glide, @NonNull Registry registry) {
registry.replace(String.class, InputStream.class, new IPv6StringLoader.Factory());
}
} }

View file

@ -125,7 +125,7 @@ public class CustomGlideRequest {
public static class Builder { public static class Builder {
private final RequestManager requestManager; private final RequestManager requestManager;
private Object item; private String item;
private Builder(Context context, String item, ResourceType type) { private Builder(Context context, String item, ResourceType type) {
this.requestManager = Glide.with(context); this.requestManager = Glide.with(context);

View file

@ -0,0 +1,110 @@
package com.cappielloantonio.tempo.glide;
import androidx.annotation.NonNull;
import com.bumptech.glide.Priority;
import com.bumptech.glide.load.DataSource;
import com.bumptech.glide.load.Options;
import com.bumptech.glide.load.data.DataFetcher;
import com.bumptech.glide.load.model.ModelLoader;
import com.bumptech.glide.load.model.ModelLoaderFactory;
import com.bumptech.glide.load.model.MultiModelLoaderFactory;
import com.bumptech.glide.signature.ObjectKey;
import java.io.IOException;
import java.io.InputStream;
import java.net.HttpURLConnection;
import java.net.URL;
public class IPv6StringLoader implements ModelLoader<String, InputStream> {
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<InputStream> 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<InputStream> {
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<? super InputStream> 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<InputStream> getDataClass() {
return InputStream.class;
}
@NonNull
@Override
public DataSource getDataSource() {
return DataSource.REMOTE;
}
}
public static class Factory implements ModelLoaderFactory<String, InputStream> {
@NonNull
@Override
public ModelLoader<String, InputStream> build(@NonNull MultiModelLoaderFactory multiFactory) {
return new IPv6StringLoader();
}
@Override
public void teardown() {
// No-op
}
}
}

View file

@ -8,18 +8,18 @@ import androidx.room.PrimaryKey
import com.cappielloantonio.tempo.subsonic.models.Child import com.cappielloantonio.tempo.subsonic.models.Child
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.util.Date
@Keep @Keep
@Parcelize @Parcelize
@Entity(tableName = "chronology") @Entity(tableName = "chronology")
class Chronology(@PrimaryKey override val id: String) : Child(id) { class Chronology(
@PrimaryKey override val id: String,
@ColumnInfo(name = "timestamp") @ColumnInfo(name = "timestamp")
var timestamp: Long = System.currentTimeMillis() var timestamp: Long = System.currentTimeMillis(),
@ColumnInfo(name = "server") @ColumnInfo(name = "server")
var server: String? = null var server: String? = null,
) : Child(id) {
constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) { constructor(mediaItem: MediaItem) : this(mediaItem.mediaMetadata.extras!!.getString("id")!!) {
parentId = mediaItem.mediaMetadata.extras!!.getString("parentId") parentId = mediaItem.mediaMetadata.extras!!.getString("parentId")
isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir") isDir = mediaItem.mediaMetadata.extras!!.getBoolean("isDir")

View file

@ -10,19 +10,17 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
@Entity(tableName = "download") @Entity(tableName = "download")
class Download(@PrimaryKey override val id: String) : Child(id) { class Download(
@PrimaryKey override val id: String,
@ColumnInfo(name = "playlist_id") @ColumnInfo(name = "playlist_id")
var playlistId: String? = null var playlistId: String? = null,
@ColumnInfo(name = "playlist_name") @ColumnInfo(name = "playlist_name")
var playlistName: String? = null var playlistName: String? = null,
@ColumnInfo(name = "download_state", defaultValue = "1") @ColumnInfo(name = "download_state", defaultValue = "1")
var downloadState: Int = 0 var downloadState: Int = 0,
@ColumnInfo(name = "download_uri", defaultValue = "") @ColumnInfo(name = "download_uri", defaultValue = "")
var downloadUri: String? = null var downloadUri: String? = null,
) : Child(id) {
constructor(child: Child) : this(child.id) { constructor(child: Child) : this(child.id) {
parentId = child.parentId parentId = child.parentId
isDir = child.isDir isDir = child.isDir
@ -62,5 +60,5 @@ class Download(@PrimaryKey override val id: String) : Child(id) {
@Keep @Keep
data class DownloadStack( data class DownloadStack(
var id: String, var id: String,
var view: String? var view: String?,
) )

View file

@ -10,20 +10,18 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
@Entity(tableName = "queue") @Entity(tableName = "queue")
class Queue(override val id: String) : Child(id) { class Queue(
override val id: String,
@PrimaryKey @PrimaryKey
@ColumnInfo(name = "track_order") @ColumnInfo(name = "track_order")
var trackOrder: Int = 0 var trackOrder: Int = 0,
@ColumnInfo(name = "last_play") @ColumnInfo(name = "last_play")
var lastPlay: Long = 0 var lastPlay: Long = 0,
@ColumnInfo(name = "playing_changed") @ColumnInfo(name = "playing_changed")
var playingChanged: Long = 0 var playingChanged: Long = 0,
@ColumnInfo(name = "stream_id") @ColumnInfo(name = "stream_id")
var streamId: String? = null var streamId: String? = null,
) : Child(id) {
constructor(child: Child) : this(child.id) { constructor(child: Child) : this(child.id) {
parentId = child.parentId parentId = child.parentId
isDir = child.isDir isDir = child.isDir

View file

@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.repository;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData; import androidx.lifecycle.MutableLiveData;
import android.util.Log;
import com.cappielloantonio.tempo.App; import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.interfaces.DecadesCallback; import com.cappielloantonio.tempo.interfaces.DecadesCallback;
@ -31,14 +32,22 @@ public class AlbumRepository {
.enqueue(new Callback<ApiResponse>() { .enqueue(new Callback<ApiResponse>() {
@Override @Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> response) { public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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()); listLiveAlbums.setValue(response.body().getSubsonicResponse().getAlbumList2().getAlbums());
} else {
Log.e("AlbumRepository", "API Error on getAlbums. HTTP Code: " + response.code());
listLiveAlbums.setValue(new ArrayList<>());
} }
} }
@Override @Override
public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) { public void onFailure(@NonNull Call<ApiResponse> call, @NonNull Throwable t) {
Log.e("AlbumRepository", "Network Failure on getAlbums: " + t.getMessage());
listLiveAlbums.setValue(new ArrayList<>());
} }
}); });

View file

@ -80,6 +80,33 @@ public class PlaylistRepository {
return listLivePlaylistSongs; return listLivePlaylistSongs;
} }
public MutableLiveData<Playlist> getPlaylist(String id) {
MutableLiveData<Playlist> playlistLiveData = new MutableLiveData<>();
App.getSubsonicClientInstance(false)
.getPlaylistClient()
.getPlaylist(id)
.enqueue(new Callback<ApiResponse>() {
@Override
public void onResponse(@NonNull Call<ApiResponse> call, @NonNull Response<ApiResponse> 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<ApiResponse> call, @NonNull Throwable t) {
playlistLiveData.setValue(null);
}
});
return playlistLiveData;
}
public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) { public void addSongToPlaylist(String playlistId, ArrayList<String> songsId) {
if (songsId.isEmpty()) { if (songsId.isEmpty()) {
Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show(); Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();

View file

@ -2,22 +2,28 @@ package com.cappielloantonio.tempo.subsonic
import com.cappielloantonio.tempo.App import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.subsonic.utils.CacheUtil import com.cappielloantonio.tempo.subsonic.utils.CacheUtil
import com.cappielloantonio.tempo.subsonic.utils.EmptyDateTypeAdapter
import com.google.gson.GsonBuilder import com.google.gson.GsonBuilder
import okhttp3.Cache import okhttp3.Cache
import okhttp3.OkHttpClient import okhttp3.OkHttpClient
import okhttp3.logging.HttpLoggingInterceptor import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory import retrofit2.converter.gson.GsonConverterFactory
import java.util.Date
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
class RetrofitClient(subsonic: Subsonic) { class RetrofitClient(subsonic: Subsonic) {
var retrofit: Retrofit var retrofit: Retrofit
init { init {
val gson = GsonBuilder()
.registerTypeAdapter(Date::class.java, EmptyDateTypeAdapter())
.setLenient()
.create()
retrofit = Retrofit.Builder() retrofit = Retrofit.Builder()
.baseUrl(subsonic.url) .baseUrl(subsonic.url)
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setDateFormat("yyyy-MM-dd'T'HH:mm:ss").create())) .addConverterFactory(GsonConverterFactory.create(gson))
.addConverterFactory(GsonConverterFactory.create(GsonBuilder().setLenient().create()))
.client(getOkHttpClient()) .client(getOkHttpClient())
.build() .build()
} }

View file

@ -4,38 +4,36 @@ import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.time.Instant import java.util.Date
import java.time.LocalDate
import java.util.*
@Keep @Keep
@Parcelize @Parcelize
open class AlbumID3 : Parcelable { open class AlbumID3(
var id: String? = null var id: String? = null,
var name: String? = null var name: String? = null,
var artist: String? = null var artist: String? = null,
var artistId: String? = null var artistId: String? = null,
@SerializedName("coverArt") @SerializedName("coverArt")
var coverArtId: String? = null var coverArtId: String? = null,
var songCount: Int? = 0 var songCount: Int? = 0,
var duration: Int? = 0 var duration: Int? = 0,
var playCount: Long? = 0 var playCount: Long? = 0,
var created: Date? = null var created: Date? = null,
var starred: Date? = null var starred: Date? = null,
var year: Int = 0 var year: Int = 0,
var genre: String? = null var genre: String? = null,
var played: Date? = Date(0) var played: Date? = Date(0),
var userRating: Int? = 0 var userRating: Int? = 0,
var recordLabels: List<RecordLabel>? = null var recordLabels: List<RecordLabel>? = null,
var musicBrainzId: String? = null var musicBrainzId: String? = null,
var genres: List<ItemGenre>? = null var genres: List<ItemGenre>? = null,
var artists: List<ArtistID3>? = null var artists: List<ArtistID3>? = null,
var displayArtist: String? = null var displayArtist: String? = null,
var releaseTypes: List<String>? = null var releaseTypes: List<String>? = null,
var moods: List<String>? = null var moods: List<String>? = null,
var sortName: String? = null var sortName: String? = null,
var originalReleaseDate: ItemDate? = null var originalReleaseDate: ItemDate? = null,
var releaseDate: ItemDate? = null var releaseDate: ItemDate? = null,
var isCompilation: Boolean? = null var isCompilation: Boolean? = null,
var discTitles: List<DiscTitle>? = null var discTitles: List<DiscTitle>? = null,
} ) : Parcelable

View file

@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
class AlbumWithSongsID3 : AlbumID3(), Parcelable { class AlbumWithSongsID3(
@SerializedName("song") @SerializedName("song")
var songs: List<Child>? = null var songs: List<Child>? = null,
} ) : AlbumID3(), Parcelable

View file

@ -7,10 +7,10 @@ import java.util.Date
@Keep @Keep
@Parcelize @Parcelize
class Artist : Parcelable { class Artist(
var id: String? = null var id: String? = null,
var name: String? = null var name: String? = null,
var starred: Date? = null var starred: Date? = null,
var userRating: Int? = null var userRating: Int? = null,
var averageRating: Double? = null var averageRating: Double? = null,
} ) : Parcelable

View file

@ -4,15 +4,15 @@ import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.util.Date
@Keep @Keep
@Parcelize @Parcelize
open class ArtistID3 : Parcelable { open class ArtistID3(
var id: String? = null var id: String? = null,
var name: String? = null var name: String? = null,
@SerializedName("coverArt") @SerializedName("coverArt")
var coverArtId: String? = null var coverArtId: String? = null,
var albumCount = 0 var albumCount: Int = 0,
var starred: Date? = null var starred: Date? = null,
} ) : Parcelable

View file

@ -7,7 +7,7 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
class ArtistWithAlbumsID3 : ArtistID3(), Parcelable { class ArtistWithAlbumsID3(
@SerializedName("album") @SerializedName("album")
var albums: List<AlbumID3>? = null var albums: List<AlbumID3>? = null,
} ) : ArtistID3(), Parcelable

View file

@ -8,15 +8,15 @@ import java.util.Date
@Keep @Keep
@Parcelize @Parcelize
class Directory : Parcelable { class Directory(
@SerializedName("child") @SerializedName("child")
var children: List<Child>? = null var children: List<Child>? = null,
var id: String? = null var id: String? = null,
@SerializedName("parent") @SerializedName("parent")
var parentId: String? = null var parentId: String? = null,
var name: String? = null var name: String? = null,
var starred: Date? = null var starred: Date? = null,
var userRating: Int? = null var userRating: Int? = null,
var averageRating: Double? = null var averageRating: Double? = null,
var playCount: Long? = null var playCount: Long? = null,
} ) : Parcelable

View file

@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
open class DiscTitle : Parcelable { open class DiscTitle(
var disc: Int? = null var disc: Int? = null,
var title: String? = null var title: String? = null,
} ) : Parcelable

View file

@ -7,9 +7,9 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
class Genre : Parcelable { class Genre(
@SerializedName("value") @SerializedName("value")
var genre: String? = null var genre: String? = null,
var songCount = 0 var songCount: Int = 0,
var albumCount = 0 var albumCount: Int = 0,
} ) : Parcelable

View file

@ -6,9 +6,9 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
class InternetRadioStation : Parcelable { class InternetRadioStation(
var id: String? = null var id: String? = null,
var name: String? = null var name: String? = null,
var streamUrl: String? = null var streamUrl: String? = null,
var homePageUrl: String? = null var homePageUrl: String? = null,
} ) : Parcelable

View file

@ -9,11 +9,11 @@ import java.util.Locale
@Keep @Keep
@Parcelize @Parcelize
open class ItemDate : Parcelable { open class ItemDate(
var year: Int? = null var year: Int? = null,
var month: Int? = null var month: Int? = null,
var day: Int? = null var day: Int? = null,
) : Parcelable {
fun getFormattedDate(): String? { fun getFormattedDate(): String? {
if (year == null && month == null && day == null) return null if (year == null && month == null && day == null) return null
@ -22,8 +22,7 @@ open class ItemDate : Parcelable {
SimpleDateFormat("yyyy", Locale.getDefault()) SimpleDateFormat("yyyy", Locale.getDefault())
} else if (day == null) { } else if (day == null) {
SimpleDateFormat("MMMM yyyy", Locale.getDefault()) SimpleDateFormat("MMMM yyyy", Locale.getDefault())
} } else {
else{
SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault()) SimpleDateFormat("MMMM dd, yyyy", Locale.getDefault())
} }

View file

@ -6,6 +6,6 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
open class ItemGenre : Parcelable { open class ItemGenre(
var name: String? = null var name: String? = null,
} ) : Parcelable

View file

@ -6,7 +6,7 @@ import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
class MusicFolder : Parcelable { class MusicFolder(
var id: String? = null var id: String? = null,
var name: String? = null var name: String? = null,
} ) : Parcelable

View file

@ -8,10 +8,9 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
class NowPlayingEntry( class NowPlayingEntry(
@SerializedName("_id") @SerializedName("_id")
override val id: String override val id: String,
) : Child(id) { var username: String? = null,
var username: String? = null var minutesAgo: Int = 0,
var minutesAgo = 0 var playerId: Int = 0,
var playerId = 0 var playerName: String? = null,
var playerName: String? = null ) : Child(id)
}

View file

@ -7,8 +7,9 @@ import androidx.room.Entity
import androidx.room.Ignore import androidx.room.Ignore
import androidx.room.PrimaryKey import androidx.room.PrimaryKey
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.util.Date
@Keep @Keep
@Parcelize @Parcelize
@ -16,27 +17,56 @@ import java.util.*
open class Playlist( open class Playlist(
@PrimaryKey @PrimaryKey
@ColumnInfo(name = "id") @ColumnInfo(name = "id")
open var id: String open var id: String,
) : Parcelable {
@ColumnInfo(name = "name") @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 @Ignore
@IgnoredOnParcel
var comment: String? = null var comment: String? = null
@Ignore @Ignore
@IgnoredOnParcel
var owner: String? = null var owner: String? = null
@Ignore @Ignore
@IgnoredOnParcel
@SerializedName("public") @SerializedName("public")
var isUniversal: Boolean? = null var isUniversal: Boolean? = null
@Ignore @Ignore
@IgnoredOnParcel
var songCount: Int = 0 var songCount: Int = 0
@ColumnInfo(name = "duration")
var duration: Long = 0
@Ignore @Ignore
@IgnoredOnParcel
var created: Date? = null var created: Date? = null
@Ignore @Ignore
@IgnoredOnParcel
var changed: Date? = null var changed: Date? = null
@ColumnInfo(name = "coverArt")
var coverArtId: String? = null
@Ignore @Ignore
@IgnoredOnParcel
var allowedUsers: List<String>? = null var allowedUsers: List<String>? = 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<String>?,
) : 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
}
} }

View file

@ -9,8 +9,7 @@ import kotlinx.parcelize.Parcelize
@Parcelize @Parcelize
class PlaylistWithSongs( class PlaylistWithSongs(
@SerializedName("_id") @SerializedName("_id")
override var id: String override var id: String,
) : Playlist(id), Parcelable {
@SerializedName("entry") @SerializedName("entry")
var entries: List<Child>? = null var entries: List<Child>? = null,
} ) : Playlist(id), Parcelable

View file

@ -6,5 +6,5 @@ import com.google.gson.annotations.SerializedName
@Keep @Keep
class Playlists( class Playlists(
@SerializedName("playlist") @SerializedName("playlist")
var playlists: List<Playlist>? = null var playlists: List<Playlist>? = null,
) )

View file

@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
class PodcastChannel : Parcelable { class PodcastChannel(
@SerializedName("episode") @SerializedName("episode")
var episodes: List<PodcastEpisode>? = null var episodes: List<PodcastEpisode>? = null,
var id: String? = null var id: String? = null,
var url: String? = null var url: String? = null,
var title: String? = null var title: String? = null,
var description: String? = null var description: String? = null,
@SerializedName("coverArt") @SerializedName("coverArt")
var coverArtId: String? = null var coverArtId: String? = null,
var originalImageUrl: String? = null var originalImageUrl: String? = null,
var status: String? = null var status: String? = null,
var errorMessage: String? = null var errorMessage: String? = null,
} ) : Parcelable

View file

@ -3,37 +3,38 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.util.*
@Keep @Keep
@Parcelize @Parcelize
class PodcastEpisode : Parcelable { class PodcastEpisode(
var id: String? = null var id: String? = null,
@SerializedName("parent") @SerializedName("parent")
var parentId: String? = null var parentId: String? = null,
var isDir = false var isDir: Boolean = false,
var title: String? = null var title: String? = null,
var album: String? = null var album: String? = null,
var artist: String? = null var artist: String? = null,
var year: Int? = null var year: Int? = null,
var genre: String? = null var genre: String? = null,
@SerializedName("coverArt") @SerializedName("coverArt")
var coverArtId: String? = null var coverArtId: String? = null,
var size: Long? = null var size: Long? = null,
var contentType: String? = null var contentType: String? = null,
var suffix: String? = null var suffix: String? = null,
var duration: Int? = null var duration: Int? = null,
@SerializedName("bitRate") @SerializedName("bitRate")
var bitrate: Int? = null var bitrate: Int? = null,
var path: String? = null var path: String? = null,
var isVideo: Boolean = false var isVideo: Boolean = false,
var created: Date? = null var created: Date? = null,
var artistId: String? = null var artistId: String? = null,
var type: String? = null var type: String? = null,
var streamId: String? = null var streamId: String? = null,
var channelId: String? = null var channelId: String? = null,
var description: String? = null var description: String? = null,
var status: String? = null var status: String? = null,
var publishDate: Date? = null var publishDate: Date? = null,
} ) : Parcelable

View file

@ -2,10 +2,11 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@Keep @Keep
@Parcelize @Parcelize
open class RecordLabel : Parcelable { open class RecordLabel(
var name: String? = null var name: String? = null,
} ) : Parcelable

View file

@ -3,20 +3,21 @@ package com.cappielloantonio.tempo.subsonic.models
import android.os.Parcelable import android.os.Parcelable
import androidx.annotation.Keep import androidx.annotation.Keep
import com.google.gson.annotations.SerializedName import com.google.gson.annotations.SerializedName
import kotlinx.parcelize.IgnoredOnParcel
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import java.util.* import java.util.Date
@Keep @Keep
@Parcelize @Parcelize
class Share : Parcelable { data class Share(
@SerializedName("entry") @SerializedName("entry")
var entries: List<Child>? = null var entries: List<Child>? = null,
var id: String? = null var id: String? = null,
var url: String? = null var url: String? = null,
var description: String? = null var description: String? = null,
var username: String? = null var username: String? = null,
var created: Date? = null var created: Date? = null,
var expires: Date? = null var expires: Date? = null,
var lastVisited: Date? = null var lastVisited: Date? = null,
var visitCount = 0 var visitCount: Int = 0
} ) : Parcelable

View file

@ -38,21 +38,36 @@ public class CacheUtil {
return chain.proceed(request); return chain.proceed(request);
}; };
private boolean isConnected() { private boolean isConnected() {
ConnectivityManager connectivityManager = (ConnectivityManager) App.getContext().getSystemService(Context.CONNECTIVITY_SERVICE); ConnectivityManager connectivityManager = (ConnectivityManager) App.getContext().getSystemService(Context.CONNECTIVITY_SERVICE);
if (connectivityManager == null) {
if (connectivityManager != null) { return false;
Network network = connectivityManager.getActiveNetwork();
if (network != null) {
NetworkCapabilities capabilities = connectivityManager.getNetworkCapabilities(network);
if (capabilities != null) {
return capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET);
}
}
} }
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;
} }
} }

View file

@ -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<Date> {
// Define the date formats expected from the Subsonic server.
private val dateFormats: List<SimpleDateFormat> = 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
}
}

View file

@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.ui.dialog.ConnectionAlertDialog;
import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog; import com.cappielloantonio.tempo.ui.dialog.GithubTempoUpdateDialog;
import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog; import com.cappielloantonio.tempo.ui.dialog.ServerUnreachableDialog;
import com.cappielloantonio.tempo.ui.fragment.PlayerBottomSheetFragment; 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.Constants;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.MainViewModel; import com.cappielloantonio.tempo.viewmodel.MainViewModel;
@ -60,6 +62,8 @@ public class MainActivity extends BaseActivity {
private BottomNavigationView bottomNavigationView; private BottomNavigationView bottomNavigationView;
public NavController navController; public NavController navController;
private BottomSheetBehavior bottomSheetBehavior; private BottomSheetBehavior bottomSheetBehavior;
private AssetLinkNavigator assetLinkNavigator;
private AssetLinkUtil.AssetLink pendingAssetLink;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver; ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
private Intent pendingDownloadPlaybackIntent; private Intent pendingDownloadPlaybackIntent;
@ -76,6 +80,7 @@ public class MainActivity extends BaseActivity {
setContentView(view); setContentView(view);
mainViewModel = new ViewModelProvider(this).get(MainViewModel.class); mainViewModel = new ViewModelProvider(this).get(MainViewModel.class);
assetLinkNavigator = new AssetLinkNavigator(this);
connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this); connectivityStatusBroadcastReceiver = new ConnectivityStatusBroadcastReceiver(this);
connectivityStatusReceiverManager(true); connectivityStatusReceiverManager(true);
@ -311,6 +316,24 @@ public class MainActivity extends BaseActivity {
public void goFromLogin() { public void goFromLogin() {
setBottomSheetInPeek(mainViewModel.isQueueLoaded()); setBottomSheetInPeek(mainViewModel.isQueueLoaded());
goToHome(); 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() { public void quit() {
@ -443,6 +466,7 @@ public class MainActivity extends BaseActivity {
|| intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) { || intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
pendingDownloadPlaybackIntent = new Intent(intent); pendingDownloadPlaybackIntent = new Intent(intent);
} }
handleAssetLinkIntent(intent);
} }
private void consumePendingPlaybackIntent() { private void consumePendingPlaybackIntent() {
@ -452,6 +476,35 @@ public class MainActivity extends BaseActivity {
playDownloadedMedia(intent); 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) { private void playDownloadedMedia(Intent intent) {
String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI); String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
if (TextUtils.isEmpty(uriString)) { if (TextUtils.isEmpty(uriString)) {

View file

@ -20,6 +20,7 @@ import com.cappielloantonio.tempo.util.MusicUtil;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.Comparator; import java.util.Comparator;
import java.util.Date;
import java.util.List; import java.util.List;
public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable { public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAdapter.ViewHolder> implements Filterable {
@ -152,12 +153,20 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
} }
public void sort(String order) { public void sort(String order) {
if (albums == null) return;
switch (order) { switch (order) {
case Constants.ALBUM_ORDER_BY_NAME: case Constants.ALBUM_ORDER_BY_NAME:
albums.sort(Comparator.comparing(AlbumID3::getName)); albums.sort(Comparator.comparing(
album -> album.getName() != null ? album.getName() : "",
String.CASE_INSENSITIVE_ORDER
));
break; break;
case Constants.ALBUM_ORDER_BY_ARTIST: 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; break;
case Constants.ALBUM_ORDER_BY_YEAR: case Constants.ALBUM_ORDER_BY_YEAR:
albums.sort(Comparator.comparing(AlbumID3::getYear)); albums.sort(Comparator.comparing(AlbumID3::getYear));
@ -166,15 +175,23 @@ public class AlbumCatalogueAdapter extends RecyclerView.Adapter<AlbumCatalogueAd
Collections.shuffle(albums); Collections.shuffle(albums);
break; break;
case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED: case Constants.ALBUM_ORDER_BY_RECENTLY_ADDED:
albums.sort(Comparator.comparing(AlbumID3::getCreated)); albums.sort(Comparator.comparing(
album -> album.getCreated() != null ? album.getCreated() : new Date(0),
Comparator.nullsLast(Date::compareTo)
));
Collections.reverse(albums); Collections.reverse(albums);
break; break;
case Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED: 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); Collections.reverse(albums);
break; break;
case Constants.ALBUM_ORDER_BY_MOST_PLAYED: 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); Collections.reverse(albums);
break; break;
} }

View file

@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.ui.dialog;
import android.app.Dialog; import android.app.Dialog;
import android.os.Bundle; import android.os.Bundle;
import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.fragment.app.DialogFragment; import androidx.fragment.app.DialogFragment;
@ -10,6 +11,7 @@ import androidx.media3.common.MediaMetadata;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding; import com.cappielloantonio.tempo.databinding.DialogTrackInfoBinding;
import com.cappielloantonio.tempo.glide.CustomGlideRequest; import com.cappielloantonio.tempo.glide.CustomGlideRequest;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
@ -21,6 +23,11 @@ public class TrackInfoDialog extends DialogFragment {
private DialogTrackInfoBinding bind; private DialogTrackInfoBinding bind;
private final MediaMetadata mediaMetadata; 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) { public TrackInfoDialog(MediaMetadata mediaMetadata) {
this.mediaMetadata = mediaMetadata; this.mediaMetadata = mediaMetadata;
@ -52,6 +59,8 @@ public class TrackInfoDialog extends DialogFragment {
} }
private void setTrackInfo() { private void setTrackInfo() {
genreLink = null;
yearLink = null;
bind.trakTitleInfoTextView.setText(mediaMetadata.title); bind.trakTitleInfoTextView.setText(mediaMetadata.title);
bind.trakArtistInfoTextView.setText( bind.trakArtistInfoTextView.setText(
mediaMetadata.artist != null mediaMetadata.artist != null
@ -61,17 +70,41 @@ public class TrackInfoDialog extends DialogFragment {
: ""); : "");
if (mediaMetadata.extras != null) { 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 CustomGlideRequest.Builder
.from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song) .from(requireContext(), mediaMetadata.extras.getString("coverArtId", ""), CustomGlideRequest.ResourceType.Song)
.build() .build()
.into(bind.trackCoverInfoImageView); .into(bind.trackCoverInfoImageView);
bind.titleValueSector.setText(mediaMetadata.extras.getString("title", getString(R.string.label_placeholder))); bindAssetLink(bind.trackCoverInfoImageView, albumLink != null ? albumLink : songLink);
bind.albumValueSector.setText(mediaMetadata.extras.getString("album", getString(R.string.label_placeholder))); bindAssetLink(bind.trakTitleInfoTextView, songLink);
bind.artistValueSector.setText(mediaMetadata.extras.getString("artist", getString(R.string.label_placeholder))); 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.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.yearValueSector.setText(yearValue != 0 ? String.valueOf(yearValue) : getString(R.string.label_placeholder));
bind.genreValueSector.setText(mediaMetadata.extras.getString("genre", 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.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.contentTypeValueSector.setText(mediaMetadata.extras.getString("contentType", getString(R.string.label_placeholder)));
bind.suffixValueSector.setText(mediaMetadata.extras.getString("suffix", 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.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.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)); 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); 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;
});
}
} }

View file

@ -32,17 +32,19 @@ import com.cappielloantonio.tempo.interfaces.ClickCallback;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter; import com.cappielloantonio.tempo.ui.adapter.AlbumCatalogueAdapter;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel; import com.cappielloantonio.tempo.viewmodel.AlbumCatalogueViewModel;
@OptIn(markerClass = UnstableApi.class) @OptIn(markerClass = UnstableApi.class)
public class AlbumCatalogueFragment extends Fragment implements ClickCallback { public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
private static final String TAG = "ArtistCatalogueFragment"; private static final String TAG = "AlbumCatalogueFragment";
private FragmentAlbumCatalogueBinding bind; private FragmentAlbumCatalogueBinding bind;
private MainActivity activity; private MainActivity activity;
private AlbumCatalogueViewModel albumCatalogueViewModel; private AlbumCatalogueViewModel albumCatalogueViewModel;
private AlbumCatalogueAdapter albumAdapter; private AlbumCatalogueAdapter albumAdapter;
private String currentSortOrder;
@Override @Override
public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreate(@Nullable Bundle savedInstanceState) {
@ -115,7 +117,10 @@ public class AlbumCatalogueFragment extends Fragment implements ClickCallback {
albumAdapter = new AlbumCatalogueAdapter(this, true); albumAdapter = new AlbumCatalogueAdapter(this, true);
albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY); albumAdapter.setStateRestorationPolicy(RecyclerView.Adapter.StateRestorationPolicy.PREVENT_WHEN_EMPTY);
bind.albumCatalogueRecyclerView.setAdapter(albumAdapter); 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) -> { bind.albumCatalogueRecyclerView.setOnTouchListener((v, event) -> {
hideKeyboard(v); 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 @Override
public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) {
inflater.inflate(R.menu.toolbar_menu, menu); 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.getMenuInflater().inflate(menuResource, popup.getMenu());
popup.setOnMenuItemClickListener(menuItem -> { popup.setOnMenuItemClickListener(menuItem -> {
String newSortOrder = null;
if (menuItem.getItemId() == R.id.menu_album_sort_name) { if (menuItem.getItemId() == R.id.menu_album_sort_name) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_NAME); newSortOrder = Constants.ALBUM_ORDER_BY_NAME;
return true;
} else if (menuItem.getItemId() == R.id.menu_album_sort_artist) { } else if (menuItem.getItemId() == R.id.menu_album_sort_artist) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_ARTIST); newSortOrder = Constants.ALBUM_ORDER_BY_ARTIST;
return true;
} else if (menuItem.getItemId() == R.id.menu_album_sort_year) { } else if (menuItem.getItemId() == R.id.menu_album_sort_year) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_YEAR); newSortOrder = Constants.ALBUM_ORDER_BY_YEAR;
return true;
} else if (menuItem.getItemId() == R.id.menu_album_sort_random) { } else if (menuItem.getItemId() == R.id.menu_album_sort_random) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RANDOM); newSortOrder = Constants.ALBUM_ORDER_BY_RANDOM;
return true;
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) { } else if (menuItem.getItemId() == R.id.menu_album_sort_recently_added) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_ADDED); newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_ADDED;
return true;
} else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) { } else if (menuItem.getItemId() == R.id.menu_album_sort_recently_played) {
albumAdapter.sort(Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED); newSortOrder = Constants.ALBUM_ORDER_BY_RECENTLY_PLAYED;
return true;
} else if (menuItem.getItemId() == R.id.menu_album_sort_most_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; return true;
} }

View file

@ -35,6 +35,7 @@ import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil; import com.cappielloantonio.tempo.util.MappingUtil;
@ -177,8 +178,35 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.albumNameLabel.setText(album.getName()); bind.albumNameLabel.setText(album.getName());
bind.albumArtistLabel.setText(album.getArtist()); 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.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)); 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()) { if (album.getGenre() != null && !album.getGenre().isEmpty()) {
bind.albumGenresTextview.setText(album.getGenre()); bind.albumGenresTextview.setText(album.getGenre());
@ -351,4 +379,23 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
private void setMediaBrowserListenableFuture() { private void setMediaBrowserListenableFuture() {
songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture); songHorizontalAdapter.setMediaBrowserListenableFuture(mediaBrowserListenableFuture);
} }
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());
}
} }

View file

@ -195,6 +195,7 @@ public class PlayerBottomSheetFragment extends Fragment {
} }
} }
private void setMediaControllerUI(MediaBrowser mediaBrowser) { private void setMediaControllerUI(MediaBrowser mediaBrowser) {
if (mediaBrowser.getMediaMetadata().extras != null) { if (mediaBrowser.getMediaMetadata().extras != null) {
switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) { switch (mediaBrowser.getMediaMetadata().extras.getString("type", Constants.MEDIA_TYPE_MUSIC)) {

View file

@ -13,9 +13,10 @@ import android.view.ViewGroup;
import android.widget.Button; import android.widget.Button;
import android.widget.ImageButton; import android.widget.ImageButton;
import android.widget.LinearLayout; import android.widget.LinearLayout;
import android.widget.RatingBar;
import android.widget.TextView; import android.widget.TextView;
import android.widget.ToggleButton; import android.widget.ToggleButton;
import android.widget.RatingBar; import android.widget.Toast;
import androidx.annotation.NonNull; import androidx.annotation.NonNull;
import androidx.constraintlayout.widget.ConstraintLayout; 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.RatingDialog;
import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog; import com.cappielloantonio.tempo.ui.dialog.TrackInfoDialog;
import com.cappielloantonio.tempo.ui.fragment.pager.PlayerControllerHorizontalPager; 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.Constants;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences; import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.RatingViewModel; import com.cappielloantonio.tempo.viewmodel.RatingViewModel;
import com.google.android.material.chip.Chip; import com.google.android.material.chip.Chip;
import com.google.android.material.chip.ChipGroup;
import com.google.android.material.elevation.SurfaceColors; import com.google.android.material.elevation.SurfaceColors;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
@ -76,6 +79,10 @@ public class PlayerControllerFragment extends Fragment {
private ImageButton playerTrackInfo; private ImageButton playerTrackInfo;
private LinearLayout ratingContainer; private LinearLayout ratingContainer;
private ImageButton equalizerButton; private ImageButton equalizerButton;
private ChipGroup assetLinkChipGroup;
private Chip playerSongLinkChip;
private Chip playerAlbumLinkChip;
private Chip playerArtistLinkChip;
private MainActivity activity; private MainActivity activity;
private PlayerBottomSheetViewModel playerBottomSheetViewModel; private PlayerBottomSheetViewModel playerBottomSheetViewModel;
@ -139,6 +146,10 @@ public class PlayerControllerFragment extends Fragment {
songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar); songRatingBar = bind.getRoot().findViewById(R.id.song_rating_bar);
ratingContainer = bind.getRoot().findViewById(R.id.rating_container); ratingContainer = bind.getRoot().findViewById(R.id.rating_container);
equalizerButton = bind.getRoot().findViewById(R.id.player_open_equalizer_button); 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(); 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 || mediaMetadata.extras != null && Objects.equals(mediaMetadata.extras.getString("type"), Constants.MEDIA_TYPE_RADIO) && mediaMetadata.extras.getString("uri") != null
? View.VISIBLE ? View.VISIBLE
: View.GONE); : View.GONE);
updateAssetLinkChips(mediaMetadata);
} }
private void setMediaInfo(MediaMetadata 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) { private void setMediaControllerUI(MediaBrowser mediaBrowser) {
initPlaybackSpeedButton(mediaBrowser); initPlaybackSpeedButton(mediaBrowser);

View file

@ -30,6 +30,7 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog; import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog; import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.ExternalAudioReader; import com.cappielloantonio.tempo.util.ExternalAudioReader;
@ -39,6 +40,8 @@ import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel; import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel; import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment; 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 com.google.common.util.concurrent.ListenableFuture;
import android.content.Intent; import android.content.Intent;
@ -56,6 +59,13 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private TextView downloadButton; private TextView downloadButton;
private TextView removeButton; 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<MediaBrowser> mediaBrowserListenableFuture; private ListenableFuture<MediaBrowser> mediaBrowserListenableFuture;
@ -109,6 +119,11 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
TextView artistSong = view.findViewById(R.id.song_artist_text_view); TextView artistSong = view.findViewById(R.id.song_artist_text_view);
artistSong.setText(songBottomSheetViewModel.getSong().getArtist()); 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); ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null); favoriteToggle.setChecked(songBottomSheetViewModel.getSong().getStarred() != null);
favoriteToggle.setOnClickListener(v -> { 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() { private void initializeMediaBrowser() {
mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync();
} }

View file

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

View file

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

View file

@ -17,6 +17,9 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity; 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.InputStream;
import java.io.OutputStream; import java.io.OutputStream;
import java.net.HttpURLConnection; import java.net.HttpURLConnection;
@ -102,35 +105,76 @@ public class ExternalAudioWriter {
ExternalDownloadMetadataStore.remove(metadataKey); ExternalDownloadMetadataStore.remove(metadataKey);
return; return;
} }
String scheme = mediaUri.getScheme();
if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) { String scheme = mediaUri.getScheme() != null ? mediaUri.getScheme().toLowerCase(Locale.ROOT) : "";
notifyFailure(context, "Unsupported media URI.");
ExternalDownloadMetadataStore.remove(metadataKey);
return;
}
HttpURLConnection connection = null; HttpURLConnection connection = null;
DocumentFile sourceDocument = null;
File sourceFile = null;
long remoteLength = -1;
String mimeType = null;
DocumentFile targetFile = 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(); try {
if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) { if (scheme.equals("http") || scheme.equals("https")) {
notifyFailure(context, "Server returned " + responseCode); 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); ExternalDownloadMetadataStore.remove(metadataKey);
return; return;
} }
String mimeType = connection.getContentType();
if (mimeType == null || mimeType.isEmpty()) { if (mimeType == null || mimeType.isEmpty()) {
mimeType = "application/octet-stream"; mimeType = "application/octet-stream";
} }
String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType); 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()) { if (extension == null || extension.isEmpty()) {
String suffix = child.getSuffix(); String suffix = child.getSuffix();
if (suffix != null && !suffix.isEmpty()) { if (suffix != null && !suffix.isEmpty()) {
@ -146,7 +190,6 @@ public class ExternalAudioWriter {
String fileName = sanitized + "." + extension; String fileName = sanitized + "." + extension;
DocumentFile existingFile = findFile(directory, fileName); DocumentFile existingFile = findFile(directory, fileName);
long remoteLength = connection.getContentLengthLong();
Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey); Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
if (existingFile != null && existingFile.exists()) { if (existingFile != null && existingFile.exists()) {
long localLength = existingFile.length(); long localLength = existingFile.length();
@ -175,7 +218,7 @@ public class ExternalAudioWriter {
} }
Uri targetUri = targetFile.getUri(); Uri targetUri = targetFile.getUri();
try (InputStream in = connection.getInputStream(); try (InputStream in = openInputStream(context, mediaUri, scheme, connection, sourceFile);
OutputStream out = context.getContentResolver().openOutputStream(targetUri)) { OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
if (out == null) { if (out == null) {
notifyFailure(context, "Cannot open output stream."); notifyFailure(context, "Cannot open output stream.");
@ -319,4 +362,32 @@ public class ExternalAudioWriter {
PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
); );
} }
private static InputStream openInputStream(Context context,
Uri mediaUri,
String scheme,
HttpURLConnection connection,
File sourceFile) throws IOException {
switch (scheme) {
case "http":
case "https":
if (connection == null) {
throw new IOException("Connection not initialized");
}
return connection.getInputStream();
case "content":
InputStream contentStream = context.getContentResolver().openInputStream(mediaUri);
if (contentStream == null) {
throw new IOException("Cannot open content stream");
}
return contentStream;
case "file":
if (sourceFile == null || !sourceFile.exists()) {
throw new IOException("Missing source file");
}
return new FileInputStream(sourceFile);
default:
throw new IOException("Unsupported scheme " + scheme);
}
}
} }

View file

@ -74,6 +74,12 @@ public class MappingUtil {
bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0); bundle.putInt("originalWidth", media.getOriginalWidth() != null ? media.getOriginalWidth() : 0);
bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0); bundle.putInt("originalHeight", media.getOriginalHeight() != null ? media.getOriginalHeight() : 0);
bundle.putString("uri", uri.toString()); 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() return new MediaItem.Builder()
.setMediaId(media.getId()) .setMediaId(media.getId())

View file

@ -77,6 +77,8 @@ object Preferences {
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels" private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility" private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
private const val ALBUM_DETAIL = "album_detail" 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 @JvmStatic
fun getServer(): String? { fun getServer(): String? {
@ -644,4 +646,14 @@ object Preferences {
fun showAlbumDetail(): Boolean { fun showAlbumDetail(): Boolean {
return App.getInstance().preferences.getBoolean(ALBUM_DETAIL, false) 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()
}
} }

View file

@ -5,17 +5,20 @@ import android.appwidget.AppWidgetManager;
import android.appwidget.AppWidgetProvider; import android.appwidget.AppWidgetProvider;
import android.content.Context; import android.content.Context;
import android.content.Intent; import android.content.Intent;
import android.net.Uri;
import android.text.TextUtils;
import android.widget.RemoteViews; import android.widget.RemoteViews;
import com.cappielloantonio.tempo.R; import com.cappielloantonio.tempo.R;
import android.app.TaskStackBuilder; import android.app.TaskStackBuilder;
import android.app.PendingIntent;
import com.cappielloantonio.tempo.ui.activity.MainActivity; import com.cappielloantonio.tempo.ui.activity.MainActivity;
import android.util.Log; import android.util.Log;
import androidx.annotation.Nullable;
public class WidgetProvider extends AppWidgetProvider { public class WidgetProvider extends AppWidgetProvider {
private static final String TAG = "TempoWidget"; private static final String TAG = "TempoWidget";
public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE"; 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) { public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) {
for (int id : ids) { for (int id : ids) {
RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id); RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id);
attachIntents(ctx, rv, id); attachIntents(ctx, rv, id, null, null, null);
mgr.updateAppWidget(id, rv); 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) { public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) {
super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions); super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId); RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId);
attachIntents(context, rv, appWidgetId); attachIntents(context, rv, appWidgetId, null, null, null);
appWidgetManager.updateAppWidget(appWidgetId, rv); appWidgetManager.updateAppWidget(appWidgetId, rv);
WidgetUpdateManager.refreshFromController(context); WidgetUpdateManager.refreshFromController(context);
} }
public static void attachIntents(Context ctx, RemoteViews rv) { 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) { 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( PendingIntent playPause = PendingIntent.getBroadcast(
ctx, ctx,
requestCodeBase + 0, requestCodeBase + 0,
@ -97,9 +107,31 @@ public class WidgetProvider extends AppWidgetProvider {
rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle); rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle);
rv.setOnClickPendingIntent(R.id.btn_repeat, repeat); rv.setOnClickPendingIntent(R.id.btn_repeat, repeat);
PendingIntent launch = TaskStackBuilder.create(ctx) PendingIntent launch = buildMainActivityPendingIntent(ctx, requestCodeBase + 10, null);
.addNextIntentWithParentStack(new Intent(ctx, MainActivity.class))
.getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
rv.setOnClickPendingIntent(R.id.root, launch); 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);
} }
} }

View file

@ -4,8 +4,9 @@ import android.appwidget.AppWidgetManager;
import android.content.ComponentName; import android.content.ComponentName;
import android.content.Context; import android.content.Context;
import android.graphics.Bitmap; import android.graphics.Bitmap;
import android.text.TextUtils;
import android.graphics.drawable.Drawable; 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.target.CustomTarget;
import com.bumptech.glide.request.transition.Transition; import com.bumptech.glide.request.transition.Transition;
@ -17,6 +18,7 @@ import androidx.media3.session.MediaController;
import androidx.media3.session.SessionToken; import androidx.media3.session.SessionToken;
import com.cappielloantonio.tempo.service.MediaService; import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.util.AssetLinkUtil;
import com.cappielloantonio.tempo.util.MusicUtil; import com.cappielloantonio.tempo.util.MusicUtil;
import com.google.common.util.concurrent.ListenableFuture; import com.google.common.util.concurrent.ListenableFuture;
import com.google.common.util.concurrent.MoreExecutors; import com.google.common.util.concurrent.MoreExecutors;
@ -25,6 +27,8 @@ import java.util.concurrent.ExecutionException;
public final class WidgetUpdateManager { public final class WidgetUpdateManager {
private static final int WIDGET_SAFE_ART_SIZE = 512;
public static void updateFromState(Context ctx, public static void updateFromState(Context ctx,
String title, String title,
String artist, String artist,
@ -34,7 +38,10 @@ public final class WidgetUpdateManager {
boolean shuffleEnabled, boolean shuffleEnabled,
int repeatMode, int repeatMode,
long positionMs, 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(title)) title = ctx.getString(R.string.widget_not_playing);
if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle); if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle);
if (TextUtils.isEmpty(album)) album = ""; if (TextUtils.isEmpty(album)) album = "";
@ -46,7 +53,7 @@ public final class WidgetUpdateManager {
for (int id : ids) { for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing, android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing,
timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id); 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); mgr.updateAppWidget(id, rv);
} }
} }
@ -56,7 +63,7 @@ public final class WidgetUpdateManager {
int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class)); int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
for (int id : ids) { for (int id : ids) {
android.widget.RemoteViews rv = chooseBuild(ctx, id); android.widget.RemoteViews rv = chooseBuild(ctx, id);
WidgetProvider.attachIntents(ctx, rv, id); WidgetProvider.attachIntents(ctx, rv, id, null, null, null);
mgr.updateAppWidget(id, rv); mgr.updateAppWidget(id, rv);
} }
} }
@ -70,7 +77,10 @@ public final class WidgetUpdateManager {
boolean shuffleEnabled, boolean shuffleEnabled,
int repeatMode, int repeatMode,
long positionMs, long positionMs,
long durationMs) { long durationMs,
String songLink,
String albumLink,
String artistLink) {
final Context appCtx = ctx.getApplicationContext(); final Context appCtx = ctx.getApplicationContext();
final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title; 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; 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 boolean sh = shuffleEnabled;
final int rep = repeatMode; final int rep = repeatMode;
final TimingInfo timing = createTimingInfo(positionMs, durationMs); final TimingInfo timing = createTimingInfo(positionMs, durationMs);
final String songLinkFinal = songLink;
final String albumLinkFinal = albumLink;
final String artistLinkFinal = artistLink;
if (!TextUtils.isEmpty(coverArtId)) { if (!TextUtils.isEmpty(coverArtId)) {
CustomGlideRequest.loadAlbumArtBitmap( CustomGlideRequest.loadAlbumArtBitmap(
appCtx, appCtx,
coverArtId, coverArtId,
com.cappielloantonio.tempo.util.Preferences.getImageSize(), WIDGET_SAFE_ART_SIZE,
new CustomTarget<Bitmap>() { new CustomTarget<Bitmap>() {
@Override @Override
public void onResourceReady(Bitmap resource, Transition<? super Bitmap> transition) { public void onResourceReady(Bitmap resource, Transition<? super Bitmap> transition) {
@ -93,7 +106,7 @@ public final class WidgetUpdateManager {
for (int id : ids) { for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p, android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p,
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); 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); mgr.updateAppWidget(id, rv);
} }
} }
@ -105,7 +118,7 @@ public final class WidgetUpdateManager {
for (int id : ids) { for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); 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); mgr.updateAppWidget(id, rv);
} }
} }
@ -117,7 +130,7 @@ public final class WidgetUpdateManager {
for (int id : ids) { for (int id : ids) {
android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p, android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
timing.elapsedText, timing.totalText, timing.progress, sh, rep, id); 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); mgr.updateAppWidget(id, rv);
} }
} }
@ -133,6 +146,7 @@ public final class WidgetUpdateManager {
MediaController c = future.get(); MediaController c = future.get();
androidx.media3.common.MediaItem mi = c.getCurrentMediaItem(); androidx.media3.common.MediaItem mi = c.getCurrentMediaItem();
String title = null, artist = null, album = null, coverId = null; String title = null, artist = null, album = null, coverId = null;
String songLink = null, albumLink = null, artistLink = null;
if (mi != null && mi.mediaMetadata != null) { if (mi != null && mi.mediaMetadata != null) {
if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString(); if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString();
if (mi.mediaMetadata.artist != null) if (mi.mediaMetadata.artist != null)
@ -140,10 +154,26 @@ public final class WidgetUpdateManager {
if (mi.mediaMetadata.albumTitle != null) if (mi.mediaMetadata.albumTitle != null)
album = mi.mediaMetadata.albumTitle.toString(); album = mi.mediaMetadata.albumTitle.toString();
if (mi.mediaMetadata.extras != null) { if (mi.mediaMetadata.extras != null) {
Bundle extras = mi.mediaMetadata.extras;
if (title == null) title = mi.mediaMetadata.extras.getString("title"); if (title == null) title = mi.mediaMetadata.extras.getString("title");
if (artist == null) artist = mi.mediaMetadata.extras.getString("artist"); if (artist == null) artist = mi.mediaMetadata.extras.getString("artist");
if (album == null) album = mi.mediaMetadata.extras.getString("album"); 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(); long position = c.getCurrentPosition();
@ -159,7 +189,10 @@ public final class WidgetUpdateManager {
c.getShuffleModeEnabled(), c.getShuffleModeEnabled(),
c.getRepeatMode(), c.getRepeatMode(),
position, position,
duration); duration,
songLink,
albumLink,
artistLink);
c.release(); c.release();
} catch (ExecutionException | InterruptedException ignored) { } catch (ExecutionException | InterruptedException ignored) {
} }

View file

@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="?attr/colorOnSurfaceVariant"
android:pathData="M3.9,12c0,1.71 1.39,3.1 3.1,3.1h3v1.8h-3c-2.7,0 -4.9,-2.2 -4.9,-4.9s2.2,-4.9 4.9,-4.9h3v1.8h-3c-1.71,0 -3.1,1.39 -3.1,3.1zM7,13h10v-2H7v2zM17,6.9h-3v-1.8h3c2.7,0 4.9,2.2 4.9,4.9s-2.2,4.9 -4.9,4.9h-3v-1.8h3c1.71,0 3.1,-1.39 3.1,-3.1s-1.39,-3.1 -3.1,-3.1z" />
</vector>

View file

@ -68,6 +68,14 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<include
android:id="@+id/song_asset_link_row"
layout="@layout/view_asset_link_row"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:paddingStart="20dp"
android:paddingEnd="12dp" />
<LinearLayout <LinearLayout
android:id="@+id/option_linear_layout" android:id="@+id/option_linear_layout"
android:layout_width="match_parent" android:layout_width="match_parent"

View file

@ -41,23 +41,40 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent" />
<Button <LinearLayout
android:id="@+id/album_list_sort_image_view" android:id="@+id/sort_container"
style="@style/Widget.Material3.Button.TonalButton.Icon" android:layout_width="wrap_content"
android:layout_width="52dp" android:layout_height="wrap_content"
android:layout_height="52dp"
android:layout_marginStart="16dp" android:layout_marginStart="16dp"
android:layout_marginTop="16dp" android:layout_marginTop="16dp"
android:layout_marginEnd="16dp" android:layout_marginEnd="16dp"
android:layout_marginBottom="12dp" android:layout_marginBottom="12dp"
android:insetLeft="0dp" android:orientation="horizontal"
android:insetTop="0dp" android:gravity="center_vertical"
android:insetRight="0dp"
android:insetBottom="0dp"
app:cornerRadius="30dp"
app:icon="@drawable/ic_sort_list"
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent" /> app:layout_constraintEnd_toEndOf="parent">
<TextView
android:id="@+id/albumListSortTextView"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textAppearance="@style/TextAppearance.Material3.BodySmall"
android:paddingEnd="8dp"
android:visibility="gone" />
<Button
android:id="@+id/album_list_sort_image_view"
style="@style/Widget.Material3.Button.TonalButton.Icon"
android:layout_width="52dp"
android:layout_height="52dp"
android:insetLeft="0dp"
android:insetTop="0dp"
android:insetRight="0dp"
android:insetBottom="0dp"
app:cornerRadius="30dp"
app:icon="@drawable/ic_sort_list" />
</LinearLayout>
<ProgressBar <ProgressBar
android:id="@+id/album_list_progress_loader" android:id="@+id/album_list_progress_loader"
@ -71,7 +88,6 @@
app:layout_constraintBottom_toBottomOf="parent" app:layout_constraintBottom_toBottomOf="parent"
app:layout_constraintEnd_toEndOf="parent"/> app:layout_constraintEnd_toEndOf="parent"/>
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
</com.google.android.material.appbar.AppBarLayout> </com.google.android.material.appbar.AppBarLayout>
@ -87,4 +103,3 @@
app:layout_behavior="@string/appbar_scrolling_view_behavior" /> app:layout_behavior="@string/appbar_scrolling_view_behavior" />
</androidx.coordinatorlayout.widget.CoordinatorLayout> </androidx.coordinatorlayout.widget.CoordinatorLayout>
</LinearLayout> </LinearLayout>

View file

@ -57,6 +57,17 @@
</androidx.constraintlayout.widget.ConstraintLayout> </androidx.constraintlayout.widget.ConstraintLayout>
<include
android:id="@+id/player_asset_link_row"
layout="@layout/view_asset_link_row"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:layout_marginEnd="16dp"
app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@id/player_media_quality_sector" />
<androidx.viewpager2.widget.ViewPager2 <androidx.viewpager2.widget.ViewPager2
android:id="@+id/player_media_cover_view_pager" android:id="@+id/player_media_cover_view_pager"
android:layout_width="0dp" android:layout_width="0dp"
@ -66,7 +77,7 @@
app:layout_constraintBottom_toTopOf="@id/guideline" app:layout_constraintBottom_toTopOf="@id/guideline"
app:layout_constraintEnd_toEndOf="parent" app:layout_constraintEnd_toEndOf="parent"
app:layout_constraintStart_toStartOf="parent" app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/player_media_quality_sector" /> app:layout_constraintTop_toBottomOf="@+id/player_asset_link_row" />
<androidx.constraintlayout.widget.Guideline <androidx.constraintlayout.widget.Guideline
android:id="@+id/guideline" android:id="@+id/guideline"

View file

@ -0,0 +1,55 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.chip.ChipGroup xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/asset_link_chip_group"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone"
android:paddingTop="4dp"
android:paddingBottom="4dp"
app:singleLine="true"
app:selectionRequired="false"
app:singleSelection="false">
<com.google.android.material.chip.Chip
android:id="@+id/asset_link_song_chip"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checkable="false"
android:clickable="true"
android:ellipsize="end"
android:maxLines="1"
android:text=""
app:chipIcon="@drawable/ic_link"
app:chipIconTint="?attr/colorOnSurfaceVariant"
app:rippleColor="@color/ripple_material_light" />
<com.google.android.material.chip.Chip
android:id="@+id/asset_link_album_chip"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checkable="false"
android:clickable="true"
android:ellipsize="end"
android:maxLines="1"
android:text=""
app:chipIcon="@drawable/ic_link"
app:chipIconTint="?attr/colorOnSurfaceVariant"
app:rippleColor="@color/ripple_material_light" />
<com.google.android.material.chip.Chip
android:id="@+id/asset_link_artist_chip"
style="@style/Widget.Material3.Chip.Assist"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:checkable="false"
android:clickable="true"
android:ellipsize="end"
android:maxLines="1"
android:text=""
app:chipIcon="@drawable/ic_link"
app:chipIconTint="?attr/colorOnSurfaceVariant"
app:rippleColor="@color/ripple_material_light" />
</com.google.android.material.chip.ChipGroup>

View file

@ -88,6 +88,9 @@
<string name="error_required">Wymagane</string> <string name="error_required">Wymagane</string>
<string name="error_server_prefix">wymagany jest prefiks http lub https</string> <string name="error_server_prefix">wymagany jest prefiks http lub https</string>
<string name="exo_download_notification_channel_name">Pobieranie</string> <string name="exo_download_notification_channel_name">Pobieranie</string>
<string name="exo_controls_heart_off_description">Wyłącz serce</string>
<string name="exo_controls_heart_on_description">Włącz serce</string>
<string name="cast_expanded_controller_loading">Ładowanie…</string>
<string name="filter_info_selection">Wybierz dwa lub więcej filtrów</string> <string name="filter_info_selection">Wybierz dwa lub więcej filtrów</string>
<string name="filter_title">Filtry</string> <string name="filter_title">Filtry</string>
<string name="filter_artist">Filtruj wykonawców</string> <string name="filter_artist">Filtruj wykonawców</string>
@ -213,8 +216,9 @@
<string name="playlist_chooser_dialog_negative_button">Anuluj</string> <string name="playlist_chooser_dialog_negative_button">Anuluj</string>
<string name="playlist_chooser_dialog_neutral_button">Utwórz</string> <string name="playlist_chooser_dialog_neutral_button">Utwórz</string>
<string name="playlist_chooser_dialog_title">Dodaj do playlisty</string> <string name="playlist_chooser_dialog_title">Dodaj do playlisty</string>
<string name="playlist_chooser_dialog_toast_add_success">Dodano piosenkę do playlisty</string> <string name="playlist_chooser_dialog_toast_add_success">Dodano piosenki do playlisty</string>
<string name="playlist_chooser_dialog_toast_add_failure">Nie udało się dodać piosenki do playlisty</string> <string name="playlist_chooser_dialog_toast_add_failure">Nie udało się dodać piosenek do playlisty</string>
<string name="playlist_chooser_dialog_toast_all_skipped">Pominięto wszystkie piosenki jako duplikaty</string>
<string name="playlist_counted_tracks">%1$d utworów • %2$s</string> <string name="playlist_counted_tracks">%1$d utworów • %2$s</string>
<string name="playlist_duration">Długość • %1$s</string> <string name="playlist_duration">Długość • %1$s</string>
<string name="playlist_editor_dialog_action_delete_toast">Przytrzymaj aby usunąć</string> <string name="playlist_editor_dialog_action_delete_toast">Przytrzymaj aby usunąć</string>
@ -281,6 +285,8 @@
<string name="settings_about_summary">Tempo jest otwarto-źródłowym i lekkim klientem muzycznym dla Subsonic, stworzonym i zbudowanym natywnie dla Androida.</string> <string name="settings_about_summary">Tempo jest otwarto-źródłowym i lekkim klientem muzycznym dla Subsonic, stworzonym i zbudowanym natywnie dla Androida.</string>
<string name="settings_about_title">O aplikacji</string> <string name="settings_about_title">O aplikacji</string>
<string name="settings_always_on_display">Always on display</string> <string name="settings_always_on_display">Always on display</string>
<string name="settings_allow_playlist_duplicates">Zezwalaj na dodawania duplikatów do playlist</string>
<string name="settings_allow_playlist_duplicates_summary">Jeżeli włączone, duplikaty nie będą sprawdzane podczas dodawania do playlisty.</string>
<string name="settings_audio_transcode_download_format">Format transkodowania</string> <string name="settings_audio_transcode_download_format">Format transkodowania</string>
<string name="settings_audio_transcode_download_priority_summary">Jeżeli włączone, Tempo nie będzię wymuszał pobierania utworu z ustawieniami transkodowania wybranymi poniżej.</string> <string name="settings_audio_transcode_download_priority_summary">Jeżeli włączone, Tempo nie będzię wymuszał pobierania utworu z ustawieniami transkodowania wybranymi poniżej.</string>
<string name="settings_audio_transcode_download_priority_title">Priorytetyzuj ustawienia serwera używanego do strumieniowania w pobieraniach</string> <string name="settings_audio_transcode_download_priority_title">Priorytetyzuj ustawienia serwera używanego do strumieniowania w pobieraniach</string>
@ -296,6 +302,8 @@
<string name="settings_audio_transcode_priority_toast">Priorytet przy transkodowaniu utworu danego serwerowi</string> <string name="settings_audio_transcode_priority_toast">Priorytet przy transkodowaniu utworu danego serwerowi</string>
<string name="settings_buffering_strategy">Strategia buforowania</string> <string name="settings_buffering_strategy">Strategia buforowania</string>
<string name="settings_buffering_strategy_summary">Aby zmiany przyniosły efekt, musisz ręcznie zrestartować aplikację.</string> <string name="settings_buffering_strategy_summary">Aby zmiany przyniosły efekt, musisz ręcznie zrestartować aplikację.</string>
<string name="settings_choose_download_folder">Wybierz folder dla pobranych plików muzycznych</string>
<string name="settings_clear_download_folder">Wyczyść folder pobierania</string>
<string name="settings_continuous_play_summary">Pozwala muzyce odtwarzać się dalej po końcu playlisty, odtwarza podobne piosenki</string> <string name="settings_continuous_play_summary">Pozwala muzyce odtwarzać się dalej po końcu playlisty, odtwarza podobne piosenki</string>
<string name="settings_continuous_play_title">Odtwarzanie bez przerwy</string> <string name="settings_continuous_play_title">Odtwarzanie bez przerwy</string>
<string name="settings_covers_cache">Rozmiar cache dla okładek</string> <string name="settings_covers_cache">Rozmiar cache dla okładek</string>
@ -304,6 +312,9 @@
<string name="settings_delete_download_storage_summary">Zatwierdzenie nieodwracalnie usunie wszystkie zapisane elementy</string> <string name="settings_delete_download_storage_summary">Zatwierdzenie nieodwracalnie usunie wszystkie zapisane elementy</string>
<string name="settings_delete_download_storage_title">Usuń zapisane elementy</string> <string name="settings_delete_download_storage_title">Usuń zapisane elementy</string>
<string name="settings_download_storage_title">Pamięć do pobierania</string> <string name="settings_download_storage_title">Pamięć do pobierania</string>
<string name="settings_download_folder_cleared">Utworzono folder pobierania.</string>
<string name="settings_download_folder_set">Wybrano folder pobierania</string>
<string name="settings_set_download_folder">Ustaw folder pobierania</string>
<string name="settings_system_equalizer_summary">Zmień ustawienia audio</string> <string name="settings_system_equalizer_summary">Zmień ustawienia audio</string>
<string name="settings_system_equalizer_title">Korektor systemowy</string> <string name="settings_system_equalizer_title">Korektor systemowy</string>
<string name="settings_github_link">https://github.com/eddyizm/tempo</string> <string name="settings_github_link">https://github.com/eddyizm/tempo</string>
@ -312,6 +323,7 @@
<string name="settings_support_discussion_link">https://github.com/eddyizm/tempo/discussions</string> <string name="settings_support_discussion_link">https://github.com/eddyizm/tempo/discussions</string>
<string name="settings_support_summary">Dołącz do dyskusji i wsparcia społeczności</string> <string name="settings_support_summary">Dołącz do dyskusji i wsparcia społeczności</string>
<string name="settings_support_title">Wsparcie użytkowników</string> <string name="settings_support_title">Wsparcie użytkowników</string>
<string name="settings_scan_result">Skanowanie: naliczono %1$d utworów</string>
<string name="settings_image_size">Rozdzielczość obrazów</string> <string name="settings_image_size">Rozdzielczość obrazów</string>
<string name="settings_language">Język</string> <string name="settings_language">Język</string>
<string name="settings_logout_title">Wyloguj</string> <string name="settings_logout_title">Wyloguj</string>
@ -332,6 +344,8 @@
<string name="settings_queue_syncing_countdown">Timer synchronizacji</string> <string name="settings_queue_syncing_countdown">Timer synchronizacji</string>
<string name="settings_queue_syncing_summary">Jeżeli włączone, użytkownik będzie miał możliwość zapisania kolejki i będzie miał możliwość załadowania jej stanu przy otwarciu aplikacji.</string> <string name="settings_queue_syncing_summary">Jeżeli włączone, użytkownik będzie miał możliwość zapisania kolejki i będzie miał możliwość załadowania jej stanu przy otwarciu aplikacji.</string>
<string name="settings_queue_syncing_title">Synchronizuj kolejkę odtwarzania dla tego użytkownika [Niedokończone]</string> <string name="settings_queue_syncing_title">Synchronizuj kolejkę odtwarzania dla tego użytkownika [Niedokończone]</string>
<string name="settings_show_mini_shuffle_button">Pokaż przycisk odtwarzania losowego</string>
<string name="settings_show_mini_shuffle_button_summary">Jeżeli włączone, pokazuje przycisk losowego odtwarzania, i usuwa przycisk serca w mini odtwarzaczu</string>
<string name="settings_radio">Pokaż radio</string> <string name="settings_radio">Pokaż radio</string>
<string name="settings_radio_summary">Jeżeli włączone, widoczna będzie sekcja radia. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.</string> <string name="settings_radio_summary">Jeżeli włączone, widoczna będzie sekcja radia. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.</string>
<string name="settings_auto_download_lyrics">Automatyczne pobieranie tesktów</string> <string name="settings_auto_download_lyrics">Automatyczne pobieranie tesktów</string>
@ -366,6 +380,7 @@
<string name="settings_theme">Motyw</string> <string name="settings_theme">Motyw</string>
<string name="settings_title_data">Dane</string> <string name="settings_title_data">Dane</string>
<string name="settings_title_general">Ogólne</string> <string name="settings_title_general">Ogólne</string>
<string name="settings_title_playlist">Playlisty</string>
<string name="settings_title_rating">Oceny</string> <string name="settings_title_rating">Oceny</string>
<string name="settings_title_replay_gain">Wzmocnienie głośności przy ponownym odtwarzaniu</string> <string name="settings_title_replay_gain">Wzmocnienie głośności przy ponownym odtwarzaniu</string>
<string name="settings_title_scrobble">Scrobble</string> <string name="settings_title_scrobble">Scrobble</string>
@ -454,6 +469,17 @@
<string name="undraw_page">unDraw</string> <string name="undraw_page">unDraw</string>
<string name="undraw_thanks">Specjalne podziękowania dla unDraw bez którego ilustracji nie mogliśmy uczynić tej aplikacji jeszcze piękniejszą.</string> <string name="undraw_thanks">Specjalne podziękowania dla unDraw bez którego ilustracji nie mogliśmy uczynić tej aplikacji jeszcze piękniejszą.</string>
<string name="undraw_url">https://undraw.co/</string> <string name="undraw_url">https://undraw.co/</string>
<string name="widget_label">Widget Tempo</string>
<string name="widget_not_playing">Nie odtwarza</string>
<string name="widget_placeholder_subtitle">Otwórz Tempo</string>
<string name="widget_time_elapsed_placeholder">0:00</string>
<string name="widget_time_duration_placeholder">0:00</string>
<string name="widget_content_desc_album_art">Okładka albumu</string>
<string name="widget_content_desc_play_pause">Play lub pauza</string>
<string name="widget_content_desc_next">Następny utwór</string>
<string name="widget_content_desc_prev">Poprzedni utwór</string>
<string name="widget_content_desc_shuffle">Przełącznik odtwarzania losowego</string>
<string name="widget_content_desc_repeat">Zmień tryb powtarzania</string>
<plurals name="home_sync_starred_albums_count"> <plurals name="home_sync_starred_albums_count">
<item quantity="one">%d album do zsynchronizowania </item> <item quantity="one">%d album do zsynchronizowania </item>
<item quantity="other">%d albumów do zsynchrpnizowania</item> <item quantity="other">%d albumów do zsynchrpnizowania</item>

View file

@ -0,0 +1,3 @@
<resources>
<item name="tag_link_original_color" type="id" />
</resources>

View file

@ -410,6 +410,22 @@
<string name="share_bottom_sheet_update">Update share</string> <string name="share_bottom_sheet_update">Update share</string>
<string name="share_subtitle_item">Expiration date: %1$s</string> <string name="share_subtitle_item">Expiration date: %1$s</string>
<string name="share_unsupported_error">Sharing is not supported or not enabled</string> <string name="share_unsupported_error">Sharing is not supported or not enabled</string>
<string name="asset_link_clipboard_label">Tempo asset link</string>
<string name="asset_link_label_song">Song UID</string>
<string name="asset_link_label_album">Album UID</string>
<string name="asset_link_label_artist">Artist UID</string>
<string name="asset_link_label_playlist">Playlist UID</string>
<string name="asset_link_label_genre">Genre UID</string>
<string name="asset_link_label_year">Year UID</string>
<string name="asset_link_label_unknown">Asset UID</string>
<string name="asset_link_error_unsupported">Unsupported asset link</string>
<string name="asset_link_error_song">Song could not be opened</string>
<string name="asset_link_error_album">Album could not be opened</string>
<string name="asset_link_error_artist">Artist could not be opened</string>
<string name="asset_link_error_playlist">Playlist could not be opened</string>
<string name="asset_link_chip_text">%1$s • %2$s</string>
<string name="asset_link_copied_toast">Copied %1$s to clipboard</string>
<string name="asset_link_debug_toast">Asset link: %1$s</string>
<string name="share_update_dialog_hint_description">Description</string> <string name="share_update_dialog_hint_description">Description</string>
<string name="share_update_dialog_hint_expiration_date">Expiration date</string> <string name="share_update_dialog_hint_expiration_date">Expiration date</string>
<string name="share_update_dialog_negative_button">Cancel</string> <string name="share_update_dialog_negative_button">Cancel</string>

View file

@ -14,15 +14,19 @@ import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl import androidx.media3.exoplayer.DefaultLoadControl
import androidx.media3.exoplayer.ExoPlayer import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.source.MediaSource
import androidx.media3.exoplayer.source.TrackGroupArray import androidx.media3.exoplayer.source.TrackGroupArray
import androidx.media3.exoplayer.trackselection.TrackSelectionArray import androidx.media3.exoplayer.trackselection.TrackSelectionArray
import androidx.media3.session.* import androidx.media3.session.*
import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.R import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.cappielloantonio.tempo.widget.WidgetUpdateManager
@ -83,6 +87,7 @@ class MediaService : MediaLibraryService() {
initializeCustomCommands() initializeCustomCommands()
initializePlayer() initializePlayer()
initializeMediaLibrarySession() initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener() initializePlayerListener()
initializeEqualizerManager() initializeEqualizerManager()
@ -118,15 +123,17 @@ class MediaService : MediaLibraryService() {
val connectionResult = super.onConnect(session, controller) val connectionResult = super.onConnect(session, controller)
val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon() val availableSessionCommands = connectionResult.availableSessionCommands.buildUpon()
shuffleCommands.forEach { commandButton -> (shuffleCommands + repeatCommands).forEach { commandButton ->
// TODO: Aggiungere i comandi personalizzati commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
// commandButton.sessionCommand?.let { availableSessionCommands.add(it) }
} }
return MediaSession.ConnectionResult.accept( customLayout = buildCustomLayout(session.player)
availableSessionCommands.build(),
connectionResult.availablePlayerCommands return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
) .setAvailableSessionCommands(availableSessionCommands.build())
.setAvailablePlayerCommands(connectionResult.availablePlayerCommands)
.setCustomLayout(customLayout)
.build()
} }
override fun onPostConnect(session: MediaSession, controller: ControllerInfo) { override fun onPostConnect(session: MediaSession, controller: ControllerInfo) {
@ -225,7 +232,7 @@ class MediaService : MediaLibraryService() {
private fun initializePlayer() { private fun initializePlayer() {
player = ExoPlayer.Builder(this) player = ExoPlayer.Builder(this)
.setRenderersFactory(getRenderersFactory()) .setRenderersFactory(getRenderersFactory())
.setMediaSourceFactory(DynamicMediaSourceFactory(this)) .setMediaSourceFactory(getMediaSourceFactory())
.setAudioAttributes(AudioAttributes.DEFAULT, true) .setAudioAttributes(AudioAttributes.DEFAULT, true)
.setHandleAudioBecomingNoisy(true) .setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_NETWORK) .setWakeMode(C.WAKE_MODE_NETWORK)
@ -268,6 +275,33 @@ class MediaService : MediaLibraryService() {
} }
} }
private fun restorePlayerFromQueue() {
if (player.mediaItemCount > 0) return
val queueRepository = QueueRepository()
val storedQueue = queueRepository.media
if (storedQueue.isNullOrEmpty()) return
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
if (mediaItems.isEmpty()) return
val lastIndex = try {
queueRepository.lastPlayedMediaIndex
} catch (_: Exception) {
0
}.coerceIn(0, mediaItems.size - 1)
val lastPosition = try {
queueRepository.lastPlayedMediaTimestamp
} catch (_: Exception) {
0L
}.let { if (it < 0L) 0L else it }
player.setMediaItems(mediaItems, lastIndex, lastPosition)
player.prepare()
updateWidget()
}
private fun initializePlayerListener() { private fun initializePlayerListener() {
player.addListener(object : Player.Listener { player.addListener(object : Player.Listener {
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
@ -398,7 +432,7 @@ class MediaService : MediaLibraryService() {
.build() .build()
} }
private fun ignoreFuture(customLayout: ListenableFuture<SessionResult>) { private fun ignoreFuture(@Suppress("UNUSED_PARAMETER") customLayout: ListenableFuture<SessionResult>) {
/* Do nothing. */ /* Do nothing. */
} }
@ -421,7 +455,14 @@ class MediaService : MediaLibraryService() {
?: mi?.mediaMetadata?.extras?.getString("artist") ?: mi?.mediaMetadata?.extras?.getString("artist")
val album = mi?.mediaMetadata?.albumTitle?.toString() val album = mi?.mediaMetadata?.albumTitle?.toString()
?: mi?.mediaMetadata?.extras?.getString("album") ?: mi?.mediaMetadata?.extras?.getString("album")
val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") val extras = mi?.mediaMetadata?.extras
val coverId = extras?.getString("coverArtId")
val songLink = extras?.getString("assetLinkSong")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
val albumLink = extras?.getString("assetLinkAlbum")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
val artistLink = extras?.getString("assetLinkArtist")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
WidgetUpdateManager.updateFromState( WidgetUpdateManager.updateFromState(
@ -434,7 +475,10 @@ class MediaService : MediaLibraryService() {
player.shuffleModeEnabled, player.shuffleModeEnabled,
player.repeatMode, player.repeatMode,
position, position,
duration duration,
songLink,
albumLink,
artistLink
) )
} }
@ -453,6 +497,7 @@ class MediaService : MediaLibraryService() {
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false) private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
private fun getMediaSourceFactory(): MediaSource.Factory = DynamicMediaSourceFactory(this)
} }
private const val WIDGET_UPDATE_INTERVAL_MS = 1000L private const val WIDGET_UPDATE_INTERVAL_MS = 1000L

View file

@ -295,11 +295,6 @@ open class MediaLibrarySessionCallback(
args: Bundle args: Bundle
): ListenableFuture<SessionResult> { ): ListenableFuture<SessionResult> {
val mediaItemId = args.getString(
MediaConstants.EXTRA_KEY_MEDIA_ID,
session.player.currentMediaItem?.mediaId
)
when (customCommand.customAction) { when (customCommand.customAction) {
CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> { CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> {
session.player.shuffleModeEnabled = true session.player.shuffleModeEnabled = true

View file

@ -8,6 +8,7 @@ import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.os.Handler import android.os.Handler
import android.os.Looper import android.os.Looper
import androidx.core.content.ContextCompat
import androidx.media3.cast.CastPlayer import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes import androidx.media3.common.AudioAttributes
@ -21,10 +22,13 @@ import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.session.MediaLibraryService import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession.ControllerInfo import androidx.media3.session.MediaSession.ControllerInfo
import com.cappielloantonio.tempo.repository.AutomotiveRepository import com.cappielloantonio.tempo.repository.AutomotiveRepository
import com.cappielloantonio.tempo.repository.QueueRepository
import com.cappielloantonio.tempo.ui.activity.MainActivity import com.cappielloantonio.tempo.ui.activity.MainActivity
import com.cappielloantonio.tempo.util.AssetLinkUtil
import com.cappielloantonio.tempo.util.Constants import com.cappielloantonio.tempo.util.Constants
import com.cappielloantonio.tempo.util.DownloadUtil import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.MappingUtil
import com.cappielloantonio.tempo.util.Preferences import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil import com.cappielloantonio.tempo.util.ReplayGainUtil
import com.cappielloantonio.tempo.widget.WidgetUpdateManager import com.cappielloantonio.tempo.widget.WidgetUpdateManager
@ -70,9 +74,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
initializeRepository() initializeRepository()
initializePlayer() initializePlayer()
initializeCastPlayer()
initializeMediaLibrarySession() initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener() initializePlayerListener()
initializeCastPlayer()
initializeEqualizerManager() initializeEqualizerManager()
setPlayer( setPlayer(
@ -142,12 +147,20 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.repeatMode = Preferences.getRepeatMode() player.repeatMode = Preferences.getRepeatMode()
} }
@Suppress("DEPRECATION")
private fun initializeCastPlayer() { private fun initializeCastPlayer() {
if (GoogleApiAvailability.getInstance() if (GoogleApiAvailability.getInstance()
.isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS .isGooglePlayServicesAvailable(this) == ConnectionResult.SUCCESS
) { ) {
castPlayer = CastPlayer(CastContext.getSharedInstance(this)) CastContext.getSharedInstance(this, ContextCompat.getMainExecutor(this))
castPlayer.setSessionAvailabilityListener(this) .addOnSuccessListener { castContext ->
castPlayer = CastPlayer(castContext)
castPlayer.setSessionAvailabilityListener(this@MediaService)
if (castPlayer.isCastSessionAvailable && this::mediaLibrarySession.isInitialized) {
setPlayer(player, castPlayer)
}
}
} }
} }
@ -165,6 +178,33 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
.build() .build()
} }
private fun restorePlayerFromQueue() {
if (player.mediaItemCount > 0) return
val queueRepository = QueueRepository()
val storedQueue = queueRepository.media
if (storedQueue.isNullOrEmpty()) return
val mediaItems = MappingUtil.mapMediaItems(storedQueue)
if (mediaItems.isEmpty()) return
val lastIndex = try {
queueRepository.lastPlayedMediaIndex
} catch (_: Exception) {
0
}.coerceIn(0, mediaItems.size - 1)
val lastPosition = try {
queueRepository.lastPlayedMediaTimestamp
} catch (_: Exception) {
0L
}.let { if (it < 0L) 0L else it }
player.setMediaItems(mediaItems, lastIndex, lastPosition)
player.prepare()
updateWidget()
}
private fun createLibrarySessionCallback(): MediaLibrarySessionCallback { private fun createLibrarySessionCallback(): MediaLibrarySessionCallback {
return MediaLibrarySessionCallback(this, automotiveRepository) return MediaLibrarySessionCallback(this, automotiveRepository)
} }
@ -262,7 +302,14 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
?: mi?.mediaMetadata?.extras?.getString("artist") ?: mi?.mediaMetadata?.extras?.getString("artist")
val album = mi?.mediaMetadata?.albumTitle?.toString() val album = mi?.mediaMetadata?.albumTitle?.toString()
?: mi?.mediaMetadata?.extras?.getString("album") ?: mi?.mediaMetadata?.extras?.getString("album")
val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId") val extras = mi?.mediaMetadata?.extras
val coverId = extras?.getString("coverArtId")
val songLink = extras?.getString("assetLinkSong")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_SONG, extras?.getString("id"))
val albumLink = extras?.getString("assetLinkAlbum")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ALBUM, extras?.getString("albumId"))
val artistLink = extras?.getString("assetLinkArtist")
?: AssetLinkUtil.buildLink(AssetLinkUtil.TYPE_ARTIST, extras?.getString("artistId"))
val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
WidgetUpdateManager.updateFromState( WidgetUpdateManager.updateFromState(
@ -275,7 +322,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.shuffleModeEnabled, player.shuffleModeEnabled,
player.repeatMode, player.repeatMode,
position, position,
duration duration,
songLink,
albumLink,
artistLink
) )
} }

View file

@ -2,13 +2,16 @@ package com.cappielloantonio.tempo.util;
import android.content.Context; import android.content.Context;
import androidx.core.content.ContextCompat;
import com.google.android.gms.cast.framework.CastContext; import com.google.android.gms.cast.framework.CastContext;
import com.google.android.gms.common.ConnectionResult; import com.google.android.gms.common.ConnectionResult;
import com.google.android.gms.common.GoogleApiAvailability; import com.google.android.gms.common.GoogleApiAvailability;
public class Flavors { public class Flavors {
@SuppressWarnings("deprecation")
public static void initializeCastContext(Context context) { public static void initializeCastContext(Context context) {
if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS) if (GoogleApiAvailability.getInstance().isGooglePlayServicesAvailable(context) == ConnectionResult.SUCCESS)
CastContext.getSharedInstance(context); CastContext.getSharedInstance(context, ContextCompat.getMainExecutor(context));
} }
} }

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB