From fb7296b467b01ebd2bc64fd9b74b6520ee71ec67 Mon Sep 17 00:00:00 2001 From: CappielloAntonio Date: Sat, 8 Jun 2024 18:53:58 +0200 Subject: [PATCH] feat: added the ability to pin playlists to the home screen --- .../10.json | 1065 +++++++++++++++++ .../tempo/database/AppDatabase.java | 10 +- .../tempo/repository/PlaylistRepository.java | 50 + .../ui/fragment/HomeTabMusicFragment.java | 66 +- .../ui/fragment/PlaylistPageFragment.java | 14 + .../cappielloantonio/tempo/util/Constants.kt | 1 + .../viewmodel/HomeRearrangementViewModel.java | 3 +- .../tempo/viewmodel/HomeViewModel.java | 24 + .../viewmodel/PlaylistPageViewModel.java | 20 + .../res/layout/fragment_home_tab_music.xml | 30 + app/src/main/res/menu/playlist_page_menu.xml | 10 + app/src/main/res/values/strings.xml | 3 + 12 files changed, 1288 insertions(+), 8 deletions(-) create mode 100644 app/schemas/com.cappielloantonio.tempo.database.AppDatabase/10.json diff --git a/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/10.json b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/10.json new file mode 100644 index 00000000..e039aede --- /dev/null +++ b/app/schemas/com.cappielloantonio.tempo.database.AppDatabase/10.json @@ -0,0 +1,1065 @@ +{ + "formatVersion": 1, + "database": { + "version": 10, + "identityHash": "58cb958cdb09f054c27673d1de7f26d0", + "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, `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": "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, `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": "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, `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": "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": [] + } + ], + "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, '58cb958cdb09f054c27673d1de7f26d0')" + ] + } +} \ 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 c47aa987..ed37106b 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.dao.ChronologyDao; import com.cappielloantonio.tempo.database.dao.DownloadDao; import com.cappielloantonio.tempo.database.dao.FavoriteDao; +import com.cappielloantonio.tempo.database.dao.PlaylistDao; import com.cappielloantonio.tempo.database.dao.QueueDao; import com.cappielloantonio.tempo.database.dao.RecentSearchDao; import com.cappielloantonio.tempo.database.dao.ServerDao; @@ -23,12 +24,13 @@ import com.cappielloantonio.tempo.model.RecentSearch; import com.cappielloantonio.tempo.model.Server; import com.cappielloantonio.tempo.model.SessionMediaItem; +import com.cappielloantonio.tempo.subsonic.models.Playlist; @UnstableApi @Database( - version = 9, - entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class}, - autoMigrations = {@AutoMigration(from = 8, to = 9)} + version = 10, + entities = {Queue.class, Server.class, RecentSearch.class, Download.class, Chronology.class, Favorite.class, SessionMediaItem.class, Playlist.class}, + autoMigrations = {@AutoMigration(from = 9, to = 10)} ) @TypeConverters({DateConverters.class}) public abstract class AppDatabase extends RoomDatabase { @@ -58,4 +60,6 @@ public static synchronized AppDatabase getInstance() { public abstract FavoriteDao favoriteDao(); public abstract SessionMediaItemDao sessionMediaItemDao(); + + public abstract PlaylistDao playlistDao(); } 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 003f845a..bcf0c732 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java +++ b/app/src/main/java/com/cappielloantonio/tempo/repository/PlaylistRepository.java @@ -1,9 +1,12 @@ package com.cappielloantonio.tempo.repository; import androidx.annotation.NonNull; +import androidx.lifecycle.LiveData; import androidx.lifecycle.MutableLiveData; import com.cappielloantonio.tempo.App; +import com.cappielloantonio.tempo.database.AppDatabase; +import com.cappielloantonio.tempo.database.dao.PlaylistDao; import com.cappielloantonio.tempo.subsonic.base.ApiResponse; import com.cappielloantonio.tempo.subsonic.models.Child; import com.cappielloantonio.tempo.subsonic.models.Playlist; @@ -17,6 +20,7 @@ import retrofit2.Response; public class PlaylistRepository { + private final PlaylistDao playlistDao = AppDatabase.getInstance().playlistDao(); public MutableLiveData> getPlaylists(boolean random, int size) { MutableLiveData> listLivePlaylists = new MutableLiveData<>(new ArrayList<>()); @@ -153,4 +157,50 @@ public void onFailure(@NonNull Call call, @NonNull Throwable t) { } }); } + + public LiveData> getPinnedPlaylists() { + return playlistDao.getAll(); + } + + public void insert(Playlist playlist) { + InsertThreadSafe insert = new InsertThreadSafe(playlistDao, playlist); + Thread thread = new Thread(insert); + thread.start(); + } + + public void delete(Playlist playlist) { + DeleteThreadSafe delete = new DeleteThreadSafe(playlistDao, playlist); + Thread thread = new Thread(delete); + thread.start(); + } + + private static class InsertThreadSafe implements Runnable { + private final PlaylistDao playlistDao; + private final Playlist playlist; + + public InsertThreadSafe(PlaylistDao playlistDao, Playlist playlist) { + this.playlistDao = playlistDao; + this.playlist = playlist; + } + + @Override + public void run() { + playlistDao.insert(playlist); + } + } + + private static class DeleteThreadSafe implements Runnable { + private final PlaylistDao playlistDao; + private final Playlist playlist; + + public DeleteThreadSafe(PlaylistDao playlistDao, Playlist playlist) { + this.playlistDao = playlistDao; + this.playlist = playlist; + } + + @Override + public void run() { + playlistDao.delete(playlist); + } + } } 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 7649dbaf..b71c27e8 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 @@ -31,6 +31,7 @@ import com.cappielloantonio.tempo.helper.recyclerview.CustomLinearSnapHelper; import com.cappielloantonio.tempo.helper.recyclerview.DotsIndicatorDecoration; import com.cappielloantonio.tempo.interfaces.ClickCallback; +import com.cappielloantonio.tempo.interfaces.PlaylistCallback; import com.cappielloantonio.tempo.model.Download; import com.cappielloantonio.tempo.model.HomeSector; import com.cappielloantonio.tempo.service.DownloaderManager; @@ -44,12 +45,13 @@ import com.cappielloantonio.tempo.ui.adapter.ArtistAdapter; import com.cappielloantonio.tempo.ui.adapter.ArtistHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.DiscoverSongAdapter; -import com.cappielloantonio.tempo.ui.adapter.GridTrackAdapter; +import com.cappielloantonio.tempo.ui.adapter.PlaylistHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.ShareHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.SimilarTrackAdapter; import com.cappielloantonio.tempo.ui.adapter.SongHorizontalAdapter; import com.cappielloantonio.tempo.ui.adapter.YearAdapter; import com.cappielloantonio.tempo.ui.dialog.HomeRearrangementDialog; +import com.cappielloantonio.tempo.ui.dialog.PlaylistEditorDialog; import com.cappielloantonio.tempo.util.Constants; import com.cappielloantonio.tempo.util.DownloadUtil; import com.cappielloantonio.tempo.util.MappingUtil; @@ -85,7 +87,7 @@ public class HomeTabMusicFragment extends Fragment implements ClickCallback { private AlbumAdapter mostPlayedAlbumAdapter; private AlbumHorizontalAdapter newReleasesAlbumAdapter; private YearAdapter yearAdapter; - private GridTrackAdapter gridTrackAdapter; + private PlaylistHorizontalAdapter playlistHorizontalAdapter; private ShareHorizontalAdapter shareHorizontalAdapter; private ListenableFuture mediaBrowserListenableFuture; @@ -122,6 +124,7 @@ public void onViewCreated(@NonNull View view, @Nullable Bundle savedInstanceStat initYearSongView(); initRecentAddedAlbumView(); initTopSongsView(); + initPinnedPlaylistsView(); initSharesView(); initHomeReorganizer(); @@ -422,7 +425,8 @@ private void initTopSongsView() { } else { if (bind != null) bind.homeGridTracksSector.setVisibility(View.VISIBLE); if (bind != null) bind.afterGridDivider.setVisibility(View.VISIBLE); - if (bind != null) bind.topSongsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(chronologies.size(), 5), GridLayoutManager.HORIZONTAL, false)); + if (bind != null) + bind.topSongsRecyclerView.setLayoutManager(new GridLayoutManager(requireContext(), UIUtil.getSpanCount(chronologies.size(), 5), GridLayoutManager.HORIZONTAL, false)); List topSongs = chronologies.stream() .map(cronologia -> (Child) cronologia) @@ -671,6 +675,26 @@ private void initRecentAddedAlbumView() { recentAddedAlbumSnapHelper.attachToRecyclerView(bind.recentlyAddedAlbumsRecyclerView); } + private void initPinnedPlaylistsView() { + if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_PINNED_PLAYLISTS)) return; + + bind.pinnedPlaylistsRecyclerView.setLayoutManager(new LinearLayoutManager(requireContext())); + bind.pinnedPlaylistsRecyclerView.setHasFixedSize(true); + + playlistHorizontalAdapter = new PlaylistHorizontalAdapter(this); + bind.pinnedPlaylistsRecyclerView.setAdapter(playlistHorizontalAdapter); + homeViewModel.getPinnedPlaylists(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), playlists -> { + if (playlists == null) { + if (bind != null) bind.pinnedPlaylistsSector.setVisibility(View.GONE); + } else { + if (bind != null) + bind.pinnedPlaylistsSector.setVisibility(!playlists.isEmpty() ? View.VISIBLE : View.GONE); + + playlistHorizontalAdapter.setItems(playlists); + } + }); + } + private void initSharesView() { if (homeViewModel.checkHomeSectorVisibility(Constants.HOME_SECTOR_SHARED)) return; @@ -708,7 +732,9 @@ private void initSharesView() { private void initHomeReorganizer() { final Handler handler = new Handler(); - final Runnable runnable = () -> { if (bind != null) bind.homeSectorRearrangementButton.setVisibility(View.VISIBLE); }; + final Runnable runnable = () -> { + if (bind != null) bind.homeSectorRearrangementButton.setVisibility(View.VISIBLE); + }; handler.postDelayed(runnable, 5000); bind.homeSectorRearrangementButton.setOnClickListener(v -> { @@ -789,6 +815,9 @@ public void reorder() { case Constants.HOME_SECTOR_RECENTLY_ADDED: bind.homeLinearLayoutContainer.addView(bind.homeRecentlyAddedAlbumsSector); break; + case Constants.HOME_SECTOR_PINNED_PLAYLISTS: + bind.homeLinearLayoutContainer.addView(bind.pinnedPlaylistsSector); + break; case Constants.HOME_SECTOR_SHARED: bind.homeLinearLayoutContainer.addView(bind.sharesSector); break; @@ -824,6 +853,17 @@ private void showPopupMenu(View view, int menuResource) { popup.show(); } + private void refreshPlaylistView() { + final Handler handler = new Handler(); + + final Runnable runnable = () -> { + if (getView() != null && bind != null && homeViewModel != null) + homeViewModel.getPinnedPlaylists(getViewLifecycleOwner()); + }; + + handler.postDelayed(runnable, 100); + } + private void initializeMediaBrowser() { mediaBrowserListenableFuture = new MediaBrowser.Builder(requireContext(), new SessionToken(requireContext(), new ComponentName(requireContext(), MediaService.class))).buildAsync(); } @@ -922,6 +962,24 @@ public void onShareClick(Bundle bundle) { startActivity(intent); } + @Override + public void onPlaylistClick(Bundle bundle) { + Navigation.findNavController(requireView()).navigate(R.id.playlistPageFragment, bundle); + } + + @Override + public void onPlaylistLongClick(Bundle bundle) { + PlaylistEditorDialog dialog = new PlaylistEditorDialog(new PlaylistCallback() { + @Override + public void onDismiss() { + refreshPlaylistView(); + } + }); + + dialog.setArguments(bundle); + dialog.show(activity.getSupportFragmentManager(), null); + } + @Override public void onShareLongClick(Bundle bundle) { Navigation.findNavController(requireView()).navigate(R.id.shareBottomSheetDialog, bundle); 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 b0438de3..80019244 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 @@ -60,6 +60,7 @@ public void onCreate(@Nullable Bundle savedInstanceState) { public void onCreateOptionsMenu(@NonNull Menu menu, @NonNull MenuInflater inflater) { super.onCreateOptionsMenu(menu, inflater); inflater.inflate(R.menu.playlist_page_menu, menu); + initMenuOption(menu); } @Override @@ -115,6 +116,12 @@ public boolean onOptionsItemSelected(@NonNull MenuItem item) { } }); return true; + } else if (item.getItemId() == R.id.action_pin_playlist) { + playlistPageViewModel.setPinned(true); + return true; + } else if (item.getItemId() == R.id.action_unpin_playlist) { + playlistPageViewModel.setPinned(false); + return true; } return false; @@ -124,6 +131,13 @@ private void init() { playlistPageViewModel.setPlaylist(requireArguments().getParcelable(Constants.PLAYLIST_OBJECT)); } + private void initMenuOption(Menu menu) { + playlistPageViewModel.isPinned(getViewLifecycleOwner()).observe(getViewLifecycleOwner(), isPinned -> { + menu.findItem(R.id.action_unpin_playlist).setVisible(isPinned); + menu.findItem(R.id.action_pin_playlist).setVisible(!isPinned); + }); + } + private void initAppBar() { activity.setSupportActionBar(bind.animToolbar); 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 a97ff060..aa788c3f 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt +++ b/app/src/main/java/com/cappielloantonio/tempo/util/Constants.kt @@ -105,5 +105,6 @@ object Constants { const val HOME_SECTOR_MOST_PLAYED = "HOME_SECTOR_MOST_PLAYED" const val HOME_SECTOR_LAST_PLAYED = "HOME_SECTOR_LAST_PLAYED" 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" } \ No newline at end of file diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeRearrangementViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeRearrangementViewModel.java index fd7f193c..9c34b29b 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeRearrangementViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeRearrangementViewModel.java @@ -70,7 +70,8 @@ private List fillStandardHomeSectorList() { sectors.add(new HomeSector(Constants.HOME_SECTOR_MOST_PLAYED, getApplication().getString(R.string.home_title_most_played), true, 11)); sectors.add(new HomeSector(Constants.HOME_SECTOR_LAST_PLAYED, getApplication().getString(R.string.home_title_last_played), true, 12)); sectors.add(new HomeSector(Constants.HOME_SECTOR_RECENTLY_ADDED, getApplication().getString(R.string.home_title_recently_added), true, 13)); - sectors.add(new HomeSector(Constants.HOME_SECTOR_SHARED, getApplication().getString(R.string.home_title_shares), true, 14)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_PINNED_PLAYLISTS, getApplication().getString(R.string.home_title_pinned_playlists), true, 14)); + sectors.add(new HomeSector(Constants.HOME_SECTOR_SHARED, getApplication().getString(R.string.home_title_shares), true, 15)); return sectors; } 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 a31258c0..a780d1cf 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/HomeViewModel.java @@ -16,11 +16,13 @@ import com.cappielloantonio.tempo.repository.ArtistRepository; import com.cappielloantonio.tempo.repository.ChronologyRepository; import com.cappielloantonio.tempo.repository.FavoriteRepository; +import com.cappielloantonio.tempo.repository.PlaylistRepository; import com.cappielloantonio.tempo.repository.SharingRepository; import com.cappielloantonio.tempo.repository.SongRepository; import com.cappielloantonio.tempo.subsonic.models.AlbumID3; import com.cappielloantonio.tempo.subsonic.models.ArtistID3; import com.cappielloantonio.tempo.subsonic.models.Child; +import com.cappielloantonio.tempo.subsonic.models.Playlist; import com.cappielloantonio.tempo.subsonic.models.Share; import com.cappielloantonio.tempo.util.Preferences; import com.google.common.reflect.TypeToken; @@ -32,6 +34,7 @@ import java.util.Comparator; import java.util.HashMap; import java.util.List; +import java.util.stream.Collectors; public class HomeViewModel extends AndroidViewModel { private static final String TAG = "HomeViewModel"; @@ -41,6 +44,7 @@ public class HomeViewModel extends AndroidViewModel { private final ArtistRepository artistRepository; private final ChronologyRepository chronologyRepository; private final FavoriteRepository favoriteRepository; + private final PlaylistRepository playlistRepository; private final SharingRepository sharingRepository; private final MutableLiveData> dicoverSongSample = new MutableLiveData<>(null); @@ -60,6 +64,7 @@ public class HomeViewModel extends AndroidViewModel { private final MutableLiveData> mediaInstantMix = new MutableLiveData<>(null); private final MutableLiveData> artistInstantMix = new MutableLiveData<>(null); private final MutableLiveData> artistBestOf = new MutableLiveData<>(null); + private final MutableLiveData> pinnedPlaylists = new MutableLiveData<>(null); private final MutableLiveData> shares = new MutableLiveData<>(null); private List sectors; @@ -74,6 +79,7 @@ public HomeViewModel(@NonNull Application application) { artistRepository = new ArtistRepository(); chronologyRepository = new ChronologyRepository(); favoriteRepository = new FavoriteRepository(); + playlistRepository = new PlaylistRepository(); sharingRepository = new SharingRepository(); setOfflineFavorite(); @@ -224,6 +230,24 @@ public LiveData> getArtistBestOf(LifecycleOwner owner, ArtistID3 art return artistBestOf; } + public LiveData> getPinnedPlaylists(LifecycleOwner owner) { + pinnedPlaylists.setValue(Collections.emptyList()); + + playlistRepository.getPlaylists(false, -1).observe(owner, remotes -> { + playlistRepository.getPinnedPlaylists().observe(owner, locals -> { + if (remotes != null && locals != null) { + List toReturn = remotes.stream() + .filter(remote -> locals.stream().anyMatch(local -> local.getId().equals(remote.getId()))) + .collect(Collectors.toList()); + + pinnedPlaylists.setValue(toReturn); + } + }); + }); + + return pinnedPlaylists; + } + public LiveData> getShares(LifecycleOwner owner) { if (shares.getValue() == null) { sharingRepository.getShares().observe(owner, shares::postValue); diff --git a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java index 0b15ab2f..d59f5ac6 100644 --- a/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java +++ b/app/src/main/java/com/cappielloantonio/tempo/viewmodel/PlaylistPageViewModel.java @@ -4,7 +4,9 @@ import androidx.annotation.NonNull; import androidx.lifecycle.AndroidViewModel; +import androidx.lifecycle.LifecycleOwner; import androidx.lifecycle.LiveData; +import androidx.lifecycle.MutableLiveData; import com.cappielloantonio.tempo.repository.PlaylistRepository; import com.cappielloantonio.tempo.subsonic.models.Child; @@ -35,4 +37,22 @@ public Playlist getPlaylist() { public void setPlaylist(Playlist playlist) { this.playlist = playlist; } + + public LiveData isPinned(LifecycleOwner owner) { + MutableLiveData isPinnedLive = new MutableLiveData<>(); + + playlistRepository.getPinnedPlaylists().observe(owner, playlists -> { + isPinnedLive.postValue(playlists.stream().anyMatch(obj -> obj.getId().equals(playlist.getId()))); + }); + + return isPinnedLive; + } + + public void setPinned(boolean isNowPinned) { + if (isNowPinned) { + playlistRepository.insert(playlist); + } else { + playlistRepository.delete(playlist); + } + } } diff --git a/app/src/main/res/layout/fragment_home_tab_music.xml b/app/src/main/res/layout/fragment_home_tab_music.xml index b50b4199..06530614 100644 --- a/app/src/main/res/layout/fragment_home_tab_music.xml +++ b/app/src/main/res/layout/fragment_home_tab_music.xml @@ -720,6 +720,36 @@ android:paddingBottom="8dp" /> + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 717933a5..91d532af 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -125,6 +125,7 @@ See all New releases Newest podcasts + Playlists Channels See all Radio stations @@ -175,6 +176,8 @@ Name Random Recently added + Add to home screen + Remove from home screen Year %1$.2fx Clean play queue