diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 9a63af9a4..98d6bce68 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -32,7 +32,7 @@ jobs: cache: 'gradle' - name: Build debug APK and run jvm tests - run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + run: ./gradlew assembleDebug lintFullDebug testFullDebugUnitTest --stacktrace -DskipFormatKtlint env: MUSIC_DEBUG_KEYSTORE_FILE: 'music-debug.jks' MUSIC_DEBUG_SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} @@ -42,4 +42,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: app - path: app/build/outputs/apk/debug/*.apk + path: app/build/outputs/apk/full/debug/*.apk \ No newline at end of file diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index 6fafe23f7..86e12b112 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -18,7 +18,7 @@ jobs: cache: 'gradle' - name: Build debug APK and run jvm tests - run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + run: ./gradlew assembleDebug lintFullDebug testFullDebugUnitTest --stacktrace -DskipFormatKtlint env: PULL_REQUEST: 'true' @@ -26,4 +26,4 @@ jobs: uses: actions/upload-artifact@v3 with: name: app - path: app/build/outputs/apk/debug/*.apk + path: app/build/outputs/apk/full/debug/*.apk diff --git a/README.md b/README.md index 442d9899c..bce7be45c 100644 --- a/README.md +++ b/README.md @@ -4,13 +4,16 @@ A Material 3 YouTube Music client for Android -[Get it on F-Droid](https://f-droid.org/packages/com.zionhuang.music) -[](https://apt.izzysoft.de/fdroid/index/apk/com.zionhuang.music) - [![Latest release](https://img.shields.io/github/v/release/z-huang/InnerTune?include_prereleases)](https://github.com/z-huang/music/releases) [![License](https://img.shields.io/github/license/z-huang/InnerTune)](https://www.gnu.org/licenses/gpl-3.0) [![Downloads](https://img.shields.io/github/downloads/z-huang/InnerTune/total)](https://github.com/z-huang/InnerTune/releases) +[Get it on GitHub](https://github.com/z-huang/InnerTune/releases/latest) +[Get it on F-Droid](https://f-droid.org/packages/com.zionhuang.music) +[](https://apt.izzysoft.de/fdroid/index/apk/com.zionhuang.music) + +[Compare versions](https://github.com/z-huang/InnerTune/wiki/App-Versions) + ## Features - Play songs from YT/YT Music without ads @@ -20,6 +23,7 @@ A Material 3 YouTube Music client for Android - Library management - Cache and download songs for offline playback - Synchronized lyrics +- Lyrics translator (experimental) - Skip silence - Audio normalization - Adjust tempo/pitch diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 5b368d687..02eb490e1 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,16 +1,19 @@ @file:Suppress("UnstableApiUsage") +val isFullBuild: Boolean by rootProject.extra + plugins { id("com.android.application") kotlin("android") kotlin("kapt") id("com.google.dagger.hilt.android") id("com.google.devtools.ksp") - if (System.getenv("PULL_REQUEST") == null) { - id("com.google.gms.google-services") - id("com.google.firebase.crashlytics") - id("com.google.firebase.firebase-perf") - } +} + +if (isFullBuild && System.getenv("PULL_REQUEST") == null) { + apply(plugin = "com.google.gms.google-services") + apply(plugin = "com.google.firebase.crashlytics") + apply(plugin = "com.google.firebase.firebase-perf") } android { @@ -21,8 +24,8 @@ android { applicationId = "com.zionhuang.music" minSdk = 24 targetSdk = 33 - versionCode = 17 - versionName = "0.5.1" + versionCode = 18 + versionName = "0.5.2" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -37,6 +40,15 @@ android { resValue("string", "app_name", "InnerTune Debug") } } + flavorDimensions += "version" + productFlavors { + create("full") { + dimension = "version" + } + create("foss") { + dimension = "version" + } + } signingConfigs { getByName("debug") { if (System.getenv("MUSIC_DEBUG_SIGNING_STORE_PASSWORD") != null) { @@ -65,11 +77,13 @@ android { freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" jvmTarget = "11" } - testOptions { unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true } + lint { + disable += "MissingTranslation" + } } ksp { @@ -126,11 +140,14 @@ dependencies { coreLibraryDesugaring(libs.desugaring) - implementation(platform(libs.firebase.bom)) - implementation(libs.firebase.analytics) - implementation(libs.firebase.crashlytics) - implementation(libs.firebase.config) - implementation(libs.firebase.perf) + "fullImplementation"(platform(libs.firebase.bom)) + "fullImplementation"(libs.firebase.analytics) + "fullImplementation"(libs.firebase.crashlytics) + "fullImplementation"(libs.firebase.config) + "fullImplementation"(libs.firebase.perf) + "fullImplementation"(libs.mlkit.language.id) + "fullImplementation"(libs.mlkit.translate) + "fullImplementation"(libs.opencc4j) implementation(libs.timber) } diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/12.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/12.json new file mode 100644 index 000000000..2570d1e76 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/12.json @@ -0,0 +1,812 @@ +{ + "formatVersion": 1, + "database": { + "version": 12, + "identityHash": "8db3d5731dbcc716a90427d4dde63c66", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_song_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `themeColor` INTEGER, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "themeColor", + "columnName": "themeColor", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `browseId` TEXT, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "song_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `artistId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_song_artist_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "song_album_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `albumId` TEXT NOT NULL, `index` INTEGER NOT NULL, PRIMARY KEY(`songId`, `albumId`), FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "index", + "columnName": "index", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "albumId" + ] + }, + "indices": [ + { + "name": "index_song_album_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_song_album_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_song_album_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "album_artist_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`albumId` TEXT NOT NULL, `artistId` TEXT NOT NULL, `order` INTEGER NOT NULL, PRIMARY KEY(`albumId`, `artistId`), FOREIGN KEY(`albumId`) REFERENCES `album`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`artistId`) REFERENCES `artist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "artistId", + "columnName": "artistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "order", + "columnName": "order", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "albumId", + "artistId" + ] + }, + "indices": [ + { + "name": "index_album_artist_map_albumId", + "unique": false, + "columnNames": [ + "albumId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_albumId` ON `${TABLE_NAME}` (`albumId`)" + }, + { + "name": "index_album_artist_map_artistId", + "unique": false, + "columnNames": [ + "artistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_album_artist_map_artistId` ON `${TABLE_NAME}` (`artistId`)" + } + ], + "foreignKeys": [ + { + "table": "album", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "albumId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "artist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "artistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "playlist_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `playlistId` TEXT NOT NULL, `songId` TEXT NOT NULL, `position` INTEGER NOT NULL, FOREIGN KEY(`playlistId`) REFERENCES `playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_playlist_song_map_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + }, + { + "name": "index_playlist_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_playlist_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "search_history", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "query", + "columnName": "query", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_search_history_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_search_history_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + }, + { + "tableName": "format", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `itag` INTEGER NOT NULL, `mimeType` TEXT NOT NULL, `codecs` TEXT NOT NULL, `bitrate` INTEGER NOT NULL, `sampleRate` INTEGER, `contentLength` INTEGER NOT NULL, `loudnessDb` REAL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "itag", + "columnName": "itag", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "mimeType", + "columnName": "mimeType", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "codecs", + "columnName": "codecs", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "bitrate", + "columnName": "bitrate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "sampleRate", + "columnName": "sampleRate", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "contentLength", + "columnName": "contentLength", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "loudnessDb", + "columnName": "loudnessDb", + "affinity": "REAL", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "lyrics", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `lyrics` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "lyrics", + "columnName": "lyrics", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "event", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `timestamp` INTEGER NOT NULL, `playTime` INTEGER NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "timestamp", + "columnName": "timestamp", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "playTime", + "columnName": "playTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_event_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_event_songId` ON `${TABLE_NAME}` (`songId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "related_song_map", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `songId` TEXT NOT NULL, `relatedSongId` TEXT NOT NULL, FOREIGN KEY(`songId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`relatedSongId`) REFERENCES `song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "relatedSongId", + "columnName": "relatedSongId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [ + { + "name": "index_related_song_map_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_related_song_map_relatedSongId", + "unique": false, + "columnNames": [ + "relatedSongId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_related_song_map_relatedSongId` ON `${TABLE_NAME}` (`relatedSongId`)" + } + ], + "foreignKeys": [ + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "relatedSongId" + ], + "referencedColumns": [ + "id" + ] + } + ] + } + ], + "views": [ + { + "viewName": "sorted_song_artist_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_artist_map ORDER BY position" + }, + { + "viewName": "sorted_song_album_map", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM song_album_map ORDER BY `index`" + }, + { + "viewName": "playlist_song_map_preview", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position" + } + ], + "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, '8db3d5731dbcc716a90427d4dde63c66')" + ] + } +} \ No newline at end of file diff --git a/app/src/foss/java/com/zionhuang/music/utils/TranslationHelper.kt b/app/src/foss/java/com/zionhuang/music/utils/TranslationHelper.kt new file mode 100644 index 000000000..f48e10821 --- /dev/null +++ b/app/src/foss/java/com/zionhuang/music/utils/TranslationHelper.kt @@ -0,0 +1,8 @@ +package com.zionhuang.music.utils + +import com.zionhuang.music.db.entities.LyricsEntity + +object TranslationHelper { + suspend fun translate(lyrics: LyricsEntity): LyricsEntity = lyrics + suspend fun clearModels() {} +} \ No newline at end of file diff --git a/app/src/foss/java/com/zionhuang/music/utils/Utils.kt b/app/src/foss/java/com/zionhuang/music/utils/Utils.kt new file mode 100644 index 000000000..2a471f2b6 --- /dev/null +++ b/app/src/foss/java/com/zionhuang/music/utils/Utils.kt @@ -0,0 +1,10 @@ +package com.zionhuang.music.utils + +import com.zionhuang.music.MainActivity +import java.lang.Exception + +fun MainActivity.setupRemoteConfig() {} + +fun reportException(throwable: Throwable) { + throwable.printStackTrace() +} diff --git a/app/src/full/java/com/zionhuang/music/utils/TranslationHelper.kt b/app/src/full/java/com/zionhuang/music/utils/TranslationHelper.kt new file mode 100644 index 000000000..900107ec1 --- /dev/null +++ b/app/src/full/java/com/zionhuang/music/utils/TranslationHelper.kt @@ -0,0 +1,78 @@ +package com.zionhuang.music.utils + +import android.util.LruCache +import com.github.houbb.opencc4j.util.ZhConverterUtil +import com.google.mlkit.common.model.DownloadConditions +import com.google.mlkit.common.model.RemoteModelManager +import com.google.mlkit.nl.languageid.LanguageIdentification +import com.google.mlkit.nl.translate.TranslateLanguage +import com.google.mlkit.nl.translate.TranslateRemoteModel +import com.google.mlkit.nl.translate.Translation +import com.google.mlkit.nl.translate.TranslatorOptions +import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.lyrics.LyricsUtils +import kotlinx.coroutines.tasks.await +import java.util.Locale + +object TranslationHelper { + private const val MAX_CACHE_SIZE = 20 + private val cache = LruCache(MAX_CACHE_SIZE) + + suspend fun translate(lyrics: LyricsEntity): LyricsEntity { + cache[lyrics.id]?.let { return it } + val isSynced = lyrics.lyrics.startsWith("[") + val sourceLanguage = TranslateLanguage.fromLanguageTag( + LanguageIdentification.getClient().identifyLanguage( + lyrics.lyrics.lines().joinToString(separator = "\n") { it.replace("\\[\\d{2}:\\d{2}.\\d{2,3}\\] *".toRegex(), "") } + ).await() + ) + val targetLanguage = TranslateLanguage.fromLanguageTag( + Locale.getDefault().toLanguageTag().substring(0..1) + ) + return if (sourceLanguage == null || targetLanguage == null || sourceLanguage == targetLanguage) { + lyrics + } else { + val translator = Translation.getClient( + TranslatorOptions.Builder() + .setSourceLanguage(sourceLanguage) + .setTargetLanguage(targetLanguage) + .build() + ) + translator.downloadModelIfNeeded( + DownloadConditions.Builder() + .requireWifi() + .build() + ).await() + val traditionalChinese = Locale.getDefault().toLanguageTag().replace("-Hant", "") == "zh-TW" + lyrics.copy( + lyrics = if (isSynced) { + LyricsUtils.parseLyrics(lyrics.lyrics).map { + val translated = translator.translate(it.text).await() + it.copy( + text = if (traditionalChinese) ZhConverterUtil.toTraditional(translated) else translated + ) + }.joinToString(separator = "\n") { + "[%02d:%02d.%03d]${it.text}".format(it.time / 60000, (it.time / 1000) % 60, it.time % 1000) + } + } else { + lyrics.lyrics.lines() + .map { + val translated = translator.translate(it).await() + if (traditionalChinese) ZhConverterUtil.toTraditional(translated) else translated + } + .joinToString(separator = "\n") + } + ) + }.also { + cache.put(it.id, it) + } + } + + suspend fun clearModels() { + val modelManager = RemoteModelManager.getInstance() + val downloadedModels = modelManager.getDownloadedModels(TranslateRemoteModel::class.java).await() + downloadedModels.forEach { + modelManager.deleteDownloadedModel(it).await() + } + } +} \ No newline at end of file diff --git a/app/src/full/java/com/zionhuang/music/utils/Utils.kt b/app/src/full/java/com/zionhuang/music/utils/Utils.kt new file mode 100644 index 000000000..cb1e2148d --- /dev/null +++ b/app/src/full/java/com/zionhuang/music/utils/Utils.kt @@ -0,0 +1,37 @@ +package com.zionhuang.music.utils + +import com.google.firebase.crashlytics.ktx.crashlytics +import com.google.firebase.ktx.Firebase +import com.google.firebase.remoteconfig.ConfigUpdate +import com.google.firebase.remoteconfig.ConfigUpdateListener +import com.google.firebase.remoteconfig.FirebaseRemoteConfigException +import com.google.firebase.remoteconfig.ktx.remoteConfig +import com.google.firebase.remoteconfig.ktx.remoteConfigSettings +import com.zionhuang.music.MainActivity +import kotlin.time.Duration.Companion.hours + +fun MainActivity.setupRemoteConfig() { + val remoteConfig = Firebase.remoteConfig + remoteConfig.setConfigSettingsAsync(remoteConfigSettings { + minimumFetchIntervalInSeconds = 6.hours.inWholeSeconds + }) + remoteConfig.fetchAndActivate() + .addOnCompleteListener(this) { task -> + if (task.isSuccessful) { + latestVersion = remoteConfig.getLong("latest_version") + } + } + remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { + override fun onError(error: FirebaseRemoteConfigException) {} + override fun onUpdate(configUpdate: ConfigUpdate) { + remoteConfig.activate().addOnCompleteListener { + latestVersion = remoteConfig.getLong("latest_version") + } + } + }) +} + +fun reportException(throwable: Throwable) { + Firebase.crashlytics.recordException(throwable) + throwable.printStackTrace() +} diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index e8113327e..cc31c882d 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -15,6 +15,7 @@ import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.* import com.zionhuang.music.utils.dataStore import com.zionhuang.music.utils.get +import com.zionhuang.music.utils.reportException import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope @@ -55,7 +56,7 @@ class App : Application(), ImageLoaderFactory { ) } catch (e: Exception) { Toast.makeText(this, "Failed to parse proxy url.", LENGTH_SHORT).show() - e.printStackTrace() + reportException(e) } } diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 494a7bb83..dfd5b330c 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -64,19 +64,11 @@ import androidx.navigation.navArgument import coil.imageLoader import coil.request.ImageRequest import com.google.common.util.concurrent.MoreExecutors -import com.google.firebase.ktx.Firebase -import com.google.firebase.remoteconfig.ConfigUpdate -import com.google.firebase.remoteconfig.ConfigUpdateListener -import com.google.firebase.remoteconfig.FirebaseRemoteConfigException -import com.google.firebase.remoteconfig.ktx.remoteConfig -import com.google.firebase.remoteconfig.ktx.remoteConfigSettings import com.valentinilk.shimmer.LocalShimmerTheme import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.constants.* import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID import com.zionhuang.music.db.entities.SearchHistory import com.zionhuang.music.extensions.* import com.zionhuang.music.playback.DownloadUtil @@ -95,7 +87,6 @@ import com.zionhuang.music.ui.screens.library.LibraryAlbumsScreen import com.zionhuang.music.ui.screens.library.LibraryArtistsScreen import com.zionhuang.music.ui.screens.library.LibraryPlaylistsScreen import com.zionhuang.music.ui.screens.library.LibrarySongsScreen -import com.zionhuang.music.ui.screens.playlist.BuiltInPlaylistScreen import com.zionhuang.music.ui.screens.playlist.LocalPlaylistScreen import com.zionhuang.music.ui.screens.playlist.OnlinePlaylistScreen import com.zionhuang.music.ui.screens.search.LocalSearchScreen @@ -110,6 +101,8 @@ import com.zionhuang.music.utils.dataStore import com.zionhuang.music.utils.get import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.utils.reportException +import com.zionhuang.music.utils.setupRemoteConfig import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.collectLatest @@ -118,7 +111,6 @@ import kotlinx.coroutines.withContext import java.net.URLDecoder import java.net.URLEncoder import javax.inject.Inject -import kotlin.time.Duration.Companion.hours @AndroidEntryPoint class MainActivity : ComponentActivity() { @@ -133,7 +125,7 @@ class MainActivity : ComponentActivity() { private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service is MusicBinder) { - playerConnection = PlayerConnection(service, database, lifecycleScope) + playerConnection = PlayerConnection(this@MainActivity, service, database, lifecycleScope) } } @@ -142,7 +134,7 @@ class MainActivity : ComponentActivity() { playerConnection = null } } - private var latestVersion by mutableStateOf(BuildConfig.VERSION_CODE.toLong()) + var latestVersion by mutableStateOf(BuildConfig.VERSION_CODE.toLong()) override fun onStart() { super.onStart() @@ -376,7 +368,7 @@ class MainActivity : ComponentActivity() { navController.navigate("album/$browseId") } }.onFailure { - it.printStackTrace() + reportException(it) } } } else { @@ -399,7 +391,7 @@ class MainActivity : ComponentActivity() { }.onSuccess { sharedSong = it.firstOrNull() }.onFailure { - it.printStackTrace() + reportException(it) } } } @@ -539,13 +531,8 @@ class MainActivity : ComponentActivity() { type = NavType.StringType } ) - ) { backStackEntry -> - val playlistId = backStackEntry.arguments?.getString("playlistId")!! - if (playlistId == LIKED_PLAYLIST_ID || playlistId == DOWNLOADED_PLAYLIST_ID) { - BuiltInPlaylistScreen(navController, scrollBehavior) - } else { - LocalPlaylistScreen(navController, scrollBehavior) - } + ) { + LocalPlaylistScreen(navController, scrollBehavior) } composable( route = "youtube_browse/{browseId}?params={params}", @@ -843,27 +830,6 @@ class MainActivity : ComponentActivity() { } } - private fun setupRemoteConfig() { - val remoteConfig = Firebase.remoteConfig - remoteConfig.setConfigSettingsAsync(remoteConfigSettings { - minimumFetchIntervalInSeconds = 12.hours.inWholeSeconds - }) - remoteConfig.fetchAndActivate() - .addOnCompleteListener(this) { task -> - if (task.isSuccessful) { - latestVersion = remoteConfig.getLong("latest_version") - } - } - remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { - override fun onError(error: FirebaseRemoteConfigException) {} - override fun onUpdate(configUpdate: ConfigUpdate) { - remoteConfig.activate().addOnCompleteListener { - latestVersion = remoteConfig.getLong("latest_version") - } - } - }) - } - companion object { const val ACTION_SEARCH = "com.zionhuang.music.action.SEARCH" const val ACTION_SONGS = "com.zionhuang.music.action.SONGS" diff --git a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt index 4c1053692..4157a7e90 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -36,8 +36,6 @@ val EnableKugouKey = booleanPreferencesKey("enableKugou") val SongSortTypeKey = stringPreferencesKey("songSortType") val SongSortDescendingKey = booleanPreferencesKey("songSortDescending") -val DownloadedSongSortTypeKey = stringPreferencesKey("songSortType") -val DownloadedSongSortDescendingKey = booleanPreferencesKey("songSortDescending") val PlaylistSongSortTypeKey = stringPreferencesKey("playlistSongSortType") val PlaylistSongSortDescendingKey = booleanPreferencesKey("playlistSongSortDescending") val ArtistSortTypeKey = stringPreferencesKey("artistSortType") @@ -49,7 +47,9 @@ val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType") val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending") -val ArtistViewTypeKey = stringPreferencesKey("artistViewType") +val SongFilterKey = stringPreferencesKey("songFilter") +val ArtistFilterKey = stringPreferencesKey("artistFilter") +val AlbumFilterKey = stringPreferencesKey("albumFilter") val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock") @@ -57,10 +57,6 @@ enum class SongSortType { CREATE_DATE, NAME, ARTIST, PLAY_TIME } -enum class DownloadedSongSortType { - CREATE_DATE, NAME, ARTIST, PLAY_TIME -} - enum class PlaylistSongSortType { CUSTOM, CREATE_DATE, NAME, ARTIST, PLAY_TIME } @@ -81,12 +77,21 @@ enum class PlaylistSortType { CREATE_DATE, NAME, SONG_COUNT } -enum class ArtistViewType { - ALL, BOOKMARKED +enum class SongFilter { + LIBRARY, LIKED, DOWNLOADED +} + +enum class ArtistFilter { + LIBRARY, LIKED +} + +enum class AlbumFilter { + LIBRARY, LIKED } val ShowLyricsKey = booleanPreferencesKey("showLyrics") val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") +val TranslateLyricsKey = booleanPreferencesKey("translateLyrics") val PlayerVolumeKey = floatPreferencesKey("playerVolume") val RepeatModeKey = intPreferencesKey("repeatMode") diff --git a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt index 7c86c4c31..642dcd5fa 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -186,28 +186,31 @@ interface DatabaseDao { @Transaction @Query( """ - SELECT albumId - FROM song - JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime - FROM event - WHERE timestamp > :fromTimeStamp - GROUP BY songId) AS e - ON song.id = e.songId - WHERE albumId IS NOT NULL - GROUP BY albumId - ORDER BY SUM(songTotalPlayTime) DESC - LIMIT :limit + SELECT album.* + FROM album + JOIN(SELECT albumId + FROM song + JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime + FROM event + WHERE timestamp > :fromTimeStamp + GROUP BY songId) AS e + ON song.id = e.songId + WHERE albumId IS NOT NULL + GROUP BY albumId + ORDER BY SUM(songTotalPlayTime) DESC + LIMIT :limit) + ON album.id = albumId """ ) - fun mostPlayedAlbums(fromTimeStamp: Long, limit: Int = 6): Flow> + fun mostPlayedAlbums(fromTimeStamp: Long, limit: Int = 6): Flow> @Transaction @Query("SELECT * FROM song WHERE id = :songId") fun song(songId: String?): Flow @Transaction - @Query("SELECT * FROM song WHERE id IN (:songIds)") - fun songs(songIds: List): Flow> + @Query("SELECT * FROM song") + fun allSongs(): Flow> @Query("SELECT * FROM format WHERE id = :id") fun format(id: String?): Flow @@ -216,7 +219,7 @@ interface DatabaseDao { fun lyrics(id: String?): Flow @Transaction - @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY bookmarkedAt") + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY rowId") fun artistsByCreateDateAsc(): Flow> @Transaction @@ -289,7 +292,11 @@ interface DatabaseDao { ArtistSortType.NAME -> artistsByNameAsc() ArtistSortType.SONG_COUNT -> artistsBySongCountAsc() ArtistSortType.PLAY_TIME -> artistsByPlayTimeAsc() - }.map { it.reversed(descending) } + }.map { artists -> + artists + .filter { it.artist.isYouTubeArtist } + .reversed(descending) + } fun artistsBookmarked(sortType: ArtistSortType, descending: Boolean) = when (sortType) { @@ -297,33 +304,33 @@ interface DatabaseDao { ArtistSortType.NAME -> artistsBookmarkedByNameAsc() ArtistSortType.SONG_COUNT -> artistsBookmarkedBySongCountAsc() ArtistSortType.PLAY_TIME -> artistsBookmarkedByPlayTimeAsc() - }.map { it.reversed(descending) } + }.map { artists -> + artists + .filter { it.artist.isYouTubeArtist } + .reversed(descending) + } @Query("SELECT * FROM artist WHERE id = :id") fun artist(id: String): Flow @Transaction - @Query("SELECT * FROM album ORDER BY rowId") - fun albumsByRowIdAsc(): Flow> - - @Transaction - @Query("SELECT * FROM album ORDER BY createDate") + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY rowId") fun albumsByCreateDateAsc(): Flow> @Transaction - @Query("SELECT * FROM album ORDER BY title") + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY title") fun albumsByNameAsc(): Flow> @Transaction - @Query("SELECT * FROM album ORDER BY year") + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY year") fun albumsByYearAsc(): Flow> @Transaction - @Query("SELECT * FROM album ORDER BY songCount") + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY songCount") fun albumsBySongCountAsc(): Flow> @Transaction - @Query("SELECT * FROM album ORDER BY duration") + @Query("SELECT * FROM album WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) ORDER BY duration") fun albumsByLengthAsc(): Flow> @Transaction @@ -333,17 +340,52 @@ interface DatabaseDao { FROM album JOIN song ON song.albumId = album.id + WHERE EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) GROUP BY album.id ORDER BY SUM(song.totalPlayTime) """ ) fun albumsByPlayTimeAsc(): Flow> + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY rowId") + fun albumsLikedByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY title") + fun albumsLikedByNameAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY year") + fun albumsLikedByYearAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun albumsLikedBySongCountAsc(): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE bookmarkedAt IS NOT NULL ORDER BY duration") + fun albumsLikedByLengthAsc(): Flow> + + @Transaction + @Query( + """ + SELECT album.* + FROM album + JOIN song + ON song.albumId = album.id + WHERE bookmarkedAt IS NOT NULL + GROUP BY album.id + ORDER BY SUM(song.totalPlayTime) + """ + ) + fun albumsLikedByPlayTimeAsc(): Flow> + fun albums(sortType: AlbumSortType, descending: Boolean) = when (sortType) { AlbumSortType.CREATE_DATE -> albumsByCreateDateAsc() AlbumSortType.NAME -> albumsByNameAsc() - AlbumSortType.ARTIST -> albumsByRowIdAsc().map { albums -> + AlbumSortType.ARTIST -> albumsByCreateDateAsc().map { albums -> albums.sortedBy { album -> album.artists.joinToString(separator = "") { it.name } } @@ -355,6 +397,22 @@ interface DatabaseDao { AlbumSortType.PLAY_TIME -> albumsByPlayTimeAsc() }.map { it.reversed(descending) } + fun albumsLiked(sortType: AlbumSortType, descending: Boolean) = + when (sortType) { + AlbumSortType.CREATE_DATE -> albumsLikedByCreateDateAsc() + AlbumSortType.NAME -> albumsLikedByNameAsc() + AlbumSortType.ARTIST -> albumsLikedByCreateDateAsc().map { albums -> + albums.sortedBy { album -> + album.artists.joinToString(separator = "") { it.name } + } + } + + AlbumSortType.YEAR -> albumsLikedByYearAsc() + AlbumSortType.SONG_COUNT -> albumsLikedBySongCountAsc() + AlbumSortType.LENGTH -> albumsLikedByLengthAsc() + AlbumSortType.PLAY_TIME -> albumsLikedByPlayTimeAsc() + }.map { it.reversed(descending) } + @Transaction @Query("SELECT * FROM album WHERE id = :id") fun album(id: String): Flow @@ -395,7 +453,7 @@ interface DatabaseDao { fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction - @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") + @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' AND EXISTS(SELECT * FROM song WHERE song.albumId = album.id AND song.inLibrary IS NOT NULL) LIMIT :previewSize") fun searchAlbums(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction @@ -536,33 +594,15 @@ interface DatabaseDao { ?.forEach(::insert) } - @Transaction - fun insert(albumWithSongs: AlbumWithSongs) { - if (insert(albumWithSongs.album) == -1L) return - albumWithSongs.songs.map(Song::toMediaMetadata).forEach(::insert) - albumWithSongs.songs.mapIndexed { index, song -> - SongAlbumMap( - songId = song.id, - albumId = albumWithSongs.album.id, - index = index - ) - }.forEach(::upsert) - albumWithSongs.artists.forEach(::insert) - albumWithSongs.artists.mapIndexed { index, artist -> - AlbumArtistMap( - albumId = albumWithSongs.album.id, - artistId = artist.id, - order = index - ) - }.forEach(::insert) - } - @Update fun update(song: SongEntity) @Update fun update(artist: ArtistEntity) + @Update + fun update(album: AlbumEntity) + @Update fun update(playlist: PlaylistEntity) @@ -579,6 +619,46 @@ interface DatabaseDao { ) } + @Transaction + fun update(album: AlbumEntity, albumPage: AlbumPage) { + update( + album.copy( + id = albumPage.album.browseId, + title = albumPage.album.title, + year = albumPage.album.year, + thumbnailUrl = albumPage.album.thumbnail, + songCount = albumPage.songs.size, + duration = albumPage.songs.sumOf { it.duration ?: 0 } + ) + ) + albumPage.songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = albumPage.album.browseId, + index = index + ) + } + .forEach(::upsert) + albumPage.album.artists + ?.map { artist -> + ArtistEntity( + id = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId(), + name = artist.name + ) + } + ?.onEach(::insert) + ?.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id, + order = index + ) + } + ?.forEach(::insert) + } + @Upsert fun upsert(map: SongAlbumMap) diff --git a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt index a867fa5d8..a6c12f021 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -59,7 +59,7 @@ class MusicDatabase( SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 11, + version = 12, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), @@ -70,7 +70,8 @@ class MusicDatabase( AutoMigration(from = 7, to = 8, spec = Migration7To8::class), AutoMigration(from = 8, to = 9), AutoMigration(from = 9, to = 10, spec = Migration9To10::class), - AutoMigration(from = 10, to = 11, spec = Migration10To11::class) + AutoMigration(from = 10, to = 11, spec = Migration10To11::class), + AutoMigration(from = 11, to = 12, spec = Migration11To12::class) ] ) @TypeConverters(Converters::class) @@ -314,4 +315,31 @@ class Migration9To10 : AutoMigrationSpec DeleteColumn(tableName = "artist", columnName = "description"), DeleteColumn(tableName = "artist", columnName = "createDate") ) -class Migration10To11 : AutoMigrationSpec \ No newline at end of file +class Migration10To11 : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "album", columnName = "createDate") +) +class Migration11To12 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.execSQL("UPDATE album SET bookmarkedAt = lastUpdateTime") + db.query("SELECT DISTINCT albumId, albumName FROM song").use { cursor -> + while (cursor.moveToNext()) { + val albumId = cursor.getString(0) + val albumName = cursor.getString(1) + db.insert( + table = "album", + conflictAlgorithm = SQLiteDatabase.CONFLICT_IGNORE, + values = contentValuesOf( + "id" to albumId, + "title" to albumName, + "songCount" to 0, + "duration" to 0, + "lastUpdateTime" to 0 + ) + ) + } + } + db.query("CREATE INDEX IF NOT EXISTS `index_song_albumId` ON `song` (`albumId`)") + } +} diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt index 0f1678107..a56283291 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumEntity.kt @@ -12,8 +12,13 @@ data class AlbumEntity( val title: String, val year: Int? = null, val thumbnailUrl: String? = null, + val themeColor: Int? = null, val songCount: Int, val duration: Int, - val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) \ No newline at end of file + val bookmarkedAt: LocalDateTime? = null, +) { + fun toggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt index e38a3a937..6fa79bd9e 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/ArtistEntity.kt @@ -21,6 +21,10 @@ data class ArtistEntity( val isLocalArtist: Boolean get() = id.startsWith("LA") + fun toggleLike() = copy( + bookmarkedAt = if (bookmarkedAt != null) null else LocalDateTime.now() + ) + companion object { fun generateArtistId() = "LA" + RandomStringUtils.random(8, true, false) } diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt index c5a1d3681..bbc53b145 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/SongEntity.kt @@ -2,11 +2,19 @@ package com.zionhuang.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.Entity +import androidx.room.Index import androidx.room.PrimaryKey import java.time.LocalDateTime @Immutable -@Entity(tableName = "song") +@Entity( + tableName = "song", + indices = [ + Index( + value = ["albumId"] + ) + ] +) data class SongEntity( @PrimaryKey val id: String, val title: String, diff --git a/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt b/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt new file mode 100644 index 000000000..f4baf24ed --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/extensions/UtilExt.kt @@ -0,0 +1,8 @@ +package com.zionhuang.music.extensions + +fun tryOrNull(block: () -> T): T? = + try { + block() + } catch (e: Exception) { + null + } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt index 9c1121aa2..86b791398 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt @@ -4,6 +4,7 @@ import android.content.Context import android.util.LruCache import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.utils.reportException import dagger.hilt.android.qualifiers.ApplicationContext import javax.inject.Inject @@ -28,7 +29,7 @@ class LyricsHelper @Inject constructor( ).onSuccess { lyrics -> return lyrics }.onFailure { - it.printStackTrace() + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt index 821df447c..bd127a36f 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsUtils.kt @@ -5,8 +5,8 @@ import com.zionhuang.music.ui.component.animateScrollDuration @Suppress("RegExpRedundantEscape") object LyricsUtils { - private val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)".toRegex() - private val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() + val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)".toRegex() + val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() fun parseLyrics(lyrics: String): List = lyrics.lines() diff --git a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt index 219738c80..6a63749aa 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -120,6 +120,7 @@ import com.zionhuang.music.utils.CoilBitmapLoader import com.zionhuang.music.utils.dataStore import com.zionhuang.music.utils.enumPreference import com.zionhuang.music.utils.get +import com.zionhuang.music.utils.reportException import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -134,6 +135,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.map import kotlinx.coroutines.guava.future import kotlinx.coroutines.launch @@ -711,7 +713,7 @@ class MusicService : MediaLibraryService(), } } }.onFailure { - it.printStackTrace() + reportException(it) } } @@ -831,15 +833,18 @@ class MusicService : MediaLibraryService(), LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) DOWNLOADED_PLAYLIST_ID -> { val downloads = downloadUtil.downloads.value - database.songs( - downloads.filter { (_, download) -> - download.state == Download.STATE_COMPLETED - }.keys.toList() - ).map { songs -> - songs.map { it to downloads[it.id] } - .sortedBy { it.second?.updateTimeMs ?: 0L } - .map { it.first } - } + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } } else -> database.playlistSongs(playlistId).map { list -> @@ -917,15 +922,18 @@ class MusicService : MediaLibraryService(), LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true) DOWNLOADED_PLAYLIST_ID -> { val downloads = downloadUtil.downloads.value - database.songs( - downloads.filter { (_, download) -> - download.state == Download.STATE_COMPLETED - }.keys.toList() - ).map { songs -> - songs.map { it to downloads[it.id] } - .sortedBy { it.second?.updateTimeMs ?: 0L } - .map { it.first } - } + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + songs.map { it to downloads[it.id] } + .sortedBy { it.second?.updateTimeMs ?: 0L } + .map { it.first } + } } else -> database.playlistSongs(playlistId).map { list -> diff --git a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt index 74c63cb2c..e66ef1253 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -1,5 +1,6 @@ package com.zionhuang.music.playback +import android.content.Context import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -11,23 +12,31 @@ import androidx.media3.common.Player.REPEAT_MODE_OFF import androidx.media3.common.Player.REPEAT_MODE_ONE import androidx.media3.common.Player.STATE_ENDED import androidx.media3.common.Timeline +import com.zionhuang.music.constants.TranslateLyricsKey import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.zionhuang.music.extensions.currentMetadata import com.zionhuang.music.extensions.getCurrentQueueIndex import com.zionhuang.music.extensions.getQueueWindows import com.zionhuang.music.extensions.metadata import com.zionhuang.music.playback.MusicService.MusicBinder import com.zionhuang.music.playback.queues.Queue +import com.zionhuang.music.utils.TranslationHelper +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.reportException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn @OptIn(ExperimentalCoroutinesApi::class) class PlayerConnection( + context: Context, binder: MusicBinder, val database: MusicDatabase, scope: CoroutineScope, @@ -44,9 +53,26 @@ class PlayerConnection( val currentSong = mediaMetadata.flatMapLatest { database.song(it?.id) } - val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> - database.lyrics(mediaMetadata?.id) - } + val translating = MutableStateFlow(false) + val currentLyrics = combine( + context.dataStore.data.map { + it[TranslateLyricsKey] ?: true + }.distinctUntilChanged(), + mediaMetadata.flatMapLatest { mediaMetadata -> + database.lyrics(mediaMetadata?.id) + } + ) { translateEnabled, lyrics -> + if (!translateEnabled || lyrics == null || lyrics.lyrics == LYRICS_NOT_FOUND) return@combine lyrics + translating.value = true + try { + TranslationHelper.translate(lyrics) + } catch (e: Exception) { + reportException(e) + lyrics + }.also { + translating.value = false + } + }.stateIn(scope, SharingStarted.Lazily, null) val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> database.format(mediaMetadata?.id) } @@ -148,6 +174,9 @@ class PlayerConnection( } override fun onPlayerErrorChanged(playbackError: PlaybackException?) { + if (playbackError != null) { + reportException(playbackError) + } error.value = playbackError } diff --git a/app/src/main/java/com/zionhuang/music/ui/component/ChipsRow.kt b/app/src/main/java/com/zionhuang/music/ui/component/ChipsRow.kt new file mode 100644 index 000000000..fafaa2952 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/ChipsRow.kt @@ -0,0 +1,44 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun ChipsRow( + chips: List>, + currentValue: E, + onValueUpdate: (E) -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier = modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Spacer(Modifier.width(12.dp)) + + chips.forEach { (value, label) -> + FilterChip( + label = { Text(label) }, + selected = currentValue == value, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), + onClick = { onValueUpdate(value) } + ) + + Spacer(Modifier.width(8.dp)) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt index 1d35bf1aa..88045a713 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/Items.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/Items.kt @@ -31,12 +31,15 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.text.font.FontWeight @@ -45,11 +48,14 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEachIndexed +import androidx.core.graphics.drawable.toBitmapOrNull import androidx.media3.exoplayer.offline.Download import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED import androidx.media3.exoplayer.offline.Download.STATE_DOWNLOADING import androidx.media3.exoplayer.offline.Download.STATE_QUEUED import coil.compose.AsyncImage +import coil.compose.AsyncImagePainter +import coil.request.ImageRequest import com.zionhuang.innertube.models.AlbumItem import com.zionhuang.innertube.models.ArtistItem import com.zionhuang.innertube.models.PlaylistItem @@ -67,8 +73,11 @@ import com.zionhuang.music.db.entities.Artist import com.zionhuang.music.db.entities.Playlist import com.zionhuang.music.db.entities.Song import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.ui.theme.extractThemeColor import com.zionhuang.music.utils.joinByBullet import com.zionhuang.music.utils.makeTimeString +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @Composable inline fun ListItem( @@ -302,17 +311,8 @@ fun ArtistListItem( fun AlbumListItem( album: Album, modifier: Modifier = Modifier, - isActive: Boolean = false, - isPlaying: Boolean = false, - trailingContent: @Composable RowScope.() -> Unit = {}, -) = ListItem( - title = album.album.title, - subtitle = joinByBullet( - album.artists.joinToString { it.name }, - pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), - album.album.year?.toString() - ), - badges = { + showLikedIcon: Boolean = true, + badges: @Composable RowScope.() -> Unit = { val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current var songs by remember { @@ -346,6 +346,17 @@ fun AlbumListItem( } } + if (showLikedIcon && album.album.bookmarkedAt != null) { + Icon( + painter = painterResource(R.drawable.favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + when (downloadState) { STATE_COMPLETED -> Icon( painter = painterResource(R.drawable.offline), @@ -365,10 +376,38 @@ fun AlbumListItem( else -> {} } }, + isActive: Boolean = false, + isPlaying: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = album.album.title, + subtitle = joinByBullet( + album.artists.joinToString { it.name }, + pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), + album.album.year?.toString() + ), + badges = badges, thumbnailContent = { + val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + AsyncImage( - model = album.album.thumbnailUrl, + model = ImageRequest.Builder(LocalContext.current) + .data(album.album.thumbnailUrl) + .allowHardware(false) + .build(), contentDescription = null, + onState = { state -> + if (album.album.themeColor == null && state is AsyncImagePainter.State.Success) { + coroutineScope.launch(Dispatchers.IO) { + state.result.drawable.toBitmapOrNull()?.extractThemeColor()?.toArgb()?.let { color -> + database.query { + update(album.album.copy(themeColor = color)) + } + } + } + } + }, modifier = Modifier .size(ListThumbnailSize) .clip(RoundedCornerShape(ThumbnailCornerRadius)) @@ -487,9 +526,10 @@ fun YouTubeListItem( val database = LocalDatabase.current val song by database.song(item.id).collectAsState(initial = null) val album by database.album(item.id).collectAsState(initial = null) - val playlist by database.playlist(item.id).collectAsState(initial = null) - if (item is SongItem && song?.song?.liked == true) { + if (item is SongItem && song?.song?.liked == true || + item is AlbumItem && album?.album?.bookmarkedAt != null + ) { Icon( painter = painterResource(R.drawable.favorite), contentDescription = null, @@ -508,10 +548,7 @@ fun YouTubeListItem( .padding(end = 2.dp) ) } - if (item is SongItem && song?.song?.inLibrary != null || - item is AlbumItem && album != null || - item is PlaylistItem && playlist != null - ) { + if (item is SongItem && song?.song?.inLibrary != null) { Icon( painter = painterResource(R.drawable.library_add_check), contentDescription = null, @@ -606,9 +643,10 @@ fun YouTubeGridItem( val database = LocalDatabase.current val song by database.song(item.id).collectAsState(initial = null) val album by database.album(item.id).collectAsState(initial = null) - val playlist by database.playlist(item.id).collectAsState(initial = null) - if (item is SongItem && song?.song?.liked == true) { + if (item is SongItem && song?.song?.liked == true || + item is AlbumItem && album?.album?.bookmarkedAt != null + ) { Icon( painter = painterResource(R.drawable.favorite), contentDescription = null, @@ -627,10 +665,7 @@ fun YouTubeGridItem( .padding(end = 2.dp) ) } - if (item is SongItem && song?.song?.inLibrary != null || - item is AlbumItem && album != null || - item is PlaylistItem && playlist != null - ) { + if (item is SongItem && song?.song?.inLibrary != null) { Icon( painter = painterResource(R.drawable.library_add_check), contentDescription = null, diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt b/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt index d315665ef..ae8e3acad 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/Lyrics.kt @@ -1,18 +1,34 @@ package com.zionhuang.music.ui.component -import android.app.SearchManager -import android.content.Intent -import androidx.compose.animation.animateContentSize import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.add +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState -import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -20,35 +36,31 @@ import androidx.compose.ui.geometry.Offset import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.input.nestedscroll.nestedScroll -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalDensity import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight -import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextAlign -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.Velocity import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel -import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.BuildConfig import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.LyricsTextPositionKey -import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.constants.TranslateLyricsKey import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.zionhuang.music.lyrics.LyricsEntry import com.zionhuang.music.lyrics.LyricsEntry.Companion.HEAD_LYRICS_ENTRY import com.zionhuang.music.lyrics.LyricsUtils.findCurrentLineIndex import com.zionhuang.music.lyrics.LyricsUtils.parseLyrics -import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.component.shimmer.TextPlaceholder +import com.zionhuang.music.ui.menu.LyricsMenu import com.zionhuang.music.ui.screens.settings.LyricsPosition import com.zionhuang.music.ui.utils.fadingEdge import com.zionhuang.music.utils.rememberEnumPreference -import com.zionhuang.music.viewmodels.LyricsMenuViewModel +import com.zionhuang.music.utils.rememberPreference import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlin.time.Duration.Companion.seconds @@ -63,11 +75,14 @@ fun Lyrics( val density = LocalDensity.current val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) + var translationEnabled by rememberPreference(TranslateLyricsKey, false) val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val translating by playerConnection.translating.collectAsState() val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) - val lyrics = remember(lyricsEntity) { - lyricsEntity?.lyrics + val lyrics = remember(lyricsEntity, translating) { + if (translating) null + else lyricsEntity?.lyrics } val lines = remember(lyrics) { @@ -162,31 +177,8 @@ fun Lyrics( }) ) { val displayedCurrentLineIndex = if (isSeeking) deferredCurrentLineIndex else currentLineIndex - itemsIndexed( - items = lines - ) { index, item -> - Text( - text = item.text, - fontSize = 20.sp, - color = if (index == displayedCurrentLineIndex) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, - textAlign = when (lyricsTextPosition) { - LyricsPosition.LEFT -> TextAlign.Left - LyricsPosition.CENTER -> TextAlign.Center - LyricsPosition.RIGHT -> TextAlign.Right - }, - fontWeight = FontWeight.Bold, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = isSynced) { - playerConnection.player.seekTo(item.time) - lastPreviewTime = 0L - } - .padding(horizontal = 24.dp, vertical = 8.dp) - .alpha(if (!isSynced || index == displayedCurrentLineIndex) 1f else 0.5f) - ) - } - if (lyrics == null) { + if (lyrics == null || translating) { item { ShimmerHost { repeat(10) { @@ -205,6 +197,30 @@ fun Lyrics( } } } + } else { + itemsIndexed( + items = lines + ) { index, item -> + Text( + text = item.text, + fontSize = 20.sp, + color = if (index == displayedCurrentLineIndex) MaterialTheme.colorScheme.primary else MaterialTheme.colorScheme.secondary, + textAlign = when (lyricsTextPosition) { + LyricsPosition.LEFT -> TextAlign.Left + LyricsPosition.CENTER -> TextAlign.Center + LyricsPosition.RIGHT -> TextAlign.Right + }, + fontWeight = FontWeight.Bold, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = isSynced) { + playerConnection.player.seekTo(item.time) + lastPreviewTime = 0L + } + .padding(horizontal = 24.dp, vertical = 8.dp) + .alpha(if (!isSynced || index == displayedCurrentLineIndex) 1f else 0.5f) + ) + } } } @@ -227,279 +243,44 @@ fun Lyrics( } mediaMetadata?.let { mediaMetadata -> - IconButton( + Row( modifier = Modifier .align(Alignment.BottomEnd) - .padding(12.dp), - onClick = { - menuState.show { - LyricsMenu( - lyricsProvider = { lyricsEntity }, - mediaMetadataProvider = { mediaMetadata }, - onDismiss = menuState::dismiss - ) - } - } + .padding(end = 12.dp) ) { - Icon( - painter = painterResource(id = R.drawable.more_horiz), - contentDescription = null - ) - } - } - } -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun LyricsMenu( - lyricsProvider: () -> LyricsEntity?, - mediaMetadataProvider: () -> MediaMetadata, - onDismiss: () -> Unit, - viewModel: LyricsMenuViewModel = hiltViewModel(), -) { - val context = LocalContext.current - val database = LocalDatabase.current - - var showEditDialog by rememberSaveable { - mutableStateOf(false) - } - - if (showEditDialog) { - TextFieldDialog( - onDismiss = { showEditDialog = false }, - icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, - title = { Text(text = mediaMetadataProvider().title) }, - initialTextFieldValue = TextFieldValue(lyricsProvider()?.lyrics.orEmpty()), - singleLine = false, - onDone = { - database.query { - upsert( - LyricsEntity( - id = mediaMetadataProvider().id, - lyrics = it - ) - ) - } - } - ) - } - - var showSearchDialog by rememberSaveable { - mutableStateOf(false) - } - var showSearchResultDialog by rememberSaveable { - mutableStateOf(false) - } - - val searchMediaMetadata = remember(showSearchDialog) { - mediaMetadataProvider() - } - val (titleField, onTitleFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue( - text = mediaMetadataProvider().title - ) - ) - } - val (artistField, onArtistFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { - mutableStateOf( - TextFieldValue( - text = mediaMetadataProvider().artists.joinToString { it.name } - ) - ) - } - - if (showSearchDialog) { - DefaultDialog( - modifier = Modifier.verticalScroll(rememberScrollState()), - onDismiss = { showSearchDialog = false }, - icon = { Icon(painter = painterResource(R.drawable.search), contentDescription = null) }, - title = { Text(stringResource(R.string.search_lyrics)) }, - buttons = { - TextButton( - onClick = { showSearchDialog = false } - ) { - Text(stringResource(android.R.string.cancel)) - } - - Spacer(Modifier.width(8.dp)) - - TextButton( - onClick = { - showSearchDialog = false - onDismiss() - try { - context.startActivity( - Intent(Intent.ACTION_WEB_SEARCH).apply { - putExtra(SearchManager.QUERY, "${artistField.text} ${titleField.text} lyrics") - } - ) - } catch (_: Exception) { - } - } - ) { - Text(stringResource(R.string.search_online)) - } - - Spacer(Modifier.width(8.dp)) - - TextButton( - onClick = { - viewModel.search(searchMediaMetadata.id, titleField.text, artistField.text, searchMediaMetadata.duration) - showSearchResultDialog = true - } - ) { - Text(stringResource(android.R.string.ok)) - } - } - ) { - OutlinedTextField( - value = titleField, - onValueChange = onTitleFieldChange, - singleLine = true, - label = { Text(stringResource(R.string.song_title)) } - ) - - Spacer(Modifier.height(12.dp)) - - OutlinedTextField( - value = artistField, - onValueChange = onArtistFieldChange, - singleLine = true, - label = { Text(stringResource(R.string.song_artists)) } - ) - } - } - - if (showSearchResultDialog) { - val results by viewModel.results.collectAsState() - val isLoading by viewModel.isLoading.collectAsState() - - var expandedItemIndex by rememberSaveable { - mutableStateOf(-1) - } - - ListDialog( - onDismiss = { showSearchResultDialog = false } - ) { - itemsIndexed(results) { index, result -> - Row( - modifier = Modifier - .fillMaxWidth() - .clickable { - onDismiss() - viewModel.cancelSearch() - database.query { - upsert( - LyricsEntity( - id = searchMediaMetadata.id, - lyrics = result.lyrics - ) - ) - } - } - .padding(12.dp) - .animateContentSize() - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = result.lyrics, - style = MaterialTheme.typography.bodyMedium, - maxLines = if (index == expandedItemIndex) Int.MAX_VALUE else 2, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.padding(bottom = 4.dp) - ) - - Row( - verticalAlignment = Alignment.CenterVertically - ) { - Text( - text = result.providerName, - style = MaterialTheme.typography.labelMedium, - color = MaterialTheme.colorScheme.secondary, - maxLines = 1 - ) - if (result.lyrics.startsWith("[")) { - Icon( - painter = painterResource(R.drawable.sync), - contentDescription = null, - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .padding(start = 4.dp) - .size(18.dp) - ) - } - } - } - + if (BuildConfig.FLAVOR == "full") { IconButton( onClick = { - expandedItemIndex = if (expandedItemIndex == index) -1 else index + translationEnabled = !translationEnabled } ) { Icon( - painter = painterResource(if (index == expandedItemIndex) R.drawable.expand_less else R.drawable.expand_more), - contentDescription = null + painter = painterResource(id = R.drawable.translate), + contentDescription = null, + tint = LocalContentColor.current.copy(alpha = if (translationEnabled) 1f else 0.3f) ) } } - } - if (isLoading) { - item { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier.fillMaxWidth() - ) { - CircularProgressIndicator() + IconButton( + onClick = { + menuState.show { + LyricsMenu( + lyricsProvider = { lyricsEntity }, + mediaMetadataProvider = { mediaMetadata }, + onDismiss = menuState::dismiss + ) + } } - } - } - - if (!isLoading && results.isEmpty()) { - item { - Text( - text = context.getString(R.string.lyrics_not_found), - textAlign = TextAlign.Center, - modifier = Modifier - .fillMaxWidth() + ) { + Icon( + painter = painterResource(id = R.drawable.more_horiz), + contentDescription = null ) } } } } - - GridMenu( - contentPadding = PaddingValues( - start = 8.dp, - top = 8.dp, - end = 8.dp, - bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() - ) - ) { - GridMenuItem( - icon = R.drawable.edit, - title = R.string.edit - ) { - showEditDialog = true - } - GridMenuItem( - icon = R.drawable.cached, - title = R.string.refetch - ) { - onDismiss() - viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) - } - GridMenuItem( - icon = R.drawable.search, - title = R.string.search, - ) { - showSearchDialog = true - } - } } const val animateScrollDuration = 300L diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt index 0dfa5b39b..3ab27542b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/AlbumMenu.kt @@ -13,9 +13,15 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember @@ -25,6 +31,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -49,6 +56,7 @@ import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.ExoDownloadService import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.ui.component.AlbumListItem import com.zionhuang.music.ui.component.DownloadGridMenu import com.zionhuang.music.ui.component.GridMenu import com.zionhuang.music.ui.component.GridMenuItem @@ -56,15 +64,16 @@ import com.zionhuang.music.ui.component.ListDialog @Composable fun AlbumMenu( - album: Album, + originalAlbum: Album, navController: NavController, playerConnection: PlayerConnection, - showDeleteButton: Boolean = true, onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current + val libraryAlbum by database.album(originalAlbum.id).collectAsState(initial = originalAlbum) + val album = libraryAlbum ?: originalAlbum var songs by remember { mutableStateOf(emptyList()) } @@ -171,6 +180,29 @@ fun AlbumMenu( } } + AlbumListItem( + album = album, + showLikedIcon = false, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + update(album.album.toggleLike()) + } + } + ) { + Icon( + painter = painterResource(if (album.album.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (album.album.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + + Divider() + GridMenu( contentPadding = PaddingValues( start = 8.dp, @@ -249,16 +281,5 @@ fun AlbumMenu( } context.startActivity(Intent.createChooser(intent, null)) } - if (showDeleteButton) { - GridMenuItem( - icon = R.drawable.delete, - title = R.string.delete - ) { - onDismiss() - database.query { - delete(album.album) - } - } - } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/LyricsMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/LyricsMenu.kt new file mode 100644 index 000000000..33d48d2e6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/LyricsMenu.kt @@ -0,0 +1,310 @@ +package com.zionhuang.music.ui.menu + +import android.app.SearchManager +import android.content.Intent +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.CircularProgressIndicator +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.R +import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.ui.component.DefaultDialog +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog +import com.zionhuang.music.ui.component.TextFieldDialog +import com.zionhuang.music.viewmodels.LyricsMenuViewModel + + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun LyricsMenu( + lyricsProvider: () -> LyricsEntity?, + mediaMetadataProvider: () -> MediaMetadata, + onDismiss: () -> Unit, + viewModel: LyricsMenuViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val database = LocalDatabase.current + + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + onDismiss = { showEditDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.edit), contentDescription = null) }, + title = { Text(text = mediaMetadataProvider().title) }, + initialTextFieldValue = TextFieldValue(lyricsProvider()?.lyrics.orEmpty()), + singleLine = false, + onDone = { + database.query { + upsert( + LyricsEntity( + id = mediaMetadataProvider().id, + lyrics = it + ) + ) + } + } + ) + } + + var showSearchDialog by rememberSaveable { + mutableStateOf(false) + } + var showSearchResultDialog by rememberSaveable { + mutableStateOf(false) + } + + val searchMediaMetadata = remember(showSearchDialog) { + mediaMetadataProvider() + } + val (titleField, onTitleFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().title + ) + ) + } + val (artistField, onArtistFieldChange) = rememberSaveable(showSearchDialog, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = mediaMetadataProvider().artists.joinToString { it.name } + ) + ) + } + + if (showSearchDialog) { + DefaultDialog( + modifier = Modifier.verticalScroll(rememberScrollState()), + onDismiss = { showSearchDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.search), contentDescription = null) }, + title = { Text(stringResource(R.string.search_lyrics)) }, + buttons = { + TextButton( + onClick = { showSearchDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + showSearchDialog = false + onDismiss() + try { + context.startActivity( + Intent(Intent.ACTION_WEB_SEARCH).apply { + putExtra(SearchManager.QUERY, "${artistField.text} ${titleField.text} lyrics") + } + ) + } catch (_: Exception) { + } + } + ) { + Text(stringResource(R.string.search_online)) + } + + Spacer(Modifier.width(8.dp)) + + TextButton( + onClick = { + viewModel.search(searchMediaMetadata.id, titleField.text, artistField.text, searchMediaMetadata.duration) + showSearchResultDialog = true + } + ) { + Text(stringResource(android.R.string.ok)) + } + } + ) { + OutlinedTextField( + value = titleField, + onValueChange = onTitleFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_title)) } + ) + + Spacer(Modifier.height(12.dp)) + + OutlinedTextField( + value = artistField, + onValueChange = onArtistFieldChange, + singleLine = true, + label = { Text(stringResource(R.string.song_artists)) } + ) + } + } + + if (showSearchResultDialog) { + val results by viewModel.results.collectAsState() + val isLoading by viewModel.isLoading.collectAsState() + + var expandedItemIndex by rememberSaveable { + mutableStateOf(-1) + } + + ListDialog( + onDismiss = { showSearchResultDialog = false } + ) { + itemsIndexed(results) { index, result -> + Row( + modifier = Modifier + .fillMaxWidth() + .clickable { + onDismiss() + viewModel.cancelSearch() + database.query { + upsert( + LyricsEntity( + id = searchMediaMetadata.id, + lyrics = result.lyrics + ) + ) + } + } + .padding(12.dp) + .animateContentSize() + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = result.lyrics, + style = MaterialTheme.typography.bodyMedium, + maxLines = if (index == expandedItemIndex) Int.MAX_VALUE else 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.padding(bottom = 4.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically + ) { + Text( + text = result.providerName, + style = MaterialTheme.typography.labelMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1 + ) + if (result.lyrics.startsWith("[")) { + Icon( + painter = painterResource(R.drawable.sync), + contentDescription = null, + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding(start = 4.dp) + .size(18.dp) + ) + } + } + } + + IconButton( + onClick = { + expandedItemIndex = if (expandedItemIndex == index) -1 else index + } + ) { + Icon( + painter = painterResource(if (index == expandedItemIndex) R.drawable.expand_less else R.drawable.expand_more), + contentDescription = null + ) + } + } + } + + if (isLoading) { + item { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.fillMaxWidth() + ) { + CircularProgressIndicator() + } + } + } + + if (!isLoading && results.isEmpty()) { + item { + Text( + text = context.getString(R.string.lyrics_not_found), + textAlign = TextAlign.Center, + modifier = Modifier + .fillMaxWidth() + ) + } + } + } + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.edit, + title = R.string.edit + ) { + showEditDialog = true + } + GridMenuItem( + icon = R.drawable.cached, + title = R.string.refetch + ) { + onDismiss() + viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) + } + GridMenuItem( + icon = R.drawable.search, + title = R.string.search, + ) { + showSearchDialog = true + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt index 057e75b66..963d2acd8 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt @@ -1,7 +1,6 @@ package com.zionhuang.music.ui.menu import android.content.Intent -import android.content.res.Configuration.ORIENTATION_LANDSCAPE import android.media.audiofx.AudioEffect import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -9,6 +8,7 @@ import androidx.annotation.DrawableRes import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.WindowInsets @@ -18,11 +18,14 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -32,14 +35,15 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalConfiguration import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.window.DialogProperties import androidx.core.net.toUri import androidx.media3.common.PlaybackParameters import androidx.media3.exoplayer.offline.DownloadRequest @@ -47,6 +51,7 @@ import androidx.media3.exoplayer.offline.DownloadService import androidx.navigation.NavController import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.db.entities.PlaylistSongMap @@ -75,7 +80,6 @@ fun PlayerMenu( mediaMetadata ?: return val context = LocalContext.current val database = LocalDatabase.current - val localConfiguration = LocalConfiguration.current val playerVolume = playerConnection.service.playerVolume.collectAsState() val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } @@ -138,14 +142,14 @@ fun PlayerMenu( } } - var tempo by remember { - mutableStateOf(playerConnection.player.playbackParameters.speed) - } - var transposeValue by remember { - mutableStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt()) + var showPitchTempoDialog by rememberSaveable { + mutableStateOf(false) } - val updatePlaybackParameters = { - playerConnection.player.playbackParameters = PlaybackParameters(tempo, 2f.pow(transposeValue.toFloat() / 12)) + + if (showPitchTempoDialog) { + PitchTempoDialog( + onDismiss = { showPitchTempoDialog = false } + ) } Row( @@ -169,65 +173,6 @@ fun PlayerMenu( ) } - if (localConfiguration.orientation == ORIENTATION_LANDSCAPE) { - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp) - ) { - ValueAdjuster( - icon = R.drawable.slow_motion_video, - currentValue = tempo, - values = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f), - onValueUpdate = { - tempo = it - updatePlaybackParameters() - }, - valueText = { "x$it" }, - modifier = Modifier.weight(1f) - ) - - ValueAdjuster( - icon = R.drawable.tune, - currentValue = transposeValue, - values = (-12..12).toList(), - onValueUpdate = { - transposeValue = it - updatePlaybackParameters() - }, - valueText = { "${if (it > 0) "+" else ""}$it" }, - modifier = Modifier.weight(1f) - ) - } - } else { - ValueAdjuster( - icon = R.drawable.slow_motion_video, - currentValue = tempo, - values = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f), - onValueUpdate = { - tempo = it - updatePlaybackParameters() - }, - valueText = { "x$it" }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 6.dp) - ) - - ValueAdjuster( - icon = R.drawable.tune, - currentValue = transposeValue, - values = (-12..12).toList(), - onValueUpdate = { - transposeValue = it - updatePlaybackParameters() - }, - valueText = { "${if (it > 0) "+" else ""}$it" }, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp, vertical = 6.dp) - ) - } - GridMenu( contentPadding = PaddingValues( start = 8.dp, @@ -330,9 +275,79 @@ fun PlayerMenu( } onDismiss() } + GridMenuItem( + icon = R.drawable.tune, + title = R.string.advanced + ) { + showPitchTempoDialog = true + } } } +@Composable +fun PitchTempoDialog( + onDismiss: () -> Unit, +) { + val playerConnection = LocalPlayerConnection.current ?: return + var tempo by remember { + mutableStateOf(playerConnection.player.playbackParameters.speed) + } + var transposeValue by remember { + mutableStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt()) + } + val updatePlaybackParameters = { + playerConnection.player.playbackParameters = PlaybackParameters(tempo, 2f.pow(transposeValue.toFloat() / 12)) + } + + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = onDismiss, + dismissButton = { + TextButton( + onClick = { + tempo = 1f + transposeValue = 0 + updatePlaybackParameters() + } + ) { + Text(stringResource(R.string.reset)) + } + }, + confirmButton = { + TextButton( + onClick = onDismiss + ) { + Text(stringResource(android.R.string.ok)) + } + }, + text = { + Column { + ValueAdjuster( + icon = R.drawable.slow_motion_video, + currentValue = tempo, + values = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f), + onValueUpdate = { + tempo = it + updatePlaybackParameters() + }, + valueText = { "x$it" }, + modifier = Modifier.padding(bottom = 12.dp) + ) + ValueAdjuster( + icon = R.drawable.discover_tune, + currentValue = transposeValue, + values = (-12..12).toList(), + onValueUpdate = { + transposeValue = it + updatePlaybackParameters() + }, + valueText = { "${if (it > 0) "+" else ""}$it" } + ) + } + } + ) +} + @Composable fun ValueAdjuster( @DrawableRes icon: Int, @@ -340,7 +355,7 @@ fun ValueAdjuster( values: List, onValueUpdate: (T) -> Unit, valueText: (T) -> String, - modifier: Modifier, + modifier: Modifier = Modifier, ) { Row( horizontalArrangement = Arrangement.spacedBy(24.dp), @@ -369,7 +384,7 @@ fun ValueAdjuster( text = valueText(currentValue), style = MaterialTheme.typography.titleMedium, textAlign = TextAlign.Center, - modifier = Modifier.weight(1f) + modifier = Modifier.width(80.dp) ) IconButton( diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt index af363a5a4..e4d94cc90 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -178,7 +179,7 @@ fun SongMenu( ) { Icon( painter = painterResource(if (song.song.liked) R.drawable.favorite else R.drawable.favorite_border), - tint = MaterialTheme.colorScheme.error, + tint = if (song.song.liked) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt index 3fd63d30f..711b718df 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeAlbumMenu.kt @@ -11,6 +11,11 @@ import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.lazy.items +import androidx.compose.material3.Divider +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect @@ -23,6 +28,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp @@ -34,14 +40,12 @@ import androidx.media3.exoplayer.offline.DownloadService import androidx.navigation.NavController import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem -import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.ExoDownloadService import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeAlbumRadio @@ -49,10 +53,12 @@ import com.zionhuang.music.ui.component.DownloadGridMenu import com.zionhuang.music.ui.component.GridMenu import com.zionhuang.music.ui.component.GridMenuItem import com.zionhuang.music.ui.component.ListDialog +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.utils.reportException @Composable fun YouTubeAlbumMenu( - album: AlbumItem, + albumItem: AlbumItem, navController: NavController, playerConnection: PlayerConnection, onDismiss: () -> Unit, @@ -60,14 +66,19 @@ fun YouTubeAlbumMenu( val context = LocalContext.current val database = LocalDatabase.current val downloadUtil = LocalDownloadUtil.current - val libraryAlbum by database.album(album.id).collectAsState(initial = null) - var albumPage: AlbumPage? by remember { - mutableStateOf(null) - } + val album by database.albumWithSongs(albumItem.id).collectAsState(initial = null) LaunchedEffect(Unit) { - YouTube.album(album.browseId).onSuccess { - albumPage = it + database.album(albumItem.id).collect { album -> + if (album == null) { + YouTube.album(albumItem.id).onSuccess { albumPage -> + database.transaction { + insert(albumPage) + } + }.onFailure { + reportException(it) + } + } } } @@ -75,8 +86,8 @@ fun YouTubeAlbumMenu( mutableStateOf(Download.STATE_STOPPED) } - LaunchedEffect(albumPage) { - val songs = albumPage?.songs?.map { it.id } ?: return@LaunchedEffect + LaunchedEffect(album) { + val songs = album?.songs?.map { it.id } ?: return@LaunchedEffect downloadUtil.downloads.collect { downloads -> downloadState = if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) @@ -101,19 +112,14 @@ fun YouTubeAlbumMenu( onAdd = { playlist -> var position = playlist.songCount database.transaction { - albumPage?.let { albumPage -> - albumPage.songs - .map { it.toMediaMetadata() } - .onEach(::insert) - .forEach { song -> - insert( - PlaylistSongMap( - songId = song.id, - playlistId = playlist.id, - position = position++ - ) - ) - } + album?.songs?.forEach { song -> + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position++ + ) + ) } } }, @@ -129,8 +135,8 @@ fun YouTubeAlbumMenu( onDismiss = { showSelectArtistDialog = false } ) { items( - items = album.artists.orEmpty(), - key = { it.id!! } + items = album?.artists.orEmpty(), + key = { it.id } ) { artist -> Row( verticalAlignment = Alignment.CenterVertically, @@ -168,6 +174,28 @@ fun YouTubeAlbumMenu( } } + YouTubeListItem( + item = albumItem, + badges = {}, + trailingContent = { + IconButton( + onClick = { + database.query { + album?.album?.toggleLike()?.let(::update) + } + } + ) { + Icon( + painter = painterResource(if (album?.album?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (album?.album?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, + contentDescription = null + ) + } + } + ) + + Divider() + GridMenu( contentPadding = PaddingValues( start = 8.dp, @@ -180,14 +208,14 @@ fun YouTubeAlbumMenu( icon = R.drawable.radio, title = R.string.start_radio ) { - playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + playerConnection.playQueue(YouTubeAlbumRadio(albumItem.playlistId)) onDismiss() } GridMenuItem( icon = R.drawable.playlist_play, title = R.string.play_next ) { - albumPage?.songs + album?.songs ?.map { it.toMediaItem() } ?.let(playerConnection::playNext) onDismiss() @@ -196,31 +224,11 @@ fun YouTubeAlbumMenu( icon = R.drawable.queue_music, title = R.string.add_to_queue ) { - albumPage?.songs + album?.songs ?.map { it.toMediaItem() } ?.let(playerConnection::addToQueue) onDismiss() } - if (libraryAlbum != null) { - GridMenuItem( - icon = R.drawable.library_add_check, - title = R.string.remove_from_library - ) { - database.query { - libraryAlbum?.album?.let(::delete) - } - } - } else { - GridMenuItem( - icon = R.drawable.library_add, - title = R.string.add_to_library - ) { - database.transaction { - albumPage?.let(::insert) - } - } - } - GridMenuItem( icon = R.drawable.playlist_add, title = R.string.add_to_playlist @@ -230,10 +238,10 @@ fun YouTubeAlbumMenu( DownloadGridMenu( state = downloadState, onDownload = { - albumPage?.songs?.forEach { song -> + album?.songs?.forEach { song -> val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) .setCustomCacheKey(song.id) - .setData(song.title.toByteArray()) + .setData(song.song.title.toByteArray()) .build() DownloadService.sendAddDownload( context, @@ -244,7 +252,7 @@ fun YouTubeAlbumMenu( } }, onRemoveDownload = { - albumPage?.songs?.forEach { song -> + album?.songs?.forEach { song -> DownloadService.sendRemoveDownload( context, ExoDownloadService::class.java, @@ -254,7 +262,7 @@ fun YouTubeAlbumMenu( } } ) - album.artists?.let { artists -> + albumItem.artists?.let { artists -> GridMenuItem( icon = R.drawable.artist, title = R.string.view_artist @@ -274,7 +282,7 @@ fun YouTubeAlbumMenu( val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" - putExtra(Intent.EXTRA_TEXT, album.shareLink) + putExtra(Intent.EXTRA_TEXT, albumItem.shareLink) } context.startActivity(Intent.createChooser(intent, null)) onDismiss() diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt index 705a86b10..cee8308e3 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubePlaylistMenu.kt @@ -125,7 +125,7 @@ fun YouTubePlaylistMenu( icon = R.drawable.queue_music, title = R.string.add_to_queue ) { - coroutineScope.launch(Dispatchers.IO) { + coroutineScope.launch { songs.ifEmpty { withContext(Dispatchers.IO) { YouTube.playlist(playlist.id).completed().getOrNull()?.songs.orEmpty() diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt index 655b932a0..fb9c4a0a4 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeSongMenu.kt @@ -16,6 +16,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Divider import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -180,7 +181,7 @@ fun YouTubeSongMenu( ) { Icon( painter = painterResource(if (librarySong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border), - tint = MaterialTheme.colorScheme.error, + tint = if (librarySong?.song?.liked == true) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt b/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt index badff73af..6e6d9fe3f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt @@ -2,9 +2,7 @@ package com.zionhuang.music.ui.player import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background -import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -18,7 +16,6 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -26,11 +23,8 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -47,15 +41,9 @@ import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.MiniPlayerHeight import com.zionhuang.music.constants.ThumbnailCornerRadius -import com.zionhuang.music.extensions.metadata import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.ui.utils.HorizontalPager -import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider -import kotlinx.coroutines.flow.drop -import kotlin.math.abs -@OptIn(ExperimentalFoundationApi::class) @Composable fun MiniPlayer( position: Long, @@ -66,40 +54,8 @@ fun MiniPlayer( val isPlaying by playerConnection.isPlaying.collectAsState() val playbackState by playerConnection.playbackState.collectAsState() val error by playerConnection.error.collectAsState() - val windows by playerConnection.queueWindows.collectAsState() - val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() - - val pagerState = rememberPagerState( - initialPage = currentWindowIndex.takeIf { it != -1 } ?: 0 - ) - - val snapLayoutInfoProvider = remember(pagerState) { - SnapLayoutInfoProvider( - pagerState = pagerState, - positionInLayout = { _, _ -> 0f } - ) - } - - LaunchedEffect(pagerState, currentWindowIndex) { - if (windows.isNotEmpty()) { - try { - if (abs(pagerState.currentPage - currentWindowIndex) <= 1) { - pagerState.animateScrollToPage(currentWindowIndex) - } else { - pagerState.scrollToPage(currentWindowIndex) - } - } catch (_: Exception) { - } - } - } - - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.drop(1).collect { index -> - if (!pagerState.isScrollInProgress && index != currentWindowIndex && windows.isNotEmpty()) { - playerConnection.player.seekToDefaultPosition(windows[index].firstPeriodIndex) - } - } - } + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val canSkipNext by playerConnection.canSkipNext.collectAsState() Box( modifier = modifier @@ -118,17 +74,10 @@ fun MiniPlayer( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxSize() - .padding(end = 12.dp), + .padding(end = 6.dp), ) { - HorizontalPager( - state = pagerState, - flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), - items = windows, - key = { it.uid.hashCode() }, - beyondBoundsPageCount = 2, - modifier = Modifier.weight(1f) - ) { window -> - window.mediaItem.metadata?.let { + Box(Modifier.weight(1f)) { + mediaMetadata?.let { MiniMediaInfo( mediaMetadata = it, error = error, @@ -152,6 +101,16 @@ fun MiniPlayer( contentDescription = null ) } + + IconButton( + enabled = canSkipNext, + onClick = playerConnection.player::seekToNext + ) { + Icon( + painter = painterResource(R.drawable.skip_next), + contentDescription = null + ) + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Player.kt b/app/src/main/java/com/zionhuang/music/ui/player/Player.kt index f5766cbfb..934411645 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Player.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Player.kt @@ -22,6 +22,7 @@ import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.NavigationBarDefaults import androidx.compose.material3.Slider @@ -215,7 +216,7 @@ fun BottomSheetPlayer( Box(modifier = Modifier.weight(1f)) { ResizableIconButton( icon = if (currentSong?.song?.liked == true) R.drawable.favorite else R.drawable.favorite_border, - color = MaterialTheme.colorScheme.error, + color = if (currentSong?.song?.liked == true) MaterialTheme.colorScheme.error else LocalContentColor.current, modifier = Modifier .size(32.dp) .padding(4.dp) diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt b/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt index 8950e644a..ad549194b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Thumbnail.kt @@ -3,13 +3,17 @@ package com.zionhuang.music.ui.player import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut -import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.detectTapGestures -import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.pager.rememberPagerState +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -20,15 +24,9 @@ import coil.compose.AsyncImage import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.constants.ShowLyricsKey import com.zionhuang.music.constants.ThumbnailCornerRadius -import com.zionhuang.music.extensions.metadata import com.zionhuang.music.ui.component.Lyrics -import com.zionhuang.music.ui.utils.HorizontalPager -import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider import com.zionhuang.music.utils.rememberPreference -import kotlinx.coroutines.flow.drop -import kotlin.math.abs -@OptIn(ExperimentalFoundationApi::class) @Composable fun Thumbnail( sliderPositionProvider: () -> Long?, @@ -37,50 +35,11 @@ fun Thumbnail( val playerConnection = LocalPlayerConnection.current ?: return val currentView = LocalView.current - val windows by playerConnection.queueWindows.collectAsState() - val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val error by playerConnection.error.collectAsState() val showLyrics by rememberPreference(ShowLyricsKey, false) - val pagerState = rememberPagerState( - initialPage = currentWindowIndex.takeIf { it != -1 } ?: 0 - ) - - val snapLayoutInfoProvider = remember(pagerState) { - SnapLayoutInfoProvider( - pagerState = pagerState, - positionInLayout = { _, _ -> 0f } - ) - } - - LaunchedEffect(pagerState, currentWindowIndex) { - if (windows.isNotEmpty()) { - try { - if (abs(pagerState.currentPage - currentWindowIndex) <= 1) { - pagerState.animateScrollToPage(currentWindowIndex) - } else { - pagerState.scrollToPage(currentWindowIndex) - } - } catch (_: Exception) { - } - } - } - - LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.drop(1).collect { index -> - if (!pagerState.isScrollInProgress && index != currentWindowIndex && windows.isNotEmpty()) { - playerConnection.player.seekToDefaultPosition(windows[index].firstPeriodIndex) - } - } - } - - LaunchedEffect(showLyrics) { - if (!showLyrics) { - pagerState.scrollToPage(currentWindowIndex) - } - } - DisposableEffect(showLyrics) { currentView.keepScreenOn = showLyrics onDispose { @@ -97,35 +56,30 @@ fun Thumbnail( .fillMaxSize() .statusBarsPadding() ) { - HorizontalPager( - state = pagerState, - flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), - items = windows, - key = { it.uid.hashCode() }, - beyondBoundsPageCount = 2 - ) { window -> - Box(Modifier.fillMaxSize()) { - AsyncImage( - model = window.mediaItem.metadata?.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 16.dp) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - .align(Alignment.Center) - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { offset -> - if (offset.x < size.width / 2) { - playerConnection.player.seekBack() - } else { - playerConnection.player.seekForward() - } + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + if (offset.x < size.width / 2) { + playerConnection.player.seekBack() + } else { + playerConnection.player.seekForward() } - ) - } - ) - } + } + ) + } + ) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt index 287f05e9e..060769738 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/AlbumScreen.kt @@ -23,6 +23,7 @@ import androidx.compose.material3.CircularProgressIndicator import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -55,18 +56,14 @@ import androidx.media3.exoplayer.offline.DownloadRequest import androidx.media3.exoplayer.offline.DownloadService import androidx.navigation.NavController import coil.compose.AsyncImage -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalDownloadUtil import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.AlbumThumbnailSize -import com.zionhuang.music.constants.CONTENT_TYPE_SONG import com.zionhuang.music.constants.ThumbnailCornerRadius import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.db.entities.AlbumWithSongs import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause @@ -76,20 +73,13 @@ import com.zionhuang.music.ui.component.AutoResizeText import com.zionhuang.music.ui.component.FontSizeRange import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem -import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.ui.component.shimmer.ButtonPlaceholder import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.component.shimmer.TextPlaceholder import com.zionhuang.music.ui.menu.AlbumMenu import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.ui.menu.YouTubeAlbumMenu -import com.zionhuang.music.ui.menu.YouTubeSongMenu import com.zionhuang.music.viewmodels.AlbumViewModel -import com.zionhuang.music.viewmodels.AlbumViewState -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -100,24 +90,20 @@ fun AlbumScreen( ) { val context = LocalContext.current val menuState = LocalMenuState.current + val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - val viewState by viewModel.viewState.collectAsState() - val inLibrary by viewModel.inLibrary.collectAsState() + val albumWithSongs by viewModel.albumWithSongs.collectAsState() val downloadUtil = LocalDownloadUtil.current var downloadState by remember { mutableStateOf(Download.STATE_STOPPED) } - LaunchedEffect(viewState) { - val songs = when (val state = viewState) { - is AlbumViewState.Local -> state.albumWithSongs.songs.map { it.id } - is AlbumViewState.Remote -> state.albumPage.songs.map { it.id } - else -> return@LaunchedEffect - } + LaunchedEffect(albumWithSongs) { + val songs = albumWithSongs?.songs?.map { it.id } ?: return@LaunchedEffect downloadUtil.downloads.collect { downloads -> downloadState = if (songs.all { downloads[it]?.state == Download.STATE_COMPLETED }) @@ -136,140 +122,153 @@ fun AlbumScreen( LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { - viewState.let { viewState -> - when (viewState) { - is AlbumViewState.Local -> { - item { - LocalAlbumHeader( - albumWithSongs = viewState.albumWithSongs, - inLibrary = inLibrary, - downloadState = downloadState, - onDownload = { - viewState.albumWithSongs.songs.forEach { song -> - val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false - ) + val albumWithSongs = albumWithSongs + if (albumWithSongs != null) { + item { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = albumWithSongs.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = albumWithSongs.album.title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + albumWithSongs.artists.fastForEachIndexed { index, artist -> + pushStringAnnotation(artist.id, artist.name) + append(artist.name) + pop() + if (index != albumWithSongs.artists.lastIndex) { + append(", ") + } + } } - }, - onRemoveDownload = { - viewState.albumWithSongs.songs.forEach { song -> - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - song.id, - false - ) + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") } - }, - navController = navController - ) - } + } - itemsIndexed( - items = viewState.albumWithSongs.songs, - key = { _, song -> song.id } - ) { index, song -> - SongListItem( - song = song, - albumIndex = index + 1, - isActive = song.id == mediaMetadata?.id, - isPlaying = isPlaying, - showInLibraryIcon = true, - trailingContent = { + if (albumWithSongs.album.year != null) { + Text( + text = albumWithSongs.album.year.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + + Row { IconButton( onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) + database.query { + update(albumWithSongs.album.toggleLike()) } } ) { Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null + painter = painterResource(if (albumWithSongs.album.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + contentDescription = null, + tint = if (albumWithSongs.album.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current ) } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = viewState.albumWithSongs.album.title, - items = viewState.albumWithSongs.songs.map { it.toMediaItem() }, - startIndex = index + + when (downloadState) { + Download.STATE_COMPLETED -> { + IconButton( + onClick = { + albumWithSongs.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.offline), + contentDescription = null ) - ) + } } - } - ) - } - } - is AlbumViewState.Remote -> { - item { - RemoteAlbumHeader( - albumPage = viewState.albumPage, - inLibrary = inLibrary, - downloadState = downloadState, - onDownload = { - viewState.albumPage.songs.forEach { song -> - val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) - .setCustomCacheKey(song.id) - .setData(song.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false - ) - } - }, - onRemoveDownload = { - viewState.albumPage.songs.forEach { song -> - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - song.id, - false - ) + Download.STATE_DOWNLOADING -> { + IconButton( + onClick = { + albumWithSongs.songs.forEach { song -> + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + song.id, + false + ) + } + } + ) { + CircularProgressIndicator( + strokeWidth = 2.dp, + modifier = Modifier.size(24.dp) + ) + } + } + + else -> { + IconButton( + onClick = { + albumWithSongs.songs.forEach { song -> + val downloadRequest = DownloadRequest.Builder(song.id, song.id.toUri()) + .setCustomCacheKey(song.id) + .setData(song.song.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.download), + contentDescription = null + ) + } + } } - }, - navController = navController - ) - } - itemsIndexed( - items = viewState.albumPage.songs, - key = { _, song -> song.id }, - contentType = { _, _ -> CONTENT_TYPE_SONG } - ) { index, song -> - YouTubeListItem( - item = song, - albumIndex = index + 1, - isActive = song.id == mediaMetadata?.id, - isPlaying = isPlaying, - trailingContent = { IconButton( onClick = { menuState.show { - YouTubeSongMenu( - song = song, + AlbumMenu( + originalAlbum = Album(albumWithSongs.album, albumWithSongs.artists), navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss @@ -282,467 +281,158 @@ fun AlbumScreen( contentDescription = null ) } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = viewState.albumPage.album.title, - items = viewState.albumPage.songs.map { it.toMediaItem() }, - startIndex = index - ) - ) - } - } - ) - } - } - - null -> { - item { - ShimmerHost { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer( - modifier = Modifier - .size(AlbumThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - .background(MaterialTheme.colorScheme.onSurface) - ) - - Spacer(Modifier.width(16.dp)) - - Column( - verticalArrangement = Arrangement.Center, - ) { - TextPlaceholder() - TextPlaceholder() - TextPlaceholder() - } - } - - Spacer(Modifier.padding(8.dp)) - - Row { - ButtonPlaceholder(Modifier.weight(1f)) - - Spacer(Modifier.width(12.dp)) - - ButtonPlaceholder(Modifier.weight(1f)) - } - } - - repeat(6) { - ListItemPlaceHolder() } } } - } - } - } - } - TopAppBar( - title = { }, - navigationIcon = { - IconButton(onClick = navController::navigateUp) { - Icon( - painterResource(R.drawable.arrow_back), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior - ) -} - -@Composable -fun LocalAlbumHeader( - albumWithSongs: AlbumWithSongs, - inLibrary: Boolean, - downloadState: Int, - onDownload: () -> Unit, - onRemoveDownload: () -> Unit, - navController: NavController, -) { - val playerConnection = LocalPlayerConnection.current ?: return - val database = LocalDatabase.current - val menuState = LocalMenuState.current - - Column( - modifier = Modifier.padding(12.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - AsyncImage( - model = albumWithSongs.album.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .size(AlbumThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - Spacer(Modifier.width(16.dp)) - - Column( - verticalArrangement = Arrangement.Center, - ) { - AutoResizeText( - text = albumWithSongs.album.title, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontSizeRange = FontSizeRange(16.sp, 22.sp) - ) - - val annotatedString = buildAnnotatedString { - withStyle( - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onBackground - ).toSpanStyle() - ) { - albumWithSongs.artists.fastForEachIndexed { index, artist -> - pushStringAnnotation(artist.id, artist.name) - append(artist.name) - pop() - if (index != albumWithSongs.artists.lastIndex) { - append(", ") - } - } - } - } - ClickableText(annotatedString) { offset -> - annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> - navController.navigate("artist/${range.tag}") - } - } + Spacer(Modifier.height(12.dp)) - if (albumWithSongs.album.year != null) { - Text( - text = albumWithSongs.album.year.toString(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Normal - ) - } - - Row { - IconButton( - onClick = { - database.query { - if (inLibrary) { - delete(albumWithSongs.album) - } else { - insert(albumWithSongs) - } - } - } - ) { - Icon( - painter = painterResource(if (inLibrary) R.drawable.library_add_check else R.drawable.library_add), - contentDescription = null - ) - } - - when (downloadState) { - Download.STATE_COMPLETED -> { - IconButton(onClick = onRemoveDownload) { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map(Song::toMediaItem) + ) ) - } + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.play) + ) } - Download.STATE_DOWNLOADING -> { - IconButton(onClick = onRemoveDownload) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(24.dp) + OutlinedButton( + onClick = { + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.shuffled().map(Song::toMediaItem) + ) ) - } + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.shuffle)) } + } + } + } - else -> { - IconButton(onClick = onDownload) { - Icon( - painter = painterResource(R.drawable.download), - contentDescription = null - ) + itemsIndexed( + items = albumWithSongs.songs, + key = { _, song -> song.id } + ) { index, song -> + SongListItem( + song = song, + albumIndex = index + 1, + isActive = song.id == mediaMetadata?.id, + isPlaying = isPlaying, + showInLibraryIcon = true, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) } - } - - IconButton( - onClick = { - menuState.show { - AlbumMenu( - album = Album(albumWithSongs.album, albumWithSongs.artists), - navController = navController, - playerConnection = playerConnection, - showDeleteButton = false, - onDismiss = menuState::dismiss + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + if (song.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map { it.toMediaItem() }, + startIndex = index + ) ) } } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) - } - } - } - } - - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { - playerConnection.playQueue( - ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.map(Song::toMediaItem) - ) - ) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.play) - ) - } - - OutlinedButton( - onClick = { - playerConnection.playQueue( - ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.shuffled().map(Song::toMediaItem) - ) - ) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.shuffle)) } - } - } -} - -@Composable -fun RemoteAlbumHeader( - albumPage: AlbumPage, - inLibrary: Boolean, - downloadState: Int, - onDownload: () -> Unit, - onRemoveDownload: () -> Unit, - navController: NavController, -) { - val playerConnection = LocalPlayerConnection.current ?: return - val menuState = LocalMenuState.current - val database = LocalDatabase.current - - Column( - modifier = Modifier.padding(12.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - AsyncImage( - model = albumPage.album.thumbnail, - contentDescription = null, - modifier = Modifier - .size(AlbumThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - Spacer(Modifier.width(16.dp)) - - Column( - verticalArrangement = Arrangement.Center, - ) { - AutoResizeText( - text = albumPage.album.title, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontSizeRange = FontSizeRange(16.sp, 22.sp) - ) - - val annotatedString = buildAnnotatedString { - withStyle( - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onBackground - ).toSpanStyle() - ) { - albumPage.album.artists?.fastForEachIndexed { index, artist -> - if (artist.id != null) { - pushStringAnnotation(artist.id!!, artist.name) - append(artist.name) - pop() - } else { - append(artist.name) - } - if (index != albumPage.album.artists?.lastIndex) { - append(", ") + } else { + item { + ShimmerHost { + Column(Modifier.padding(12.dp)) { + Row(verticalAlignment = Alignment.CenterVertically) { + Spacer( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() } } - } - } - ClickableText(annotatedString) { offset -> - annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> - navController.navigate("artist/${range.tag}") - } - } - if (albumPage.album.year != null) { - Text( - text = albumPage.album.year.toString(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Normal - ) - } + Spacer(Modifier.padding(8.dp)) - Row { - IconButton( - onClick = { - database.query { - if (inLibrary) { - runBlocking(Dispatchers.IO) { - albumWithSongs(albumPage.album.browseId).first() - }?.let { - delete(it.album) - } - } else { - insert(albumPage) - } - } - } - ) { - Icon( - painter = painterResource(if (inLibrary) R.drawable.library_add_check else R.drawable.library_add), - contentDescription = null - ) - } + Row { + ButtonPlaceholder(Modifier.weight(1f)) - when (downloadState) { - Download.STATE_COMPLETED -> { - IconButton(onClick = onRemoveDownload) { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null - ) - } - } + Spacer(Modifier.width(12.dp)) - Download.STATE_DOWNLOADING -> { - IconButton(onClick = onRemoveDownload) { - CircularProgressIndicator( - strokeWidth = 2.dp, - modifier = Modifier.size(24.dp) - ) - } - } - - else -> { - IconButton(onClick = onDownload) { - Icon( - painter = painterResource(R.drawable.download), - contentDescription = null - ) - } + ButtonPlaceholder(Modifier.weight(1f)) } } - IconButton( - onClick = { - menuState.show { - YouTubeAlbumMenu( - album = albumPage.album, - navController = navController, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) - } - } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) + repeat(6) { + ListItemPlaceHolder() } } } } + } - Spacer(Modifier.height(12.dp)) - - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { - playerConnection.playQueue( - ListQueue( - title = albumPage.album.title, - items = albumPage.songs.map(SongItem::toMediaItem) - ) - ) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.play), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.play) - ) - } - - OutlinedButton( - onClick = { - playerConnection.playQueue( - ListQueue( - title = albumPage.album.title, - items = albumPage.songs.shuffled().map(SongItem::toMediaItem) - ) - ) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { Icon( - painter = painterResource(R.drawable.shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) + painterResource(R.drawable.arrow_back), + contentDescription = null ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.shuffle)) } - } - } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt index 191d0282d..83497de52 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HomeScreen.kt @@ -230,7 +230,7 @@ fun HomeScreen( onLongClick = { menuState.show { YouTubeAlbumMenu( - album = album, + albumItem = album, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt index 63e4e0c12..75f315fb5 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt @@ -29,6 +29,7 @@ import com.zionhuang.music.constants.AccountNameKey import com.zionhuang.music.constants.InnerTubeCookieKey import com.zionhuang.music.constants.VisitorDataKey import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.utils.reportException import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch @@ -58,10 +59,10 @@ fun LoginScreen( innerTubeCookie = CookieManager.getInstance().getCookie(url) GlobalScope.launch { YouTube.accountInfo().onSuccess { - accountName = it?.name.orEmpty() - accountEmail = it?.email.orEmpty() + accountName = it.name + accountEmail = it.email }.onFailure { - it.printStackTrace() + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt index 14eb1408e..b2e053024 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt @@ -67,7 +67,7 @@ fun NewReleaseScreen( onLongClick = { menuState.show { YouTubeAlbumMenu( - album = album, + albumItem = album, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt index a72f2b024..82bc94319 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt @@ -3,24 +3,16 @@ package com.zionhuang.music.ui.screens import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.TopAppBar import androidx.compose.runtime.Composable @@ -30,7 +22,6 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.models.WatchEndpoint @@ -41,13 +32,14 @@ import com.zionhuang.music.constants.StatPeriod import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.AlbumListItem import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.SongListItem -import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.menu.AlbumMenu import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.ui.menu.YouTubeAlbumMenu import com.zionhuang.music.viewmodels.StatsViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -71,36 +63,18 @@ fun StatsScreen( modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top)) ) { item { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - Spacer(Modifier.width(8.dp)) - - StatPeriod.values().forEach { period -> - FilterChip( - label = { - Text( - when (period) { - StatPeriod.`1_WEEK` -> pluralStringResource(R.plurals.n_week, 1, 1) - StatPeriod.`1_MONTH` -> pluralStringResource(R.plurals.n_month, 1, 1) - StatPeriod.`3_MONTH` -> pluralStringResource(R.plurals.n_month, 3, 3) - StatPeriod.`6_MONTH` -> pluralStringResource(R.plurals.n_month, 6, 6) - StatPeriod.`1_YEAR` -> pluralStringResource(R.plurals.n_year, 1, 1) - StatPeriod.ALL -> stringResource(R.string.filter_all) - } - ) - }, - selected = statPeriod == period, - colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), - onClick = { - viewModel.statPeriod.value = period - } - ) - Spacer(Modifier.width(8.dp)) - } - } + ChipsRow( + chips = listOf( + StatPeriod.`1_WEEK` to pluralStringResource(R.plurals.n_week, 1, 1), + StatPeriod.`1_MONTH` to pluralStringResource(R.plurals.n_month, 1, 1), + StatPeriod.`3_MONTH` to pluralStringResource(R.plurals.n_month, 3, 3), + StatPeriod.`6_MONTH` to pluralStringResource(R.plurals.n_month, 6, 6), + StatPeriod.`1_YEAR` to pluralStringResource(R.plurals.n_year, 1, 1), + StatPeriod.ALL to stringResource(R.string.filter_all) + ), + currentValue = statPeriod, + onValueUpdate = { viewModel.statPeriod.value = it } + ) } item { @@ -176,17 +150,17 @@ fun StatsScreen( items( items = mostPlayedAlbums, key = { it.id } - ) { item -> - YouTubeListItem( - item = item, - isActive = mediaMetadata?.album?.id == item.id, + ) { album -> + AlbumListItem( + album = album, + isActive = album.id == mediaMetadata?.album?.id, isPlaying = isPlaying, trailingContent = { IconButton( onClick = { menuState.show { - YouTubeAlbumMenu( - album = item, + AlbumMenu( + originalAlbum = album, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss @@ -201,8 +175,9 @@ fun StatsScreen( } }, modifier = Modifier - .clickable { - navController.navigate("album/${item.id}") + .fillMaxWidth() + .combinedClickable { + navController.navigate("album/${album.id}") } .animateItemPlacement() ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt index 02a948503..1d037fb7f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt @@ -108,7 +108,7 @@ fun YouTubeBrowseScreen( ) is AlbumItem -> YouTubeAlbumMenu( - album = item, + albumItem = item, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt index 78d8b637d..b16d32cb5 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt @@ -118,7 +118,7 @@ fun ArtistItemsScreen( ) is AlbumItem -> YouTubeAlbumMenu( - album = item, + albumItem = item, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss @@ -214,7 +214,7 @@ fun ArtistItemsScreen( ) is AlbumItem -> YouTubeAlbumMenu( - album = item, + albumItem = item, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt index 14b0c453a..f8e168dee 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt @@ -343,7 +343,7 @@ fun ArtistScreen( ) is AlbumItem -> YouTubeAlbumMenu( - album = item, + albumItem = item, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss @@ -434,11 +434,7 @@ fun ArtistScreen( database.transaction { val artist = libraryArtist if (artist != null) { - update( - artist.copy( - bookmarkedAt = if (artist.bookmarkedAt != null) null else LocalDateTime.now() - ) - ) + update(artist.toggleLike()) } else { artistPage?.artist?.let { insert( @@ -455,8 +451,8 @@ fun ArtistScreen( } ) { Icon( - painter = painterResource(if (libraryArtist?.bookmarkedAt != null) R.drawable.bookmark_filled else R.drawable.bookmark), - tint = if (libraryArtist?.bookmarkedAt != null) MaterialTheme.colorScheme.primary else LocalContentColor.current, + painter = painterResource(if (libraryArtist?.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (libraryArtist?.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt index 7c222e653..22f541fef 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryAlbumsScreen.kt @@ -13,9 +13,11 @@ import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -23,6 +25,7 @@ import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.* import com.zionhuang.music.ui.component.AlbumListItem +import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.menu.AlbumMenu @@ -41,6 +44,7 @@ fun LibraryAlbumsScreen( val isPlaying by playerConnection.isPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + var filter by rememberEnumPreference(AlbumFilterKey, AlbumFilter.LIBRARY) val (sortType, onSortTypeChange) = rememberEnumPreference(AlbumSortTypeKey, AlbumSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(AlbumSortDescendingKey, true) @@ -52,6 +56,17 @@ fun LibraryAlbumsScreen( LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { + item(key = "filter") { + ChipsRow( + chips = listOf( + AlbumFilter.LIBRARY to stringResource(R.string.filter_library), + AlbumFilter.LIKED to stringResource(R.string.filter_liked) + ), + currentValue = filter, + onValueUpdate = { filter = it } + ) + } + item( key = "header", contentType = CONTENT_TYPE_HEADER @@ -90,7 +105,7 @@ fun LibraryAlbumsScreen( onClick = { menuState.show { AlbumMenu( - album = album, + originalAlbum = album, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt index cfdb49da7..92b5c784c 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryArtistsScreen.kt @@ -2,25 +2,16 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.foundation.rememberScrollState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue @@ -29,33 +20,32 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R +import com.zionhuang.music.constants.ArtistFilter +import com.zionhuang.music.constants.ArtistFilterKey import com.zionhuang.music.constants.ArtistSortDescendingKey import com.zionhuang.music.constants.ArtistSortType import com.zionhuang.music.constants.ArtistSortTypeKey -import com.zionhuang.music.constants.ArtistViewType -import com.zionhuang.music.constants.ArtistViewTypeKey import com.zionhuang.music.constants.CONTENT_TYPE_ARTIST import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryArtistsViewModel -import java.time.LocalDateTime -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryArtistsScreen( navController: NavController, viewModel: LibraryArtistsViewModel = hiltViewModel(), ) { val database = LocalDatabase.current - var viewType by rememberEnumPreference(ArtistViewTypeKey, ArtistViewType.ALL) + var filter by rememberEnumPreference(ArtistFilterKey, ArtistFilter.LIBRARY) val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) @@ -67,27 +57,15 @@ fun LibraryArtistsScreen( LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { - item(key = "viewType") { - Row( - modifier = Modifier - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - Spacer(Modifier.width(16.dp)) - - listOf( - ArtistViewType.ALL to stringResource(R.string.filter_all), - ArtistViewType.BOOKMARKED to stringResource(R.string.filter_bookmarked) - ).forEach { - FilterChip( - label = { Text(it.second) }, - selected = viewType == it.first, - colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), - onClick = { viewType = it.first } - ) - Spacer(Modifier.width(8.dp)) - } - } + item(key = "filter") { + ChipsRow( + chips = listOf( + ArtistFilter.LIBRARY to stringResource(R.string.filter_library), + ArtistFilter.LIKED to stringResource(R.string.filter_liked) + ), + currentValue = filter, + onValueUpdate = { filter = it } + ) } item(key = "header") { @@ -119,17 +97,13 @@ fun LibraryArtistsScreen( IconButton( onClick = { database.transaction { - update( - artist.artist.copy( - bookmarkedAt = if (artist.artist.bookmarkedAt != null) null else LocalDateTime.now() - ) - ) + update(artist.artist.toggleLike()) } } ) { Icon( - painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.bookmark_filled else R.drawable.bookmark), - tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.primary else LocalContentColor.current, + painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.favorite else R.drawable.favorite_border), + tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.error else LocalContentColor.current, contentDescription = null ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt index 68580b118..27e4b5168 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibraryPlaylistsScreen.kt @@ -6,13 +6,11 @@ import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState @@ -32,15 +30,11 @@ import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.CONTENT_TYPE_HEADER import com.zionhuang.music.constants.CONTENT_TYPE_PLAYLIST -import com.zionhuang.music.constants.ListThumbnailSize import com.zionhuang.music.constants.PlaylistSortDescendingKey import com.zionhuang.music.constants.PlaylistSortType import com.zionhuang.music.constants.PlaylistSortTypeKey import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID import com.zionhuang.music.ui.component.HideOnScrollFAB -import com.zionhuang.music.ui.component.ListItem import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.PlaylistListItem import com.zionhuang.music.ui.component.SortHeader @@ -64,8 +58,6 @@ fun LibraryPlaylistsScreen( val (sortType, onSortTypeChange) = rememberEnumPreference(PlaylistSortTypeKey, PlaylistSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSortDescendingKey, true) - val likedSongCount by viewModel.likedSongCount.collectAsState() - val downloadedSongCount by viewModel.downloadedSongCount.collectAsState(0) val playlists by viewModel.allPlaylists.collectAsState() val lazyListState = rememberLazyListState() @@ -118,51 +110,6 @@ fun LibraryPlaylistsScreen( ) } - item( - key = LIKED_PLAYLIST_ID, - contentType = CONTENT_TYPE_PLAYLIST - ) { - ListItem( - title = stringResource(R.string.liked_songs), - subtitle = pluralStringResource(R.plurals.n_song, likedSongCount, likedSongCount), - thumbnailContent = { - Icon( - painter = painterResource(R.drawable.favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier.size(ListThumbnailSize) - ) - }, - modifier = Modifier - .clickable { - navController.navigate("local_playlist/$LIKED_PLAYLIST_ID") - } - .animateItemPlacement() - ) - } - - item( - key = DOWNLOADED_PLAYLIST_ID, - contentType = CONTENT_TYPE_PLAYLIST - ) { - ListItem( - title = stringResource(R.string.downloaded_songs), - subtitle = pluralStringResource(R.plurals.n_song, downloadedSongCount, downloadedSongCount), - thumbnailContent = { - Icon( - painter = painterResource(R.drawable.offline), - contentDescription = null, - modifier = Modifier.size(ListThumbnailSize) - ) - }, - modifier = Modifier - .clickable { - navController.navigate("local_playlist/$DOWNLOADED_PLAYLIST_ID") - } - .animateItemPlacement() - ) - } - items( items = playlists, key = { it.id }, diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt index 4503ffc35..dda00765a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/library/LibrarySongsScreen.kt @@ -11,10 +11,12 @@ import androidx.compose.material3.IconButton import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -24,6 +26,7 @@ import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.HideOnScrollFAB import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem @@ -45,6 +48,7 @@ fun LibrarySongsScreen( val isPlaying by playerConnection.isPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + var filter by rememberEnumPreference(SongFilterKey, SongFilter.LIBRARY) val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) @@ -59,10 +63,19 @@ fun LibrarySongsScreen( state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { - item( - key = "header", - contentType = CONTENT_TYPE_HEADER - ) { + item(key = "filter") { + ChipsRow( + chips = listOf( + SongFilter.LIBRARY to stringResource(R.string.filter_library), + SongFilter.LIKED to stringResource(R.string.filter_liked), + SongFilter.DOWNLOADED to stringResource(R.string.filter_downloaded) + ), + currentValue = filter, + onValueUpdate = { filter = it } + ) + } + + item(key = "header") { SortHeader( sortType = sortType, sortDescending = sortDescending, diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt deleted file mode 100644 index 41e68f994..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt +++ /dev/null @@ -1,214 +0,0 @@ -package com.zionhuang.music.ui.screens.playlist - -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton -import androidx.compose.material3.Text -import androidx.compose.material3.TopAppBar -import androidx.compose.material3.TopAppBarScrollBehavior -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.pluralStringResource -import androidx.compose.ui.util.fastSumBy -import androidx.hilt.navigation.compose.hiltViewModel -import androidx.navigation.NavController -import com.zionhuang.music.LocalPlayerAwareWindowInsets -import com.zionhuang.music.LocalPlayerConnection -import com.zionhuang.music.R -import com.zionhuang.music.constants.DownloadedSongSortDescendingKey -import com.zionhuang.music.constants.DownloadedSongSortType -import com.zionhuang.music.constants.DownloadedSongSortTypeKey -import com.zionhuang.music.constants.SongSortDescendingKey -import com.zionhuang.music.constants.SongSortType -import com.zionhuang.music.constants.SongSortTypeKey -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.extensions.togglePlayPause -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.component.HideOnScrollFAB -import com.zionhuang.music.ui.component.LocalMenuState -import com.zionhuang.music.ui.component.SongListItem -import com.zionhuang.music.ui.component.SortHeader -import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.utils.joinByBullet -import com.zionhuang.music.utils.makeTimeString -import com.zionhuang.music.utils.rememberEnumPreference -import com.zionhuang.music.utils.rememberPreference -import com.zionhuang.music.viewmodels.BuiltInPlaylistViewModel - -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) -@Composable -fun BuiltInPlaylistScreen( - navController: NavController, - scrollBehavior: TopAppBarScrollBehavior, - viewModel: BuiltInPlaylistViewModel = hiltViewModel(), -) { - val context = LocalContext.current - val menuState = LocalMenuState.current - val playerConnection = LocalPlayerConnection.current ?: return - val isPlaying by playerConnection.isPlaying.collectAsState() - val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - - val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) - val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) - val (dlSortType, onDlSortTypeChange) = rememberEnumPreference(DownloadedSongSortTypeKey, DownloadedSongSortType.CREATE_DATE) - val (dlSortDescending, onDlSortDescendingChange) = rememberPreference(DownloadedSongSortDescendingKey, true) - - val songs by viewModel.songs.collectAsState() - val playlistLength = remember(songs) { - songs.fastSumBy { it.song.duration } - } - val playlistName = remember { - context.getString( - when (viewModel.playlistId) { - LIKED_PLAYLIST_ID -> R.string.liked_songs - DOWNLOADED_PLAYLIST_ID -> R.string.downloaded_songs - else -> error("Unknown playlist id") - } - ) - } - - val lazyListState = rememberLazyListState() - - Box( - modifier = Modifier.fillMaxSize() - ) { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() - ) { - item { - if (viewModel.playlistId == LIKED_PLAYLIST_ID) { - SortHeader( - sortType = sortType, - sortDescending = sortDescending, - onSortTypeChange = onSortTypeChange, - onSortDescendingChange = onSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - SongSortType.CREATE_DATE -> R.string.sort_by_create_date - SongSortType.NAME -> R.string.sort_by_name - SongSortType.ARTIST -> R.string.sort_by_artist - SongSortType.PLAY_TIME -> R.string.sort_by_play_time - } - }, - trailingText = joinByBullet( - makeTimeString(playlistLength * 1000L), - pluralStringResource(R.plurals.n_song, songs.size, songs.size) - ) - ) - } else { - SortHeader( - sortType = dlSortType, - sortDescending = dlSortDescending, - onSortTypeChange = onDlSortTypeChange, - onSortDescendingChange = onDlSortDescendingChange, - sortTypeText = { sortType -> - when (sortType) { - DownloadedSongSortType.CREATE_DATE -> R.string.sort_by_create_date - DownloadedSongSortType.NAME -> R.string.sort_by_name - DownloadedSongSortType.ARTIST -> R.string.sort_by_artist - DownloadedSongSortType.PLAY_TIME -> R.string.sort_by_play_time - } - }, - trailingText = joinByBullet( - makeTimeString(playlistLength * 1000L), - pluralStringResource(R.plurals.n_song, songs.size, songs.size) - ) - ) - } - } - - itemsIndexed( - items = songs, - key = { _, song -> song.id } - ) { index, song -> - SongListItem( - song = song, - showLikedIcon = viewModel.playlistId != LIKED_PLAYLIST_ID, - showInLibraryIcon = true, - showDownloadIcon = viewModel.playlistId != DOWNLOADED_PLAYLIST_ID, - isActive = song.id == mediaMetadata?.id, - isPlaying = isPlaying, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) - } - } - ) { - Icon( - painter = painterResource(R.drawable.more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - if (song.id == mediaMetadata?.id) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.playQueue( - ListQueue( - title = playlistName, - items = songs.map { it.toMediaItem() }, - startIndex = index - ) - ) - } - } - .animateItemPlacement() - ) - } - } - - TopAppBar( - title = { Text(playlistName) }, - navigationIcon = { - IconButton(onClick = navController::navigateUp) { - Icon( - painterResource(R.drawable.arrow_back), - contentDescription = null - ) - } - }, - scrollBehavior = scrollBehavior - ) - - HideOnScrollFAB( - visible = songs.isNotEmpty(), - lazyListState = lazyListState, - icon = R.drawable.shuffle, - onClick = { - playerConnection.playQueue( - ListQueue( - title = playlistName, - items = songs.shuffled().map { it.toMediaItem() }, - ) - ) - } - ) - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt index f6ad2af55..74026d030 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt @@ -2,7 +2,6 @@ package com.zionhuang.music.ui.screens.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -12,10 +11,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -48,6 +44,7 @@ import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.component.AlbumListItem import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.EmptyPlaceholder import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.PlaylistListItem @@ -90,28 +87,17 @@ fun LocalSearchScreen( } Column { - Row( - horizontalArrangement = Arrangement.spacedBy(8.dp), - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 8.dp) - .horizontalScroll(rememberScrollState()) - ) { - listOf( - LocalFilter.ALL to R.string.filter_all, - LocalFilter.SONG to R.string.filter_songs, - LocalFilter.ALBUM to R.string.filter_albums, - LocalFilter.ARTIST to R.string.filter_artists, - LocalFilter.PLAYLIST to R.string.filter_playlists - ).forEach { (filter, label) -> - FilterChip( - label = { Text(stringResource(label)) }, - selected = searchFilter == filter, - colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), - onClick = { viewModel.filter.value = filter } - ) - } - } + ChipsRow( + chips = listOf( + LocalFilter.ALL to stringResource(R.string.filter_all), + LocalFilter.SONG to stringResource(R.string.filter_songs), + LocalFilter.ALBUM to stringResource(R.string.filter_albums), + LocalFilter.ARTIST to stringResource(R.string.filter_artists), + LocalFilter.PLAYLIST to stringResource(R.string.filter_playlists) + ), + currentValue = searchFilter, + onValueUpdate = { viewModel.filter.value = it } + ) LazyColumn( state = lazyListState, diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt index ffec47295..b6e92f45e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt @@ -2,31 +2,21 @@ package com.zionhuang.music.ui.screens.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.add import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.systemBars -import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyItemScope import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FilterChip -import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState @@ -38,7 +28,6 @@ import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM @@ -61,6 +50,7 @@ import com.zionhuang.music.constants.SearchFilterHeight import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.ChipsRow import com.zionhuang.music.ui.component.EmptyPlaceholder import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.NavigationTitle @@ -129,7 +119,7 @@ fun OnlineSearchResult( ) is AlbumItem -> YouTubeAlbumMenu( - album = item, + albumItem = item, navController = navController, playerConnection = playerConnection, onDismiss = menuState::dismiss @@ -242,38 +232,27 @@ fun OnlineSearchResult( } } - Row( + ChipsRow( + chips = listOf( + null to stringResource(R.string.filter_all), + FILTER_SONG to stringResource(R.string.filter_songs), + FILTER_VIDEO to stringResource(R.string.filter_videos), + FILTER_ALBUM to stringResource(R.string.filter_albums), + FILTER_ARTIST to stringResource(R.string.filter_artists), + FILTER_COMMUNITY_PLAYLIST to stringResource(R.string.filter_community_playlists), + FILTER_FEATURED_PLAYLIST to stringResource(R.string.filter_featured_playlists) + ), + currentValue = searchFilter, + onValueUpdate = { + if (viewModel.filter.value != it) { + viewModel.filter.value = it + } + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + }, modifier = Modifier .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) .padding(top = AppBarHeight) - .fillMaxWidth() - .horizontalScroll(rememberScrollState()) - ) { - Spacer(Modifier.width(8.dp)) - - listOf( - null to R.string.filter_all, - FILTER_SONG to R.string.filter_songs, - FILTER_VIDEO to R.string.filter_videos, - FILTER_ALBUM to R.string.filter_albums, - FILTER_ARTIST to R.string.filter_artists, - FILTER_COMMUNITY_PLAYLIST to R.string.filter_community_playlists, - FILTER_FEATURED_PLAYLIST to R.string.filter_featured_playlists - ).forEach { (filter, label) -> - FilterChip( - label = { Text(text = stringResource(label)) }, - selected = searchFilter == filter, - colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), - onClick = { - if (viewModel.filter.value != filter) { - viewModel.filter.value = filter - } - coroutineScope.launch { - lazyListState.animateScrollToItem(0) - } - } - ) - Spacer(Modifier.width(8.dp)) - } - } + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt index 97168db71..7444f441c 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AboutScreen.kt @@ -67,6 +67,25 @@ fun AboutScreen( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary ) + + Spacer(Modifier.width(4.dp)) + + Text( + text = BuildConfig.FLAVOR.uppercase(), + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape + ) + .padding( + horizontal = 6.dp, + vertical = 2.dp + ) + ) + if (BuildConfig.DEBUG) { Spacer(Modifier.width(4.dp)) @@ -81,7 +100,7 @@ fun AboutScreen( shape = CircleShape ) .padding( - horizontal = 4.dp, + horizontal = 6.dp, vertical = 2.dp ) ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt index 2ec8fa899..89ae0cef0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/StorageSettings.kt @@ -29,15 +29,18 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import coil.annotation.ExperimentalCoilApi import coil.imageLoader +import com.zionhuang.music.BuildConfig import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.MaxImageCacheSizeKey import com.zionhuang.music.constants.MaxSongCacheSizeKey +import com.zionhuang.music.extensions.tryOrNull import com.zionhuang.music.ui.component.ListPreference import com.zionhuang.music.ui.component.PreferenceEntry import com.zionhuang.music.ui.component.PreferenceGroupTitle import com.zionhuang.music.ui.utils.formatFileSize +import com.zionhuang.music.utils.TranslationHelper import com.zionhuang.music.utils.rememberPreference import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -61,10 +64,10 @@ fun StorageSettings( mutableStateOf(imageDiskCache.size) } var playerCacheSize by remember { - mutableStateOf(playerCache.cacheSpace) + mutableStateOf(tryOrNull { playerCache.cacheSpace } ?: 0) } var downloadCacheSize by remember { - mutableStateOf(downloadCache.cacheSpace) + mutableStateOf(tryOrNull { downloadCache.cacheSpace } ?: 0) } LaunchedEffect(imageDiskCache) { @@ -76,13 +79,13 @@ fun StorageSettings( LaunchedEffect(playerCache) { while (isActive) { delay(500) - playerCacheSize = playerCache.cacheSpace + playerCacheSize = tryOrNull { playerCache.cacheSpace } ?: 0 } } LaunchedEffect(downloadCache) { while (isActive) { delay(500) - downloadCacheSize = downloadCache.cacheSpace + downloadCacheSize = tryOrNull { downloadCache.cacheSpace } ?: 0 } } @@ -194,6 +197,21 @@ fun StorageSettings( } }, ) + + if (BuildConfig.FLAVOR == "full") { + PreferenceGroupTitle( + title = stringResource(R.string.translation_models) + ) + + PreferenceEntry( + title = { Text(stringResource(R.string.clear_translation_models)) }, + onClick = { + coroutineScope.launch(Dispatchers.IO) { + TranslationHelper.clearModels() + } + }, + ) + } } TopAppBar( diff --git a/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt index 1b4beff72..855ca4933 100644 --- a/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt +++ b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt @@ -27,6 +27,7 @@ class CoilBitmapLoader( val result = context.imageLoader.execute( ImageRequest.Builder(context) .data(uri) + .allowHardware(false) .build() ) (result.drawable as BitmapDrawable).bitmap diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.kt index dd73c3124..2c4974bc6 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -18,7 +19,7 @@ class AccountViewModel @Inject constructor() : ViewModel() { YouTube.likedPlaylists().onSuccess { playlists.value = it }.onFailure { - it.printStackTrace() + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt index 401d27927..6bc539b93 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt @@ -4,16 +4,11 @@ import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.AlbumWithSongs +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @@ -24,35 +19,27 @@ class AlbumViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { val albumId = savedStateHandle.get("albumId")!! - private val _viewState = MutableStateFlow(null) - val viewState = _viewState.asStateFlow() - val inLibrary: StateFlow = database.album(albumId) - .map { it != null } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val albumWithSongs = database.albumWithSongs(albumId) + .stateIn(viewModelScope, SharingStarted.Eagerly, null) init { viewModelScope.launch { - if (database.albumWithSongs(albumId).first() == null) { - YouTube.album(albumId).getOrNull()?.let { - _viewState.value = AlbumViewState.Remote(it) - } - } else { - database.albumWithSongs(albumId).collect { albumWithSongs -> - if (albumWithSongs != null) { - _viewState.value = AlbumViewState.Local(albumWithSongs) + val album = database.album(albumId).first() + if (album == null || album.album.songCount == 0) { + YouTube.album(albumId).onSuccess { + database.transaction { + if (album == null) insert(it) + else update(album.album, it) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + album?.album?.let(::delete) + } } } } } } } - -sealed class AlbumViewState { - data class Local( - val albumWithSongs: AlbumWithSongs, - ) : AlbumViewState() - - data class Remote( - val albumPage: AlbumPage, - ) : AlbumViewState() -} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt index e759ecc45..c3e26137a 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.BrowseEndpoint import com.zionhuang.music.models.ItemsPage +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update @@ -35,8 +36,8 @@ class ArtistItemsViewModel @Inject constructor( items = artistItemsPage.items, continuation = artistItemsPage.continuation ) - }.onFailure { e -> - e.printStackTrace() + }.onFailure { + reportException(it) } } } @@ -53,8 +54,8 @@ class ArtistItemsViewModel @Inject constructor( continuation = artistItemsContinuationPage.continuation ) } - }.onFailure { e -> - e.printStackTrace() + }.onFailure { + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt index 7f61d6e80..aa7ad1fee 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -9,6 +9,7 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.ArtistPage import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn @@ -32,8 +33,8 @@ class ArtistViewModel @Inject constructor( YouTube.artist(artistId) .onSuccess { artistPage = it - }.onFailure { e -> - e.printStackTrace() + }.onFailure { + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt index 60b92321f..5a8bf5f14 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -14,6 +14,7 @@ import com.zionhuang.music.extensions.zipInputStream import com.zionhuang.music.extensions.zipOutputStream import com.zionhuang.music.playback.MusicService import com.zionhuang.music.playback.MusicService.Companion.PERSISTENT_QUEUE_FILE +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -47,7 +48,7 @@ class BackupRestoreViewModel @Inject constructor( }.onSuccess { Toast.makeText(context, R.string.backup_create_success, Toast.LENGTH_SHORT).show() }.onFailure { - it.printStackTrace() + reportException(it) Toast.makeText(context, R.string.backup_create_failed, Toast.LENGTH_SHORT).show() } } @@ -84,7 +85,7 @@ class BackupRestoreViewModel @Inject constructor( context.startActivity(Intent(context, MainActivity::class.java)) exitProcess(0) }.onFailure { - it.printStackTrace() + reportException(it) Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt deleted file mode 100644 index 6f813253d..000000000 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt +++ /dev/null @@ -1,82 +0,0 @@ -package com.zionhuang.music.viewmodels - -import android.content.Context -import androidx.lifecycle.SavedStateHandle -import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED -import com.zionhuang.music.constants.DownloadedSongSortDescendingKey -import com.zionhuang.music.constants.DownloadedSongSortType -import com.zionhuang.music.constants.DownloadedSongSortTypeKey -import com.zionhuang.music.constants.SongSortDescendingKey -import com.zionhuang.music.constants.SongSortType -import com.zionhuang.music.constants.SongSortTypeKey -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID -import com.zionhuang.music.extensions.reversed -import com.zionhuang.music.extensions.toEnum -import com.zionhuang.music.playback.DownloadUtil -import com.zionhuang.music.utils.dataStore -import dagger.hilt.android.lifecycle.HiltViewModel -import dagger.hilt.android.qualifiers.ApplicationContext -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.flow.stateIn -import javax.inject.Inject - -@HiltViewModel -class BuiltInPlaylistViewModel @Inject constructor( - @ApplicationContext context: Context, - database: MusicDatabase, - downloadUtil: DownloadUtil, - savedStateHandle: SavedStateHandle, -) : ViewModel() { - val playlistId = savedStateHandle.get("playlistId")!! - - @OptIn(ExperimentalCoroutinesApi::class) - val songs = when (playlistId) { - LIKED_PLAYLIST_ID -> context.dataStore.data - .map { - it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) - } - .distinctUntilChanged() - .flatMapLatest { (sortType, descending) -> - database.likedSongs(sortType, descending) - } - - DOWNLOADED_PLAYLIST_ID -> combine( - downloadUtil.downloads.flatMapLatest { downloads -> - database.songs( - downloads.filter { (_, download) -> - download.state == STATE_COMPLETED - }.keys.toList() - ).map { songs -> - songs.map { it to downloads[it.id] } - } - }, - context.dataStore.data - .map { - it[DownloadedSongSortTypeKey].toEnum(DownloadedSongSortType.CREATE_DATE) to (it[DownloadedSongSortDescendingKey] ?: true) - } - .distinctUntilChanged() - ) { songs, (sortType, descending) -> - when (sortType) { - DownloadedSongSortType.CREATE_DATE -> songs.sortedBy { it.second?.updateTimeMs ?: 0L } - DownloadedSongSortType.NAME -> songs.sortedBy { it.first.song.title } - DownloadedSongSortType.ARTIST -> songs.sortedBy { song -> - song.first.artists.joinToString(separator = "") { it.name } - } - DownloadedSongSortType.PLAY_TIME -> songs.sortedBy { it.first.song.totalPlayTime } - } - .map { it.first } - .reversed(descending) - } - - else -> error("Unknown playlist id") - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) -} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt index cabe66ff9..8f2fb6627 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt @@ -6,6 +6,7 @@ import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.ExplorePage import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -27,7 +28,7 @@ class HomeViewModel @Inject constructor( YouTube.explore().onSuccess { explorePage.value = it }.onFailure { - it.printStackTrace() + reportException(it) } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt index b5ff00181..15fee1187 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -6,15 +6,18 @@ import android.content.Context import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import androidx.media3.exoplayer.offline.Download.STATE_COMPLETED +import androidx.media3.exoplayer.offline.Download import com.zionhuang.innertube.YouTube import com.zionhuang.music.constants.* import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.extensions.reversed import com.zionhuang.music.extensions.toEnum import com.zionhuang.music.playback.DownloadUtil import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.qualifiers.ApplicationContext +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch @@ -26,14 +29,42 @@ import javax.inject.Inject class LibrarySongsViewModel @Inject constructor( @ApplicationContext context: Context, database: MusicDatabase, + downloadUtil: DownloadUtil, ) : ViewModel() { val allSongs = context.dataStore.data .map { - it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + Triple( + it[SongFilterKey].toEnum(SongFilter.LIBRARY), + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE), + (it[SongSortDescendingKey] ?: true) + ) } .distinctUntilChanged() - .flatMapLatest { (sortType, descending) -> - database.songs(sortType, descending) + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + SongFilter.LIBRARY -> database.songs(sortType, descending) + SongFilter.LIKED -> database.likedSongs(sortType, descending) + SongFilter.DOWNLOADED -> downloadUtil.downloads.flatMapLatest { downloads -> + database.allSongs() + .flowOn(Dispatchers.IO) + .map { songs -> + songs.filter { + downloads[it.id]?.state == Download.STATE_COMPLETED + } + } + .map { songs -> + when (sortType) { + SongSortType.CREATE_DATE -> songs.sortedBy { downloads[it.id]?.updateTimeMs ?: 0L } + SongSortType.NAME -> songs.sortedBy { it.song.title } + SongSortType.ARTIST -> songs.sortedBy { song -> + song.artists.joinToString(separator = "") { it.name } + } + + SongSortType.PLAY_TIME -> songs.sortedBy { it.song.totalPlayTime } + }.reversed(descending) + } + } + } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @@ -45,22 +76,22 @@ class LibraryArtistsViewModel @Inject constructor( val allArtists = context.dataStore.data .map { Triple( - it[ArtistViewTypeKey].toEnum(ArtistViewType.ALL), + it[ArtistFilterKey].toEnum(ArtistFilter.LIBRARY), it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE), it[ArtistSortDescendingKey] ?: true ) } .distinctUntilChanged() - .flatMapLatest { (viewType, sortType, descending) -> - when (viewType) { - ArtistViewType.ALL -> database.artists(sortType, descending) - ArtistViewType.BOOKMARKED -> database.artistsBookmarked(sortType, descending) + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + ArtistFilter.LIBRARY -> database.artists(sortType, descending) + ArtistFilter.LIKED -> database.artistsBookmarked(sortType, descending) } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { allArtists.collect { artists -> artists .map { it.artist } @@ -86,30 +117,50 @@ class LibraryAlbumsViewModel @Inject constructor( ) : ViewModel() { val allAlbums = context.dataStore.data .map { - it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE) to (it[AlbumSortDescendingKey] ?: true) + Triple( + it[AlbumFilterKey].toEnum(AlbumFilter.LIBRARY), + it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE), + it[AlbumSortDescendingKey] ?: true + ) } .distinctUntilChanged() - .flatMapLatest { (sortType, descending) -> - database.albums(sortType, descending) + .flatMapLatest { (filter, sortType, descending) -> + when (filter) { + AlbumFilter.LIBRARY -> database.albums(sortType, descending) + AlbumFilter.LIKED -> database.albumsLiked(sortType, descending) + } } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + init { + viewModelScope.launch(Dispatchers.IO) { + allAlbums.collect { albums -> + albums.filter { + it.album.songCount == 0 + }.forEach { album -> + YouTube.album(album.id).onSuccess { albumPage -> + database.query { + update(album.album, albumPage) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } + } } @HiltViewModel class LibraryPlaylistsViewModel @Inject constructor( @ApplicationContext context: Context, database: MusicDatabase, - downloadUtil: DownloadUtil, ) : ViewModel() { - val likedSongCount = database.likedSongsCount() - .stateIn(viewModelScope, SharingStarted.Lazily, 0) - - val downloadedSongCount = downloadUtil.downloads.map { - it.count { (_, download) -> - download.state == STATE_COMPLETED - } - } - val allPlaylists = context.dataStore.data .map { it[PlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE) to (it[PlaylistSortDescendingKey] ?: true) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.kt index ea2a29d51..c764cc28d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.MoodAndGenres +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -18,7 +19,7 @@ class MoodAndGenresViewModel @Inject constructor() : ViewModel() { YouTube.moodAndGenres().onSuccess { moodAndGenres.value = it }.onFailure { - it.printStackTrace() + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt index 8770dfe64..fa30406bd 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt @@ -4,6 +4,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow @@ -20,7 +21,7 @@ class NewReleaseViewModel @Inject constructor() : ViewModel() { YouTube.newReleaseAlbums().onSuccess { _newReleaseAlbums.value = it }.onFailure { - it.printStackTrace() + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt index cd42a4676..f3b6e5589 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -7,6 +7,7 @@ import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.PlaylistItem import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.utils.completed +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -28,8 +29,8 @@ class OnlinePlaylistViewModel @Inject constructor( .onSuccess { playlistPage -> playlist.value = playlistPage.playlist playlistSongs.value = playlistPage.songs - }.onFailure { e -> - e.printStackTrace() + }.onFailure { + reportException(it) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt index d8dda558d..b68686009 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt @@ -5,12 +5,12 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.music.constants.StatPeriod import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Duration @@ -35,10 +35,6 @@ class StatsViewModel @Inject constructor( val mostPlayedAlbums = statPeriod.flatMapLatest { period -> database.mostPlayedAlbums(period.toTimeMillis()) - }.map { albums -> - albums.mapNotNull { id -> - YouTube.album(id, withSongs = false).getOrNull()?.album - } }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { @@ -58,5 +54,25 @@ class StatsViewModel @Inject constructor( } } } + viewModelScope.launch { + mostPlayedAlbums.collect { albums -> + albums.filter { + it.album.songCount == 0 + }.forEach { album -> + YouTube.album(album.id).onSuccess { albumPage -> + database.query { + update(album.album, albumPage) + } + }.onFailure { + reportException(it) + if (it.message?.contains("NOT_FOUND") == true) { + database.query { + delete(album.album) + } + } + } + } + } + } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt index 2e53332d3..1627041b2 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt @@ -5,6 +5,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.BrowseResult +import com.zionhuang.music.utils.reportException import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch @@ -24,7 +25,7 @@ class YouTubeBrowseViewModel @Inject constructor( YouTube.browse(browseId, params).onSuccess { result.value = it }.onFailure { - it.printStackTrace() + reportException(it) } } } diff --git a/app/src/main/res/drawable/discover_tune.xml b/app/src/main/res/drawable/discover_tune.xml new file mode 100644 index 000000000..fa2974502 --- /dev/null +++ b/app/src/main/res/drawable/discover_tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/translate.xml b/app/src/main/res/drawable/translate.xml new file mode 100644 index 000000000..c1d325b9f --- /dev/null +++ b/app/src/main/res/drawable/translate.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index adc998ec7..0c520dfb7 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -36,6 +36,9 @@ Suche YouTube Musik durchsuchen… Bibliothek durchsuchen… + Library + Liked + Downloaded Alles Titel Videos @@ -59,6 +62,7 @@ Wiederholen Radio Shuffle + Reset Details @@ -82,6 +86,7 @@ aus dem Wiedergabeverlauf entfernen Online-Suche Sync + Advanced Datum hinzugefügt @@ -255,4 +260,6 @@ App-Version New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml index 5e4f5bfec..e2b2ff4cf 100644 --- a/app/src/main/res/values-be/strings.xml +++ b/app/src/main/res/values-be/strings.xml @@ -38,6 +38,9 @@ Пошук Пошук у YouTube Music… Пошук у бібліятэцы… + Library + Liked + Downloaded Усе Песні Відэа @@ -61,6 +64,7 @@ Паўтарыць Радыё Перамяшаць + Reset Падрабязнасці @@ -84,6 +88,7 @@ Выдаліць з гісторыі Шукаць у сетцы Cінхранізацыя + Advanced Нядаўна дададзена @@ -273,4 +278,6 @@ Версія праграмы New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml index 9088e7ba1..e7c8642b0 100644 --- a/app/src/main/res/values-bn-rIN/strings.xml +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -36,6 +36,9 @@ খুঁজুন খুঁজুন YouTube Music এ খুঁজুন লাইব্রেরি তে + Library + Liked + Downloaded সব সংগীত ভিডিও @@ -59,6 +62,7 @@ পুনরায় চেষ্টা করুন বেতার এলোমেলো + Reset বিস্তারিত @@ -82,6 +86,7 @@ ইতিহাস থেকে অপসারণ অনলাইন এ খুঁজুন সুসংগত + Advanced সময় সম্পাদনা করা হয়েছে @@ -255,4 +260,6 @@ অ্যাপ সংস্করণ New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index a7642851e..029bd1a7f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -38,6 +38,9 @@ Vyhledávání Hledat v YouTube Music… Hledat v knihovně… + Library + Liked + Downloaded Vše Skladby Videa @@ -61,6 +64,7 @@ Zkusit znovu Rádio Náhodně + Reset Podrobnosti @@ -84,6 +88,7 @@ Remove from history Hledat online Sync + Advanced Datum přidání @@ -267,4 +272,6 @@ Verze aplikace New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 90924cfd5..323f1b0a4 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -37,6 +37,9 @@ Buscar Buscar en Youtube Music… Buscar en la biblioteca… + Library + Liked + Downloaded Todo Canciones Vídeos @@ -60,6 +63,7 @@ Reintentar Radio Mezclar + Reset Detalles @@ -83,6 +87,7 @@ Eliminar del historial Buscar online Sincronizar + Advanced Fecha añadida @@ -264,4 +269,6 @@ Versión de la app New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 152449763..1cddb06c6 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -36,6 +36,9 @@ جستجو جستجو در یوتیوب موزیک… جستجوی کتاب‌خانه… + Library + Liked + Downloaded همه آهنگ‌ها فیلم‌ها @@ -59,6 +62,7 @@ تلاش‌مجدد رادیو بُرزدن + Reset Details @@ -82,6 +86,7 @@ Remove from history Search online Sync + Advanced تاریخ اضافه‌شده @@ -255,4 +260,6 @@ نسخه‌ی برنامه New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 2e830364e..25c20df95 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -36,6 +36,9 @@ Etsi Search YouTube Music… Search library… + Library + Liked + Downloaded Kaikki Kappaleet Videot @@ -59,6 +62,7 @@ Toisto Radio Sekoita + Reset Details @@ -82,6 +86,7 @@ Remove from history Search online Sync + Advanced Lisäyspäivä @@ -255,4 +260,6 @@ Sovelluksen versio New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 342970be2..4afb606e5 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1,10 +1,10 @@ - + - Home + Accueil Chansons Artistes Albums - Listes de lecture + Listes de cours @@ -14,102 +14,107 @@ - History - Stats - Mood and Genres - Account - Quick picks - Listen to songs to generate your quick picks - New release albums + Historique + Statistiques + Humeurs et genres + Compte + Sélections rapides + Écoutez quelques musiques pour générer vos sélections rapides + Nouveautés - Today - Yesterday - This week - Last week + Aujourd\'hui + Hier + Cette semaine + Semaine dernière - Most played songs - Most played artists - Most played albums + Musiques les plus jouées + Artistes les plus joués + Albums les plus joués - Recherche - Search YouTube Music… - Search library… + Recherché + Rechercher sur Youtube Musique… + Rechercher dans votre librairie… + Librairie + Aimé + Télécharger Tout Chansons Vidéos Albums Artistes - Listes de lecture - Community playlists - Featured playlists - Bookmarked - No results found + Playlist + Playlist de la communauté + Playlist mise en avant + Favoris + Aucun résultat trouvé - From your library + De votre bibliothèque - Liked songs - Downloaded songs - The playlist is empty + Chansons aimées + Musiques téléchargées + La playlist est vide Réessayer Radio Lecture aléatoire + Reset - Details + Détails Modifier - Start radio - Play + Démarrer la radio + Jouer Jouer à la suite Ajouter à la file d\'attente Ajouter à la bibliothèque - Remove from library + Supprimer de la bibliothèque Télécharger - Downloading + Téléchargement Supprimer le téléchargement - Import playlist + Importer une liste de lecture Ajouter à la liste de lecture - View artist - View album - Refetch - Share - Effacer - Remove from history - Search online - Sync + Voir l\'artiste + Voir l\'album + Récupérer + Partager + Effaceur + Supprimer de l\'historique + Rechercher en ligne + Synchroniser + Advanced Date d\'ajout Nom Artiste - Year - Song count - Length - Play time - Custom order + Année + Nombre de chansons + Longueur + Récréation + Commande personnalisée - Media id - MIME type + Identifiant du média + Type MIME Codecs - Bitrate - Sample rate - Loudness + Débit + Taux d\'échantillonnage + Intensité Volume - File size - Unknown - Copied to clipboard + Taille du fichier + Inconnu + Copié dans le presse-papier - Edit lyrics - Search lyrics + Modifier les paroles + Rechercher des paroles - Editer la chanson + Éditer la chanson Titre de la chanson Artiste de la chanson Le titre de la chanson ne peut pas être vide. @@ -117,12 +122,12 @@ Sauvegarder Choisir la liste de lecture - Editer la liste de lecture + Editer la playlist Créer une liste de lecture - Nom de la liste de lecture - Le nom de la liste de lecture ne peut pas être vide. + Nom de la playlist + Le nom de la playlist ne peut pas être vide. - Editer l\'artiste + Éditer l\'artiste Nom de l\'artiste Le nom de l\'artiste ne peut être vide. @@ -145,120 +150,125 @@ %d playlist %d playlists - %d albums + %d playlists - - %d week - %d weeks + + %d semaine + %d semaines + %d semaines - - %d month - %d months + + %d mois + %d mois + %d mois - - %d year - %d years + + %d ans + %d ans + %d ans - Playlist imported - Removed \"%s\" from playlist - Playlist synced - Undo + Liste de lecture importée + \"%s\" supprimé de la liste de lecture + Liste de lecture synchronisée + annuler - Lyrics not found - Sleep timer - End of song + Paroles introuvables + Minuterie de sommeil + Fin de chanson - 1 minute + %d minute %d minutes %d minutes - No stream available - No network connection - Timeout - Unknown error + Aucun flux disponible + Pas de connexion réseau + Temps libre + Erreur inconnue Like - Remove like + Enlever le like - All songs - Searched songs + Toutes les chansons + Chansons recherchées Lecteur de musique Paramètres - Appearance - Enable dynamic theme - Dark theme - On - Off - Follow system - Pure black - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right + Apparence + Activer le thème dynamique + Thème sombre + Activé + Désactivé + Suivre le système + Noir pur + Menu ouvert par défaut + Personnaliser les menus de navigation + Position du texte des paroles + Gauche + Centre + Droite - Content - Login + Contenu + Connexion Langue du contenu par défaut Pays du contenu par défaut Système par défaut - Enable proxy - Proxy type - Proxy URL - Restart to take effect + Activer le proxy + Type de proxy + URL proxy + Redémarrer pour prendre effet - Player and audio - Audio quality + Lecteur et audio + Qualité audio Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer + Haut + Faible + File d\'attente persistante + Ignorer le silence + Normalisation audio + Égaliseur - Storage + Stockage Cache - Image Cache - Song Cache - Max cache size - Unlimited - Clear all downloads - Max image cache size - Clear image cache - Max song cache size - Clear song cache - %s used + Cache d\'images + Cache de chanson + Taille maximale du cache + Illimité + Effacer tous les téléchargements + Taille maximale du cache d\'images + Effacer le cache des images + Taille maximale du cache de chansons + Effacer le cache de la chanson + %s utilisé - Privacy - Pause listen history - Clear listen history - Are you sure to clear all listen history? - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider + Confidentialité + Suspendre l\'historique d\'écoute + Effacer l\'historique d\'écoute + Voulez-vous vraiment effacer tout l\'historique d\'écoute? + Suspendre l\'historique des recherches + Effacer l\'historique + Voulez-vous vraiment effacer tout l\'historique de recherche? + Activer le fournisseur de paroles KuGou - Backup and restore - Backup - Restore - Imported playlist - Backup created successfully - Couldn\'t create backup - Failed to restore backup + Sauvegarde et restauration + Sauvegarde + Restaurer + Liste de lecture importée + Sauvegarde créée avec succès + Impossible de créer la sauvegarde + Échec de la restauration de la sauvegarde - À propos + À propos de Version de l\'application - New version available + Nouvelle version disponible + Translation Models + Clear translation models diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index cc35c1078..d6eeefef4 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -15,8 +15,8 @@ Előzmény Statisztika - Mood and Genres - Account + Hangulat és műfajok + Fiók Gyors választás Hallgasson meg néhány dalt a gyors választások elkészítéséhez Új kiadású albumok @@ -36,6 +36,9 @@ Keresés YouTube Music keresés… Keresés könyvtárban… + Könyvtár + Lájkolt + Letöltött Mind Dalok Videók @@ -44,7 +47,7 @@ Listák Közösségi listák Kiemelt lejátszási listák - Bookmarked + Könyvjelzőzött Nincs találat @@ -59,6 +62,7 @@ Újra Rádió Keverés + Reset Részletek @@ -82,6 +86,7 @@ Előzményből eltávolít Keresés online Szinkron. + Advanced Létrehozás dátuma @@ -143,16 +148,16 @@ %d lejátszólista - %d week - %d weeks + %d hét + %d hét - %d month - %d months + %d hónap + %d hónap - %d year - %d years + %d év + %d év @@ -255,4 +260,6 @@ App verzió New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 95bede859..cd8b84f28 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -35,6 +35,9 @@ Caru Cari di YouTube Music… Cari di perpustakaan… + Library + Liked + Downloaded Semua Lagu Video @@ -58,6 +61,7 @@ Mencoba kembali Radio Acak + Reset Rincian @@ -81,6 +85,7 @@ Remove from history Cari secara online Sync + Advanced Tanggal ditambahkan @@ -246,4 +251,6 @@ Versi aplikasi New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index b82d543f7..0ee41e82d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -37,6 +37,9 @@ Cerca Cerca su YouTube Music… Cerca nella libreria… + Library + Liked + Downloaded Tutto Brani Video @@ -60,6 +63,7 @@ Riprova Radio Mischia + Reset Dettagli @@ -83,6 +87,7 @@ Rimuovi da cronologia Cerca online Sincronizza + Advanced Data di aggiunta @@ -264,4 +269,6 @@ Versione dell\'app New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index f95b6b252..70f770914 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -35,6 +35,9 @@ 検索 YouTube Musicを検索… ライブラリを検索… + Library + Liked + Downloaded すべて 動画 @@ -58,6 +61,7 @@ 再試行 ラジオ シャッフル + Reset 詳細 @@ -81,6 +85,7 @@ 履歴から削除 オンラインで検索 同期 + Advanced 追加日時 @@ -246,4 +251,6 @@ アプリのバージョン New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 62486122a..8f53fcd23 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -35,6 +35,9 @@ 검색 Search YouTube Music… Search library… + Library + Liked + Downloaded 모두 노래 비디오 @@ -58,6 +61,7 @@ 다시 Radio 셔플 + Reset Details @@ -81,6 +85,7 @@ Remove from history Search online Sync + Advanced 추가된 날짜 @@ -246,4 +251,6 @@ 앱 버전 New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 02283bfca..8d70e692c 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -36,6 +36,9 @@ തിരയുക യൂട്യൂബ് സംഗീതം തിരയുക… തിരയൽ ലൈബ്രറി… + Library + Liked + Downloaded എല്ലാം പാട്ടുകൾ വീഡിയോകൾ @@ -59,6 +62,7 @@ വീണ്ടും ശ്രമിക്കുക റേഡിയോ ഷഫിൾ + Reset Details @@ -82,6 +86,7 @@ Remove from history Search online Sync + Advanced ചേർത്ത തീയതി @@ -255,4 +260,6 @@ അപ്ലിക്കേഷൻ പതിപ്പ് New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index cee9296e8..c9cf5e63c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -36,6 +36,9 @@ Zoeken Zoeken via YouTube Music… Zoeken in bibliotheek + Library + Liked + Downloaded Alles Nummers Videos @@ -59,6 +62,7 @@ Opnieuw Radio Shuffle + Reset Details @@ -82,6 +86,7 @@ Verwijder uit geschiedenis Zoek online Synchroniseer + Advanced Datum toegevoegd @@ -255,4 +260,6 @@ App versie New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 940e2e4c2..eaacaf138 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -36,6 +36,9 @@ ସନ୍ଧାନ କରନ୍ତୁ ୟୁଟ୍ୟୁବ୍ ମ୍ୟୁଜିକ୍ ଖୋଜ… ଲାଇବ୍ରେରୀ ଖୋଜ… + Library + Liked + Downloaded ସମସ୍ତ ସଙ୍ଗୀତ ଗୁଡ଼ିକ ଭିଡିଓ ଗୁଡ଼ିକ @@ -59,6 +62,7 @@ ପୁନଃ ଚେଷ୍ଟା କରନ୍ତୁ ରେଡିଓ ଶଫଲ୍ କରନ୍ତୁ + Reset ବିବରଣୀ @@ -82,6 +86,7 @@ Remove from history ଅନଲାଇନ୍ ସନ୍ଧାନ କରନ୍ତୁ Sync + Advanced ତାରିଖ ରେ ଯୋଡା ଯାଇଛି @@ -255,4 +260,6 @@ ଆପ୍ ସଂସ୍କରଣ New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 716626bb7..7e70755e9 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -36,6 +36,9 @@ ਖੋਜੋ ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… ਲਾਇਬ੍ਰੇਰੀ ਖੋਜੋ… + Library + Liked + Downloaded ਸਾਰੇ ਗੀਤ ਵੀਡੀਓ @@ -59,6 +62,7 @@ ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਰੇਡੀਓ ਸ਼ਫਲ ਕਰੋ + Reset ਵੇਰਵੇ @@ -82,6 +86,7 @@ ਅਤੀਤ ਵਿੱਚੋਂ ਹਟਾਓ ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ ਸਿੰਕਰੋਨਾਈਜ਼ ਕਰੋ + Advanced ਮਿਤੀ @@ -255,4 +260,6 @@ ਐਪ ਸੰਸਕਰਣ New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml index 234a85b47..5b8ff7887 100644 --- a/app/src/main/res/values-pl/strings.xml +++ b/app/src/main/res/values-pl/strings.xml @@ -38,6 +38,9 @@ Szukaj Szukaj w YouTube Music… Szukaj w bibliotece… + Biblioteka + Polubione + Pobrane Wszystko Utwory Filmy @@ -61,6 +64,7 @@ Spróbuj ponownie Radio Losowo + Reset Szczegóły @@ -84,6 +88,7 @@ Usuń z historii Szukaj online Synchronizuj + Advanced Data dodania @@ -273,4 +278,6 @@ Wersja aplikacji Dostępna nowa wersja + Translation Models + Clear translation models diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e671ed8ba..80e022273 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -37,6 +37,9 @@ Pesquisar Pesquisar no YouTube Music… Pesquisar na biblioteca… + Library + Liked + Downloaded Tudo Músicas Vídeos @@ -60,6 +63,7 @@ Tentar novamente Rádio Aleatório + Reset Detalhes @@ -83,6 +87,7 @@ Remove from history Search online Sync + Advanced Quando adicionada @@ -264,4 +269,6 @@ Versão do aplicativo New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index bae17e29e..7d2b665f3 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -38,6 +38,9 @@ Поиск Поиск в YouTube Music… Поиск в библиотеке… + Библиотека + Понравившиеся + Загруженные Все Композиции Видео @@ -61,6 +64,7 @@ Повторить Радио Перемешать + Сбросить Подробнее @@ -84,6 +88,7 @@ Удалить из истории Поиск в Интернете Синхронизация + Контроль аудио Недавно добавленные @@ -273,4 +278,6 @@ Версия приложения Доступна новая версия + Translation Models + Clear translation models diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index eac93c378..2a1aad6ca 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -36,6 +36,9 @@ Sök Search YouTube Music… Search library… + Library + Liked + Downloaded Alla Låtar Videor @@ -59,6 +62,7 @@ Försök igen Radio Blanda + Reset Details @@ -82,6 +86,7 @@ Remove from history Search online Sync + Advanced Datum tillagd @@ -255,4 +260,6 @@ App version New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 42441d045..08cddf1ee 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -36,6 +36,9 @@ Arama YouTube Müzik\'te Ara… Kütüphanede ara… + Kütüphane + Beğenilenler + İndirilenler Hepsi Şarkılar Videolar @@ -59,6 +62,7 @@ Yeniden dene Radyo Karıştır + Reset Ayrıntılar @@ -82,6 +86,7 @@ Geçmişten kaldır Çevrimiçi arama Eşitle + Advanced Eklendiği tarih @@ -255,4 +260,6 @@ Uygulama sürümü Yeni sürüm mevcut + Translation Models + Clear translation models diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 686cf8591..83475b8e8 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -38,6 +38,9 @@ Пошук Пошук в YouTube Music… Пошук в бібліотеці… + Бібліотека + Вподобані + Завантажені Всі Композиції Відео @@ -61,6 +64,7 @@ Повторювати Радіо Перемішати + Скинути Детальніше @@ -84,6 +88,7 @@ Видалити з історії Пошук в Інтернеті Синхронізація + Контроль аудіо Нещодавно додані @@ -273,4 +278,6 @@ Версія застосунку Доступна нова версія + Translation Models + Clear translation models diff --git a/app/src/main/res/values-vn/strings.xml b/app/src/main/res/values-vi/strings.xml similarity index 96% rename from app/src/main/res/values-vn/strings.xml rename to app/src/main/res/values-vi/strings.xml index 659aff00b..79f87da9e 100644 --- a/app/src/main/res/values-vn/strings.xml +++ b/app/src/main/res/values-vi/strings.xml @@ -35,6 +35,9 @@ Tìm kiếm Tìm kiếm YouTube Music… Tìm kiếm thư viện… + Library + Liked + Downloaded Tất cả Bài hát Videos @@ -58,6 +61,7 @@ Thử lại Đài Trộn + Reset Chi tiết @@ -81,6 +85,7 @@ Xoá khỏi lịch sử Tìm kiếm trực tuyến Đồng bộ + Advanced Ngày thêm vào @@ -126,31 +131,24 @@ - Bài hát %d Các bài hát %d - Tác giả %d Các tác giả %d - %d album %d albums - Danh sách phát %d Các danh sách phát %d - %d tuần %d tuần - %d tháng %d tháng - %d năm %d năm @@ -165,7 +163,6 @@ Hẹn giờ Kết thúc bài hát - 1 phút %d phút Không có stream @@ -254,4 +251,6 @@ Phiên bản ứng dụng New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 423d76a33..1ce3da49f 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -35,6 +35,9 @@ 搜索 搜索 YouTube Music… 搜索媒体库… + Library + Liked + Downloaded 全部 歌曲 视频 @@ -58,6 +61,7 @@ 重试 电台 随机播放 + Reset 详情 @@ -81,6 +85,7 @@ 从记录中移除 在线搜索 同步 + Advanced 新建时间 @@ -246,4 +251,6 @@ 应用版本 New version available + Translation Models + Clear translation models diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 2cd8eadef..49357eebc 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -35,6 +35,9 @@ 搜尋 搜尋 YouTube Music… 搜尋媒體庫… + 媒體庫 + 已按讚 + 已下載 全部 歌曲 影片 @@ -58,6 +61,7 @@ 重試 電台 隨機播放 + 重設 詳細資訊 @@ -81,6 +85,7 @@ 從記錄中移除 線上搜尋 同步 + 進階 新增時間 @@ -246,4 +251,6 @@ 應用程式版本 發現新版本 + 翻譯模型 + 清除翻譯模型 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index eda7659dc..cc29fc3cf 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -35,6 +35,9 @@ Search Search YouTube Music… Search library… + Library + Liked + Downloaded All Songs Videos @@ -58,6 +61,7 @@ Retry Radio Shuffle + Reset Details @@ -81,6 +85,7 @@ Remove from history Search online Sync + Advanced Date added @@ -254,4 +259,6 @@ App version New version available + Translation Models + Clear translation models diff --git a/build.gradle.kts b/build.gradle.kts index c5b81cacb..5dcafdea6 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -4,6 +4,10 @@ plugins { } buildscript { + val isFullBuild by extra { + gradle.startParameter.taskNames.none { task -> task.contains("foss", ignoreCase = true) } + } + repositories { google() mavenCentral() @@ -12,9 +16,11 @@ buildscript { dependencies { classpath(libs.gradle) classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) - classpath(libs.google.services) - classpath(libs.firebase.crashlytics.plugin) - classpath(libs.firebase.perf.plugin) + if (isFullBuild) { + classpath(libs.google.services) + classpath(libs.firebase.crashlytics.plugin) + classpath(libs.firebase.perf.plugin) + } } } diff --git a/crowdin.yml b/crowdin.yml new file mode 100644 index 000000000..7a6371886 --- /dev/null +++ b/crowdin.yml @@ -0,0 +1,3 @@ +files: + - source: /app/src/main/res/values/strings.xml + translation: /app/src/main/res/values-%android_code%/strings.xml diff --git a/fastlane/metadata/android/en-US/changelogs/18.txt b/fastlane/metadata/android/en-US/changelogs/18.txt new file mode 100644 index 000000000..4334ebd2b --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/18.txt @@ -0,0 +1,3 @@ +- Improve library design +- Lyrics translator (full version only) +- Minor enhancement and bug fixes \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 31f77b1d0..291b47f43 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -83,5 +83,8 @@ firebase-config = { group = "com.google.firebase", name = "firebase-config-ktx" firebase-perf = { group = "com.google.firebase", name = "firebase-perf-ktx" } firebase-perf-plugin = { module = "com.google.firebase:perf-plugin", version = "1.4.2" } +mlkit-language-id = { group = "com.google.mlkit", name = "language-id", version = "17.0.4" } +mlkit-translate = { group = "com.google.mlkit", name = "translate", version = "17.0.1" } + [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index f97b3dc88..b41ffba18 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -239,7 +239,7 @@ class InnerTube { suspend fun getSwJsData() = httpClient.get("https://music.youtube.com/sw.js_data") suspend fun accountMenu(client: YouTubeClient) = httpClient.post("account/account_menu") { - ytClient(client) + ytClient(client, setLogin = true) setBody(AccountMenuBody(client.toContext(locale, visitorData))) } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 0376212e2..18bf4fed6 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -110,14 +110,19 @@ object YouTube { ?.mapNotNull { it.musicResponsiveListItemRenderer } ?.mapNotNull(SearchSummaryPage.Companion::fromMusicResponsiveListItemRenderer) .orEmpty() - ).takeIf { it.isNotEmpty() } ?: return@mapNotNull null + ) + .distinctBy { it.id } + .ifEmpty { null } ?: return@mapNotNull null ) else SearchSummary( title = it.musicShelfRenderer?.title?.runs?.firstOrNull()?.text ?: return@mapNotNull null, - items = it.musicShelfRenderer.contents?.mapNotNull { - SearchSummaryPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) - }?.ifEmpty { null } ?: return@mapNotNull null + items = it.musicShelfRenderer.contents + ?.mapNotNull { + SearchSummaryPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + } + ?.distinctBy { it.id } + ?.ifEmpty { null } ?: return@mapNotNull null ) }!! ) @@ -485,8 +490,8 @@ object YouTube { .jsonPrimitive.content } - suspend fun accountInfo(): Result = runCatching { - innerTube.accountMenu(WEB_REMIX).body().actions[0].openPopupAction.popup.multiPageMenuRenderer.header?.activeAccountHeaderRenderer?.toAccountInfo() + suspend fun accountInfo(): Result = runCatching { + innerTube.accountMenu(WEB_REMIX).body().actions[0].openPopupAction.popup.multiPageMenuRenderer.header?.activeAccountHeaderRenderer?.toAccountInfo()!! } @JvmInline