diff --git a/README.md b/README.md
index 01e81112..44caa050 100644
--- a/README.md
+++ b/README.md
@@ -24,6 +24,9 @@ Tempo does not rely on magic algorithms to decide what you should listen to. Ins
## Fork
+sha256 signing key fingerprint
+`SHA256: B7:85:01:B9:34:D0:4E:0A:CA:8D:94:AF:D6:72:6A:4D:1D:CE:65:79:7F:1D:41:71:0F:64:3C:29:00:EB:1D:1D`
+
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)
diff --git a/app/build.gradle b/app/build.gradle
index 4ffdc410..a5d63c7d 100644
--- a/app/build.gradle
+++ b/app/build.gradle
@@ -10,8 +10,8 @@ android {
minSdkVersion 24
targetSdk 35
- versionCode 32
- versionName '3.15.0'
+ versionCode 33
+ versionName '3.16.0'
testInstrumentationRunner 'androidx.test.runner.AndroidJUnitRunner'
javaCompileOptions {
diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
new file mode 100644
index 00000000..77974597
--- /dev/null
+++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/12.json
@@ -0,0 +1,1151 @@
+{
+ "formatVersion": 1,
+ "database": {
+ "version": 12,
+ "identityHash": "2d26471ae15a1cdaf996261b72f81613",
+ "entities": [
+ {
+ "tableName": "queue",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `track_order` INTEGER NOT NULL, `last_play` INTEGER NOT NULL, `playing_changed` INTEGER NOT NULL, `stream_id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`track_order`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "trackOrder",
+ "columnName": "track_order",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "lastPlay",
+ "columnName": "last_play",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playingChanged",
+ "columnName": "playing_changed",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "streamId",
+ "columnName": "stream_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "samplingRate",
+ "columnName": "sampling_rate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitDepth",
+ "columnName": "bit_depth",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "track_order"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "server",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `server_name` TEXT NOT NULL, `username` TEXT NOT NULL, `password` TEXT NOT NULL, `address` TEXT NOT NULL, `local_address` TEXT, `timestamp` INTEGER NOT NULL, `low_security` INTEGER NOT NULL DEFAULT false, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "serverId",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "serverName",
+ "columnName": "server_name",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "username",
+ "columnName": "username",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "password",
+ "columnName": "password",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "address",
+ "columnName": "address",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "localAddress",
+ "columnName": "local_address",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "isLowSecurity",
+ "columnName": "low_security",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "false"
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "recent_search",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`search` TEXT NOT NULL, PRIMARY KEY(`search`))",
+ "fields": [
+ {
+ "fieldPath": "search",
+ "columnName": "search",
+ "affinity": "TEXT",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "search"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "download",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `playlist_id` TEXT, `playlist_name` TEXT, `download_state` INTEGER NOT NULL DEFAULT 1, `download_uri` TEXT DEFAULT '', `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "playlistId",
+ "columnName": "playlist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playlistName",
+ "columnName": "playlist_name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "downloadState",
+ "columnName": "download_state",
+ "affinity": "INTEGER",
+ "notNull": true,
+ "defaultValue": "1"
+ },
+ {
+ "fieldPath": "downloadUri",
+ "columnName": "download_uri",
+ "affinity": "TEXT",
+ "notNull": false,
+ "defaultValue": "''"
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "samplingRate",
+ "columnName": "sampling_rate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitDepth",
+ "columnName": "bit_depth",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "chronology",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `server` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `sampling_rate` INTEGER, `bit_depth` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "server",
+ "columnName": "server",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "samplingRate",
+ "columnName": "sampling_rate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitDepth",
+ "columnName": "bit_depth",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "favorite",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`timestamp` INTEGER NOT NULL, `songId` TEXT, `albumId` TEXT, `artistId` TEXT, `toStar` INTEGER NOT NULL, PRIMARY KEY(`timestamp`))",
+ "fields": [
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "songId",
+ "columnName": "songId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "albumId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artistId",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "toStar",
+ "columnName": "toStar",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "timestamp"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "session_media_item",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`index` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `id` TEXT, `parent_id` TEXT, `is_dir` INTEGER NOT NULL, `title` TEXT, `album` TEXT, `artist` TEXT, `track` INTEGER, `year` INTEGER, `genre` TEXT, `cover_art_id` TEXT, `size` INTEGER, `content_type` TEXT, `suffix` TEXT, `transcoding_content_type` TEXT, `transcoded_suffix` TEXT, `duration` INTEGER, `bitrate` INTEGER, `path` TEXT, `is_video` INTEGER NOT NULL, `user_rating` INTEGER, `average_rating` REAL, `play_count` INTEGER, `disc_number` INTEGER, `created` INTEGER, `starred` INTEGER, `album_id` TEXT, `artist_id` TEXT, `type` TEXT, `bookmark_position` INTEGER, `original_width` INTEGER, `original_height` INTEGER, `stream_id` TEXT, `stream_url` TEXT, `timestamp` INTEGER)",
+ "fields": [
+ {
+ "fieldPath": "index",
+ "columnName": "index",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "parentId",
+ "columnName": "parent_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isDir",
+ "columnName": "is_dir",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "album",
+ "columnName": "album",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "track",
+ "columnName": "track",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "year",
+ "columnName": "year",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "genre",
+ "columnName": "genre",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "cover_art_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "size",
+ "columnName": "size",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "contentType",
+ "columnName": "content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "suffix",
+ "columnName": "suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedContentType",
+ "columnName": "transcoding_content_type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "transcodedSuffix",
+ "columnName": "transcoded_suffix",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bitrate",
+ "columnName": "bitrate",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "path",
+ "columnName": "path",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "isVideo",
+ "columnName": "is_video",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "userRating",
+ "columnName": "user_rating",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "averageRating",
+ "columnName": "average_rating",
+ "affinity": "REAL",
+ "notNull": false
+ },
+ {
+ "fieldPath": "playCount",
+ "columnName": "play_count",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "discNumber",
+ "columnName": "disc_number",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "created",
+ "columnName": "created",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "starred",
+ "columnName": "starred",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "albumId",
+ "columnName": "album_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "artistId",
+ "columnName": "artist_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "type",
+ "columnName": "type",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "bookmarkPosition",
+ "columnName": "bookmark_position",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalWidth",
+ "columnName": "original_width",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "originalHeight",
+ "columnName": "original_height",
+ "affinity": "INTEGER",
+ "notNull": false
+ },
+ {
+ "fieldPath": "streamId",
+ "columnName": "stream_id",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "streamUrl",
+ "columnName": "stream_url",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "timestamp",
+ "columnName": "timestamp",
+ "affinity": "INTEGER",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": true,
+ "columnNames": [
+ "index"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "playlist",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT, `duration` INTEGER NOT NULL, `coverArt` TEXT, PRIMARY KEY(`id`))",
+ "fields": [
+ {
+ "fieldPath": "id",
+ "columnName": "id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "name",
+ "columnName": "name",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "duration",
+ "columnName": "duration",
+ "affinity": "INTEGER",
+ "notNull": true
+ },
+ {
+ "fieldPath": "coverArtId",
+ "columnName": "coverArt",
+ "affinity": "TEXT",
+ "notNull": false
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ },
+ {
+ "tableName": "lyrics_cache",
+ "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`song_id` TEXT NOT NULL, `artist` TEXT, `title` TEXT, `lyrics` TEXT, `structured_lyrics` TEXT, `updated_at` INTEGER NOT NULL, PRIMARY KEY(`song_id`))",
+ "fields": [
+ {
+ "fieldPath": "songId",
+ "columnName": "song_id",
+ "affinity": "TEXT",
+ "notNull": true
+ },
+ {
+ "fieldPath": "artist",
+ "columnName": "artist",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "title",
+ "columnName": "title",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "lyrics",
+ "columnName": "lyrics",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "structuredLyrics",
+ "columnName": "structured_lyrics",
+ "affinity": "TEXT",
+ "notNull": false
+ },
+ {
+ "fieldPath": "updatedAt",
+ "columnName": "updated_at",
+ "affinity": "INTEGER",
+ "notNull": true
+ }
+ ],
+ "primaryKey": {
+ "autoGenerate": false,
+ "columnNames": [
+ "song_id"
+ ]
+ },
+ "indices": [],
+ "foreignKeys": []
+ }
+ ],
+ "views": [],
+ "setupQueries": [
+ "CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
+ "INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, '2d26471ae15a1cdaf996261b72f81613')"
+ ]
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml
index cc9990e7..816683ca 100644
--- a/app/src/main/AndroidManifest.xml
+++ b/app/src/main/AndroidManifest.xml
@@ -73,5 +73,20 @@
android:name="autoStoreLocales"
android:value="true" />
+
+
+
+
+
+
+
+
+
+
-
\ No newline at end of file
+
diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java
index b19e934f..3a5e98ef 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/database/AppDatabase.java
@@ -12,6 +12,7 @@ import com.cappielloantonio.tempo.database.converter.DateConverters;
import com.cappielloantonio.tempo.database.dao.ChronologyDao;
import com.cappielloantonio.tempo.database.dao.DownloadDao;
import com.cappielloantonio.tempo.database.dao.FavoriteDao;
+import com.cappielloantonio.tempo.database.dao.LyricsDao;
import com.cappielloantonio.tempo.database.dao.PlaylistDao;
import com.cappielloantonio.tempo.database.dao.QueueDao;
import com.cappielloantonio.tempo.database.dao.RecentSearchDao;
@@ -20,6 +21,7 @@ import com.cappielloantonio.tempo.database.dao.SessionMediaItemDao;
import com.cappielloantonio.tempo.model.Chronology;
import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.Favorite;
+import com.cappielloantonio.tempo.model.LyricsCache;
import com.cappielloantonio.tempo.model.Queue;
import com.cappielloantonio.tempo.model.RecentSearch;
import com.cappielloantonio.tempo.model.Server;
@@ -28,9 +30,9 @@ import com.cappielloantonio.tempo.subsonic.models.Playlist;
@UnstableApi
@Database(
- version = 11,
- entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class},
- autoMigrations = {@AutoMigration(from = 10, to = 11)}
+ version = 12,
+ entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class, LyricsCache.class},
+ autoMigrations = {@AutoMigration(from = 10, to = 11), @AutoMigration(from = 11, to = 12)}
)
@TypeConverters({DateConverters.class})
public abstract class AppDatabase extends RoomDatabase {
@@ -62,4 +64,6 @@ public abstract class AppDatabase extends RoomDatabase {
public abstract SessionMediaItemDao sessionMediaItemDao();
public abstract PlaylistDao playlistDao();
+
+ public abstract LyricsDao lyricsDao();
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java
index 628e9dd6..a2d49f6b 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/DownloadDao.java
@@ -15,6 +15,9 @@ public interface DownloadDao {
@Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
LiveData> getAll();
+ @Query("SELECT * FROM download WHERE download_state = 1 ORDER BY artist, album, disc_number, track ASC")
+ List getAllSync();
+
@Query("SELECT * FROM download WHERE id = :id")
Download getOne(String id);
@@ -30,6 +33,9 @@ public interface DownloadDao {
@Query("DELETE FROM download WHERE id = :id")
void delete(String id);
+ @Query("DELETE FROM download WHERE id IN (:ids)")
+ void deleteByIds(List ids);
+
@Query("DELETE FROM download")
void deleteAll();
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java b/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java
new file mode 100644
index 00000000..89d0d585
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/database/dao/LyricsDao.java
@@ -0,0 +1,24 @@
+package com.cappielloantonio.tempo.database.dao;
+
+import androidx.lifecycle.LiveData;
+import androidx.room.Dao;
+import androidx.room.Insert;
+import androidx.room.OnConflictStrategy;
+import androidx.room.Query;
+
+import com.cappielloantonio.tempo.model.LyricsCache;
+
+@Dao
+public interface LyricsDao {
+ @Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
+ LyricsCache getOne(String songId);
+
+ @Query("SELECT * FROM lyrics_cache WHERE song_id = :songId")
+ LiveData observeOne(String songId);
+
+ @Insert(onConflict = OnConflictStrategy.REPLACE)
+ void insert(LyricsCache lyricsCache);
+
+ @Query("DELETE FROM lyrics_cache WHERE song_id = :songId")
+ void delete(String songId);
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java
index fe57c163..8e49111f 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/glide/CustomGlideRequest.java
@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.glide;
import android.content.Context;
+import android.graphics.Bitmap;
import android.graphics.drawable.ColorDrawable;
import android.graphics.drawable.Drawable;
import android.util.Log;
@@ -16,6 +17,7 @@ import com.bumptech.glide.load.resource.bitmap.CenterCrop;
import com.bumptech.glide.load.resource.bitmap.RoundedCorners;
import com.bumptech.glide.load.resource.drawable.DrawableTransitionOptions;
import com.bumptech.glide.request.RequestOptions;
+import com.bumptech.glide.request.target.CustomTarget;
import com.bumptech.glide.signature.ObjectKey;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.R;
@@ -109,6 +111,18 @@ public class CustomGlideRequest {
return uri.toString();
}
+ public static void loadAlbumArtBitmap(Context context,
+ String coverId,
+ int size,
+ CustomTarget target) {
+ String url = createUrl(coverId, size);
+ Glide.with(context)
+ .asBitmap()
+ .load(url)
+ .apply(createRequestOptions(context, coverId, ResourceType.Album))
+ .into(target);
+ }
+
public static class Builder {
private final RequestManager requestManager;
private Object item;
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt b/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt
new file mode 100644
index 00000000..3c437e2c
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/LyricsCache.kt
@@ -0,0 +1,25 @@
+package com.cappielloantonio.tempo.model
+
+import androidx.annotation.Keep
+import androidx.room.ColumnInfo
+import androidx.room.Entity
+import androidx.room.PrimaryKey
+import kotlin.jvm.JvmOverloads
+
+@Keep
+@Entity(tableName = "lyrics_cache")
+data class LyricsCache @JvmOverloads constructor(
+ @PrimaryKey
+ @ColumnInfo(name = "song_id")
+ var songId: String,
+ @ColumnInfo(name = "artist")
+ var artist: String? = null,
+ @ColumnInfo(name = "title")
+ var title: String? = null,
+ @ColumnInfo(name = "lyrics")
+ var lyrics: String? = null,
+ @ColumnInfo(name = "structured_lyrics")
+ var structuredLyrics: String? = null,
+ @ColumnInfo(name = "updated_at")
+ var updatedAt: Long = System.currentTimeMillis()
+)
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt b/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt
index 90d01f90..60d641ce 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/model/SessionMediaItem.kt
@@ -3,6 +3,7 @@ package com.cappielloantonio.tempo.model
import android.net.Uri
import android.os.Bundle
import androidx.annotation.Keep
+import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaItem.RequestMetadata
import androidx.media3.common.MediaMetadata
@@ -243,6 +244,13 @@ class SessionMediaItem() {
.setAlbumTitle(album)
.setArtist(artist)
.setArtworkUri(artworkUri)
+ .setUserRating(HeartRating(starred != null))
+ .setSupportedCommands(
+ listOf(
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
+ )
+ )
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java
index f39dbffa..4e06fad7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/ArtistRepository.java
@@ -2,10 +2,12 @@ package com.cappielloantonio.tempo.repository;
import androidx.annotation.NonNull;
import androidx.lifecycle.MutableLiveData;
+import android.util.Log;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
+import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
import com.cappielloantonio.tempo.subsonic.models.ArtistInfo2;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.IndexID3;
@@ -13,12 +15,92 @@ import com.cappielloantonio.tempo.subsonic.models.IndexID3;
import java.util.ArrayList;
import java.util.Collections;
import java.util.List;
+import java.util.concurrent.atomic.AtomicInteger;
import retrofit2.Call;
import retrofit2.Callback;
import retrofit2.Response;
public class ArtistRepository {
+ private final AlbumRepository albumRepository;
+
+ public ArtistRepository() {
+ this.albumRepository = new AlbumRepository();
+ }
+
+ public void getArtistAllSongs(String artistId, ArtistSongsCallback callback) {
+ Log.d("ArtistSync", "Getting albums for artist: " + artistId);
+
+ // Get the artist info first, which contains the albums
+ App.getSubsonicClientInstance(false)
+ .getBrowsingClient()
+ .getArtist(artistId)
+ .enqueue(new Callback() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ if (response.isSuccessful() && response.body() != null &&
+ response.body().getSubsonicResponse().getArtist() != null &&
+ response.body().getSubsonicResponse().getArtist().getAlbums() != null) {
+
+ List albums = response.body().getSubsonicResponse().getArtist().getAlbums();
+ Log.d("ArtistSync", "Got albums directly: " + albums.size());
+
+ if (!albums.isEmpty()) {
+ fetchAllAlbumSongsWithCallback(albums, callback);
+ } else {
+ Log.d("ArtistSync", "No albums found in artist response");
+ callback.onSongsCollected(new ArrayList<>());
+ }
+ } else {
+ Log.d("ArtistSync", "Failed to get artist info");
+ callback.onSongsCollected(new ArrayList<>());
+ }
+ }
+
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ Log.d("ArtistSync", "Error getting artist info: " + t.getMessage());
+ callback.onSongsCollected(new ArrayList<>());
+ }
+ });
+ }
+
+ private void fetchAllAlbumSongsWithCallback(List albums, ArtistSongsCallback callback) {
+ if (albums == null || albums.isEmpty()) {
+ Log.d("ArtistSync", "No albums to process");
+ callback.onSongsCollected(new ArrayList<>());
+ return;
+ }
+
+ List allSongs = new ArrayList<>();
+ AtomicInteger remainingAlbums = new AtomicInteger(albums.size());
+ Log.d("ArtistSync", "Processing " + albums.size() + " albums");
+
+ for (AlbumID3 album : albums) {
+ Log.d("ArtistSync", "Getting tracks for album: " + album.getName());
+ MutableLiveData> albumTracks = albumRepository.getAlbumTracks(album.getId());
+ albumTracks.observeForever(songs -> {
+ Log.d("ArtistSync", "Got " + (songs != null ? songs.size() : 0) + " songs from album");
+ if (songs != null) {
+ allSongs.addAll(songs);
+ }
+ albumTracks.removeObservers(null);
+
+ int remaining = remainingAlbums.decrementAndGet();
+ Log.d("ArtistSync", "Remaining albums: " + remaining);
+
+ if (remaining == 0) {
+ Log.d("ArtistSync", "All albums processed. Total songs: " + allSongs.size());
+ callback.onSongsCollected(allSongs);
+ }
+ });
+ }
+ }
+
+ public interface ArtistSongsCallback {
+ void onSongsCollected(List songs);
+ }
+
public MutableLiveData> getStarredArtists(boolean random, int size) {
MutableLiveData> starredArtists = new MutableLiveData<>(new ArrayList<>());
@@ -89,7 +171,7 @@ public class ArtistRepository {
}
/*
- * Metodo che mi restituisce le informazioni essenzionali dell'artista (cover, numero di album...)
+ * Method that returns essential artist information (cover, album number, etc.)
*/
public void getArtistInfo(List artists, MutableLiveData> list) {
List liveArtists = list.getValue();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java
index c71d2f8c..1d8c935a 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/DownloadRepository.java
@@ -18,6 +18,20 @@ public class DownloadRepository {
return downloadDao.getAll();
}
+ public List getAllDownloads() {
+ GetAllDownloadsThreadSafe getDownloads = new GetAllDownloadsThreadSafe(downloadDao);
+ Thread thread = new Thread(getDownloads);
+ thread.start();
+
+ try {
+ thread.join();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ return getDownloads.getDownloads();
+ }
+
public Download getDownload(String id) {
Download download = null;
@@ -35,6 +49,24 @@ public class DownloadRepository {
return download;
}
+ private static class GetAllDownloadsThreadSafe implements Runnable {
+ private final DownloadDao downloadDao;
+ private List downloads;
+
+ public GetAllDownloadsThreadSafe(DownloadDao downloadDao) {
+ this.downloadDao = downloadDao;
+ }
+
+ @Override
+ public void run() {
+ downloads = downloadDao.getAllSync();
+ }
+
+ public List getDownloads() {
+ return downloads;
+ }
+ }
+
private static class GetDownloadThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final String id;
@@ -143,6 +175,12 @@ public class DownloadRepository {
thread.start();
}
+ public void delete(List ids) {
+ DeleteMultipleThreadSafe delete = new DeleteMultipleThreadSafe(downloadDao, ids);
+ Thread thread = new Thread(delete);
+ thread.start();
+ }
+
private static class DeleteThreadSafe implements Runnable {
private final DownloadDao downloadDao;
private final String id;
@@ -157,4 +195,19 @@ public class DownloadRepository {
downloadDao.delete(id);
}
}
+
+ private static class DeleteMultipleThreadSafe implements Runnable {
+ private final DownloadDao downloadDao;
+ private final List ids;
+
+ public DeleteMultipleThreadSafe(DownloadDao downloadDao, List ids) {
+ this.downloadDao = downloadDao;
+ this.ids = ids;
+ }
+
+ @Override
+ public void run() {
+ downloadDao.deleteByIds(ids);
+ }
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java
new file mode 100644
index 00000000..fb7a05a3
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/LyricsRepository.java
@@ -0,0 +1,92 @@
+package com.cappielloantonio.tempo.repository;
+
+import androidx.lifecycle.LiveData;
+
+import com.cappielloantonio.tempo.database.AppDatabase;
+import com.cappielloantonio.tempo.database.dao.LyricsDao;
+import com.cappielloantonio.tempo.model.LyricsCache;
+
+public class LyricsRepository {
+ private final LyricsDao lyricsDao = AppDatabase.getInstance().lyricsDao();
+
+ public LyricsCache getLyrics(String songId) {
+ GetLyricsThreadSafe getLyricsThreadSafe = new GetLyricsThreadSafe(lyricsDao, songId);
+ Thread thread = new Thread(getLyricsThreadSafe);
+ thread.start();
+
+ try {
+ thread.join();
+ return getLyricsThreadSafe.getLyrics();
+ } catch (InterruptedException e) {
+ e.printStackTrace();
+ }
+
+ return null;
+ }
+
+ public LiveData observeLyrics(String songId) {
+ return lyricsDao.observeOne(songId);
+ }
+
+ public void insert(LyricsCache lyricsCache) {
+ InsertThreadSafe insert = new InsertThreadSafe(lyricsDao, lyricsCache);
+ Thread thread = new Thread(insert);
+ thread.start();
+ }
+
+ public void delete(String songId) {
+ DeleteThreadSafe delete = new DeleteThreadSafe(lyricsDao, songId);
+ Thread thread = new Thread(delete);
+ thread.start();
+ }
+
+ private static class GetLyricsThreadSafe implements Runnable {
+ private final LyricsDao lyricsDao;
+ private final String songId;
+ private LyricsCache lyricsCache;
+
+ public GetLyricsThreadSafe(LyricsDao lyricsDao, String songId) {
+ this.lyricsDao = lyricsDao;
+ this.songId = songId;
+ }
+
+ @Override
+ public void run() {
+ lyricsCache = lyricsDao.getOne(songId);
+ }
+
+ public LyricsCache getLyrics() {
+ return lyricsCache;
+ }
+ }
+
+ private static class InsertThreadSafe implements Runnable {
+ private final LyricsDao lyricsDao;
+ private final LyricsCache lyricsCache;
+
+ public InsertThreadSafe(LyricsDao lyricsDao, LyricsCache lyricsCache) {
+ this.lyricsDao = lyricsDao;
+ this.lyricsCache = lyricsCache;
+ }
+
+ @Override
+ public void run() {
+ lyricsDao.insert(lyricsCache);
+ }
+ }
+
+ private static class DeleteThreadSafe implements Runnable {
+ private final LyricsDao lyricsDao;
+ private final String songId;
+
+ public DeleteThreadSafe(LyricsDao lyricsDao, String songId) {
+ this.lyricsDao = lyricsDao;
+ this.songId = songId;
+ }
+
+ @Override
+ public void run() {
+ lyricsDao.delete(songId);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java
index 7884159f..f87cd577 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java
@@ -81,20 +81,24 @@ public class PlaylistRepository {
}
public void addSongToPlaylist(String playlistId, ArrayList songsId) {
- App.getSubsonicClientInstance(false)
- .getPlaylistClient()
- .updatePlaylist(playlistId, null, true, songsId, null)
- .enqueue(new Callback() {
- @Override
- public void onResponse(@NonNull Call call, @NonNull Response response) {
- Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
- }
+ if (songsId.isEmpty()) {
+ Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_all_skipped), Toast.LENGTH_SHORT).show();
+ } else{
+ App.getSubsonicClientInstance(false)
+ .getPlaylistClient()
+ .updatePlaylist(playlistId, null, true, songsId, null)
+ .enqueue(new Callback() {
+ @Override
+ public void onResponse(@NonNull Call call, @NonNull Response response) {
+ Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_success), Toast.LENGTH_SHORT).show();
+ }
- @Override
- public void onFailure(@NonNull Call call, @NonNull Throwable t) {
- Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
- }
- });
+ @Override
+ public void onFailure(@NonNull Call call, @NonNull Throwable t) {
+ Toast.makeText(App.getContext(), App.getContext().getString(R.string.playlist_chooser_dialog_toast_add_failure), Toast.LENGTH_SHORT).show();
+ }
+ });
+ }
}
public void createPlaylist(String playlistId, String name, ArrayList songsId) {
@@ -131,23 +135,6 @@ public class PlaylistRepository {
});
}
- public void updatePlaylist(String playlistId, String name, boolean isPublic, ArrayList songIdToAdd, ArrayList songIndexToRemove) {
- App.getSubsonicClientInstance(false)
- .getPlaylistClient()
- .updatePlaylist(playlistId, name, isPublic, songIdToAdd, songIndexToRemove)
- .enqueue(new Callback() {
- @Override
- public void onResponse(@NonNull Call call, @NonNull Response response) {
-
- }
-
- @Override
- public void onFailure(@NonNull Call call, @NonNull Throwable t) {
-
- }
- });
- }
-
public void deletePlaylist(String playlistId) {
App.getSubsonicClientInstance(false)
.getPlaylistClient()
diff --git a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java
index 6c99c2b7..f7cd8a38 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/service/MediaManager.java
@@ -223,6 +223,25 @@ public class MediaManager {
}
}
+ public static void playDownloadedMediaItem(ListenableFuture mediaBrowserListenableFuture, MediaItem mediaItem) {
+ if (mediaBrowserListenableFuture != null && mediaItem != null) {
+ mediaBrowserListenableFuture.addListener(() -> {
+ try {
+ if (mediaBrowserListenableFuture.isDone()) {
+ MediaBrowser mediaBrowser = mediaBrowserListenableFuture.get();
+ mediaBrowser.clearMediaItems();
+ mediaBrowser.setMediaItem(mediaItem);
+ mediaBrowser.prepare();
+ mediaBrowser.play();
+ clearDatabase();
+ }
+ } catch (ExecutionException | InterruptedException e) {
+ e.printStackTrace();
+ }
+ }, MoreExecutors.directExecutor());
+ }
+ }
+
public static void startRadio(ListenableFuture mediaBrowserListenableFuture, InternetRadioStation internetRadioStation) {
if (mediaBrowserListenableFuture != null) {
mediaBrowserListenableFuture.addListener(() -> {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java b/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java
index d4a6521a..c5227da7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/subsonic/api/system/SystemClient.java
@@ -5,6 +5,9 @@ import android.util.Log;
import com.cappielloantonio.tempo.subsonic.RetrofitClient;
import com.cappielloantonio.tempo.subsonic.Subsonic;
import com.cappielloantonio.tempo.subsonic.base.ApiResponse;
+import com.cappielloantonio.tempo.util.Preferences;
+
+import java.util.concurrent.TimeUnit;
import retrofit2.Call;
@@ -21,7 +24,15 @@ public class SystemClient {
public Call ping() {
Log.d(TAG, "ping()");
- return systemService.ping(subsonic.getParams());
+ Call pingCall = systemService.ping(subsonic.getParams());
+ if (Preferences.isInUseServerAddressLocal()) {
+ pingCall.timeout()
+ .timeout(1, TimeUnit.SECONDS);
+ } else {
+ pingCall.timeout()
+ .timeout(3, TimeUnit.SECONDS);
+ }
+ return pingCall;
}
public Call getLicense() {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java
index c959648b..db76b98d 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/activity/MainActivity.java
@@ -1,11 +1,14 @@
package com.cappielloantonio.tempo.ui.activity;
import android.content.Context;
+import android.content.Intent;
import android.content.IntentFilter;
import android.net.ConnectivityManager;
import android.net.NetworkInfo;
+import android.net.Uri;
import android.os.Bundle;
import android.os.Handler;
+import android.text.TextUtils;
import android.util.Log;
import android.view.View;
@@ -13,7 +16,10 @@ import androidx.annotation.NonNull;
import androidx.core.splashscreen.SplashScreen;
import androidx.fragment.app.FragmentManager;
import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.MediaItem;
+import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
+import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
import androidx.navigation.NavController;
import androidx.navigation.fragment.NavHostFragment;
@@ -56,6 +62,7 @@ public class MainActivity extends BaseActivity {
private BottomSheetBehavior bottomSheetBehavior;
ConnectivityStatusBroadcastReceiver connectivityStatusBroadcastReceiver;
+ private Intent pendingDownloadPlaybackIntent;
@Override
protected void onCreate(Bundle savedInstanceState) {
@@ -77,12 +84,16 @@ public class MainActivity extends BaseActivity {
checkConnectionType();
getOpenSubsonicExtensions();
checkTempoUpdate();
+
+ maybeSchedulePlaybackIntent(getIntent());
}
@Override
protected void onStart() {
super.onStart();
+ pingServer();
initService();
+ consumePendingPlaybackIntent();
}
@Override
@@ -98,6 +109,14 @@ public class MainActivity extends BaseActivity {
bind = null;
}
+ @Override
+ protected void onNewIntent(Intent intent) {
+ super.onNewIntent(intent);
+ setIntent(intent);
+ maybeSchedulePlaybackIntent(intent);
+ consumePendingPlaybackIntent();
+ }
+
@Override
public void onBackPressed() {
if (bottomSheetBehavior.getState() == BottomSheetBehavior.STATE_EXPANDED)
@@ -351,6 +370,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
+ resetView();
} else {
Preferences.setOpenSubsonic(subsonicResponse.getOpenSubsonic() != null && subsonicResponse.getOpenSubsonic());
}
@@ -361,6 +381,7 @@ public class MainActivity extends BaseActivity {
Preferences.switchInUseServerAddress();
App.refreshSubsonicClient();
pingServer();
+ resetView();
} else {
mainViewModel.ping().observe(this, subsonicResponse -> {
if (subsonicResponse == null) {
@@ -376,6 +397,13 @@ public class MainActivity extends BaseActivity {
}
}
+ private void resetView() {
+ resetViewModel();
+ int id = Objects.requireNonNull(navController.getCurrentDestination()).getId();
+ navController.popBackStack(id, true);
+ navController.navigate(id);
+ }
+
private void getOpenSubsonicExtensions() {
if (Preferences.getToken() != null) {
mainViewModel.getOpenSubsonicExtensions().observe(this, openSubsonicExtensions -> {
@@ -408,4 +436,68 @@ public class MainActivity extends BaseActivity {
}
}
}
+
+ private void maybeSchedulePlaybackIntent(Intent intent) {
+ if (intent == null) return;
+ if (Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD.equals(intent.getAction())
+ || intent.hasExtra(Constants.EXTRA_DOWNLOAD_URI)) {
+ pendingDownloadPlaybackIntent = new Intent(intent);
+ }
+ }
+
+ private void consumePendingPlaybackIntent() {
+ if (pendingDownloadPlaybackIntent == null) return;
+ Intent intent = pendingDownloadPlaybackIntent;
+ pendingDownloadPlaybackIntent = null;
+ playDownloadedMedia(intent);
+ }
+
+ private void playDownloadedMedia(Intent intent) {
+ String uriString = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_URI);
+ if (TextUtils.isEmpty(uriString)) {
+ return;
+ }
+
+ Uri uri = Uri.parse(uriString);
+ String mediaId = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID);
+ if (TextUtils.isEmpty(mediaId)) {
+ mediaId = uri.toString();
+ }
+
+ String title = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_TITLE);
+ String artist = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ARTIST);
+ String album = intent.getStringExtra(Constants.EXTRA_DOWNLOAD_ALBUM);
+ int duration = intent.getIntExtra(Constants.EXTRA_DOWNLOAD_DURATION, 0);
+
+ Bundle extras = new Bundle();
+ extras.putString("id", mediaId);
+ extras.putString("title", title);
+ extras.putString("artist", artist);
+ extras.putString("album", album);
+ extras.putString("uri", uri.toString());
+ extras.putString("type", Constants.MEDIA_TYPE_MUSIC);
+ extras.putInt("duration", duration);
+
+ MediaMetadata.Builder metadataBuilder = new MediaMetadata.Builder()
+ .setExtras(extras)
+ .setIsBrowsable(false)
+ .setIsPlayable(true);
+
+ if (!TextUtils.isEmpty(title)) metadataBuilder.setTitle(title);
+ if (!TextUtils.isEmpty(artist)) metadataBuilder.setArtist(artist);
+ if (!TextUtils.isEmpty(album)) metadataBuilder.setAlbumTitle(album);
+
+ MediaItem mediaItem = new MediaItem.Builder()
+ .setMediaId(mediaId)
+ .setMediaMetadata(metadataBuilder.build())
+ .setUri(uri)
+ .setMimeType(MimeTypes.BASE_TYPE_AUDIO)
+ .setRequestMetadata(new MediaItem.RequestMetadata.Builder()
+ .setMediaUri(uri)
+ .setExtras(extras)
+ .build())
+ .build();
+
+ MediaManager.playDownloadedMediaItem(getMediaBrowserListenableFuture(), mediaItem);
+ }
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java
index cb10ab4e..1d78f2ea 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/adapter/SongHorizontalAdapter.java
@@ -11,6 +11,7 @@ import android.widget.Filterable;
import androidx.annotation.NonNull;
import androidx.appcompat.content.res.AppCompatResources;
+import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.util.UnstableApi;
import androidx.media3.session.MediaBrowser;
import androidx.recyclerview.widget.RecyclerView;
@@ -24,6 +25,8 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.DiscTitle;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.util.concurrent.ListenableFuture;
@@ -89,7 +92,7 @@ public class SongHorizontalAdapter extends RecyclerView.Adapter {
- DownloadUtil.getDownloadTracker(requireContext()).removeAll();
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).removeAll();
+ }
+
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString != null) {
+ DocumentFile directory = DocumentFile.fromTreeUri(requireContext(), Uri.parse(uriString));
+ if (directory != null && directory.canWrite()) {
+ for (DocumentFile file : directory.listFiles()) {
+ file.delete();
+ }
+ }
+ ExternalAudioReader.refreshCache();
+ ExternalDownloadMetadataStore.clear();
+ }
dialog.dismiss();
});
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java
new file mode 100644
index 00000000..62dcd404
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadDirectoryPickerDialog.java
@@ -0,0 +1,63 @@
+package com.cappielloantonio.tempo.ui.dialog;
+
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.os.Bundle;
+import android.widget.Toast;
+
+import androidx.activity.result.ActivityResultLauncher;
+import androidx.activity.result.contract.ActivityResultContracts;
+import androidx.annotation.NonNull;
+import androidx.fragment.app.DialogFragment;
+
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+import com.cappielloantonio.tempo.R;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.Preferences;
+
+public class DownloadDirectoryPickerDialog extends DialogFragment {
+
+ private ActivityResultLauncher folderPickerLauncher;
+
+ @NonNull
+ @Override
+ public android.app.Dialog onCreateDialog(Bundle savedInstanceState) {
+ // Register launcher *before* button triggers
+ folderPickerLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {
+ if (result.getResultCode() == android.app.Activity.RESULT_OK) {
+ Intent data = result.getData();
+ if (data != null) {
+ Uri uri = data.getData();
+ if (uri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ );
+
+ Preferences.setDownloadDirectoryUri(uri.toString());
+ ExternalAudioReader.refreshCache();
+
+ Toast.makeText(requireContext(), "Download directory set:\n" + uri.toString(), Toast.LENGTH_LONG).show();
+ }
+ }
+ }
+ }
+ );
+
+ return new MaterialAlertDialogBuilder(requireContext())
+ .setTitle("Set Download Directory")
+ .setMessage("Choose a folder where downloaded songs will be stored.")
+ .setPositiveButton("Choose Folder", (dialog, which) -> {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ | Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ folderPickerLauncher.launch(intent);
+ })
+ .setNegativeButton(android.R.string.cancel, null)
+ .create();
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java
index f5ff1577..766064e2 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/DownloadStorageDialog.java
@@ -34,6 +34,7 @@ public class DownloadStorageDialog extends DialogFragment {
.setTitle(R.string.download_storage_dialog_title)
.setPositiveButton(R.string.download_storage_external_dialog_positive_button, null)
.setNegativeButton(R.string.download_storage_internal_dialog_negative_button, null)
+ .setNeutralButton(R.string.download_storage_directory_dialog_neutral_button, null)
.create();
}
@@ -74,6 +75,20 @@ public class DownloadStorageDialog extends DialogFragment {
dialog.dismiss();
});
+
+ Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
+ neutralButton.setOnClickListener(v -> {
+ int currentPreference = Preferences.getDownloadStoragePreference();
+ int newPreference = 2;
+
+ if (currentPreference != newPreference) {
+ Preferences.setDownloadStoragePreference(newPreference);
+ DownloadUtil.getDownloadTracker(requireContext()).removeAll();
+ dialogClickCallback.onNeutralClick();
+ }
+
+ dialog.dismiss();
+ });
}
}
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java
index 360a5ec5..a6842512 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/PlaylistChooserDialog.java
@@ -27,6 +27,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
private PlaylistDialogHorizontalAdapter playlistDialogHorizontalAdapter;
+
@NonNull
@Override
public Dialog onCreateDialog(Bundle savedInstanceState) {
@@ -100,8 +101,7 @@ public class PlaylistChooserDialog extends DialogFragment implements ClickCallba
public void onPlaylistClick(Bundle bundle) {
if (playlistChooserViewModel.getSongsToAdd() != null && !playlistChooserViewModel.getSongsToAdd().isEmpty()) {
Playlist playlist = bundle.getParcelable(Constants.PLAYLIST_OBJECT);
- playlistChooserViewModel.addSongsToPlaylist(playlist.getId());
- dismiss();
+ playlistChooserViewModel.addSongsToPlaylist(this, getDialog(), playlist.getId());
} else {
Toast.makeText(requireContext(), R.string.playlist_chooser_dialog_toast_add_failure, Toast.LENGTH_SHORT).show();
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java
new file mode 100644
index 00000000..448ca072
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredArtistSyncDialog.java
@@ -0,0 +1,88 @@
+package com.cappielloantonio.tempo.ui.dialog;
+
+import android.app.Dialog;
+import android.content.Context;
+import android.os.Bundle;
+import android.widget.Button;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.OptIn;
+import androidx.fragment.app.DialogFragment;
+import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.util.UnstableApi;
+
+import com.cappielloantonio.tempo.R;
+import com.cappielloantonio.tempo.databinding.DialogStarredArtistSyncBinding;
+import com.cappielloantonio.tempo.model.Download;
+import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.MappingUtil;
+import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.viewmodel.StarredArtistsSyncViewModel;
+import com.google.android.material.dialog.MaterialAlertDialogBuilder;
+
+import java.util.stream.Collectors;
+
+@OptIn(markerClass = UnstableApi.class)
+public class StarredArtistSyncDialog extends DialogFragment {
+ private StarredArtistsSyncViewModel starredArtistsSyncViewModel;
+
+ private Runnable onCancel;
+
+ public StarredArtistSyncDialog(Runnable onCancel) {
+ this.onCancel = onCancel;
+ }
+
+ @NonNull
+ @Override
+ public Dialog onCreateDialog(Bundle savedInstanceState) {
+ DialogStarredArtistSyncBinding bind = DialogStarredArtistSyncBinding.inflate(getLayoutInflater());
+
+ starredArtistsSyncViewModel = new ViewModelProvider(requireActivity()).get(StarredArtistsSyncViewModel.class);
+
+ return new MaterialAlertDialogBuilder(getActivity())
+ .setView(bind.getRoot())
+ .setTitle(R.string.starred_artist_sync_dialog_title)
+ .setPositiveButton(R.string.starred_sync_dialog_positive_button, null)
+ .setNeutralButton(R.string.starred_sync_dialog_neutral_button, null)
+ .setNegativeButton(R.string.starred_sync_dialog_negative_button, null)
+ .create();
+ }
+
+ @Override
+ public void onResume() {
+ super.onResume();
+ setButtonAction(requireContext());
+ }
+
+ private void setButtonAction(Context context) {
+ androidx.appcompat.app.AlertDialog dialog = (androidx.appcompat.app.AlertDialog) getDialog();
+
+ if (dialog != null) {
+ Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
+ positiveButton.setOnClickListener(v -> {
+ starredArtistsSyncViewModel.getStarredArtistSongs(requireActivity()).observe(this, allSongs -> {
+ if (allSongs != null && !allSongs.isEmpty()) {
+ DownloadUtil.getDownloadTracker(context).download(
+ MappingUtil.mapDownloads(allSongs),
+ allSongs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ }
+ dialog.dismiss();
+ });
+ });
+
+ Button neutralButton = dialog.getButton(Dialog.BUTTON_NEUTRAL);
+ neutralButton.setOnClickListener(v -> {
+ Preferences.setStarredArtistsSyncEnabled(true);
+ dialog.dismiss();
+ });
+
+ Button negativeButton = dialog.getButton(Dialog.BUTTON_NEGATIVE);
+ negativeButton.setOnClickListener(v -> {
+ Preferences.setStarredArtistsSyncEnabled(false);
+ if (onCancel != null) onCancel.run();
+ dialog.dismiss();
+ });
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java
index 3d9b07a9..d3edfdf7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/dialog/StarredSyncDialog.java
@@ -61,7 +61,7 @@ public class StarredSyncDialog extends DialogFragment {
Button positiveButton = dialog.getButton(Dialog.BUTTON_POSITIVE);
positiveButton.setOnClickListener(v -> {
starredSyncViewModel.getStarredTracks(requireActivity()).observe(requireActivity(), songs -> {
- if (songs != null) {
+ if (songs != null && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(Download::new).collect(Collectors.toList())
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java
index 03ea9100..d0b417d7 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/AlbumPageFragment.java
@@ -39,6 +39,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.AlbumPageViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -130,7 +132,14 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
if (item.getItemId() == R.id.action_download_album) {
albumPageViewModel.getAlbumSongLiveList().observe(getViewLifecycleOwner(), songs -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(MappingUtil.mapDownloads(songs), songs.stream().map(Download::new).collect(Collectors.toList()));
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownloads(songs),
+ songs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
});
return true;
}
@@ -280,7 +289,7 @@ public class AlbumPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, false, false, album);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, false, false, album);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java
index 48453b8c..fe24f06e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/ArtistPageFragment.java
@@ -165,7 +165,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
bind.artistPageRadioButton.setOnClickListener(v -> {
artistPageViewModel.getArtistInstantMix().observe(getViewLifecycleOwner(), songs -> {
- if (!songs.isEmpty()) {
+ if (songs != null && !songs.isEmpty()) {
MediaManager.startQueue(mediaBrowserListenableFuture, songs, 0);
activity.setBottomSheetInPeek(true);
} else {
@@ -178,7 +178,7 @@ public class ArtistPageFragment extends Fragment implements ClickCallback {
private void initTopSongsView() {
bind.mostStreamedSongRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, true, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, true, null);
bind.mostStreamedSongRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java
index 2423011e..104d79f8 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DirectoryFragment.java
@@ -33,7 +33,9 @@ import com.cappielloantonio.tempo.ui.adapter.MusicDirectoryAdapter;
import com.cappielloantonio.tempo.ui.dialog.DownloadDirectoryDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.util.MappingUtil;
+import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.DirectoryViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -109,10 +111,14 @@ public class DirectoryFragment extends Fragment implements ClickCallback {
directoryViewModel.loadMusicDirectory(getArguments().getString(Constants.MUSIC_DIRECTORY_ID)).observe(getViewLifecycleOwner(), directory -> {
if (isVisible() && getActivity() != null) {
List songs = directory.getChildren().stream().filter(child -> !child.isDir()).collect(Collectors.toList());
- DownloadUtil.getDownloadTracker(requireContext()).download(
- MappingUtil.mapDownloads(songs),
- songs.stream().map(Download::new).collect(Collectors.toList())
- );
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownloads(songs),
+ songs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
}
});
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java
index b8108fe3..be2b3eb0 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/DownloadFragment.java
@@ -28,11 +28,17 @@ import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.DownloadHorizontalAdapter;
import com.cappielloantonio.tempo.util.Constants;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.DownloadViewModel;
import com.google.android.material.appbar.MaterialToolbar;
import com.google.common.util.concurrent.ListenableFuture;
+import android.content.Intent;
+import android.app.Activity;
+import android.net.Uri;
+import android.widget.Toast;
+
import java.util.Collections;
import java.util.List;
import java.util.Objects;
@@ -40,6 +46,7 @@ import java.util.Objects;
@UnstableApi
public class DownloadFragment extends Fragment implements ClickCallback {
private static final String TAG = "DownloadFragment";
+ private static final int REQUEST_CODE_PICK_DIRECTORY = 1002;
private FragmentDownloadBinding bind;
private MainActivity activity;
@@ -129,8 +136,27 @@ public class DownloadFragment extends Fragment implements ClickCallback {
}
});
+ downloadViewModel.getRefreshResult().observe(getViewLifecycleOwner(), count -> {
+ if (count == null || bind == null) {
+ return;
+ }
+
+ if (count == -1) {
+ Toast.makeText(requireContext(), R.string.download_refresh_no_directory, Toast.LENGTH_SHORT).show();
+ } else if (count == 0) {
+ Toast.makeText(requireContext(), R.string.download_refresh_no_changes, Toast.LENGTH_SHORT).show();
+ } else {
+ Toast.makeText(
+ requireContext(),
+ getResources().getQuantityString(R.plurals.download_refresh_removed, count, count),
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+ });
+
bind.downloadedGroupByImageView.setOnClickListener(view -> showPopupMenu(view, R.menu.download_popup_menu));
bind.downloadedGoBackImageView.setOnClickListener(view -> downloadViewModel.popViewStack());
+ bind.downloadedRefreshImageView.setOnClickListener(view -> downloadViewModel.refreshExternalDownloads());
}
private void finishDownloadView(List songs) {
@@ -216,6 +242,10 @@ public class DownloadFragment extends Fragment implements ClickCallback {
downloadViewModel.initViewStack(new DownloadStack(Constants.DOWNLOAD_TYPE_YEAR, null));
Preferences.setDefaultDownloadViewType(Constants.DOWNLOAD_TYPE_YEAR);
return true;
+ } else if (menuItem.getItemId() == R.id.menu_download_set_directory) {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ startActivityForResult(intent, REQUEST_CODE_PICK_DIRECTORY);
+ return true;
}
return false;
@@ -267,4 +297,21 @@ public class DownloadFragment extends Fragment implements ClickCallback {
public void onDownloadGroupLongClick(Bundle bundle) {
Navigation.findNavController(requireView()).navigate(R.id.downloadBottomSheetDialog, bundle);
}
+
+ @Override
+ public void onActivityResult(int requestCode, int resultCode, Intent data) {
+ super.onActivityResult(requestCode, resultCode, data);
+ if (requestCode == REQUEST_CODE_PICK_DIRECTORY && resultCode == Activity.RESULT_OK) {
+ Uri uri = data.getData();
+ if (uri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ );
+ Preferences.setDownloadDirectoryUri(uri.toString());
+ ExternalAudioReader.refreshCache();
+ Toast.makeText(requireContext(), "Download directory set", Toast.LENGTH_SHORT).show();
+ }
+ }
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java
index 4c47f0c9..7936b5b3 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/HomeTabMusicFragment.java
@@ -9,6 +9,7 @@ import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.widget.PopupMenu;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -40,6 +41,7 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Share;
import com.cappielloantonio.tempo.subsonic.models.AlbumID3;
+import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
import com.cappielloantonio.tempo.ui.activity.MainActivity;
import com.cappielloantonio.tempo.ui.adapter.AlbumAdapter;
import com.cappielloantonio.tempo.ui.adapter.AlbumHorizontalAdapter;
@@ -64,6 +66,8 @@ import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.google.android.material.snackbar.Snackbar;
import com.google.common.util.concurrent.ListenableFuture;
+import androidx.media3.common.MediaItem;
+
import java.util.ArrayList;
import java.util.List;
import java.util.stream.Collectors;
@@ -116,6 +120,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
initSyncStarredView();
initSyncStarredAlbumsView();
+ initSyncStarredArtistsView();
initDiscoverSongSlideView();
initSimilarSongView();
initArtistRadio();
@@ -274,7 +279,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
}
private void initSyncStarredView() {
- if (Preferences.isStarredSyncEnabled()) {
+ if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
homeViewModel.getAllStarredTracks().observeForever(new Observer>() {
@Override
public void onChanged(List songs) {
@@ -327,32 +332,12 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
private void initSyncStarredAlbumsView() {
if (Preferences.isStarredAlbumsSyncEnabled()) {
- homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observeForever(new Observer>() {
+ homeViewModel.getStarredAlbums(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer>() {
@Override
public void onChanged(List albums) {
- if (albums != null) {
- DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
- List albumsToSync = new ArrayList<>();
- int albumCount = 0;
-
- for (AlbumID3 album : albums) {
- boolean needsSync = false;
- albumCount++;
- albumsToSync.add(album.getName());
- }
-
- if (albumCount > 0) {
- bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
- String message = getResources().getQuantityString(
- R.plurals.home_sync_starred_albums_count,
- albumCount,
- albumCount
- );
- bind.homeSyncStarredAlbumsToSync.setText(message);
- }
+ if (albums != null && !albums.isEmpty()) {
+ checkIfAlbumsNeedSync(albums);
}
-
- homeViewModel.getStarredAlbums(getViewLifecycleOwner()).removeObserver(this);
}
});
}
@@ -362,26 +347,157 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
});
bind.homeSyncStarredAlbumsDownload.setOnClickListener(v -> {
- homeViewModel.getAllStarredAlbumSongs().observeForever(new Observer>() {
+ homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer>() {
@Override
public void onChanged(List allSongs) {
- if (allSongs != null) {
+ if (allSongs != null && !allSongs.isEmpty()) {
DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
+ int songsToDownload = 0;
for (Child song : allSongs) {
if (!manager.isDownloaded(song.getId())) {
manager.download(MappingUtil.mapDownload(song), new Download(song));
+ songsToDownload++;
}
}
- }
- homeViewModel.getAllStarredAlbumSongs().removeObserver(this);
+ if (songsToDownload > 0) {
+ Toast.makeText(requireContext(),
+ getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
}
});
});
}
+ private void checkIfAlbumsNeedSync(List albums) {
+ homeViewModel.getAllStarredAlbumSongs().observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List allSongs) {
+ if (allSongs != null) {
+ DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
+ int songsToDownload = 0;
+ List albumsNeedingSync = new ArrayList<>();
+
+ for (AlbumID3 album : albums) {
+ boolean albumNeedsSync = false;
+ // Check if any songs from this album need downloading
+ for (Child song : allSongs) {
+ if (song.getAlbumId() != null && song.getAlbumId().equals(album.getId()) &&
+ !manager.isDownloaded(song.getId())) {
+ songsToDownload++;
+ albumNeedsSync = true;
+ }
+ }
+ if (albumNeedsSync) {
+ albumsNeedingSync.add(album.getName());
+ }
+ }
+
+ if (songsToDownload > 0) {
+ bind.homeSyncStarredAlbumsCard.setVisibility(View.VISIBLE);
+ String message = getResources().getQuantityString(
+ R.plurals.home_sync_starred_albums_count,
+ albumsNeedingSync.size(),
+ albumsNeedingSync.size()
+ );
+ bind.homeSyncStarredAlbumsToSync.setText(message);
+ } else {
+ bind.homeSyncStarredAlbumsCard.setVisibility(View.GONE);
+ }
+ }
+ }
+ });
+ }
+
+ private void initSyncStarredArtistsView() {
+ if (Preferences.isStarredArtistsSyncEnabled()) {
+ homeViewModel.getStarredArtists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List artists) {
+ if (artists != null && !artists.isEmpty()) {
+ checkIfArtistsNeedSync(artists);
+ }
+ }
+ });
+ }
+
+ bind.homeSyncStarredArtistsCancel.setOnClickListener(v -> {
+ bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
+ });
+
+ bind.homeSyncStarredArtistsDownload.setOnClickListener(v -> {
+ homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List allSongs) {
+ if (allSongs != null && !allSongs.isEmpty()) {
+ DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
+ int songsToDownload = 0;
+
+ for (Child song : allSongs) {
+ if (!manager.isDownloaded(song.getId())) {
+ manager.download(MappingUtil.mapDownload(song), new Download(song));
+ songsToDownload++;
+ }
+ }
+
+ if (songsToDownload > 0) {
+ Toast.makeText(requireContext(),
+ getResources().getQuantityString(R.plurals.songs_download_started, songsToDownload, songsToDownload),
+ Toast.LENGTH_SHORT).show();
+ }
+ }
+
+ bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
+ }
+ });
+ });
+ }
+
+ private void checkIfArtistsNeedSync(List artists) {
+ homeViewModel.getAllStarredArtistSongs().observe(getViewLifecycleOwner(), new Observer>() {
+ @Override
+ public void onChanged(List allSongs) {
+ if (allSongs != null) {
+ DownloaderManager manager = DownloadUtil.getDownloadTracker(requireContext());
+ int songsToDownload = 0;
+ List artistsNeedingSync = new ArrayList<>();
+
+ for (ArtistID3 artist : artists) {
+ boolean artistNeedsSync = false;
+ // Check if any songs from this artist need downloading
+ for (Child song : allSongs) {
+ if (song.getArtistId() != null && song.getArtistId().equals(artist.getId()) &&
+ !manager.isDownloaded(song.getId())) {
+ songsToDownload++;
+ artistNeedsSync = true;
+ }
+ }
+ if (artistNeedsSync) {
+ artistsNeedingSync.add(artist.getName());
+ }
+ }
+
+ if (songsToDownload > 0) {
+ bind.homeSyncStarredArtistsCard.setVisibility(View.VISIBLE);
+ String message = getResources().getQuantityString(
+ R.plurals.home_sync_starred_artists_count,
+ artistsNeedingSync.size(),
+ artistsNeedingSync.size()
+ );
+ bind.homeSyncStarredArtistsToSync.setText(message);
+ } else {
+ bind.homeSyncStarredArtistsCard.setVisibility(View.GONE);
+ }
+ }
+ }
+ });
+ }
+
private void initDiscoverSongSlideView() {
if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_DISCOVERY)) return;
@@ -484,7 +600,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.topSongsRecyclerView.setHasFixedSize(true);
- topSongAdapter = new SongHorizontalAdapter(this, true, false, null);
+ topSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.topSongsRecyclerView.setAdapter(topSongAdapter);
setTopSongsMediaBrowserListenableFuture();
reapplyTopSongsPlayback();
@@ -525,7 +641,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback {
bind.starredTracksRecyclerView.setHasFixedSize(true);
- starredSongAdapter = new SongHorizontalAdapter(this, true, false, null);
+ starredSongAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.starredTracksRecyclerView.setAdapter(starredSongAdapter);
setStarredSongsMediaBrowserListenableFuture();
reapplyStarredSongsPlayback();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java
index 392c5786..2d73847e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerCoverFragment.java
@@ -14,6 +14,7 @@ import java.util.ArrayList;
import androidx.annotation.NonNull;
import androidx.fragment.app.Fragment;
import androidx.lifecycle.ViewModelProvider;
+import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.Player;
import androidx.media3.common.util.UnstableApi;
@@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.google.android.material.snackbar.Snackbar;
@@ -115,10 +117,14 @@ public class PlayerCoverFragment extends Fragment {
playerBottomSheetViewModel.getLiveMedia().observe(getViewLifecycleOwner(), song -> {
if (song != null && bind != null) {
bind.innerButtonTopLeft.setOnClickListener(view -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(
- MappingUtil.mapDownload(song),
- new Download(song)
- );
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownload(song),
+ new Download(song)
+ );
+ } else {
+ ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
+ }
});
bind.innerButtonTopRight.setOnClickListener(view -> {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java
index 7140632f..24a1abcd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlayerLyricsFragment.java
@@ -4,15 +4,16 @@ import android.annotation.SuppressLint;
import android.content.ComponentName;
import android.os.Bundle;
import android.os.Handler;
+import android.text.Layout;
import android.text.Spannable;
import android.text.SpannableString;
-import android.text.Layout;
+import android.text.TextUtils;
import android.text.style.ForegroundColorSpan;
-import android.util.Log;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
+import android.widget.Toast;
import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
@@ -29,10 +30,10 @@ import com.cappielloantonio.tempo.service.MediaService;
import com.cappielloantonio.tempo.subsonic.models.Line;
import com.cappielloantonio.tempo.subsonic.models.LyricsList;
import com.cappielloantonio.tempo.util.MusicUtil;
-import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlayerBottomSheetViewModel;
import com.google.common.util.concurrent.ListenableFuture;
+import com.google.android.material.button.MaterialButton;
import com.google.common.util.concurrent.MoreExecutors;
import java.util.List;
@@ -48,6 +49,9 @@ public class PlayerLyricsFragment extends Fragment {
private MediaBrowser mediaBrowser;
private Handler syncLyricsHandler;
private Runnable syncLyricsRunnable;
+ private String currentLyrics;
+ private LyricsList currentLyricsList;
+ private String currentDescription;
@Override
public View onCreateView(@NonNull LayoutInflater inflater, ViewGroup container, Bundle savedInstanceState) {
@@ -66,6 +70,7 @@ public class PlayerLyricsFragment extends Fragment {
super.onViewCreated(view, savedInstanceState);
initPanelContent();
+ observeDownloadState();
}
@Override
@@ -101,12 +106,26 @@ public class PlayerLyricsFragment extends Fragment {
public void onDestroyView() {
super.onDestroyView();
bind = null;
+ currentLyrics = null;
+ currentLyricsList = null;
+ currentDescription = null;
}
private void initOverlay() {
bind.syncLyricsTapButton.setOnClickListener(view -> {
playerBottomSheetViewModel.changeSyncLyricsState();
});
+
+ bind.downloadLyricsButton.setOnClickListener(view -> {
+ boolean saved = playerBottomSheetViewModel.downloadCurrentLyrics();
+ if (getContext() != null) {
+ Toast.makeText(
+ requireContext(),
+ saved ? R.string.player_lyrics_download_success : R.string.player_lyrics_download_failure,
+ Toast.LENGTH_SHORT
+ ).show();
+ }
+ });
}
private void initializeBrowser() {
@@ -136,50 +155,91 @@ public class PlayerLyricsFragment extends Fragment {
}
private void initPanelContent() {
- if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
- playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
- setPanelContent(null, lyricsList);
- });
- } else {
- playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
- setPanelContent(lyrics, null);
- });
- }
+ playerBottomSheetViewModel.getLiveLyrics().observe(getViewLifecycleOwner(), lyrics -> {
+ currentLyrics = lyrics;
+ updatePanelContent();
+ });
+
+ playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
+ currentLyricsList = lyricsList;
+ updatePanelContent();
+ });
+
+ playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
+ currentDescription = description;
+ updatePanelContent();
+ });
}
- private void setPanelContent(String lyrics, LyricsList lyricsList) {
- playerBottomSheetViewModel.getLiveDescription().observe(getViewLifecycleOwner(), description -> {
+ private void observeDownloadState() {
+ playerBottomSheetViewModel.getLyricsCachedState().observe(getViewLifecycleOwner(), cached -> {
if (bind != null) {
- bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
-
- if (lyrics != null && !lyrics.trim().equals("")) {
- bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(lyrics));
- bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
- bind.emptyDescriptionImageView.setVisibility(View.GONE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
- bind.syncLyricsTapButton.setVisibility(View.GONE);
- } else if (lyricsList != null && lyricsList.getStructuredLyrics() != null) {
- setSyncLirics(lyricsList);
- bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
- bind.emptyDescriptionImageView.setVisibility(View.GONE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
- bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
- } else if (description != null && !description.trim().equals("")) {
- bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(description));
- bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
- bind.emptyDescriptionImageView.setVisibility(View.GONE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
- bind.syncLyricsTapButton.setVisibility(View.GONE);
+ MaterialButton downloadButton = (MaterialButton) bind.downloadLyricsButton;
+ if (cached != null && cached) {
+ downloadButton.setIconResource(R.drawable.ic_done);
+ downloadButton.setContentDescription(getString(R.string.player_lyrics_downloaded_content_description));
} else {
- bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
- bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
- bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
- bind.syncLyricsTapButton.setVisibility(View.GONE);
+ downloadButton.setIconResource(R.drawable.ic_download);
+ downloadButton.setContentDescription(getString(R.string.player_lyrics_download_content_description));
}
}
});
}
+ private void updatePanelContent() {
+ if (bind == null) {
+ return;
+ }
+
+ bind.nowPlayingSongLyricsSrollView.smoothScrollTo(0, 0);
+
+ if (hasStructuredLyrics(currentLyricsList)) {
+ setSyncLirics(currentLyricsList);
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
+ bind.emptyDescriptionImageView.setVisibility(View.GONE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
+ bind.syncLyricsTapButton.setVisibility(View.VISIBLE);
+ bind.downloadLyricsButton.setVisibility(View.VISIBLE);
+ bind.downloadLyricsButton.setEnabled(true);
+ } else if (hasText(currentLyrics)) {
+ bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentLyrics));
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
+ bind.emptyDescriptionImageView.setVisibility(View.GONE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
+ bind.syncLyricsTapButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setVisibility(View.VISIBLE);
+ bind.downloadLyricsButton.setEnabled(true);
+ } else if (hasText(currentDescription)) {
+ bind.nowPlayingSongLyricsTextView.setText(MusicUtil.getReadableLyrics(currentDescription));
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.VISIBLE);
+ bind.emptyDescriptionImageView.setVisibility(View.GONE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.GONE);
+ bind.syncLyricsTapButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setEnabled(false);
+ } else {
+ bind.nowPlayingSongLyricsTextView.setVisibility(View.GONE);
+ bind.emptyDescriptionImageView.setVisibility(View.VISIBLE);
+ bind.titleEmptyDescriptionLabel.setVisibility(View.VISIBLE);
+ bind.syncLyricsTapButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setVisibility(View.GONE);
+ bind.downloadLyricsButton.setEnabled(false);
+ }
+ }
+
+ private boolean hasText(String value) {
+ return value != null && !value.trim().isEmpty();
+ }
+
+ private boolean hasStructuredLyrics(LyricsList lyricsList) {
+ return lyricsList != null
+ && lyricsList.getStructuredLyrics() != null
+ && !lyricsList.getStructuredLyrics().isEmpty()
+ && lyricsList.getStructuredLyrics().get(0) != null
+ && lyricsList.getStructuredLyrics().get(0).getLine() != null
+ && !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
+ }
+
@SuppressLint("DefaultLocale")
private void setSyncLirics(LyricsList lyricsList) {
if (lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
@@ -198,28 +258,28 @@ public class PlayerLyricsFragment extends Fragment {
private void defineProgressHandler() {
playerBottomSheetViewModel.getLiveLyricsList().observe(getViewLifecycleOwner(), lyricsList -> {
- if (lyricsList != null) {
-
- if (lyricsList.getStructuredLyrics() != null && lyricsList.getStructuredLyrics().get(0) != null && !lyricsList.getStructuredLyrics().get(0).getSynced()) {
- releaseHandler();
- return;
- }
-
- syncLyricsHandler = new Handler();
- syncLyricsRunnable = () -> {
- if (syncLyricsHandler != null) {
- if (bind != null) {
- displaySyncedLyrics();
- }
-
- syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
- }
- };
-
- syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
- } else {
+ if (!hasStructuredLyrics(lyricsList)) {
releaseHandler();
+ return;
}
+
+ if (!lyricsList.getStructuredLyrics().get(0).getSynced()) {
+ releaseHandler();
+ return;
+ }
+
+ syncLyricsHandler = new Handler();
+ syncLyricsRunnable = () -> {
+ if (syncLyricsHandler != null) {
+ if (bind != null) {
+ displaySyncedLyrics();
+ }
+
+ syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
+ }
+ };
+
+ syncLyricsHandler.postDelayed(syncLyricsRunnable, 250);
});
}
@@ -227,7 +287,7 @@ public class PlayerLyricsFragment extends Fragment {
LyricsList lyricsList = playerBottomSheetViewModel.getLiveLyricsList().getValue();
int timestamp = (int) (mediaBrowser.getCurrentPosition());
- if (lyricsList != null && lyricsList.getStructuredLyrics() != null && !lyricsList.getStructuredLyrics().isEmpty() && lyricsList.getStructuredLyrics().get(0).getLine() != null) {
+ if (hasStructuredLyrics(lyricsList)) {
StringBuilder lyricsBuilder = new StringBuilder();
List lines = lyricsList.getStructuredLyrics().get(0).getLine();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java
index 7fefe7db..d4cf6c0f 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/PlaylistPageFragment.java
@@ -37,6 +37,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.viewmodel.PlaybackViewModel;
import com.cappielloantonio.tempo.viewmodel.PlaylistPageViewModel;
import com.google.common.util.concurrent.ListenableFuture;
@@ -140,7 +142,8 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
if (item.getItemId() == R.id.action_download_playlist) {
playlistPageViewModel.getPlaylistSongLiveList().observe(getViewLifecycleOwner(), songs -> {
if (isVisible() && getActivity() != null) {
- DownloadUtil.getDownloadTracker(requireContext()).download(
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
MappingUtil.mapDownloads(songs),
songs.stream().map(child -> {
Download toDownload = new Download(child);
@@ -148,7 +151,10 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
toDownload.setPlaylistName(playlistPageViewModel.getPlaylist().getName());
return toDownload;
}).collect(Collectors.toList())
- );
+ );
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
}
});
return true;
@@ -258,7 +264,7 @@ public class PlaylistPageFragment extends Fragment implements ClickCallback {
bind.songRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java
index c4b5f3b1..14efc149 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SearchFragment.java
@@ -121,7 +121,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
bind.searchResultTracksRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.searchResultTracksRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
setMediaBrowserListenableFuture();
reapplyPlayback();
@@ -254,7 +254,7 @@ public class SearchFragment extends Fragment implements ClickCallback {
}
private boolean isQueryValid(String query) {
- return !query.equals("") && query.trim().length() > 2;
+ return !query.equals("") && query.trim().length() > 1;
}
private void inputFocus() {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java
index aa33631c..455964a5 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SettingsFragment.java
@@ -1,17 +1,19 @@
package com.cappielloantonio.tempo.ui.fragment;
-import android.content.ComponentName;
+import android.app.Activity;
import android.content.Context;
+import android.content.ComponentName;
import android.content.Intent;
import android.content.ServiceConnection;
import android.media.audiofx.AudioEffect;
+import android.net.Uri;
import android.os.Bundle;
-import android.os.Handler;
import android.os.IBinder;
import android.view.LayoutInflater;
import android.view.View;
import android.view.ViewGroup;
import android.view.WindowManager;
+import android.widget.Toast;
import androidx.activity.result.ActivityResultLauncher;
import androidx.activity.result.contract.ActivityResultContracts;
@@ -42,10 +44,12 @@ import com.cappielloantonio.tempo.ui.dialog.DeleteDownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.DownloadStorageDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StarredAlbumSyncDialog;
+import com.cappielloantonio.tempo.ui.dialog.StarredArtistSyncDialog;
import com.cappielloantonio.tempo.ui.dialog.StreamingCacheStorageDialog;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.Preferences;
import com.cappielloantonio.tempo.util.UIUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.SettingViewModel;
import java.util.Locale;
@@ -58,7 +62,8 @@ public class SettingsFragment extends PreferenceFragmentCompat {
private MainActivity activity;
private SettingViewModel settingViewModel;
- private ActivityResultLauncher someActivityResultLauncher;
+ private ActivityResultLauncher equalizerResultLauncher;
+ private ActivityResultLauncher directoryPickerLauncher;
private MediaService.LocalBinder mediaServiceBinder;
private boolean isServiceBound = false;
@@ -67,9 +72,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
public void onCreate(@Nullable Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
- someActivityResultLauncher = registerForActivityResult(
+ equalizerResultLauncher = registerForActivityResult(
+ new ActivityResultContracts.StartActivityForResult(),
+ result -> {}
+ );
+
+ directoryPickerLauncher = registerForActivityResult(
new ActivityResultContracts.StartActivityForResult(),
result -> {
+ if (result.getResultCode() == Activity.RESULT_OK) {
+ Intent data = result.getData();
+ if (data != null) {
+ Uri uri = data.getData();
+ if (uri != null) {
+ requireContext().getContentResolver().takePersistableUriPermission(
+ uri,
+ Intent.FLAG_GRANT_READ_URI_PERMISSION | Intent.FLAG_GRANT_WRITE_URI_PERMISSION
+ );
+
+ Preferences.setDownloadDirectoryUri(uri.toString());
+ ExternalAudioReader.refreshCache();
+ Toast.makeText(requireContext(), "Download folder set.", Toast.LENGTH_SHORT).show();
+ checkDownloadDirectory();
+ }
+ }
+ }
});
}
@@ -101,6 +128,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
checkSystemEqualizer();
checkCacheStorage();
checkStorage();
+ checkDownloadDirectory();
setStreamingCacheSize();
setAppLanguage();
@@ -110,10 +138,14 @@ public class SettingsFragment extends PreferenceFragmentCompat {
actionScan();
actionSyncStarredAlbums();
actionSyncStarredTracks();
+ actionSyncStarredArtists();
actionChangeStreamingCacheStorage();
actionChangeDownloadStorage();
+ actionSetDownloadDirectory();
actionDeleteDownloadStorage();
actionKeepScreenOn();
+ actionAutoDownloadLyrics();
+ actionMiniPlayerHeart();
bindMediaService();
actionAppEqualizer();
@@ -148,7 +180,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if ((intent.resolveActivity(requireActivity().getPackageManager()) != null)) {
equalizer.setOnPreferenceClickListener(preference -> {
- someActivityResultLauncher.launch(intent);
+ equalizerResultLauncher.launch(intent);
return true;
});
} else {
@@ -165,7 +197,7 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
- storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
+ storage.setSummary(Preferences.getStreamingCacheStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
}
} catch (Exception exception) {
storage.setVisible(false);
@@ -181,13 +213,46 @@ public class SettingsFragment extends PreferenceFragmentCompat {
if (requireContext().getExternalFilesDirs(null)[1] == null) {
storage.setVisible(false);
} else {
- storage.setSummary(Preferences.getDownloadStoragePreference() == 0 ? R.string.download_storage_internal_dialog_negative_button : R.string.download_storage_external_dialog_positive_button);
+ int pref = Preferences.getDownloadStoragePreference();
+ if (pref == 0) {
+ storage.setSummary(R.string.download_storage_internal_dialog_negative_button);
+ } else if (pref == 1) {
+ storage.setSummary(R.string.download_storage_external_dialog_positive_button);
+ } else {
+ storage.setSummary(R.string.download_storage_directory_dialog_neutral_button);
+ }
}
} catch (Exception exception) {
storage.setVisible(false);
}
}
+ private void checkDownloadDirectory() {
+ Preference storage = findPreference("download_storage");
+ Preference directory = findPreference("set_download_directory");
+
+ if (directory == null) return;
+
+ String current = Preferences.getDownloadDirectoryUri();
+ if (current != null) {
+ if (storage != null) storage.setVisible(false);
+ directory.setVisible(true);
+ directory.setIcon(R.drawable.ic_close);
+ directory.setTitle("Clear download folder");
+ directory.setSummary(current);
+ } else {
+ if (storage != null) storage.setVisible(true);
+ if (Preferences.getDownloadStoragePreference() == 2) {
+ directory.setVisible(true);
+ directory.setIcon(R.drawable.ic_folder);
+ directory.setTitle("Set download folder");
+ directory.setSummary("Choose a folder for downloaded music files");
+ } else {
+ directory.setVisible(false);
+ }
+ }
+ }
+
private void setStreamingCacheSize() {
ListPreference streamingCachePreference = findPreference("streaming_cache_size");
@@ -296,7 +361,21 @@ public class SettingsFragment extends PreferenceFragmentCompat {
return true;
});
}
-
+
+ private void actionSyncStarredArtists() {
+ findPreference("sync_starred_artists_for_offline_use").setOnPreferenceChangeListener((preference, newValue) -> {
+ if (newValue instanceof Boolean) {
+ if ((Boolean) newValue) {
+ StarredArtistSyncDialog dialog = new StarredArtistSyncDialog(() -> {
+ ((SwitchPreference)preference).setChecked(false);
+ });
+ dialog.show(activity.getSupportFragmentManager(), null);
+ }
+ }
+ return true;
+ });
+ }
+
private void actionChangeStreamingCacheStorage() {
findPreference("streaming_cache_storage").setOnPreferenceClickListener(preference -> {
StreamingCacheStorageDialog dialog = new StreamingCacheStorageDialog(new DialogClickCallback() {
@@ -321,11 +400,19 @@ public class SettingsFragment extends PreferenceFragmentCompat {
@Override
public void onPositiveClick() {
findPreference("download_storage").setSummary(R.string.download_storage_external_dialog_positive_button);
+ checkDownloadDirectory();
}
@Override
public void onNegativeClick() {
findPreference("download_storage").setSummary(R.string.download_storage_internal_dialog_negative_button);
+ checkDownloadDirectory();
+ }
+
+ @Override
+ public void onNeutralClick() {
+ findPreference("download_storage").setSummary(R.string.download_storage_directory_dialog_neutral_button);
+ checkDownloadDirectory();
}
});
dialog.show(activity.getSupportFragmentManager(), null);
@@ -333,6 +420,31 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
+ private void actionSetDownloadDirectory() {
+ Preference pref = findPreference("set_download_directory");
+ if (pref != null) {
+ pref.setOnPreferenceClickListener(preference -> {
+ String current = Preferences.getDownloadDirectoryUri();
+
+ if (current != null) {
+ Preferences.setDownloadDirectoryUri(null);
+ Preferences.setDownloadStoragePreference(0);
+ ExternalAudioReader.refreshCache();
+ Toast.makeText(requireContext(), "Download folder cleared.", Toast.LENGTH_SHORT).show();
+ checkStorage();
+ checkDownloadDirectory();
+ } else {
+ Intent intent = new Intent(Intent.ACTION_OPEN_DOCUMENT_TREE);
+ intent.addFlags(Intent.FLAG_GRANT_PERSISTABLE_URI_PERMISSION
+ | Intent.FLAG_GRANT_READ_URI_PERMISSION
+ | Intent.FLAG_GRANT_WRITE_URI_PERMISSION);
+ directoryPickerLauncher.launch(intent);
+ }
+ return true;
+ });
+ }
+ }
+
private void actionDeleteDownloadStorage() {
findPreference("delete_download_storage").setOnPreferenceClickListener(preference -> {
DeleteDownloadStorageDialog dialog = new DeleteDownloadStorageDialog();
@@ -341,6 +453,36 @@ public class SettingsFragment extends PreferenceFragmentCompat {
});
}
+ private void actionMiniPlayerHeart() {
+ SwitchPreference preference = findPreference("mini_shuffle_button_visibility");
+ if (preference == null) {
+ return;
+ }
+
+ preference.setChecked(Preferences.showShuffleInsteadOfHeart());
+ preference.setOnPreferenceChangeListener((pref, newValue) -> {
+ if (newValue instanceof Boolean) {
+ Preferences.setShuffleInsteadOfHeart((Boolean) newValue);
+ }
+ return true;
+ });
+ }
+
+ private void actionAutoDownloadLyrics() {
+ SwitchPreference preference = findPreference("auto_download_lyrics");
+ if (preference == null) {
+ return;
+ }
+
+ preference.setChecked(Preferences.isAutoDownloadLyricsEnabled());
+ preference.setOnPreferenceChangeListener((pref, newValue) -> {
+ if (newValue instanceof Boolean) {
+ Preferences.setAutoDownloadLyricsEnabled((Boolean) newValue);
+ }
+ return true;
+ });
+ }
+
private void getScanStatus() {
settingViewModel.getScanStatus(new ScanCallback() {
@Override
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java
index a524e7c7..fe46edae 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/SongListPageFragment.java
@@ -201,7 +201,7 @@ public class SongListPageFragment extends Fragment implements ClickCallback {
bind.songListRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext()));
bind.songListRecyclerView.setHasFixedSize(true);
- songHorizontalAdapter = new SongHorizontalAdapter(this, true, false, null);
+ songHorizontalAdapter = new SongHorizontalAdapter(getViewLifecycleOwner(), this, true, false, null);
bind.songListRecyclerView.setAdapter(songHorizontalAdapter);
setMediaBrowserListenableFuture();
reapplyPlayback();
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java
index f7b742e4..a6167eed 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/AlbumBottomSheetDialog.java
@@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.MediaItem;
@@ -37,6 +38,8 @@ import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.viewmodel.AlbumBottomSheetViewModel;
import com.cappielloantonio.tempo.viewmodel.HomeViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
@@ -54,6 +57,10 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
private AlbumBottomSheetViewModel albumBottomSheetViewModel;
private AlbumID3 album;
+ private TextView removeAllTextView;
+ private List currentAlbumTracks = Collections.emptyList();
+ private List currentAlbumMediaItems = Collections.emptyList();
+
private ListenableFuture mediaBrowserListenableFuture;
@Nullable
@@ -72,6 +79,12 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateRemoveAllVisibility);
+ }
+
@Override
public void onStart() {
super.onStart();
@@ -163,7 +176,11 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
downloadAll.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(mediaItems, downloads);
+ } else {
+ songs.forEach(child -> ExternalAudioWriter.downloadToUserDirectory(requireContext(), child));
+ }
dismissBottomSheet();
});
});
@@ -182,19 +199,23 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
});
});
- TextView removeAll = view.findViewById(R.id.remove_all_text_view);
+ removeAllTextView = view.findViewById(R.id.remove_all_text_view);
albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
- List mediaItems = MappingUtil.mapDownloads(songs);
- List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
+ currentAlbumTracks = songs != null ? songs : Collections.emptyList();
+ currentAlbumMediaItems = MappingUtil.mapDownloads(currentAlbumTracks);
- removeAll.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
+ removeAllTextView.setOnClickListener(v -> {
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ List downloads = currentAlbumTracks.stream().map(Download::new).collect(Collectors.toList());
+ DownloadUtil.getDownloadTracker(requireContext()).remove(currentAlbumMediaItems, downloads);
+ } else {
+ currentAlbumTracks.forEach(ExternalAudioReader::delete);
+ }
dismissBottomSheet();
});
+ updateRemoveAllVisibility();
});
- initDownloadUI(removeAll);
-
TextView goToArtist = view.findViewById(R.id.go_to_artist_text_view);
goToArtist.setOnClickListener(v -> albumBottomSheetViewModel.getArtist().observe(getViewLifecycleOwner(), artist -> {
if (artist != null) {
@@ -234,14 +255,29 @@ public class AlbumBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
- private void initDownloadUI(TextView removeAll) {
- albumBottomSheetViewModel.getAlbumTracks().observe(getViewLifecycleOwner(), songs -> {
- List mediaItems = MappingUtil.mapDownloads(songs);
+ private void updateRemoveAllVisibility() {
+ if (removeAllTextView == null) {
+ return;
+ }
- if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
- removeAll.setVisibility(View.VISIBLE);
+ if (currentAlbumTracks == null || currentAlbumTracks.isEmpty()) {
+ removeAllTextView.setVisibility(View.GONE);
+ return;
+ }
+
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ List mediaItems = currentAlbumMediaItems;
+ if (mediaItems == null || mediaItems.isEmpty()) {
+ removeAllTextView.setVisibility(View.GONE);
+ } else if (DownloadUtil.getDownloadTracker(requireContext()).areDownloaded(mediaItems)) {
+ removeAllTextView.setVisibility(View.VISIBLE);
+ } else {
+ removeAllTextView.setVisibility(View.GONE);
}
- });
+ } else {
+ boolean hasLocal = currentAlbumTracks.stream().anyMatch(song -> ExternalAudioReader.getUri(song) != null);
+ removeAllTextView.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
+ }
}
private void initializeMediaBrowser() {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java
index f3c9d490..78fc943e 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/ArtistBottomSheetDialog.java
@@ -66,7 +66,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
super.onStop();
}
- // TODO Utilizzare il viewmodel come tramite ed evitare le chiamate dirette
+ // TODO Use the viewmodel as a conduit and avoid direct calls
private void init(View view) {
ImageView coverArtist = view.findViewById(R.id.artist_cover_image_view);
CustomGlideRequest.Builder
@@ -81,7 +81,7 @@ public class ArtistBottomSheetDialog extends BottomSheetDialogFragment implement
ToggleButton favoriteToggle = view.findViewById(R.id.button_favorite);
favoriteToggle.setChecked(artistBottomSheetViewModel.getArtist().getStarred() != null);
favoriteToggle.setOnClickListener(v -> {
- artistBottomSheetViewModel.setFavorite();
+ artistBottomSheetViewModel.setFavorite(requireContext());
});
TextView playRadio = view.findViewById(R.id.play_radio_text_view);
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java
index ee339342..4a720640 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/DownloadedBottomSheetDialog.java
@@ -25,6 +25,8 @@ import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
+import com.cappielloantonio.tempo.util.Preferences;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture;
@@ -117,10 +119,13 @@ public class DownloadedBottomSheetDialog extends BottomSheetDialogFragment imple
TextView removeAll = view.findViewById(R.id.remove_all_text_view);
removeAll.setOnClickListener(v -> {
- List mediaItems = MappingUtil.mapDownloads(songs);
- List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
-
- DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ List mediaItems = MappingUtil.mapDownloads(songs);
+ List downloads = songs.stream().map(Download::new).collect(Collectors.toList());
+ DownloadUtil.getDownloadTracker(requireContext()).remove(mediaItems, downloads);
+ } else {
+ songs.forEach(ExternalAudioReader::delete);
+ }
dismissBottomSheet();
});
diff --git a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java
index 0d15ad2f..90e32793 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/ui/fragment/bottomsheetdialog/SongBottomSheetDialog.java
@@ -13,6 +13,7 @@ import android.widget.TextView;
import android.widget.Toast;
import android.widget.ToggleButton;
+import androidx.annotation.NonNull;
import androidx.annotation.Nullable;
import androidx.lifecycle.ViewModelProvider;
import androidx.media3.common.util.UnstableApi;
@@ -31,6 +32,7 @@ import com.cappielloantonio.tempo.ui.dialog.PlaylistChooserDialog;
import com.cappielloantonio.tempo.ui.dialog.RatingDialog;
import com.cappielloantonio.tempo.util.Constants;
import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.MusicUtil;
import com.cappielloantonio.tempo.util.Preferences;
@@ -39,6 +41,10 @@ import com.cappielloantonio.tempo.viewmodel.SongBottomSheetViewModel;
import com.google.android.material.bottomsheet.BottomSheetDialogFragment;
import com.google.common.util.concurrent.ListenableFuture;
+import android.content.Intent;
+import androidx.media3.common.MediaItem;
+import com.cappielloantonio.tempo.util.ExternalAudioWriter;
+
import java.util.ArrayList;
import java.util.Collections;
@@ -48,6 +54,9 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
private SongBottomSheetViewModel songBottomSheetViewModel;
private Child song;
+ private TextView downloadButton;
+ private TextView removeButton;
+
private ListenableFuture mediaBrowserListenableFuture;
@Nullable
@@ -66,6 +75,12 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
return view;
}
+ @Override
+ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceState) {
+ super.onViewCreated(view, savedInstanceState);
+ MappingUtil.observeExternalAudioRefresh(getViewLifecycleOwner(), this::updateDownloadButtons);
+ }
+
@Override
public void onStart() {
super.onStart();
@@ -157,25 +172,33 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismissBottomSheet();
});
- TextView download = view.findViewById(R.id.download_text_view);
- download.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).download(
- MappingUtil.mapDownload(song),
- new Download(song)
- );
+ downloadButton = view.findViewById(R.id.download_text_view);
+ downloadButton.setOnClickListener(v -> {
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).download(
+ MappingUtil.mapDownload(song),
+ new Download(song)
+ );
+ } else {
+ ExternalAudioWriter.downloadToUserDirectory(requireContext(), song);
+ }
dismissBottomSheet();
});
- TextView remove = view.findViewById(R.id.remove_text_view);
- remove.setOnClickListener(v -> {
- DownloadUtil.getDownloadTracker(requireContext()).remove(
- MappingUtil.mapDownload(song),
- new Download(song)
- );
+ removeButton = view.findViewById(R.id.remove_text_view);
+ removeButton.setOnClickListener(v -> {
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ DownloadUtil.getDownloadTracker(requireContext()).remove(
+ MappingUtil.mapDownload(song),
+ new Download(song)
+ );
+ } else {
+ ExternalAudioReader.delete(song);
+ }
dismissBottomSheet();
});
- initDownloadUI(download, remove);
+ updateDownloadButtons();
TextView addToPlaylist = view.findViewById(R.id.add_to_playlist_text_view);
addToPlaylist.setOnClickListener(v -> {
@@ -243,12 +266,19 @@ public class SongBottomSheetDialog extends BottomSheetDialogFragment implements
dismiss();
}
- private void initDownloadUI(TextView download, TextView remove) {
- if (DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId())) {
- remove.setVisibility(View.VISIBLE);
+ private void updateDownloadButtons() {
+ if (downloadButton == null || removeButton == null) {
+ return;
+ }
+
+ if (Preferences.getDownloadDirectoryUri() == null) {
+ boolean downloaded = DownloadUtil.getDownloadTracker(requireContext()).isDownloaded(song.getId());
+ downloadButton.setVisibility(downloaded ? View.GONE : View.VISIBLE);
+ removeButton.setVisibility(downloaded ? View.VISIBLE : View.GONE);
} else {
- download.setVisibility(View.VISIBLE);
- remove.setVisibility(View.GONE);
+ boolean hasLocal = ExternalAudioReader.getUri(song) != null;
+ downloadButton.setVisibility(hasLocal ? View.GONE : View.VISIBLE);
+ removeButton.setVisibility(hasLocal ? View.VISIBLE : View.GONE);
}
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt
index da8862df..bf2a1a72 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt
@@ -85,6 +85,13 @@ object Constants {
const val MEDIA_LEAST_RECENTLY_STARRED = "MEDIA_LEAST_RECENTLY_STARRED"
const val DOWNLOAD_URI = "rest/download"
+ const val ACTION_PLAY_EXTERNAL_DOWNLOAD = "com.cappielloantonio.tempo.action.PLAY_EXTERNAL_DOWNLOAD"
+ const val EXTRA_DOWNLOAD_URI = "EXTRA_DOWNLOAD_URI"
+ const val EXTRA_DOWNLOAD_MEDIA_ID = "EXTRA_DOWNLOAD_MEDIA_ID"
+ const val EXTRA_DOWNLOAD_TITLE = "EXTRA_DOWNLOAD_TITLE"
+ const val EXTRA_DOWNLOAD_ARTIST = "EXTRA_DOWNLOAD_ARTIST"
+ const val EXTRA_DOWNLOAD_ALBUM = "EXTRA_DOWNLOAD_ALBUM"
+ const val EXTRA_DOWNLOAD_DURATION = "EXTRA_DOWNLOAD_DURATION"
const val DOWNLOAD_TYPE_TRACK = "download_type_track"
const val DOWNLOAD_TYPE_ALBUM = "download_type_album"
@@ -116,4 +123,13 @@ object Constants {
const val HOME_SECTOR_RECENTLY_ADDED = "HOME_SECTOR_RECENTLY_ADDED"
const val HOME_SECTOR_PINNED_PLAYLISTS = "HOME_SECTOR_PINNED_PLAYLISTS"
const val HOME_SECTOR_SHARED = "HOME_SECTOR_SHARED"
+
+ const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON = "android.media3.session.demo.SHUFFLE_ON"
+ const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF = "android.media3.session.demo.SHUFFLE_OFF"
+ const val CUSTOM_COMMAND_TOGGLE_HEART_ON = "android.media3.session.demo.HEART_ON"
+ const val CUSTOM_COMMAND_TOGGLE_HEART_OFF = "android.media3.session.demo.HEART_OFF"
+ const val CUSTOM_COMMAND_TOGGLE_HEART_LOADING = "android.media3.session.demo.HEART_LOADING"
+ const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF = "android.media3.session.demo.REPEAT_OFF"
+ const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE = "android.media3.session.demo.REPEAT_ONE"
+ const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL = "android.media3.session.demo.REPEAT_ALL"
}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java
index 238b4136..6df73eb6 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/DownloadUtil.java
@@ -187,19 +187,21 @@ public final class DownloadUtil {
private static synchronized File getDownloadDirectory(Context context) {
if (downloadDirectory == null) {
- if (Preferences.getDownloadStoragePreference() == 0) {
+ int pref = Preferences.getDownloadStoragePreference();
+ if (pref == 0) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
if (downloadDirectory == null) {
downloadDirectory = context.getFilesDir();
}
- } else {
+ } else if (pref == 1) {
try {
downloadDirectory = context.getExternalFilesDirs(null)[1];
} catch (Exception exception) {
downloadDirectory = context.getExternalFilesDirs(null)[0];
Preferences.setDownloadStoragePreference(0);
}
-
+ } else {
+ downloadDirectory = context.getExternalFilesDirs(null)[0];
}
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java
new file mode 100644
index 00000000..b8679f13
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioReader.java
@@ -0,0 +1,244 @@
+package com.cappielloantonio.tempo.util;
+
+import android.net.Uri;
+import android.os.Looper;
+import android.os.SystemClock;
+
+import androidx.documentfile.provider.DocumentFile;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.MutableLiveData;
+
+import com.cappielloantonio.tempo.App;
+import com.cappielloantonio.tempo.subsonic.models.Child;
+import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
+
+import java.text.Normalizer;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Locale;
+import java.util.Map;
+import java.util.Set;
+import java.util.concurrent.ConcurrentHashMap;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class ExternalAudioReader {
+
+ private static final Map cache = new ConcurrentHashMap<>();
+ private static final Object LOCK = new Object();
+ private static final ExecutorService REFRESH_EXECUTOR = Executors.newSingleThreadExecutor();
+ private static final MutableLiveData refreshEvents = new MutableLiveData<>();
+
+ private static volatile String cachedDirUri;
+ private static volatile boolean refreshInProgress = false;
+ private static volatile boolean refreshQueued = false;
+
+ private static String sanitizeFileName(String name) {
+ String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
+ sanitized = sanitized.replaceAll("\\s+", " ").trim();
+ return sanitized;
+ }
+
+ private static String normalizeForComparison(String name) {
+ String s = sanitizeFileName(name);
+ s = Normalizer.normalize(s, Normalizer.Form.NFKD);
+ s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
+ return s.toLowerCase(Locale.ROOT);
+ }
+
+ private static void ensureCache() {
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString == null) {
+ synchronized (LOCK) {
+ cache.clear();
+ cachedDirUri = null;
+ }
+ ExternalDownloadMetadataStore.clear();
+ return;
+ }
+
+ if (uriString.equals(cachedDirUri)) {
+ return;
+ }
+
+ boolean runSynchronously = false;
+ synchronized (LOCK) {
+ if (refreshInProgress) {
+ return;
+ }
+
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ scheduleRefreshLocked();
+ return;
+ }
+
+ refreshInProgress = true;
+ runSynchronously = true;
+ }
+
+ if (runSynchronously) {
+ try {
+ rebuildCache();
+ } finally {
+ onRefreshFinished();
+ }
+ }
+ }
+
+ public static void refreshCache() {
+ refreshCacheAsync();
+ }
+
+ public static void refreshCacheAsync() {
+ synchronized (LOCK) {
+ cachedDirUri = null;
+ cache.clear();
+ }
+ requestRefresh();
+ }
+
+ public static LiveData getRefreshEvents() {
+ return refreshEvents;
+ }
+
+ private static String buildKey(String artist, String title, String album) {
+ String name = artist != null && !artist.isEmpty() ? artist + " - " + title : title;
+ if (album != null && !album.isEmpty()) name += " (" + album + ")";
+ return normalizeForComparison(name);
+ }
+
+ private static Uri findUri(String artist, String title, String album) {
+ ensureCache();
+ if (cachedDirUri == null) return null;
+
+ DocumentFile file = cache.get(buildKey(artist, title, album));
+ return file != null && file.exists() ? file.getUri() : null;
+ }
+
+ public static Uri getUri(Child media) {
+ return findUri(media.getArtist(), media.getTitle(), media.getAlbum());
+ }
+
+ public static Uri getUri(PodcastEpisode episode) {
+ return findUri(episode.getArtist(), episode.getTitle(), episode.getAlbum());
+ }
+
+ public static synchronized void removeMetadata(Child media) {
+ if (media == null) {
+ return;
+ }
+
+ String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
+ cache.remove(key);
+ ExternalDownloadMetadataStore.remove(key);
+ }
+
+ public static boolean delete(Child media) {
+ ensureCache();
+ if (cachedDirUri == null) return false;
+
+ String key = buildKey(media.getArtist(), media.getTitle(), media.getAlbum());
+ DocumentFile file = cache.get(key);
+ boolean deleted = false;
+ if (file != null && file.exists()) {
+ deleted = file.delete();
+ }
+ if (deleted) {
+ cache.remove(key);
+ ExternalDownloadMetadataStore.remove(key);
+ }
+ return deleted;
+ }
+
+ private static void requestRefresh() {
+ synchronized (LOCK) {
+ scheduleRefreshLocked();
+ }
+ }
+
+ private static void scheduleRefreshLocked() {
+ if (refreshInProgress) {
+ refreshQueued = true;
+ return;
+ }
+
+ refreshInProgress = true;
+ REFRESH_EXECUTOR.execute(() -> {
+ try {
+ rebuildCache();
+ } finally {
+ onRefreshFinished();
+ }
+ });
+ }
+
+ private static void rebuildCache() {
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString == null) {
+ synchronized (LOCK) {
+ cache.clear();
+ cachedDirUri = null;
+ }
+ ExternalDownloadMetadataStore.clear();
+ return;
+ }
+
+ DocumentFile directory = DocumentFile.fromTreeUri(App.getContext(), Uri.parse(uriString));
+ Map expectedSizes = ExternalDownloadMetadataStore.snapshot();
+ Set verifiedKeys = new HashSet<>();
+ Map newEntries = new HashMap<>();
+
+ if (directory != null && directory.canRead()) {
+ for (DocumentFile file : directory.listFiles()) {
+ if (file == null || file.isDirectory()) continue;
+ String existing = file.getName();
+ if (existing == null) continue;
+
+ String base = existing.replaceFirst("\\.[^\\.]+$", "");
+ String key = normalizeForComparison(base);
+ Long expected = expectedSizes.get(key);
+ long actualLength = file.length();
+
+ if (expected != null && expected > 0 && actualLength == expected) {
+ newEntries.put(key, file);
+ verifiedKeys.add(key);
+ } else {
+ ExternalDownloadMetadataStore.remove(key);
+ }
+ }
+ }
+
+ if (!expectedSizes.isEmpty()) {
+ if (verifiedKeys.isEmpty()) {
+ ExternalDownloadMetadataStore.clear();
+ } else {
+ for (String key : expectedSizes.keySet()) {
+ if (!verifiedKeys.contains(key)) {
+ ExternalDownloadMetadataStore.remove(key);
+ }
+ }
+ }
+ }
+
+ synchronized (LOCK) {
+ cache.clear();
+ cache.putAll(newEntries);
+ cachedDirUri = uriString;
+ }
+ }
+
+ private static void onRefreshFinished() {
+ boolean runAgain;
+ synchronized (LOCK) {
+ refreshInProgress = false;
+ runAgain = refreshQueued;
+ refreshQueued = false;
+ }
+
+ refreshEvents.postValue(SystemClock.elapsedRealtime());
+
+ if (runAgain) {
+ requestRefresh();
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java
new file mode 100644
index 00000000..cf0f768e
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalAudioWriter.java
@@ -0,0 +1,322 @@
+package com.cappielloantonio.tempo.util;
+
+import android.app.NotificationManager;
+import android.app.PendingIntent;
+import android.content.Context;
+import android.content.Intent;
+import android.net.Uri;
+import android.provider.Settings;
+import android.webkit.MimeTypeMap;
+
+import androidx.core.app.NotificationCompat;
+import androidx.documentfile.provider.DocumentFile;
+import androidx.media3.common.MediaItem;
+
+import com.cappielloantonio.tempo.model.Download;
+import com.cappielloantonio.tempo.repository.DownloadRepository;
+import com.cappielloantonio.tempo.subsonic.models.Child;
+import com.cappielloantonio.tempo.ui.activity.MainActivity;
+
+import java.io.InputStream;
+import java.io.OutputStream;
+import java.net.HttpURLConnection;
+import java.net.URL;
+import java.text.Normalizer;
+import java.util.Locale;
+import java.util.concurrent.ExecutorService;
+import java.util.concurrent.Executors;
+
+public class ExternalAudioWriter {
+
+ private static final ExecutorService EXECUTOR = Executors.newSingleThreadExecutor();
+ private static final int BUFFER_SIZE = 8192;
+ private static final int CONNECT_TIMEOUT_MS = 15_000;
+ private static final int READ_TIMEOUT_MS = 60_000;
+
+ private ExternalAudioWriter() {
+ }
+
+ private static String sanitizeFileName(String name) {
+ String sanitized = name.replaceAll("[\\/:*?\\\"<>|]", "_");
+ sanitized = sanitized.replaceAll("\\s+", " ").trim();
+ return sanitized;
+ }
+
+ private static String normalizeForComparison(String name) {
+ String s = sanitizeFileName(name);
+ s = Normalizer.normalize(s, Normalizer.Form.NFKD);
+ s = s.replaceAll("\\p{InCombiningDiacriticalMarks}+", "");
+ return s.toLowerCase(Locale.ROOT);
+ }
+
+ private static DocumentFile findFile(DocumentFile dir, String fileName) {
+ String normalized = normalizeForComparison(fileName);
+ for (DocumentFile file : dir.listFiles()) {
+ if (file.isDirectory()) continue;
+ String existing = file.getName();
+ if (existing != null && normalizeForComparison(existing).equals(normalized)) {
+ return file;
+ }
+ }
+ return null;
+ }
+
+ public static void downloadToUserDirectory(Context context, Child child) {
+ if (context == null || child == null) {
+ return;
+ }
+ Context appContext = context.getApplicationContext();
+ MediaItem mediaItem = MappingUtil.mapDownload(child);
+ String fallbackName = child.getTitle() != null ? child.getTitle() : child.getId();
+ EXECUTOR.execute(() -> performDownload(appContext, mediaItem, fallbackName, child));
+ }
+
+ private static void performDownload(Context context, MediaItem mediaItem, String fallbackName, Child child) {
+ String uriString = Preferences.getDownloadDirectoryUri();
+ if (uriString == null) {
+ notifyUnavailable(context);
+ return;
+ }
+
+ DocumentFile directory = DocumentFile.fromTreeUri(context, Uri.parse(uriString));
+ if (directory == null || !directory.canWrite()) {
+ notifyFailure(context, "Cannot write to folder.");
+ return;
+ }
+
+ String artist = child.getArtist() != null ? child.getArtist() : "";
+ String title = child.getTitle() != null ? child.getTitle() : fallbackName;
+ String album = child.getAlbum() != null ? child.getAlbum() : "";
+ String baseName = artist.isEmpty() ? title : artist + " - " + title;
+ if (!album.isEmpty()) baseName += " (" + album + ")";
+ if (baseName.isEmpty()) {
+ baseName = fallbackName != null ? fallbackName : "download";
+ }
+ String metadataKey = normalizeForComparison(baseName);
+
+ Uri mediaUri = mediaItem != null && mediaItem.requestMetadata != null
+ ? mediaItem.requestMetadata.mediaUri
+ : null;
+ if (mediaUri == null) {
+ notifyFailure(context, "Invalid media URI.");
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ return;
+ }
+ String scheme = mediaUri.getScheme();
+ if (scheme == null || (!scheme.equalsIgnoreCase("http") && !scheme.equalsIgnoreCase("https"))) {
+ notifyFailure(context, "Unsupported media URI.");
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ return;
+ }
+
+ HttpURLConnection connection = null;
+ DocumentFile targetFile = null;
+ try {
+ connection = (HttpURLConnection) new URL(mediaUri.toString()).openConnection();
+ connection.setConnectTimeout(CONNECT_TIMEOUT_MS);
+ connection.setReadTimeout(READ_TIMEOUT_MS);
+ connection.setRequestProperty("Accept-Encoding", "identity");
+ connection.connect();
+
+ int responseCode = connection.getResponseCode();
+ if (responseCode >= HttpURLConnection.HTTP_BAD_REQUEST) {
+ notifyFailure(context, "Server returned " + responseCode);
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ return;
+ }
+
+ String mimeType = connection.getContentType();
+ if (mimeType == null || mimeType.isEmpty()) {
+ mimeType = "application/octet-stream";
+ }
+
+ String extension = MimeTypeMap.getSingleton().getExtensionFromMimeType(mimeType);
+ if (extension == null || extension.isEmpty()) {
+ String suffix = child.getSuffix();
+ if (suffix != null && !suffix.isEmpty()) {
+ extension = suffix;
+ } else {
+ extension = "bin";
+ }
+ }
+
+ String sanitized = sanitizeFileName(baseName);
+ if (sanitized.isEmpty()) sanitized = sanitizeFileName(fallbackName);
+ if (sanitized.isEmpty()) sanitized = "download";
+ String fileName = sanitized + "." + extension;
+
+ DocumentFile existingFile = findFile(directory, fileName);
+ long remoteLength = connection.getContentLengthLong();
+ Long recordedSize = ExternalDownloadMetadataStore.getSize(metadataKey);
+ if (existingFile != null && existingFile.exists()) {
+ long localLength = existingFile.length();
+ boolean matches = false;
+ if (remoteLength > 0 && localLength == remoteLength) {
+ matches = true;
+ } else if (remoteLength <= 0 && recordedSize != null && localLength == recordedSize) {
+ matches = true;
+ }
+ if (matches) {
+ ExternalDownloadMetadataStore.recordSize(metadataKey, localLength);
+ recordDownload(child, existingFile.getUri());
+ ExternalAudioReader.refreshCacheAsync();
+ notifyExists(context, fileName);
+ return;
+ } else {
+ existingFile.delete();
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ }
+ }
+
+ targetFile = directory.createFile(mimeType, fileName);
+ if (targetFile == null) {
+ notifyFailure(context, "Failed to create file.");
+ return;
+ }
+
+ Uri targetUri = targetFile.getUri();
+ try (InputStream in = connection.getInputStream();
+ OutputStream out = context.getContentResolver().openOutputStream(targetUri)) {
+ if (out == null) {
+ notifyFailure(context, "Cannot open output stream.");
+ targetFile.delete();
+ return;
+ }
+
+ byte[] buffer = new byte[BUFFER_SIZE];
+ int len;
+ long total = 0;
+ while ((len = in.read(buffer)) != -1) {
+ out.write(buffer, 0, len);
+ total += len;
+ }
+ out.flush();
+
+ if (total <= 0) {
+ targetFile.delete();
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ notifyFailure(context, "Empty download.");
+ return;
+ }
+
+ if (remoteLength > 0 && total != remoteLength) {
+ targetFile.delete();
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ notifyFailure(context, "Incomplete download.");
+ return;
+ }
+
+ ExternalDownloadMetadataStore.recordSize(metadataKey, total);
+ recordDownload(child, targetUri);
+ notifySuccess(context, fileName, child, targetUri);
+ ExternalAudioReader.refreshCacheAsync();
+ }
+ } catch (Exception e) {
+ if (targetFile != null) {
+ targetFile.delete();
+ }
+ ExternalDownloadMetadataStore.remove(metadataKey);
+ notifyFailure(context, e.getMessage() != null ? e.getMessage() : "Download failed");
+ } finally {
+ if (connection != null) {
+ connection.disconnect();
+ }
+ }
+ }
+
+ private static void notifyUnavailable(Context context) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ Intent settingsIntent = new Intent(Settings.ACTION_APPLICATION_DETAILS_SETTINGS,
+ Uri.fromParts("package", context.getPackageName(), null));
+ PendingIntent openSettings = PendingIntent.getActivity(context, 0, settingsIntent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE);
+
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("No download folder set")
+ .setContentText("Tap to set one in settings")
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setPriority(NotificationCompat.PRIORITY_LOW)
+ .setSilent(true)
+ .setContentIntent(openSettings)
+ .setAutoCancel(true);
+
+ manager.notify(1011, builder.build());
+ }
+
+ private static void notifyFailure(Context context, String message) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Download failed")
+ .setContentText(message)
+ .setSmallIcon(android.R.drawable.stat_notify_error)
+ .setAutoCancel(true);
+ manager.notify((int) System.currentTimeMillis(), builder.build());
+ }
+
+ private static void notifySuccess(Context context, String name, Child child, Uri fileUri) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Download complete")
+ .setContentText(name)
+ .setSmallIcon(android.R.drawable.stat_sys_download_done)
+ .setAutoCancel(true);
+
+ PendingIntent playIntent = buildPlayIntent(context, child, fileUri);
+ if (playIntent != null) {
+ builder.setContentIntent(playIntent);
+ }
+
+ manager.notify((int) System.currentTimeMillis(), builder.build());
+ }
+
+ private static void recordDownload(Child child, Uri fileUri) {
+ if (child == null) {
+ return;
+ }
+
+ Download download = new Download(child);
+ download.setDownloadState(1);
+ if (fileUri != null) {
+ download.setDownloadUri(fileUri.toString());
+ }
+
+ new DownloadRepository().insert(download);
+ }
+
+ private static void notifyExists(Context context, String name) {
+ NotificationManager manager = (NotificationManager) context.getSystemService(Context.NOTIFICATION_SERVICE);
+ NotificationCompat.Builder builder = new NotificationCompat.Builder(context, DownloadUtil.DOWNLOAD_NOTIFICATION_CHANNEL_ID)
+ .setContentTitle("Already downloaded")
+ .setContentText(name)
+ .setSmallIcon(android.R.drawable.stat_sys_warning)
+ .setAutoCancel(true);
+ manager.notify((int) System.currentTimeMillis(), builder.build());
+ }
+
+ private static PendingIntent buildPlayIntent(Context context, Child child, Uri fileUri) {
+ if (fileUri == null) return null;
+ Intent intent = new Intent(context, MainActivity.class)
+ .setAction(Constants.ACTION_PLAY_EXTERNAL_DOWNLOAD)
+ .putExtra(Constants.EXTRA_DOWNLOAD_URI, fileUri.toString())
+ .putExtra(Constants.EXTRA_DOWNLOAD_MEDIA_ID, child.getId())
+ .putExtra(Constants.EXTRA_DOWNLOAD_TITLE, child.getTitle())
+ .putExtra(Constants.EXTRA_DOWNLOAD_ARTIST, child.getArtist())
+ .putExtra(Constants.EXTRA_DOWNLOAD_ALBUM, child.getAlbum())
+ .putExtra(Constants.EXTRA_DOWNLOAD_DURATION, child.getDuration() != null ? child.getDuration() : 0)
+ .addFlags(Intent.FLAG_ACTIVITY_SINGLE_TOP | Intent.FLAG_ACTIVITY_CLEAR_TOP);
+
+ int requestCode;
+ if (child.getId() != null) {
+ requestCode = Math.abs(child.getId().hashCode());
+ } else {
+ requestCode = Math.abs(fileUri.toString().hashCode());
+ }
+
+ return PendingIntent.getActivity(
+ context,
+ requestCode,
+ intent,
+ PendingIntent.FLAG_UPDATE_CURRENT | PendingIntent.FLAG_IMMUTABLE
+ );
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java
new file mode 100644
index 00000000..4bd4089d
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/ExternalDownloadMetadataStore.java
@@ -0,0 +1,123 @@
+package com.cappielloantonio.tempo.util;
+
+import android.content.SharedPreferences;
+
+import androidx.annotation.Nullable;
+
+import com.cappielloantonio.tempo.App;
+
+import org.json.JSONException;
+import org.json.JSONObject;
+
+import java.util.Collections;
+import java.util.HashMap;
+import java.util.HashSet;
+import java.util.Iterator;
+import java.util.Map;
+import java.util.Set;
+
+public final class ExternalDownloadMetadataStore {
+
+ private static final String PREF_KEY = "external_download_metadata";
+
+ private ExternalDownloadMetadataStore() {
+ }
+
+ private static SharedPreferences preferences() {
+ return App.getInstance().getPreferences();
+ }
+
+ private static JSONObject readAll() {
+ String raw = preferences().getString(PREF_KEY, "{}");
+ try {
+ return new JSONObject(raw);
+ } catch (JSONException e) {
+ return new JSONObject();
+ }
+ }
+
+ private static void writeAll(JSONObject object) {
+ preferences().edit().putString(PREF_KEY, object.toString()).apply();
+ }
+
+ public static synchronized void clear() {
+ writeAll(new JSONObject());
+ }
+
+ public static synchronized void recordSize(String key, long size) {
+ if (key == null || size <= 0) {
+ return;
+ }
+ JSONObject object = readAll();
+ try {
+ object.put(key, size);
+ } catch (JSONException ignored) {
+ }
+ writeAll(object);
+ }
+
+ public static synchronized void remove(String key) {
+ if (key == null) {
+ return;
+ }
+ JSONObject object = readAll();
+ object.remove(key);
+ writeAll(object);
+ }
+
+ @Nullable
+ public static synchronized Long getSize(String key) {
+ if (key == null) {
+ return null;
+ }
+ JSONObject object = readAll();
+ if (!object.has(key)) {
+ return null;
+ }
+ long size = object.optLong(key, -1L);
+ return size > 0 ? size : null;
+ }
+
+ public static synchronized Map snapshot() {
+ JSONObject object = readAll();
+ if (object.length() == 0) {
+ return Collections.emptyMap();
+ }
+ Map sizes = new HashMap<>();
+ Iterator keys = object.keys();
+ while (keys.hasNext()) {
+ String key = keys.next();
+ long size = object.optLong(key, -1L);
+ if (size > 0) {
+ sizes.put(key, size);
+ }
+ }
+ return sizes;
+ }
+
+ public static synchronized void retainOnly(Set keysToKeep) {
+ if (keysToKeep == null || keysToKeep.isEmpty()) {
+ clear();
+ return;
+ }
+ JSONObject object = readAll();
+ if (object.length() == 0) {
+ return;
+ }
+ Set keys = new HashSet<>();
+ Iterator iterator = object.keys();
+ while (iterator.hasNext()) {
+ keys.add(iterator.next());
+ }
+ boolean changed = false;
+ for (String key : keys) {
+ if (!keysToKeep.contains(key)) {
+ object.remove(key);
+ changed = true;
+ }
+ }
+ if (changed) {
+ writeAll(object);
+ }
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java
index f8f15f07..afb89e34 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/MappingUtil.java
@@ -4,10 +4,12 @@ import android.net.Uri;
import android.os.Bundle;
import androidx.annotation.OptIn;
+import androidx.lifecycle.LifecycleOwner;
import androidx.media3.common.MediaItem;
import androidx.media3.common.MediaMetadata;
import androidx.media3.common.MimeTypes;
import androidx.media3.common.util.UnstableApi;
+import androidx.media3.common.HeartRating;
import com.cappielloantonio.tempo.App;
import com.cappielloantonio.tempo.glide.CustomGlideRequest;
@@ -16,6 +18,7 @@ import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.InternetRadioStation;
import com.cappielloantonio.tempo.subsonic.models.PodcastEpisode;
+import com.google.common.collect.ImmutableList;
import java.util.ArrayList;
import java.util.List;
@@ -83,6 +86,13 @@ public class MappingUtil {
.setAlbumTitle(media.getAlbum())
.setArtist(media.getArtist())
.setArtworkUri(artworkUri)
+ .setUserRating(new HeartRating(media.getStarred() != null))
+ .setSupportedCommands(
+ ImmutableList.of(
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON,
+ Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
+ )
+ )
.setExtras(bundle)
.setIsBrowsable(false)
.setIsPlayable(true)
@@ -217,12 +227,20 @@ public class MappingUtil {
}
private static Uri getUri(Child media) {
+ if (Preferences.getDownloadDirectoryUri() != null) {
+ Uri local = ExternalAudioReader.getUri(media);
+ return local != null ? local : MusicUtil.getStreamUri(media.getId());
+ }
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(media.getId())
? getDownloadUri(media.getId())
: MusicUtil.getStreamUri(media.getId());
}
private static Uri getUri(PodcastEpisode podcastEpisode) {
+ if (Preferences.getDownloadDirectoryUri() != null) {
+ Uri local = ExternalAudioReader.getUri(podcastEpisode);
+ return local != null ? local : MusicUtil.getStreamUri(podcastEpisode.getStreamId());
+ }
return DownloadUtil.getDownloadTracker(App.getContext()).isDownloaded(podcastEpisode.getStreamId())
? getDownloadUri(podcastEpisode.getStreamId())
: MusicUtil.getStreamUri(podcastEpisode.getStreamId());
@@ -232,4 +250,11 @@ public class MappingUtil {
Download download = new DownloadRepository().getDownload(id);
return download != null && !download.getDownloadUri().isEmpty() ? Uri.parse(download.getDownloadUri()) : MusicUtil.getDownloadUri(id);
}
+
+ public static void observeExternalAudioRefresh(LifecycleOwner owner, Runnable onRefresh) {
+ if (owner == null || onRefresh == null) {
+ return;
+ }
+ ExternalAudioReader.getRefreshEvents().observe(owner, event -> onRefresh.run());
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt
index 92cb30cd..62a5e276 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt
+++ b/app/src/main/java/com/cappielloantonio/tempo/util/Preferences.kt
@@ -37,6 +37,7 @@ object Preferences {
private const val WIFI_ONLY = "wifi_only"
private const val DATA_SAVING_MODE = "data_saving_mode"
private const val SERVER_UNREACHABLE = "server_unreachable"
+ private const val SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE = "sync_starred_artists_for_offline_use"
private const val SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE = "sync_starred_albums_for_offline_use"
private const val SYNC_STARRED_TRACKS_FOR_OFFLINE_USE = "sync_starred_tracks_for_offline_use"
private const val QUEUE_SYNCING = "queue_syncing"
@@ -45,11 +46,13 @@ object Preferences {
private const val ROUNDED_CORNER_SIZE = "rounded_corner_size"
private const val PODCAST_SECTION_VISIBILITY = "podcast_section_visibility"
private const val RADIO_SECTION_VISIBILITY = "radio_section_visibility"
+ private const val AUTO_DOWNLOAD_LYRICS = "auto_download_lyrics"
private const val MUSIC_DIRECTORY_SECTION_VISIBILITY = "music_directory_section_visibility"
private const val REPLAY_GAIN_MODE = "replay_gain_mode"
private const val AUDIO_TRANSCODE_PRIORITY = "audio_transcode_priority"
private const val STREAMING_CACHE_STORAGE = "streaming_cache_storage"
private const val DOWNLOAD_STORAGE = "download_storage"
+ private const val DOWNLOAD_DIRECTORY_URI = "download_directory_uri"
private const val DEFAULT_DOWNLOAD_VIEW_TYPE = "default_download_view_type"
private const val AUDIO_TRANSCODE_DOWNLOAD = "audio_transcode_download"
private const val AUDIO_TRANSCODE_DOWNLOAD_PRIORITY = "audio_transcode_download_priority"
@@ -69,8 +72,10 @@ object Preferences {
private const val NEXT_UPDATE_CHECK = "next_update_check"
private const val CONTINUOUS_PLAY = "continuous_play"
private const val LAST_INSTANT_MIX = "last_instant_mix"
+ private const val ALLOW_PLAYLIST_DUPLICATES = "allow_playlist_duplicates"
private const val EQUALIZER_ENABLED = "equalizer_enabled"
private const val EQUALIZER_BAND_LEVELS = "equalizer_band_levels"
+ private const val MINI_SHUFFLE_BUTTON_VISIBILITY = "mini_shuffle_button_visibility"
@JvmStatic
fun getServer(): String? {
@@ -162,6 +167,24 @@ object Preferences {
App.getInstance().preferences.edit().putString(OPEN_SUBSONIC_EXTENSIONS, Gson().toJson(extension)).apply()
}
+ @JvmStatic
+ fun isAutoDownloadLyricsEnabled(): Boolean {
+ val preferences = App.getInstance().preferences
+
+ if (preferences.contains(AUTO_DOWNLOAD_LYRICS)) {
+ return preferences.getBoolean(AUTO_DOWNLOAD_LYRICS, false)
+ }
+
+ return false
+ }
+
+ @JvmStatic
+ fun setAutoDownloadLyricsEnabled(isEnabled: Boolean) {
+ App.getInstance().preferences.edit()
+ .putBoolean(AUTO_DOWNLOAD_LYRICS, isEnabled)
+ .apply()
+ }
+
@JvmStatic
fun getLocalAddress(): String? {
return App.getInstance().preferences.getString(LOCAL_ADDRESS, null)
@@ -303,6 +326,18 @@ object Preferences {
.apply()
}
+ @JvmStatic
+ fun isStarredArtistsSyncEnabled(): Boolean {
+ return App.getInstance().preferences.getBoolean(SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, false)
+ }
+
+ @JvmStatic
+ fun setStarredArtistsSyncEnabled(isStarredSyncEnabled: Boolean) {
+ App.getInstance().preferences.edit().putBoolean(
+ SYNC_STARRED_ARTISTS_FOR_OFFLINE_USE, isStarredSyncEnabled
+ ).apply()
+ }
+
@JvmStatic
fun isStarredAlbumsSyncEnabled(): Boolean {
return App.getInstance().preferences.getBoolean(SYNC_STARRED_ALBUMS_FOR_OFFLINE_USE, false)
@@ -327,6 +362,16 @@ object Preferences {
).apply()
}
+ @JvmStatic
+ fun showShuffleInsteadOfHeart(): Boolean {
+ return App.getInstance().preferences.getBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, false)
+ }
+
+ @JvmStatic
+ fun setShuffleInsteadOfHeart(enabled: Boolean) {
+ App.getInstance().preferences.edit().putBoolean(MINI_SHUFFLE_BUTTON_VISIBILITY, enabled).apply()
+ }
+
@JvmStatic
fun showServerUnreachableDialog(): Boolean {
return App.getInstance().preferences.getLong(
@@ -420,6 +465,20 @@ object Preferences {
).apply()
}
+ @JvmStatic
+ fun getDownloadDirectoryUri(): String? {
+ return App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
+ }
+
+ @JvmStatic
+ fun setDownloadDirectoryUri(uri: String?) {
+ val current = App.getInstance().preferences.getString(DOWNLOAD_DIRECTORY_URI, null)
+ if (current != uri) {
+ ExternalDownloadMetadataStore.clear()
+ }
+ App.getInstance().preferences.edit().putString(DOWNLOAD_DIRECTORY_URI, uri).apply()
+ }
+
@JvmStatic
fun getDefaultDownloadViewType(): String {
return App.getInstance().preferences.getString(
@@ -540,6 +599,19 @@ object Preferences {
) + 5000 < System.currentTimeMillis()
}
+ @JvmStatic
+ fun setAllowPlaylistDuplicates(allowDuplicates: Boolean) {
+ return App.getInstance().preferences.edit().putString(
+ ALLOW_PLAYLIST_DUPLICATES,
+ allowDuplicates.toString()
+ ).apply()
+ }
+
+ @JvmStatic
+ fun allowPlaylistDuplicates(): Boolean {
+ return App.getInstance().preferences.getBoolean(ALLOW_PLAYLIST_DUPLICATES, false)
+ }
+
@JvmStatic
fun setEqualizerEnabled(enabled: Boolean) {
App.getInstance().preferences.edit().putBoolean(EQUALIZER_ENABLED, enabled).apply()
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java
index 08ae3681..2c008d80 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/ArtistBottomSheetViewModel.java
@@ -1,17 +1,25 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
-
+import android.content.Context;
+import android.util.Log;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
+import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
+import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.util.NetworkUtil;
+import com.cappielloantonio.tempo.util.DownloadUtil;
+import com.cappielloantonio.tempo.util.MappingUtil;
+import com.cappielloantonio.tempo.util.Preferences;
import java.util.Date;
+import java.util.stream.Collectors;
+import java.util.List;
public class ArtistBottomSheetViewModel extends AndroidViewModel {
private final ArtistRepository artistRepository;
@@ -34,7 +42,7 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
this.artist = artist;
}
- public void setFavorite() {
+ public void setFavorite(Context context) {
if (artist.getStarred() != null) {
if (NetworkUtil.isOffline()) {
removeFavoriteOffline();
@@ -43,9 +51,9 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
}
} else {
if (NetworkUtil.isOffline()) {
- setFavoriteOffline();
+ setFavoriteOffline(context);
} else {
- setFavoriteOnline();
+ setFavoriteOnline(context);
}
}
}
@@ -59,7 +67,6 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
favoriteRepository.unstar(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
- // artist.setStarred(new Date());
favoriteRepository.starLater(null, null, artist.getId(), false);
}
});
@@ -67,20 +74,45 @@ public class ArtistBottomSheetViewModel extends AndroidViewModel {
artist.setStarred(null);
}
- private void setFavoriteOffline() {
+ private void setFavoriteOffline(Context context) {
favoriteRepository.starLater(null, null, artist.getId(), true);
artist.setStarred(new Date());
}
- private void setFavoriteOnline() {
+ private void setFavoriteOnline(Context context) {
favoriteRepository.star(null, null, artist.getId(), new StarCallback() {
@Override
public void onError() {
- // artist.setStarred(null);
favoriteRepository.starLater(null, null, artist.getId(), true);
}
});
artist.setStarred(new Date());
+
+ Log.d("ArtistSync", "Checking preference: " + Preferences.isStarredArtistsSyncEnabled());
+
+ if (Preferences.isStarredArtistsSyncEnabled()) {
+ Log.d("ArtistSync", "Starting artist sync for: " + artist.getName());
+
+ artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
+ @Override
+ public void onSongsCollected(List songs) {
+ Log.d("ArtistSync", "Callback triggered with songs: " + (songs != null ? songs.size() : 0));
+ if (songs != null && !songs.isEmpty()) {
+ Log.d("ArtistSync", "Starting download of " + songs.size() + " songs");
+ DownloadUtil.getDownloadTracker(context).download(
+ MappingUtil.mapDownloads(songs),
+ songs.stream().map(Download::new).collect(Collectors.toList())
+ );
+ Log.d("ArtistSync", "Download started successfully");
+ } else {
+ Log.d("ArtistSync", "No songs to download");
+ }
+ }
+ });
+ } else {
+ Log.d("ArtistSync", "Artist sync preference is disabled");
+ }
}
+ ///
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java
index 3059eb09..6f69cee1 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/DownloadViewModel.java
@@ -1,6 +1,7 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
+import android.net.Uri;
import android.util.Log;
import androidx.annotation.NonNull;
@@ -8,10 +9,13 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
+import androidx.documentfile.provider.DocumentFile;
+import com.cappielloantonio.tempo.model.Download;
import com.cappielloantonio.tempo.model.DownloadStack;
import com.cappielloantonio.tempo.repository.DownloadRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
+import com.cappielloantonio.tempo.util.ExternalAudioReader;
import com.cappielloantonio.tempo.util.Preferences;
import java.util.ArrayList;
@@ -25,6 +29,7 @@ public class DownloadViewModel extends AndroidViewModel {
private final MutableLiveData> downloadedTrackSample = new MutableLiveData<>(null);
private final MutableLiveData> viewStack = new MutableLiveData<>(null);
+ private final MutableLiveData refreshResult = new MutableLiveData<>();
public DownloadViewModel(@NonNull Application application) {
super(application);
@@ -43,6 +48,10 @@ public class DownloadViewModel extends AndroidViewModel {
return viewStack;
}
+ public LiveData getRefreshResult() {
+ return refreshResult;
+ }
+
public void initViewStack(DownloadStack level) {
ArrayList stack = new ArrayList<>();
stack.add(level);
@@ -60,4 +69,59 @@ public class DownloadViewModel extends AndroidViewModel {
stack.remove(stack.size() - 1);
viewStack.setValue(stack);
}
+
+ public void refreshExternalDownloads() {
+ new Thread(() -> {
+ String directoryUri = Preferences.getDownloadDirectoryUri();
+ if (directoryUri == null) {
+ refreshResult.postValue(-1);
+ return;
+ }
+
+ List downloads = downloadRepository.getAllDownloads();
+ if (downloads == null || downloads.isEmpty()) {
+ refreshResult.postValue(0);
+ return;
+ }
+
+ ArrayList toRemove = new ArrayList<>();
+
+ for (Download download : downloads) {
+ String uriString = download.getDownloadUri();
+ if (uriString == null || uriString.isEmpty()) {
+ continue;
+ }
+
+ Uri uri = Uri.parse(uriString);
+ if (uri.getScheme() == null || !uri.getScheme().equalsIgnoreCase("content")) {
+ continue;
+ }
+
+ DocumentFile file;
+ try {
+ file = DocumentFile.fromSingleUri(getApplication(), uri);
+ } catch (SecurityException exception) {
+ file = null;
+ }
+
+ if (file == null || !file.exists()) {
+ toRemove.add(download);
+ }
+ }
+
+ if (!toRemove.isEmpty()) {
+ ArrayList ids = new ArrayList<>();
+ for (Download download : toRemove) {
+ ids.add(download.getId());
+ ExternalAudioReader.removeMetadata(download);
+ }
+
+ downloadRepository.delete(ids);
+ ExternalAudioReader.refreshCache();
+ refreshResult.postValue(ids.size());
+ } else {
+ refreshResult.postValue(0);
+ }
+ }).start();
+ }
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java
index 6477178c..2089ce20 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java
@@ -48,6 +48,7 @@ public class HomeViewModel extends AndroidViewModel {
private final SharingRepository sharingRepository;
private final StarredAlbumsSyncViewModel albumsSyncViewModel;
+ private final StarredArtistsSyncViewModel artistSyncViewModel;
private final MutableLiveData> dicoverSongSample = new MutableLiveData<>(null);
private final MutableLiveData> newReleasedAlbum = new MutableLiveData<>(null);
@@ -85,6 +86,7 @@ public class HomeViewModel extends AndroidViewModel {
sharingRepository = new SharingRepository();
albumsSyncViewModel = new StarredAlbumsSyncViewModel(application);
+ artistSyncViewModel = new StarredArtistsSyncViewModel(application);
setOfflineFavorite();
}
@@ -174,6 +176,10 @@ public class HomeViewModel extends AndroidViewModel {
return albumsSyncViewModel.getAllStarredAlbumSongs();
}
+ public LiveData> getAllStarredArtistSongs() {
+ return artistSyncViewModel.getAllStarredArtistSongs();
+ }
+
public LiveData> getStarredArtists(LifecycleOwner owner) {
if (starredArtists.getValue() == null) {
artistRepository.getStarredArtists(true, 20).observe(owner, starredArtists::postValue);
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java
index bf90fa65..2a100fbf 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlayerBottomSheetViewModel.java
@@ -2,6 +2,7 @@ package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
import android.content.Context;
+import android.text.TextUtils;
import androidx.annotation.NonNull;
import androidx.annotation.OptIn;
@@ -9,14 +10,17 @@ import androidx.lifecycle.AndroidViewModel;
import androidx.lifecycle.LifecycleOwner;
import androidx.lifecycle.LiveData;
import androidx.lifecycle.MutableLiveData;
+import androidx.lifecycle.Observer;
import androidx.media3.common.util.UnstableApi;
import com.cappielloantonio.tempo.interfaces.StarCallback;
import com.cappielloantonio.tempo.model.Download;
+import com.cappielloantonio.tempo.model.LyricsCache;
import com.cappielloantonio.tempo.model.Queue;
import com.cappielloantonio.tempo.repository.AlbumRepository;
import com.cappielloantonio.tempo.repository.ArtistRepository;
import com.cappielloantonio.tempo.repository.FavoriteRepository;
+import com.cappielloantonio.tempo.repository.LyricsRepository;
import com.cappielloantonio.tempo.repository.OpenRepository;
import com.cappielloantonio.tempo.repository.QueueRepository;
import com.cappielloantonio.tempo.repository.SongRepository;
@@ -31,6 +35,7 @@ import com.cappielloantonio.tempo.util.MappingUtil;
import com.cappielloantonio.tempo.util.NetworkUtil;
import com.cappielloantonio.tempo.util.OpenSubsonicExtensionsUtil;
import com.cappielloantonio.tempo.util.Preferences;
+import com.google.gson.Gson;
import java.util.Collections;
import java.util.Date;
@@ -47,14 +52,20 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
private final QueueRepository queueRepository;
private final FavoriteRepository favoriteRepository;
private final OpenRepository openRepository;
+ private final LyricsRepository lyricsRepository;
private final MutableLiveData lyricsLiveData = new MutableLiveData<>(null);
private final MutableLiveData lyricsListLiveData = new MutableLiveData<>(null);
+ private final MutableLiveData lyricsCachedLiveData = new MutableLiveData<>(false);
private final MutableLiveData descriptionLiveData = new MutableLiveData<>(null);
private final MutableLiveData liveMedia = new MutableLiveData<>(null);
private final MutableLiveData liveAlbum = new MutableLiveData<>(null);
private final MutableLiveData liveArtist = new MutableLiveData<>(null);
private final MutableLiveData> instantMix = new MutableLiveData<>(null);
+ private final Gson gson = new Gson();
private boolean lyricsSyncState = true;
+ private LiveData cachedLyricsSource;
+ private String currentSongId;
+ private final Observer cachedLyricsObserver = this::onCachedLyricsChanged;
public PlayerBottomSheetViewModel(@NonNull Application application) {
@@ -66,6 +77,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
queueRepository = new QueueRepository();
favoriteRepository = new FavoriteRepository();
openRepository = new OpenRepository();
+ lyricsRepository = new LyricsRepository();
}
public LiveData> getQueueSong() {
@@ -122,7 +134,7 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
media.setStarred(new Date());
- if (Preferences.isStarredSyncEnabled()) {
+ if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownload(media),
new Download(media)
@@ -139,12 +151,49 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
public void refreshMediaInfo(LifecycleOwner owner, Child media) {
+ lyricsLiveData.postValue(null);
+ lyricsListLiveData.postValue(null);
+ lyricsCachedLiveData.postValue(false);
+
+ clearCachedLyricsObserver();
+
+ String songId = media != null ? media.getId() : currentSongId;
+
+ if (TextUtils.isEmpty(songId) || owner == null) {
+ return;
+ }
+
+ currentSongId = songId;
+
+ observeCachedLyrics(owner, songId);
+
+ LyricsCache cachedLyrics = lyricsRepository.getLyrics(songId);
+ if (cachedLyrics != null) {
+ onCachedLyricsChanged(cachedLyrics);
+ }
+
+ if (NetworkUtil.isOffline() || media == null) {
+ return;
+ }
+
if (OpenSubsonicExtensionsUtil.isSongLyricsExtensionAvailable()) {
- openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsListLiveData::postValue);
- lyricsLiveData.postValue(null);
+ openRepository.getLyricsBySongId(media.getId()).observe(owner, lyricsList -> {
+ lyricsListLiveData.postValue(lyricsList);
+ lyricsLiveData.postValue(null);
+
+ if (shouldAutoDownloadLyrics() && hasStructuredLyrics(lyricsList)) {
+ saveLyricsToCache(media, null, lyricsList);
+ }
+ });
} else {
- songRepository.getSongLyrics(media).observe(owner, lyricsLiveData::postValue);
- lyricsListLiveData.postValue(null);
+ songRepository.getSongLyrics(media).observe(owner, lyrics -> {
+ lyricsLiveData.postValue(lyrics);
+ lyricsListLiveData.postValue(null);
+
+ if (shouldAutoDownloadLyrics() && !TextUtils.isEmpty(lyrics)) {
+ saveLyricsToCache(media, lyrics, null);
+ }
+ });
}
}
@@ -153,6 +202,17 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
}
public void setLiveMedia(LifecycleOwner owner, String mediaType, String mediaId) {
+ currentSongId = mediaId;
+
+ if (!TextUtils.isEmpty(mediaId)) {
+ refreshMediaInfo(owner, null);
+ } else {
+ clearCachedLyricsObserver();
+ lyricsLiveData.postValue(null);
+ lyricsListLiveData.postValue(null);
+ lyricsCachedLiveData.postValue(false);
+ }
+
if (mediaType != null) {
switch (mediaType) {
case Constants.MEDIA_TYPE_MUSIC:
@@ -162,7 +222,12 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
case Constants.MEDIA_TYPE_PODCAST:
liveMedia.postValue(null);
break;
+ default:
+ liveMedia.postValue(null);
+ break;
}
+ } else {
+ liveMedia.postValue(null);
}
}
@@ -233,6 +298,105 @@ public class PlayerBottomSheetViewModel extends AndroidViewModel {
return false;
}
+ private void observeCachedLyrics(LifecycleOwner owner, String songId) {
+ if (TextUtils.isEmpty(songId)) {
+ return;
+ }
+
+ cachedLyricsSource = lyricsRepository.observeLyrics(songId);
+ cachedLyricsSource.observe(owner, cachedLyricsObserver);
+ }
+
+ private void clearCachedLyricsObserver() {
+ if (cachedLyricsSource != null) {
+ cachedLyricsSource.removeObserver(cachedLyricsObserver);
+ cachedLyricsSource = null;
+ }
+ }
+
+ private void onCachedLyricsChanged(LyricsCache lyricsCache) {
+ if (lyricsCache == null) {
+ lyricsCachedLiveData.postValue(false);
+ return;
+ }
+
+ lyricsCachedLiveData.postValue(true);
+
+ if (!TextUtils.isEmpty(lyricsCache.getStructuredLyrics())) {
+ try {
+ LyricsList cachedList = gson.fromJson(lyricsCache.getStructuredLyrics(), LyricsList.class);
+ lyricsListLiveData.postValue(cachedList);
+ lyricsLiveData.postValue(null);
+ } catch (Exception exception) {
+ lyricsListLiveData.postValue(null);
+ lyricsLiveData.postValue(lyricsCache.getLyrics());
+ }
+ } else {
+ lyricsListLiveData.postValue(null);
+ lyricsLiveData.postValue(lyricsCache.getLyrics());
+ }
+ }
+
+ private void saveLyricsToCache(Child media, String lyrics, LyricsList lyricsList) {
+ if (media == null) {
+ return;
+ }
+
+ if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
+ return;
+ }
+
+ LyricsCache lyricsCache = new LyricsCache(media.getId());
+ lyricsCache.setArtist(media.getArtist());
+ lyricsCache.setTitle(media.getTitle());
+ lyricsCache.setUpdatedAt(System.currentTimeMillis());
+
+ if (lyricsList != null && hasStructuredLyrics(lyricsList)) {
+ lyricsCache.setStructuredLyrics(gson.toJson(lyricsList));
+ lyricsCache.setLyrics(null);
+ } else {
+ lyricsCache.setLyrics(lyrics);
+ lyricsCache.setStructuredLyrics(null);
+ }
+
+ lyricsRepository.insert(lyricsCache);
+ lyricsCachedLiveData.postValue(true);
+ }
+
+ private boolean hasStructuredLyrics(LyricsList lyricsList) {
+ return lyricsList != null
+ && lyricsList.getStructuredLyrics() != null
+ && !lyricsList.getStructuredLyrics().isEmpty()
+ && lyricsList.getStructuredLyrics().get(0) != null
+ && lyricsList.getStructuredLyrics().get(0).getLine() != null
+ && !lyricsList.getStructuredLyrics().get(0).getLine().isEmpty();
+ }
+
+ private boolean shouldAutoDownloadLyrics() {
+ return Preferences.isAutoDownloadLyricsEnabled();
+ }
+
+ public boolean downloadCurrentLyrics() {
+ Child media = getLiveMedia().getValue();
+ if (media == null) {
+ return false;
+ }
+
+ LyricsList lyricsList = lyricsListLiveData.getValue();
+ String lyrics = lyricsLiveData.getValue();
+
+ if ((lyricsList == null || !hasStructuredLyrics(lyricsList)) && TextUtils.isEmpty(lyrics)) {
+ return false;
+ }
+
+ saveLyricsToCache(media, lyrics, lyricsList);
+ return true;
+ }
+
+ public LiveData getLyricsCachedState() {
+ return lyricsCachedLiveData;
+ }
+
public void changeSyncLyricsState() {
lyricsSyncState = !lyricsSyncState;
}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java
index 2ec6c21f..ca7af150 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistChooserViewModel.java
@@ -1,6 +1,8 @@
package com.cappielloantonio.tempo.viewmodel;
import android.app.Application;
+import android.app.Dialog;
+import android.content.SharedPreferences;
import androidx.annotation.NonNull;
import androidx.lifecycle.AndroidViewModel;
@@ -11,10 +13,10 @@ import androidx.lifecycle.MutableLiveData;
import com.cappielloantonio.tempo.repository.PlaylistRepository;
import com.cappielloantonio.tempo.subsonic.models.Child;
import com.cappielloantonio.tempo.subsonic.models.Playlist;
+import com.cappielloantonio.tempo.util.Preferences;
import com.google.common.collect.Lists;
import java.util.ArrayList;
-import java.util.Collections;
import java.util.List;
public class PlaylistChooserViewModel extends AndroidViewModel {
@@ -34,8 +36,21 @@ public class PlaylistChooserViewModel extends AndroidViewModel {
return playlists;
}
- public void addSongsToPlaylist(String playlistId) {
- playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(Lists.transform(toAdd, Child::getId)));
+ public void addSongsToPlaylist(LifecycleOwner owner, Dialog dialog, String playlistId) {
+ List songIds = Lists.transform(toAdd, Child::getId);
+ if (Preferences.allowPlaylistDuplicates()) {
+ playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
+ dialog.dismiss();
+ } else {
+ playlistRepository.getPlaylistSongs(playlistId).observe(owner, playlistSongs -> {
+ if (playlistSongs != null) {
+ List playlistSongIds = Lists.transform(playlistSongs, Child::getId);
+ songIds.removeAll(playlistSongIds);
+ }
+ playlistRepository.addSongToPlaylist(playlistId, new ArrayList<>(songIds));
+ dialog.dismiss();
+ });
+ }
}
public void setSongsToAdd(ArrayList songs) {
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java
index 79f1655b..de379dcd 100644
--- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/SongBottomSheetViewModel.java
@@ -109,7 +109,7 @@ public class SongBottomSheetViewModel extends AndroidViewModel {
media.setStarred(new Date());
- if (Preferences.isStarredSyncEnabled()) {
+ if (Preferences.isStarredSyncEnabled() && Preferences.getDownloadDirectoryUri() == null) {
DownloadUtil.getDownloadTracker(context).download(
MappingUtil.mapDownload(media),
new Download(media)
diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java
new file mode 100644
index 00000000..474cbe87
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/StarredArtistsSyncViewModel.java
@@ -0,0 +1,94 @@
+package com.cappielloantonio.tempo.viewmodel;
+
+import android.app.Application;
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+import androidx.lifecycle.AndroidViewModel;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.LiveData;
+import androidx.lifecycle.Observer;
+import androidx.lifecycle.MutableLiveData;
+
+import com.cappielloantonio.tempo.repository.ArtistRepository;
+import com.cappielloantonio.tempo.subsonic.models.ArtistID3;
+import com.cappielloantonio.tempo.subsonic.models.Child;
+
+import java.util.ArrayList;
+import java.util.List;
+import java.util.concurrent.CountDownLatch;
+import java.util.concurrent.atomic.AtomicInteger;
+
+public class StarredArtistsSyncViewModel extends AndroidViewModel {
+ private final ArtistRepository artistRepository;
+
+ private final MutableLiveData> starredArtists = new MutableLiveData<>(null);
+ private final MutableLiveData> starredArtistSongs = new MutableLiveData<>(null);
+
+ public StarredArtistsSyncViewModel(@NonNull Application application) {
+ super(application);
+ artistRepository = new ArtistRepository();
+ }
+
+ public LiveData> getStarredArtists(LifecycleOwner owner) {
+ artistRepository.getStarredArtists(false, -1).observe(owner, starredArtists::postValue);
+ return starredArtists;
+ }
+
+ public LiveData> getAllStarredArtistSongs() {
+ artistRepository.getStarredArtists(false, -1).observeForever(new Observer>() {
+ @Override
+ public void onChanged(List artists) {
+ if (artists != null && !artists.isEmpty()) {
+ collectAllArtistSongs(artists, starredArtistSongs::postValue);
+ } else {
+ starredArtistSongs.postValue(new ArrayList<>());
+ }
+ artistRepository.getStarredArtists(false, -1).removeObserver(this);
+ }
+ });
+
+ return starredArtistSongs;
+ }
+
+ public LiveData> getStarredArtistSongs(Activity activity) {
+ artistRepository.getStarredArtists(false, -1).observe((LifecycleOwner) activity, artists -> {
+ if (artists != null && !artists.isEmpty()) {
+ collectAllArtistSongs(artists, starredArtistSongs::postValue);
+ } else {
+ starredArtistSongs.postValue(new ArrayList<>());
+ }
+ });
+ return starredArtistSongs;
+ }
+
+ private void collectAllArtistSongs(List artists, ArtistSongsCallback callback) {
+ if (artists == null || artists.isEmpty()) {
+ callback.onSongsCollected(new ArrayList<>());
+ return;
+ }
+
+ List allSongs = new ArrayList<>();
+ AtomicInteger remainingArtists = new AtomicInteger(artists.size());
+
+ for (ArtistID3 artist : artists) {
+ artistRepository.getArtistAllSongs(artist.getId(), new ArtistRepository.ArtistSongsCallback() {
+ @Override
+ public void onSongsCollected(List songs) {
+ if (songs != null) {
+ allSongs.addAll(songs);
+ }
+
+ int remaining = remainingArtists.decrementAndGet();
+ if (remaining == 0) {
+ callback.onSongsCollected(allSongs);
+ }
+ }
+ });
+ }
+ }
+
+ private interface ArtistSongsCallback {
+ void onSongsCollected(List songs);
+ }
+}
\ No newline at end of file
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java
new file mode 100644
index 00000000..c6bd8e60
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetActions.java
@@ -0,0 +1,62 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.content.ComponentName;
+import android.content.Context;
+import android.util.Log;
+
+import androidx.media3.common.Player;
+import androidx.media3.session.MediaController;
+import androidx.media3.session.SessionToken;
+
+import com.cappielloantonio.tempo.service.MediaService;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.ExecutionException;
+
+public final class WidgetActions {
+ public static void dispatchToMediaSession(Context ctx, String action) {
+ Log.d("TempoWidget", "dispatch action=" + action);
+ Context appCtx = ctx.getApplicationContext();
+ SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
+ ListenableFuture future = new MediaController.Builder(appCtx, token).buildAsync();
+ future.addListener(() -> {
+ try {
+ if (!future.isDone()) return;
+ MediaController c = future.get();
+ Log.d("TempoWidget", "controller connected, isPlaying=" + c.isPlaying());
+ switch (action) {
+ case WidgetProvider.ACT_PLAY_PAUSE:
+ if (c.isPlaying()) c.pause();
+ else c.play();
+ break;
+ case WidgetProvider.ACT_NEXT:
+ c.seekToNext();
+ break;
+ case WidgetProvider.ACT_PREV:
+ c.seekToPrevious();
+ break;
+ case WidgetProvider.ACT_TOGGLE_SHUFFLE:
+ c.setShuffleModeEnabled(!c.getShuffleModeEnabled());
+ break;
+ case WidgetProvider.ACT_CYCLE_REPEAT:
+ int repeatMode = c.getRepeatMode();
+ int nextMode;
+ if (repeatMode == Player.REPEAT_MODE_OFF) {
+ nextMode = Player.REPEAT_MODE_ALL;
+ } else if (repeatMode == Player.REPEAT_MODE_ALL) {
+ nextMode = Player.REPEAT_MODE_ONE;
+ } else {
+ nextMode = Player.REPEAT_MODE_OFF;
+ }
+ c.setRepeatMode(nextMode);
+ break;
+ }
+ WidgetUpdateManager.refreshFromController(ctx);
+ c.release();
+ } catch (ExecutionException | InterruptedException e) {
+ Log.e("TempoWidget", "dispatch failed", e);
+ }
+ }, MoreExecutors.directExecutor());
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java
new file mode 100644
index 00000000..06986145
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider.java
@@ -0,0 +1,105 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.app.PendingIntent;
+import android.appwidget.AppWidgetManager;
+import android.appwidget.AppWidgetProvider;
+import android.content.Context;
+import android.content.Intent;
+import android.widget.RemoteViews;
+
+import com.cappielloantonio.tempo.R;
+
+import android.app.TaskStackBuilder;
+import android.app.PendingIntent;
+
+import com.cappielloantonio.tempo.ui.activity.MainActivity;
+
+import android.util.Log;
+
+public class WidgetProvider extends AppWidgetProvider {
+ private static final String TAG = "TempoWidget";
+ public static final String ACT_PLAY_PAUSE = "tempo.widget.PLAY_PAUSE";
+ public static final String ACT_NEXT = "tempo.widget.NEXT";
+ public static final String ACT_PREV = "tempo.widget.PREV";
+ public static final String ACT_TOGGLE_SHUFFLE = "tempo.widget.SHUFFLE";
+ public static final String ACT_CYCLE_REPEAT = "tempo.widget.REPEAT";
+
+ @Override
+ public void onUpdate(Context ctx, AppWidgetManager mgr, int[] ids) {
+ for (int id : ids) {
+ RemoteViews rv = WidgetUpdateManager.chooseBuild(ctx, id);
+ attachIntents(ctx, rv, id);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ @Override
+ public void onReceive(Context ctx, Intent intent) {
+ super.onReceive(ctx, intent);
+ String a = intent.getAction();
+ Log.d(TAG, "onReceive action=" + a);
+ if (ACT_PLAY_PAUSE.equals(a) || ACT_NEXT.equals(a) || ACT_PREV.equals(a)
+ || ACT_TOGGLE_SHUFFLE.equals(a) || ACT_CYCLE_REPEAT.equals(a)) {
+ WidgetActions.dispatchToMediaSession(ctx, a);
+ } else if (AppWidgetManager.ACTION_APPWIDGET_UPDATE.equals(a)) {
+ WidgetUpdateManager.refreshFromController(ctx);
+ }
+ }
+
+ @Override
+ public void onAppWidgetOptionsChanged(Context context, AppWidgetManager appWidgetManager, int appWidgetId, android.os.Bundle newOptions) {
+ super.onAppWidgetOptionsChanged(context, appWidgetManager, appWidgetId, newOptions);
+ RemoteViews rv = WidgetUpdateManager.chooseBuild(context, appWidgetId);
+ attachIntents(context, rv, appWidgetId);
+ appWidgetManager.updateAppWidget(appWidgetId, rv);
+ WidgetUpdateManager.refreshFromController(context);
+ }
+
+ public static void attachIntents(Context ctx, RemoteViews rv) {
+ attachIntents(ctx, rv, 0);
+ }
+
+ public static void attachIntents(Context ctx, RemoteViews rv, int requestCodeBase) {
+ PendingIntent playPause = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 0,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PLAY_PAUSE),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent next = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 1,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_NEXT),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent prev = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 2,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_PREV),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent shuffle = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 3,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_TOGGLE_SHUFFLE),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+ PendingIntent repeat = PendingIntent.getBroadcast(
+ ctx,
+ requestCodeBase + 4,
+ new Intent(ctx, WidgetProvider4x1.class).setAction(ACT_CYCLE_REPEAT),
+ PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT
+ );
+
+ rv.setOnClickPendingIntent(R.id.btn_play_pause, playPause);
+ rv.setOnClickPendingIntent(R.id.btn_next, next);
+ rv.setOnClickPendingIntent(R.id.btn_prev, prev);
+ rv.setOnClickPendingIntent(R.id.btn_shuffle, shuffle);
+ rv.setOnClickPendingIntent(R.id.btn_repeat, repeat);
+
+ PendingIntent launch = TaskStackBuilder.create(ctx)
+ .addNextIntentWithParentStack(new Intent(ctx, MainActivity.class))
+ .getPendingIntent(requestCodeBase + 10, PendingIntent.FLAG_IMMUTABLE | PendingIntent.FLAG_UPDATE_CURRENT);
+ rv.setOnClickPendingIntent(R.id.root, launch);
+ }
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java
new file mode 100644
index 00000000..b4e5923a
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetProvider4x1.java
@@ -0,0 +1,9 @@
+package com.cappielloantonio.tempo.widget;
+
+/**
+ * AppWidget provider entry for the 4x1 widget card. Inherits all behavior
+ * from {@link WidgetProvider}.
+ */
+public class WidgetProvider4x1 extends WidgetProvider {
+}
+
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java
new file mode 100644
index 00000000..4132511e
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetUpdateManager.java
@@ -0,0 +1,276 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.appwidget.AppWidgetManager;
+import android.content.ComponentName;
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.text.TextUtils;
+import android.graphics.drawable.Drawable;
+
+import com.bumptech.glide.request.target.CustomTarget;
+import com.bumptech.glide.request.transition.Transition;
+import com.cappielloantonio.tempo.glide.CustomGlideRequest;
+import com.cappielloantonio.tempo.R;
+
+import androidx.media3.common.C;
+import androidx.media3.session.MediaController;
+import androidx.media3.session.SessionToken;
+
+import com.cappielloantonio.tempo.service.MediaService;
+import com.cappielloantonio.tempo.util.MusicUtil;
+import com.google.common.util.concurrent.ListenableFuture;
+import com.google.common.util.concurrent.MoreExecutors;
+
+import java.util.concurrent.ExecutionException;
+
+public final class WidgetUpdateManager {
+
+ public static void updateFromState(Context ctx,
+ String title,
+ String artist,
+ String album,
+ Bitmap art,
+ boolean playing,
+ boolean shuffleEnabled,
+ int repeatMode,
+ long positionMs,
+ long durationMs) {
+ if (TextUtils.isEmpty(title)) title = ctx.getString(R.string.widget_not_playing);
+ if (TextUtils.isEmpty(artist)) artist = ctx.getString(R.string.widget_placeholder_subtitle);
+ if (TextUtils.isEmpty(album)) album = "";
+
+ final TimingInfo timing = createTimingInfo(positionMs, durationMs);
+
+ AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(ctx, title, artist, album, art, playing,
+ timing.elapsedText, timing.totalText, timing.progress, shuffleEnabled, repeatMode, id);
+ WidgetProvider.attachIntents(ctx, rv, id);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ public static void pushNow(Context ctx) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(ctx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = chooseBuild(ctx, id);
+ WidgetProvider.attachIntents(ctx, rv, id);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ public static void updateFromState(Context ctx,
+ String title,
+ String artist,
+ String album,
+ String coverArtId,
+ boolean playing,
+ boolean shuffleEnabled,
+ int repeatMode,
+ long positionMs,
+ long durationMs) {
+ final Context appCtx = ctx.getApplicationContext();
+ final String t = TextUtils.isEmpty(title) ? appCtx.getString(R.string.widget_not_playing) : title;
+ final String a = TextUtils.isEmpty(artist) ? appCtx.getString(R.string.widget_placeholder_subtitle) : artist;
+ final String alb = !TextUtils.isEmpty(album) ? album : "";
+ final boolean p = playing;
+ final boolean sh = shuffleEnabled;
+ final int rep = repeatMode;
+ final TimingInfo timing = createTimingInfo(positionMs, durationMs);
+
+ if (!TextUtils.isEmpty(coverArtId)) {
+ CustomGlideRequest.loadAlbumArtBitmap(
+ appCtx,
+ coverArtId,
+ com.cappielloantonio.tempo.util.Preferences.getImageSize(),
+ new CustomTarget() {
+ @Override
+ public void onResourceReady(Bitmap resource, Transition super Bitmap> transition) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, resource, p,
+ timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
+ WidgetProvider.attachIntents(appCtx, rv, id);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+
+ @Override
+ public void onLoadCleared(Drawable placeholder) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
+ timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
+ WidgetProvider.attachIntents(appCtx, rv, id);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+ }
+ );
+ } else {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(appCtx);
+ int[] ids = mgr.getAppWidgetIds(new ComponentName(appCtx, WidgetProvider4x1.class));
+ for (int id : ids) {
+ android.widget.RemoteViews rv = choosePopulate(appCtx, t, a, alb, null, p,
+ timing.elapsedText, timing.totalText, timing.progress, sh, rep, id);
+ WidgetProvider.attachIntents(appCtx, rv, id);
+ mgr.updateAppWidget(id, rv);
+ }
+ }
+ }
+
+ public static void refreshFromController(Context ctx) {
+ final Context appCtx = ctx.getApplicationContext();
+ SessionToken token = new SessionToken(appCtx, new ComponentName(appCtx, MediaService.class));
+ ListenableFuture future = new MediaController.Builder(appCtx, token).buildAsync();
+ future.addListener(() -> {
+ try {
+ if (!future.isDone()) return;
+ MediaController c = future.get();
+ androidx.media3.common.MediaItem mi = c.getCurrentMediaItem();
+ String title = null, artist = null, album = null, coverId = null;
+ if (mi != null && mi.mediaMetadata != null) {
+ if (mi.mediaMetadata.title != null) title = mi.mediaMetadata.title.toString();
+ if (mi.mediaMetadata.artist != null)
+ artist = mi.mediaMetadata.artist.toString();
+ if (mi.mediaMetadata.albumTitle != null)
+ album = mi.mediaMetadata.albumTitle.toString();
+ if (mi.mediaMetadata.extras != null) {
+ if (title == null) title = mi.mediaMetadata.extras.getString("title");
+ if (artist == null) artist = mi.mediaMetadata.extras.getString("artist");
+ if (album == null) album = mi.mediaMetadata.extras.getString("album");
+ coverId = mi.mediaMetadata.extras.getString("coverArtId");
+ }
+ }
+ long position = c.getCurrentPosition();
+ long duration = c.getDuration();
+ if (position == C.TIME_UNSET) position = 0;
+ if (duration == C.TIME_UNSET) duration = 0;
+ updateFromState(appCtx,
+ title != null ? title : appCtx.getString(R.string.widget_not_playing),
+ artist != null ? artist : appCtx.getString(R.string.widget_placeholder_subtitle),
+ album,
+ coverId,
+ c.isPlaying(),
+ c.getShuffleModeEnabled(),
+ c.getRepeatMode(),
+ position,
+ duration);
+ c.release();
+ } catch (ExecutionException | InterruptedException ignored) {
+ }
+ }, MoreExecutors.directExecutor());
+ }
+
+ private static TimingInfo createTimingInfo(long positionMs, long durationMs) {
+ long safePosition = Math.max(0L, positionMs);
+ long safeDuration = durationMs > 0 ? durationMs : 0L;
+ if (safeDuration > 0 && safePosition > safeDuration) {
+ safePosition = safeDuration;
+ }
+
+ String elapsed = (safeDuration > 0 || safePosition > 0)
+ ? MusicUtil.getReadableDurationString(safePosition, true)
+ : null;
+ String total = safeDuration > 0
+ ? MusicUtil.getReadableDurationString(safeDuration, true)
+ : null;
+
+ int progress = 0;
+ if (safeDuration > 0) {
+ long scaled = safePosition * WidgetViewsFactory.PROGRESS_MAX;
+ long progressLong = scaled / safeDuration;
+ if (progressLong < 0) {
+ progress = 0;
+ } else if (progressLong > WidgetViewsFactory.PROGRESS_MAX) {
+ progress = WidgetViewsFactory.PROGRESS_MAX;
+ } else {
+ progress = (int) progressLong;
+ }
+ }
+
+ return new TimingInfo(elapsed, total, progress);
+ }
+
+ public static android.widget.RemoteViews chooseBuild(Context ctx, int appWidgetId) {
+ LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
+ switch (size) {
+ case MEDIUM:
+ return WidgetViewsFactory.buildMedium(ctx);
+ case LARGE:
+ return WidgetViewsFactory.buildLarge(ctx);
+ case EXPANDED:
+ return WidgetViewsFactory.buildExpanded(ctx);
+ case COMPACT:
+ default:
+ return WidgetViewsFactory.buildCompact(ctx);
+ }
+ }
+
+ private static android.widget.RemoteViews choosePopulate(Context ctx,
+ String title,
+ String artist,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode,
+ int appWidgetId) {
+ LayoutSize size = resolveLayoutSize(ctx, appWidgetId);
+ switch (size) {
+ case MEDIUM:
+ return WidgetViewsFactory.populateMedium(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ case LARGE:
+ return WidgetViewsFactory.populateLarge(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ case EXPANDED:
+ return WidgetViewsFactory.populateExpanded(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ case COMPACT:
+ default:
+ return WidgetViewsFactory.populateCompact(ctx, title, artist, album, art, playing,
+ elapsedText, totalText, progress, shuffleEnabled, repeatMode);
+ }
+ }
+
+ private static LayoutSize resolveLayoutSize(Context ctx, int appWidgetId) {
+ AppWidgetManager mgr = AppWidgetManager.getInstance(ctx);
+ android.os.Bundle opts = mgr.getAppWidgetOptions(appWidgetId);
+ int minH = opts != null ? opts.getInt(AppWidgetManager.OPTION_APPWIDGET_MIN_HEIGHT) : 0;
+ int expandedThreshold = ctx.getResources().getInteger(R.integer.widget_expanded_min_height_dp);
+ int largeThreshold = ctx.getResources().getInteger(R.integer.widget_large_min_height_dp);
+ int mediumThreshold = ctx.getResources().getInteger(R.integer.widget_medium_min_height_dp);
+ if (minH >= expandedThreshold) return LayoutSize.EXPANDED;
+ if (minH >= largeThreshold) return LayoutSize.LARGE;
+ if (minH >= mediumThreshold) return LayoutSize.MEDIUM;
+ return LayoutSize.COMPACT;
+ }
+
+ private enum LayoutSize {
+ COMPACT,
+ MEDIUM,
+ LARGE,
+ EXPANDED
+ }
+
+ private static final class TimingInfo {
+ final String elapsedText;
+ final String totalText;
+ final int progress;
+
+ TimingInfo(String elapsedText, String totalText, int progress) {
+ this.elapsedText = elapsedText;
+ this.totalText = totalText;
+ this.progress = progress;
+ }
+ }
+
+}
diff --git a/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java
new file mode 100644
index 00000000..c66fd1cb
--- /dev/null
+++ b/app/src/main/java/com/cappielloantonio/tempo/widget/WidgetViewsFactory.java
@@ -0,0 +1,252 @@
+package com.cappielloantonio.tempo.widget;
+
+import android.content.Context;
+import android.graphics.Bitmap;
+import android.graphics.BitmapShader;
+import android.graphics.Canvas;
+import android.graphics.Paint;
+import android.graphics.RectF;
+import android.graphics.Shader;
+import android.text.TextUtils;
+import android.util.TypedValue;
+import android.view.View;
+import android.widget.RemoteViews;
+
+import androidx.core.content.ContextCompat;
+import androidx.media3.common.Player;
+
+import com.cappielloantonio.tempo.R;
+
+public final class WidgetViewsFactory {
+
+ static final int PROGRESS_MAX = 1000;
+ private static final float ALBUM_ART_CORNER_RADIUS_DP = 6f;
+
+ private WidgetViewsFactory() {
+ }
+
+ public static RemoteViews buildCompact(Context ctx) {
+ return build(ctx, R.layout.widget_layout_compact, false, false);
+ }
+
+ public static RemoteViews buildMedium(Context ctx) {
+ return build(ctx, R.layout.widget_layout_medium, false, false);
+ }
+
+ public static RemoteViews buildLarge(Context ctx) {
+ return build(ctx, R.layout.widget_layout_large_short, true, true);
+ }
+
+ public static RemoteViews buildExpanded(Context ctx) {
+ return build(ctx, R.layout.widget_layout_large, true, true);
+ }
+
+ private static RemoteViews build(Context ctx,
+ int layoutRes,
+ boolean showAlbum,
+ boolean showSecondaryControls) {
+ RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
+ rv.setTextViewText(R.id.title, ctx.getString(R.string.widget_not_playing));
+ rv.setTextViewText(R.id.subtitle, ctx.getString(R.string.widget_placeholder_subtitle));
+ rv.setTextViewText(R.id.album, "");
+ rv.setViewVisibility(R.id.album, showAlbum ? View.INVISIBLE : View.GONE);
+ rv.setTextViewText(R.id.time_elapsed, ctx.getString(R.string.widget_time_elapsed_placeholder));
+ rv.setTextViewText(R.id.time_total, ctx.getString(R.string.widget_time_duration_placeholder));
+ rv.setProgressBar(R.id.progress, PROGRESS_MAX, 0, false);
+ rv.setImageViewResource(R.id.btn_play_pause, R.drawable.ic_play);
+ rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
+ applySecondaryControlsDefaults(ctx, rv, showSecondaryControls);
+ return rv;
+ }
+
+ private static void applySecondaryControlsDefaults(Context ctx,
+ RemoteViews rv,
+ boolean show) {
+ int visibility = show ? View.VISIBLE : View.GONE;
+ rv.setViewVisibility(R.id.controls_secondary, visibility);
+ rv.setViewVisibility(R.id.btn_shuffle, visibility);
+ rv.setViewVisibility(R.id.btn_repeat, visibility);
+ if (show) {
+ int defaultColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
+ rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
+ rv.setImageViewResource(R.id.btn_repeat, R.drawable.ic_repeat);
+ rv.setInt(R.id.btn_shuffle, "setColorFilter", defaultColor);
+ rv.setInt(R.id.btn_repeat, "setColorFilter", defaultColor);
+ }
+ }
+
+ public static RemoteViews populateCompact(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_compact, false, false, shuffleEnabled, repeatMode);
+ }
+
+ public static RemoteViews populateMedium(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_medium, true, true, shuffleEnabled, repeatMode);
+ }
+
+ public static RemoteViews populateLarge(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_large_short, true, true, shuffleEnabled, repeatMode);
+ }
+
+ public static RemoteViews populateExpanded(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ return populateWithLayout(ctx, title, subtitle, album, art, playing, elapsedText, totalText,
+ progress, R.layout.widget_layout_large, true, true, shuffleEnabled, repeatMode);
+ }
+
+ private static RemoteViews populateWithLayout(Context ctx,
+ String title,
+ String subtitle,
+ String album,
+ Bitmap art,
+ boolean playing,
+ String elapsedText,
+ String totalText,
+ int progress,
+ int layoutRes,
+ boolean showAlbum,
+ boolean showSecondaryControls,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ RemoteViews rv = new RemoteViews(ctx.getPackageName(), layoutRes);
+ rv.setTextViewText(R.id.title, title);
+ rv.setTextViewText(R.id.subtitle, subtitle);
+
+ if (showAlbum && !TextUtils.isEmpty(album)) {
+ rv.setTextViewText(R.id.album, album);
+ rv.setViewVisibility(R.id.album, View.VISIBLE);
+ } else {
+ rv.setTextViewText(R.id.album, "");
+ rv.setViewVisibility(R.id.album, View.GONE);
+ }
+
+ if (art != null) {
+ Bitmap rounded = maybeRoundBitmap(ctx, art);
+ rv.setImageViewBitmap(R.id.album_art, rounded != null ? rounded : art);
+ } else {
+ rv.setImageViewResource(R.id.album_art, R.drawable.ic_splash_logo);
+ }
+
+ rv.setImageViewResource(R.id.btn_play_pause,
+ playing ? R.drawable.ic_pause : R.drawable.ic_play);
+
+ String elapsed = !TextUtils.isEmpty(elapsedText)
+ ? elapsedText
+ : ctx.getString(R.string.widget_time_elapsed_placeholder);
+ String total = !TextUtils.isEmpty(totalText)
+ ? totalText
+ : ctx.getString(R.string.widget_time_duration_placeholder);
+
+ int safeProgress = progress;
+ if (safeProgress < 0) safeProgress = 0;
+ if (safeProgress > PROGRESS_MAX) safeProgress = PROGRESS_MAX;
+
+ rv.setTextViewText(R.id.time_elapsed, elapsed);
+ rv.setTextViewText(R.id.time_total, total);
+ rv.setProgressBar(R.id.progress, PROGRESS_MAX, safeProgress, false);
+
+ applySecondaryControls(ctx, rv, showSecondaryControls, shuffleEnabled, repeatMode);
+
+ return rv;
+ }
+
+ private static Bitmap maybeRoundBitmap(Context ctx, Bitmap source) {
+ if (source == null || source.isRecycled()) {
+ return null;
+ }
+
+ try {
+ int width = source.getWidth();
+ int height = source.getHeight();
+ if (width <= 0 || height <= 0) {
+ return null;
+ }
+
+ Bitmap output = Bitmap.createBitmap(width, height, Bitmap.Config.ARGB_8888);
+ Canvas canvas = new Canvas(output);
+
+ Paint paint = new Paint(Paint.ANTI_ALIAS_FLAG);
+ paint.setShader(new BitmapShader(source, Shader.TileMode.CLAMP, Shader.TileMode.CLAMP));
+
+ float radiusPx = TypedValue.applyDimension(
+ TypedValue.COMPLEX_UNIT_DIP,
+ ALBUM_ART_CORNER_RADIUS_DP,
+ ctx.getResources().getDisplayMetrics());
+ float maxRadius = Math.min(width, height) / 2f;
+ float safeRadius = Math.min(radiusPx, maxRadius);
+
+ canvas.drawRoundRect(new RectF(0f, 0f, width, height), safeRadius, safeRadius, paint);
+ return output;
+ } catch (RuntimeException | OutOfMemoryError e) {
+ android.util.Log.w("TempoWidget", "Failed to round album art", e);
+ return null;
+ }
+ }
+
+ private static void applySecondaryControls(Context ctx,
+ RemoteViews rv,
+ boolean show,
+ boolean shuffleEnabled,
+ int repeatMode) {
+ if (!show) {
+ rv.setViewVisibility(R.id.controls_secondary, View.GONE);
+ rv.setViewVisibility(R.id.btn_shuffle, View.GONE);
+ rv.setViewVisibility(R.id.btn_repeat, View.GONE);
+ return;
+ }
+
+ int inactiveColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint);
+ int activeColor = ContextCompat.getColor(ctx, R.color.widget_icon_tint_active);
+
+ rv.setViewVisibility(R.id.controls_secondary, View.VISIBLE);
+ rv.setViewVisibility(R.id.btn_shuffle, View.VISIBLE);
+ rv.setViewVisibility(R.id.btn_repeat, View.VISIBLE);
+ rv.setImageViewResource(R.id.btn_shuffle, R.drawable.ic_shuffle);
+ rv.setImageViewResource(R.id.btn_repeat,
+ repeatMode == Player.REPEAT_MODE_ONE ? R.drawable.ic_repeat_one : R.drawable.ic_repeat);
+ rv.setInt(R.id.btn_shuffle, "setColorFilter", shuffleEnabled ? activeColor : inactiveColor);
+ rv.setInt(R.id.btn_repeat, "setColorFilter",
+ repeatMode == Player.REPEAT_MODE_OFF ? inactiveColor : activeColor);
+ }
+}
diff --git a/app/src/main/res/drawable/ic_folder.xml b/app/src/main/res/drawable/ic_folder.xml
new file mode 100644
index 00000000..2c6f69f5
--- /dev/null
+++ b/app/src/main/res/drawable/ic_folder.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_refresh.xml b/app/src/main/res/drawable/ic_refresh.xml
new file mode 100644
index 00000000..f3dcb53a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_refresh.xml
@@ -0,0 +1,9 @@
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml
new file mode 100644
index 00000000..f422f79a
--- /dev/null
+++ b/app/src/main/res/drawable/ic_repeat_one.xml
@@ -0,0 +1,12 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/drawable/widget_bg.xml b/app/src/main/res/drawable/widget_bg.xml
new file mode 100644
index 00000000..c569bbeb
--- /dev/null
+++ b/app/src/main/res/drawable/widget_bg.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/app/src/main/res/layout/dialog_playlist_chooser.xml b/app/src/main/res/layout/dialog_playlist_chooser.xml
index 80b4bfbe..3be03136 100644
--- a/app/src/main/res/layout/dialog_playlist_chooser.xml
+++ b/app/src/main/res/layout/dialog_playlist_chooser.xml
@@ -19,7 +19,8 @@
\ No newline at end of file
diff --git a/app/src/main/res/layout/dialog_starred_artist_sync.xml b/app/src/main/res/layout/dialog_starred_artist_sync.xml
new file mode 100644
index 00000000..ca41742e
--- /dev/null
+++ b/app/src/main/res/layout/dialog_starred_artist_sync.xml
@@ -0,0 +1,14 @@
+
+
+
+
\ No newline at end of file
diff --git a/app/src/main/res/layout/fragment_download.xml b/app/src/main/res/layout/fragment_download.xml
index 0a454508..68d3f84b 100644
--- a/app/src/main/res/layout/fragment_download.xml
+++ b/app/src/main/res/layout/fragment_download.xml
@@ -80,7 +80,7 @@
android:layout_width="0dp"
android:layout_height="wrap_content"
android:text="@string/download_title_section"
- app:layout_constraintEnd_toStartOf="@+id/downloaded_go_back_image_view"
+ app:layout_constraintEnd_toStartOf="@+id/downloaded_refresh_image_view"
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toTopOf="parent" />
@@ -94,6 +94,19 @@
app:layout_constraintStart_toStartOf="parent"
app:layout_constraintTop_toBottomOf="@+id/downloaded_text_view_refreshable"/>
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
-
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_large.xml b/app/src/main/res/layout/widget_layout_large.xml
new file mode 100644
index 00000000..70c626bb
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_large.xml
@@ -0,0 +1,189 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_large_short.xml b/app/src/main/res/layout/widget_layout_large_short.xml
new file mode 100644
index 00000000..6a715f6e
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_large_short.xml
@@ -0,0 +1,198 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_layout_medium.xml b/app/src/main/res/layout/widget_layout_medium.xml
new file mode 100644
index 00000000..802da828
--- /dev/null
+++ b/app/src/main/res/layout/widget_layout_medium.xml
@@ -0,0 +1,216 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/layout/widget_preview_compact.xml b/app/src/main/res/layout/widget_preview_compact.xml
new file mode 100644
index 00000000..e863603a
--- /dev/null
+++ b/app/src/main/res/layout/widget_preview_compact.xml
@@ -0,0 +1,82 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/menu/download_popup_menu.xml b/app/src/main/res/menu/download_popup_menu.xml
index 16e0b537..6607d22a 100644
--- a/app/src/main/res/menu/download_popup_menu.xml
+++ b/app/src/main/res/menu/download_popup_menu.xml
@@ -16,4 +16,7 @@
+
\ No newline at end of file
diff --git a/app/src/main/res/values-fr/strings.xml b/app/src/main/res/values-fr/strings.xml
index c9f0e561..45e73574 100644
--- a/app/src/main/res/values-fr/strings.xml
+++ b/app/src/main/res/values-fr/strings.xml
@@ -440,4 +440,10 @@
- %d album à synchroniser
- %d albums à synchroniser
+ Égaliseur
+ Réinitialiser
+ Activer
+ Non supporté sur cet appareil
+ Égaliseur
+ Ouvrir l\'égaliseur intégré
diff --git a/app/src/main/res/values-night/colors_widget.xml b/app/src/main/res/values-night/colors_widget.xml
new file mode 100644
index 00000000..7bda2da7
--- /dev/null
+++ b/app/src/main/res/values-night/colors_widget.xml
@@ -0,0 +1,7 @@
+
+
+ #CC000000
+ #FFFFFFFF
+ #B3FFFFFF
+ #FFFFFFFF
+
diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml
index c515da5d..1dc81873 100644
--- a/app/src/main/res/values-pl/strings.xml
+++ b/app/src/main/res/values-pl/strings.xml
@@ -3,6 +3,7 @@
Wyłącz optymalizacje baterii aby odtwarzać media przy wyłączonym ekranie.
Optymalizcje Baterii
Tryb offline
+ Dodaj do playlisty
Dodaj do kolejki
Pobierz wszystkie
Przejdź do wykonawcy
@@ -89,6 +90,7 @@
Pobieranie
Wybierz dwa lub więcej filtrów
Filtry
+ Filtruj wykonawców
Filtruj Gatunki
(%1$d)
(+%1$d)
@@ -103,14 +105,10 @@
Reset
Zapisz
Zmień układ strony głównej
- Weź pod uwagę to że, żeby zmiany nastąpiły, musisz zrestartować aplikację.
+ Weź pod uwagę to że, żeby zmiany nastąpiły, musisz zrestartować aplikację.
Muzyka
Podcasty
Radio
- Zapisano kolejkę odtwarzania
- Głębia bitowa
- Częstotliwość próbkowania
- Język systemu
Top piosenki od twoich ulubionych wykonawców
Stwórz miks z piosenki którą lubisz
Dodaj nowe radio
@@ -119,6 +117,10 @@
Pobierz
Pobieranie tych utworów może zużyć dużo danych
Wygląda na to że, są utwory oznaczone gwiazdką
+ Synchronizacja albumów oznaczonych gwiazdką
+ Albumy oznaczone gwiazdką będą dostępne offline
+ Synchronizacja wykonawców oznaczonych gwiazdką
+ Masz wykonawców oznaczonych gwiazdką, bez pobranej muzyki
Najlepsze
Odkrywanie
Odtwórz wszystkie losowo
@@ -165,7 +167,9 @@
Serwery Subsonic
Przesyłanie
Dodaj
+ Dodaj do playlisty
Pobierz wszystko
+ Oceń album
Pobrane
Wszystko
Pobrane
@@ -194,13 +198,23 @@
Rok
%1$.2fx
Wyczyść kolejkę odtwarzania
+ Zapisana kolejka odtwarzania
+ Pobierz teksty do odtwarzania offline
+ Teksty pobrane do odtwarzania offline
+ Zapisano tekst do odtwarzania offline.
+ Tekst nie jest dostępny do pobrania.
Priorytet Serwerów
+ Nieznany format
+ Transkodowanie
+ zażądane
Katalog Playlist
Przeglądaj Playlisty
Nie utworzono playlist
Anuluj
Utwórz
Dodaj do playlisty
+ Dodano piosenkę do playlisty
+ Nie udało się dodać piosenki do playlisty
%1$d utworów • %2$s
Długość • %1$s
Przytrzymaj aby usunąć
@@ -295,6 +309,9 @@
https://github.com/eddyizm/tempo
Śledź tworzenie aplikacji
GitHub
+ https://github.com/eddyizm/tempo/discussions
+ Dołącz do dyskusji i wsparcia społeczności
+ Wsparcie użytkowników
Rozdzielczość obrazów
Język
Wyloguj
@@ -308,13 +325,17 @@
Jeżeli włączone, widoczna będzie sekcja z podcastami. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.
Pokaż jakość audio
Bitrate i format audio będzie pokazywany dla każdego utworu.
+ Pokaż ocenę piosenek w gwiazdkach
+ Jeżeli włączone, pokazuje ocenę w 5 gwiazdkach dla utworu na stronie piosenki\n\n*Wymaga ponownego uruchomienia aplikacji
Pokaż oceny elementów
Jeżeli włączone, ocena elementów oraz czy jest oznaczony jako ulubiony będą pokazywane.
Timer synchronizacji
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.
- Synchronizuj kolejkę odtwarzania dla tego użytkownika
+ Synchronizuj kolejkę odtwarzania dla tego użytkownika [Niedokończone]
Pokaż radio
Jeżeli włączone, widoczna będzie sekcja radia. Zrestartuj aplikację aby, zmiany przyniosły pełny efekt.
+ Automatyczne pobieranie tesktów
+ Automatycznie zapisuj teksty jeżeli, są dostępne aby, mogły być wyświetlane offline.
Tryb wzmocnienia głośności przy ponownym odtwarzaniu
Zaokrąglone rogi
Rozmiar rogów
@@ -322,6 +343,7 @@
Jeżeli włączone, ustawia kąt krzywizny dla wszystkich renderowanych okładek. Zmiany przyniosą efekt po restarcie.
Skanuj bibliotekę
Włącz scrobbling muzyki
+ Język systemowy
Włącz udostępnianie muzyki
Rozmiar cache dla strumieniowania
Pamięć cache dla strumieniowania
@@ -330,11 +352,15 @@
Wzmocnienie głośności jest funkcją która pozwala tobie na ustawienia poziomu głośności dla utworów aby słuchanie brzmiało cały czas tak samo. To ustawienia działa tylko wtedy kiedy utwór zawiera potrzebne metadane.
Scrobbling jest funkcją która pozwala twojemu urządzeniu na wysyłanie informacji na temat piosenek których słuchasz do serwera muzyki. Te informacje pomagają tworzyć spersonalizowane rekomendacje na podstawie twojego gustu muzycznego.
Pozwala udostępnić użytkownikowi muzykę przez link. Ta funkcjonalność musi być wspierana i włączona na serwerze i jest ograniczona do pojedyńczych utworów, albumów i playlist.
- Przywraca stan kolejki odtwarzania dla tego użytkownika. Zawiera utwory w kolejce, aktualnie odtwarzany utwór i pozycję w nim. Serwer musi wspierać tę funkcję.
+ Przywraca stan kolejki odtwarzania dla tego użytkownika. Zawiera utwory w kolejce, aktualnie odtwarzany utwór i pozycję w nim. Serwer musi wspierać tę funkcję.\n*To ustawienie nie działa na 100% na wszystkich serwerach/urządzeniach.
%1$s \nAktualnie w użyciu: %2$s MiB
Priorytet dawany trybowi transkodowania. Jeżeli ustawiony na \"Odtwarzanie bezpośrednie\" bitrate pliku nie zostanie zmieniony.
Pobieraj transkdowane media. Jeżeli włączone, endpoint pobierania nie będzie używnany, poza następującymi ustawieniami. \n\n Jeżeli \"Format transkodowania dla pobierania\" jest ustawiony na \"Pobieranie bezpośrednie\" bitrate pliku nie zostanie zmieniony.
Kiedy plik jest transkodowany w locie, klient nie pokazuje zwykle długości utworu.Jest możliwe odpytanie serwera który wspiera tą funkcjonalność aby oszacował długość odtwarzanego utworu, ale czasy odpowiedzi mogą być dłuższe.
+ Jeżeli włączone, utwory wykonawców oznaczonych gwiazdką będą pobierane do użycia offline.
+ Synchronizuj wykonawców oznacznych gwiazdką do użycia offline
+ Jeżeli włączone, albumy oznaczone gwiazdką będą pobieranew do użycia offline.
+ Synchronizuj albumy oznaczone gwiazdką do użycia offline
Jeżeli włączone, utwory oznaczone gwiazdką będą pobrane do użycia offline.
Zsynchronizuj utwory oznaczone gwiazdką do użycia offline
Motyw
@@ -390,14 +416,19 @@
Kontynuuj i pobierz
Pobieranie utworów oznaczonych gwiazdką może wymagać dużej ilośći danych.
Synchronizuj utwory oznaczone gwiazdką
+ Pobieranie utworów artystów oznaczonych gwiazdką może wymagać dużej ilośći danych.
+ Synchronizacja wykonawców oznaczonych gwiazdką
+ Pobieranie albumów oznaczonych gwiazdką może wymagać dużej ilośći danych.
+ Synchronizacja albumów oznaczonych gwiazdką
Aby zmiany przyniosły efekt, zrestartuj aplikację.
Zmiana lokalizacji plików cache z jednej na drugą spowoduje natychmiastowe usunięcie wcześniej pobranych plików cache w drugiej lokalizacji.
Wybieranie pamięci
Zewnętrzna
Wewnętrzna
- https://buymeacoffee.com/a.cappiello
+ https://ko-fi.com/eddyizm
Album
Wykonawca
+ Głębia bitowa
Bitrate
Typ Treści
OK
@@ -406,6 +437,7 @@
Długość
Gatunek
Ścieżka
+ Częstotliwość próbkowania
Rozmiar
Sufiks
Plik został pobrany przy użyciu API Subsonic. Kodek i bitrate pliku pozostaje nie zmieniony względem pliku źródłowego.
@@ -426,6 +458,14 @@
- %d album do zsynchronizowania
- %d albumów do zsynchrpnizowania
+
+ - %d wykonawca do zsynchronizowania
+ - %d wykonawców do zsynchronizowania
+
+
+ - Pobieranie %d piosenki
+ - Pobieranie %d piosenek
+
Korektor dźwięku
Reset
Włączony
diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml
index 698b8eb9..fc2bfe00 100644
--- a/app/src/main/res/values-tr/strings.xml
+++ b/app/src/main/res/values-tr/strings.xml
@@ -90,6 +90,7 @@
İndirilenler
İki veya daha fazla filtre seçin
Filtre
+ Sanatçıları filtrele
Türleri filtrele
(%1$d)
(+%1$d)
@@ -116,6 +117,7 @@
İndir
Bu parçaların indirilmesi önemli miktarda veri kullanabilir
Eşitlenecek bazı yıldızlı parçalar var gibi görünüyor
+ Yıldız ile işaretlenen albümler çevrimdışı kullanılabilir olacak
En iyiler
Keşfet
Tümünü karıştır
@@ -164,6 +166,7 @@
Ekle
Çalma listesine ekle
Tümünü indir
+ Albümü oyla
İndir
Tümü
İndirilenler
@@ -192,6 +195,7 @@
Yıl
%1$.2fx
Çalma sırasını temizle
+ Kayıtlı oynatma sırası
Sunucu önceliği
Bilinmeyen format
Dönüştürme
@@ -311,6 +315,7 @@
Etkinleştirildiğinde podcast bölümü görüntülenir. Tam etkili olması için uygulamayı yeniden başlatın.
Ses kalitesini göster
Her ses parçası için bit hızı ve ses formatı gösterilecektir.
+ " "
Öğe değerlemesini göster
Etkinleştirildiğinde, öğenin puanı ve favori olarak işaretlenip işaretlenmediği görüntülenir.
Eşitleme zamanlayıcısı
@@ -340,6 +345,7 @@
Dönüştürülmüş medyayı indir. Etkinleştirilirse indirme uç noktası kullanılmaz, bunun yerine aşağıdaki ayarlar geçerli olur. \n\n “İndirmeler için dönüştürme formatı” “Doğrudan indir” olarak ayarlanırsa dosyanın bit hızı değiştirilmez.
Dosya anlık olarak dönüştürüldüğünde, istemci genellikle parçanın süresini göstermez. Bu işlevi destekleyen sunuculardan çalınan parçanın süresini tahmin etmeleri istenebilir,
ancak yanıt süreleri daha uzun olabilir.
+ Çevrimdışı kullanım için yıldızlı albümleri senkronize et
Etkinleştirildiğinde, yıldızlı parçalar çevrimdışı kullanım için indirilecektir.
Çevrimdışı kullanım için yıldızlı parçaları eşitle
Tema
@@ -395,6 +401,8 @@
Devam et ve indir
Yıldızlı parçaların indirilmesi yüksek miktarda veri gerektirebilir.
Yıldızlı parçaları eşitle
+ Yıldızlı albümleri indirmek yüksek miktarda veri kullanımı gerektirebilir.
+ Yıldızlı albümleri senkronize et
Değişikliklerin geçerli olması için uygulamayı yeniden başlatın.
Önbelleğe alınmış dosyaların hedefini bir depolamadan diğerine değiştirmek, önceki depolamadaki önbellek dosyalarının silinmesine yol açabilir.
Depolama seçeneğini seç
@@ -433,4 +441,16 @@
unDraw
İllüstrasyonlarıyla bu uygulamayı daha güzel hale getirmemize yardımcı olan unDraw’a özel teşekkürler.
https://undraw.co/
+ Yıldızlı Albümleri Senkronize Et
+ Tempo Widget
+ Şu an oynatılmıyor
+ Tempo’yu aç
+ 0:00
+ 0:00
+ Albüm kapağı
+ Çal/Duraklat
+ Sonraki parça
+ Önceki parça
+ Şarkının yıldız derecelendirmesini göster
+ "Etkinleştirildiğinde yıldızlı albümler çevrimdışı kullanım için indirilecek. "
diff --git a/app/src/main/res/values/colors_widget.xml b/app/src/main/res/values/colors_widget.xml
new file mode 100644
index 00000000..71a34138
--- /dev/null
+++ b/app/src/main/res/values/colors_widget.xml
@@ -0,0 +1,9 @@
+
+
+
+ #CCFFFFFF
+ #DE000000
+ #99000000
+ #DE000000
+ #FF6750A4
+
diff --git a/app/src/main/res/values/integers.xml b/app/src/main/res/values/integers.xml
new file mode 100644
index 00000000..e1a1ac1c
--- /dev/null
+++ b/app/src/main/res/values/integers.xml
@@ -0,0 +1,6 @@
+
+
+ 100
+ 160
+ 220
+
diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml
index 40febf58..a19852b1 100644
--- a/app/src/main/res/values/strings.xml
+++ b/app/src/main/res/values/strings.xml
@@ -68,6 +68,7 @@
Download
All tracks in this folder will be downloaded. Tracks present in subfolders will not be downloaded.
Download the tracks
+ Set where music is downloaded
Once you download a song, you\'ll find it here
No downloads yet!
%1$s • %2$s items
@@ -78,7 +79,15 @@
Select storage option
External
Internal
+ Directory
Downloads
+ Set a download folder to refresh your downloads.
+ No missing downloads found.
+
+ - Removed %d missing download.
+ - Removed %d missing downloads.
+
+ Refresh downloaded items
Add to queue
Play next
Remove
@@ -88,6 +97,9 @@
Required
http or https prefix required
Downloads
+ Toggle Heart off
+ Toggle Heart on
+ Loading…
Select two or more filters
Filter
Filter artists
@@ -119,6 +131,8 @@
Looks like there are some starred tracks to sync
Sync Starred Albums
Albums marked with a star will be available offline
+ Starred Artists Sync
+ You have starred artists with music not downloaded
Best of
Discovery
Shuffle all
@@ -197,6 +211,10 @@
%1$.2fx
Clean play queue
Saved play queue
+ Download lyrics for offline playback
+ Lyrics downloaded for offline playback
+ Lyrics saved for offline playback.
+ Lyrics are not available to download.
Server Priority
Unknown format
Transcoding
@@ -207,8 +225,9 @@
Cancel
Create
Add to a playlist
- Added song to playlist
- Failed to add song to playlist
+ Added song(s) to playlist
+ Failed to add song(s) to playlist
+ All songs were skipped as duplicates
%1$d tracks • %2$s
Duration • %1$s
Long press to delete
@@ -275,6 +294,8 @@
Tempo is an open source and lightweight music client for Subsonic, designed and built natively for Android.
About
Always on display
+ Allow adding duplicates to playlist
+ If enabled, duplicates won\'t be checked while adding to a playlist.
Transcode format
If enabled, Tempo will not force download the track with the transcode settings below.
Prioritize server settings used for streaming in downloads
@@ -303,6 +324,9 @@
https://github.com/eddyizm/tempo
Follow the development
Github
+ https://github.com/eddyizm/tempo/discussions
+ Join community discussions and support
+ User support
Set image resolution
Language
Log out
@@ -323,8 +347,12 @@
Sync timer
If enabled, the user will have the ability to save their play queue and will have the ability to load state when opening the application.
Sync play queue for this user [Not Fully Baked]
+ Show Shuffle button
+ If enabled, show the shuffle button, remove the heart in the mini player
Show radio
If enabled, show the radio section. Restart the app for it to take full effect.
+ Auto download lyrics
+ Automatically save lyrics when they are available so they can be shown while offline.
Set replay gain mode
Rounded corners
Corners size
@@ -346,6 +374,8 @@
Priority given to the transcoding mode. If set to \"Direct play\" the bitrate of the file will not be changed.
Download transcoded media. If enabled, the download endpoint will not be used, but the following settings. \n\n If \"Transcode format for donwloads\" is set to \"Direct download\" the bitrate of the file will not be changed.
When the file is transcoded on the fly, the client usually does not show the track length. It is possible to request the servers that support the functionality to estimate the duration of the track being played, but the response times may take longer.
+ If enabled, starred artists will be downloaded for offline use.
+ Sync starred artists for offline use
If enabled, starred albums will be downloaded for offline use.
Sync starred albums for offline use
If enabled, starred tracks will be downloaded for offline use.
@@ -353,6 +383,7 @@
Theme
Data
General
+ Playlist
Rating
Replay Gain
Scrobble
@@ -403,6 +434,8 @@
Continue and download
Downloading starred tracks may require a large amount of data.
Sync starred tracks
+ Downloading starred artists may require a large amount of data.
+ Sync starred artists
Downloading starred albums may require a large amount of data.
Sync starred albums
For the changes to take effect, restart the app.
@@ -410,7 +443,7 @@
Select storage option
External
Internal
- https://buymeacoffee.com/a.cappiello
+ https://ko-fi.com/eddyizm
Album
Artist
Bit depth
@@ -439,10 +472,29 @@
unDraw
A special thanks goes to unDraw without whose illustrations we could not have made this application more beautiful.
https://undraw.co/
+ Tempo Widget
+ Not playing
+ Open Tempo
+ 0:00
+ 0:00
+ Album artwork
+ Play or pause
+ Next track
+ Previous track
+ Toggle shuffle
+ Change repeat mode
- %d album to sync
- %d albums to sync
+
+ - %d artist to sync
+ - %d artists to sync
+
+
+ - Downloading %d song
+ - Downloading %d songs
+
Equalizer
Reset
Enable
diff --git a/app/src/main/res/xml/global_preferences.xml b/app/src/main/res/xml/global_preferences.xml
index 18cf4470..b558e20e 100644
--- a/app/src/main/res/xml/global_preferences.xml
+++ b/app/src/main/res/xml/global_preferences.xml
@@ -2,14 +2,10 @@
xmlns:app="http://schemas.android.com/apk/res-auto">
-
-
+ android:layout_height="match_parent"
+ android:key="equalizer"
+ android:summary="@string/settings_system_equalizer_summary"
+ android:title="@string/settings_system_equalizer_title" />
+ app:title="@string/settings_language" />
+ android:title="@string/settings_rounded_corner" />
+ android:title="@string/settings_audio_quality" />
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/app/src/main/res/xml/widget_info.xml b/app/src/main/res/xml/widget_info.xml
new file mode 100644
index 00000000..03e072ce
--- /dev/null
+++ b/app/src/main/res/xml/widget_info.xml
@@ -0,0 +1,10 @@
+
+
diff --git a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt
index 4549e27e..27214f90 100644
--- a/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt
+++ b/app/src/notquitemy/java/com/cappielloantonio/tempo/service/MediaService.kt
@@ -8,6 +8,8 @@ import android.content.Intent
import android.os.Binder
import android.os.Bundle
import android.os.IBinder
+import android.os.Handler
+import android.os.Looper
import androidx.media3.common.*
import androidx.media3.common.util.UnstableApi
import androidx.media3.exoplayer.DefaultLoadControl
@@ -23,6 +25,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
+import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.common.collect.ImmutableList
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
@@ -39,6 +42,18 @@ class MediaService : MediaLibraryService() {
lateinit var equalizerManager: EqualizerManager
private var customLayout = ImmutableList.of()
+ private val widgetUpdateHandler = Handler(Looper.getMainLooper())
+ private var widgetUpdateScheduled = false
+ private val widgetUpdateRunnable = object : Runnable {
+ override fun run() {
+ if (!player.isPlaying) {
+ widgetUpdateScheduled = false
+ return
+ }
+ updateWidget()
+ widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
+ }
+ }
inner class LocalBinder : Binder() {
fun getEqualizerManager(): EqualizerManager {
@@ -80,6 +95,7 @@ class MediaService : MediaLibraryService() {
override fun onDestroy() {
equalizerManager.release()
+ stopWidgetUpdates()
releasePlayer()
super.onDestroy()
}
@@ -260,6 +276,7 @@ class MediaService : MediaLibraryService() {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
+ updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
@@ -282,6 +299,12 @@ class MediaService : MediaLibraryService() {
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
+ if (isPlaying) {
+ scheduleWidgetUpdates()
+ } else {
+ stopWidgetUpdates()
+ }
+ updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -293,6 +316,7 @@ class MediaService : MediaLibraryService() {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
+ updateWidget()
}
override fun onPositionDiscontinuity(
@@ -326,6 +350,9 @@ class MediaService : MediaLibraryService() {
mediaLibrarySession.setCustomLayout(customLayout)
}
})
+ if (player.isPlaying) {
+ scheduleWidgetUpdates()
+ }
}
private fun setPlayer(player: Player) {
@@ -386,5 +413,46 @@ class MediaService : MediaLibraryService() {
.build()
}
+ private fun updateWidget() {
+ val mi = player.currentMediaItem
+ val title = mi?.mediaMetadata?.title?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("title")
+ val artist = mi?.mediaMetadata?.artist?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("artist")
+ val album = mi?.mediaMetadata?.albumTitle?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("album")
+ val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId")
+ val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
+ val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
+ WidgetUpdateManager.updateFromState(
+ this,
+ title ?: "",
+ artist ?: "",
+ album ?: "",
+ coverId,
+ player.isPlaying,
+ player.shuffleModeEnabled,
+ player.repeatMode,
+ position,
+ duration
+ )
+ }
+
+ private fun scheduleWidgetUpdates() {
+ if (widgetUpdateScheduled) return
+ widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
+ widgetUpdateScheduled = true
+ }
+
+ private fun stopWidgetUpdates() {
+ if (!widgetUpdateScheduled) return
+ widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
+ widgetUpdateScheduled = false
+ }
+
+
private fun getRenderersFactory() = DownloadUtil.buildRenderersFactory(this, false)
-}
\ No newline at end of file
+
+}
+
+private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
diff --git a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt
index 780d56a0..a73a7c33 100644
--- a/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt
+++ b/app/src/play/java/com/cappielloantonio/tempo/service/MediaService.kt
@@ -6,6 +6,8 @@ import android.app.TaskStackBuilder
import android.content.Intent
import android.os.Binder
import android.os.IBinder
+import android.os.Handler
+import android.os.Looper
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
@@ -25,6 +27,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
+import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
@@ -49,6 +52,18 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
companion object {
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
}
+ private val widgetUpdateHandler = Handler(Looper.getMainLooper())
+ private var widgetUpdateScheduled = false
+ private val widgetUpdateRunnable = object : Runnable {
+ override fun run() {
+ if (!player.isPlaying) {
+ widgetUpdateScheduled = false
+ return
+ }
+ updateWidget()
+ widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
+ }
+ }
override fun onCreate() {
super.onCreate()
@@ -80,6 +95,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onDestroy() {
equalizerManager.release()
+ stopWidgetUpdates()
releasePlayer()
super.onDestroy()
}
@@ -161,6 +177,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
+ updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
@@ -183,6 +200,12 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
+ if (isPlaying) {
+ scheduleWidgetUpdates()
+ } else {
+ stopWidgetUpdates()
+ }
+ updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -195,6 +218,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
+ updateWidget()
}
override fun onPositionDiscontinuity(
@@ -230,6 +254,47 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
)
}
})
+ if (player.isPlaying) {
+ scheduleWidgetUpdates()
+ }
+ }
+
+ private fun updateWidget() {
+ val mi = player.currentMediaItem
+ val title = mi?.mediaMetadata?.title?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("title")
+ val artist = mi?.mediaMetadata?.artist?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("artist")
+ val album = mi?.mediaMetadata?.albumTitle?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("album")
+ val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId")
+
+ val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
+ val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
+ WidgetUpdateManager.updateFromState(
+ this,
+ title ?: "",
+ artist ?: "",
+ album ?: "",
+ coverId,
+ player.isPlaying,
+ player.shuffleModeEnabled,
+ player.repeatMode,
+ position,
+ duration
+ )
+ }
+
+ private fun scheduleWidgetUpdates() {
+ if (widgetUpdateScheduled) return
+ widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
+ widgetUpdateScheduled = true
+ }
+
+ private fun stopWidgetUpdates() {
+ if (!widgetUpdateScheduled) return
+ widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
+ widgetUpdateScheduled = false
}
private fun initializeLoadControl(): DefaultLoadControl {
@@ -294,3 +359,5 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.prepare()
}
}
+
+private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
index 099ae672..a01a2644 100644
--- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
+++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaLibraryServiceCallback.kt
@@ -2,21 +2,42 @@ package com.cappielloantonio.tempo.service
import android.content.Context
import android.os.Bundle
+import android.util.Log
import androidx.annotation.OptIn
+import androidx.concurrent.futures.CallbackToFutureAdapter
+import androidx.media3.common.HeartRating
import androidx.media3.common.MediaItem
+import androidx.media3.common.MediaMetadata
import androidx.media3.common.Player
+import androidx.media3.common.Rating
import androidx.media3.common.util.UnstableApi
import androidx.media3.session.CommandButton
import androidx.media3.session.LibraryResult
+import androidx.media3.session.MediaConstants
import androidx.media3.session.MediaLibraryService
import androidx.media3.session.MediaSession
import androidx.media3.session.SessionCommand
+import androidx.media3.session.SessionError
import androidx.media3.session.SessionResult
+import com.cappielloantonio.tempo.App
import com.cappielloantonio.tempo.R
import com.cappielloantonio.tempo.repository.AutomotiveRepository
+import com.cappielloantonio.tempo.subsonic.base.ApiResponse
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_LOADING
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_OFF
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_HEART_ON
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF
+import com.cappielloantonio.tempo.util.Constants.CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON
import com.google.common.collect.ImmutableList
+import com.cappielloantonio.tempo.util.Preferences
import com.google.common.util.concurrent.Futures
import com.google.common.util.concurrent.ListenableFuture
+import retrofit2.Call
+import retrofit2.Callback
+import retrofit2.Response
open class MediaLibrarySessionCallback(
context: Context,
@@ -28,82 +49,244 @@ open class MediaLibrarySessionCallback(
MediaBrowserTree.initialize(automotiveRepository)
}
- private val shuffleCommandButtons: List = listOf(
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
- .setSessionCommand(
- SessionCommand(
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
- )
- ).setIconResId(R.drawable.exo_icon_shuffle_off).build(),
+ private val customCommandToggleShuffleModeOn = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_shuffle_on_description))
+ .setSessionCommand(
+ SessionCommand(
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON, Bundle.EMPTY
+ )
+ ).setIconResId(R.drawable.exo_icon_shuffle_off).build()
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
- .setSessionCommand(
- SessionCommand(
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
- )
- ).setIconResId(R.drawable.exo_icon_shuffle_on).build()
+ private val customCommandToggleShuffleModeOff = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_shuffle_off_description))
+ .setSessionCommand(
+ SessionCommand(
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF, Bundle.EMPTY
+ )
+ ).setIconResId(R.drawable.exo_icon_shuffle_on).build()
+
+ private val customCommandToggleRepeatModeOff = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
+ .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
+ .setIconResId(R.drawable.exo_icon_repeat_off)
+ .build()
+
+ private val customCommandToggleRepeatModeOne = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
+ .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
+ .setIconResId(R.drawable.exo_icon_repeat_one)
+ .build()
+
+ private val customCommandToggleRepeatModeAll = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
+ .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
+ .setIconResId(R.drawable.exo_icon_repeat_all)
+ .build()
+
+ private val customCommandToggleHeartOn = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_heart_on_description))
+ .setSessionCommand(
+ SessionCommand(
+ CUSTOM_COMMAND_TOGGLE_HEART_ON, Bundle.EMPTY
+ )
+ )
+ .setIconResId(R.drawable.ic_favorite)
+ .build()
+
+ private val customCommandToggleHeartOff = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.exo_controls_heart_off_description))
+ .setSessionCommand(
+ SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_OFF, Bundle.EMPTY)
+ )
+ .setIconResId(R.drawable.ic_favorites_outlined)
+ .build()
+
+ // Fake Command while waiting for like update command
+ private val customCommandToggleHeartLoading = CommandButton.Builder()
+ .setDisplayName(context.getString(R.string.cast_expanded_controller_loading))
+ .setSessionCommand(
+ SessionCommand(CUSTOM_COMMAND_TOGGLE_HEART_LOADING, Bundle.EMPTY)
+ )
+ .setIconResId(R.drawable.ic_bookmark_sync)
+ .build()
+
+ private val customLayoutCommandButtons = listOf(
+ customCommandToggleShuffleModeOn,
+ customCommandToggleShuffleModeOff,
+ customCommandToggleRepeatModeOff,
+ customCommandToggleRepeatModeOne,
+ customCommandToggleRepeatModeAll,
+ customCommandToggleHeartOn,
+ customCommandToggleHeartOff,
+ customCommandToggleHeartLoading,
)
- private val repeatCommandButtons: List = listOf(
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_off_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_off)
- .build(),
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_one_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_one)
- .build(),
- CommandButton.Builder()
- .setDisplayName(context.getString(R.string.exo_controls_repeat_all_description))
- .setSessionCommand(SessionCommand(CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL, Bundle.EMPTY))
- .setIconResId(R.drawable.exo_icon_repeat_all)
- .build()
- )
-
- private val customLayoutCommandButtons: List =
- shuffleCommandButtons + repeatCommandButtons
-
@OptIn(UnstableApi::class)
val mediaNotificationSessionCommands =
MediaSession.ConnectionResult.DEFAULT_SESSION_AND_LIBRARY_COMMANDS.buildUpon()
.also { builder ->
- (shuffleCommandButtons + repeatCommandButtons).forEach { commandButton ->
+ customLayoutCommandButtons.forEach { commandButton ->
commandButton.sessionCommand?.let { builder.add(it) }
}
}.build()
- fun buildCustomLayout(player: Player): ImmutableList {
- val shuffle = shuffleCommandButtons[if (player.shuffleModeEnabled) 1 else 0]
- val repeat = when (player.repeatMode) {
- Player.REPEAT_MODE_ONE -> repeatCommandButtons[1]
- Player.REPEAT_MODE_ALL -> repeatCommandButtons[2]
- else -> repeatCommandButtons[0]
- }
- return ImmutableList.of(shuffle, repeat)
- }
-
@OptIn(UnstableApi::class)
override fun onConnect(
session: MediaSession, controller: MediaSession.ControllerInfo
): MediaSession.ConnectionResult {
+ session.player.addListener(object : Player.Listener {
+ override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
+ updateMediaNotificationCustomLayout(session)
+ }
+
+ override fun onRepeatModeChanged(repeatMode: Int) {
+ updateMediaNotificationCustomLayout(session)
+ }
+
+ override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
+ updateMediaNotificationCustomLayout(session)
+ }
+
+ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
+ updateMediaNotificationCustomLayout(session)
+ }
+ })
+
+ // FIXME: I'm not sure this if is required anymore
if (session.isMediaNotificationController(controller) || session.isAutomotiveController(
controller
) || session.isAutoCompanionController(controller)
) {
- val customLayout = buildCustomLayout(session.player)
-
return MediaSession.ConnectionResult.AcceptedResultBuilder(session)
.setAvailableSessionCommands(mediaNotificationSessionCommands)
- .setCustomLayout(customLayout).build()
+ .setCustomLayout(buildCustomLayout(session.player))
+ .build()
}
return MediaSession.ConnectionResult.AcceptedResultBuilder(session).build()
}
+ // Update the mediaNotification after some changes
+ @OptIn(UnstableApi::class)
+ private fun updateMediaNotificationCustomLayout(
+ session: MediaSession,
+ isRatingPending: Boolean = false
+ ) {
+ session.setCustomLayout(
+ session.mediaNotificationControllerInfo!!,
+ buildCustomLayout(session.player, isRatingPending)
+ )
+ }
+
+ private fun buildCustomLayout(player: Player, isRatingPending: Boolean = false): ImmutableList {
+ val customLayout = mutableListOf()
+
+ val showShuffle = Preferences.showShuffleInsteadOfHeart()
+
+ if (!showShuffle) {
+ if (player.currentMediaItem != null && !isRatingPending) {
+ if ((player.mediaMetadata.userRating as HeartRating?)?.isHeart == true) {
+ customLayout.add(customCommandToggleHeartOn)
+ } else {
+ customLayout.add(customCommandToggleHeartOff)
+ }
+ }
+ } else {
+ customLayout.add(
+ if (player.shuffleModeEnabled) customCommandToggleShuffleModeOff else customCommandToggleShuffleModeOn
+ )
+ }
+
+ // Add repeat button
+ val repeatButton = when (player.repeatMode) {
+ Player.REPEAT_MODE_ONE -> customCommandToggleRepeatModeOne
+ Player.REPEAT_MODE_ALL -> customCommandToggleRepeatModeAll
+ else -> customCommandToggleRepeatModeOff
+ }
+
+ customLayout.add(repeatButton)
+ return ImmutableList.copyOf(customLayout)
+ }
+
+ // Setting rating without a mediaId will set the currently listened mediaId
+ override fun onSetRating(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ rating: Rating
+ ): ListenableFuture {
+ return onSetRating(session, controller, session.player.currentMediaItem!!.mediaId, rating)
+ }
+
+ override fun onSetRating(
+ session: MediaSession,
+ controller: MediaSession.ControllerInfo,
+ mediaId: String,
+ rating: Rating
+ ): ListenableFuture {
+ val isStaring = (rating as HeartRating).isHeart
+
+ val networkCall = if (isStaring)
+ App.getSubsonicClientInstance(false)
+ .mediaAnnotationClient
+ .star(mediaId, null, null)
+ else
+ App.getSubsonicClientInstance(false)
+ .mediaAnnotationClient
+ .unstar(mediaId, null, null)
+
+ return CallbackToFutureAdapter.getFuture { completer ->
+ networkCall.enqueue(object : Callback {
+ @OptIn(UnstableApi::class)
+ override fun onResponse(
+ call: Call,
+ response: Response
+ ) {
+ if (response.isSuccessful) {
+
+ // Search if the media item in the player should be updated
+ for (i in 0 until session.player.mediaItemCount) {
+ val mediaItem = session.player.getMediaItemAt(i)
+ if (mediaItem.mediaId == mediaId) {
+ val newMetadata = mediaItem.mediaMetadata.buildUpon()
+ .setUserRating(HeartRating(isStaring)).build()
+ session.player.replaceMediaItem(
+ i,
+ mediaItem.buildUpon().setMediaMetadata(newMetadata).build()
+ )
+ }
+ }
+
+ updateMediaNotificationCustomLayout(session)
+ completer.set(SessionResult(SessionResult.RESULT_SUCCESS))
+ } else {
+ updateMediaNotificationCustomLayout(session)
+ completer.set(
+ SessionResult(
+ SessionError(
+ response.code(),
+ response.message()
+ )
+ )
+ )
+ }
+ }
+
+ @OptIn(UnstableApi::class)
+ override fun onFailure(call: Call, t: Throwable) {
+ updateMediaNotificationCustomLayout(session)
+ completer.set(
+ SessionResult(
+ SessionError(
+ SessionError.ERROR_UNKNOWN,
+ "An error as occurred"
+ )
+ )
+ )
+ }
+ })
+ }
+ }
+
@OptIn(UnstableApi::class)
override fun onCustomCommand(
session: MediaSession,
@@ -111,9 +294,23 @@ open class MediaLibrarySessionCallback(
customCommand: SessionCommand,
args: Bundle
): ListenableFuture {
+
+ val mediaItemId = args.getString(
+ MediaConstants.EXTRA_KEY_MEDIA_ID,
+ session.player.currentMediaItem?.mediaId
+ )
+
when (customCommand.customAction) {
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> session.player.shuffleModeEnabled = true
- CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> session.player.shuffleModeEnabled = false
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON -> {
+ session.player.shuffleModeEnabled = true
+ updateMediaNotificationCustomLayout(session)
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ }
+ CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF -> {
+ session.player.shuffleModeEnabled = false
+ updateMediaNotificationCustomLayout(session)
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
+ }
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL,
CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE -> {
@@ -123,16 +320,31 @@ open class MediaLibrarySessionCallback(
else -> Player.REPEAT_MODE_OFF
}
session.player.repeatMode = nextMode
+ updateMediaNotificationCustomLayout(session)
+ return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
- else -> return Futures.immediateFuture(SessionResult(SessionResult.RESULT_ERROR_NOT_SUPPORTED))
+ CUSTOM_COMMAND_TOGGLE_HEART_ON,
+ CUSTOM_COMMAND_TOGGLE_HEART_OFF -> {
+ val currentRating = session.player.mediaMetadata.userRating as? HeartRating
+ val isCurrentlyLiked = currentRating?.isHeart ?: false
+
+ val newLikedState = !isCurrentlyLiked
+
+ updateMediaNotificationCustomLayout(
+ session,
+ isRatingPending = true // Show loading state
+ )
+ return onSetRating(session, controller, HeartRating(newLikedState))
+ }
+ else -> return Futures.immediateFuture(
+ SessionResult(
+ SessionError(
+ SessionError.ERROR_NOT_SUPPORTED,
+ customCommand.customAction
+ )
+ )
+ )
}
-
- session.setCustomLayout(
- session.mediaNotificationControllerInfo!!,
- buildCustomLayout(session.player)
- )
-
- return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS))
}
override fun onGetLibraryRoot(
@@ -186,17 +398,4 @@ open class MediaLibrarySessionCallback(
): ListenableFuture>> {
return MediaBrowserTree.search(query)
}
-
- companion object {
- private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_ON =
- "android.media3.session.demo.SHUFFLE_ON"
- private const val CUSTOM_COMMAND_TOGGLE_SHUFFLE_MODE_OFF =
- "android.media3.session.demo.SHUFFLE_OFF"
- private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_OFF =
- "android.media3.session.demo.REPEAT_OFF"
- private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ONE =
- "android.media3.session.demo.REPEAT_ONE"
- private const val CUSTOM_COMMAND_TOGGLE_REPEAT_MODE_ALL =
- "android.media3.session.demo.REPEAT_ALL"
- }
}
\ No newline at end of file
diff --git a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt
index a8b17ef6..51292761 100644
--- a/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt
+++ b/app/src/tempo/java/com/cappielloantonio/tempo/service/MediaService.kt
@@ -6,6 +6,8 @@ import android.app.TaskStackBuilder
import android.content.Intent
import android.os.Binder
import android.os.IBinder
+import android.os.Handler
+import android.os.Looper
import androidx.media3.cast.CastPlayer
import androidx.media3.cast.SessionAvailabilityListener
import androidx.media3.common.AudioAttributes
@@ -25,6 +27,7 @@ import com.cappielloantonio.tempo.util.DownloadUtil
import com.cappielloantonio.tempo.util.DynamicMediaSourceFactory
import com.cappielloantonio.tempo.util.Preferences
import com.cappielloantonio.tempo.util.ReplayGainUtil
+import com.cappielloantonio.tempo.widget.WidgetUpdateManager
import com.google.android.gms.cast.framework.CastContext
import com.google.android.gms.common.ConnectionResult
import com.google.android.gms.common.GoogleApiAvailability
@@ -49,6 +52,18 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
companion object {
const val ACTION_BIND_EQUALIZER = "com.cappielloantonio.tempo.service.BIND_EQUALIZER"
}
+ private val widgetUpdateHandler = Handler(Looper.getMainLooper())
+ private var widgetUpdateScheduled = false
+ private val widgetUpdateRunnable = object : Runnable {
+ override fun run() {
+ if (!player.isPlaying) {
+ widgetUpdateScheduled = false
+ return
+ }
+ updateWidget()
+ widgetUpdateHandler.postDelayed(this, WIDGET_UPDATE_INTERVAL_MS)
+ }
+ }
override fun onCreate() {
super.onCreate()
@@ -80,6 +95,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onDestroy() {
equalizerManager.release()
+ stopWidgetUpdates()
releasePlayer()
super.onDestroy()
}
@@ -161,6 +177,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_SEEK || reason == Player.MEDIA_ITEM_TRANSITION_REASON_AUTO) {
MediaManager.setLastPlayedTimestamp(mediaItem)
}
+ updateWidget()
}
override fun onTracksChanged(tracks: Tracks) {
@@ -184,6 +201,12 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
} else {
MediaManager.scrobble(player.currentMediaItem, false)
}
+ if (isPlaying) {
+ scheduleWidgetUpdates()
+ } else {
+ stopWidgetUpdates()
+ }
+ updateWidget()
}
override fun onPlaybackStateChanged(playbackState: Int) {
@@ -196,6 +219,7 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
MediaManager.scrobble(player.currentMediaItem, true)
MediaManager.saveChronology(player.currentMediaItem)
}
+ updateWidget()
}
override fun onPositionDiscontinuity(
@@ -219,18 +243,52 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) {
Preferences.setShuffleModeEnabled(shuffleModeEnabled)
- mediaLibrarySession.setCustomLayout(
- librarySessionCallback.buildCustomLayout(player)
- )
}
override fun onRepeatModeChanged(repeatMode: Int) {
Preferences.setRepeatMode(repeatMode)
- mediaLibrarySession.setCustomLayout(
- librarySessionCallback.buildCustomLayout(player)
- )
}
})
+ if (player.isPlaying) {
+ scheduleWidgetUpdates()
+ }
+ }
+
+ private fun updateWidget() {
+ val mi = player.currentMediaItem
+ val title = mi?.mediaMetadata?.title?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("title")
+ val artist = mi?.mediaMetadata?.artist?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("artist")
+ val album = mi?.mediaMetadata?.albumTitle?.toString()
+ ?: mi?.mediaMetadata?.extras?.getString("album")
+ val coverId = mi?.mediaMetadata?.extras?.getString("coverArtId")
+ val position = player.currentPosition.takeIf { it != C.TIME_UNSET } ?: 0L
+ val duration = player.duration.takeIf { it != C.TIME_UNSET } ?: 0L
+ WidgetUpdateManager.updateFromState(
+ this,
+ title ?: "",
+ artist ?: "",
+ album ?: "",
+ coverId,
+ player.isPlaying,
+ player.shuffleModeEnabled,
+ player.repeatMode,
+ position,
+ duration
+ )
+ }
+
+ private fun scheduleWidgetUpdates() {
+ if (widgetUpdateScheduled) return
+ widgetUpdateHandler.postDelayed(widgetUpdateRunnable, WIDGET_UPDATE_INTERVAL_MS)
+ widgetUpdateScheduled = true
+ }
+
+ private fun stopWidgetUpdates() {
+ if (!widgetUpdateScheduled) return
+ widgetUpdateHandler.removeCallbacks(widgetUpdateRunnable)
+ widgetUpdateScheduled = false
}
private fun initializeLoadControl(): DefaultLoadControl {
@@ -294,3 +352,5 @@ class MediaService : MediaLibraryService(), SessionAvailabilityListener {
player.prepare()
}
}
+
+private const val WIDGET_UPDATE_INTERVAL_MS = 1000L
diff --git a/notes b/notes
new file mode 100644
index 00000000..e69de29b