21 Commits

Author SHA1 Message Date
eddyizm
a06ab77b42 chore: bumped version for release 2025-10-16 14:26:14 -07:00
eddyizm
04a6176bfd fix: limits image size to prevent widget crash #172 (#175) 2025-10-16 13:25:25 -07:00
eddyizm
1f4464e089 fix: Include shuffle/repeat controls in f-droid build's media notific… (#174) 2025-10-16 13:25:04 -07:00
eddyizm
9d01d2057a fix: limits image size to prevent widget crash #172 2025-10-15 21:57:24 -07:00
eddyizm
ad440c490a Fix album parse empty date field (#171) 2025-10-15 07:26:22 -07:00
le-firehawk
acdcfff9ac fix: Include shuffle/repeat controls in f-droid build's media notification window 2025-10-15 21:00:39 +10:30
eddyizm
8c7a25cbd0 fix: update to handle nulls in the sort function 2025-10-13 21:49:41 -07:00
eddyizm
bdca5e16ed Merge branch 'development' into fix-album-parse-empty-date-field 2025-10-13 21:10:01 -07:00
eddyizm
f091b3d248 fix: handle empty date fields from subsonic json 2025-10-13 21:09:27 -07:00
eddyizm
18cd84f820 fix: persist album sort preference (#168) 2025-10-13 16:53:06 -07:00
eddyizm
281ebf8263 fix: General build warning and playback issues (#167) 2025-10-13 06:59:33 -07:00
eddyizm
2854ac6354 fix: persist album sort preference. 2025-10-12 22:12:53 -07:00
eddyizm
16d25a1f1d Merge branch 'main' into development 2025-10-12 09:57:55 -07:00
eddyizm
5d3ca8acfa chore: added multi library documentation 2025-10-12 09:57:35 -07:00
eddyizm
0689272046 fix: workflow trigger updated for my tagging convention 2025-10-12 09:56:52 -07:00
le-firehawk
17372fc4d0 fix: Resolve parcel serialization build warnings 2025-10-12 23:20:47 +10:30
le-firehawk
44679855cd fix: Replace poor syntax that created warnings during build 2025-10-12 23:20:47 +10:30
le-firehawk
78e7032903 fix: When creating MediaService, restore player from previous queue 2025-10-12 23:20:47 +10:30
eddyizm
8d8087f2d6 chore: fix some grammar in readme 2025-10-11 09:07:38 -07:00
eddyizm
5b6a4fab62 chore: updated changelog 2025-10-10 22:30:55 -07:00
eddyizm
fdc41b299c chore: updated ignore file for release apk files 2025-10-10 22:29:41 -07:00
41 changed files with 519 additions and 256 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,15 @@
***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.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,7 +25,7 @@ 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.
@@ -39,7 +39,7 @@ Please note the two variants in the release assets include release/debug and 32/
As mentioned above, I am working towards a rebrand to get into app stores with a new name an icon. 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](https://github.com/eddyizm/tempo/blob/main/CHANGELOG.md) Moved details to [CHANGELOG.md](CHANGELOG.md)
Fork [**sponsorship here**](https://ko-fi.com/eddyizm). Fork [**sponsorship here**](https://ko-fi.com/eddyizm).
@@ -59,6 +59,7 @@ 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.
## Credits ## 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)
@@ -101,9 +102,9 @@ Thanks to the original repo/creator [CappielloAntonio](https://github.com/Cappie
Please fork and open PR's against the development branch. Make sure your PR builds successfully. Please fork and open PR's against the development branch. Make sure your PR builds successfully.
If there is a UI change, please provide a before/after screenshot and a short video/gif if that helps elaborating the fix/feature in the PR. 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 not tests but I would love to start on some unit tests. 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. Not a hard requirement but any new feature/change should ideally include an update to the nacent documention.

View File

@@ -57,7 +57,14 @@ 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

View File

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

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

@@ -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

@@ -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

@@ -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

@@ -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

@@ -76,6 +76,8 @@ object Preferences {
private const val EQUALIZER_ENABLED = "equalizer_enabled" private const val EQUALIZER_ENABLED = "equalizer_enabled"
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_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? {
@@ -638,4 +640,14 @@ object Preferences {
if (parts.size < bandCount) return ShortArray(bandCount.toInt()) if (parts.size < bandCount) return ShortArray(bandCount.toInt())
return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 } return ShortArray(bandCount.toInt()) { i -> parts[i].toShortOrNull() ?: 0 }
} }
@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

@@ -27,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,
@@ -95,7 +97,7 @@ public final class WidgetUpdateManager {
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) {

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

@@ -14,16 +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.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
@@ -84,6 +87,7 @@ class MediaService : MediaLibraryService() {
initializeCustomCommands() initializeCustomCommands()
initializePlayer() initializePlayer()
initializeMediaLibrarySession() initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener() initializePlayerListener()
initializeEqualizerManager() initializeEqualizerManager()
@@ -119,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) {
@@ -226,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)
@@ -269,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) {
@@ -399,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. */
} }
@@ -464,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,11 +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.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
@@ -71,9 +74,10 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
initializeRepository() initializeRepository()
initializePlayer() initializePlayer()
initializeCastPlayer()
initializeMediaLibrarySession() initializeMediaLibrarySession()
restorePlayerFromQueue()
initializePlayerListener() initializePlayerListener()
initializeCastPlayer()
initializeEqualizerManager() initializeEqualizerManager()
setPlayer( setPlayer(
@@ -143,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)
}
}
} }
} }
@@ -166,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)
} }

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));
} }
} }