From c84805317883db5dd2538f2593f2457439ec6865 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 22 Nov 2022 16:55:23 +0800 Subject: [PATCH 001/323] Player and search --- app/build.gradle.kts | 75 +- .../5.json | 748 ++++++++++++++++ app/src/main/AndroidManifest.xml | 5 +- app/src/main/java/com/zionhuang/music/App.kt | 4 +- .../music/compose/ComposeActivity.kt | 368 ++++++++ .../music/compose/component/AppBar.kt | 260 ++++++ .../music/compose/component/BottomSheet.kt | 307 +++++++ .../music/compose/component/IconButton.kt | 41 + .../music/compose/component/Items.kt | 383 +++++++++ .../music/compose/component/Lyrics.kt | 218 +++++ .../compose/component/PlayingIndicator.kt | 71 ++ .../compose/component/ProgressIndicator.kt | 20 + .../component/shimmer/ListItemPlaceholder.kt | 45 + .../compose/component/shimmer/ShimmerHost.kt | 38 + .../component/shimmer/TextPlaceholder.kt | 26 + .../music/compose/player/MiniPlayer.kt | 116 +++ .../zionhuang/music/compose/player/Player.kt | 309 +++++++ .../zionhuang/music/compose/player/Queue.kt | 194 +++++ .../music/compose/player/Thumbnail.kt | 92 ++ .../music/compose/screens/AlbumScreen.kt | 173 ++++ .../music/compose/screens/ArtistScreen.kt | 8 + .../music/compose/screens/HomeScreen.kt | 150 ++++ .../compose/screens/OnlineSearchResult.kt | 172 ++++ .../zionhuang/music/compose/screens/Screen.kt | 48 ++ .../music/compose/screens/SearchScreen.kt | 218 +++++ .../screens/library/LibraryAlbumsScreen.kt | 83 ++ .../screens/library/LibraryArtistsScreen.kt | 73 ++ .../screens/library/LibraryPlaylistsScreen.kt | 133 +++ .../screens/library/LibrarySongsScreen.kt | 186 ++++ .../zionhuang/music/compose/theme/Theme.kt | 104 +++ .../music/compose/utils/FadingEdge.kt | 24 + .../music/compose/utils/PreferenceUtils.kt | 62 ++ .../music/constants/ComposeConstants.kt | 20 + .../zionhuang/music/constants/Constants.kt | 5 - .../music/constants/PreferenceKeys.kt | 18 + .../com/zionhuang/music/db/MusicDatabase.kt | 6 +- .../com/zionhuang/music/db/daos/AlbumDao.kt | 19 +- .../com/zionhuang/music/db/daos/ArtistDao.kt | 5 +- .../music/db/daos/SearchHistoryDao.kt | 5 +- .../com/zionhuang/music/db/entities/Album.kt | 2 + .../music/db/entities/AlbumEntity.kt | 2 + .../music/db/entities/AlbumWithSongs.kt | 34 + .../com/zionhuang/music/db/entities/Artist.kt | 2 + .../music/db/entities/ArtistEntity.kt | 2 + .../zionhuang/music/db/entities/Playlist.kt | 2 + .../music/db/entities/PlaylistEntity.kt | 2 + .../com/zionhuang/music/db/entities/Song.kt | 23 +- .../music/db/entities/SongAlbumMap.kt | 2 +- .../zionhuang/music/db/entities/SongEntity.kt | 2 + .../music/db/entities/SortedSongAlbumMap.kt | 13 + .../com/zionhuang/music/extensions/AnyExt.kt | 110 ++- .../zionhuang/music/extensions/PlayerExt.kt | 57 +- .../music/extensions/SharedPreferencesExt.kt | 8 +- .../zionhuang/music/models/MediaMetadata.kt | 2 + .../music/playback/BitmapProvider.kt | 15 +- .../zionhuang/music/playback/MusicService.kt | 6 +- .../music/playback/PlayerConnection.kt | 153 ++++ .../zionhuang/music/playback/SongPlayer.kt | 35 +- .../zionhuang/music/repos/SongRepository.kt | 72 +- .../music/repos/base/LocalRepository.kt | 6 +- .../activities/base/ThemedBindingActivity.kt | 4 +- .../music/ui/fragments/WebViewFragment.kt | 8 +- .../settings/ContentSettingsFragment.kt | 4 +- .../com/zionhuang/music/utils/ThemeUtil.kt | 25 +- .../java/com/zionhuang/music/utils/Utils.kt | 19 + .../com/zionhuang/music/utils/YouTubeUtils.kt | 21 - .../music/utils/preference/Preference.kt | 3 +- .../music/viewmodels/PlaybackViewModel.kt | 4 + .../music/viewmodels/SearchViewModel.kt | 34 + .../music/viewmodels/SongsViewModel.kt | 24 +- .../music/viewmodels/SuggestionViewModel.kt | 54 +- .../zionhuang/music/youtube/YouTubeAlbum.kt | 105 +++ .../main/res/drawable/avd_pause_to_play.xml | 3 +- .../main/res/drawable/avd_play_to_pause.xml | 3 +- app/src/main/res/drawable/avd_skip_next.xml | 7 +- app/src/main/res/drawable/ic_arrow_back.xml | 13 +- .../main/res/drawable/ic_arrow_downward.xml | 8 +- .../main/res/drawable/ic_arrow_top_left.xml | 2 +- app/src/main/res/drawable/ic_arrow_upward.xml | 10 +- app/src/main/res/drawable/ic_home.xml | 2 +- .../main/res/drawable/ic_navigate_next.xml | 1 + app/src/main/res/drawable/ic_play.xml | 2 +- .../res/drawable/ic_radio_button_checked.xml | 10 + .../drawable/ic_radio_button_unchecked.xml | 10 + app/src/main/res/drawable/ic_repeat.xml | 2 +- app/src/main/res/drawable/ic_repeat_one.xml | 10 +- app/src/main/res/drawable/ic_skip_next.xml | 3 +- .../main/res/drawable/ic_skip_previous.xml | 7 +- app/src/main/res/values-night/styles.xml | 20 - app/src/main/res/values/styles.xml | 15 + app/src/main/res/values/themes_custom.xml | 22 - build.gradle.kts | 29 +- gradle.properties | 40 +- innertube/build.gradle.kts | 3 +- .../com/zionhuang/innertube/models/Runs.kt | 2 +- kugou/build.gradle.kts | 3 +- material-theme-builder/.gitignore | 1 + material-theme-builder/build.gradle.kts | 12 + .../google/material/themebuilder/MyClass.java | 4 + .../material/themebuilder/blend/Blend.java | 91 ++ .../material/themebuilder/hct/Cam16.java | 420 +++++++++ .../google/material/themebuilder/hct/Hct.java | 127 +++ .../material/themebuilder/hct/HctSolver.java | 672 +++++++++++++++ .../themebuilder/hct/ViewingConditions.java | 197 +++++ .../themebuilder/palettes/CorePalette.java | 73 ++ .../themebuilder/palettes/TonalPalette.java | 76 ++ .../themebuilder/quantize/PointProvider.java | 26 + .../quantize/PointProviderLab.java | 57 ++ .../themebuilder/quantize/Quantizer.java | 21 + .../quantize/QuantizerCelebi.java | 57 ++ .../themebuilder/quantize/QuantizerMap.java | 41 + .../quantize/QuantizerResult.java | 28 + .../quantize/QuantizerWsmeans.java | 229 +++++ .../themebuilder/quantize/QuantizerWu.java | 407 +++++++++ .../material/themebuilder/scheme/Scheme.java | 813 ++++++++++++++++++ .../material/themebuilder/score/Score.java | 183 ++++ .../themebuilder/utils/ColorUtils.java | 252 ++++++ .../themebuilder/utils/MathUtils.java | 134 +++ .../themebuilder/utils/StringUtils.java | 34 + settings.gradle.kts | 51 ++ 120 files changed, 9574 insertions(+), 346 deletions(-) create mode 100644 app/schemas/com.zionhuang.music.db.MusicDatabase/5.json create mode 100644 app/src/main/java/com/zionhuang/music/compose/ComposeActivity.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/AppBar.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/BottomSheet.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/IconButton.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/Items.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/Lyrics.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/PlayingIndicator.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/ProgressIndicator.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/shimmer/ListItemPlaceholder.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/shimmer/ShimmerHost.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/component/shimmer/TextPlaceholder.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/player/MiniPlayer.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/player/Player.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/player/Queue.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/player/Thumbnail.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/AlbumScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/ArtistScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/HomeScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/OnlineSearchResult.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/Screen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/SearchScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryAlbumsScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryArtistsScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryPlaylistsScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/screens/library/LibrarySongsScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/theme/Theme.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/utils/FadingEdge.kt create mode 100644 app/src/main/java/com/zionhuang/music/compose/utils/PreferenceUtils.kt create mode 100644 app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt create mode 100644 app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt create mode 100644 app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt create mode 100644 app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt create mode 100644 app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt delete mode 100644 app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt create mode 100644 app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt create mode 100644 app/src/main/res/drawable/ic_radio_button_checked.xml create mode 100644 app/src/main/res/drawable/ic_radio_button_unchecked.xml delete mode 100644 app/src/main/res/values/themes_custom.xml create mode 100644 material-theme-builder/.gitignore create mode 100644 material-theme-builder/build.gradle.kts create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/MyClass.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/blend/Blend.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/hct/Cam16.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/hct/Hct.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/hct/HctSolver.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/hct/ViewingConditions.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/palettes/CorePalette.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/palettes/TonalPalette.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/PointProvider.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/PointProviderLab.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/Quantizer.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/QuantizerCelebi.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/QuantizerMap.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/QuantizerResult.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/QuantizerWsmeans.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/quantize/QuantizerWu.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/scheme/Scheme.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/score/Score.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/utils/ColorUtils.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/utils/MathUtils.java create mode 100644 material-theme-builder/src/main/java/com/google/material/themebuilder/utils/StringUtils.java diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 9d1ecfb8d..6efce77ff 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,15 +1,15 @@ plugins { id("com.android.application") - id("kotlin-android") + kotlin("android") id("kotlin-parcelize") id("kotlin-kapt") id("androidx.navigation.safeargs") - id("kotlinx-serialization") - id("dev.rikka.tools.materialthemebuilder") + @Suppress("DSL_SCOPE_VIOLATION") + alias(libs.plugins.kotlin.serialization) } android { - compileSdk = 32 + compileSdk = 33 buildToolsVersion = "30.0.3" defaultConfig { applicationId = "com.zionhuang.music" @@ -54,6 +54,10 @@ android { buildFeatures { viewBinding = true dataBinding = true + compose = true + } + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } packagingOptions { resources { @@ -84,46 +88,34 @@ android { } } -materialThemeBuilder { - themes { - for ((name, color) in listOf( - "Red" to "F44336", - "Pink" to "E91E63", - "Purple" to "9C27B0", - "DeepPurple" to "673AB7", - "Indigo" to "3F51B5", - "Blue" to "2196F3", - "LightBlue" to "03A9F4", - "Cyan" to "00BCD4", - "Teal" to "009688", - "Green" to "4FAF50", - "LightGreen" to "8BC3A4", - "Lime" to "CDDC39", - "Yellow" to "FFEB3B", - "Amber" to "FFC107", - "Orange" to "FF9800", - "DeepOrange" to "FF5722", - "Brown" to "795548", - "BlueGrey" to "607D8F", - "Sakura" to "FF9CA8" - )) { - create("Material$name") { - lightThemeFormat = "ThemeOverlay.Light.%s" - lightThemeParent = "AppTheme" - darkThemeFormat = "ThemeOverlay.Dark.%s" - darkThemeParent = "AppTheme" - primaryColor = "#$color" - } - } - } -} - dependencies { // Kotlin implementation("org.jetbrains.kotlinx:kotlinx-coroutines-core:1.6.4") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.6.4") implementation("org.jetbrains.kotlinx:kotlinx-coroutines-test:1.6.4") implementation("org.jetbrains.kotlinx:kotlinx-serialization-json:1.4.0") + // Compose + implementation(libs.compose.runtime) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.ui.util) + implementation(libs.compose.ui.tooling) + implementation(libs.compose.activity) + implementation(libs.compose.viewmodel) + implementation(libs.compose.livedata) + implementation(libs.compose.navigation) + implementation(libs.compose.animation) + implementation(libs.compose.animation.graphics) + implementation(libs.compose.material3) + implementation(libs.compose.material.windowsize) + implementation(libs.compose.material.icon.core) + implementation(libs.compose.material.icon.extended) + implementation(projects.materialThemeBuilder) + + implementation(libs.compose.shimmer) + + implementation(libs.palette) + implementation(libs.systemUiController) // AndroidX implementation("androidx.core:core-ktx:1.8.0") implementation("androidx.appcompat:appcompat:1.5.1") @@ -155,6 +147,7 @@ dependencies { implementation("androidx.test:monitor:1.5.0") testImplementation("androidx.paging:paging-common-ktx:3.1.1") implementation("androidx.paging:paging-rxjava3:3.1.1") + implementation(libs.paging.compose) // Room implementation("androidx.room:room-runtime:2.4.3") kapt("androidx.room:room-compiler:2.4.3") @@ -163,16 +156,16 @@ dependencies { implementation("androidx.room:room-paging:2.4.3") testImplementation("androidx.room:room-testing:2.4.3") // YouTube API - implementation(project(mapOf("path" to ":innertube"))) + implementation(projects.innertube) // KuGou - implementation(project(mapOf("path" to ":kugou"))) + implementation(projects.kugou) // Apache Utils implementation("org.apache.commons:commons-lang3:3.12.0") implementation("org.apache.commons:commons-text:1.9") // OkHttp implementation("com.squareup.okhttp3:okhttp:4.10.0") // Coil - implementation("io.coil-kt:coil:2.2.1") + implementation(libs.coil) // Fast Scroll implementation("me.zhanghai.android.fastscroll:library:1.1.8") // Markdown diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/5.json b/app/schemas/com.zionhuang.music.db.MusicDatabase/5.json new file mode 100644 index 000000000..48533ee45 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.MusicDatabase/5.json @@ -0,0 +1,748 @@ +{ + "formatVersion": 1, + "database": { + "version": 5, + "identityHash": "2ab124580a16b74c86883a1a06edae27", + "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, `isTrash` INTEGER NOT NULL, `download_state` INTEGER NOT NULL, `create_date` INTEGER NOT NULL, `modify_date` INTEGER NOT NULL, 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": "isTrash", + "columnName": "isTrash", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "downloadState", + "columnName": "download_state", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "create_date", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modify_date", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `author` TEXT, `authorId` TEXT, `year` INTEGER, `thumbnailUrl` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "author", + "columnName": "author", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "authorId", + "columnName": "authorId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "year", + "columnName": "year", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "songId", + "artistId" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "songId", + "albumId" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "albumId", + "artistId" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": true + }, + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "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": { + "columnNames": [ + "id" + ], + "autoGenerate": false + }, + "indices": [], + "foreignKeys": [] + } + ], + "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, '2ab124580a16b74c86883a1a06edae27')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 6cfac45b0..2f435b0ba 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -21,16 +21,17 @@ android:label="@string/app_name" android:roundIcon="@mipmap/ic_launcher_round" android:supportsRtl="true" - android:theme="@style/AppTheme" + android:theme="@style/Theme.InnerTune" tools:targetApi="o"> diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index dc9985c00..762573a6f 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -13,8 +13,8 @@ import coil.disk.DiskCache import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YouTubeLocale import com.zionhuang.kugou.KuGou -import com.zionhuang.music.constants.Constants.INNERTUBE_COOKIE -import com.zionhuang.music.constants.Constants.VISITOR_DATA +import com.zionhuang.music.constants.INNERTUBE_COOKIE +import com.zionhuang.music.constants.VISITOR_DATA import com.zionhuang.music.extensions.getEnum import com.zionhuang.music.extensions.sharedPreferences import com.zionhuang.music.extensions.toInetSocketAddress diff --git a/app/src/main/java/com/zionhuang/music/compose/ComposeActivity.kt b/app/src/main/java/com/zionhuang/music/compose/ComposeActivity.kt new file mode 100644 index 000000000..ce9ec90af --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/ComposeActivity.kt @@ -0,0 +1,368 @@ +package com.zionhuang.music.compose + +import android.annotation.SuppressLint +import android.content.ComponentName +import android.content.Context +import android.content.Intent +import android.content.ServiceConnection +import android.os.Bundle +import android.os.IBinder +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.* +import androidx.compose.material.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import androidx.core.view.WindowCompat +import androidx.navigation.NavDestination.Companion.hierarchy +import androidx.navigation.NavType +import androidx.navigation.compose.NavHost +import androidx.navigation.compose.composable +import androidx.navigation.compose.currentBackStackEntryAsState +import androidx.navigation.compose.rememberNavController +import androidx.navigation.navArgument +import androidx.palette.graphics.Palette +import com.google.android.exoplayer2.Player.STATE_IDLE +import com.valentinilk.shimmer.LocalShimmerTheme +import com.valentinilk.shimmer.defaultShimmerTheme +import com.zionhuang.music.R +import com.zionhuang.music.compose.component.AppBar +import com.zionhuang.music.compose.component.AppBarState +import com.zionhuang.music.compose.component.rememberBottomSheetState +import com.zionhuang.music.compose.player.BottomSheetPlayer +import com.zionhuang.music.compose.screens.* +import com.zionhuang.music.compose.screens.library.LibraryAlbumsScreen +import com.zionhuang.music.compose.screens.library.LibraryArtistsScreen +import com.zionhuang.music.compose.screens.library.LibraryPlaylistsScreen +import com.zionhuang.music.compose.screens.library.LibrarySongsScreen +import com.zionhuang.music.compose.theme.ColorSaver +import com.zionhuang.music.compose.theme.DefaultThemeColor +import com.zionhuang.music.compose.theme.InnerTuneTheme +import com.zionhuang.music.constants.AppBarHeight +import com.zionhuang.music.constants.MiniPlayerHeight +import com.zionhuang.music.constants.NavigationBarHeight +import com.zionhuang.music.extensions.sharedPreferences +import com.zionhuang.music.playback.MusicService +import com.zionhuang.music.playback.MusicService.MusicBinder +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.utils.NavigationTabHelper +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +class ComposeActivity : ComponentActivity() { + private val playerConnection = PlayerConnection(this) + private val serviceConnection = object : ServiceConnection { + override fun onServiceConnected(name: ComponentName?, service: IBinder?) { + if (service is MusicBinder) { + playerConnection.init(service) + } + } + + override fun onServiceDisconnected(name: ComponentName?) { + playerConnection.dispose() + } + } + + override fun onStart() { + super.onStart() + bindService(Intent(this, MusicService::class.java), serviceConnection, Context.BIND_AUTO_CREATE) + } + + override fun onStop() { + super.onStop() + unbindService(serviceConnection) + } + + @SuppressLint("UnusedMaterial3ScaffoldPaddingParameter") + @OptIn(ExperimentalMaterial3Api::class) + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + WindowCompat.setDecorFitsSystemWindows(window, false) + + setContent { + val coroutineScope = rememberCoroutineScope() + val isSystemInDarkTheme = isSystemInDarkTheme() + var themeColor by rememberSaveable(stateSaver = ColorSaver) { + mutableStateOf(DefaultThemeColor) + } + + DisposableEffect(playerConnection.binder, isSystemInDarkTheme) { + playerConnection.onBitmapChanged = { bitmap -> + if (bitmap != null) { + coroutineScope.launch(Dispatchers.IO) { + val palette = Palette.from(bitmap).maximumColorCount(8).generate() + val defaultHsl = palette.dominantSwatch!!.hsl + val hsl = defaultHsl.takeIf { it[1] >= 0.08 } + ?: palette.swatches + .map { it.hsl } + .filter { it[1] != 0f } + .maxByOrNull { it[1] } + ?: defaultHsl + themeColor = Color.hsl(hsl[0], hsl[1], hsl[2]) + } + } else { + themeColor = DefaultThemeColor + } + } + + onDispose { + playerConnection.onBitmapChanged = {} + } + } + + InnerTuneTheme( + darkTheme = isSystemInDarkTheme, + dynamicColor = sharedPreferences.getBoolean(getString(R.string.pref_follow_system_accent), true), + themeColor = themeColor + ) { + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } + val playerBottomSheetState = rememberBottomSheetState( + dismissedBound = 0.dp, + collapsedBound = NavigationBarHeight.dp + MiniPlayerHeight.dp + bottomInset, + expandedBound = maxHeight, + ) + + val playbackState by playerConnection.playbackState.collectAsState(STATE_IDLE) + LaunchedEffect(playbackState) { + if (playbackState == STATE_IDLE) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() + } + } + } + + val playerAwareWindowInsets by remember(bottomInset, playerBottomSheetState.value) { + derivedStateOf { +// val bottom = if (playerBottomSheetState.isDismissed) { +// NavigationBarHeight.dp + bottomInset +// } else { +// playerBottomSheetState.collapsedBound +// } + val bottom = playerBottomSheetState.value.coerceIn(NavigationBarHeight.dp + bottomInset, playerBottomSheetState.collapsedBound) + + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets( + top = AppBarHeight.dp, + bottom = bottom + )) + } + } + + val shimmerTheme = remember { + defaultShimmerTheme.copy( + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 800, + easing = LinearEasing, + delayMillis = 250, + ), + repeatMode = RepeatMode.Restart + ), + shaderColors = listOf( + Color.Unspecified.copy(alpha = 0.25f), + Color.Unspecified.copy(alpha = 0.50f), + Color.Unspecified.copy(alpha = 0.25f), + ), + ) + } + + CompositionLocalProvider( + LocalPlayerConnection provides playerConnection, + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, + LocalShimmerTheme provides shimmerTheme + ) { + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + + val navigationItems = listOf(Screen.Home, Screen.Songs, Screen.Artists, Screen.Albums, Screen.Playlists) + val defaultNavIndex = sharedPreferences.getString(getString(R.string.pref_default_open_tab), "0")!!.toInt() + val enabledNavItems = NavigationTabHelper.getConfig(this@ComposeActivity) + + val (textFieldValue, onTextFieldValueChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue("") + ) + } + val appBarState = remember(navBackStackEntry) { + val route = navBackStackEntry?.destination?.route + ?: return@remember AppBarState(navigationIcon = R.drawable.ic_search) + navigationItems.forEach { screen -> + if (route == screen.route) { + return@remember screen.appBarState.copy( + onNavigationButtonClick = { + navController.navigate("search") + } + ) + } + } + when { + route == "search" -> AppBarState( + title = "", + onNavigationButtonClick = { + navController.navigateUp() + onTextFieldValueChange(TextFieldValue("")) + }, + canSearch = true, + searchExpanded = true + ) + route.startsWith("search/") -> AppBarState( + title = textFieldValue.text, + navigationIcon = R.drawable.ic_arrow_back, + onNavigationButtonClick = { + navController.navigateUp() + if (navController.currentDestination?.route == "search") { + navController.navigateUp() + } + onTextFieldValueChange(TextFieldValue("")) + }, + canSearch = true, + searchExpanded = false + ) + route.startsWith("album/") -> AppBarState( + onNavigationButtonClick = { + navController.navigateUp() + }, + canSearch = false + ) + else -> AppBarState() + } + } + val onSearch: (String) -> Unit = { query -> + onTextFieldValueChange(androidx.compose.ui.text.input.TextFieldValue( + text = query, + selection = androidx.compose.ui.text.TextRange(query.length) + )) + coroutineScope.launch { + com.zionhuang.music.repos.SongRepository(this@ComposeActivity).insertSearchHistory(query) + } + navController.navigate("search/$query") + } + + Scaffold( + bottomBar = { + NavigationBar( + modifier = Modifier + .offset(y = ((NavigationBarHeight.dp + bottomInset) * with(playerBottomSheetState) { + (value - collapsedBound) / (expandedBound - collapsedBound) + }.coerceIn(0f, 1f))) + ) { + navigationItems.filterIndexed { index, _ -> enabledNavItems[index] }.forEach { screen -> + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true, + icon = { + Icon( + painter = painterResource(screen.iconId), + contentDescription = null + ) + }, + label = { Text(stringResource(screen.titleId)) }, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true + } + launchSingleTop = true + restoreState = true + } + } + ) + } + } + } + ) { innerPaddingModifier -> +// NavHost(navController, startDestination = navigationItems[defaultNavIndex].route) { + NavHost(navController, startDestination = Screen.Songs.route) { + composable(Screen.Home.route) { + HomeScreen() + } + composable(Screen.Songs.route) { + LibrarySongsScreen() + } + composable(Screen.Artists.route) { + LibraryArtistsScreen(innerPaddingModifier) + } + composable(Screen.Albums.route) { + LibraryAlbumsScreen(navController, innerPaddingModifier) + } + composable(Screen.Playlists.route) { + LibraryPlaylistsScreen(innerPaddingModifier) + } + + composable( + route = "album/{albumId}", + arguments = listOf( + navArgument("albumId") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + AlbumScreen(backStackEntry.arguments?.getString("albumId")!!) + } + + composable("search") { + SearchScreen( + query = textFieldValue.text, + onTextFieldValueChange = onTextFieldValueChange, + onSearch = onSearch + ) + } + + composable( + route = "search/{query}", + arguments = listOf( + navArgument("query") { + type = NavType.StringType + } + ) + ) { backStackEntry -> + OnlineSearchResult(backStackEntry.arguments?.getString("query")!!) + } + } + + AppBar( + navController = navController, + appBarState = appBarState, + textFieldValue = textFieldValue, + onTextFieldValueChange = onTextFieldValueChange, + onExpandSearch = { + onTextFieldValueChange(textFieldValue.copy(selection = TextRange(textFieldValue.text.length))) + navController.navigate("search") + }, + onSearch = onSearch + ) + + BottomSheetPlayer(playerBottomSheetState) + } + } + } + } + } + } +} + +val LocalPlayerConnection = staticCompositionLocalOf { error("No PlayerConnection provided") } +val LocalPlayerAwareWindowInsets = compositionLocalOf { error("No WindowInsets provided") } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/compose/component/AppBar.kt new file mode 100644 index 000000000..16012c369 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/AppBar.kt @@ -0,0 +1,260 @@ +package com.zionhuang.music.compose.component + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.animateDp +import androidx.compose.animation.core.animateFloat +import androidx.compose.animation.core.animateInt +import androidx.compose.animation.core.updateTransition +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.KeyboardType +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.compose.ui.unit.sp +import androidx.navigation.NavController +import com.zionhuang.music.R +import com.zionhuang.music.constants.AppBarHeight +import kotlinx.coroutines.delay + +@Composable +fun AppBar( + modifier: Modifier = Modifier, + navController: NavController, + appBarState: AppBarState, + textFieldValue: TextFieldValue, + onTextFieldValueChange: (TextFieldValue) -> Unit, + onExpandSearch: () -> Unit, + onSearch: (String) -> Unit, +) { + val topInset = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() + val transition = updateTransition(targetState = appBarState.canSearch && !appBarState.searchExpanded, "canSearch") + val horizontalPadding by transition.animateDp(label = "") { + if (it) 12.dp else 0.dp + } + val verticalPadding by transition.animateDp(label = "") { + if (it) 8.dp else 0.dp + } + val cornerShapePercent by transition.animateInt(label = "") { + if (it) 50 else 0 + } + val percent by transition.animateFloat(label = "") { + if (it) 0f else 1f + } + + val focusRequester = remember { + FocusRequester() + } + + LaunchedEffect(appBarState.searchExpanded) { + if (appBarState.searchExpanded) { + delay(300) + focusRequester.requestFocus() + } + } + + Box( + modifier = Modifier + .fillMaxWidth() + .height(topInset) + .background(MaterialTheme.colorScheme + .surfaceColorAtElevation(6.dp) + .copy(alpha = percent)) + ) + + Box( + modifier = Modifier + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.End) + .asPaddingValues()) + .fillMaxWidth() + .height(AppBarHeight.dp) + .padding(horizontal = horizontalPadding, vertical = verticalPadding) + .clip(RoundedCornerShape(cornerShapePercent)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable { + if (appBarState.canSearch) { + onExpandSearch() + } + } + ) { + AnimatedVisibility( + visible = appBarState.canSearch, + enter = fadeIn(), + exit = fadeOut() + ) { + AnimatedVisibility( + visible = !appBarState.searchExpanded, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + IconButton( + modifier = Modifier.padding(horizontal = 4.dp), + onClick = appBarState.onNavigationButtonClick + ) { + Icon( + painter = painterResource(appBarState.navigationIcon), + contentDescription = null + ) + } + + if (appBarState.title == null) { + Text( + text = "Search", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .alpha(0.6f) + .weight(1f) + ) + } else { + Text( + text = appBarState.title, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + ) + } + } + } + + AnimatedVisibility( + visible = appBarState.searchExpanded, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + IconButton( + modifier = Modifier.padding(horizontal = 4.dp), + onClick = { + navController.navigateUp() + } + ) { + Icon( + painter = painterResource(appBarState.navigationIcon), + contentDescription = null + ) + } + + BasicTextField( + value = textFieldValue, + onValueChange = onTextFieldValueChange, + textStyle = MaterialTheme.typography.bodyLarge.copy(fontSize = 20.sp), + singleLine = true, + maxLines = 1, + cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), + keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + onSearch(textFieldValue.text) + } + ), + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.CenterStart + ) { + if (textFieldValue.text.isEmpty()) { + Text( + text = "Search", + textAlign = TextAlign.Start, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .alpha(0.6f) + ) + } + innerTextField() + } + }, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + ) + + if (textFieldValue.text.isNotEmpty()) { + IconButton( + modifier = Modifier.padding(horizontal = 4.dp), + onClick = { + onTextFieldValueChange(TextFieldValue("")) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = null + ) + } + } + } + } + } + + AnimatedVisibility( + visible = !appBarState.canSearch, + enter = fadeIn(), + exit = fadeOut() + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.fillMaxSize() + ) { + IconButton( + modifier = Modifier.padding(horizontal = 4.dp), + onClick = appBarState.onNavigationButtonClick + ) { + Icon( + painter = painterResource(appBarState.navigationIcon), + contentDescription = null + ) + } + + Text( + text = appBarState.title.orEmpty(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + ) + } + } + } +} + +@Immutable +data class AppBarState( + val title: String? = null, + @DrawableRes val navigationIcon: Int = R.drawable.ic_arrow_back, + val onNavigationButtonClick: () -> Unit = {}, + val canSearch: Boolean = true, + val searchExpanded: Boolean = false, + val actions: @Composable RowScope.() -> Unit = {}, + val transparentBackground: Boolean = false, +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/BottomSheet.kt b/app/src/main/java/com/zionhuang/music/compose/component/BottomSheet.kt new file mode 100644 index 000000000..26b00817d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/BottomSheet.kt @@ -0,0 +1,307 @@ +package com.zionhuang.music.compose.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.* +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.detectVerticalDragGestures +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.input.pointer.util.VelocityTracker +import androidx.compose.ui.input.pointer.util.addPointerInputChange +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +/** + * Bottom Sheet + * Modified from [ViMusic](https://github.com/vfsfitvnm/ViMusic) + */ +@Composable +fun BottomSheet( + state: BottomSheetState, + modifier: Modifier = Modifier, + backgroundColor: Color, + onDismiss: (() -> Unit)? = null, + collapsedContent: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit, +) { + Box( + modifier = modifier + .fillMaxSize() + .offset { + val y = (state.expandedBound - state.value) + .roundToPx() + .coerceAtLeast(0) + IntOffset(x = 0, y = y) + } + .pointerInput(state) { + val velocityTracker = VelocityTracker() + + detectVerticalDragGestures( + onVerticalDrag = { change, dragAmount -> + velocityTracker.addPointerInputChange(change) + state.dispatchRawDelta(dragAmount) + }, + onDragCancel = { + velocityTracker.resetTracking() + state.snapTo(state.collapsedBound) + }, + onDragEnd = { + val velocity = -velocityTracker.calculateVelocity().y + velocityTracker.resetTracking() + state.performFling(velocity, onDismiss) + } + ) + } + .background(backgroundColor) + ) { + if (state.isExpanded) { + BackHandler(onBack = state::collapseSoft) + } + + if (!state.isCollapsed) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .graphicsLayer { + alpha = ((state.progress - 0.25f) * 4).coerceIn(0f, 1f) + }, + content = content + ) + } + + if (!state.isExpanded && (onDismiss == null || !state.isDismissed)) { + Box( + modifier = Modifier + .graphicsLayer { + alpha = 1f - (state.progress * 4).coerceAtMost(1f) + } + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = state::expandSoft + ) + .fillMaxWidth() + .height(state.collapsedBound), + content = collapsedContent + ) + } + } +} + +@Stable +class BottomSheetState( + draggableState: DraggableState, + private val coroutineScope: CoroutineScope, + private val animatable: Animatable, + private val onAnchorChanged: (Int) -> Unit, + val collapsedBound: Dp, +) : DraggableState by draggableState { + val dismissedBound: Dp + get() = animatable.lowerBound!! + + val expandedBound: Dp + get() = animatable.upperBound!! + + val value by animatable.asState() + + val isDismissed by derivedStateOf { + value == animatable.lowerBound!! + } + + val isCollapsed by derivedStateOf { + value == collapsedBound + } + + val isExpanded by derivedStateOf { + value == animatable.upperBound + } + + val progress by derivedStateOf { + 1f - (animatable.upperBound!! - animatable.value) / (animatable.upperBound!! - collapsedBound) + } + + fun collapse(animationSpec: AnimationSpec) { + onAnchorChanged(collapsedAnchor) + coroutineScope.launch { + animatable.animateTo(collapsedBound, animationSpec) + } + } + + fun expand(animationSpec: AnimationSpec) { + onAnchorChanged(expandedAnchor) + coroutineScope.launch { + animatable.animateTo(animatable.upperBound!!, animationSpec) + } + } + + private fun collapse() { + collapse(SpringSpec()) + } + + private fun expand() { + expand(SpringSpec()) + } + + fun collapseSoft() { + collapse(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun expandSoft() { + expand(spring(stiffness = Spring.StiffnessMediumLow)) + } + + fun dismiss() { + onAnchorChanged(dismissedAnchor) + coroutineScope.launch { + animatable.animateTo(animatable.lowerBound!!) + } + } + + fun snapTo(value: Dp) { + coroutineScope.launch { + animatable.snapTo(value) + } + } + + fun performFling(velocity: Float, onDismiss: (() -> Unit)?) { + if (velocity > 250) { + expand() + } else if (velocity < -250) { + if (value < collapsedBound && onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } else { + val l0 = dismissedBound + val l1 = (collapsedBound - dismissedBound) / 2 + val l2 = (expandedBound - collapsedBound) / 2 + val l3 = expandedBound + + when (value) { + in l0..l1 -> { + if (onDismiss != null) { + dismiss() + onDismiss.invoke() + } else { + collapse() + } + } + in l1..l2 -> collapse() + in l2..l3 -> expand() + else -> Unit + } + } + } + + val preUpPostDownNestedScrollConnection + get() = object : NestedScrollConnection { + var isTopReached = false + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isExpanded && available.y < 0) { + isTopReached = false + } + + return if (isTopReached && available.y < 0 && source == NestedScrollSource.Drag) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource, + ): Offset { + if (!isTopReached) { + isTopReached = consumed.y == 0f && available.y > 0 + } + + return if (isTopReached && source == NestedScrollSource.Drag) { + dispatchRawDelta(available.y) + available + } else { + Offset.Zero + } + } + + override suspend fun onPreFling(available: Velocity): Velocity { + return if (isTopReached) { + val velocity = -available.y + performFling(velocity, null) + + available + } else { + Velocity.Zero + } + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + isTopReached = false + return Velocity.Zero + } + } +} + +const val expandedAnchor = 2 +const val collapsedAnchor = 1 +const val dismissedAnchor = 0 + +@Composable +fun rememberBottomSheetState( + dismissedBound: Dp, + expandedBound: Dp, + collapsedBound: Dp = dismissedBound, + initialAnchor: Int = dismissedAnchor, +): BottomSheetState { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + var previousAnchor by rememberSaveable { + mutableStateOf(initialAnchor) + } + + return remember(dismissedBound, expandedBound, collapsedBound, coroutineScope) { + val initialValue = when (previousAnchor) { + expandedAnchor -> expandedBound + collapsedAnchor -> collapsedBound + dismissedAnchor -> dismissedBound + else -> error("Unknown BottomSheet anchor") + } + + val animatable = Animatable(initialValue, Dp.VectorConverter).also { + it.updateBounds(dismissedBound.coerceAtMost(expandedBound), expandedBound) + } + + BottomSheetState( + draggableState = DraggableState { delta -> + coroutineScope.launch { + animatable.snapTo(animatable.value - with(density) { delta.toDp() }) + } + }, + onAnchorChanged = { previousAnchor = it }, + coroutineScope = coroutineScope, + animatable = animatable, + collapsedBound = collapsedBound + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/compose/component/IconButton.kt b/app/src/main/java/com/zionhuang/music/compose/component/IconButton.kt new file mode 100644 index 000000000..4c4a4cbbe --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/IconButton.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.compose.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.Indication +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource + +@Composable +fun IconButton( + @DrawableRes icon: Int, + modifier: Modifier = Modifier, + color: Color = MaterialTheme.colorScheme.onSurface, + enabled: Boolean = true, + indication: Indication? = null, + onClick: () -> Unit = {}, +) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(color), + modifier = Modifier + .clickable( + indication = indication ?: rememberRipple(bounded = false), + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .alpha(if (enabled) 1f else 0.5f) + .then(modifier) + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/Items.kt b/app/src/main/java/com/zionhuang/music/compose/component/Items.kt new file mode 100644 index 000000000..bfc9ba189 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/Items.kt @@ -0,0 +1,383 @@ +package com.zionhuang.music.compose.component + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.filled.MoreVert +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.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.Album +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.utils.joinByBullet +import com.zionhuang.music.utils.makeTimeString + +@Composable +fun ListItem( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + thumbnailUrl: String? = null, + thumbnailShape: Shape = CircleShape, + @DrawableRes thumbnailPlaceHolder: Int? = null, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = title, + subtitle = subtitle, + thumbnailContent = { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + placeholder = thumbnailPlaceHolder?.let { painterResource(it) }, + error = thumbnailPlaceHolder?.let { painterResource(it) }, + modifier = Modifier + .size(ListThumbnailSize.dp) + .clip(thumbnailShape) + ) + AnimatedVisibility( + visible = playingIndicator, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(ListThumbnailSize.dp) + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = thumbnailShape + ) + ) { + if (playWhenReady) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_play), + contentDescription = null, + tint = Color.White + ) + } + } + } + }, + modifier = modifier +) + +@Composable +fun ListItem( + title: String, + subtitle: String, + thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(ListItemHeight.dp) + .padding(horizontal = 6.dp), + ) { + Box( + modifier = Modifier.padding(6.dp), + contentAlignment = Alignment.Center + ) { + thumbnailContent() + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + Text( + text = title, + fontSize = 14.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + + if (subtitle.isNotEmpty()) { + Text( + text = subtitle, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + IconButton(onClick = {}) { + Icon( + imageVector = Icons.Filled.MoreVert, + contentDescription = null + ) + } + } +} + +@Composable +fun GridItem( + title: String, + subtitle: String, + modifier: Modifier = Modifier, + thumbnailUrl: String? = null, + thumbnailShape: Shape = CircleShape, +) = GridItem( + title = title, + subtitle = subtitle, + thumbnailContent = { + AsyncImage( + model = thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxWidth() + .clip(thumbnailShape) + ) + }, + modifier = modifier +) + +@Composable +fun GridItem( + title: String, + subtitle: String, + thumbnailContent: @Composable () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = modifier.padding(12.dp) + ) { + thumbnailContent() + + Spacer(modifier = Modifier.height(6.dp)) + + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 2, + overflow = TextOverflow.Ellipsis + ) + } +} + +@Composable +fun SongListItem( + song: Song, + modifier: Modifier = Modifier, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = song.song.title, + subtitle = listOf( + song.artists.joinToString(), + song.album?.title, + makeTimeString(song.song.duration * 1000L) + ).joinByBullet(), + thumbnailUrl = song.song.thumbnailUrl, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius.dp), + thumbnailPlaceHolder = R.drawable.ic_music_note, + playingIndicator = playingIndicator, + playWhenReady = playWhenReady, + modifier = modifier +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ArtistListItem( + artist: Artist, + modifier: Modifier = Modifier, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.song_count, artist.songCount, artist.songCount), + thumbnailUrl = artist.artist.thumbnailUrl, + thumbnailShape = CircleShape, + thumbnailPlaceHolder = R.drawable.ic_artist, + playingIndicator = playingIndicator, + playWhenReady = playWhenReady, + modifier = modifier +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AlbumListItem( + album: Album, + modifier: Modifier = Modifier, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = album.album.title, + subtitle = listOf( + album.artists.joinToString(), + pluralStringResource(R.plurals.song_count, album.album.songCount, album.album.songCount), + album.album.year?.toString() + ).joinByBullet(), + thumbnailUrl = album.album.thumbnailUrl, + thumbnailShape = RoundedCornerShape(6.dp), + thumbnailPlaceHolder = R.drawable.ic_album, + playingIndicator = playingIndicator, + playWhenReady = playWhenReady, + modifier = modifier +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PlaylistListItem( + playlist: Playlist, + modifier: Modifier = Modifier, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = playlist.playlist.name, + subtitle = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + thumbnailUrl = playlist.playlist.thumbnailUrl, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius.dp), + thumbnailPlaceHolder = R.drawable.ic_queue_music, + playingIndicator = playingIndicator, + playWhenReady = playWhenReady, + modifier = modifier +) + +@Composable +fun SongGridItem( + song: Song, + modifier: Modifier = Modifier, +) = GridItem( + title = song.song.title, + subtitle = song.artists.joinToString { it.name }, + thumbnailUrl = song.song.thumbnailUrl, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius.dp), + modifier = Modifier +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun ArtistGridItem( + artist: Artist, + modifier: Modifier = Modifier, +) = GridItem( + title = artist.artist.name, + subtitle = pluralStringResource(R.plurals.song_count, artist.songCount, artist.songCount), + thumbnailUrl = artist.artist.thumbnailUrl, + thumbnailShape = CircleShape, + modifier = modifier +) + + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun AlbumGridItem( + album: Album, + modifier: Modifier = Modifier, +) = GridItem( + title = album.album.title, + subtitle = listOf( + album.artists.joinToString(), + pluralStringResource(R.plurals.song_count, album.album.songCount, album.album.songCount), + album.album.year?.toString() + ).joinByBullet(), + thumbnailUrl = album.album.thumbnailUrl, + thumbnailShape = RoundedCornerShape(6.dp), + modifier = modifier +) + +@OptIn(ExperimentalComposeUiApi::class) +@Composable +fun PlaylistGridItem( + playlist: Playlist, + modifier: Modifier = Modifier, +) = GridItem( + title = playlist.playlist.name, + subtitle = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + thumbnailUrl = playlist.playlist.thumbnailUrl, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius.dp), + modifier = modifier +) + +@Composable +fun MediaMetadataListItem( + mediaMetadata: MediaMetadata, + modifier: Modifier, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = mediaMetadata.title, + subtitle = mediaMetadata.artists.joinToString { it.name }, + thumbnailUrl = mediaMetadata.thumbnailUrl, + thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius.dp), + playingIndicator = playingIndicator, + playWhenReady = playWhenReady, + modifier = modifier +) + +@Composable +fun YouTubeListItem( + item: YTItem, + modifier: Modifier = Modifier, + playingIndicator: Boolean = false, + playWhenReady: Boolean = false, +) = ListItem( + title = item.title, + subtitle = item.subtitle.orEmpty(), + thumbnailUrl = item.thumbnails.lastOrNull()?.url, + thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius.dp), + playingIndicator = playingIndicator, + playWhenReady = playWhenReady, + modifier = modifier +) + +@Composable +fun YouTubeGridItem( + item: YTItem, + modifier: Modifier = Modifier, +) = GridItem( + title = item.title, + subtitle = item.subtitle.orEmpty(), + thumbnailUrl = item.thumbnails.lastOrNull()?.url, + thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius.dp), + modifier = modifier +) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/Lyrics.kt b/app/src/main/java/com/zionhuang/music/compose/component/Lyrics.kt new file mode 100644 index 000000000..9a7047fb6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/Lyrics.kt @@ -0,0 +1,218 @@ +package com.zionhuang.music.compose.component + +import android.text.format.DateUtils +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +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.LocalDensity +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.shimmer.ShimmerHost +import com.zionhuang.music.compose.component.shimmer.TextPlaceholder +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.compose.utils.verticalFadingEdge +import com.zionhuang.music.constants.LRC_TEXT_POS +import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.seconds + +@Composable +fun Lyrics( + lyrics: String?, + playerPosition: Long, + sliderPosition: Long?, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current + val lines = remember(lyrics) { + if (lyrics == null || lyrics == LYRICS_NOT_FOUND) emptyList() + else if (lyrics.startsWith("[")) listOf(LyricsEntry(0L, "")) + parseLyrics(lyrics) + else lyrics.lines().mapIndexed { index, line -> LyricsEntry(index * 100L, line) } + } + val isSynced = remember(lyrics) { + !lyrics.isNullOrEmpty() && lyrics.startsWith("[") + } + val lyricsTextPosition by rememberPreference(LRC_TEXT_POS, 1) + + val position = sliderPosition ?: playerPosition + val currentLineIndex = rememberSaveable(position, lyrics) { + if (lyrics.isNullOrEmpty() || !lyrics.startsWith("[")) { + return@rememberSaveable -1 + } + lines.forEachIndexed { index, line -> + if (line.time >= position + animateScrollDuration) return@rememberSaveable index - 1 + } + lines.lastIndex + } + // Because LaunchedEffect has delay, which leads to inconsistent with current line color and scroll animation, + // we use deferredCurrentLineIndex when user is scrolling + var deferredCurrentLineIndex by rememberSaveable { + mutableStateOf(0) + } + + val density = LocalDensity.current + val lazyListState = rememberLazyListState() + + var lastPreviewTime by rememberSaveable { + mutableStateOf(0L) + } + + if (sliderPosition != null) { + lastPreviewTime = 0L + } + + LaunchedEffect(lastPreviewTime) { + if (lastPreviewTime != 0L) { + delay(LyricsPreviewTime) + lastPreviewTime = 0L + } + } + + LaunchedEffect(currentLineIndex, lastPreviewTime) { + if (!isSynced) return@LaunchedEffect + if (currentLineIndex != -1) { + deferredCurrentLineIndex = currentLineIndex + if (lastPreviewTime == 0L) { + if (sliderPosition != null) { + lazyListState.scrollToItem(currentLineIndex, with(density) { 36.dp.toPx().toInt() }) + } else { + lazyListState.animateScrollToItem(currentLineIndex, with(density) { 36.dp.toPx().toInt() }) + } + } + } + } + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .padding(bottom = 12.dp) + .verticalFadingEdge() + ) { + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(top = maxHeight / 2, bottom = maxHeight / 2)) + .asPaddingValues(), + modifier = Modifier.nestedScroll(remember { + object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + lastPreviewTime = System.currentTimeMillis() + return super.onPostScroll(consumed, available, source) + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + lastPreviewTime = System.currentTimeMillis() + return super.onPostFling(consumed, available) + } + } + }) + ) { + val displayedCurrentLineIndex = if (sliderPosition != null) 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) { + 0 -> TextAlign.Left + 1 -> TextAlign.Center + 2 -> TextAlign.Right + else -> TextAlign.Center + }, + 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.isNullOrEmpty()) { + item { + ShimmerHost { + repeat(10) { + Box( + contentAlignment = when (lyricsTextPosition) { + 0 -> Alignment.CenterStart + 1 -> Alignment.Center + 2 -> Alignment.CenterEnd + else -> Alignment.Center + }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 4.dp) + ) { + TextPlaceholder() + } + } + } + } + } + } + } +} + +private val LINE_REGEX = "((\\[\\d\\d:\\d\\d\\.\\d{2,3}\\])+)(.+)".toRegex() +private val TIME_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\]".toRegex() + +fun parseLyrics(lyrics: String): List = + lyrics.lines() + .flatMap { line -> + parseLine(line).orEmpty() + }.sorted() + +private fun parseLine(line: String): List? { + if (line.isEmpty()) { + return null + } + val matchResult = LINE_REGEX.matchEntire(line.trim()) ?: return null + val times = matchResult.groupValues[1] + val text = matchResult.groupValues[3] + val timeMatchResults = TIME_REGEX.findAll(times) + + return timeMatchResults.map { timeMatchResult -> + val min = timeMatchResult.groupValues[1].toLong() + val sec = timeMatchResult.groupValues[2].toLong() + val milString = timeMatchResult.groupValues[3] + var mil = milString.toLong() + if (milString.length == 2) { + mil *= 10 + } + val time = min * DateUtils.MINUTE_IN_MILLIS + sec * DateUtils.SECOND_IN_MILLIS + mil + LyricsEntry(time, text) + }.toList() +} + +data class LyricsEntry( + val time: Long, + val text: String, +) : Comparable { + override fun compareTo(other: LyricsEntry): Int = (time - other.time).toInt() +} + +const val animateScrollDuration = 300L +val LyricsPreviewTime = 4.seconds \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/PlayingIndicator.kt b/app/src/main/java/com/zionhuang/music/compose/component/PlayingIndicator.kt new file mode 100644 index 000000000..99c968f0a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/PlayingIndicator.kt @@ -0,0 +1,71 @@ +package com.zionhuang.music.compose.component + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.Canvas +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxHeight +import androidx.compose.foundation.layout.width +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.CornerRadius +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import com.zionhuang.music.constants.ThumbnailCornerRadius +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlin.random.Random + + +@Composable +fun PlayingIndicator( + color: Color, + modifier: Modifier = Modifier, + bars: Int = 3, + barWidth: Dp = 4.dp, + cornerRadius: Dp = ThumbnailCornerRadius.dp, +) { + val animatables = remember { + List(bars) { + Animatable(0.1f) + } + } + + LaunchedEffect(Unit) { + delay(300) + animatables.forEach { animatable -> + launch { + while (true) { + animatable.animateTo(Random.nextFloat() * 0.9f + 0.1f) + delay(50) + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier + ) { + animatables.forEach { animatable -> + Canvas( + modifier = Modifier + .fillMaxHeight() + .width(barWidth) + ) { + drawRoundRect( + color = color, + topLeft = Offset(x = 0f, y = size.height * (1 - animatable.value)), + size = size.copy(height = animatable.value * size.height), + cornerRadius = CornerRadius(cornerRadius.toPx()) + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/ProgressIndicator.kt b/app/src/main/java/com/zionhuang/music/compose/component/ProgressIndicator.kt new file mode 100644 index 000000000..817415e95 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/ProgressIndicator.kt @@ -0,0 +1,20 @@ +package com.zionhuang.music.compose.component + +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.ProgressIndicatorDefaults +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +@Composable +fun LinearProgressIndicator( + indeterminate: Boolean, + progress: Float, + modifier: Modifier = Modifier, + color: Color = ProgressIndicatorDefaults.linearColor, + trackColor: Color = ProgressIndicatorDefaults.linearTrackColor, +) = if (indeterminate) { + LinearProgressIndicator(modifier, color, trackColor) +} else { + LinearProgressIndicator(progress, modifier, color, trackColor) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/shimmer/ListItemPlaceholder.kt b/app/src/main/java/com/zionhuang/music/compose/component/shimmer/ListItemPlaceholder.kt new file mode 100644 index 000000000..245e8e4ff --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/shimmer/ListItemPlaceholder.kt @@ -0,0 +1,45 @@ +package com.zionhuang.music.compose.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.ListThumbnailSize +import com.zionhuang.music.constants.ThumbnailCornerRadius + +@Composable +fun ListItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius.dp), +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .height(ListItemHeight.dp) + .padding(horizontal = 6.dp), + ) { + Spacer( + modifier = Modifier + .padding(6.dp) + .size(ListThumbnailSize.dp) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.scrim) + ) + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + TextPlaceholder() + TextPlaceholder() + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/shimmer/ShimmerHost.kt b/app/src/main/java/com/zionhuang/music/compose/component/shimmer/ShimmerHost.kt new file mode 100644 index 000000000..2a42f177a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/shimmer/ShimmerHost.kt @@ -0,0 +1,38 @@ +package com.zionhuang.music.compose.component.shimmer + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer +import com.valentinilk.shimmer.shimmer + +@Composable +fun ShimmerHost( + modifier: Modifier = Modifier, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + verticalArrangement: Arrangement.Vertical = Arrangement.Top, + content: @Composable ColumnScope.() -> Unit +) { + Column( + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + modifier = modifier + .shimmer() + .graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient(listOf(Color.Black, Color.Transparent)), + blendMode = BlendMode.DstIn + ) + }, + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/component/shimmer/TextPlaceholder.kt b/app/src/main/java/com/zionhuang/music/compose/component/shimmer/TextPlaceholder.kt new file mode 100644 index 000000000..1ccdddcf1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/component/shimmer/TextPlaceholder.kt @@ -0,0 +1,26 @@ +package com.zionhuang.music.compose.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import kotlin.random.Random + +@Composable +fun TextPlaceholder( + modifier: Modifier = Modifier, +) { + Spacer( + modifier = modifier + .padding(vertical = 4.dp) + .background(MaterialTheme.colorScheme.scrim) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + .height(16.dp) + ) +} diff --git a/app/src/main/java/com/zionhuang/music/compose/player/MiniPlayer.kt b/app/src/main/java/com/zionhuang/music/compose/player/MiniPlayer.kt new file mode 100644 index 000000000..cd1359b8d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/player/MiniPlayer.kt @@ -0,0 +1,116 @@ +package com.zionhuang.music.compose.player + +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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 +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import coil.compose.AsyncImage +import com.google.android.exoplayer2.Player.STATE_BUFFERING +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.LinearProgressIndicator +import com.zionhuang.music.constants.MiniPlayerHeight +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.MediaMetadata + +@Composable +fun MiniPlayer( + mediaMetadata: MediaMetadata?, + playbackState: Int, + playWhenReady: Boolean, + position: Long, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current + val canSkipNext by playerConnection.canSkipNext.collectAsState(initial = true) + + Box( + modifier = modifier + .fillMaxWidth() + .height(MiniPlayerHeight.dp) + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues()) + ) { + LinearProgressIndicator( + indeterminate = playbackState == STATE_BUFFERING, + progress = mediaMetadata?.duration?.let { duration -> + position.toFloat() / (duration * 1000) + } ?: 0f, + modifier = Modifier + .fillMaxWidth() + .height(2.dp) + .align(Alignment.BottomCenter) + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 6.dp), + ) { + Box(modifier = Modifier.padding(6.dp)) { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .width(48.dp) + .height(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius.dp)) + ) + } + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + Text( + text = mediaMetadata?.title.orEmpty(), + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1 + ) + Text( + text = mediaMetadata?.artists?.joinToString { it.name }.orEmpty(), + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1 + ) + } + + IconButton( + onClick = { + playerConnection.player?.togglePlayPause() + } + ) { + Icon( + painter = painterResource(if (playWhenReady) R.drawable.ic_pause else R.drawable.ic_play), + contentDescription = null + ) + } + IconButton( + enabled = canSkipNext, + onClick = { + playerConnection.player?.seekToNext() + } + ) { + Icon( + painter = painterResource(R.drawable.ic_skip_next), + contentDescription = null + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/player/Player.kt b/app/src/main/java/com/zionhuang/music/compose/player/Player.kt new file mode 100644 index 000000000..204c59f4e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/player/Player.kt @@ -0,0 +1,309 @@ +package com.zionhuang.music.compose.player + +import android.content.res.Configuration +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalConfiguration +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 +import com.google.android.exoplayer2.Player.* +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.BottomSheet +import com.zionhuang.music.compose.component.BottomSheetState +import com.zionhuang.music.compose.component.IconButton +import com.zionhuang.music.compose.component.rememberBottomSheetState +import com.zionhuang.music.constants.QueuePeekHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.utils.makeTimeString +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive + +@Composable +fun BottomSheetPlayer( + bottomSheetState: BottomSheetState, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current + + val playbackState by playerConnection.playbackState.collectAsState(initial = STATE_IDLE) + val playWhenReady by playerConnection.playWhenReady.collectAsState(initial = false) + val repeatMode by playerConnection.repeatMode.collectAsState(initial = REPEAT_MODE_OFF) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState(initial = null) + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + + val canSkipPrevious by playerConnection.canSkipPrevious.collectAsState(initial = true) + val canSkipNext by playerConnection.canSkipNext.collectAsState(initial = true) + + var position by rememberSaveable(playbackState) { + mutableStateOf(playerConnection.player?.currentPosition ?: 0L) + } + var sliderPosition by remember { + mutableStateOf(null) + } + + LaunchedEffect(playbackState) { + if (playbackState == STATE_READY) { + while (isActive) { + delay(100) + position = playerConnection.player?.currentPosition ?: 0L + } + } + } + + val queueSheetState = rememberBottomSheetState( + dismissedBound = QueuePeekHeight.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding(), + expandedBound = bottomSheetState.expandedBound, + ) + + BottomSheet( + state = bottomSheetState, + modifier = modifier, + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), + onDismiss = { + playerConnection.player?.stop() + playerConnection.player?.clearMediaItems() + }, + collapsedContent = { + MiniPlayer( + mediaMetadata = mediaMetadata, + playbackState = playbackState, + playWhenReady = playWhenReady, + position = position + ) + } + ) { + val controlsContent: @Composable ColumnScope.() -> Unit = { + Text( + text = mediaMetadata?.title.orEmpty(), + style = MaterialTheme.typography.titleLarge, + fontWeight = FontWeight.Bold, + maxLines = 1, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = mediaMetadata?.artists?.joinToString { it.name }.orEmpty(), + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Slider( + value = (sliderPosition ?: position).toFloat(), + valueRange = 0f..(mediaMetadata?.duration?.times(1000f) ?: 0f), + enabled = mediaMetadata?.duration != null, + onValueChange = { + sliderPosition = it.toLong() + }, + onValueChangeFinished = { + sliderPosition?.let { + playerConnection.player?.seekTo(it) + position = it + } + sliderPosition = null + }, + modifier = Modifier.padding(horizontal = 16.dp) + ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 20.dp) + ) { + Text( + text = makeTimeString(sliderPosition ?: position), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + BasicText( + text = mediaMetadata?.duration?.times(1000L)?.let { makeTimeString(it) }.orEmpty(), + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Spacer(Modifier.height(12.dp)) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 16.dp) + ) { + Box(modifier = Modifier.weight(1f)) { + IconButton( + icon = if (currentSong?.song?.liked == true) R.drawable.ic_favorite else R.drawable.ic_favorite_border, + color = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center), + onClick = { + playerConnection.toggleLike() + } + ) + } + + Box(modifier = Modifier.weight(1f)) { + IconButton( + icon = R.drawable.ic_skip_previous, + enabled = canSkipPrevious, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = { + playerConnection.player?.seekToPrevious() + } + ) + } + + Spacer(Modifier.width(8.dp)) + + Box( + modifier = Modifier + .size(72.dp) + .clip(CircleShape) + .background(MaterialTheme.colorScheme.secondaryContainer) + .clickable { + playerConnection.player?.togglePlayPause() + } + ) { + Image( + painter = painterResource(if (playWhenReady) R.drawable.ic_pause else R.drawable.ic_play), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onSurface), + modifier = Modifier + .align(Alignment.Center) + .size(36.dp) + ) + } + + Spacer(Modifier.width(8.dp)) + + Box(modifier = Modifier.weight(1f)) { + IconButton( + icon = R.drawable.ic_skip_next, + enabled = canSkipNext, + modifier = Modifier + .size(32.dp) + .align(Alignment.Center), + onClick = { + playerConnection.player?.seekToNext() + } + ) + } + + Box(modifier = Modifier.weight(1f)) { + IconButton( + icon = when (repeatMode) { + REPEAT_MODE_OFF, REPEAT_MODE_ALL -> R.drawable.ic_repeat + REPEAT_MODE_ONE -> R.drawable.ic_repeat_one + else -> throw IllegalStateException() + }, + modifier = Modifier + .size(32.dp) + .padding(4.dp) + .align(Alignment.Center) + .alpha(if (repeatMode == REPEAT_MODE_OFF) 0.5f else 1f), + onClick = { + playerConnection.toggleRepeatMode() + } + ) + } + } + } + + when (LocalConfiguration.current.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> { + Row( + modifier = Modifier + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .add(WindowInsets(bottom = queueSheetState.collapsedBound)) + .asPaddingValues()) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + Thumbnail( + position = position, + sliderPosition = sliderPosition, + modifier = Modifier.nestedScroll(bottomSheetState.preUpPostDownNestedScrollConnection) + ) + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .weight(1f) + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .asPaddingValues()) + ) { + Spacer(Modifier.weight(1f)) + + controlsContent() + + Spacer(Modifier.weight(1f)) + } + } + } + else -> { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .add(WindowInsets(bottom = queueSheetState.collapsedBound)) + .asPaddingValues()) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.weight(1f) + ) { + Thumbnail( + position = position, + sliderPosition = sliderPosition, + modifier = Modifier.nestedScroll(bottomSheetState.preUpPostDownNestedScrollConnection) + ) + } + + controlsContent() + + Spacer(Modifier.height(24.dp)) + } + } + } + + Queue( + sheetState = queueSheetState + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/compose/player/Queue.kt b/app/src/main/java/com/zionhuang/music/compose/player/Queue.kt new file mode 100644 index 000000000..4931c6aef --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/player/Queue.kt @@ -0,0 +1,194 @@ +package com.zionhuang.music.compose.player + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.unit.dp +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.BottomSheet +import com.zionhuang.music.compose.component.BottomSheetState +import com.zionhuang.music.compose.component.MediaMetadataListItem +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.constants.SHOW_LYRICS +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.extensions.togglePlayPause + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun Queue( + sheetState: BottomSheetState, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current + val playWhenReady by playerConnection.playWhenReady.collectAsState(initial = false) + val queueTitle by playerConnection.queueTitle.collectAsState(initial = null) + val queueItems by playerConnection.queueItems.collectAsState(initial = emptyList()) + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState(initial = 0) + val currentSong by playerConnection.currentSong.collectAsState(initial = null) + + var showLyrics by rememberPreference(SHOW_LYRICS, defaultValue = false) + + BottomSheet( + state = sheetState, + backgroundColor = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), + modifier = modifier, + collapsedContent = { + Row( + horizontalArrangement = Arrangement.Center, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues()) + ) { + IconButton(onClick = { sheetState.expandSoft() }) { + Icon( + painter = painterResource(R.drawable.ic_queue_music), + contentDescription = null + ) + } + IconButton(onClick = { showLyrics = !showLyrics }) { + Icon( + painter = painterResource(R.drawable.ic_lyrics), + contentDescription = null, + modifier = Modifier.alpha(if (showLyrics) 1f else 0.5f) + ) + } + IconButton(onClick = { playerConnection.toggleLibrary() }) { + Icon( + painter = painterResource(if (currentSong != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add), + contentDescription = null + ) + } + IconButton(onClick = { /*TODO*/ }) { + Icon( + painter = painterResource(R.drawable.ic_more_horiz), + contentDescription = null + ) + } + } + } + ) { + val lazyListState = rememberLazyListState( + initialFirstVisibleItemIndex = currentWindowIndex + ) + + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars.add(WindowInsets( + top = ListItemHeight.dp, + bottom = ListItemHeight.dp) + ).asPaddingValues(), + modifier = Modifier.nestedScroll(sheetState.preUpPostDownNestedScrollConnection) + ) { + itemsIndexed( + items = queueItems, + key = { _, item -> item.uid.hashCode() } + ) { index, window -> + MediaMetadataListItem( + mediaMetadata = window.mediaItem.metadata!!, + playingIndicator = index == currentWindowIndex, + playWhenReady = playWhenReady, + modifier = Modifier + .fillMaxWidth() + .animateItemPlacement() + .clickable { + if (index == currentWindowIndex) { + playerConnection.player?.togglePlayPause() + } else { + playerConnection.player?.seekToDefaultPosition(window.firstPeriodIndex) + playerConnection.player?.playWhenReady = true + } + } + ) + } + } + + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme + .surfaceColorAtElevation(NavigationBarDefaults.Elevation) + .copy(alpha = 0.95f)) + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.End) + .asPaddingValues()) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight.dp) + .padding(12.dp) + ) { + Text( + text = queueTitle.orEmpty(), + style = MaterialTheme.typography.titleLarge, + modifier = Modifier.weight(1f) + ) + Text( + text = pluralStringResource(R.plurals.song_count, queueItems.size, queueItems.size), + style = MaterialTheme.typography.bodyMedium + ) + } + } + + val shuffleModeEnabled by playerConnection.shuffleModeEnabled.collectAsState(initial = false) + + Box( + modifier = Modifier + .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)) + .fillMaxWidth() + .height(ListItemHeight.dp + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding()) + .align(Alignment.BottomCenter) + .clickable { + sheetState.collapseSoft() + } + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.End) + .asPaddingValues()) + .padding(12.dp) + ) { + IconButton( + modifier = Modifier.align(Alignment.CenterStart), + onClick = { + playerConnection.player?.let { + it.shuffleModeEnabled = !it.shuffleModeEnabled + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier + .alpha(if (shuffleModeEnabled) 1f else 0.5f) + ) + } + + Icon( + painter = painterResource(R.drawable.ic_expand_more), + contentDescription = null, + modifier = Modifier.align(Alignment.Center) + ) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/compose/player/Thumbnail.kt b/app/src/main/java/com/zionhuang/music/compose/player/Thumbnail.kt new file mode 100644 index 000000000..c60827d0b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/player/Thumbnail.kt @@ -0,0 +1,92 @@ +package com.zionhuang.music.compose.player + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +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 +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalView +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.Lyrics +import com.zionhuang.music.constants.SHOW_LYRICS +import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.extensions.preferenceState +import com.zionhuang.music.extensions.sharedPreferences + +@Composable +fun Thumbnail( + position: Long, + sliderPosition: Long?, + modifier: Modifier = Modifier, +) { + val playerConnection = LocalPlayerConnection.current + val mediaMetadata by playerConnection.mediaMetadata.collectAsState(initial = null) + val showLyrics by preferenceState( + SHOW_LYRICS, + LocalContext.current.sharedPreferences.getBoolean(stringResource(R.string.pref_show_lyrics), false) + ) + val lyrics by playerConnection.currentLyrics.collectAsState(initial = null) + + val currentView = LocalView.current + + DisposableEffect(showLyrics) { + currentView.keepScreenOn = showLyrics + onDispose { + currentView.keepScreenOn = false + } + } + + Box(modifier = modifier) { + AnimatedVisibility( + visible = !showLyrics, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(left = 16.dp, right = 16.dp)) + .asPaddingValues()) + ) { + AsyncImage( + model = mediaMetadata?.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .clip(RoundedCornerShape(ThumbnailCornerRadius.dp)) + .fillMaxWidth() + .align(Alignment.Center) + ) + } + } + + AnimatedVisibility( + visible = showLyrics, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + modifier = Modifier.fillMaxSize() + ) { + Lyrics( + lyrics = lyrics?.lyrics, + playerPosition = position, + sliderPosition = sliderPosition + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/AlbumScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/AlbumScreen.kt new file mode 100644 index 000000000..6c2867273 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/AlbumScreen.kt @@ -0,0 +1,173 @@ +package com.zionhuang.music.compose.screens + +import android.content.res.Configuration +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerAwareWindowInsets +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.SongListItem +import com.zionhuang.music.db.entities.AlbumWithSongs +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.utils.joinByBullet +import com.zionhuang.music.utils.makeTimeString + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun AlbumScreen( + albumId: String, +) { + val context = LocalContext.current + val playerConnection = LocalPlayerConnection.current + val playWhenReady by playerConnection.playWhenReady.collectAsState(initial = false) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState(initial = null) + + var albumWithSongs: AlbumWithSongs? by remember { mutableStateOf(null) } + LaunchedEffect(Unit) { + albumWithSongs = SongRepository(context).getAlbumWithSongs(albumId) + } + + albumWithSongs?.let { albumWithSongs -> + when (LocalConfiguration.current.orientation) { + Configuration.ORIENTATION_LANDSCAPE -> {} + else -> { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = albumWithSongs.album.thumbnailUrl, + contentDescription = null, + modifier = Modifier.size(144.dp) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + Text( + text = albumWithSongs.album.title, + style = MaterialTheme.typography.titleLarge + ) + Text( + text = listOf( + albumWithSongs.artists.joinToString { it.name }, + albumWithSongs.album.year.toString() + ).joinByBullet(), + style = MaterialTheme.typography.titleMedium + ) + Text( + text = listOf( + pluralStringResource(R.plurals.song_count, albumWithSongs.album.songCount, albumWithSongs.album.songCount), + makeTimeString(albumWithSongs.album.duration * 1000L) + ).joinByBullet(), + style = MaterialTheme.typography.titleSmall + ) + Row { + IconButton(onClick = { /*TODO*/ }) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row { + Button( + onClick = { + playerConnection.playQueue(ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map { it.toMediaItem() } + )) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.btn_play) + ) + } + + Spacer(Modifier.width(12.dp)) + + OutlinedButton( + onClick = { + playerConnection.playQueue(ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.shuffled().map { it.toMediaItem() } + )) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_shuffle)) + } + } + } + } + + itemsIndexed( + items = albumWithSongs.songs, + key = { _, song -> song.id } + ) { index, song -> + SongListItem( + song = song, + playingIndicator = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map { it.toMediaItem() }, + startIndex = index + )) + } + .animateItemPlacement() + ) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/ArtistScreen.kt new file mode 100644 index 000000000..13aea312f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/ArtistScreen.kt @@ -0,0 +1,8 @@ +package com.zionhuang.music.compose.screens + +import androidx.compose.runtime.Composable + +@Composable +fun ArtistScreen() { + +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/HomeScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/HomeScreen.kt new file mode 100644 index 000000000..c8c221393 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/HomeScreen.kt @@ -0,0 +1,150 @@ +package com.zionhuang.music.compose.screens + +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.items +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.* +import com.zionhuang.music.compose.LocalPlayerAwareWindowInsets +import com.zionhuang.music.compose.component.YouTubeGridItem +import com.zionhuang.music.compose.component.YouTubeListItem +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.constants.GridItemWidth +import com.zionhuang.music.constants.SONG_SORT_DESCENDING +import com.zionhuang.music.constants.SONG_SORT_TYPE +import com.zionhuang.music.extensions.getApplication +import com.zionhuang.music.models.sortInfo.SongSortType +import com.zionhuang.music.viewmodels.YouTubeBrowseViewModel +import com.zionhuang.music.viewmodels.YouTubeBrowseViewModelFactory + +@Composable +fun HomeScreen( + viewModel: YouTubeBrowseViewModel = viewModel(factory = YouTubeBrowseViewModelFactory(getApplication(), BrowseEndpoint(browseId = YouTube.HOME_BROWSE_ID))), +) { + val sortType by rememberPreference(SONG_SORT_TYPE, SongSortType.CREATE_DATE) + val sortDescending by rememberPreference(SONG_SORT_DESCENDING, true) + val items = viewModel.pagingData.collectAsLazyPagingItems() + + BoxWithConstraints( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues() + ) { + items( + items = items + ) { ytBaseItem -> + when (ytBaseItem) { + is AlbumOrPlaylistHeader -> {} + is ArtistHeader -> {} + is CarouselSection -> { + LazyHorizontalGrid( + rows = GridCells.Fixed(ytBaseItem.numItemsPerColumn), + modifier = Modifier + .fillMaxWidth() + .height(260.dp) + ) { + items( + items = ytBaseItem.items, + key = { it.id } + ) { item -> + when (item) { + is YTItem -> if (ytBaseItem.itemViewType == YTBaseItem.ViewType.BLOCK) { + YouTubeGridItem( + item = item, + modifier = Modifier.width(GridItemWidth.dp) + ) + } else { + YouTubeListItem( + item = item, + modifier = Modifier.width(maxWidth * 0.9f) + ) + } + else -> {} + } + } + } + } + is DescriptionSection -> { + Text( + text = ytBaseItem.description, + style = MaterialTheme.typography.bodyMedium + ) + } + is GridSection -> {} + is Header -> { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = ytBaseItem.moreNavigationEndpoint != null) { + + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = ytBaseItem.title, + style = MaterialTheme.typography.headlineMedium + ) + if (ytBaseItem.subtitle != null) { + Text( + text = ytBaseItem.subtitle!!, + style = MaterialTheme.typography.titleSmall + ) + } + } + if (ytBaseItem.moreNavigationEndpoint != null) { + Image( + painter = painterResource(com.zionhuang.music.R.drawable.ic_navigate_next), + contentDescription = null + ) + } + } + } + is NavigationItem -> {} + Separator -> {} + is SuggestionTextItem -> {} + is YTItem -> YouTubeListItem( + item = ytBaseItem, + modifier = Modifier.fillMaxWidth() + ) + + null -> {} + } + } + when (items.loadState.append) { + is LoadState.NotLoading -> Unit + LoadState.Loading -> { + item { + Text("Loading") + } + } + is LoadState.Error -> { + item { + Text((items.loadState.append as LoadState.Error).error.message.toString()) + } + } + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/compose/screens/OnlineSearchResult.kt new file mode 100644 index 000000000..c6a4715d9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/OnlineSearchResult.kt @@ -0,0 +1,172 @@ +package com.zionhuang.music.compose.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.paging.LoadState +import androidx.paging.compose.collectAsLazyPagingItems +import androidx.paging.compose.items +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG +import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO +import com.zionhuang.innertube.models.* +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.YouTubeListItem +import com.zionhuang.music.compose.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.compose.component.shimmer.ShimmerHost +import com.zionhuang.music.constants.* +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.music.viewmodels.SearchViewModel +import com.zionhuang.music.viewmodels.SearchViewModelFactory +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@Composable +fun OnlineSearchResult( + query: String, + viewModel: SearchViewModel = viewModel(factory = SearchViewModelFactory( + repository = YouTubeRepository(LocalContext.current), + query = query + )), +) { + val playerConnection = LocalPlayerConnection.current + val playWhenReady by playerConnection.playWhenReady.collectAsState(initial = false) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState(initial = null) + + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + val items = viewModel.pagingData.collectAsLazyPagingItems() + val searchFilter by viewModel.filter.observeAsState() + + LazyColumn( + state = lazyListState, + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .add(WindowInsets( + top = AppBarHeight.dp + SearchFilterHeight.dp, + bottom = NavigationBarHeight.dp + MiniPlayerHeight.dp + )) + .asPaddingValues() + ) { + if (items.loadState.refresh !is LoadState.Loading) { + items(items) { item -> + when (item) { + is Header -> { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight.dp) + .padding(12.dp) + .animateItemPlacement() + ) { + Text( + text = item.title, + style = MaterialTheme.typography.headlineMedium, + maxLines = 1 + ) + } + } + is YTItem -> { + YouTubeListItem( + item = item, + playingIndicator = mediaMetadata?.id == item.id, + playWhenReady = playWhenReady, + modifier = Modifier + .clickable { + when (val endpoint = item.navigationEndpoint.endpoint) { + is WatchEndpoint -> { + playerConnection.playQueue(YouTubeQueue(endpoint, item)) + } + is WatchPlaylistEndpoint -> { + playerConnection.playQueue(YouTubeQueue(endpoint.toWatchEndpoint(), item)) + } + is BrowseEndpoint -> {} + is SearchEndpoint -> {} + is QueueAddEndpoint -> playerConnection.songPlayer?.handleQueueAddEndpoint(endpoint, item) + is ShareEntityEndpoint -> {} + is BrowseLocalArtistSongsEndpoint -> {} + null -> {} + } + } + .animateItemPlacement() + ) + } + else -> {} + } + } + } + + item { + ShimmerHost { + repeat(when { + items.loadState.refresh is LoadState.Loading -> 10 + items.loadState.append is LoadState.Loading -> 3 + else -> 0 + }) { + ListItemPlaceHolder() + } + } + } + } + + Row( + modifier = Modifier + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .add(WindowInsets(top = AppBarHeight.dp)) + .asPaddingValues()) + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Spacer(Modifier.width(8.dp)) + + listOf( + null to R.string.search_filter_all, + FILTER_SONG to R.string.search_filter_songs, + FILTER_VIDEO to R.string.search_filter_videos, + FILTER_ALBUM to R.string.search_filter_albums, + FILTER_ARTIST to R.string.search_filter_artists, + FILTER_COMMUNITY_PLAYLIST to R.string.search_filter_community_playlists, + FILTER_FEATURED_PLAYLIST to R.string.search_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) { + coroutineScope.launch { + lazyListState.animateScrollToItem(0) + } + } else { + viewModel.filter.value = filter + items.refresh() + } + } + ) + Spacer(Modifier.width(8.dp)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/Screen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/Screen.kt new file mode 100644 index 000000000..08df452b0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/Screen.kt @@ -0,0 +1,48 @@ +package com.zionhuang.music.compose.screens + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import com.zionhuang.music.R +import com.zionhuang.music.compose.component.AppBarState + +sealed class Screen( + @StringRes val titleId: Int, + @DrawableRes val iconId: Int, + val route: String, + val appBarState: AppBarState, +) { + object Home : Screen(R.string.title_home, R.drawable.ic_home, "home", + AppBarState( + navigationIcon = R.drawable.ic_search, + canSearch = true + ) + ) + + object Songs : Screen(R.string.title_songs, R.drawable.ic_music_note, "songs", + AppBarState( + navigationIcon = R.drawable.ic_search, + canSearch = true + ) + ) + + object Artists : Screen(R.string.title_artists, R.drawable.ic_artist, "artists", + AppBarState( + navigationIcon = R.drawable.ic_search, + canSearch = true + ) + ) + + object Albums : Screen(R.string.title_albums, R.drawable.ic_album, "albums", + AppBarState( + navigationIcon = R.drawable.ic_search, + canSearch = true + ) + ) + + object Playlists : Screen(R.string.title_playlists, R.drawable.ic_queue_music, "playlists", + AppBarState( + navigationIcon = R.drawable.ic_search, + canSearch = true + ) + ) +} diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/SearchScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/SearchScreen.kt new file mode 100644 index 000000000..dbb27499c --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/SearchScreen.kt @@ -0,0 +1,218 @@ +package com.zionhuang.music.compose.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +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.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import com.zionhuang.innertube.models.SuggestionTextItem +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.music.R +import com.zionhuang.music.compose.component.YouTubeListItem +import com.zionhuang.music.constants.* +import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.repos.YouTubeRepository +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun SearchScreen( + query: String, + onTextFieldValueChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val history = rememberSaveable { + mutableStateOf(emptyList()) + } + val suggestions = rememberSaveable { + mutableStateOf(emptyList()) + } + val onlineSuggestions = rememberSaveable(suggestions.value, history.value) { + suggestions.value.filter { + it !in history.value + } + } + val items = rememberSaveable { + mutableStateOf(emptyList()) + } + + LaunchedEffect(query) { + delay(200) + if (query.isEmpty()) { + SongRepository(context).getAllSearchHistory().collectLatest { list -> + history.value = list.map { it.query } + } + } else { + SongRepository(context).getSearchHistory(query).collectLatest { list -> + history.value = list.map { it.query }.take(3) + } + } + } + + LaunchedEffect(query) { + delay(200) + if (query.isEmpty()) { + suggestions.value = emptyList() + items.value = emptyList() + } else { + val result = YouTubeRepository(context).getSuggestions(query) + suggestions.value = result.filterIsInstance().map { it.query } + items.value = result.filterIsInstance() + } + } + + LazyColumn( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .add(WindowInsets( + top = AppBarHeight.dp, + bottom = NavigationBarHeight.dp + MiniPlayerHeight.dp + )) + .asPaddingValues() + ) { + items( + items = history.value, + key = { it } + ) { query -> + SuggestionItem( + query = query, + online = false, + onClick = { + onSearch(query) + }, + onDelete = { + coroutineScope.launch { + SongRepository(context).deleteSearchHistory(query) + } + }, + onFillTextField = { + onTextFieldValueChange(TextFieldValue( + text = query, + selection = TextRange(query.length) + )) + }, + modifier = Modifier.animateItemPlacement() + ) + } + + items( + items = onlineSuggestions, + key = { it } + ) { query -> + SuggestionItem( + query = query, + online = true, + onClick = { + onSearch(query) + }, + onFillTextField = { + onTextFieldValueChange(TextFieldValue( + text = query, + selection = TextRange(query.length) + )) + }, + modifier = Modifier.animateItemPlacement() + ) + } + + if (items.value.isNotEmpty()) { + item { + Divider() + } + } + + items( + items = items.value, + key = { it.id } + ) { item -> + YouTubeListItem( + item = item, + modifier = Modifier + .clickable { + + } + .animateItemPlacement() + ) + } + } +} + +@Composable +fun SuggestionItem( + modifier: Modifier = Modifier, + query: String, + online: Boolean, + onClick: () -> Unit, + onDelete: () -> Unit = {}, + onFillTextField: () -> Unit, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(SuggestionItemHeight.dp) + .clickable( + onClick = onClick + ) + ) { + Icon( + painterResource(if (online) R.drawable.ic_search else R.drawable.ic_history), + contentDescription = null, + modifier = Modifier + .padding(horizontal = 16.dp) + .alpha(0.5f) + ) + + Text( + text = query, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + + if (!online) { + IconButton( + onClick = onDelete, + modifier = Modifier.alpha(0.5f) + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = null + ) + } + } + + IconButton( + onClick = onFillTextField, + modifier = Modifier.alpha(0.5f) + ) { + Icon( + painter = painterResource(R.drawable.ic_arrow_top_left), + contentDescription = null + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryAlbumsScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryAlbumsScreen.kt new file mode 100644 index 000000000..1c61bca2e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryAlbumsScreen.kt @@ -0,0 +1,83 @@ +package com.zionhuang.music.compose.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.zionhuang.music.R +import com.zionhuang.music.compose.component.AlbumListItem +import com.zionhuang.music.compose.LocalPlayerAwareWindowInsets +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.constants.* +import com.zionhuang.music.models.sortInfo.AlbumSortType +import com.zionhuang.music.viewmodels.SongsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryAlbumsScreen( + navController: NavController, + paddingModifier: PaddingValues, + viewModel: SongsViewModel = viewModel(), +) { + val playerConnection = LocalPlayerConnection.current + val playWhenReady by playerConnection.playWhenReady.collectAsState(initial = false) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState(initial = null) + + val sortType by rememberPreference(ALBUM_SORT_TYPE, AlbumSortType.CREATE_DATE) + val sortDescending by rememberPreference(ALBUM_SORT_DESCENDING, true) + val items by viewModel.allAlbumsFlow.collectAsState(initial = emptyList()) + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + TextButton(onClick = {}) { + Text(stringResource(when (sortType) { + AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date + AlbumSortType.NAME -> R.string.sort_by_name + AlbumSortType.ARTIST -> R.string.sort_by_artist + AlbumSortType.YEAR -> R.string.sort_by_year + AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count + AlbumSortType.LENGTH -> R.string.sort_by_length + })) + } + } + + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_ALBUM } + ) { index, album -> + AlbumListItem( + album = album, + playingIndicator = mediaMetadata?.album?.id == album.id, + playWhenReady = playWhenReady, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + navController.navigate("album/${album.id}") + } + .animateItemPlacement() + ) + } + } + } +} + diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryArtistsScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryArtistsScreen.kt new file mode 100644 index 000000000..7bedbf51e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryArtistsScreen.kt @@ -0,0 +1,73 @@ +package com.zionhuang.music.compose.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.lifecycle.viewmodel.compose.viewModel +import com.zionhuang.music.R +import com.zionhuang.music.compose.component.ArtistListItem +import com.zionhuang.music.compose.LocalPlayerAwareWindowInsets +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.constants.ARTIST_SORT_DESCENDING +import com.zionhuang.music.constants.ARTIST_SORT_TYPE +import com.zionhuang.music.constants.CONTENT_TYPE_ARTIST +import com.zionhuang.music.constants.CONTENT_TYPE_HEADER +import com.zionhuang.music.models.sortInfo.ArtistSortType +import com.zionhuang.music.viewmodels.SongsViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun LibraryArtistsScreen( + paddingModifier: PaddingValues, + viewModel: SongsViewModel = viewModel(), +) { + val sortType by rememberPreference(ARTIST_SORT_TYPE, ArtistSortType.CREATE_DATE) + val sortDescending by rememberPreference(ARTIST_SORT_DESCENDING, true) + val items by viewModel.allArtistsFlow.collectAsState(emptyList()) + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + TextButton(onClick = {}) { + Text(stringResource(when (sortType) { + ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSortType.NAME -> R.string.sort_by_name + ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count + })) + } + } + + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_ARTIST } + ) { index, artist -> + ArtistListItem( + artist = artist, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + + } + .animateItemPlacement() + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryPlaylistsScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryPlaylistsScreen.kt new file mode 100644 index 000000000..293053601 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibraryPlaylistsScreen.kt @@ -0,0 +1,133 @@ +package com.zionhuang.music.compose.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +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.ui.ExperimentalComposeUiApi +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.lifecycle.viewmodel.compose.viewModel +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerAwareWindowInsets +import com.zionhuang.music.compose.component.ListItem +import com.zionhuang.music.compose.component.PlaylistListItem +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.constants.CONTENT_TYPE_HEADER +import com.zionhuang.music.constants.CONTENT_TYPE_PLAYLIST +import com.zionhuang.music.constants.Constants.DOWNLOADED_PLAYLIST_ID +import com.zionhuang.music.constants.Constants.LIKED_PLAYLIST_ID +import com.zionhuang.music.constants.PLAYLIST_SORT_DESCENDING +import com.zionhuang.music.constants.PLAYLIST_SORT_TYPE +import com.zionhuang.music.models.sortInfo.PlaylistSortType +import com.zionhuang.music.viewmodels.SongsViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun LibraryPlaylistsScreen( + paddingModifier: PaddingValues, + viewModel: SongsViewModel = viewModel(), +) { + val sortType by rememberPreference(PLAYLIST_SORT_TYPE, PlaylistSortType.CREATE_DATE) + val sortDescending by rememberPreference(PLAYLIST_SORT_DESCENDING, true) + val likedSongCount by viewModel.likedSongCount.collectAsState(initial = 0) + val downloadedSongCount by viewModel.downloadedSongCount.collectAsState(initial = 0) + val items by viewModel.allPlaylistsFlow.collectAsState(initial = emptyList()) + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End).asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + TextButton(onClick = {}) { + Text(stringResource(when (sortType) { + PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSortType.NAME -> R.string.sort_by_name + PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + })) + } + } + + item( + key = LIKED_PLAYLIST_ID, + contentType = CONTENT_TYPE_PLAYLIST + ) { + ListItem( + thumbnailContent = { + Image( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + modifier = Modifier + .width(48.dp) + .height(48.dp) + ) + }, + title = stringResource(R.string.liked_songs), + subtitle = pluralStringResource(R.plurals.song_count, likedSongCount, likedSongCount), + modifier = Modifier + .combinedClickable { + + } + .animateItemPlacement() + ) + } + + item( + key = DOWNLOADED_PLAYLIST_ID, + contentType = CONTENT_TYPE_PLAYLIST + ) { + ListItem( + thumbnailContent = { + Image( + painter = painterResource(R.drawable.ic_save_alt), + contentDescription = null, + modifier = Modifier + .width(48.dp) + .height(48.dp) + ) + }, + title = stringResource(R.string.downloaded_songs), + subtitle = pluralStringResource(R.plurals.song_count, downloadedSongCount, downloadedSongCount), + modifier = Modifier + .combinedClickable { + + } + .animateItemPlacement() + ) + } + + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_PLAYLIST } + ) { index, playlist -> + PlaylistListItem( + playlist = playlist, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + + } + .animateItemPlacement() + ) + } + } + } +} + diff --git a/app/src/main/java/com/zionhuang/music/compose/screens/library/LibrarySongsScreen.kt b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibrarySongsScreen.kt new file mode 100644 index 000000000..f4d8226e5 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/screens/library/LibrarySongsScreen.kt @@ -0,0 +1,186 @@ +package com.zionhuang.music.compose.screens.library + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.lifecycle.viewmodel.compose.viewModel +import com.zionhuang.music.R +import com.zionhuang.music.compose.LocalPlayerAwareWindowInsets +import com.zionhuang.music.compose.LocalPlayerConnection +import com.zionhuang.music.compose.component.IconButton +import com.zionhuang.music.compose.component.SongListItem +import com.zionhuang.music.compose.utils.rememberPreference +import com.zionhuang.music.constants.* +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.sortInfo.SongSortType +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.viewmodels.SongsViewModel + +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) +@Composable +fun LibrarySongsScreen( + viewModel: SongsViewModel = viewModel(), +) { + val playerConnection = LocalPlayerConnection.current + val playWhenReady by playerConnection.playWhenReady.collectAsState(initial = false) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState(initial = null) + + var sortType by rememberPreference(SONG_SORT_TYPE, SongSortType.CREATE_DATE) + var sortDescending by rememberPreference(SONG_SORT_DESCENDING, true) + val items by viewModel.allSongsFlow.collectAsState(initial = emptyList()) + + val queueTitle = stringResource(R.string.queue_all_songs) + var sortTypeMenuExpanded by remember { mutableStateOf(false) } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Vertical + WindowInsetsSides.End) + .add(WindowInsets( + top = AppBarHeight.dp, + bottom = NavigationBarHeight.dp + MiniPlayerHeight.dp + )) + .asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(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 + }), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ) { + sortTypeMenuExpanded = !sortTypeMenuExpanded + } + .padding(8.dp) + ) + + DropdownMenu( + expanded = sortTypeMenuExpanded, + onDismissRequest = { sortTypeMenuExpanded = false }, + modifier = Modifier.widthIn(min = 172.dp) + ) { + listOf( + SongSortType.CREATE_DATE to R.string.sort_by_create_date, + SongSortType.NAME to R.string.sort_by_name, + SongSortType.ARTIST to R.string.sort_by_artist, + SongSortType.PLAY_TIME to R.string.sort_by_play_time + ).forEach { (type, text) -> + DropdownMenuItem( + text = { + Text( + text = stringResource(text), + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + }, + trailingIcon = { + Icon( + painter = painterResource(if (sortType == type) R.drawable.ic_radio_button_checked else R.drawable.ic_radio_button_unchecked), + contentDescription = null + ) + }, + onClick = { + sortType = type + sortTypeMenuExpanded = false + } + ) + } + } + + IconButton( + icon = if (sortDescending) R.drawable.ic_arrow_downward else R.drawable.ic_arrow_upward, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(32.dp) + .padding(8.dp), + onClick = { sortDescending = !sortDescending } + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = pluralStringResource(R.plurals.song_count, items.size, items.size), + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } + } + + itemsIndexed( + items = items, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + SongListItem( + song = song, + playingIndicator = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(ListQueue( + title = queueTitle, + items = items.map { it.toMediaItem() }, + startIndex = index + )) + } + .animateItemPlacement() + ) + } + } + + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(bottom = LocalPlayerAwareWindowInsets.current + .asPaddingValues() + .calculateBottomPadding()) + .padding(16.dp), + onClick = { + playerConnection.playQueue(ListQueue( + title = queueTitle, + items = items.shuffled().map { it.toMediaItem() }, + )) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null + ) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/compose/theme/Theme.kt b/app/src/main/java/com/zionhuang/music/compose/theme/Theme.kt new file mode 100644 index 000000000..39bae5e1f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/theme/Theme.kt @@ -0,0 +1,104 @@ +package com.zionhuang.music.compose.theme + +import android.os.Build +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.platform.LocalContext +import com.google.material.themebuilder.scheme.Scheme + +val DefaultThemeColor = Color(0xFF6650a4) + +@Composable +fun InnerTuneTheme( + darkTheme: Boolean = isSystemInDarkTheme(), + dynamicColor: Boolean = true, + themeColor: Color = DefaultThemeColor, + content: @Composable () -> Unit, +) { + val colorScheme = when { + dynamicColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S -> { + val context = LocalContext.current + if (darkTheme) dynamicDarkColorScheme(context) else dynamicLightColorScheme(context) + } + darkTheme -> Scheme.dark(themeColor.toArgb()).toDarkColorScheme() + else -> Scheme.light(themeColor.toArgb()).toLightColorScheme() + } + + MaterialTheme( + colorScheme = colorScheme, + typography = MaterialTheme.typography, + content = content + ) +} + +fun Scheme.toDarkColorScheme() = darkColorScheme( + primary = Color(primary), + onPrimary = Color(onPrimary), + primaryContainer = Color(primaryContainer), + onPrimaryContainer = Color(onPrimaryContainer), + inversePrimary = Color(inversePrimary), + secondary = Color(secondary), + onSecondary = Color(onSecondary), + secondaryContainer = Color(secondaryContainer), + onSecondaryContainer = Color(onSecondaryContainer), + tertiary = Color(tertiary), + onTertiary = Color(onTertiary), + tertiaryContainer = Color(tertiaryContainer), + onTertiaryContainer = Color(onTertiaryContainer), + background = Color(background), + onBackground = Color(onBackground), + surface = Color(surface), + onSurface = Color(onSurface), + surfaceVariant = Color(surfaceVariant), + onSurfaceVariant = Color(onSurfaceVariant), + inverseSurface = Color(inverseSurface), + inverseOnSurface = Color(inverseOnSurface), + error = Color(error), + onError = Color(onError), + errorContainer = Color(errorContainer), + onErrorContainer = Color(onErrorContainer), + outline = Color(outline), + outlineVariant = Color(outlineVariant), + scrim = Color(scrim), +) + +fun Scheme.toLightColorScheme() = lightColorScheme( + primary = Color(primary), + onPrimary = Color(onPrimary), + primaryContainer = Color(primaryContainer), + onPrimaryContainer = Color(onPrimaryContainer), + inversePrimary = Color(inversePrimary), + secondary = Color(secondary), + onSecondary = Color(onSecondary), + secondaryContainer = Color(secondaryContainer), + onSecondaryContainer = Color(onSecondaryContainer), + tertiary = Color(tertiary), + onTertiary = Color(onTertiary), + tertiaryContainer = Color(tertiaryContainer), + onTertiaryContainer = Color(onTertiaryContainer), + background = Color(background), + onBackground = Color(onBackground), + surface = Color(surface), + onSurface = Color(onSurface), + surfaceVariant = Color(surfaceVariant), + onSurfaceVariant = Color(onSurfaceVariant), + inverseSurface = Color(inverseSurface), + inverseOnSurface = Color(inverseOnSurface), + error = Color(error), + onError = Color(onError), + errorContainer = Color(errorContainer), + onErrorContainer = Color(onErrorContainer), + outline = Color(outline), + outlineVariant = Color(outlineVariant), + scrim = Color(scrim), +) + +val ColorSaver = object : Saver { + override fun restore(value: Int): Color = Color(value) + override fun SaverScope.save(value: Color): Int = value.toArgb() +} diff --git a/app/src/main/java/com/zionhuang/music/compose/utils/FadingEdge.kt b/app/src/main/java/com/zionhuang/music/compose/utils/FadingEdge.kt new file mode 100644 index 000000000..bb9b2fb1b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/utils/FadingEdge.kt @@ -0,0 +1,24 @@ +package com.zionhuang.music.compose.utils + +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithContent +import androidx.compose.ui.graphics.BlendMode +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.graphicsLayer + +fun Modifier.verticalFadingEdge() = + graphicsLayer(alpha = 0.99f) + .drawWithContent { + drawContent() + drawRect( + brush = Brush.verticalGradient( + listOf( + Color.Transparent, + Color.Black, Color.Black, Color.Black, + Color.Transparent + ) + ), + blendMode = BlendMode.DstIn + ) + } diff --git a/app/src/main/java/com/zionhuang/music/compose/utils/PreferenceUtils.kt b/app/src/main/java/com/zionhuang/music/compose/utils/PreferenceUtils.kt new file mode 100644 index 000000000..1355a5a4d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/compose/utils/PreferenceUtils.kt @@ -0,0 +1,62 @@ +package com.zionhuang.music.compose.utils + +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.core.content.edit +import com.zionhuang.music.extensions.getEnum +import com.zionhuang.music.extensions.putEnum +import com.zionhuang.music.extensions.sharedPreferences + + +@Composable +fun rememberPreference(key: String, defaultValue: Boolean): MutableState { + val context = LocalContext.current + return remember { + mutableStatePreferenceOf(context.sharedPreferences.getBoolean(key, defaultValue)) { + context.sharedPreferences.edit { putBoolean(key, it) } + } + } +} + +@Composable +fun rememberPreference(key: String, defaultValue: Int): MutableState { + val context = LocalContext.current + return remember { + mutableStatePreferenceOf(context.sharedPreferences.getInt(key, defaultValue)) { + context.sharedPreferences.edit { putInt(key, it) } + } + } +} + +@Composable +fun rememberPreference(key: String, defaultValue: String): MutableState { + val context = LocalContext.current + return remember { + mutableStatePreferenceOf(context.sharedPreferences.getString(key, null) ?: defaultValue) { + context.sharedPreferences.edit { putString(key, it) } + } + } +} + +@Composable +inline fun > rememberPreference(key: String, defaultValue: T): MutableState { + val context = LocalContext.current + return remember { + mutableStatePreferenceOf(context.sharedPreferences.getEnum(key, defaultValue)) { + context.sharedPreferences.edit { putEnum(key, it) } + } + } +} + +inline fun mutableStatePreferenceOf( + value: T, + crossinline onStructuralInequality: (newValue: T) -> Unit, +) = mutableStateOf( + value = value, + policy = object : SnapshotMutationPolicy { + override fun equivalent(a: T, b: T): Boolean { + val areEquals = a == b + if (!areEquals) onStructuralInequality(b) + return areEquals + } + }) diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt new file mode 100644 index 000000000..e77442d04 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt @@ -0,0 +1,20 @@ +package com.zionhuang.music.constants + +const val CONTENT_TYPE_HEADER = 0 +const val CONTENT_TYPE_SONG = 1 +const val CONTENT_TYPE_ARTIST = 2 +const val CONTENT_TYPE_ALBUM = 3 +const val CONTENT_TYPE_PLAYLIST = 4 + +const val NavigationBarHeight = 80 +const val MiniPlayerHeight = 64 +const val QueuePeekHeight = 64 +const val AppBarHeight = 64 + +const val ListItemHeight = 64 +const val GridItemWidth = 168 +const val SuggestionItemHeight = 56 +const val SearchFilterHeight = 48 +const val ListThumbnailSize = 48 + +const val ThumbnailCornerRadius = 6 \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/constants/Constants.kt b/app/src/main/java/com/zionhuang/music/constants/Constants.kt index 9bff59d51..cb424594a 100644 --- a/app/src/main/java/com/zionhuang/music/constants/Constants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/Constants.kt @@ -20,9 +20,4 @@ object Constants { const val GITHUB_ISSUE_URL = "https://github.com/z-huang/InnerTune/issues" const val ERROR_INFO = "error_info" - - const val VISITOR_DATA = "visitor_data" - const val INNERTUBE_COOKIE = "innertube_cookie" - const val ACCOUNT_NAME = "account_name" - const val ACCOUNT_EMAIL = "account_email" } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt new file mode 100644 index 000000000..a1bd9536d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -0,0 +1,18 @@ +package com.zionhuang.music.constants + +const val SONG_SORT_TYPE = "SONG_SORT_TYPE" +const val SONG_SORT_DESCENDING = "SONG_SORT_DESC" +const val ARTIST_SORT_TYPE = "ARTIST_SORT_TYPE" +const val ARTIST_SORT_DESCENDING = "ARTIST_SORT_DESC" +const val ALBUM_SORT_TYPE = "ALBUM_SORT_TYPE" +const val ALBUM_SORT_DESCENDING = "ALBUM_SORT_DESC" +const val PLAYLIST_SORT_TYPE = "PLAYLIST_SORT_TYPE" +const val PLAYLIST_SORT_DESCENDING = "PLAYLIST_SORT_DESC" + +const val VISITOR_DATA = "visitor_data" +const val INNERTUBE_COOKIE = "innertube_cookie" +const val ACCOUNT_NAME = "account_name" +const val ACCOUNT_EMAIL = "account_email" + +const val SHOW_LYRICS = "SHOW_LYRICS" +const val LRC_TEXT_POS = "LRC_TEXT_POS" \ No newline at end of file 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 8049b9e21..9897ac1d1 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -33,13 +33,15 @@ import java.util.* ], views = [ SortedSongArtistMap::class, + SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 4, + version = 5, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), - AutoMigration(from = 3, to = 4) + AutoMigration(from = 3, to = 4), + AutoMigration(from = 4, to = 5) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt index 99ae49cc3..5fc813a9d 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt @@ -2,10 +2,7 @@ package com.zionhuang.music.db.daos import androidx.room.* import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.db.entities.AlbumArtistMap -import com.zionhuang.music.db.entities.AlbumEntity -import com.zionhuang.music.db.entities.SongAlbumMap +import com.zionhuang.music.db.entities.* import com.zionhuang.music.extensions.toSQLiteQuery import com.zionhuang.music.models.sortInfo.AlbumSortType import com.zionhuang.music.models.sortInfo.ISortInfo @@ -30,8 +27,13 @@ interface AlbumDao { @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") fun searchAlbumsPreview(query: String, previewSize: Int): Flow> + @Transaction + @Query("SELECT * FROM album WHERE id = :id") + suspend fun getAlbumById(id: String): Album? + + @Transaction @Query("SELECT * FROM album WHERE id = :id") - suspend fun getAlbumById(id: String): AlbumEntity? + suspend fun getAlbumWithSongs(id: String): AlbumWithSongs? @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(album: AlbumEntity): Long @@ -40,7 +42,10 @@ interface AlbumDao { suspend fun insert(albumArtistMap: AlbumArtistMap): Long @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songAlbumMaps: List): List + suspend fun insertAlbumArtistMaps(albumArtistMaps: List) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertSongAlbumMaps(songAlbumMaps: List): List @Update suspend fun update(album: AlbumEntity) @@ -49,7 +54,7 @@ interface AlbumDao { suspend fun update(songAlbumMaps: List) suspend fun upsert(songAlbumMaps: List) { - insert(songAlbumMaps) + insertSongAlbumMaps(songAlbumMaps) .withIndex() .mapNotNull { if (it.value == -1L) songAlbumMaps[it.index] else null } .let { update(it) } diff --git a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt index 399423f8f..010670aa9 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt @@ -51,11 +51,14 @@ interface ArtistDao { @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(artist: ArtistEntity): Long + @Insert(onConflict = OnConflictStrategy.IGNORE) + suspend fun insertArtists(artists: List) + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(songArtistMap: SongArtistMap): Long @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songArtistMaps: List) + suspend fun insertSongArtistMaps(songArtistMaps: List) @Update suspend fun update(artist: ArtistEntity) diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt index c30f1046d..dae8eb800 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt @@ -5,14 +5,15 @@ import androidx.room.Insert import androidx.room.OnConflictStrategy import androidx.room.Query import com.zionhuang.music.db.entities.SearchHistory +import kotlinx.coroutines.flow.Flow @Dao interface SearchHistoryDao { @Query("SELECT * FROM search_history ORDER BY id DESC") - suspend fun getAllHistory(): List + fun getAllHistory(): Flow> @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") - suspend fun getHistory(query: String): List + fun getHistory(query: String): Flow> @Insert(onConflict = OnConflictStrategy.REPLACE) suspend fun insert(searchHistory: SearchHistory) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Album.kt b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt index 46e2fbc46..0a93a7c94 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Album.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Album.kt @@ -1,9 +1,11 @@ package com.zionhuang.music.db.entities +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation +@Immutable data class Album( @Embedded val album: AlbumEntity, 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 f37cbb9fd..68a571e18 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 @@ -1,11 +1,13 @@ package com.zionhuang.music.db.entities import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize import java.time.LocalDateTime +@Immutable @Parcelize @Entity(tableName = "album") data class AlbumEntity( diff --git a/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt b/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt new file mode 100644 index 000000000..6e64f031a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/AlbumWithSongs.kt @@ -0,0 +1,34 @@ +package com.zionhuang.music.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +@Immutable +data class AlbumWithSongs( + @Embedded + val album: AlbumEntity, + @Relation( + entity = ArtistEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = AlbumArtistMap::class, + parentColumn = "albumId", + entityColumn = "artistId" + ) + ) + val artists: List, + @Relation( + entity = SongEntity::class, + entityColumn = "id", + parentColumn = "id", + associateBy = Junction( + value = SortedSongAlbumMap::class, + parentColumn = "albumId", + entityColumn = "songId" + ) + ) + val songs: List, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt index 742d92e08..2eff0dcb8 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Artist.kt @@ -1,7 +1,9 @@ package com.zionhuang.music.db.entities +import androidx.compose.runtime.Immutable import androidx.room.Embedded +@Immutable data class Artist( @Embedded val artist: ArtistEntity, 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 95a4e8dc4..f2041b5a6 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 @@ -1,12 +1,14 @@ package com.zionhuang.music.db.entities import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime +@Immutable @Parcelize @Entity(tableName = "artist") data class ArtistEntity( diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt index f80acad05..f1ee6006c 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt @@ -1,9 +1,11 @@ package com.zionhuang.music.db.entities +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation +@Immutable data class Playlist( @Embedded val playlist: PlaylistEntity, diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt index 132845b91..7de857e59 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt @@ -1,12 +1,14 @@ package com.zionhuang.music.db.entities import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey import kotlinx.parcelize.Parcelize import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime +@Immutable @Parcelize @Entity(tableName = "playlist") data class PlaylistEntity( diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt index 05d403339..6c9aeb71b 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt @@ -1,11 +1,17 @@ package com.zionhuang.music.db.entities +import android.content.Context import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.music.extensions.toSongEntity +import com.zionhuang.music.repos.SongRepository import kotlinx.parcelize.Parcelize +@Immutable @Parcelize data class Song @JvmOverloads constructor( @Embedded val song: SongEntity, @@ -31,8 +37,21 @@ data class Song @JvmOverloads constructor( ) ) val album: AlbumEntity? = null, - val position: Int? = -1, ) : LocalItem(), Parcelable { override val id: String get() = song.id -} \ No newline at end of file +} + +suspend fun SongItem.toSong(context: Context): Song = + Song( + song = toSongEntity(), + artists = artists.map { run -> + ArtistEntity( + id = run.navigationEndpoint?.browseEndpoint?.browseId + ?: SongRepository(context).getArtistByName(run.text)?.id + ?: ArtistEntity.generateArtistId(), + name = run.text + ) + }, + album = null + ) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt index 6a68a3e4b..c08b442be 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/SongAlbumMap.kt @@ -24,5 +24,5 @@ import androidx.room.ForeignKey data class SongAlbumMap( @ColumnInfo(index = true) val songId: String, @ColumnInfo(index = true) val albumId: String, - val index: Int? = null, + val index: Int, ) 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 ace58a1b2..ca8ce3767 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 @@ -1,6 +1,7 @@ package com.zionhuang.music.db.entities import android.os.Parcelable +import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey @@ -8,6 +9,7 @@ import com.zionhuang.music.constants.MediaConstants.STATE_NOT_DOWNLOADED import kotlinx.parcelize.Parcelize import java.time.LocalDateTime +@Immutable @Parcelize @Entity(tableName = "song") data class SongEntity( diff --git a/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt new file mode 100644 index 000000000..999f5f4ad --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/SortedSongAlbumMap.kt @@ -0,0 +1,13 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView( + viewName = "sorted_song_album_map", + value = "SELECT * FROM song_album_map ORDER BY `index`") +data class SortedSongAlbumMap( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val albumId: String, + val index: Int, +) diff --git a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt b/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt index 65f5d0d2e..cc52a4feb 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt @@ -1,10 +1,118 @@ package com.zionhuang.music.extensions import android.util.Log +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.geometry.Size +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.drawscope.Fill +import androidx.compose.ui.graphics.drawscope.Stroke +import androidx.compose.ui.graphics.lerp +import androidx.compose.ui.platform.debugInspectorInfo +import androidx.compose.ui.unit.dp +import com.zionhuang.music.BuildConfig +import kotlinx.coroutines.delay +import kotlin.math.min val Any.TAG: String get() = javaClass.simpleName fun Any.logd(msg: String) { Log.d(TAG, msg) -} \ No newline at end of file +} + +class Ref(var value: Int) + +// Note the inline function below which ensures that this function is essentially +// copied at the call site to ensure that its logging only recompositions from the +// original call site. +@Composable +inline fun LogCompositions(tag: String, msg: String) { + if (BuildConfig.DEBUG) { + val ref = remember { Ref(0) } + SideEffect { ref.value++ } + Log.d(tag, "Compositions: $msg ${ref.value}") + } +} +/** + * A [Modifier] that draws a border around elements that are recomposing. The border increases in + * size and interpolates from red to green as more recompositions occur before a timeout. + */ +@Stable +fun Modifier.recomposeHighlighter(): Modifier = this.then(recomposeModifier) + +// Use a single instance + @Stable to ensure that recompositions can enable skipping optimizations +// Modifier.composed will still remember unique data per call site. +private val recomposeModifier = + Modifier.composed(inspectorInfo = debugInspectorInfo { name = "recomposeHighlighter" }) { + // The total number of compositions that have occurred. We're not using a State<> here be + // able to read/write the value without invalidating (which would cause infinite + // recomposition). + val totalCompositions = remember { arrayOf(0L) } + totalCompositions[0]++ + + // The value of totalCompositions at the last timeout. + val totalCompositionsAtLastTimeout = remember { mutableStateOf(0L) } + + // Start the timeout, and reset everytime there's a recomposition. (Using totalCompositions + // as the key is really just to cause the timer to restart every composition). + LaunchedEffect(totalCompositions[0]) { + delay(3000) + totalCompositionsAtLastTimeout.value = totalCompositions[0] + } + + Modifier.drawWithCache { + onDrawWithContent { + // Draw actual content. + drawContent() + + // Below is to draw the highlight, if necessary. A lot of the logic is copied from + // Modifier.border + val numCompositionsSinceTimeout = + totalCompositions[0] - totalCompositionsAtLastTimeout.value + + val hasValidBorderParams = size.minDimension > 0f + if (!hasValidBorderParams || numCompositionsSinceTimeout <= 0) { + return@onDrawWithContent + } + + val (color, strokeWidthPx) = + when (numCompositionsSinceTimeout) { + // We need at least one composition to draw, so draw the smallest border + // color in blue. + 1L -> Color.Blue to 1f + // 2 compositions is _probably_ okay. + 2L -> Color.Green to 2.dp.toPx() + // 3 or more compositions before timeout may indicate an issue. lerp the + // color from yellow to red, and continually increase the border size. + else -> { + lerp( + Color.Yellow.copy(alpha = 0.8f), + Color.Red.copy(alpha = 0.5f), + min(1f, (numCompositionsSinceTimeout - 1).toFloat() / 100f) + ) to numCompositionsSinceTimeout.toInt().dp.toPx() + } + } + + val halfStroke = strokeWidthPx / 2 + val topLeft = Offset(halfStroke, halfStroke) + val borderSize = Size(size.width - strokeWidthPx, size.height - strokeWidthPx) + + val fillArea = (strokeWidthPx * 2) > size.minDimension + val rectTopLeft = if (fillArea) Offset.Zero else topLeft + val size = if (fillArea) size else borderSize + val style = if (fillArea) Fill else Stroke(strokeWidthPx) + + drawRect( + brush = SolidColor(color), + topLeft = rectTopLeft, + size = size, + style = style + ) + } + } + } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt index a9f8d733a..c110f3e31 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt @@ -1,17 +1,62 @@ package com.zionhuang.music.extensions +import com.google.android.exoplayer2.C import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.Player.REPEAT_MODE_OFF +import com.google.android.exoplayer2.Timeline import com.zionhuang.music.models.MediaMetadata +import java.util.* +import kotlin.collections.AbstractList -fun Player.findMediaItemById(mediaId: String): MediaItem? { - for (i in 0 until mediaItemCount) { - val item = getMediaItemAt(i) - if (item.mediaId == mediaId) { - return item +fun Player.togglePlayPause() { + playWhenReady = !playWhenReady +} + +fun Player.getQueueWindows(): List { + val timeline = currentTimeline + if (timeline.isEmpty) { + return emptyList() + } + val queue = ArrayDeque() + val queueSize = timeline.windowCount + + val currentMediaItemIndex: Int = currentMediaItemIndex + queue.add(timeline.getWindow(currentMediaItemIndex, Timeline.Window())) + + var firstMediaItemIndex = currentMediaItemIndex + var lastMediaItemIndex = currentMediaItemIndex + val shuffleModeEnabled = shuffleModeEnabled + while ((firstMediaItemIndex != C.INDEX_UNSET || lastMediaItemIndex != C.INDEX_UNSET) && queue.size < queueSize) { + if (lastMediaItemIndex != C.INDEX_UNSET) { + lastMediaItemIndex = timeline.getNextWindowIndex(lastMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (lastMediaItemIndex != C.INDEX_UNSET) { + queue.add(timeline.getWindow(lastMediaItemIndex, Timeline.Window())) + } + } + if (firstMediaItemIndex != C.INDEX_UNSET && queue.size < queueSize) { + firstMediaItemIndex = timeline.getPreviousWindowIndex(firstMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (firstMediaItemIndex != C.INDEX_UNSET) { + queue.addFirst(timeline.getWindow(firstMediaItemIndex, Timeline.Window())) + } } } - return null + return queue.toList() +} + +fun Player.getCurrentQueueIndex(): Int { + if (currentTimeline.isEmpty) { + return -1 + } + var index = 0 + var currentMediaItemIndex = currentMediaItemIndex + while (currentMediaItemIndex != C.INDEX_UNSET) { + currentMediaItemIndex = currentTimeline.getPreviousWindowIndex(currentMediaItemIndex, REPEAT_MODE_OFF, shuffleModeEnabled) + if (currentMediaItemIndex != C.INDEX_UNSET) { + index++ + } + } + return index } fun Player.mediaItemIndexOf(mediaId: String?): Int? { diff --git a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt b/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt index a8dbb4fb2..2c888f87f 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt @@ -1,6 +1,9 @@ package com.zionhuang.music.extensions import android.content.SharedPreferences +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.ui.platform.LocalContext import androidx.core.content.edit import kotlinx.coroutines.channels.awaitClose import kotlinx.coroutines.flow.* @@ -25,7 +28,7 @@ inline fun > SharedPreferences.getEnum(key: String, defaultV } } ?: defaultValue -inline fun > SharedPreferences.putEnum(key: String, value: T) = edit().putString(key, value.name).apply() +inline fun > SharedPreferences.Editor.putEnum(key: String, value: T) = putString(key, value.name) @Suppress("UNCHECKED_CAST") fun SharedPreferences.get(key: String, defaultValue: T): T = when (defaultValue::class) { @@ -66,6 +69,9 @@ fun SharedPreferences.booleanFlow(key: String, defaultValue: Boolean) = keyFlow .map { getBoolean(key, defaultValue) } .conflate() +@Composable +fun preferenceState(key: String, defaultValue: Boolean) = LocalContext.current.sharedPreferences.booleanFlow(key, defaultValue).collectAsState(initial = defaultValue) + inline fun > SharedPreferences.enumFlow(key: String, defaultValue: E) = keyFlow .filter { it == key || it == null } .onStart { emit("init trigger") } diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt index 31421c166..67afbfd09 100644 --- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -4,6 +4,7 @@ import android.content.Context import android.os.Parcelable import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat.* +import androidx.compose.runtime.Immutable import androidx.core.net.toUri import androidx.core.os.bundleOf import com.zionhuang.innertube.models.SongItem @@ -15,6 +16,7 @@ import kotlinx.parcelize.Parcelize import java.io.Serializable import kotlin.math.roundToInt +@Immutable @Parcelize data class MediaMetadata( val id: String, diff --git a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt index 7e9f5fd07..ef847709b 100644 --- a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt +++ b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt @@ -9,9 +9,14 @@ import coil.request.Disposable import coil.request.ImageRequest class BitmapProvider(private val context: Context) { + var currentBitmap: Bitmap? = null private val map = LruCache(MAX_CACHE_SIZE) - private var disposable: Disposable? = null + var onBitmapChanged: (Bitmap?) -> Unit = {} + set(value) { + field = value + value(currentBitmap) + } fun load(url: String, callback: (Bitmap) -> Unit): Bitmap? { val cache = map.get(url) @@ -19,17 +24,23 @@ class BitmapProvider(private val context: Context) { if (cache == null) { disposable = context.imageLoader.enqueue(ImageRequest.Builder(context) .data(url) + .allowHardware(false) .target(onSuccess = { drawable -> val bitmap = (drawable as BitmapDrawable).bitmap map.put(url, bitmap) callback(bitmap) + currentBitmap = bitmap + onBitmapChanged(bitmap) }) .build()) + } else { + currentBitmap = cache + onBitmapChanged(cache) } return cache } companion object { - const val MAX_CACHE_SIZE = 10 + const val MAX_CACHE_SIZE = 15 } } \ No newline at end of file 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 2db79422a..635fa84ac 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -17,6 +17,7 @@ import androidx.core.content.ContextCompat.startForegroundService import androidx.core.net.toUri import androidx.lifecycle.lifecycleScope import androidx.media.session.MediaButtonReceiver +import com.google.android.exoplayer2.Player import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.zionhuang.music.R @@ -48,7 +49,7 @@ class MusicService : LifecycleMediaBrowserService() { startForegroundService(this@MusicService, Intent(this@MusicService, MusicService::class.java)) startForeground(notificationId, notification) } else { - stopForeground(0) + stopForeground(STOP_FOREGROUND_DETACH) } } }) @@ -83,6 +84,9 @@ class MusicService : LifecycleMediaBrowserService() { val sessionToken: MediaSessionCompat.Token get() = songPlayer.mediaSession.sessionToken + val player: Player + get() = this@MusicService.songPlayer.player + val songPlayer: SongPlayer get() = this@MusicService.songPlayer diff --git a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt new file mode 100644 index 000000000..9c0e35d49 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -0,0 +1,153 @@ +package com.zionhuang.music.playback + +import android.content.Context +import android.graphics.Bitmap +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.Player.* +import com.google.android.exoplayer2.Timeline +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.models.MediaMetadata +import com.zionhuang.music.playback.MusicService.MusicBinder +import com.zionhuang.music.playback.queues.Queue +import com.zionhuang.music.repos.SongRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flatMapLatest + +@OptIn(ExperimentalCoroutinesApi::class) +class PlayerConnection(context: Context) : Listener { + val songRepository by lazy { SongRepository(context) } + var binder: MusicBinder? = null + val songPlayer: SongPlayer? get() = binder?.songPlayer + val player: Player? get() = binder?.player + + val playbackState = MutableStateFlow(STATE_IDLE) + val playWhenReady = MutableStateFlow(false) + val mediaMetadata = MutableStateFlow(null) + val currentSong = mediaMetadata.flatMapLatest { + songRepository.getSongById(it?.id).flow + } + val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> + songRepository.getLyrics(mediaMetadata?.id) + } + val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> + songRepository.getSongFormat(mediaMetadata?.id).flow + } + + val queueTitle = MutableStateFlow(null) + val queueItems = MutableStateFlow>(emptyList()) + val currentMediaItemIndex = MutableStateFlow(-1) + val currentWindowIndex = MutableStateFlow(-1) + + val shuffleModeEnabled = MutableStateFlow(false) + val repeatMode = MutableStateFlow(REPEAT_MODE_OFF) + + val canSkipPrevious = MutableStateFlow(true) + val canSkipNext = MutableStateFlow(true) + + var onBitmapChanged: (Bitmap?) -> Unit = {} + set(value) { + field = value + binder?.songPlayer?.bitmapProvider?.onBitmapChanged = value + } + + fun init(binder: MusicBinder) { + this.binder = binder + binder.player.addListener(this) + binder.songPlayer.bitmapProvider.onBitmapChanged = onBitmapChanged + + playbackState.value = binder.player.playbackState + playWhenReady.value = binder.player.playWhenReady + mediaMetadata.value = binder.player.currentMetadata + queueTitle.value = binder.songPlayer.queueTitle + queueItems.value = binder.player.getQueueWindows() + currentWindowIndex.value = binder.player.getCurrentQueueIndex() + currentMediaItemIndex.value = binder.player.currentMediaItemIndex + shuffleModeEnabled.value = binder.player.shuffleModeEnabled + repeatMode.value = binder.player.repeatMode + } + + fun playQueue(queue: Queue) { + binder?.songPlayer?.playQueue(queue) + } + + fun toggleRepeatMode() { + binder?.player?.let { + it.repeatMode = when (it.repeatMode) { + REPEAT_MODE_OFF -> REPEAT_MODE_ALL + REPEAT_MODE_ALL -> REPEAT_MODE_ONE + REPEAT_MODE_ONE -> REPEAT_MODE_OFF + else -> throw IllegalStateException() + } + } + } + + fun toggleLike() { + binder?.songPlayer?.toggleLike() + } + + fun toggleLibrary() { + binder?.songPlayer?.toggleLibrary() + } + + override fun onPlaybackStateChanged(state: Int) { + playbackState.value = state + } + + override fun onPlayWhenReadyChanged(newPlayWhenReady: Boolean, reason: Int) { + playWhenReady.value = newPlayWhenReady + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + mediaMetadata.value = mediaItem?.metadata + currentMediaItemIndex.value = player?.currentMediaItemIndex ?: -1 + currentWindowIndex.value = binder?.player?.getCurrentQueueIndex()!! + updateCanSkipPreviousAndNext() + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + queueItems.value = binder?.player?.getQueueWindows()!! + queueTitle.value = binder?.songPlayer?.queueTitle + currentMediaItemIndex.value = player?.currentMediaItemIndex ?: -1 + currentWindowIndex.value = binder?.player?.getCurrentQueueIndex()!! + updateCanSkipPreviousAndNext() + } + + override fun onShuffleModeEnabledChanged(enabled: Boolean) { + shuffleModeEnabled.value = enabled + queueItems.value = binder?.player?.getQueueWindows()!! + currentWindowIndex.value = binder?.player?.getCurrentQueueIndex()!! + updateCanSkipPreviousAndNext() + } + + override fun onRepeatModeChanged(mode: Int) { + repeatMode.value = mode + updateCanSkipPreviousAndNext() + } + + private fun updateCanSkipPreviousAndNext() { + player?.let { player -> + if (!player.currentTimeline.isEmpty) { + val window = player.currentTimeline.getWindow(player.currentMediaItemIndex, Timeline.Window()) + canSkipPrevious.value = player.isCommandAvailable(COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM) + || !window.isLive() + || player.isCommandAvailable(COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM) + canSkipNext.value = window.isLive() && window.isDynamic + || player.isCommandAvailable(COMMAND_SEEK_TO_NEXT_MEDIA_ITEM) + } else { + canSkipPrevious.value = false + canSkipNext.value = false + } + } + } + + fun dispose() { + binder?.songPlayer?.bitmapProvider?.onBitmapChanged = {} + binder?.songPlayer?.player?.removeListener(this) + binder = null + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 7d1b0ea39..1a06f0ed6 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -111,12 +111,13 @@ class SongPlayer( ) : Listener, PlaybackStatsListener.Callback { private val songRepository = SongRepository(context) private val connectivityManager = context.getSystemService()!! - private val bitmapProvider = BitmapProvider(context) + val bitmapProvider = BitmapProvider(context) private var autoAddSong by context.preference(R.string.pref_auto_add_song, true) private var audioQuality by enumPreference(context, R.string.pref_audio_quality, AudioQuality.AUTO) private var currentQueue: Queue = EmptyQueue() + var queueTitle: String? = null val playerVolume = MutableStateFlow(1f) val currentMediaMetadata = MutableStateFlow(null) @@ -191,7 +192,7 @@ class SongPlayer( val album = songRepository.getAlbum(albumId) ?: return@launch val songs = songRepository.getAlbumSongs(albumId) playQueue(ListQueue( - title = album.title, + title = album.album.title, items = songs.map { it.toMediaItem() }, startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 ), playWhenReady) @@ -517,16 +518,20 @@ class SongPlayer( .build() } + fun updateQueueTitle(title: String?) { + mediaSession.setQueueTitle(title) + queueTitle = title + } + fun playQueue(queue: Queue, playWhenReady: Boolean = true) { currentQueue = queue - mediaSession.setQueueTitle(null) - player.clearMediaItems() + updateQueueTitle(null) player.shuffleModeEnabled = false scope.launch(context.exceptionHandler) { val initialStatus = withContext(IO) { queue.getInitialStatus() } initialStatus.title?.let { queueTitle -> - mediaSession.setQueueTitle(queueTitle) + updateQueueTitle(queueTitle) } player.setMediaItems(initialStatus.items, if (initialStatus.index > 0) initialStatus.index else 0, initialStatus.position) player.prepare() @@ -544,7 +549,7 @@ class SongPlayer( val radioQueue = YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) val initialStatus = radioQueue.getInitialStatus() initialStatus.title?.let { queueTitle -> - mediaSession.setQueueTitle(queueTitle) + updateQueueTitle(queueTitle) } player.addMediaItems(initialStatus.items.drop(1)) currentQueue = radioQueue @@ -647,13 +652,17 @@ class SongPlayer( * Auto load more */ override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason == MEDIA_ITEM_TRANSITION_REASON_REPEAT || - player.playbackState == STATE_IDLE || - player.mediaItemCount - player.currentMediaItemIndex > 5 || - !currentQueue.hasNextPage() - ) return - scope.launch(context.exceptionHandler) { - player.addMediaItems(currentQueue.nextPage()) + if (reason != MEDIA_ITEM_TRANSITION_REASON_REPEAT && + player.playbackState != STATE_IDLE && + player.mediaItemCount - player.currentMediaItemIndex <= 5 && + currentQueue.hasNextPage() + ) { + scope.launch(context.exceptionHandler) { + player.addMediaItems(currentQueue.nextPage()) + } + } + if (mediaItem == null) { + bitmapProvider.onBitmapChanged(null) } } diff --git a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt index 747af8290..3dd67b452 100644 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt @@ -30,6 +30,8 @@ import com.zionhuang.music.repos.base.LocalRepository import com.zionhuang.music.ui.bindings.resizeThumbnailUrl import com.zionhuang.music.utils.md5 import com.zionhuang.music.utils.preference.enumPreference +import com.zionhuang.music.youtube.YouTubeAlbum +import com.zionhuang.music.youtube.getYouTubeAlbum import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine @@ -249,7 +251,7 @@ class SongRepository(private val context: Context) : LocalRepository { } } songDao.insert(songs) - artistDao.insert(songArtistMaps) + artistDao.insertSongArtistMaps(songArtistMaps) if (autoDownload) downloadSongs(songs) return@withContext songs } @@ -293,7 +295,7 @@ class SongRepository(private val context: Context) : LocalRepository { } } artistDao.deleteSongArtists(songs.map { it.id }) - artistDao.insert(songArtistMaps) + artistDao.insertSongArtistMaps(songArtistMaps) artistDao.delete(songs .flatMap { it.artists } .distinctBy { it.id } @@ -483,40 +485,30 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Album */ + suspend fun addAlbum(youTubeAlbum: YouTubeAlbum) = withContext(IO) { + val (album, artists, songs) = youTubeAlbum + albumDao.insert(album) + addSongs(songs) + albumDao.upsert(songs.mapIndexed { index, songItem -> + SongAlbumMap( + songId = songItem.id, + albumId = album.id, + index = index + ) + }) + artistDao.insertArtists(artists) + albumDao.insertAlbumArtistMaps(artists.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = album.id, + artistId = artist.id, + order = index + ) + }) + } + override suspend fun addAlbums(albums: List) = withContext(IO) { albums.forEach { album -> - val ids = YouTube.browse(BrowseEndpoint(browseId = "VL" + album.playlistId)).getOrThrow().items.filterIsInstance().map { it.id } - YouTube.getQueue(videoIds = ids).getOrThrow().let { songs -> - albumDao.insert(AlbumEntity( - id = album.id, - title = album.title, - year = album.year, - thumbnailUrl = album.thumbnails.last().url, - songCount = songs.size, - duration = songs.sumOf { it.duration ?: 0 } - )) - addSongs(songs) - albumDao.upsert(songs.mapIndexed { index, songItem -> - SongAlbumMap( - songId = songItem.id, - albumId = album.id, - index = index - ) - }) - } - (YouTube.browse(BrowseEndpoint(browseId = album.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.artists?.forEachIndexed { index, run -> - val artistId = (run.navigationEndpoint?.browseEndpoint?.browseId ?: getArtistByName(run.text)?.id ?: generateArtistId()).also { - artistDao.insert(ArtistEntity( - id = it, - name = run.text - )) - } - albumDao.insert(AlbumArtistMap( - albumId = album.id, - artistId = artistId, - order = index - )) - } + addAlbum(album.getYouTubeAlbum(context)) } } @@ -537,6 +529,10 @@ class SongRepository(private val context: Context) : LocalRepository { albumDao.getAlbumById(albumId) } + suspend fun getAlbumWithSongs(albumId: String) = withContext(IO) { + albumDao.getAlbumWithSongs(albumId) + } + override suspend fun deleteAlbums(albums: List) = withContext(IO) { albums.forEach { album -> val songs = songDao.getAlbumSongs(album.id).map { it.copy(album = null) } @@ -684,13 +680,9 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Search history */ - override suspend fun getAllSearchHistory() = withContext(IO) { - searchHistoryDao.getAllHistory() - } + override fun getAllSearchHistory() = searchHistoryDao.getAllHistory() - override suspend fun getSearchHistory(query: String) = withContext(IO) { - searchHistoryDao.getHistory(query) - } + override fun getSearchHistory(query: String) = searchHistoryDao.getHistory(query) override suspend fun insertSearchHistory(query: String) = withContext(IO) { searchHistoryDao.insert(SearchHistory(query = query)) diff --git a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt index 5c90da4c6..176a9bf1c 100644 --- a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt @@ -86,7 +86,7 @@ interface LocalRepository { suspend fun addAlbums(albums: List) suspend fun refetchAlbum(album: AlbumEntity) = refetchAlbums(listOf(album)) suspend fun refetchAlbums(albums: List) - suspend fun getAlbum(albumId: String): AlbumEntity? + suspend fun getAlbum(albumId: String): Album? suspend fun deleteAlbums(albums: List) /** @@ -121,8 +121,8 @@ interface LocalRepository { /** * Search history */ - suspend fun getAllSearchHistory(): List - suspend fun getSearchHistory(query: String): List + fun getAllSearchHistory(): Flow> + fun getSearchHistory(query: String): Flow> suspend fun insertSearchHistory(query: String) suspend fun deleteSearchHistory(query: String) suspend fun clearSearchHistory() diff --git a/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt b/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt index 508d0e187..d10ff75f4 100644 --- a/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt +++ b/app/src/main/java/com/zionhuang/music/ui/activities/base/ThemedBindingActivity.kt @@ -8,8 +8,8 @@ import androidx.viewbinding.ViewBinding import com.google.android.material.color.DynamicColors import com.zionhuang.music.R import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.livedata.ThemeUtil -import com.zionhuang.music.utils.livedata.ThemeUtil.DEFAULT_THEME +import com.zionhuang.music.utils.ThemeUtil +import com.zionhuang.music.utils.ThemeUtil.DEFAULT_THEME abstract class ThemedBindingActivity : BindingActivity(), SharedPreferences.OnSharedPreferenceChangeListener { override fun onCreate(savedInstanceState: Bundle?) { diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt index db2279478..361ab9908 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/WebViewFragment.kt @@ -9,10 +9,10 @@ import android.webkit.WebView import android.webkit.WebViewClient import androidx.core.content.edit import com.zionhuang.innertube.YouTube -import com.zionhuang.music.constants.Constants.ACCOUNT_EMAIL -import com.zionhuang.music.constants.Constants.ACCOUNT_NAME -import com.zionhuang.music.constants.Constants.INNERTUBE_COOKIE -import com.zionhuang.music.constants.Constants.VISITOR_DATA +import com.zionhuang.music.constants.ACCOUNT_EMAIL +import com.zionhuang.music.constants.ACCOUNT_NAME +import com.zionhuang.music.constants.INNERTUBE_COOKIE +import com.zionhuang.music.constants.VISITOR_DATA import com.zionhuang.music.databinding.FragmentWebviewBinding import com.zionhuang.music.extensions.sharedPreferences import com.zionhuang.music.ui.fragments.base.BindingFragment diff --git a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt index cb1a65f36..ad1a32b40 100644 --- a/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt +++ b/app/src/main/java/com/zionhuang/music/ui/fragments/settings/ContentSettingsFragment.kt @@ -11,8 +11,8 @@ import androidx.preference.ListPreference import androidx.preference.Preference import androidx.preference.SwitchPreferenceCompat import com.zionhuang.music.R -import com.zionhuang.music.constants.Constants.ACCOUNT_EMAIL -import com.zionhuang.music.constants.Constants.ACCOUNT_NAME +import com.zionhuang.music.constants.ACCOUNT_EMAIL +import com.zionhuang.music.constants.ACCOUNT_NAME import com.zionhuang.music.extensions.preferenceLiveData import com.zionhuang.music.extensions.sharedPreferences import com.zionhuang.music.ui.activities.MainActivity diff --git a/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt b/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt index 63795f6b9..af616eeae 100644 --- a/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt +++ b/app/src/main/java/com/zionhuang/music/utils/ThemeUtil.kt @@ -1,30 +1,9 @@ -package com.zionhuang.music.utils.livedata +package com.zionhuang.music.utils import androidx.annotation.StyleRes -import com.zionhuang.music.R object ThemeUtil { - private val colorThemeMap: Map = mapOf( - "SAKURA" to R.style.ThemeOverlay_MaterialSakura, - "MATERIAL_RED" to R.style.ThemeOverlay_MaterialRed, - "MATERIAL_PINK" to R.style.ThemeOverlay_MaterialPink, - "MATERIAL_PURPLE" to R.style.ThemeOverlay_MaterialPurple, - "MATERIAL_DEEP_PURPLE" to R.style.ThemeOverlay_MaterialDeepPurple, - "MATERIAL_INDIGO" to R.style.ThemeOverlay_MaterialIndigo, - "MATERIAL_BLUE" to R.style.ThemeOverlay_MaterialBlue, - "MATERIAL_LIGHT_BLUE" to R.style.ThemeOverlay_MaterialLightBlue, - "MATERIAL_CYAN" to R.style.ThemeOverlay_MaterialCyan, - "MATERIAL_TEAL" to R.style.ThemeOverlay_MaterialTeal, - "MATERIAL_GREEN" to R.style.ThemeOverlay_MaterialGreen, - "MATERIAL_LIGHT_GREEN" to R.style.ThemeOverlay_MaterialLightGreen, - "MATERIAL_LIME" to R.style.ThemeOverlay_MaterialLime, - "MATERIAL_YELLOW" to R.style.ThemeOverlay_MaterialYellow, - "MATERIAL_AMBER" to R.style.ThemeOverlay_MaterialAmber, - "MATERIAL_ORANGE" to R.style.ThemeOverlay_MaterialOrange, - "MATERIAL_DEEP_ORANGE" to R.style.ThemeOverlay_MaterialDeepOrange, - "MATERIAL_BROWN" to R.style.ThemeOverlay_MaterialBrown, - "MATERIAL_BLUE_GREY" to R.style.ThemeOverlay_MaterialBlueGrey - ) + private val colorThemeMap: Map = mapOf() const val DEFAULT_THEME = "MATERIAL_BLUE" diff --git a/app/src/main/java/com/zionhuang/music/utils/Utils.kt b/app/src/main/java/com/zionhuang/music/utils/Utils.kt index a97ef89a2..59b41ad59 100644 --- a/app/src/main/java/com/zionhuang/music/utils/Utils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/Utils.kt @@ -11,6 +11,25 @@ import com.zionhuang.music.ui.activities.ErrorActivity import java.math.BigInteger import java.security.MessageDigest +private const val MS_FORMAT = "%1\$d:%2$02d" +private const val HMS_FORMAT = "%1\$d:%2$02d:%3$02d" + +/** + * Convert duration in seconds to formatted time string + * + * @param duration in milliseconds + * @return formatted string + */ +fun makeTimeString(duration: Long?): String { + if (duration == null) return "0:00" + var sec = duration / 1000 + val hour = sec / 3600 + sec %= 3600 + val minute = (sec / 60).toInt() + sec %= 60 + return if (hour == 0L) MS_FORMAT.format(minute, sec) else HMS_FORMAT.format(hour, minute, sec) +} + fun md5(str: String): String { val md = MessageDigest.getInstance("MD5") return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') diff --git a/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt b/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt deleted file mode 100644 index 74550e0f8..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/YouTubeUtils.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.utils - -private const val MS_FORMAT = "%1\$d:%2$02d" -private const val HMS_FORMAT = "%1\$d:%2$02d:%3$02d" - -/** - * Convert duration in seconds to formatted time string - * - * @param duration in milliseconds - * @return formatted string - */ - -fun makeTimeString(duration: Long?): String { - if (duration == null) return "0:00" - var sec = duration / 1000 - val hour = sec / 3600 - sec %= 3600 - val minute = (sec / 60).toInt() - sec %= 60 - return if (hour == 0L) MS_FORMAT.format(minute, sec) else HMS_FORMAT.format(hour, minute, sec) -} diff --git a/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt b/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt index d414298e8..e28254374 100644 --- a/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt +++ b/app/src/main/java/com/zionhuang/music/utils/preference/Preference.kt @@ -5,6 +5,7 @@ package com.zionhuang.music.utils.preference import android.content.Context import android.content.SharedPreferences import androidx.annotation.StringRes +import androidx.core.content.edit import com.zionhuang.music.extensions.* import kotlin.reflect.KProperty @@ -35,6 +36,6 @@ inline fun serializablePreference(context: Context, keyId: Int inline fun > enumPreference(context: Context, keyId: Int, defaultValue: E): Preference = object : Preference(context, keyId, defaultValue) { override fun getPreferenceValue(): E = sharedPreferences.getEnum(key, defaultValue) override fun setPreferenceValue(value: E) { - sharedPreferences.putEnum(key, value) + sharedPreferences.edit { putEnum(key, value) } } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt index d5fb33561..2e9b7dd3e 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/PlaybackViewModel.kt @@ -106,6 +106,10 @@ class PlaybackViewModel(application: Application) : AndroidViewModel(application } } + fun playQueue( queue: Queue) { + MediaSessionConnection.binder?.songPlayer?.playQueue(queue) + } + fun playQueue(activity: Activity, queue: Queue) { MediaSessionConnection.binder?.songPlayer?.playQueue(queue) (activity as? MainActivity)?.showBottomSheet() diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt new file mode 100644 index 000000000..7d2a2d109 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SearchViewModel.kt @@ -0,0 +1,34 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.MutableLiveData +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.viewModelScope +import androidx.paging.Pager +import androidx.paging.PagingConfig +import androidx.paging.cachedIn +import com.zionhuang.innertube.YouTube +import com.zionhuang.music.repos.YouTubeRepository + +class SearchViewModel( + private val repository: YouTubeRepository, + private val query: String, +) : ViewModel() { + val filter = MutableLiveData(null) + + val pagingData = Pager(PagingConfig(pageSize = 20)) { + filter.value.let { + if (it == null) repository.searchAll(query) + else repository.search(query, it) + } + }.flow.cachedIn(viewModelScope) +} + +@Suppress("UNCHECKED_CAST") +class SearchViewModelFactory( + private val repository: YouTubeRepository, + private val query: String, +) : ViewModelProvider.NewInstanceFactory() { + override fun create(modelClass: Class): T = + SearchViewModel(repository, query) as T +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt index 61dcfa65c..f65228abb 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SongsViewModel.kt @@ -9,7 +9,6 @@ import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference import com.zionhuang.music.models.sortInfo.SongSortInfoPreference import com.zionhuang.music.repos.SongRepository import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.map @@ -19,36 +18,23 @@ class SongsViewModel(application: Application) : AndroidViewModel(application) { val allSongsFlow = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> songRepository.getAllSongs(sortInfo).flow - }.map { list -> - listOf(SongHeader(songRepository.getSongCount(), SongSortInfoPreference.currentInfo)) + list } val allArtistsFlow = ArtistSortInfoPreference.flow.flatMapLatest { sortInfo -> songRepository.getAllArtists(sortInfo).flow - }.map { list -> - listOf(ArtistHeader(songRepository.getArtistCount(), ArtistSortInfoPreference.currentInfo)) + list } val allAlbumsFlow = AlbumSortInfoPreference.flow.flatMapLatest { sortInfo -> songRepository.getAllAlbums(sortInfo).flow - }.map { list -> - listOf(AlbumHeader(songRepository.getAlbumCount(), AlbumSortInfoPreference.currentInfo)) + list } - val allPlaylistsFlow = combine( - songRepository.getLikedSongCount().map { LikedPlaylist(it) }, - songRepository.getDownloadedSongCount().map { DownloadedPlaylist(it) }, - PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllPlaylists(sortInfo).flow - } - ) { likedPlaylist, downloadedPlaylist, playlists -> - listOf( - PlaylistHeader(playlists.size + 2, PlaylistSortInfoPreference.currentInfo), - likedPlaylist, - downloadedPlaylist - ) + playlists + val allPlaylistsFlow = PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> + songRepository.getAllPlaylists(sortInfo).flow } + val likedSongCount = songRepository.getLikedSongCount() + val downloadedSongCount = songRepository.getDownloadedSongCount() + fun getArtistSongsAsFlow(artistId: String) = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> songRepository.getArtistSongs(artistId, sortInfo).flow }.map { list -> diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt index 2705ad114..6aa637f24 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/SuggestionViewModel.kt @@ -4,8 +4,6 @@ import android.app.Application import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.MutableLiveData import androidx.lifecycle.viewModelScope -import com.zionhuang.innertube.models.SuggestionTextItem -import com.zionhuang.innertube.models.SuggestionTextItem.SuggestionSource.LOCAL import com.zionhuang.innertube.models.YTBaseItem import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.repos.YouTubeRepository @@ -17,32 +15,32 @@ class SuggestionViewModel(application: Application) : AndroidViewModel(applicati val suggestions = MutableLiveData>(emptyList()) fun fetchSuggestions(query: String?) = viewModelScope.launch { - if (query.isNullOrEmpty()) { - suggestions.postValue(songRepository.getAllSearchHistory().map { SuggestionTextItem(it.query, LOCAL) }) - } else { - val history = songRepository.getSearchHistory(query).map { - SuggestionTextItem(it.query, LOCAL) - } - val ytSuggestions = try { - youTubeRepository.getSuggestions(query).filter { item -> - item !is SuggestionTextItem || history.find { it.query == item.query } == null - } - } catch (e: Exception) { - e.printStackTrace() - // Fix incorrect visitorData - // comment out because now YouTube Music doesn't give us suggestions if we're not logged in -// if (e is MissingFieldException) { -// // Reset visitorData -// YouTube.generateVisitorData().getOrNull()?.let { -// getApplication().sharedPreferences.edit { -// putString(getApplication().getString(R.string.pref_visitor_data), it) -// } -// YouTube.visitorData = it -// } +// if (query.isNullOrEmpty()) { +// suggestions.postValue(songRepository.getAllSearchHistory().map { SuggestionTextItem(it.query, LOCAL) }) +// } else { +// val history = songRepository.getSearchHistory(query).map { +// SuggestionTextItem(it.query, LOCAL) +// } +// val ytSuggestions = try { +// youTubeRepository.getSuggestions(query).filter { item -> +// item !is SuggestionTextItem || history.find { it.query == item.query } == null // } - emptyList() - } - suggestions.postValue(history + ytSuggestions) - } +// } catch (e: Exception) { +// e.printStackTrace() +// // Fix incorrect visitorData +// // comment out because now YouTube Music doesn't give us suggestions if we're not logged in +//// if (e is MissingFieldException) { +//// // Reset visitorData +//// YouTube.generateVisitorData().getOrNull()?.let { +//// getApplication().sharedPreferences.edit { +//// putString(getApplication().getString(R.string.pref_visitor_data), it) +//// } +//// YouTube.visitorData = it +//// } +//// } +// emptyList() +// } +// suggestions.postValue(history + ytSuggestions) +// } } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt b/app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt new file mode 100644 index 000000000..c718dbd94 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt @@ -0,0 +1,105 @@ +package com.zionhuang.music.youtube + +import android.content.Context +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.AlbumOrPlaylistHeader +import com.zionhuang.innertube.models.BrowseEndpoint +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.music.db.entities.AlbumEntity +import com.zionhuang.music.db.entities.AlbumWithSongs +import com.zionhuang.music.db.entities.ArtistEntity +import com.zionhuang.music.db.entities.toSong +import com.zionhuang.music.repos.SongRepository + +data class YouTubeAlbum( + val album: AlbumEntity, + val artists: List, + val songs: List, +) + +suspend fun AlbumItem.toAlbumWithSongs(context: Context): AlbumWithSongs { + val songIds = YouTube.browse(BrowseEndpoint(browseId = "VL$playlistId")).getOrThrow() + .items + .filterIsInstance() + .map { + it.id + } + val songs = YouTube.getQueue(videoIds = songIds).getOrThrow() + YouTubeAlbum( + album = AlbumEntity( + id = id, + title = title, + year = year, + thumbnailUrl = thumbnails.last().url, + songCount = songs.size, + duration = songs.sumOf { it.duration ?: 0 } + ), + artists = (YouTube.browse(BrowseEndpoint(browseId = id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader) + ?.artists + ?.map { run -> + val artistId = run.navigationEndpoint?.browseEndpoint?.browseId + ?: SongRepository(context).getArtistByName(run.text)?.id + ?: ArtistEntity.generateArtistId() + ArtistEntity( + id = artistId, + name = run.text + ) + }.orEmpty(), + songs = songs + ) + return AlbumWithSongs( + album = AlbumEntity( + id = id, + title = title, + year = year, + thumbnailUrl = thumbnails.last().url, + songCount = songs.size, + duration = songs.sumOf { it.duration ?: 0 } + ), + artists = (YouTube.browse(BrowseEndpoint(browseId = id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader) + ?.artists + ?.map { run -> + val artistId = run.navigationEndpoint?.browseEndpoint?.browseId + ?: SongRepository(context).getArtistByName(run.text)?.id + ?: ArtistEntity.generateArtistId() + ArtistEntity( + id = artistId, + name = run.text + ) + }.orEmpty(), + songs = songs.map { it.toSong(context) } + ) +} + +suspend fun AlbumItem.getYouTubeAlbum(context: Context): YouTubeAlbum { + val songIds = YouTube.browse(BrowseEndpoint(browseId = "VL$playlistId")).getOrThrow() + .items + .filterIsInstance() + .map { + it.id + } + val songs = YouTube.getQueue(videoIds = songIds).getOrThrow() + return YouTubeAlbum( + album = AlbumEntity( + id = id, + title = title, + year = year, + thumbnailUrl = thumbnails.last().url, + songCount = songs.size, + duration = songs.sumOf { it.duration ?: 0 } + ), + artists = (YouTube.browse(BrowseEndpoint(browseId = id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader) + ?.artists + ?.map { run -> + val artistId = run.navigationEndpoint?.browseEndpoint?.browseId + ?: SongRepository(context).getArtistByName(run.text)?.id + ?: ArtistEntity.generateArtistId() + ArtistEntity( + id = artistId, + name = run.text + ) + }.orEmpty(), + songs = songs + ) +} \ No newline at end of file diff --git a/app/src/main/res/drawable/avd_pause_to_play.xml b/app/src/main/res/drawable/avd_pause_to_play.xml index a41e6a837..d33f71eb3 100644 --- a/app/src/main/res/drawable/avd_pause_to_play.xml +++ b/app/src/main/res/drawable/avd_pause_to_play.xml @@ -5,6 +5,7 @@ android:name="pause_to_play" android:width="24dp" android:height="24dp" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/avd_play_to_pause.xml b/app/src/main/res/drawable/avd_play_to_pause.xml index 8b2fabf0f..a447cde79 100644 --- a/app/src/main/res/drawable/avd_play_to_pause.xml +++ b/app/src/main/res/drawable/avd_play_to_pause.xml @@ -5,6 +5,7 @@ android:name="play_to_pause" android:width="24dp" android:height="24dp" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/avd_skip_next.xml b/app/src/main/res/drawable/avd_skip_next.xml index b4243941e..fd2cec112 100644 --- a/app/src/main/res/drawable/avd_skip_next.xml +++ b/app/src/main/res/drawable/avd_skip_next.xml @@ -5,6 +5,7 @@ android:name="skip_next" android:width="24dp" android:height="24dp" + android:tint="?colorControlNormal" android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_arrow_back.xml b/app/src/main/res/drawable/ic_arrow_back.xml index beafea395..a4082a500 100644 --- a/app/src/main/res/drawable/ic_arrow_back.xml +++ b/app/src/main/res/drawable/ic_arrow_back.xml @@ -1,9 +1,10 @@ + android:width="24dp" + android:height="24dp" + android:tint="?colorControlNormal" + android:viewportWidth="24.0" + android:viewportHeight="24.0"> + android:fillColor="@android:color/white" + android:pathData="M20,11H7.83l5.59,-5.59L12,4l-8,8 8,8 1.41,-1.41L7.83,13H20v-2z" /> diff --git a/app/src/main/res/drawable/ic_arrow_downward.xml b/app/src/main/res/drawable/ic_arrow_downward.xml index 6ce6a15a7..28bc2d45e 100644 --- a/app/src/main/res/drawable/ic_arrow_downward.xml +++ b/app/src/main/res/drawable/ic_arrow_downward.xml @@ -1,10 +1,10 @@ - + diff --git a/app/src/main/res/drawable/ic_arrow_top_left.xml b/app/src/main/res/drawable/ic_arrow_top_left.xml index 20b455730..97cb88334 100644 --- a/app/src/main/res/drawable/ic_arrow_top_left.xml +++ b/app/src/main/res/drawable/ic_arrow_top_left.xml @@ -6,6 +6,6 @@ android:viewportWidth="24" android:viewportHeight="24"> \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_arrow_upward.xml b/app/src/main/res/drawable/ic_arrow_upward.xml index 2e8706f01..f20adcb6c 100644 --- a/app/src/main/res/drawable/ic_arrow_upward.xml +++ b/app/src/main/res/drawable/ic_arrow_upward.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_home.xml b/app/src/main/res/drawable/ic_home.xml index 3b2c4ccb4..ba4d830ec 100644 --- a/app/src/main/res/drawable/ic_home.xml +++ b/app/src/main/res/drawable/ic_home.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_navigate_next.xml b/app/src/main/res/drawable/ic_navigate_next.xml index 4d08c5f21..4f5f401bc 100644 --- a/app/src/main/res/drawable/ic_navigate_next.xml +++ b/app/src/main/res/drawable/ic_navigate_next.xml @@ -1,6 +1,7 @@ diff --git a/app/src/main/res/drawable/ic_play.xml b/app/src/main/res/drawable/ic_play.xml index da896608b..f065a278a 100644 --- a/app/src/main/res/drawable/ic_play.xml +++ b/app/src/main/res/drawable/ic_play.xml @@ -1,7 +1,7 @@ + + diff --git a/app/src/main/res/drawable/ic_radio_button_unchecked.xml b/app/src/main/res/drawable/ic_radio_button_unchecked.xml new file mode 100644 index 000000000..ad12d819e --- /dev/null +++ b/app/src/main/res/drawable/ic_radio_button_unchecked.xml @@ -0,0 +1,10 @@ + + + diff --git a/app/src/main/res/drawable/ic_repeat.xml b/app/src/main/res/drawable/ic_repeat.xml index 7a4a756e2..4e90c0c0d 100644 --- a/app/src/main/res/drawable/ic_repeat.xml +++ b/app/src/main/res/drawable/ic_repeat.xml @@ -5,6 +5,6 @@ android:viewportWidth="24" android:viewportHeight="24"> diff --git a/app/src/main/res/drawable/ic_repeat_one.xml b/app/src/main/res/drawable/ic_repeat_one.xml index e26ae699b..e8c7449eb 100644 --- a/app/src/main/res/drawable/ic_repeat_one.xml +++ b/app/src/main/res/drawable/ic_repeat_one.xml @@ -1,10 +1,10 @@ - + android:viewportHeight="24"> + diff --git a/app/src/main/res/drawable/ic_skip_next.xml b/app/src/main/res/drawable/ic_skip_next.xml index 993680b81..eecf709f0 100644 --- a/app/src/main/res/drawable/ic_skip_next.xml +++ b/app/src/main/res/drawable/ic_skip_next.xml @@ -1,9 +1,10 @@ diff --git a/app/src/main/res/drawable/ic_skip_previous.xml b/app/src/main/res/drawable/ic_skip_previous.xml index f8c3bab71..3220ee51b 100644 --- a/app/src/main/res/drawable/ic_skip_previous.xml +++ b/app/src/main/res/drawable/ic_skip_previous.xml @@ -1,9 +1,10 @@ diff --git a/app/src/main/res/values-night/styles.xml b/app/src/main/res/values-night/styles.xml index 4330e9e81..47d0e5ceb 100644 --- a/app/src/main/res/values-night/styles.xml +++ b/app/src/main/res/values-night/styles.xml @@ -3,24 +3,4 @@ - - + - \ No newline at end of file diff --git a/app/src/main/res/values-v29/styles.xml b/app/src/main/res/values-v29/styles.xml deleted file mode 100644 index ed6f83ddf..000000000 --- a/app/src/main/res/values-v29/styles.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/attrs.xml b/app/src/main/res/values/attrs.xml index d28906267..e62e00b91 100644 --- a/app/src/main/res/values/attrs.xml +++ b/app/src/main/res/values/attrs.xml @@ -1,7 +1,4 @@ - - - \ No newline at end of file diff --git a/app/src/main/res/values/constants.xml b/app/src/main/res/values/constants.xml deleted file mode 100644 index c4308ef0e..000000000 --- a/app/src/main/res/values/constants.xml +++ /dev/null @@ -1,563 +0,0 @@ - - - - InnerTune - - - 300 - 225 - 175 - - - APPEARANCE - CONTENT - PLAYER_AUDIO - STORAGE - GENERAL - PRIVACY - BACKUP_RESTORE - ABOUT - - FOLLOW_SYSTEM_ACCENT - THEME_COLOR - DARK_THEME - DEFAULT_OPEN_TAB - NAV_TAB_CONFIG - LRC_TEXT_POS - - ACCOUNT - CONTENT_LANGUAGE - CONTENT_COUNTRY - PROXY_ENABLED - PROXY_TYPE - PROXY_URL - RESTART - - PERSISTENT_QUEUE - AUDIO_QUALITY - AUTO_DOWNLOAD - AUTO_ADD_SONG - EXPAND_ON_PLAY - NOTIFICATION_MORE_ACTION - EQUALIZER - - OPEN_SAF - IMAGE_MAX_CACHE_SIZE - CLEAR_IMAGE_CACHE - SONG_MAX_CACHE_SIZE - CLEAR_SONG_CACHE - - PAUSE_SEARCH_HISTORY - CLEAR_SEARCH_HISTORY - ENABLE_KUGOU - - BACKUP - RESTORE - - APP_VERSION - GITHUB - GitHub - z-huang/InnerTune - - SONG_SORT_TYPE - SONG_SORT_DESC - ARTIST_SORT_TYPE - ARTIST_SORT_DESC - ALBUM_SORT_TYPE - ALBUM_SORT_DESC - PLAYLIST_SORT_TYPE - PLAYLIST_SORT_DESC - SHOW_LYRICS - SKIP_SILENCE - AUDIO_NORMALIZE - - - - - @string/dark_theme_off - @string/dark_theme_on - @string/dark_theme_follow_system - - - - 1 - 2 - -1 - - - - SAKURA - MATERIAL_RED - MATERIAL_PINK - MATERIAL_PURPLE - MATERIAL_DEEP_PURPLE - MATERIAL_INDIGO - MATERIAL_BLUE - MATERIAL_LIGHT_BLUE - MATERIAL_CYAN - MATERIAL_TEAL - MATERIAL_GREEN - MATERIAL_LIGHT_GREEN - MATERIAL_LIME - MATERIAL_YELLOW - MATERIAL_AMBER - MATERIAL_ORANGE - MATERIAL_DEEP_ORANGE - MATERIAL_BROWN - MATERIAL_BLUE_GREY - - - - @string/color_sakura - @string/color_red - @string/color_pink - @string/color_purple - @string/color_deep_purple - @string/color_indigo - @string/color_blue - @string/color_light_blue - @string/color_cyan - @string/color_teal - @string/color_green - @string/color_light_green - @string/color_lime - @string/color_yellow - @string/color_amber - @string/color_orange - @string/color_deep_orange - @string/color_brown - @string/color_blue_grey - - - - @string/title_home - @string/title_songs - @string/title_artists - @string/title_albums - @string/title_playlists - - - - 0 - 1 - 2 - 3 - 4 - - - - @string/align_left - @string/align_center - @string/align_right - - - - 0 - 1 - 2 - - - - HTTP - SOCKS - - - - @string/audio_quality_auto - @string/audio_quality_high - @string/audio_quality_low - - - - AUTO - HIGH - LOW - - - - system - - - - @string/default_localization_key - af - az - id - ms - ca - cs - da - de - et - en-GB - en - es - es-419 - eu - fil - fr - fr-CA - gl - hr - zu - is - it - sw - lt - hu - nl - no - uz - pl - pt-PT - pt - ro - sq - sk - sl - fi - sv - bo - vi - tr - bg - ky - kk - mk - mn - ru - sr - uk - el - hy - iw - ur - ar - fa - ne - mr - hi - bn - pa - gu - ta - te - kn - ml - si - th - lo - my - ka - am - km - zh-CN - zh-TW - zh-HK - ja - ko - - - @string/system_default - Afrikaans - Azərbaycan - Bahasa Indonesia - Bahasa Malaysia - Català - Čeština - Dansk - Deutsch - Eesti - English (UK) - English (US) - Español (España) - Español (Latinoamérica) - Euskara - Filipino - Français - Français (Canada) - Galego - Hrvatski - IsiZulu - Íslenska - Italiano - Kiswahili - Lietuvių - Magyar - Nederlands - Norsk - O‘zbek - Polski - Português - Português (Brasil) - Română - Shqip - Slovenčina - Slovenščina - Suomi - Svenska - Tibetan བོད་སྐད། - Tiếng Việt - Türkçe - Български - Кыргызча - Қазақ Тілі - Македонски - Монгол - Русский - Српски - Українська - Ελληνικά - Հայերեն - עברית - اردو - العربية - فارسی - नेपाली - मराठी - हिन्दी - বাংলা - ਪੰਜਾਬੀ - ગુજરાતી - தமிழ் - తెలుగు - ಕನ್ನಡ - മലയാളം - සිංහල - ภาษาไทย - ລາວ - ဗမာ - ქართული - አማርኛ - ខ្មែរ - 中文 (简体) - 中文 (繁體) - 中文 (香港) - 日本語 - 한국어 - - - @string/system_default - Algeria - Argentina - Australia - Austria - Azerbaijan - Bahrain - Bangladesh - Belarus - Belgium - Bolivia - Bosnia and Herzegovina - Brazil - Bulgaria - Cambodia - Canada - Chile - Hong Kong - Colombia - Costa Rica - Croatia - Cyprus - Czech Republic - Denmark - Dominican Republic - Ecuador - Egypt - El Salvador - Estonia - Finland - France - Georgia - Germany - Ghana - Greece - Guatemala - Honduras - Hungary - Iceland - India - Indonesia - Iraq - Ireland - Israel - Italy - Jamaica - Japan - Jordan - Kazakhstan - Kenya - South Korea - Kuwait - Lao - Latvia - Lebanon - Libya - Liechtenstein - Lithuania - Luxembourg - Macedonia - Malaysia - Malta - Mexico - Montenegro - Morocco - Nepal - Netherlands - New Zealand - Nicaragua - Nigeria - Norway - Oman - Pakistan - Panama - Papua New Guinea - Paraguay - Peru - Philippines - Poland - Portugal - Puerto Rico - Qatar - Romania - Russian Federation - Saudi Arabia - Senegal - Serbia - Singapore - Slovakia - Slovenia - South Africa - Spain - Sri Lanka - Sweden - Switzerland - Taiwan - Tanzania - Thailand - Tunisia - Turkey - Uganda - Ukraine - United Arab Emirates - United Kingdom - United States - Uruguay - Venezuela (Bolivarian Republic) - Vietnam - Yemen - Zimbabwe - - - @string/default_localization_key - DZ - AR - AU - AT - AZ - BH - BD - BY - BE - BO - BA - BR - BG - KH - CA - CL - HK - CO - CR - HR - CY - CZ - DK - DO - EC - EG - SV - EE - FI - FR - GE - DE - GH - GR - GT - HN - HU - IS - IN - ID - IQ - IE - IL - IT - JM - JP - JO - KZ - KE - KR - KW - LA - LV - LB - LY - LI - LT - LU - MK - MY - MT - MX - ME - MA - NP - NL - NZ - NI - NG - NO - OM - PK - PA - PG - PY - PE - PH - PL - PT - PR - QA - RO - RU - SA - SN - RS - SG - SK - SI - ZA - ES - LK - SE - CH - TW - TZ - TH - TN - TR - UG - UA - AE - GB - US - UY - VE - VN - YE - ZW - - \ No newline at end of file diff --git a/app/src/main/res/values/dimens.xml b/app/src/main/res/values/dimens.xml deleted file mode 100755 index a1310229b..000000000 --- a/app/src/main/res/values/dimens.xml +++ /dev/null @@ -1,14 +0,0 @@ - - 56dp - 64dp - - 6dp - 52dp - 128dp - 144dp - - 64dp - 64dp - - 4dp - diff --git a/app/src/main/res/values/lyrics_colors.xml b/app/src/main/res/values/lyrics_colors.xml deleted file mode 100644 index a558eb259..000000000 --- a/app/src/main/res/values/lyrics_colors.xml +++ /dev/null @@ -1,9 +0,0 @@ - - - #E6E1E5 - #CAC4D0 - #6750A4 - #CAC4D0 - #CAC4D0 - #CAC4D0 - \ No newline at end of file diff --git a/app/src/main/res/values/lyrics_dimens.xml b/app/src/main/res/values/lyrics_dimens.xml deleted file mode 100644 index fae7fdc9e..000000000 --- a/app/src/main/res/values/lyrics_dimens.xml +++ /dev/null @@ -1,10 +0,0 @@ - - - 300 - 20sp - 16sp - 16dp - 1dp - 30dp - 40dp - \ No newline at end of file diff --git a/app/src/main/res/values/lyrics_view_attrs.xml b/app/src/main/res/values/lyrics_view_attrs.xml deleted file mode 100644 index 31c137907..000000000 --- a/app/src/main/res/values/lyrics_view_attrs.xml +++ /dev/null @@ -1,25 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/values/styles.xml b/app/src/main/res/values/styles.xml index 122e67afc..db1559c0a 100755 --- a/app/src/main/res/values/styles.xml +++ b/app/src/main/res/values/styles.xml @@ -1,84 +1,13 @@ - - - - - - - - - - - --> - - - - - - diff --git a/app/src/main/res/xml/pref_about.xml b/app/src/main/res/xml/pref_about.xml deleted file mode 100644 index 4fd5f4f9f..000000000 --- a/app/src/main/res/xml/pref_about.xml +++ /dev/null @@ -1,14 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_appearance.xml b/app/src/main/res/xml/pref_appearance.xml deleted file mode 100644 index 2be3ed665..000000000 --- a/app/src/main/res/xml/pref_appearance.xml +++ /dev/null @@ -1,43 +0,0 @@ - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_backup_restore.xml b/app/src/main/res/xml/pref_backup_restore.xml deleted file mode 100644 index 3f576d20f..000000000 --- a/app/src/main/res/xml/pref_backup_restore.xml +++ /dev/null @@ -1,11 +0,0 @@ - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_content.xml b/app/src/main/res/xml/pref_content.xml deleted file mode 100644 index b9ec68b9e..000000000 --- a/app/src/main/res/xml/pref_content.xml +++ /dev/null @@ -1,45 +0,0 @@ - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_general.xml b/app/src/main/res/xml/pref_general.xml deleted file mode 100644 index d0f921569..000000000 --- a/app/src/main/res/xml/pref_general.xml +++ /dev/null @@ -1,27 +0,0 @@ - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_main.xml b/app/src/main/res/xml/pref_main.xml deleted file mode 100644 index 2fba7ef0c..000000000 --- a/app/src/main/res/xml/pref_main.xml +++ /dev/null @@ -1,35 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_player_audio.xml b/app/src/main/res/xml/pref_player_audio.xml deleted file mode 100644 index a4444fa40..000000000 --- a/app/src/main/res/xml/pref_player_audio.xml +++ /dev/null @@ -1,30 +0,0 @@ - - - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_privacy.xml b/app/src/main/res/xml/pref_privacy.xml deleted file mode 100644 index 735242102..000000000 --- a/app/src/main/res/xml/pref_privacy.xml +++ /dev/null @@ -1,17 +0,0 @@ - - - - - - \ No newline at end of file diff --git a/app/src/main/res/xml/pref_storage.xml b/app/src/main/res/xml/pref_storage.xml deleted file mode 100644 index 5fafebabb..000000000 --- a/app/src/main/res/xml/pref_storage.xml +++ /dev/null @@ -1,32 +0,0 @@ - - - - - - - - - - - \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index de1f5bef6..3507d39ad 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -5,10 +5,7 @@ buildscript { } dependencies { classpath("com.android.tools.build:gradle:7.3.1") - classpath("androidx.navigation:navigation-safe-args-gradle-plugin:2.5.2") classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) - // NOTE: Do not place your application dependencies here; they belong - // in the individual module build.gradle files } } diff --git a/gradle.properties b/gradle.properties index 397a3b41c..4e50218ed 100755 --- a/gradle.properties +++ b/gradle.properties @@ -11,8 +11,7 @@ # http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects # org.gradle.parallel=true #Sat Nov 19 15:59:34 CST 2022 -ktor_version=2.0.0 -logback_version=1.2.11 + org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" android.useAndroidX=true android.enableJetifier=true diff --git a/innertube/build.gradle.kts b/innertube/build.gradle.kts index fdec0c1ca..59c535e24 100644 --- a/innertube/build.gradle.kts +++ b/innertube/build.gradle.kts @@ -16,7 +16,7 @@ android { } buildTypes { release { - isMinifyEnabled = false + isMinifyEnabled = true proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") } } @@ -37,13 +37,11 @@ val ktor_version: String by project val logback_version: String by project dependencies { - implementation("io.ktor:ktor-client-core:$ktor_version") - implementation("io.ktor:ktor-client-okhttp:$ktor_version") - implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") - implementation("io.ktor:ktor-client-encoding:$ktor_version") - implementation("ch.qos.logback:logback-classic:$logback_version") - implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") - implementation("org.brotli:dec:0.1.2") - testImplementation("junit:junit:4.13.2") + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.json) + implementation(libs.ktor.client.encoding) + implementation(libs.brotli) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/kugou/build.gradle.kts b/kugou/build.gradle.kts index cb62b98e7..3b533af19 100644 --- a/kugou/build.gradle.kts +++ b/kugou/build.gradle.kts @@ -13,16 +13,12 @@ tasks.withType().configureEach kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" } -val ktor_version: String by project - dependencies { - implementation("io.ktor:ktor-client-core:$ktor_version") - implementation("io.ktor:ktor-client-okhttp:$ktor_version") - implementation("io.ktor:ktor-client-content-negotiation:$ktor_version") - implementation("io.ktor:ktor-serialization-kotlinx-json:$ktor_version") - implementation("io.ktor:ktor-client-encoding:$ktor_version") - implementation("io.ktor:ktor-client-logging-jvm:$ktor_version") - // SC to TC - implementation("com.github.houbb:opencc4j:1.7.2") - testImplementation("junit:junit:4.13.2") + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.okhttp) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.serialization.json) + implementation(libs.ktor.client.encoding) + implementation(libs.opencc4j) + testImplementation(libs.junit) } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 742422aa8..848ac8912 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -14,36 +14,62 @@ dependencyResolutionManagement { version("kotlin", "1.7.20") plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") + library("activity", "androidx.activity", "activity-compose").version("1.5.1") + library("navigation", "androidx.navigation", "navigation-compose").version("2.5.3") + version("compose-compiler", "1.3.2") version("compose", "1.3.0") - version("material2", "1.0.0-alpha08") - version("material3", "1.1.0-alpha03") - library("compose-runtime", "androidx.compose.runtime", "runtime").versionRef("compose") library("compose-foundation", "androidx.compose.foundation", "foundation").versionRef("compose") library("compose-ui", "androidx.compose.ui", "ui").versionRef("compose") library("compose-ui-util", "androidx.compose.ui", "ui-util").versionRef("compose") library("compose-ui-tooling", "androidx.compose.ui", "ui-tooling").versionRef("compose") - library("compose-activity", "androidx.activity", "activity-compose").version("1.5.1") - library("compose-viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-compose").version("2.5.1") - library("compose-livedata", "androidx.compose.runtime", "runtime-livedata").versionRef("compose") - library("compose-navigation", "androidx.navigation", "navigation-compose").version("2.5.3") library("compose-animation", "androidx.compose.animation", "animation-graphics").versionRef("compose") library("compose-animation-graphics", "androidx.compose.animation", "animation-graphics").versionRef("compose") - library("compose-material2", "androidx.compose.material", "material").versionRef("compose") - library("compose-material3", "androidx.compose.material3", "material3").versionRef("material3") - library("compose-material-windowsize", "androidx.compose.material3", "material3-window-size-class").versionRef("material3") - library("compose-material-icon-core", "androidx.compose.material", "material-icons-core").versionRef("material2") - library("compose-material-icon-extended", "androidx.compose.material", "material-icons-extended").versionRef("material2") - library("compose-shimmer", "com.valentinilk.shimmer", "compose-shimmer").version("1.0.3") + version("lifecycle", "2.5.1") + library("viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("lifecycle") + library("viewmodel-compose", "androidx.lifecycle", "lifecycle-viewmodel-compose").versionRef("lifecycle") - library("palette", "androidx.palette", "palette").version("1.0.0") - library("systemUiController", "com.google.accompanist", "accompanist-systemuicontroller").version("0.27.0") + version("material3", "1.1.0-alpha03") + library("material3", "androidx.compose.material3", "material3").versionRef("material3") + library("material3-windowsize", "androidx.compose.material3", "material3-window-size-class").versionRef("material3") library("coil", "io.coil-kt", "coil-compose").version("2.2.2") + library("shimmer", "com.valentinilk.shimmer", "compose-shimmer").version("1.0.3") + + library("palette", "androidx.palette", "palette").version("1.0.0") + + version("exoplayer", "2.18.2") + library("exoplayer", "com.google.android.exoplayer", "exoplayer").versionRef("exoplayer") + library("exoplayer-mediasession", "com.google.android.exoplayer", "extension-mediasession").versionRef("exoplayer") + library("exoplayer-okhttp", "com.google.android.exoplayer", "extension-okhttp").versionRef("exoplayer") + + library("paging-runtime", "androidx.paging", "paging-runtime").version("3.1.1") library("paging-compose", "androidx.paging", "paging-compose").version("1.0.0-alpha17") + + version("room", "2.4.3") + library("room-runtime", "androidx.room", "room-runtime").versionRef("room") + library("room-compiler", "androidx.room", "room-compiler").versionRef("room") + library("room-ktx", "androidx.room", "room-ktx").versionRef("room") + + library("apache-lang3", "org.apache.commons", "commons-lang3").version("3.12.0") + + version("ktor", "2.2.2") + library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor") + library("ktor-client-okhttp", "io.ktor", "ktor-client-okhttp").versionRef("ktor") + library("ktor-client-content-negotiation", "io.ktor", "ktor-client-content-negotiation").versionRef("ktor") + library("ktor-client-encoding", "io.ktor", "ktor-client-encoding").versionRef("ktor") + library("ktor-serialization-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor") + + library("brotli", "org.brotli", "dec").version("0.1.2") + + library("opencc4j", "com.github.houbb", "opencc4j").version("1.7.2") + + library("desugaring", "com.android.tools", "desugar_jdk_libs").version("1.1.5") + + library("junit", "junit", "junit").version("4.13.2") } } } From 495bd6375c60c44543610dfd7d9df1151ae5836a Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Mon, 9 Jan 2023 19:23:58 +0530 Subject: [PATCH 099/323] Delete app/src/main/res/Values-pa directory --- app/src/main/res/Values-pa/strings.xml | 306 ------------------------- 1 file changed, 306 deletions(-) delete mode 100644 app/src/main/res/Values-pa/strings.xml diff --git a/app/src/main/res/Values-pa/strings.xml b/app/src/main/res/Values-pa/strings.xml deleted file mode 100644 index 0ab722f7b..000000000 --- a/app/src/main/res/Values-pa/strings.xml +++ /dev/null @@ -1,306 +0,0 @@ - - - ਘਰ - ਗੀਤ - ਕਲਾਕਾਰ - ਐਲਬਮ - ਪਲੇਲਿਸਟ - Explore - Settings - Now playing - Error report - - - Appearance - Follow system theme - Theme color - Dark theme - On - Off - Follow system - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Content - Login - Default content language - Default content country - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Auto download - Download song when added to library - Auto add song to library - Add song to your library when it completes playing - Expand bottom player on play - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - About - App version - - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey - - - Search - Search YouTube Music… - Search library… - - - Liked songs - Downloaded songs - - - Details - Edit - Start radio - Play - Play next - Add to queue - Add to library - Download - Remove download - Import playlist - Add to playlist - View artist - View album - Refetch - Share - Delete - Search online - Choose other lyrics - - - Details - Media id - MIME type - Codecs - Bitrate - Sample rate - Loudness - Volume - File size - Unknown - - Edit lyrics - Search lyrics - Choose lyrics - - Edit song - Song title - Song artists - Song title cannot be empty. - Song artist cannot be empty. - Save - - Create playlist - Playlist name - Playlist name cannot be empty. - - Edit artist - Artist name - Artist name cannot be empty. - - Duplicate artists - Artist %1$s already exists. - - Choose playlist - - Edit playlist - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Music Player - Download - - - - %d song - %d songs - - - %d artist - %d artists - - - %d album - %d albums - - - %d playlist - %d playlists - - - - Retry - Play - Play All - Radio - Shuffle - Copy stacktrace - Report - Report on GitHub - - - Date added - Name - Artist - Year - Song count - Length - Play time - - - - %d song has been deleted. - %d songs has been deleted. - - - %d selected - - Undo - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library - Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs - - Removed download - View - - - Like - Remove like - Add to library - Remove from library - - - All - Songs - Videos - Albums - Artists - Playlists - Community playlists - Featured playlists - - System default - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - - - All songs - Searched songs - - - Lyrics not found - From 3df5e9b3fdc2a7df6b98e5644a9e7cdee9d6b982 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 13 Jan 2023 14:41:50 +0800 Subject: [PATCH 100/323] Rewrite InnerTube library --- app/build.gradle.kts | 22 +- .../java/com/zionhuang/music/MainActivity.kt | 42 +- .../music/constants/ComposeConstants.kt | 5 +- .../com/zionhuang/music/db/MusicDatabase.kt | 16 +- .../com/zionhuang/music/db/daos/ArtistDao.kt | 6 +- .../zionhuang/music/db/daos/PlaylistDao.kt | 8 +- .../com/zionhuang/music/db/daos/SongDao.kt | 8 +- .../music/db/entities/AlbumEntity.kt | 5 +- .../music/db/entities/ArtistEntity.kt | 5 +- .../music/db/entities/PlaylistEntity.kt | 5 +- .../com/zionhuang/music/db/entities/Song.kt | 23 +- .../zionhuang/music/db/entities/SongEntity.kt | 9 +- .../zionhuang/music/extensions/BundleExt.kt | 12 - .../zionhuang/music/extensions/YouTubeExt.kt | 40 -- .../music/lyrics/YouTubeLyricsProvider.kt | 14 +- .../com/zionhuang/music/models/DataWrapper.kt | 12 - .../music/models/DownloadProgress.kt | 7 - .../com/zionhuang/music/models/ErrorInfo.kt | 13 - .../com/zionhuang/music/models/ItemsPage.kt | 8 + .../com/zionhuang/music/models/ListWrapper.kt | 8 - .../zionhuang/music/models/MediaMetadata.kt | 23 +- .../music/models/PlaybackStateData.kt | 25 - .../zionhuang/music/playback/MusicService.kt | 14 +- .../music/playback/PlayerConnection.kt | 4 +- .../zionhuang/music/playback/SongPlayer.kt | 132 ++--- .../music/playback/queues/EmptyQueue.kt | 2 +- .../zionhuang/music/playback/queues/Queue.kt | 4 +- .../music/playback/queues/YouTubeQueue.kt | 4 +- .../zionhuang/music/provider/SongsProvider.kt | 15 +- .../zionhuang/music/repos/SongRepository.kt | 542 +++++++----------- .../music/repos/YouTubeRepository.kt | 92 --- .../music/repos/base/LocalRepository.kt | 52 +- .../com/zionhuang/music/ui/component/Items.kt | 400 +++++++------ .../music/ui/component/NoResultFound.kt | 4 +- .../music/ui/component/PlayingIndicator.kt | 7 +- .../component/shimmer/GridItemPlaceholder.kt | 36 ++ .../com/zionhuang/music/ui/menu/SongMenu.kt | 7 +- .../zionhuang/music/ui/menu/YouTubeMenu.kt | 100 +++- .../com/zionhuang/music/ui/player/Player.kt | 11 +- .../com/zionhuang/music/ui/player/Queue.kt | 13 +- .../zionhuang/music/ui/screens/AlbumScreen.kt | 449 +++++++++------ .../music/ui/screens/ArtistItemsScreen.kt | 301 ++++++++++ .../music/ui/screens/ArtistScreen.kt | 533 +++++++++-------- .../music/ui/screens/OnlineSearchResult.kt | 278 +++++---- .../music/ui/screens/OnlineSearchScreen.kt | 113 ++-- .../com/zionhuang/music/ui/screens/Screens.kt | 6 +- .../screens/library/LibraryArtistsScreen.kt | 6 +- .../com/zionhuang/music/utils/StringUtils.kt | 4 +- .../music/viewmodels/AlbumViewModel.kt | 45 +- .../music/viewmodels/ArtistItemsViewModel.kt | 49 ++ .../music/viewmodels/ArtistViewModel.kt | 37 +- .../music/viewmodels/LibraryViewModels.kt | 12 +- .../viewmodels/LocalPlaylistViewModel.kt | 3 +- .../OnlineSearchSuggestionViewModel.kt | 51 ++ .../music/viewmodels/OnlineSearchViewModel.kt | 57 +- .../zionhuang/music/youtube/YouTubeAlbum.kt | 135 ----- app/src/main/res/drawable/ic_explicit.xml | 9 + innertube/build.gradle.kts | 34 +- innertube/src/main/AndroidManifest.xml | 2 - .../java/com/zionhuang/innertube/YouTube.kt | 237 ++++++-- .../com/zionhuang/innertube/models/Badges.kt | 13 + .../innertube/models/BrowseResult.kt | 10 - .../innertube/models/Continuation.kt | 11 +- .../zionhuang/innertube/models/Endpoint.kt | 83 +-- .../com/zionhuang/innertube/models/Filter.kt | 9 - .../innertube/models/GridRenderer.kt | 10 +- .../com/zionhuang/innertube/models/Icon.kt | 9 +- .../zionhuang/innertube/models/ItemMenu.kt | 16 - .../com/zionhuang/innertube/models/Link.kt | 13 - .../com/zionhuang/innertube/models/Menu.kt | 31 - .../models/MusicCarouselShelfRenderer.kt | 18 +- .../models/MusicDescriptionShelfRenderer.kt | 9 +- .../models/MusicNavigationButtonRenderer.kt | 9 +- .../models/MusicResponsiveListItemRenderer.kt | 51 +- .../innertube/models/MusicShelfRenderer.kt | 16 +- .../models/MusicTwoRowItemRenderer.kt | 21 +- .../innertube/models/NavigationEndpoint.kt | 33 +- .../zionhuang/innertube/models/NextResult.kt | 10 - .../innertube/models/PlaylistPanelRenderer.kt | 2 +- .../models/PlaylistPanelVideoRenderer.kt | 27 +- .../com/zionhuang/innertube/models/Runs.kt | 18 +- .../SearchSuggestionsSectionRenderer.kt | 72 +-- .../innertube/models/SectionListRenderer.kt | 49 +- .../innertube/models/ThumbnailRenderer.kt | 18 +- .../zionhuang/innertube/models/Thumbnails.kt | 7 +- .../zionhuang/innertube/models/YTBaseItem.kt | 65 --- .../com/zionhuang/innertube/models/YTItem.kt | 281 ++------- .../innertube/models/YouTubeLocale.kt | 2 +- .../models/response/BrowseResponse.kt | 70 +-- .../models/response/SearchResponse.kt | 5 +- .../zionhuang/innertube/pages/AlbumPage.kt | 39 ++ .../pages/ArtistItemsContinuationPage.kt | 41 ++ .../innertube/pages/ArtistItemsPage.kt | 58 ++ .../zionhuang/innertube/pages/ArtistPage.kt | 149 +++++ .../com/zionhuang/innertube/pages/NextPage.kt | 42 ++ .../pages/PlaylistContinuationPage.kt | 8 + .../zionhuang/innertube/pages/PlaylistPage.kt | 39 ++ .../zionhuang/innertube/pages/SearchPage.kt | 104 ++++ .../innertube/pages/SearchSuggestionPage.kt | 74 +++ .../innertube/pages/SearchSummaryPage.kt | 108 ++++ .../zionhuang/innertube/utils/Extension.kt | 43 -- .../zionhuang/innertube/utils/TimeParser.kt | 18 - .../com/zionhuang/innertube/utils/Utils.kt | 71 +-- .../innertube/utils/YouTubeLinkHandler.kt | 172 ------ .../com/zionhuang/innertube/YouTubeTest.kt | 71 +-- kugou/build.gradle.kts | 9 - .../main/java/com/zionhuang/kugou/KuGou.kt | 3 +- kugou/src/test/java/Test.kt | 9 +- 108 files changed, 3050 insertions(+), 3112 deletions(-) delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/BundleExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/DataWrapper.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt create mode 100644 app/src/main/java/com/zionhuang/music/models/ItemsPage.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/ListWrapper.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt delete mode 100644 app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt delete mode 100644 app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt create mode 100644 app/src/main/res/drawable/ic_explicit.xml delete mode 100644 innertube/src/main/AndroidManifest.xml create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Badges.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/ItemMenu.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/Link.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/YTBaseItem.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/AlbumPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsContinuationPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/NextPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistContinuationPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/SearchSuggestionPage.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/utils/Extension.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt delete mode 100644 innertube/src/main/java/com/zionhuang/innertube/utils/YouTubeLinkHandler.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 6703694a4..e6eb580c2 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,9 +2,6 @@ plugins { id("com.android.application") kotlin("android") kotlin("kapt") - id("kotlin-parcelize") - @Suppress("DSL_SCOPE_VIOLATION") - alias(libs.plugins.kotlin.serialization) } android { @@ -55,33 +52,18 @@ android { composeOptions { kotlinCompilerExtensionVersion = libs.versions.compose.compiler.get() } - packagingOptions { - resources { - excludes += listOf("META-INF/proguard/androidx-annotations.pro", "META-INF/DEPENDENCIES") - } - } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility(JavaVersion.VERSION_1_8) - targetCompatibility(JavaVersion.VERSION_1_8) + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { jvmTarget = "1.8" - freeCompilerArgs = freeCompilerArgs + listOf("-opt-in=kotlin.RequiresOptIn") - } - configurations.all { - resolutionStrategy { - exclude(group = "org.jetbrains.kotlinx", module = "kotlinx-coroutines-debug") - } } testOptions { unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true } - sourceSets { - // Adds exported schema location as test app assets. - getByName("androidTest").assets.srcDir("$projectDir/schemas") - } } dependencies { diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index c94447b26..43817ca95 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -44,6 +44,12 @@ import androidx.navigation.navArgument import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player import com.valentinilk.shimmer.LocalShimmerTheme +import com.zionhuang.music.constants.* +import com.zionhuang.music.extensions.* +import com.zionhuang.music.playback.MusicService +import com.zionhuang.music.playback.MusicService.MusicBinder +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.component.shimmer.ShimmerTheme import com.zionhuang.music.ui.player.BottomSheetPlayer @@ -54,12 +60,6 @@ import com.zionhuang.music.ui.screens.library.LibraryPlaylistsScreen import com.zionhuang.music.ui.screens.library.LibrarySongsScreen import com.zionhuang.music.ui.screens.settings.* import com.zionhuang.music.ui.theme.* -import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.* -import com.zionhuang.music.playback.MusicService -import com.zionhuang.music.playback.MusicService.MusicBinder -import com.zionhuang.music.playback.PlayerConnection -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.utils.NavigationTabHelper import com.zionhuang.music.viewmodels.MainViewModel import kotlinx.coroutines.Dispatchers @@ -155,7 +155,7 @@ class MainActivity : ComponentActivity() { val (textFieldValue, onTextFieldValueChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } val appBarConfig = remember(navBackStackEntry) { when { - route == null || navigationItems.any { it.route == route } -> defaultAppBarConfig() + route == null || navigationItems.any { it.route == route } -> searchAppBarConfig() route.startsWith("search/") -> onlineSearchResultAppBarConfig(navBackStackEntry?.arguments?.getString("query").orEmpty()) route.startsWith("album/") -> albumAppBarConfig() route.startsWith("artist/") -> artistAppBarConfig() @@ -306,11 +306,8 @@ class MainActivity : ComponentActivity() { nullable = true } ) - ) { backStackEntry -> - AlbumScreen( - albumId = backStackEntry.arguments?.getString("albumId")!!, - playlistId = backStackEntry.arguments?.getString("playlistId"), - ) + ) { + AlbumScreen(navController) } composable( route = "artist/{artistId}", @@ -319,9 +316,25 @@ class MainActivity : ComponentActivity() { type = NavType.StringType } ) - ) { backStackEntry -> + ) { ArtistScreen( - artistId = backStackEntry.arguments?.getString("artistId")!!, + navController = navController, + appBarConfig = appBarConfig + ) + } + composable( + route = "artistItems/{browseId}?params={params}", + arguments = listOf( + navArgument("browseId") { + type = NavType.StringType + }, + navArgument("params") { + type = NavType.StringType + nullable = true + } + ) + ) { + ArtistItemsScreen( navController = navController, appBarConfig = appBarConfig ) @@ -347,7 +360,6 @@ class MainActivity : ComponentActivity() { ) ) { backStackEntry -> OnlineSearchResult( - query = backStackEntry.arguments?.getString("query")!!, navController = navController ) } diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt index be8c268ec..3b8ec2376 100644 --- a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt @@ -19,13 +19,12 @@ val QueuePeekHeight = 64.dp val AppBarHeight = 64.dp val ListItemHeight = 64.dp -val GridItemWidth = 168.dp val SuggestionItemHeight = 56.dp val SearchFilterHeight = 48.dp val ListThumbnailSize = 48.dp -val GridThumbnailSize = 144.dp +val GridThumbnailHeight = 144.dp val AlbumThumbnailSize = 144.dp val ThumbnailCornerRadius = 6.dp -val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) \ No newline at end of file +val NavigationBarAnimationSpec = spring(stiffness = Spring.StiffnessMediumLow) 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 9897ac1d1..41a2afd37 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -1,11 +1,11 @@ package com.zionhuang.music.db import android.content.Context -import android.database.Cursor import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT import androidx.core.content.contentValuesOf import androidx.room.* import androidx.room.migration.Migration +import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase import com.zionhuang.music.db.daos.* import com.zionhuang.music.db.entities.* @@ -217,17 +217,5 @@ val MIGRATION_1_2 = object : Migration(1, 2) { } fun RoomDatabase.checkpoint() { - openHelper.writableDatabase.run { - query("PRAGMA journal_mode").use { cursor -> - if (cursor.moveToFirst()) { - when (cursor.getString(0).lowercase()) { - "wal" -> { - query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst) - query("PRAGMA wal_checkpoint(TRUNCATE)").use(Cursor::moveToFirst) - query("PRAGMA wal_checkpoint").use(Cursor::moveToFirst) - } - } - } - } - } + query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) } diff --git a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt index 010670aa9..7451c333a 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt @@ -42,11 +42,7 @@ interface ArtistDao { suspend fun hasArtist(id: String): Boolean @Query("DELETE FROM song_artist_map WHERE songId = :songId") - suspend fun deleteSongArtists(songId: String) - - suspend fun deleteSongArtists(songIds: List) = songIds.forEach { - deleteSongArtists(it) - } + suspend fun deleteSongArtistMaps(songId: String) @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(artist: ArtistEntity): Long diff --git a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt index ab928fdf3..91e4b01d1 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt @@ -15,10 +15,6 @@ interface PlaylistDao { @Query("SELECT id FROM playlist") fun getAllPlaylistId(): Flow> - @Transaction - @Query(QUERY_ALL_PLAYLIST) - suspend fun getAllPlaylistsAsList(): List - @Transaction @RawQuery(observedEntities = [PlaylistEntity::class, PlaylistSongMap::class]) fun getPlaylistsAsFlow(query: SupportSQLiteQuery): Flow> @@ -93,7 +89,7 @@ interface PlaylistDao { @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId AND position IN (:position)") suspend fun deletePlaylistSong(playlistId: String, position: List) - @Query("SELECT max(position) FROM playlist_song_map WHERE playlistId = :playlistId") + @Query("SELECT MAX(position) FROM playlist_song_map WHERE playlistId = :playlistId") suspend fun getPlaylistMaxId(playlistId: String): Int? fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( @@ -109,4 +105,4 @@ interface PlaylistDao { private const val QUERY_ALL_PLAYLIST = "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist" private const val QUERY_ORDER = " ORDER BY %s %s" } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt index a4ea55411..e1c6e2324 100644 --- a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt @@ -35,8 +35,7 @@ interface SongDao { @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :artistId") suspend fun getArtistSongCount(artistId: String): Int - @Query("SELECT song.id FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND NOT song.isTrash LIMIT 5") - suspend fun getArtistSongsPreview(artistId: String): List + suspend fun getArtistSongsPreview(artistId: String): Flow> = getSongsAsFlow((QUERY_ARTIST_SONG.format(artistId) + " LIMIT 5").toSQLiteQuery()) @Transaction @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) @@ -94,6 +93,9 @@ interface SongDao { @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) + @Query("UPDATE song SET duration = :duration WHERE id = :songId") + suspend fun updateSongDuration(songId: String, duration: Int) + @Insert(onConflict = OnConflictStrategy.IGNORE) suspend fun insert(songs: List) @@ -145,4 +147,4 @@ interface SongDao { private const val QUERY_LIKED_SONG = "SELECT * FROM song WHERE liked" private const val QUERY_DOWNLOADED_SONG = "SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED" } -} \ No newline at end of file +} 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 68a571e18..0f1678107 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 @@ -1,14 +1,11 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import java.time.LocalDateTime @Immutable -@Parcelize @Entity(tableName = "album") data class AlbumEntity( @PrimaryKey val id: String, @@ -19,4 +16,4 @@ data class AlbumEntity( val duration: Int, val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) : Parcelable \ No newline at end of file +) \ 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 f2041b5a6..e054e2e22 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 @@ -1,15 +1,12 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime @Immutable -@Parcelize @Entity(tableName = "artist") data class ArtistEntity( @PrimaryKey val id: String, @@ -19,7 +16,7 @@ data class ArtistEntity( val description: String? = null, val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) : Parcelable { +) { override fun toString(): String = name val isYouTubeArtist: Boolean diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt index bc9fe7cb4..3afd7c0d8 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt @@ -1,15 +1,12 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import org.apache.commons.lang3.RandomStringUtils import java.time.LocalDateTime @Immutable -@Parcelize @Entity(tableName = "playlist") data class PlaylistEntity( @PrimaryKey val id: String = generatePlaylistId(), @@ -20,7 +17,7 @@ data class PlaylistEntity( val thumbnailUrl: String? = null, val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), -) : Parcelable { +) { val isLocalPlaylist: Boolean get() = id.startsWith("LP") diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt index 6c9aeb71b..9e081aa46 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt @@ -1,18 +1,11 @@ package com.zionhuang.music.db.entities -import android.content.Context -import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.room.Embedded import androidx.room.Junction import androidx.room.Relation -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.extensions.toSongEntity -import com.zionhuang.music.repos.SongRepository -import kotlinx.parcelize.Parcelize @Immutable -@Parcelize data class Song @JvmOverloads constructor( @Embedded val song: SongEntity, @Relation( @@ -37,21 +30,7 @@ data class Song @JvmOverloads constructor( ) ) val album: AlbumEntity? = null, -) : LocalItem(), Parcelable { +) : LocalItem() { override val id: String get() = song.id } - -suspend fun SongItem.toSong(context: Context): Song = - Song( - song = toSongEntity(), - artists = artists.map { run -> - ArtistEntity( - id = run.navigationEndpoint?.browseEndpoint?.browseId - ?: SongRepository(context).getArtistByName(run.text)?.id - ?: ArtistEntity.generateArtistId(), - name = run.text - ) - }, - album = null - ) 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 1fb4ad6a2..36521240c 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 @@ -1,20 +1,17 @@ package com.zionhuang.music.db.entities -import android.os.Parcelable import androidx.compose.runtime.Immutable import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey -import kotlinx.parcelize.Parcelize import java.time.LocalDateTime @Immutable -@Parcelize @Entity(tableName = "song") data class SongEntity( @PrimaryKey val id: String, val title: String, - val duration: Int = 0, // in seconds + val duration: Int = -1, // in seconds val thumbnailUrl: String? = null, val albumId: String? = null, val albumName: String? = null, @@ -27,9 +24,9 @@ data class SongEntity( val createDate: LocalDateTime = LocalDateTime.now(), @ColumnInfo(name = "modify_date") val modifyDate: LocalDateTime = LocalDateTime.now(), -) : Parcelable +) const val STATE_NOT_DOWNLOADED = 0 const val STATE_PREPARING = 1 const val STATE_DOWNLOADING = 2 -const val STATE_DOWNLOADED = 3 \ No newline at end of file +const val STATE_DOWNLOADED = 3 diff --git a/app/src/main/java/com/zionhuang/music/extensions/BundleExt.kt b/app/src/main/java/com/zionhuang/music/extensions/BundleExt.kt deleted file mode 100644 index f14c5c82b..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/BundleExt.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.extensions - -import android.os.Build -import android.os.Bundle - -@Suppress("DEPRECATION") -inline fun Bundle.getParcelableCompat(key: String): T? = - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - getParcelable(key, T::class.java) - } else { - getParcelable(key) - } diff --git a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt b/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt deleted file mode 100644 index 46bdfcc0d..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/YouTubeExt.kt +++ /dev/null @@ -1,40 +0,0 @@ -package com.zionhuang.music.extensions - -import androidx.paging.PagingSource.LoadResult -import com.zionhuang.innertube.models.AlbumOrPlaylistHeader -import com.zionhuang.innertube.models.BrowseResult -import com.zionhuang.innertube.models.PlaylistItem -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.SongEntity - -// the SongItem should be produced by get_queue endpoint to have detailed information -fun SongItem.toSongEntity() = SongEntity( - id = id, - title = title, - duration = duration!!, - thumbnailUrl = thumbnails.last().url, - albumId = album?.navigationEndpoint?.browseId, - albumName = album?.text -) - -fun PlaylistItem.toPlaylistEntity() = PlaylistEntity( - id = id, - name = title, - thumbnailUrl = thumbnails.last().url -) - -fun AlbumOrPlaylistHeader.toPlaylistEntity() = PlaylistEntity( - id = id, - name = name, - author = artists?.firstOrNull()?.text, - authorId = artists?.firstOrNull()?.navigationEndpoint?.browseEndpoint?.browseId, - year = year, - thumbnailUrl = thumbnails.lastOrNull()?.url -) - -fun BrowseResult.toPage() = LoadResult.Page( - data = items, - nextKey = continuations?.ifEmpty { null }, - prevKey = null -) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt index 1b73b2d2c..94f62d3c5 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt @@ -7,14 +7,14 @@ import com.zionhuang.innertube.models.WatchEndpoint object YouTubeLyricsProvider : LyricsProvider { override val name = "YouTube Music" override fun isEnabled(context: Context) = true - override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = - YouTube.next(WatchEndpoint(videoId = id!!)).mapCatching { nextResult -> - YouTube.browse(nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found")).getOrThrow() - }.mapCatching { browseResult -> - browseResult.lyrics ?: throw IllegalStateException("Lyrics unavailable") - } + override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = runCatching { + val nextResult = YouTube.next(WatchEndpoint(videoId = id!!)).getOrThrow() + YouTube.getLyrics( + endpoint = nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found") + ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable") + } override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int, callback: (String) -> Unit) { getLyrics(id, title, artist, duration).onSuccess(callback) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt b/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt deleted file mode 100644 index 0580801da..000000000 --- a/app/src/main/java/com/zionhuang/music/models/DataWrapper.kt +++ /dev/null @@ -1,12 +0,0 @@ -package com.zionhuang.music.models - -import kotlinx.coroutines.flow.Flow - -open class DataWrapper( - val getValue: () -> T = { throw UnsupportedOperationException() }, - val getValueAsync: suspend () -> T = { throw UnsupportedOperationException() }, - open val getFlow: () -> Flow = { throw UnsupportedOperationException() }, -) { - val value: T get() = getValue() - val flow: Flow get() = getFlow() -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt b/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt deleted file mode 100644 index 0ed48c72c..000000000 --- a/app/src/main/java/com/zionhuang/music/models/DownloadProgress.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.zionhuang.music.models - -data class DownloadProgress( - val status: Int, - val currentBytes: Int = -1, - val totalBytes: Int = -1, -) diff --git a/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt b/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt deleted file mode 100644 index a0e214c23..000000000 --- a/app/src/main/java/com/zionhuang/music/models/ErrorInfo.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zionhuang.music.models - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class ErrorInfo( - val stackTrace: String, -) : Parcelable - -fun Throwable.toErrorInfo() = ErrorInfo( - stackTraceToString() -) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt b/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt new file mode 100644 index 000000000..a5dc16c4f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/models/ItemsPage.kt @@ -0,0 +1,8 @@ +package com.zionhuang.music.models + +import com.zionhuang.innertube.models.YTItem + +data class ItemsPage( + val items: List, + val continuation: String?, +) diff --git a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt b/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt deleted file mode 100644 index 954a8862a..000000000 --- a/app/src/main/java/com/zionhuang/music/models/ListWrapper.kt +++ /dev/null @@ -1,8 +0,0 @@ -package com.zionhuang.music.models - -import kotlinx.coroutines.flow.Flow - -class ListWrapper( - val getList: suspend () -> List = { throw UnsupportedOperationException() }, - override val getFlow: () -> Flow> = { throw UnsupportedOperationException() }, -) : DataWrapper>(getValueAsync = getList) \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt index 00df58ac5..65068293b 100644 --- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -7,8 +7,8 @@ import androidx.compose.runtime.Immutable import androidx.core.net.toUri import androidx.core.os.bundleOf import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.ui.utils.resize import com.zionhuang.music.db.entities.* +import com.zionhuang.music.ui.utils.resize import kotlin.math.roundToInt @Immutable @@ -21,14 +21,13 @@ data class MediaMetadata( val album: Album? = null, ) { data class Artist( - val id: String, + val id: String?, val name: String, ) data class Album( val id: String, val title: String, - val year: Int? = null, ) fun toMediaDescription(context: Context): MediaDescriptionCompat = builder @@ -72,8 +71,7 @@ fun Song.toMediaMetadata() = MediaMetadata( album = album?.let { MediaMetadata.Album( id = it.id, - title = it.title, - year = it.year + title = it.title ) } ?: song.albumId?.let { albumId -> MediaMetadata.Album( @@ -88,17 +86,16 @@ fun SongItem.toMediaMetadata() = MediaMetadata( title = title, artists = artists.map { MediaMetadata.Artist( - id = it.navigationEndpoint?.browseEndpoint?.browseId ?: ArtistEntity.generateArtistId(), - name = it.text + id = it.id, + name = it.name ) }, - duration = duration ?: 0, - thumbnailUrl = thumbnails.lastOrNull()?.url, + duration = duration ?: -1, + thumbnailUrl = thumbnail, album = album?.let { MediaMetadata.Album( - id = it.navigationEndpoint.browseId, - title = it.text, - year = albumYear + id = it.id, + title = it.name ) } -) \ No newline at end of file +) diff --git a/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt b/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt deleted file mode 100644 index e7ef85447..000000000 --- a/app/src/main/java/com/zionhuang/music/models/PlaybackStateData.kt +++ /dev/null @@ -1,25 +0,0 @@ -package com.zionhuang.music.models - -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.PlaybackStateCompat -import android.support.v4.media.session.PlaybackStateCompat.* - -data class PlaybackStateData( - @State val state: Int = STATE_NONE, - @ShuffleMode val shuffleMode: Int = SHUFFLE_MODE_NONE, - @RepeatMode val repeatMode: Int = REPEAT_MODE_NONE, - @Actions val actions: Long = 0, - val errorCode: Int = 0, - val errorMessage: String? = null, -) { - companion object { - fun from(mediaController: MediaControllerCompat, playbackState: PlaybackStateCompat) = PlaybackStateData( - playbackState.state, - mediaController.shuffleMode, - mediaController.repeatMode, - playbackState.actions, - playbackState.errorCode, - playbackState.errorMessage?.toString() - ) - } -} 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 4eead6c3b..ea5bc5b33 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -109,19 +109,19 @@ class MusicService : MediaBrowserServiceCompat() { )) SONG -> { result.detach() - result.sendResult(songRepository.getAllSongs(SongSortInfoPreference).flow.first().map { + result.sendResult(songRepository.getAllSongs(SongSortInfoPreference).first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) } ARTIST -> { result.detach() - result.sendResult(songRepository.getAllArtists(ArtistSortInfoPreference).flow.first().map { artist -> + result.sendResult(songRepository.getAllArtists(ArtistSortInfoPreference).first().map { artist -> mediaBrowserItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.song_count, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri()) }.toMutableList()) } ALBUM -> { result.detach() - result.sendResult(songRepository.getAllAlbums(AlbumSortInfoPreference).flow.first().map { album -> + result.sendResult(songRepository.getAllAlbums(AlbumSortInfoPreference).first().map { album -> mediaBrowserItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri()) }.toMutableList()) } @@ -132,14 +132,14 @@ class MusicService : MediaBrowserServiceCompat() { result.sendResult((listOf( mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.song_count, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.song_count, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) - ) + songRepository.getAllPlaylists(PlaylistSortInfoPreference).flow.first().filter { it.playlist.isLocalPlaylist }.map { playlist -> + ) + songRepository.getAllPlaylists(PlaylistSortInfoPreference).first().filter { it.playlist.isLocalPlaylist }.map { playlist -> mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount), playlist.playlist.thumbnailUrl?.toUri() ?: playlist.thumbnails.firstOrNull()?.toUri()) }).toMutableList()) } else -> when { parentId.startsWith("$ARTIST/") -> { result.detach() - result.sendResult(songRepository.getArtistSongs(parentId.removePrefix("$ARTIST/"), SongSortInfoPreference).flow.first().map { + result.sendResult(songRepository.getArtistSongs(parentId.removePrefix("$ARTIST/"), SongSortInfoPreference).first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) } @@ -155,7 +155,7 @@ class MusicService : MediaBrowserServiceCompat() { LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference) DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference) else -> songRepository.getPlaylistSongs(playlistId) - }.flow.first().map { + }.first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) } @@ -191,4 +191,4 @@ class MusicService : MediaBrowserServiceCompat() { const val ALBUM = "album" const val PLAYLIST = "playlist" } -} \ No newline at end of file +} 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 18fb0eb0b..92547c1ce 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -33,7 +33,7 @@ class PlayerConnection(context: Context, val binder: MusicBinder) : Listener { songRepository.getLyrics(mediaMetadata?.id) } val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongFormat(mediaMetadata?.id).flow + songRepository.getSongFormat(mediaMetadata?.id) } val queueTitle = MutableStateFlow(null) @@ -154,4 +154,4 @@ class PlayerConnection(context: Context, val binder: MusicBinder) : Listener { songPlayer.bitmapProvider.onBitmapChanged = {} player.removeListener(this) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 04a22b06e..6a5c59ff8 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -47,9 +47,7 @@ import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvicto import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.models.QueueAddEndpoint.Companion.INSERT_AFTER_CURRENT_VIDEO -import com.zionhuang.innertube.models.QueueAddEndpoint.Companion.INSERT_AT_END +import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.ACTION_SHOW_BOTTOM_SHEET import com.zionhuang.music.MainActivity import com.zionhuang.music.R @@ -107,7 +105,7 @@ class SongPlayer( private var autoAddSong by context.preference(AUTO_ADD_TO_LIBRARY, true) private var audioQuality by enumPreference(context, AUDIO_QUALITY, AudioQuality.AUTO) - private var currentQueue: Queue = EmptyQueue() + private var currentQueue: Queue = EmptyQueue var queueTitle: String? = null val currentMediaMetadata = MutableStateFlow(null) @@ -115,7 +113,7 @@ class SongPlayer( songRepository.getSongById(mediaMetadata?.id) } private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongFormat(mediaMetadata?.id).flow + songRepository.getSongFormat(mediaMetadata?.id) } var currentSong: Song? = null @@ -158,7 +156,7 @@ class SongPlayer( when (path.firstOrNull()) { SONG -> { val songId = path.getOrNull(1) ?: return@launch - val allSongs = songRepository.getAllSongs(SongSortInfoPreference).flow.first() + val allSongs = songRepository.getAllSongs(SongSortInfoPreference).first() playQueue(ListQueue( title = context.getString(R.string.queue_all_songs), items = allSongs.map { it.toMediaItem() }, @@ -169,7 +167,7 @@ class SongPlayer( val songId = path.getOrNull(2) ?: return@launch val artistId = path.getOrNull(1) ?: return@launch val artist = songRepository.getArtistById(artistId) ?: return@launch - val songs = songRepository.getArtistSongs(artistId, SongSortInfoPreference).flow.first() + val songs = songRepository.getArtistSongs(artistId, SongSortInfoPreference).first() playQueue(ListQueue( title = artist.name, items = songs.map { it.toMediaItem() }, @@ -191,9 +189,9 @@ class SongPlayer( val songId = path.getOrNull(2) ?: return@launch val playlistId = path.getOrNull(1) ?: return@launch val songs = when (playlistId) { - LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference).flow.first() - DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference).flow.first() - else -> songRepository.getPlaylistSongs(playlistId).getList() + LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference).first() + DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference).first() + else -> songRepository.getPlaylistSongs(playlistId).first() } playQueue(ListQueue( title = when (playlistId) { @@ -427,45 +425,14 @@ class SongPlayer( // Check whether format exists so that users from older version can view format details // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently - val playedFormat = songRepository.getSongFormat(mediaId).flow.firstOrNull() - if (playedFormat != null && songRepository.getSongById(mediaId).firstOrNull()?.song?.downloadState == STATE_DOWNLOADED) { + val playedFormat = songRepository.getSongFormat(mediaId).firstOrNull() + val song = songRepository.getSongById(mediaId).firstOrNull() + if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { return@runBlocking dataSpec.withUri(songRepository.getSongFile(mediaId).toUri()) } - withContext(IO) { + val playerResponse = withContext(IO) { YouTube.player(mediaId) - }.map { playerResponse -> - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) - } - val format = if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { - // Use itag to identify previous played format - it.itag == playedFormat.itag - } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - AudioQuality.HIGH -> 1 - AudioQuality.LOW -> -1 - } - } - } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) - songRepository.upsert(FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - )) - InfoCache.putInfo(mediaId, format.url, playerResponse.streamingData!!.expiresInSeconds * 1000L) - dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) }.getOrElse { throwable -> if (throwable is ConnectException || throwable is UnknownHostException) { throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) @@ -475,6 +442,39 @@ class SongPlayer( } throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) } + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) + } + + val format = if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { + // Use itag to identify previous played format + it.itag == playedFormat.itag + } + } else { + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + } + } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) + + songRepository.upsert(FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + )) + InfoCache.putInfo(mediaId, format.url, playerResponse.streamingData!!.expiresInSeconds * 1000L) + dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } }) @@ -493,7 +493,7 @@ class SongPlayer( .build() } - fun updateQueueTitle(title: String?) { + private fun updateQueueTitle(title: String?) { mediaSession.setQueueTitle(title) queueTitle = title } @@ -515,9 +515,10 @@ class SongPlayer( updateQueueTitle(queueTitle) } if (queue.preloadItem != null) { - player.addMediaItems(initialStatus.items.drop(1)) + player.addMediaItems(0, initialStatus.items.subList(0, initialStatus.mediaItemIndex)) + player.addMediaItems(initialStatus.items.subList(initialStatus.mediaItemIndex + 1, initialStatus.items.size)) } else { - player.setMediaItems(initialStatus.items, if (initialStatus.index > 0) initialStatus.index else 0, initialStatus.position) + player.setMediaItems(initialStatus.items, if (initialStatus.mediaItemIndex > 0) initialStatus.mediaItemIndex else 0, initialStatus.position) player.prepare() player.playWhenReady = playWhenReady } @@ -539,37 +540,6 @@ class SongPlayer( } } - fun handleQueueAddEndpoint(endpoint: QueueAddEndpoint, item: YTItem?) { - scope.launch { - val items = when (item) { - is SongItem -> YouTube.getQueue(videoIds = listOf(item.id)).getOrThrow().map { it.toMediaItem() } - is AlbumItem -> withContext(IO) { - YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).getOrThrow().items.filterIsInstance().map { it.toMediaItem() } - // consider refetch by [YouTube.getQueue] if needed - } - is PlaylistItem -> withContext(IO) { - YouTube.getQueue(playlistId = endpoint.queueTarget.playlistId!!).getOrThrow().map { it.toMediaItem() } - } - is ArtistItem -> return@launch - null -> when { - endpoint.queueTarget.videoId != null -> withContext(IO) { - YouTube.getQueue(videoIds = listOf(endpoint.queueTarget.videoId!!)).getOrThrow().map { it.toMediaItem() } - } - endpoint.queueTarget.playlistId != null -> withContext(IO) { - YouTube.getQueue(playlistId = endpoint.queueTarget.playlistId).getOrThrow().map { it.toMediaItem() } - } - else -> error("Unknown queue target") - } - } - when (endpoint.queueInsertPosition) { - INSERT_AFTER_CURRENT_VIDEO -> player.addMediaItems((if (player.mediaItemCount == 0) -1 else player.currentMediaItemIndex) + 1, items) - INSERT_AT_END -> player.addMediaItems(items) - else -> {} - } - player.prepare() - } - } - fun playNext(items: List) { player.addMediaItems(if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, items) player.prepare() @@ -663,7 +633,7 @@ class SongPlayer( override fun onPlaybackStateChanged(@State playbackState: Int) { if (playbackState == STATE_IDLE) { - currentQueue = EmptyQueue() + currentQueue = EmptyQueue player.shuffleModeEnabled = false mediaSession.setQueueTitle("") } @@ -763,4 +733,4 @@ class SongPlayer( FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt index b03b75bef..9e6cae186 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt @@ -3,7 +3,7 @@ package com.zionhuang.music.playback.queues import com.google.android.exoplayer2.MediaItem import com.zionhuang.music.models.MediaMetadata -class EmptyQueue : Queue { +object EmptyQueue : Queue { override val preloadItem: MediaMetadata? = null override suspend fun getInitialStatus() = Queue.Status(null, emptyList(), -1) override fun hasNextPage() = false diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt index c4eaf76d1..68a24d557 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt @@ -12,7 +12,7 @@ interface Queue { data class Status( val title: String?, val items: List, - val index: Int, + val mediaItemIndex: Int, val position: Long = 0L, ) -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt index ebe0b0923..d8a472e4f 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt @@ -22,7 +22,7 @@ class YouTubeQueue( return Queue.Status( title = nextResult.title, items = nextResult.items.map { it.toMediaItem() }, - index = nextResult.currentIndex ?: 0 + mediaItemIndex = nextResult.currentIndex ?: 0 ) } @@ -35,4 +35,4 @@ class YouTubeQueue( continuation = nextResult.continuation return nextResult.items.map { it.toMediaItem() } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt index b73d4da21..8b79d928e 100644 --- a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt @@ -13,7 +13,6 @@ import com.zionhuang.music.R import com.zionhuang.music.models.sortInfo.SongSortInfoPreference import com.zionhuang.music.repos.SongRepository import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.firstOrNull import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.time.ZoneOffset @@ -46,8 +45,8 @@ class SongsProvider : DocumentsProvider() { .add(Document.COLUMN_DISPLAY_NAME, context!!.getString(R.string.app_name)) .add(Document.COLUMN_MIME_TYPE, MIME_TYPE_DIR) else -> { - val song = songRepository.getSongById(documentId).firstOrNull() ?: throw FileNotFoundException() - val format = songRepository.getSongFormat(documentId).getValueAsync() ?: throw FileNotFoundException() + val song = songRepository.getSongById(documentId).first() ?: throw FileNotFoundException() + val format = songRepository.getSongFormat(documentId).first() ?: throw FileNotFoundException() newRow() .add(Document.COLUMN_DOCUMENT_ID, documentId) .add(Document.COLUMN_DISPLAY_NAME, song.song.title) @@ -62,8 +61,8 @@ class SongsProvider : DocumentsProvider() { override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor = runBlocking { MatrixCursor(DEFAULT_DOCUMENT_PROJECTION).apply { when (parentDocumentId) { - ROOT_DOC -> songRepository.getDownloadedSongs(SongSortInfoPreference).flow.first().forEach { song -> - val format = songRepository.getSongFormat(song.id).getValueAsync() + ROOT_DOC -> songRepository.getDownloadedSongs(SongSortInfoPreference).first().forEach { song -> + val format = songRepository.getSongFormat(song.id).first() if (format != null) { newRow() .add(Document.COLUMN_DOCUMENT_ID, song.id) @@ -82,7 +81,7 @@ class SongsProvider : DocumentsProvider() { when (rootId) { ROOT -> { songRepository.searchDownloadedSongs(query).first().forEach { song -> - val format = songRepository.getSongFormat(song.id).getValueAsync() + val format = songRepository.getSongFormat(song.id).first() if (format != null) { newRow() .add(Document.COLUMN_DOCUMENT_ID, song.id) @@ -103,7 +102,7 @@ class SongsProvider : DocumentsProvider() { } override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = runBlocking { - val song = songRepository.getSongById(documentId).firstOrNull() + val song = songRepository.getSongById(documentId).first() song != null && parentDocumentId == ROOT_DOC } @@ -151,4 +150,4 @@ class SongsProvider : DocumentsProvider() { Document.COLUMN_FLAGS ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt index 023ab50bf..f40f3f1f8 100644 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt @@ -1,36 +1,29 @@ package com.zionhuang.music.repos -import android.app.DownloadManager import android.content.Context -import android.net.ConnectivityManager -import androidx.core.content.getSystemService -import androidx.core.net.toUri import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.YouTube.MAX_GET_QUEUE_SIZE import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.utils.browseAll -import com.zionhuang.music.R -import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.constants.AUDIO_QUALITY import com.zionhuang.music.constants.AUTO_ADD_TO_LIBRARY import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.* +import com.zionhuang.music.db.entities.Album +import com.zionhuang.music.db.entities.Artist import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId -import com.zionhuang.music.extensions.* -import com.zionhuang.music.models.DataWrapper -import com.zionhuang.music.models.ListWrapper +import com.zionhuang.music.extensions.div +import com.zionhuang.music.extensions.preference +import com.zionhuang.music.extensions.reversed import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.sortInfo.* -import com.zionhuang.music.playback.SongPlayer +import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.repos.base.LocalRepository -import com.zionhuang.music.utils.enumPreference +import com.zionhuang.music.ui.utils.resize import com.zionhuang.music.utils.md5 -import com.zionhuang.music.youtube.YouTubeAlbum -import com.zionhuang.music.youtube.getYouTubeAlbum import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map import kotlinx.coroutines.withContext import java.io.File @@ -47,9 +40,7 @@ class SongRepository(private val context: Context) : LocalRepository { private val formatDao = database.formatDao private val lyricsDao = database.lyricsDao - private val connectivityManager = context.getSystemService()!! private var autoDownload by context.preference(AUTO_ADD_TO_LIBRARY, false) - private var audioQuality by enumPreference(context, AUDIO_QUALITY, SongPlayer.AudioQuality.AUTO) override fun getAllSongId(): Flow> = songDao.getAllSongId() override fun getAllLikedSongId(): Flow> = songDao.getAllLikedSongId() @@ -59,138 +50,87 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Browse */ - override fun getAllSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getFlow = { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getAllSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getAllSongsAsFlow(sortInfo) + override fun getAllSongs(sortInfo: ISortInfo) = + if (sortInfo.type == SongSortType.ARTIST) { + songDao.getAllSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> + list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> + song.artists.joinToString(separator = "") { it.name } + }).reversed(sortInfo.isDescending) } + } else { + songDao.getAllSongsAsFlow(sortInfo) } - ) override suspend fun getSongCount() = withContext(IO) { songDao.getSongCount() } - override fun getAllArtists(sortInfo: ISortInfo) = ListWrapper( - getFlow = { - if (sortInfo.type == ArtistSortType.SONG_COUNT) { - artistDao.getAllArtistsAsFlow(SortInfo(ArtistSortType.CREATE_DATE, true)).map { list -> - list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) - } - } else { - artistDao.getAllArtistsAsFlow(sortInfo) + override fun getAllArtists(sortInfo: ISortInfo) = + if (sortInfo.type == ArtistSortType.SONG_COUNT) { + artistDao.getAllArtistsAsFlow(SortInfo(ArtistSortType.CREATE_DATE, true)).map { list -> + list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) } + } else { + artistDao.getAllArtistsAsFlow(sortInfo) } - ) override suspend fun getArtistCount() = withContext(IO) { artistDao.getArtistCount() } - override suspend fun getArtistSongsPreview(artistId: String): Result> = withContext(IO) { - runCatching { - if (artistDao.hasArtist(artistId)) { - listOf(Header( - title = context.getString(R.string.header_from_your_library), - moreNavigationEndpoint = NavigationEndpoint( - browseLocalArtistSongsEndpoint = BrowseLocalArtistSongsEndpoint(artistId) - ) - )) + YouTube.getQueue(videoIds = songDao.getArtistSongsPreview(artistId)).getOrThrow() - } else { - emptyList() - } - } - } + override suspend fun getArtistSongsPreview(artistId: String) = songDao.getArtistSongsPreview(artistId) - override fun getArtistSongs(artistId: String, sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getArtistSongsAsList(artistId, sortInfo) } }, - getFlow = { - songDao.getArtistSongsAsFlow(artistId, if (sortInfo.type == SongSortType.ARTIST) SortInfo(SongSortType.CREATE_DATE, sortInfo.isDescending) else sortInfo) - } - ) + override fun getArtistSongs(artistId: String, sortInfo: ISortInfo) = + songDao.getArtistSongsAsFlow(artistId, if (sortInfo.type == SongSortType.ARTIST) SortInfo(SongSortType.CREATE_DATE, sortInfo.isDescending) else sortInfo) override suspend fun getArtistSongCount(artistId: String) = withContext(IO) { songDao.getArtistSongCount(artistId) } - override fun getAllAlbums(sortInfo: ISortInfo) = ListWrapper( - getFlow = { - if (sortInfo.type == AlbumSortType.ARTIST) { - albumDao.getAllAlbumsAsFlow(SortInfo(AlbumSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - albumDao.getAllAlbumsAsFlow(sortInfo) + override fun getAllAlbums(sortInfo: ISortInfo) = + if (sortInfo.type == AlbumSortType.ARTIST) { + albumDao.getAllAlbumsAsFlow(SortInfo(AlbumSortType.CREATE_DATE, true)).map { list -> + list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> + song.artists.joinToString(separator = "") { it.name } + }).reversed(sortInfo.isDescending) } + } else { + albumDao.getAllAlbumsAsFlow(sortInfo) } - ) override suspend fun getAlbumCount() = withContext(IO) { albumDao.getAlbumCount() } override suspend fun getAlbumSongs(albumId: String) = withContext(IO) { songDao.getAlbumSongs(albumId) } - override fun getAllPlaylists(sortInfo: ISortInfo) = ListWrapper( - getList = { withContext(IO) { playlistDao.getAllPlaylistsAsList() } }, - getFlow = { - if (sortInfo.type == PlaylistSortType.SONG_COUNT) { - playlistDao.getAllPlaylistsAsFlow(SortInfo(PlaylistSortType.CREATE_DATE, true)).map { list -> - list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) - } - } else { - playlistDao.getAllPlaylistsAsFlow(sortInfo) + override fun getAllPlaylists(sortInfo: ISortInfo) = + if (sortInfo.type == PlaylistSortType.SONG_COUNT) { + playlistDao.getAllPlaylistsAsFlow(SortInfo(PlaylistSortType.CREATE_DATE, true)).map { list -> + list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) } + } else { + playlistDao.getAllPlaylistsAsFlow(sortInfo) } - ) - - override fun getPlaylistSongs(playlistId: String): ListWrapper = ListWrapper( - getList = { withContext(IO) { songDao.getPlaylistSongsAsList(playlistId) } }, - getFlow = { songDao.getPlaylistSongsAsFlow(playlistId) } - ) - - override fun getLikedSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getFlow = { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getLikedSongs(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getLikedSongs(sortInfo) + + override fun getPlaylistSongs(playlistId: String) = songDao.getPlaylistSongsAsFlow(playlistId) + + override fun getLikedSongs(sortInfo: ISortInfo) = + if (sortInfo.type == SongSortType.ARTIST) { + songDao.getLikedSongs(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> + list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> + song.artists.joinToString(separator = "") { it.name } + }).reversed(sortInfo.isDescending) } + } else { + songDao.getLikedSongs(sortInfo) } - ) override fun getLikedSongCount(): Flow = songDao.getLikedSongCount() - override fun getDownloadedSongs(sortInfo: ISortInfo): ListWrapper = ListWrapper( - getList = { - withContext(IO) { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getDownloadedSongsAsList(SortInfo(SongSortType.CREATE_DATE, true)) - .sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } else { - songDao.getDownloadedSongsAsList(sortInfo) - } - } - }, - getFlow = { - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getDownloadedSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getDownloadedSongsAsFlow(sortInfo) + override fun getDownloadedSongs(sortInfo: ISortInfo) = + if (sortInfo.type == SongSortType.ARTIST) { + songDao.getDownloadedSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> + list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> + song.artists.joinToString(separator = "") { it.name } + }).reversed(sortInfo.isDescending) } + } else { + songDao.getDownloadedSongsAsFlow(sortInfo) } - ) override fun getDownloadedSongCount(): Flow = songDao.getDownloadedSongCount() @@ -198,10 +138,10 @@ class SongRepository(private val context: Context) : LocalRepository { * Search */ override fun searchAll(query: String): Flow> = combine( - songDao.searchSongsPreview(query, 3), - artistDao.searchArtistsPreview(query, 3), - albumDao.searchAlbumsPreview(query, 3), - playlistDao.searchPlaylistsPreview(query, 3) + songDao.searchSongsPreview(query, PREVIEW_SIZE), + artistDao.searchArtistsPreview(query, PREVIEW_SIZE), + albumDao.searchAlbumsPreview(query, PREVIEW_SIZE), + playlistDao.searchPlaylistsPreview(query, PREVIEW_SIZE) ) { songResult, artistResult, albumResult, playlistResult -> songResult + artistResult + albumResult + playlistResult } override fun searchSongs(query: String) = songDao.searchSongs(query) @@ -213,55 +153,29 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Song */ - override suspend fun addSong(mediaMetadata: MediaMetadata): SongEntity = withContext(IO) { - songDao.getSong(mediaMetadata.id)?.let { - return@withContext it.song - } - val song = mediaMetadata.toSongEntity() - songDao.insert(song) - mediaMetadata.artists.forEachIndexed { index, artist -> - artistDao.insert(ArtistEntity( - id = artist.id, - name = artist.name - )) - artistDao.insert(SongArtistMap( - songId = mediaMetadata.id, - artistId = artist.id, - position = index - )) - } - if (autoDownload) downloadSong(song) - song - } - - private suspend fun addSongs(items: List) = withContext(IO) { - val songs = items.map { it.toSongEntity() } - val songArtistMaps = items.flatMap { song -> - song.artists.mapIndexed { index, run -> - val artistId = (run.navigationEndpoint?.browseEndpoint?.browseId ?: getArtistByName(run.text)?.id ?: generateArtistId()).also { - artistDao.insert(ArtistEntity( - id = it, - name = run.text - )) + override suspend fun addSongs(songs: List) = withContext(IO) { + songs.forEach { mediaMetadata -> + songDao.getSong(mediaMetadata.id)?.let { song -> + if (song.song.downloadState == STATE_NOT_DOWNLOADED && autoDownload) { + downloadSong(mediaMetadata.id) } - SongArtistMap( - songId = song.id, + return@forEach + } + songDao.insert(mediaMetadata.toSongEntity()) + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId() + artistDao.insert(ArtistEntity( + id = artistId, + name = artist.name + )) + artistDao.insert(SongArtistMap( + songId = mediaMetadata.id, artistId = artistId, position = index - ) + )) } + if (autoDownload) downloadSong(mediaMetadata.id) } - songDao.insert(songs) - artistDao.insertSongArtistMaps(songArtistMaps) - if (autoDownload) downloadSongs(songs) - return@withContext songs - } - - override suspend fun safeAddSongs(songs: List): List = withContext(IO) { - // call [YouTube.getQueue] to ensure we get full information - addSongs(songs.chunked(MAX_GET_QUEUE_SIZE).flatMap { chunk -> - YouTube.getQueue(chunk.map { it.id }).getOrThrow() - }) } override suspend fun refetchSongs(songs: List) = withContext(IO) { @@ -274,18 +188,18 @@ class SongRepository(private val context: Context) : LocalRepository { id = item.id, title = item.title, duration = item.duration!!, - thumbnailUrl = item.thumbnails.last().url, - albumId = item.album?.navigationEndpoint?.browseId, - albumName = item.album?.text, + thumbnailUrl = item.thumbnail, + albumId = item.album?.id, + albumName = item.album?.name, modifyDate = LocalDateTime.now() ) }) val songArtistMaps = songItems.flatMap { song -> - song.artists.mapIndexed { index, run -> - val artistId = (run.navigationEndpoint?.browseEndpoint?.browseId ?: getArtistByName(run.text)?.id ?: generateArtistId()).also { + song.artists.mapIndexed { index, artist -> + val artistId = (artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId()).also { artistDao.insert(ArtistEntity( id = it, - name = run.text + name = artist.name )) } SongArtistMap( @@ -295,12 +209,15 @@ class SongRepository(private val context: Context) : LocalRepository { ) } } - artistDao.deleteSongArtists(songs.map { it.id }) + songs.forEach { song -> + artistDao.deleteSongArtistMaps(songId = song.id) + } artistDao.insertSongArtistMaps(songArtistMaps) - artistDao.delete(songs - .flatMap { it.artists } - .distinctBy { it.id } - .filter { artistDao.getArtistSongCount(it.id) == 0 } + artistDao.delete( + songs + .flatMap { it.artists } + .distinctBy { it.id } + .filter { artistDao.getArtistSongCount(it.id) == 0 } ) } @@ -318,14 +235,14 @@ class SongRepository(private val context: Context) : LocalRepository { return mediaDir / (md5(songId) + ".tmp") } - override fun hasSong(songId: String): DataWrapper = DataWrapper( - getValueAsync = { songDao.hasSong(songId) } - ) - override suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) = withContext(IO) { songDao.incrementSongTotalPlayTime(songId, playTime) } + override suspend fun updateSongDuration(songId: String, duration: Int) = withContext(IO) { + songDao.updateSongDuration(songId, duration) + } + override suspend fun updateSongTitle(song: Song, newTitle: String) = withContext(IO) { songDao.update(song.song.copy( title = newTitle, @@ -342,54 +259,8 @@ class SongRepository(private val context: Context) : LocalRepository { }) } - override suspend fun downloadSongs(songs: List) = withContext(IO) { - songs.filter { it.downloadState == STATE_NOT_DOWNLOADED }.let { songs -> - songDao.update(songs.map { it.copy(downloadState = STATE_PREPARING) }) - songs.forEach { song -> - val playedFormat = getSongFormat(song.id).getValueAsync() - val playerResponse = YouTube.player(videoId = song.id).getOrThrow() - if (playerResponse.playabilityStatus.status == "OK") { - val format = if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { it.itag == playedFormat.itag } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - SongPlayer.AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - SongPlayer.AudioQuality.HIGH -> 1 - SongPlayer.AudioQuality.LOW -> -1 - } - } - } - if (format == null) { - songDao.update(song.copy(downloadState = STATE_NOT_DOWNLOADED)) - // TODO - } else { - upsert(FormatEntity( - id = song.id, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - )) - songDao.update(song.copy(downloadState = STATE_DOWNLOADING)) - val downloadManager = context.getSystemService()!! - val req = DownloadManager.Request(format.url.toUri()) - .setTitle(song.title) - .setDestinationUri(getSongTempFile(song.id).toUri()) - val did = downloadManager.enqueue(req) - addDownloadEntity(DownloadEntity(did, song.id)) - } - } else { - songDao.update(song.copy(downloadState = STATE_NOT_DOWNLOADED)) - // TODO - } - } - } + override suspend fun downloadSongs(songIds: List) = withContext(IO) { + // TODO } override suspend fun onDownloadComplete(downloadId: Long, success: Boolean): Unit = withContext(IO) { @@ -403,7 +274,7 @@ class SongRepository(private val context: Context) : LocalRepository { } override suspend fun validateDownloads() { - getDownloadedSongs(SongSortInfoPreference).getList().forEach { song -> + getDownloadedSongs(SongSortInfoPreference).first().forEach { song -> if (!getSongFile(song.id).exists() && !getSongTempFile(song.id).exists()) { songDao.update(song.song.copy(downloadState = STATE_NOT_DOWNLOADED)) } @@ -461,20 +332,15 @@ class SongRepository(private val context: Context) : LocalRepository { } override suspend fun refetchArtists(artists: List) = withContext(IO) { - artists.forEach { artist -> - if (artist.isYouTubeArtist) { - val browseResult = YouTube.browse(BrowseEndpoint(browseId = artist.id)).getOrThrow() - val header = browseResult.items.firstOrNull() - if (header is ArtistHeader) { - artistDao.update(artist.copy( - name = header.name, - thumbnailUrl = header.bannerThumbnails?.lastOrNull()?.url?.resize(400, 400), - bannerUrl = header.bannerThumbnails?.lastOrNull()?.url, - description = header.description, - lastUpdateTime = LocalDateTime.now() - )) - } - } + artists.filter { artist -> + artist.isYouTubeArtist + }.forEach { artist -> + val artistPage = YouTube.browseArtist(artist.id).getOrThrow() + artistDao.update(artist.copy( + name = artistPage.artist.title, + thumbnailUrl = artistPage.artist.thumbnail.resize(400, 400), + lastUpdateTime = LocalDateTime.now() + )) } } @@ -485,43 +351,40 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Album */ - suspend fun addAlbum(youTubeAlbum: YouTubeAlbum) = withContext(IO) { - val (album, artists, songs) = youTubeAlbum - albumDao.insert(album) - addSongs(songs) - albumDao.upsert(songs.mapIndexed { index, songItem -> - SongAlbumMap( - songId = songItem.id, - albumId = album.id, - index = index - ) - }) - artistDao.insertArtists(artists) - albumDao.insertAlbumArtistMaps(artists.mapIndexed { index, artist -> - AlbumArtistMap( - albumId = album.id, - artistId = artist.id, - order = index - ) - }) - } - override suspend fun addAlbums(albums: List) = withContext(IO) { - albums.forEach { album -> - addAlbum(album.getYouTubeAlbum(context)) - } - } - - override suspend fun refetchAlbums(albums: List) = withContext(IO) { - albums.forEach { album -> - (YouTube.browse(BrowseEndpoint(browseId = album.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> - albumDao.update(album.copy( - title = header.name, - thumbnailUrl = header.thumbnails.lastOrNull()?.url, - year = header.year, - lastUpdateTime = LocalDateTime.now() - )) - } + albums.filter { + albumDao.getAlbumById(it.id) == null + }.forEach { album -> + val albumPage = YouTube.browseAlbum(album.browseId).getOrThrow() + albumDao.insert(AlbumEntity( + 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 } + )) + addSongs(albumPage.songs.map(SongItem::toMediaMetadata)) + albumDao.upsert(albumPage.songs.mapIndexed { index, song -> + SongAlbumMap( + songId = song.id, + albumId = album.id, + index = index + ) + }) + artistDao.insertArtists(albumPage.album.artists!!.map { artist -> + ArtistEntity( + id = artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId(), + name = artist.name + ) + }) + albumDao.insertAlbumArtistMaps(albumPage.album.artists!!.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = album.id, + artistId = artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId(), + order = index + ) + }) } } @@ -529,12 +392,12 @@ class SongRepository(private val context: Context) : LocalRepository { albumDao.getAlbumById(albumId) } - override suspend fun deleteAlbum(albumId: String) = withContext(IO) { - albumDao.delete(albumId) + override suspend fun getAlbumWithSongs(albumId: String) = withContext(IO) { + albumDao.getAlbumWithSongs(albumId) } - suspend fun getAlbumWithSongs(albumId: String) = withContext(IO) { - albumDao.getAlbumWithSongs(albumId) + override suspend fun deleteAlbum(albumId: String) = withContext(IO) { + albumDao.delete(albumId) } override suspend fun deleteAlbums(albums: List) = withContext(IO) { @@ -549,33 +412,51 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Playlist */ - override fun getPlaylist(playlistId: String): Flow = playlistDao.getPlaylist(playlistId) - override suspend fun insertPlaylist(playlist: PlaylistEntity): Unit = withContext(IO) { playlistDao.insert(playlist) } override suspend fun addPlaylists(playlists: List) = withContext(IO) { playlists.forEach { playlist -> - (YouTube.browse(BrowseEndpoint(browseId = "VL" + playlist.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> - playlistDao.insert(header.toPlaylistEntity()) - } + val playlistPage = YouTube.browsePlaylist("VL${playlist.id}").getOrThrow() + playlistDao.insert(PlaylistEntity( + id = playlistPage.playlist.id, + name = playlistPage.playlist.title, + author = playlistPage.playlist.author.name, + authorId = playlistPage.playlist.author.id, + thumbnailUrl = playlistPage.playlist.thumbnail + )) } } override suspend fun importPlaylists(playlists: List) = withContext(IO) { playlists.forEach { playlist -> val playlistId = generatePlaylistId() - playlistDao.insert(playlist.toPlaylistEntity().copy(id = playlistId)) + val playlistPage = YouTube.browsePlaylist("VL${playlist.id}").getOrThrow() + playlistDao.insert(PlaylistEntity( + id = playlistId, + name = playlistPage.playlist.title, + thumbnailUrl = playlistPage.playlist.thumbnail + )) var index = 0 - val songs = YouTube.browseAll(BrowseEndpoint(browseId = "VL" + playlist.id)).getOrThrow().filterIsInstance() - safeAddSongs(songs) - playlistDao.insert(songs.map { - PlaylistSongMap( - playlistId = playlistId, - songId = it.id, - position = index++ - ) - }) + var songs: List = playlistPage.songs + var continuation = playlistPage.songsContinuation + while (true) { + playlistDao.insert(songs.map { + PlaylistSongMap( + playlistId = playlistId, + songId = it.id, + position = index++ + ) + }) + if (continuation == null) break + val continuationPage = YouTube.browsePlaylistContinuation(continuation).getOrThrow() + songs = continuationPage.songs + continuation = continuationPage.continuation + } } } + override suspend fun insertPlaylist(playlist: PlaylistEntity): Unit = withContext(IO) { playlistDao.insert(playlist) } + + override fun getPlaylist(playlistId: String): Flow = playlistDao.getPlaylist(playlistId) + private suspend fun addSongsToPlaylist(playlistId: String, songIds: List) { var maxId = playlistDao.getPlaylistMaxId(playlistId) ?: -1 playlistDao.insert(songIds.map { songId -> @@ -590,59 +471,59 @@ class SongRepository(private val context: Context) : LocalRepository { override suspend fun addToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { val songIds = items.flatMap { item -> when (item) { - is Song -> listOf(item).map { it.id } + is Song -> listOf(item.id) is Album -> getAlbumSongs(item.id).map { it.id } - is Artist -> getArtistSongs(item.id, SongSortInfoPreference).getList().map { it.id } + is Artist -> getArtistSongs(item.id, SongSortInfoPreference).first().map { it.id } is Playlist -> if (item.playlist.isLocalPlaylist) { - getPlaylistSongs(item.id).getList().map { it.id } + getPlaylistSongs(item.id).first().map { it.id } } else { - safeAddSongs(YouTube.browseAll(BrowseEndpoint(browseId = "VL" + item.id)).getOrThrow().filterIsInstance()).map { it.id } + emptyList() } } } addSongsToPlaylist(playlist.id, songIds) } - override suspend fun addYouTubeItemsToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { + override suspend fun addYTItemsToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { val songs = items.flatMap { item -> when (item) { - is SongItem -> YouTube.getQueue(videoIds = listOf(item.id)).getOrThrow() - is AlbumItem -> YouTube.browse(BrowseEndpoint(browseId = "VL" + item.playlistId)).getOrThrow().items.filterIsInstance() // consider refetch by [YouTube.getQueue] if needed + is SongItem -> listOf(item) + is AlbumItem -> YouTube.browseAlbum("VL${item.playlistId}").getOrThrow().songs is PlaylistItem -> YouTube.getQueue(playlistId = item.id).getOrThrow() is ArtistItem -> emptyList() } - } + }.map(SongItem::toMediaMetadata) addSongs(songs) addSongsToPlaylist(playlist.id, songs.map { it.id }) } - override suspend fun addMediaItemToPlaylist(playlist: PlaylistEntity, item: MediaMetadata) = withContext(IO) { - val song = YouTube.getQueue(videoIds = listOf(item.id)).getOrThrow() - addSongs(song) - addSongsToPlaylist(playlist.id, song.map { it.id }) + override suspend fun addMediaMetadataToPlaylist(playlist: PlaylistEntity, mediaMetadata: MediaMetadata) = withContext(IO) { + addSong(mediaMetadata) + addSongsToPlaylist(playlist.id, listOf(mediaMetadata.id)) } override suspend fun refetchPlaylists(playlists: List): Unit = withContext(IO) { - playlists.forEach { playlist -> - (YouTube.browse(BrowseEndpoint(browseId = "VL" + playlist.id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader)?.let { header -> - playlistDao.update(playlist.playlist.copy( - name = header.name, - author = header.artists?.firstOrNull()?.text, - authorId = header.artists?.firstOrNull()?.navigationEndpoint?.browseEndpoint?.browseId, - year = header.year, - thumbnailUrl = header.thumbnails.lastOrNull()?.url, - lastUpdateTime = LocalDateTime.now() - )) - } + playlists.filter { playlist -> + playlist.playlist.isYouTubePlaylist + }.forEach { playlist -> + val playlistPage = YouTube.browsePlaylist("VL${playlist.id}").getOrThrow() + playlistDao.update(PlaylistEntity( + id = playlistPage.playlist.id, + name = playlistPage.playlist.title, + author = playlistPage.playlist.author.name, + authorId = playlistPage.playlist.author.id, + thumbnailUrl = playlistPage.playlist.thumbnail, + lastUpdateTime = LocalDateTime.now() + )) } } override suspend fun downloadPlaylists(playlists: List) = withContext(IO) { downloadSongs(playlists .filter { it.playlist.isLocalPlaylist } - .flatMap { getPlaylistSongs(it.id).getList() } - .distinctBy { it.id } - .map { it.song }) + .flatMap { getPlaylistSongs(it.id).first() } + .map { it.id } + .distinct()) } override suspend fun getPlaylistById(playlistId: String): Playlist = withContext(IO) { @@ -704,10 +585,7 @@ class SongRepository(private val context: Context) : LocalRepository { /** * Format */ - override fun getSongFormat(songId: String?): DataWrapper = DataWrapper( - getValueAsync = { withContext(IO) { formatDao.getSongFormat(songId) } }, - getFlow = { formatDao.getSongFormatAsFlow(songId) } - ) + override fun getSongFormat(songId: String?) = formatDao.getSongFormatAsFlow(songId) override suspend fun upsert(format: FormatEntity) = withContext(IO) { formatDao.upsert(format) @@ -728,4 +606,8 @@ class SongRepository(private val context: Context) : LocalRepository { } override suspend fun deleteLyrics(songId: String) = lyricsDao.deleteLyrics(songId) -} \ No newline at end of file + + companion object { + const val PREVIEW_SIZE = 3 + } +} diff --git a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt b/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt deleted file mode 100644 index 48759950c..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/YouTubeRepository.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.zionhuang.music.repos - -import android.content.Context -import androidx.paging.PagingSource -import androidx.paging.PagingState -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.YouTube.EXPLORE_BROWSE_ID -import com.zionhuang.innertube.YouTube.HOME_BROWSE_ID -import com.zionhuang.innertube.models.* -import com.zionhuang.innertube.models.Icon.Companion.ICON_EXPLORE -import com.zionhuang.innertube.utils.plus -import com.zionhuang.music.R -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.toPage -import com.zionhuang.music.utils.InfoCache.checkCache -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext - -class YouTubeRepository(val context: Context) { - private val songRepository = SongRepository(context) - - fun searchAll(query: String) = object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>) = withContext(IO) { - try { - YouTube.searchAllType(query).getOrThrow().toPage() - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - - fun search(query: String, filter: YouTube.SearchFilter): PagingSource, YTBaseItem> = object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>) = withContext(IO) { - try { - if (params.key == null) { - YouTube.search(query, filter).getOrThrow() - } else { - YouTube.search(params.key!![0]).getOrThrow() - }.toPage() - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - - fun browse(endpoint: BrowseEndpoint): PagingSource, YTBaseItem> = object : PagingSource, YTBaseItem>() { - override suspend fun load(params: LoadParams>) = withContext(IO) { - try { - if (params.key == null) { - val browseResult = YouTube.browse(endpoint).getOrThrow() - if (endpoint.browseId == HOME_BROWSE_ID) { - // inject explore link - browseResult.copy( - items = NavigationItem( - title = getApplication().getString(R.string.title_explore), - icon = ICON_EXPLORE, - navigationEndpoint = NavigationEndpoint( - browseEndpoint = BrowseEndpoint(browseId = EXPLORE_BROWSE_ID) - ) - ) + browseResult.items - ) - } else if (endpoint.isArtistEndpoint && endpoint.params == null) { - // inject library artist songs preview - browseResult.copy( - items = browseResult.items.toMutableList().apply { - addAll(if (browseResult.items.firstOrNull() is ArtistHeader) 1 else 0, songRepository.getArtistSongsPreview(endpoint.browseId).getOrThrow()) - } - ) - } else { - browseResult - } - } else { - YouTube.browse(params.key!!).getOrThrow() - }.toPage() - } catch (e: Exception) { - LoadResult.Error(e) - } - } - - override fun getRefreshKey(state: PagingState, YTBaseItem>): List? = null - } - - suspend fun getSuggestions(query: String): SearchSuggestions = withContext(IO) { - checkCache("SU$query") { - YouTube.getSearchSuggestions(query).getOrThrow() - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt index 9a8a7d523..4b29e20f0 100644 --- a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt +++ b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt @@ -1,9 +1,9 @@ package com.zionhuang.music.repos.base -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.YTItem import com.zionhuang.music.db.entities.* -import com.zionhuang.music.models.DataWrapper -import com.zionhuang.music.models.ListWrapper import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.sortInfo.* import kotlinx.coroutines.flow.Flow @@ -18,27 +18,27 @@ interface LocalRepository { /** * Browse */ - fun getAllSongs(sortInfo: ISortInfo): ListWrapper + fun getAllSongs(sortInfo: ISortInfo): Flow> suspend fun getSongCount(): Int - fun getAllArtists(sortInfo: ISortInfo): ListWrapper + fun getAllArtists(sortInfo: ISortInfo): Flow> suspend fun getArtistCount(): Int - suspend fun getArtistSongsPreview(artistId: String): Result> - fun getArtistSongs(artistId: String, sortInfo: ISortInfo): ListWrapper + suspend fun getArtistSongsPreview(artistId: String): Flow> + fun getArtistSongs(artistId: String, sortInfo: ISortInfo): Flow> suspend fun getArtistSongCount(artistId: String): Int - fun getAllAlbums(sortInfo: ISortInfo): ListWrapper + fun getAllAlbums(sortInfo: ISortInfo): Flow> suspend fun getAlbumCount(): Int suspend fun getAlbumSongs(albumId: String): List - fun getAllPlaylists(sortInfo: ISortInfo): ListWrapper + fun getAllPlaylists(sortInfo: ISortInfo): Flow> - fun getPlaylistSongs(playlistId: String): ListWrapper + fun getPlaylistSongs(playlistId: String): Flow> - fun getLikedSongs(sortInfo: ISortInfo): ListWrapper + fun getLikedSongs(sortInfo: ISortInfo): Flow> fun getLikedSongCount(): Flow - fun getDownloadedSongs(sortInfo: ISortInfo): ListWrapper + fun getDownloadedSongs(sortInfo: ISortInfo): Flow> fun getDownloadedSongCount(): Flow /** @@ -54,20 +54,19 @@ interface LocalRepository { /** * Song */ - suspend fun addSong(mediaMetadata: MediaMetadata): SongEntity - suspend fun safeAddSong(song: SongItem) = safeAddSongs(listOf(song)) - suspend fun safeAddSongs(songs: List): List + suspend fun addSong(mediaMetadata: MediaMetadata) = addSongs(listOf(mediaMetadata)) + suspend fun addSongs(songs: List) suspend fun refetchSong(song: Song) = refetchSongs(listOf(song)) suspend fun refetchSongs(songs: List) fun getSongById(songId: String?): Flow fun getSongFile(songId: String): File - fun hasSong(songId: String): DataWrapper suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) + suspend fun updateSongDuration(songId: String, duration: Int) suspend fun updateSongTitle(song: Song, newTitle: String) suspend fun toggleLiked(song: Song) = toggleLiked(listOf(song)) suspend fun toggleLiked(songs: List) - suspend fun downloadSong(song: SongEntity) = downloadSongs(listOf(song)) - suspend fun downloadSongs(songs: List) + suspend fun downloadSong(songId: String) = downloadSongs(listOf(songId)) + suspend fun downloadSongs(songIds: List) suspend fun onDownloadComplete(downloadId: Long, success: Boolean) suspend fun validateDownloads() suspend fun removeDownloads(songs: List) @@ -91,26 +90,25 @@ interface LocalRepository { */ suspend fun addAlbum(album: AlbumItem) = addAlbums(listOf(album)) suspend fun addAlbums(albums: List) - suspend fun refetchAlbum(album: AlbumEntity) = refetchAlbums(listOf(album)) - suspend fun refetchAlbums(albums: List) suspend fun getAlbum(albumId: String): Album? + suspend fun getAlbumWithSongs(albumId: String): AlbumWithSongs? suspend fun deleteAlbum(albumId: String) suspend fun deleteAlbums(albums: List) /** * Playlist */ - fun getPlaylist(playlistId: String): Flow - suspend fun insertPlaylist(playlist: PlaylistEntity) suspend fun addPlaylist(playlist: PlaylistItem) = addPlaylists(listOf(playlist)) suspend fun addPlaylists(playlists: List) suspend fun importPlaylist(playlist: PlaylistItem) = importPlaylists(listOf(playlist)) suspend fun importPlaylists(playlists: List) + suspend fun insertPlaylist(playlist: PlaylistEntity) + fun getPlaylist(playlistId: String): Flow suspend fun addToPlaylist(playlist: PlaylistEntity, item: LocalItem) = addToPlaylist(playlist, listOf(item)) suspend fun addToPlaylist(playlist: PlaylistEntity, items: List) - suspend fun addYouTubeItemToPlaylist(playlist: PlaylistEntity, item: YTItem) = addYouTubeItemsToPlaylist(playlist, listOf(item)) - suspend fun addYouTubeItemsToPlaylist(playlist: PlaylistEntity, items: List) - suspend fun addMediaItemToPlaylist(playlist: PlaylistEntity, item: MediaMetadata) + suspend fun addYouTubeItemToPlaylist(playlist: PlaylistEntity, item: YTItem) = addYTItemsToPlaylist(playlist, listOf(item)) + suspend fun addYTItemsToPlaylist(playlist: PlaylistEntity, items: List) + suspend fun addMediaMetadataToPlaylist(playlist: PlaylistEntity, mediaMetadata: MediaMetadata) suspend fun refetchPlaylists(playlists: List) suspend fun downloadPlaylists(playlists: List) suspend fun getPlaylistById(playlistId: String): Playlist @@ -139,7 +137,7 @@ interface LocalRepository { /** * Format */ - fun getSongFormat(songId: String?): DataWrapper + fun getSongFormat(songId: String?): Flow suspend fun upsert(format: FormatEntity) /** @@ -149,4 +147,4 @@ interface LocalRepository { suspend fun hasLyrics(songId: String): Boolean suspend fun upsert(lyrics: LyricsEntity) suspend fun deleteLyrics(songId: String) -} \ No newline at end of file +} 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 096203406..e5e368dc0 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 @@ -1,9 +1,7 @@ package com.zionhuang.music.ui.component -import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.* import androidx.compose.animation.core.tween -import androidx.compose.animation.fadeIn -import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape @@ -17,11 +15,11 @@ import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.Shape import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource 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 @@ -71,10 +69,8 @@ inline fun ListItem( overflow = TextOverflow.Ellipsis ) - Row( - verticalAlignment = Alignment.CenterVertically - ) { - if (subtitle != null) { + if (subtitle != null) { + Row(verticalAlignment = Alignment.CenterVertically) { subtitle() } } @@ -88,7 +84,7 @@ inline fun ListItem( inline fun ListItem( modifier: Modifier = Modifier, title: String, - subtitle: String, + subtitle: String?, crossinline badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable () -> Unit, trailingContent: @Composable RowScope.() -> Unit = {}, @@ -97,13 +93,15 @@ inline fun ListItem( subtitle = { badges() - Text( - text = subtitle, - color = MaterialTheme.colorScheme.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + if (!subtitle.isNullOrEmpty()) { + Text( + text = subtitle, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } }, thumbnailContent = thumbnailContent, trailingContent = trailingContent, @@ -111,14 +109,14 @@ inline fun ListItem( ) @Composable -fun GridItem( +inline fun GridItem( + modifier: Modifier = Modifier, title: String, - subtitle: String, + noinline subtitle: (@Composable RowScope.() -> Unit)? = null, thumbnailContent: @Composable () -> Unit, - modifier: Modifier = Modifier, ) { Column( - modifier = modifier + modifier = modifier.padding(12.dp) ) { Box { thumbnailContent() @@ -134,88 +132,54 @@ fun GridItem( overflow = TextOverflow.Ellipsis ) - Text( - text = subtitle, - style = MaterialTheme.typography.bodyMedium, - color = MaterialTheme.colorScheme.secondary, - maxLines = 2, - overflow = TextOverflow.Ellipsis - ) + if (subtitle != null) { + Row(verticalAlignment = Alignment.CenterVertically) { + subtitle() + } + } } } @Composable -fun GridItem( +inline fun GridItem( + modifier: Modifier = Modifier, title: String, subtitle: String, - modifier: Modifier = Modifier, - thumbnailUrl: String? = null, - thumbnailRatio: Float = 1f, - thumbnailShape: Shape = CircleShape, - playingIndicator: Boolean = false, - playWhenReady: Boolean = false, + crossinline badges: @Composable RowScope.() -> Unit = {}, + thumbnailContent: @Composable () -> Unit, ) = GridItem( + modifier = modifier, title = title, - subtitle = subtitle, - thumbnailContent = { - AsyncImage( - model = thumbnailUrl, - contentDescription = null, - modifier = Modifier - .width(GridThumbnailSize * thumbnailRatio) - .height(GridThumbnailSize) - .clip(thumbnailShape) + subtitle = { + badges() + + Text( + text = subtitle, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) - AnimatedVisibility( - visible = playingIndicator, - enter = fadeIn(tween(500)), - exit = fadeOut(tween(500)) - ) { - Box( - contentAlignment = Alignment.Center, - modifier = Modifier - .width(GridThumbnailSize * thumbnailRatio) - .height(GridThumbnailSize) - .background( - color = Color.Black.copy(alpha = 0.4f), - shape = thumbnailShape - ) - ) { - if (playWhenReady) { - PlayingIndicator( - color = Color.White, - modifier = Modifier.height(24.dp) - ) - } else { - Icon( - painter = painterResource(R.drawable.ic_play), - contentDescription = null, - tint = Color.White - ) - } - } - } }, - modifier = modifier - .padding(12.dp) - .width(GridThumbnailSize * thumbnailRatio) + thumbnailContent = thumbnailContent, ) @Composable inline fun SongListItem( song: Song, modifier: Modifier = Modifier, + albumIndex: Int? = null, showBadges: Boolean = true, isPlaying: Boolean = false, playWhenReady: Boolean = false, trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = song.song.title, - subtitle = listOf( + subtitle = joinByBullet( song.artists.joinToString(), song.album?.title, makeTimeString(song.song.duration * 1000L) - ).joinByBullet(), + ), badges = { if (showBadges && song.song.liked) { Icon( @@ -229,26 +193,43 @@ inline fun SongListItem( } }, thumbnailContent = { - AsyncImage( - model = song.song.thumbnailUrl, - contentDescription = null, - placeholder = painterResource(R.drawable.ic_music_note), - error = painterResource(R.drawable.ic_music_note), - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - PlayingIndicatorBox( - isPlaying = isPlaying, - playWhenReady = playWhenReady, - modifier = Modifier - .size(ListThumbnailSize) - .background( - color = Color.Black.copy(alpha = 0.4f), - shape = RoundedCornerShape(ThumbnailCornerRadius) + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize) + ) { + if (albumIndex != null) { + AnimatedVisibility( + visible = !isPlaying, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) + } + } else { + AsyncImage( + model = song.song.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) - ) + } + + PlayingIndicatorBox( + isPlaying = isPlaying, + playWhenReady = playWhenReady, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = Modifier + .fillMaxSize() + .background( + color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = 0.4f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) + } }, trailingContent = trailingContent, modifier = modifier @@ -267,8 +248,6 @@ inline fun ArtistListItem( AsyncImage( model = artist.artist.thumbnailUrl, contentDescription = null, - placeholder = painterResource(R.drawable.ic_artist), - error = painterResource(R.drawable.ic_artist), modifier = Modifier .size(ListThumbnailSize) .clip(CircleShape) @@ -288,17 +267,15 @@ inline fun AlbumListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = album.album.title, - subtitle = listOf( + subtitle = joinByBullet( album.artists.joinToString(), pluralStringResource(R.plurals.song_count, album.album.songCount, album.album.songCount), album.album.year?.toString() - ).joinByBullet(), + ), thumbnailContent = { AsyncImage( model = album.album.thumbnailUrl, contentDescription = null, - placeholder = painterResource(R.drawable.ic_album), - error = painterResource(R.drawable.ic_album), modifier = Modifier .size(ListThumbnailSize) .clip(RoundedCornerShape(ThumbnailCornerRadius)) @@ -381,10 +358,10 @@ inline fun MediaMetadataListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = mediaMetadata.title, - subtitle = listOf( + subtitle = joinByBullet( mediaMetadata.artists.joinToString { it.name }, makeTimeString(mediaMetadata.duration * 1000L) - ).joinByBullet(), + ), thumbnailContent = { AsyncImage( model = mediaMetadata.thumbnailUrl, @@ -413,99 +390,162 @@ inline fun MediaMetadataListItem( inline fun YouTubeListItem( item: YTItem, modifier: Modifier = Modifier, + albumIndex: Int? = null, crossinline badges: @Composable RowScope.() -> Unit = {}, isPlaying: Boolean = false, playWhenReady: Boolean = false, trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = item.title, - subtitle = item.subtitle.orEmpty(), + subtitle = when (item) { + is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author.name, item.songCountText) + }, badges = badges, thumbnailContent = { - val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) - AsyncImage( - model = item.thumbnails.lastOrNull()?.url, - contentDescription = null, - modifier = Modifier - .size(ListThumbnailSize) - .clip(thumbnailShape) - ) - - PlayingIndicatorBox( - isPlaying = isPlaying, - playWhenReady = playWhenReady, - modifier = Modifier - .size(ListThumbnailSize) - .background( - color = Color.Black.copy(alpha = 0.4f), - shape = thumbnailShape + Box( + contentAlignment = Alignment.Center, + modifier = Modifier.size(ListThumbnailSize) + ) { + val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) + if (albumIndex != null) { + AnimatedVisibility( + visible = !isPlaying, + enter = fadeIn() + expandIn(expandFrom = Alignment.Center), + exit = shrinkOut(shrinkTowards = Alignment.Center) + fadeOut() + ) { + Text( + text = albumIndex.toString(), + style = MaterialTheme.typography.labelLarge + ) + } + } else { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = Modifier + .fillMaxSize() + .clip(thumbnailShape) ) - ) + } + + PlayingIndicatorBox( + isPlaying = isPlaying, + playWhenReady = playWhenReady, + color = if (albumIndex != null) MaterialTheme.colorScheme.onBackground else Color.White, + modifier = Modifier + .fillMaxSize() + .background( + color = if (albumIndex != null) Color.Transparent else Color.Black.copy(alpha = 0.4f), + shape = thumbnailShape + ) + ) + } }, trailingContent = trailingContent, modifier = modifier ) -@OptIn(ExperimentalComposeUiApi::class) @Composable -fun ArtistGridItem( - artist: Artist, +inline fun YouTubeGridItem( + item: YTItem, modifier: Modifier = Modifier, -) = GridItem( - title = artist.artist.name, - subtitle = pluralStringResource(R.plurals.song_count, artist.songCount, artist.songCount), - thumbnailUrl = artist.artist.thumbnailUrl, - thumbnailShape = CircleShape, - modifier = modifier -) + crossinline badges: @Composable RowScope.() -> Unit = {}, + isPlaying: Boolean = false, + playWhenReady: Boolean = false, + fillMaxWidth: Boolean = false, +) { + val thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius) + val thumbnailRatio = if (item is SongItem) 16f / 9 else 1f + + Column( + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight * thumbnailRatio) + } + ) { + Box( + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(thumbnailRatio) + .clip(thumbnailShape) + ) { + AsyncImage( + model = item.thumbnail, + contentDescription = null, + modifier = Modifier.fillMaxSize() + ) + androidx.compose.animation.AnimatedVisibility( + visible = isPlaying, + enter = fadeIn(tween(500)), + exit = fadeOut(tween(500)) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .fillMaxSize() + .background( + color = Color.Black.copy(alpha = 0.4f), + shape = thumbnailShape + ) + ) { + if (playWhenReady) { + PlayingIndicator( + color = Color.White, + modifier = Modifier.height(24.dp) + ) + } else { + Icon( + painter = painterResource(R.drawable.ic_play), + contentDescription = null, + tint = Color.White + ) + } + } + } + } -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun AlbumGridItem( - album: Album, - modifier: Modifier = Modifier, -) = GridItem( - title = album.album.title, - subtitle = listOf( - album.artists.joinToString(), - pluralStringResource(R.plurals.song_count, album.album.songCount, album.album.songCount), - album.album.year?.toString() - ).joinByBullet(), - thumbnailUrl = album.album.thumbnailUrl, - thumbnailShape = RoundedCornerShape(6.dp), - modifier = modifier -) + Spacer(modifier = Modifier.height(6.dp)) -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PlaylistGridItem( - playlist: Playlist, - modifier: Modifier = Modifier, -) = GridItem( - title = playlist.playlist.name, - subtitle = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), - thumbnailUrl = playlist.playlist.thumbnailUrl, - thumbnailShape = RoundedCornerShape(ThumbnailCornerRadius), - modifier = modifier -) + Text( + text = item.title, + style = MaterialTheme.typography.bodyLarge, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, + ) -@Composable -fun YouTubeGridItem( - item: YTItem, - modifier: Modifier = Modifier, - playingIndicator: Boolean = false, - playWhenReady: Boolean = false, -) = GridItem( - title = item.title, - subtitle = item.subtitle.orEmpty(), - thumbnailUrl = item.thumbnails.lastOrNull()?.url, - thumbnailRatio = item.thumbnails.lastOrNull()?.let { - if (it.width != null && it.height != null) it.width!!.toFloat() / it.height!! - else 1f - } ?: 1f, - thumbnailShape = if (item is ArtistItem) CircleShape else RoundedCornerShape(ThumbnailCornerRadius), - playingIndicator = playingIndicator, - playWhenReady = playWhenReady, - modifier = modifier -) \ No newline at end of file + Row(verticalAlignment = Alignment.CenterVertically) { + badges() + + val subtitle = when (item) { + is SongItem -> joinByBullet(item.artists.joinToString { it.name }, makeTimeString(item.duration?.times(1000L))) + is AlbumItem -> joinByBullet(item.artists?.joinToString { it.name }, item.year?.toString()) + is ArtistItem -> null + is PlaylistItem -> joinByBullet(item.author.name, item.songCountText) + } + + if (subtitle != null) { + Text( + text = subtitle, + style = MaterialTheme.typography.bodyMedium, + color = MaterialTheme.colorScheme.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt b/app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt index e73a6b3b7..0914bcce2 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt @@ -7,6 +7,7 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -23,6 +24,7 @@ fun NoResultFound() { Image( painter = painterResource(id = R.drawable.ic_search), contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), modifier = Modifier.size(64.dp) ) @@ -33,4 +35,4 @@ fun NoResultFound() { style = MaterialTheme.typography.bodyLarge ) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt b/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt index a22e3a49d..642ce2f5b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt @@ -76,9 +76,10 @@ fun PlayingIndicator( @Composable fun PlayingIndicatorBox( + modifier: Modifier = Modifier, isPlaying: Boolean, playWhenReady: Boolean, - modifier: Modifier = Modifier, + color: Color = Color.White, ) { AnimatedVisibility( visible = isPlaying, @@ -91,7 +92,7 @@ fun PlayingIndicatorBox( ) { if (playWhenReady) { PlayingIndicator( - color = Color.White, + color = color, modifier = Modifier.height(24.dp) ) } else { @@ -103,4 +104,4 @@ fun PlayingIndicatorBox( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt new file mode 100644 index 000000000..9ad6cd9bc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt @@ -0,0 +1,36 @@ +package com.zionhuang.music.ui.component.shimmer + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.dp +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.constants.ThumbnailCornerRadius + +@Composable +fun GridItemPlaceHolder( + modifier: Modifier = Modifier, + thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), +) { + Column( + modifier = modifier.padding(12.dp) + ) { + Spacer( + modifier = Modifier + .size(GridThumbnailHeight) + .clip(thumbnailShape) + .background(MaterialTheme.colorScheme.onSurface) + ) + + Spacer(modifier = Modifier.height(6.dp)) + + TextPlaceholder() + + TextPlaceholder() + } +} 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 5f0ba09c7..ad7bd9452 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 @@ -12,6 +12,7 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -23,7 +24,6 @@ import androidx.navigation.NavController import coil.compose.AsyncImage import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.R -import com.zionhuang.music.ui.component.* import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ListThumbnailSize import com.zionhuang.music.db.entities.Playlist @@ -35,6 +35,7 @@ import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.ui.component.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -68,7 +69,7 @@ fun SongMenu( } LaunchedEffect(Unit) { - SongRepository(context).getAllPlaylists(PlaylistSortInfoPreference).flow.collect { + SongRepository(context).getAllPlaylists(PlaylistSortInfoPreference).collect { playlists = it } } @@ -84,6 +85,7 @@ fun SongMenu( Image( painter = painterResource(R.drawable.ic_add), contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), modifier = Modifier.size(ListThumbnailSize) ) }, @@ -151,6 +153,7 @@ fun SongMenu( contentDescription = null, placeholder = painterResource(R.drawable.ic_artist), error = painterResource(R.drawable.ic_artist), + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), modifier = Modifier .size(ListThumbnailSize) .clip(CircleShape) diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt index e6316ab3d..efb5bcae9 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt @@ -21,9 +21,6 @@ import com.zionhuang.innertube.models.ArtistItem import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.R -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.constants.ListItemHeight import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.MediaMetadata @@ -31,6 +28,9 @@ import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.repos.SongRepository +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.viewmodels.MainViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -52,8 +52,8 @@ fun YouTubeSongMenu( } val artists = remember { song.artists.mapNotNull { - it.navigationEndpoint?.browseEndpoint?.browseId?.let { artistId -> - MediaMetadata.Artist(id = artistId, name = it.text) + it.id?.let { artistId -> + MediaMetadata.Artist(id = artistId, name = it.name) } } } @@ -66,10 +66,7 @@ fun YouTubeSongMenu( ListDialog( onDismiss = { showSelectArtistDialog = false } ) { - items( - items = artists, - key = { it.id } - ) { artist -> + items(artists) { artist -> Row( verticalAlignment = Alignment.CenterVertically, modifier = Modifier @@ -150,7 +147,7 @@ fun YouTubeSongMenu( title = R.string.action_add_to_library ) { coroutineScope.launch { - songRepository.safeAddSong(song) + songRepository.addSong(song.toMediaMetadata()) } } } @@ -186,7 +183,7 @@ fun YouTubeSongMenu( icon = R.drawable.ic_album, title = R.string.menu_view_album ) { - navController.navigate("album/${album.navigationEndpoint.browseId}") + navController.navigate("album/${album.id}") onDismiss() } } @@ -221,6 +218,54 @@ fun YouTubeAlbumMenu( album.id in libraryAlbumIds } + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items( + items = album.artists.orEmpty(), + key = { it.id!! } + ) { artist -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + onDismiss() + } + .padding(horizontal = 12.dp), + ) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + showSelectArtistDialog = false + onDismiss() + navController.navigate("artist/${artist.id}") + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + } + GridMenu( contentPadding = PaddingValues( start = 8.dp, @@ -229,14 +274,15 @@ fun YouTubeAlbumMenu( bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() ) ) { - album.menu.radioEndpoint?.watchPlaylistEndpoint?.toWatchEndpoint()?.let { watchEndpoint -> - GridMenuItem( - icon = R.drawable.ic_radio, - title = R.string.menu_start_radio - ) { - playerConnection.playQueue(YouTubeQueue(watchEndpoint)) - onDismiss() - } + GridMenuItem( + icon = R.drawable.ic_radio, + title = R.string.menu_start_radio + ) { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint( + playlistId = album.playlistId, + params = "wAEB" + ))) + onDismiss() } GridMenuItem( icon = R.drawable.ic_playlist_play, @@ -286,13 +332,17 @@ fun YouTubeAlbumMenu( ) { } - album.menu.artistEndpoint?.browseEndpoint?.let { browseEndpoint -> + album.artists?.let { artists -> GridMenuItem( icon = R.drawable.ic_artist, title = R.string.menu_view_artist ) { - navController.navigate("artist/${browseEndpoint.browseId}") - onDismiss() + if (artists.size == 1) { + navController.navigate("artist/${artists[0].id}") + onDismiss() + } else { + showSelectArtistDialog = true + } } } GridMenuItem( @@ -326,7 +376,7 @@ fun YouTubeArtistMenu( bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() ) ) { - artist.menu.radioEndpoint?.watchPlaylistEndpoint?.toWatchEndpoint()?.let { watchEndpoint -> + artist.radioEndpoint?.let { watchEndpoint -> GridMenuItem( icon = R.drawable.ic_radio, title = R.string.menu_start_radio @@ -335,7 +385,7 @@ fun YouTubeArtistMenu( onDismiss() } } - artist.menu.shuffleEndpoint?.watchPlaylistEndpoint?.toWatchEndpoint()?.let { watchEndpoint -> + artist.shuffleEndpoint?.let { watchEndpoint -> GridMenuItem( icon = R.drawable.ic_shuffle, title = R.string.btn_shuffle @@ -357,4 +407,4 @@ fun YouTubeArtistMenu( onDismiss() } } -} \ No newline at end of file +} 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 619a094c7..5a7c890e9 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 @@ -27,13 +27,13 @@ import com.google.android.exoplayer2.C import com.google.android.exoplayer2.Player.* import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R +import com.zionhuang.music.constants.QueuePeekHeight +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.ui.component.BottomSheet import com.zionhuang.music.ui.component.BottomSheetState import com.zionhuang.music.ui.component.ResizableIconButton import com.zionhuang.music.ui.component.rememberBottomSheetState -import com.zionhuang.music.constants.QueuePeekHeight -import com.zionhuang.music.extensions.togglePlayPause -import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.utils.makeTimeString import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -58,7 +58,7 @@ fun BottomSheetPlayer( var position by rememberSaveable(playbackState) { mutableStateOf(playerConnection.player.currentPosition) } - val duration by rememberSaveable(playbackState) { + var duration by rememberSaveable(playbackState) { mutableStateOf(playerConnection.player.duration) } var sliderPosition by remember { @@ -70,6 +70,7 @@ fun BottomSheetPlayer( while (isActive) { delay(500) position = playerConnection.player.currentPosition + duration = playerConnection.player.duration } } } @@ -121,7 +122,7 @@ fun BottomSheetPlayer( style = MaterialTheme.typography.titleMedium, color = MaterialTheme.colorScheme.secondary, maxLines = 1, - modifier = Modifier.clickable { + modifier = Modifier.clickable(enabled = artist.id != null) { navController.navigate("artist/${artist.id}") state.collapseSoft() } diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index b5cf2a8e8..2a5110268 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -71,7 +71,7 @@ fun Queue( val currentSong by playerConnection.currentSong.collectAsState(initial = null) val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) - var showLyrics by mutablePreferenceState(SHOW_LYRICS, defaultValue = false) + val (showLyrics, onShowLyricsChange) = mutablePreferenceState(SHOW_LYRICS, defaultValue = false) var showDetailsDialog by rememberSaveable { mutableStateOf(false) @@ -157,7 +157,7 @@ fun Queue( contentDescription = null ) } - IconButton(onClick = { showLyrics = !showLyrics }) { + IconButton(onClick = { onShowLyricsChange(!showLyrics) }) { Icon( painter = painterResource(R.drawable.ic_lyrics), contentDescription = null, @@ -248,10 +248,10 @@ fun Queue( modifier = Modifier.weight(1f) ) Text( - text = listOf( + text = joinByBullet( makeTimeString(queueLength * 1000L), pluralStringResource(R.plurals.song_count, queueItems.size, queueItems.size) - ).joinByBullet(), + ), style = MaterialTheme.typography.bodyMedium ) } @@ -320,10 +320,7 @@ fun PlayerMenu( ListDialog( onDismiss = { showSelectArtistDialog = false } ) { - items( - items = mediaMetadata.artists, - key = { it.id } - ) { artist -> + items(mediaMetadata.artists) { artist -> Box( contentAlignment = Alignment.CenterStart, modifier = Modifier 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 843ec8c04..954e84edb 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 @@ -7,239 +7,344 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi 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.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.compose.ui.util.fastForEachIndexed import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController import coil.compose.AsyncImage 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.extensions.toMediaItem +import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.component.AutoResizeText import com.zionhuang.music.ui.component.FontSizeRange 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.constants.AlbumThumbnailSize -import com.zionhuang.music.constants.CONTENT_TYPE_SHIMMER -import com.zionhuang.music.constants.CONTENT_TYPE_SONG -import com.zionhuang.music.constants.ThumbnailCornerRadius -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.utils.joinByBullet -import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.viewmodels.AlbumViewModel +import com.zionhuang.music.viewmodels.AlbumViewState -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class) @Composable fun AlbumScreen( - albumId: String, - playlistId: String?, - viewModel: AlbumViewModel = viewModel(factory = AlbumViewModel.Factory( - context = LocalContext.current, - albumId = albumId, - playlistId = playlistId - )), + navController: NavController, + viewModel: AlbumViewModel = viewModel(), ) { val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - val albumWithSongsState = viewModel.albumWithSongs.collectAsState() - val albumWithSongs = remember(albumWithSongsState.value) { - albumWithSongsState.value - } + val viewState by viewModel.viewState.collectAsState() LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { - if (albumWithSongs != null) { - item( - key = "header" - ) { - Column( - modifier = Modifier - .padding(12.dp) - .animateItemPlacement() - ) { - Row( - verticalAlignment = Alignment.CenterVertically - ) { - AsyncImage( - model = albumWithSongs.album.thumbnailUrl, - contentDescription = null, + viewState.let { viewState -> + when (viewState) { + is AlbumViewState.Local -> { + item { + AlbumHeader( + title = viewState.albumWithSongs.album.title, + artists = { + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal + ).toSpanStyle() + ) { + viewState.albumWithSongs.artists.fastForEachIndexed { index, artist -> + pushStringAnnotation(artist.id, artist.name) + append(artist.name) + pop() + if (index != viewState.albumWithSongs.artists.lastIndex) { + append(", ") + } + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + }, + year = viewState.albumWithSongs.album.year, + thumbnail = viewState.albumWithSongs.album.thumbnailUrl, + onPlay = { + playerConnection.playQueue(ListQueue( + title = viewState.albumWithSongs.album.title, + items = viewState.albumWithSongs.songs.map { it.toMediaItem() } + )) + }, + onShuffle = { + playerConnection.playQueue(ListQueue( + title = viewState.albumWithSongs.album.title, + items = viewState.albumWithSongs.songs.shuffled().map { it.toMediaItem() } + )) + } + ) + } + + itemsIndexed( + items = viewState.albumWithSongs.songs, + key = { _, song -> song.id } + ) { index, song -> + SongListItem( + song = song, + albumIndex = index + 1, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, modifier = Modifier - .size(AlbumThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(ListQueue( + title = viewState.albumWithSongs.album.title, + items = viewState.albumWithSongs.songs.map { it.toMediaItem() }, + startIndex = index + )) + } + ) + } + } + is AlbumViewState.Remote -> { + item { + AlbumHeader( + title = viewState.albumPage.album.title, + artists = { + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal + ).toSpanStyle() + ) { + viewState.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 != viewState.albumPage.album.artists?.lastIndex) { + append(", ") + } + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + }, + year = viewState.albumPage.album.year, + thumbnail = viewState.albumPage.album.thumbnail, + onPlay = { + playerConnection.playQueue(ListQueue( + title = viewState.albumPage.album.title, + items = viewState.albumPage.songs.map { it.toMediaItem() } + )) + }, + onShuffle = { + playerConnection.playQueue(ListQueue( + title = viewState.albumPage.album.title, + items = viewState.albumPage.songs.shuffled().map { it.toMediaItem() } + )) + } ) + } - 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) - ) - Text( - text = listOf( - albumWithSongs.artists.joinToString { it.name }, - albumWithSongs.album.year.toString() - ).joinByBullet(), - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Normal - ) - Text( - text = listOf( - pluralStringResource(R.plurals.song_count, albumWithSongs.album.songCount, albumWithSongs.album.songCount), - makeTimeString(albumWithSongs.album.duration * 1000L) - ).joinByBullet(), - style = MaterialTheme.typography.titleSmall - ) - Row { - IconButton(onClick = { /*TODO*/ }) { + itemsIndexed( + items = viewState.albumPage.songs, + key = { _, song -> song.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + YouTubeListItem( + item = song, + albumIndex = index + 1, + badges = { + if (song.explicit) { Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) ) } - } - } + }, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + 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.height(12.dp)) + Spacer(Modifier.width(16.dp)) - Row { - Button( - onClick = { - playerConnection.playQueue(ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.map { it.toMediaItem() } - )) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_play), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.btn_play) - ) - } + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } + } - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.padding(8.dp)) - OutlinedButton( - onClick = { - playerConnection.playQueue(ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.shuffled().map { it.toMediaItem() } - )) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_shuffle)) + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } } } } } + } + } +} - itemsIndexed( - items = albumWithSongs.songs, - key = { _, song -> song.id }, - contentType = { _, _ -> CONTENT_TYPE_SONG } - ) { index, song -> - SongListItem( - song = song, - isPlaying = song.id == mediaMetadata?.id, - playWhenReady = playWhenReady, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - playerConnection.playQueue(ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.map { it.toMediaItem() }, - startIndex = index - )) - } - .animateItemPlacement() - ) - } - } else { - item( - key = "shimmer", - contentType = CONTENT_TYPE_SHIMMER - ) { - ShimmerHost( - modifier = Modifier.animateItemPlacement() - ) { - 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() - } - } +@Composable +inline fun AlbumHeader( + title: String, + artists: @Composable () -> Unit, + year: Int? = null, + thumbnail: String?, + noinline onPlay: () -> Unit, + noinline onShuffle: () -> Unit, +) { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = thumbnail, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) - Spacer(Modifier.padding(8.dp)) + Spacer(Modifier.width(16.dp)) - Row { - ButtonPlaceholder(Modifier.weight(1f)) + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = title, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) - Spacer(Modifier.width(12.dp)) + artists() - ButtonPlaceholder(Modifier.weight(1f)) - } - } + year?.let { year -> + Text( + text = year.toString(), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } - repeat(6) { - ListItemPlaceHolder() + Row { + IconButton(onClick = { /*TODO*/ }) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null + ) } } } } + + Spacer(Modifier.height(12.dp)) + + Row { + Button( + onClick = onPlay, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.btn_play) + ) + } + + Spacer(Modifier.width(12.dp)) + + OutlinedButton( + onClick = onShuffle, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_shuffle)) + } + } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt new file mode 100644 index 000000000..0911ae9fc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt @@ -0,0 +1,301 @@ +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.layout.asPaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +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.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.* +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.AppBarConfig +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.component.YouTubeListItem +import com.zionhuang.music.ui.component.shimmer.GridItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.menu.YouTubeArtistMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.ArtistItemsViewModel +import com.zionhuang.music.viewmodels.MainViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun ArtistItemsScreen( + navController: NavController, + appBarConfig: AppBarConfig, + viewModel: ArtistItemsViewModel = viewModel(), + mainViewModel: MainViewModel = viewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val librarySongIds by mainViewModel.librarySongIds.collectAsState() + val likedSongIds by mainViewModel.likedSongIds.collectAsState() + val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() + val libraryPlaylistIds by mainViewModel.libraryPlaylistIds.collectAsState() + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + val title by viewModel.title.collectAsState() + val itemsPage by viewModel.itemsPage.collectAsState() + + LaunchedEffect(title) { + appBarConfig.title = { + Text( + text = title, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + if (itemsPage?.items?.firstOrNull() is AlbumItem) { + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + badges = { + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> {} + } + }, + onLongClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> {} + } + } + } + ) + ) + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(4) { + GridItemPlaceHolder() + } + } + } + } + } + } else { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { item -> + YouTubeListItem( + item = item, + badges = { + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> {} + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> {} + } + } + ) + } + + if (itemsPage == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt index dba6e6b44..a10255c37 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt @@ -10,7 +10,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* 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.font.FontWeight @@ -18,6 +17,7 @@ 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.util.fastForEach import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import coil.compose.AsyncImage @@ -26,6 +26,9 @@ import com.zionhuang.innertube.models.* import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R +import com.zionhuang.music.constants.AppBarHeight +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.component.shimmer.ButtonPlaceholder import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder @@ -36,20 +39,16 @@ import com.zionhuang.music.ui.menu.YouTubeArtistMenu import com.zionhuang.music.ui.menu.YouTubeSongMenu import com.zionhuang.music.ui.utils.fadingEdge import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.constants.AppBarHeight -import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.viewmodels.ArtistViewModel +import com.zionhuang.music.viewmodels.MainViewModel @OptIn(ExperimentalFoundationApi::class) @Composable fun ArtistScreen( - artistId: String, navController: NavController, appBarConfig: AppBarConfig, - viewModel: ArtistViewModel = viewModel(factory = ArtistViewModel.Factory( - context = LocalContext.current, - artistId = artistId - )), + viewModel: ArtistViewModel = viewModel(), + mainViewModel: MainViewModel = viewModel(), ) { val menuState = LocalMenuState.current val coroutineScope = rememberCoroutineScope() @@ -57,11 +56,12 @@ fun ArtistScreen( val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - val artistHeaderState = viewModel.artistHeader.collectAsState() - val artistHeader = remember(artistHeaderState.value) { - artistHeaderState.value - } - val content by viewModel.content.collectAsState() + val librarySongIds by mainViewModel.librarySongIds.collectAsState() + val likedSongIds by mainViewModel.likedSongIds.collectAsState() + val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() + val libraryPlaylistIds by mainViewModel.libraryPlaylistIds.collectAsState() + + val artistPage = viewModel.artistPage val lazyListState = rememberLazyListState() @@ -73,10 +73,10 @@ fun ArtistScreen( LaunchedEffect(transparentAppBar) { appBarConfig.transparentBackground = transparentAppBar } - LaunchedEffect(artistHeader) { + LaunchedEffect(artistPage) { appBarConfig.title = { Text( - text = if (!transparentAppBar) artistHeader?.name.orEmpty() else "", + text = if (!transparentAppBar) artistPage?.artist?.title.orEmpty() else "", style = MaterialTheme.typography.titleLarge, maxLines = 1, overflow = TextOverflow.Ellipsis, @@ -91,279 +91,302 @@ fun ArtistScreen( .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) .asPaddingValues() ) { - if (artistHeader != null) { - item(key = "header") { - Column { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3) - ) { - AsyncImage( - model = artistHeader.bannerThumbnails?.lastOrNull()?.url?.resize(1200, 900), - contentDescription = null, - modifier = Modifier.fadingEdge( - top = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() + AppBarHeight, - bottom = 64.dp - ) - ) - AutoResizeText( - text = artistHeader.name, - style = MaterialTheme.typography.displayLarge, - fontSizeRange = FontSizeRange(36.sp, 58.sp), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, + artistPage.let { + if (artistPage != null) { + item(key = "header") { + Column { + Box( modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 48.dp) - ) - } - - Row( - modifier = Modifier.padding(12.dp) - ) { - artistHeader.shuffleEndpoint?.watchEndpoint?.let { shuffleEndpoint -> - Button( - onClick = { - playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.btn_shuffle) + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + AsyncImage( + model = artistPage.artist.thumbnail.resize(1200, 900), + contentDescription = null, + modifier = Modifier.fadingEdge( + top = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() + AppBarHeight, + bottom = 64.dp ) - } + ) + AutoResizeText( + text = artistPage.artist.title, + style = MaterialTheme.typography.displayLarge, + fontSizeRange = FontSizeRange(36.sp, 58.sp), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) } - if (artistHeader.shuffleEndpoint != null && artistHeader.radioEndpoint != null) { - Spacer(Modifier.width(12.dp)) - } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.btn_shuffle) + ) + } + } - artistHeader.radioEndpoint?.watchEndpoint?.let { radioEndpoint -> - OutlinedButton( - onClick = { - playerConnection.playQueue(YouTubeQueue(radioEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_radio), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_radio)) + artistPage.artist.radioEndpoint?.let { radioEndpoint -> + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_radio)) + } } } } } - } - } - - items( - items = content, - key = { it.id } - ) { item -> - when (item) { - is Header -> { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = item.moreNavigationEndpoint != null) { - } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) + artistPage.sections.fastForEach { section -> + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = section.moreEndpoint != null) { + navController.navigate("artistItems/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + } + .padding(12.dp) ) { - Text( - text = item.title, - style = MaterialTheme.typography.headlineMedium - ) - if (item.subtitle != null) { + Column( + modifier = Modifier.weight(1f) + ) { Text( - text = item.subtitle!!, - style = MaterialTheme.typography.titleSmall + text = section.title, + style = MaterialTheme.typography.headlineMedium ) } - } - if (item.moreNavigationEndpoint != null) { - Image( - painter = painterResource(R.drawable.ic_navigate_next), - contentDescription = null - ) - } - } - } - is YTItem -> { - YouTubeListItem( - item = item, - trailingContent = { - IconButton( - onClick = { - menuState.show { - when (item) { - is SongItem -> YouTubeSongMenu( - song = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is AlbumItem -> YouTubeAlbumMenu( - album = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is ArtistItem -> YouTubeArtistMenu( - artist = item, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) - is PlaylistItem -> {} - } - } - } - ) { + if (section.moreEndpoint != null) { Icon( - painter = painterResource(R.drawable.ic_more_vert), + painter = painterResource(R.drawable.ic_navigate_next), contentDescription = null ) } - }, - modifier = Modifier - .clickable { - when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id))) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} - } - } - ) - } - is CarouselSection -> { - LazyRow { - items(item.items) { item -> - if (item is YTItem) { - YouTubeGridItem( - item = item, - playingIndicator = when (item) { - is SongItem -> mediaMetadata?.id == item.id - is AlbumItem -> mediaMetadata?.album?.id == item.id - else -> false - }, - playWhenReady = playWhenReady, - modifier = Modifier - .combinedClickable( - onClick = { - when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id))) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} - } - }, - onLongClick = { - menuState.show { + } + } + + if ((section.items.firstOrNull() as? SongItem)?.album != null) { + items( + items = section.items, + key = { it.id } + ) { song -> + YouTubeListItem( + item = song as SongItem, + badges = { + if (song.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in librarySongIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == song.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) + } + } else { + item { + LazyRow { + items( + items = section.items, + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + badges = { + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, + modifier = Modifier + .combinedClickable( + onClick = { when (item) { - is SongItem -> YouTubeSongMenu( - song = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is AlbumItem -> YouTubeAlbumMenu( - album = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is ArtistItem -> YouTubeArtistMenu( - artist = item, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) + is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is ArtistItem -> navController.navigate("artist/${item.id}") is PlaylistItem -> {} } + }, + onLongClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> {} + } + } } - } - ) - ) + ) + .animateItemPlacement() + ) + } } } } } - is DescriptionSection -> { - Text( - text = item.description, - style = MaterialTheme.typography.bodyMedium, - modifier = Modifier - .padding(12.dp) - ) - } - else -> {} - } - } - - if (artistHeader == null) { - item(key = "shimmer") { - ShimmerHost { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3) - ) { - Spacer( + } else { + item(key = "shimmer") { + ShimmerHost { + Box( modifier = Modifier - .shimmer() - .background(MaterialTheme.colorScheme.onSurface) - .fadingEdge( - top = WindowInsets.systemBars - .asPaddingValues() - .calculateTopPadding() + AppBarHeight, - bottom = 108.dp - ) - ) - TextPlaceholder( - height = 56.dp, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 48.dp) - ) - } + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + Spacer( + modifier = Modifier + .shimmer() + .background(MaterialTheme.colorScheme.onSurface) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 108.dp + ) + ) + TextPlaceholder( + height = 56.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } - Row( - modifier = Modifier.padding(12.dp) - ) { - ButtonPlaceholder(Modifier.weight(1f)) + Row( + modifier = Modifier.padding(12.dp) + ) { + ButtonPlaceholder(Modifier.weight(1f)) - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(12.dp)) - ButtonPlaceholder(Modifier.weight(1f)) - } + ButtonPlaceholder(Modifier.weight(1f)) + } - repeat(6) { - ListItemPlaceHolder() + repeat(6) { + ListItemPlaceHolder() + } } } } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt index d25683925..7510bcd59 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt @@ -5,24 +5,19 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* 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.* -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.* 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.unit.dp import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController -import androidx.paging.LoadState -import androidx.paging.compose.collectAsLazyPagingItems -import androidx.paging.compose.items import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST @@ -38,7 +33,6 @@ import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.SearchFilterHeight import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.YouTubeRepository import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.NoResultFound import com.zionhuang.music.ui.component.YouTubeListItem @@ -47,20 +41,15 @@ import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.menu.YouTubeAlbumMenu import com.zionhuang.music.ui.menu.YouTubeArtistMenu import com.zionhuang.music.ui.menu.YouTubeSongMenu -import com.zionhuang.music.ui.utils.rememberLazyListState import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlineSearchViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @Composable fun OnlineSearchResult( - query: String, navController: NavController, - viewModel: OnlineSearchViewModel = viewModel(factory = OnlineSearchViewModel.Factory( - repository = YouTubeRepository(LocalContext.current), - query = query - )), + viewModel: OnlineSearchViewModel = viewModel(), mainViewModel: MainViewModel = viewModel(), ) { val menuState = LocalMenuState.current @@ -68,25 +57,133 @@ fun OnlineSearchResult( val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - val coroutineScope = rememberCoroutineScope() - val lazyListState = rememberLazyListState() - val items = viewModel.pagingData.collectAsLazyPagingItems() - val searchFilter by viewModel.filter.collectAsState() val librarySongIds by mainViewModel.librarySongIds.collectAsState() val likedSongIds by mainViewModel.likedSongIds.collectAsState() val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() val libraryPlaylistIds by mainViewModel.libraryPlaylistIds.collectAsState() + val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() + + val searchFilter by viewModel.filter.collectAsState() + val searchSummary = viewModel.summaryPage + val itemsPage by remember(searchFilter) { + derivedStateOf { + searchFilter?.value?.let { + viewModel.viewStateMap[it] + } + } + } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + val ytItemContent: @Composable LazyItemScope.(YTItem) -> Unit = { item: YTItem -> + YouTubeListItem( + item = item, + badges = { + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> {} + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> {} + } + } + .animateItemPlacement() + ) + } + LazyColumn( - state = items.rememberLazyListState(), + state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current .add(WindowInsets(top = SearchFilterHeight)) .asPaddingValues() ) { - if (items.loadState.refresh !is LoadState.Loading) { - items(items) { item -> - when (item) { - is Header -> Box( + if (searchFilter == null) { + searchSummary?.summaries?.forEach { summary -> + item { + Box( contentAlignment = Alignment.CenterStart, modifier = Modifier .fillMaxWidth() @@ -95,108 +192,53 @@ fun OnlineSearchResult( .animateItemPlacement() ) { Text( - text = item.title, + text = summary.title, style = MaterialTheme.typography.headlineMedium, maxLines = 1 ) } - is YTItem -> YouTubeListItem( - item = item, - badges = { - if (item is SongItem && item.id in librarySongIds || - item is AlbumItem && item.id in libraryAlbumIds || - item is PlaylistItem && item.id in libraryPlaylistIds - ) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (item is SongItem && item.id in likedSongIds) { - Icon( - painter = painterResource(R.drawable.ic_favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = when (item) { - is SongItem -> mediaMetadata?.id == item.id - is AlbumItem -> mediaMetadata?.album?.id == item.id - else -> false - }, - playWhenReady = playWhenReady, - trailingContent = { - IconButton( - onClick = { - menuState.show { - when (item) { - is SongItem -> YouTubeSongMenu( - song = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is AlbumItem -> YouTubeAlbumMenu( - album = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is ArtistItem -> YouTubeArtistMenu( - artist = item, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) - is PlaylistItem -> {} - } - } - } - ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .clickable { - when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} - } - } - .animateItemPlacement() - ) - else -> {} } + + items( + items = summary.items, + key = { it.id }, + itemContent = ytItemContent + ) } - if (items.itemCount == 0) { + if (searchSummary?.summaries?.isEmpty() == true) { + item { + NoResultFound() + } + } + } else { + items( + items = itemsPage?.items.orEmpty(), + key = { it.id }, + itemContent = ytItemContent + ) + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + + if (itemsPage?.items?.isEmpty() == true) { item { NoResultFound() } } } - if (items.loadState.refresh is LoadState.Loading || items.loadState.append is LoadState.Loading) { + if (searchFilter == null && searchSummary == null || searchFilter != null && itemsPage == null) { item { ShimmerHost { - repeat(when { - items.loadState.refresh is LoadState.Loading -> 8 - items.loadState.append is LoadState.Loading -> 3 - else -> 0 - }) { + repeat(8) { ListItemPlaceHolder() } } @@ -228,17 +270,15 @@ fun OnlineSearchResult( selected = searchFilter == filter, colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), onClick = { - if (viewModel.filter.value == filter) { - coroutineScope.launch { - lazyListState.animateScrollToItem(0) - } - } else { + if (viewModel.filter.value != filter) { viewModel.filter.value = filter - items.refresh() + } + coroutineScope.launch { + lazyListState.animateScrollToItem(0) } } ) Spacer(Modifier.width(8.dp)) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt index c1b1ff26a..bf9aa3608 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt @@ -1,18 +1,12 @@ package com.zionhuang.music.ui.screens +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn 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.Text +import androidx.compose.material3.* import androidx.compose.runtime.* -import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha @@ -22,6 +16,7 @@ import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.zionhuang.innertube.models.* import com.zionhuang.music.LocalPlayerConnection @@ -30,11 +25,12 @@ import com.zionhuang.music.constants.SuggestionItemHeight import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.repos.YouTubeRepository import com.zionhuang.music.ui.component.YouTubeListItem -import kotlinx.coroutines.delay +import com.zionhuang.music.viewmodels.MainViewModel +import com.zionhuang.music.viewmodels.OnlineSearchSuggestionViewModel import kotlinx.coroutines.launch +@OptIn(ExperimentalFoundationApi::class) @Composable fun OnlineSearchScreen( query: String, @@ -42,42 +38,29 @@ fun OnlineSearchScreen( navController: NavController, onSearch: (String) -> Unit, onDismiss: () -> Unit, + viewModel: OnlineSearchSuggestionViewModel = viewModel(), + mainViewModel: MainViewModel = viewModel(), ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() - val playerConnection = LocalPlayerConnection.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - var history by rememberSaveable { - mutableStateOf(emptyList()) - } - var suggestions by rememberSaveable { - mutableStateOf(emptyList()) - } - var items by rememberSaveable { - mutableStateOf(emptyList()) - } + val librarySongIds by mainViewModel.librarySongIds.collectAsState() + val likedSongIds by mainViewModel.likedSongIds.collectAsState() + val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() + val libraryPlaylistIds by mainViewModel.libraryPlaylistIds.collectAsState() + + val viewState by viewModel.viewState.collectAsState() LaunchedEffect(query) { - if (query.isEmpty()) { - delay(200) - SongRepository(context).getAllSearchHistory().collect { list -> - history = list.map { it.query } - suggestions = emptyList() - items = emptyList() - } - } else { - val result = YouTubeRepository(context).getSuggestions(query) - SongRepository(context).getSearchHistory(query).collect { list -> - history = list.map { it.query }.take(3) - suggestions = result.queries.filter { it !in history } - items = result.recommendedItems - } - } + viewModel.query.value = query } LazyColumn { items( - items = history, + items = viewState.history, key = { it } ) { query -> SuggestionItem( @@ -97,12 +80,13 @@ fun OnlineSearchScreen( text = query, selection = TextRange(query.length) )) - } + }, + modifier = Modifier.animateItemPlacement() ) } items( - items = suggestions, + items = viewState.suggestions, key = { it } ) { query -> SuggestionItem( @@ -117,27 +101,67 @@ fun OnlineSearchScreen( text = query, selection = TextRange(query.length) )) - } + }, + modifier = Modifier.animateItemPlacement() ) } - if (items.isNotEmpty() && history.size + suggestions.size > 0) { + if (viewState.items.isNotEmpty() && viewState.history.size + viewState.suggestions.size > 0) { item { Divider() } } items( - items = items, + items = viewState.items, key = { it.id } ) { item -> YouTubeListItem( item = item, + badges = { + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, modifier = Modifier .clickable { when (item) { is SongItem -> { - playerConnection?.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) onDismiss() } is AlbumItem -> { @@ -151,6 +175,7 @@ fun OnlineSearchScreen( is PlaylistItem -> {} } } + .animateItemPlacement() ) } } @@ -211,4 +236,4 @@ fun SuggestionItem( ) } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt index 280b09f5c..78c0179a8 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt @@ -26,6 +26,10 @@ sealed class Screen( } fun defaultAppBarConfig() = AppBarConfig( + searchable = false +) + +fun searchAppBarConfig() = AppBarConfig( isRootDestination = true, title = { Text( @@ -87,4 +91,4 @@ fun settingsAppBarConfig(route: String) = AppBarConfig( ) }, searchable = false -) \ No newline at end of file +) 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 e845f9d64..5db812896 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 @@ -23,12 +23,12 @@ import androidx.lifecycle.viewmodel.compose.viewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.ui.component.ArtistListItem -import com.zionhuang.music.ui.component.ResizableIconButton import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.mutablePreferenceState import com.zionhuang.music.models.sortInfo.ArtistSortType import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.ui.component.ArtistListItem +import com.zionhuang.music.ui.component.ResizableIconButton import com.zionhuang.music.viewmodels.LibraryArtistsViewModel import java.time.Duration import java.time.LocalDateTime @@ -45,7 +45,7 @@ fun LibraryArtistsScreen( LaunchedEffect(artists) { SongRepository(context).refetchArtists( artists.map { it.artist }.filter { - it.bannerUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) + it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) } ) } diff --git a/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt b/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt index c9d82af50..43ed8d083 100644 --- a/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt @@ -13,7 +13,7 @@ private const val HMS_FORMAT = "%1\$d:%2$02d:%3$02d" * @return formatted string */ fun makeTimeString(duration: Long?): String { - if (duration == null) return "0:00" + if (duration == null || duration < 0) return "" var sec = duration / 1000 val hour = sec / 3600 sec %= 3600 @@ -27,4 +27,4 @@ fun md5(str: String): String { return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') } -fun List.joinByBullet() = filterNot { it.isNullOrEmpty() }.joinToString(separator = " • ") +fun joinByBullet(vararg str: String?) = str.filterNot { it.isNullOrEmpty() }.joinToString(separator = " • ") 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 8ab7759fa..a550606ad 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt @@ -1,36 +1,43 @@ package com.zionhuang.music.viewmodels -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.music.db.entities.AlbumWithSongs import com.zionhuang.music.repos.SongRepository -import com.zionhuang.music.youtube.getAlbumWithSongs import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.launch class AlbumViewModel( - context: Context, - albumId: String, - playlistId: String?, -) : ViewModel() { - val albumWithSongs = MutableStateFlow(null) + application: Application, + savedStateHandle: SavedStateHandle, +) : AndroidViewModel(application) { + val songRepository = SongRepository(application) + val albumId = savedStateHandle.get("albumId")!! + private val _viewState = MutableStateFlow(null) + val viewState = _viewState.asStateFlow() init { viewModelScope.launch { - albumWithSongs.value = SongRepository(context).getAlbumWithSongs(albumId) - ?: YouTube.getAlbumWithSongs(context, albumId, playlistId) + _viewState.value = songRepository.getAlbumWithSongs(albumId)?.let { + AlbumViewState.Local(it) + } ?: YouTube.browseAlbum(albumId).getOrNull()?.let { + AlbumViewState.Remote(it) + } } } +} - class Factory( - val context: Context, - val albumId: String, - val playlistId: String?, - ) : ViewModelProvider.NewInstanceFactory() { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = AlbumViewModel(context, albumId, playlistId) as T - } +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 new file mode 100644 index 000000000..1a5e05d0a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt @@ -0,0 +1,49 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.BrowseEndpoint +import com.zionhuang.music.models.ItemsPage +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch + +class ArtistItemsViewModel( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val browseId = savedStateHandle.get("browseId")!! + val params = savedStateHandle.get("params") + + val title = MutableStateFlow("") + val itemsPage = MutableStateFlow(null) + + init { + viewModelScope.launch { + val artistItemsPage = YouTube.browseArtistItems(BrowseEndpoint( + browseId = browseId, + params = params + )).getOrNull() ?: return@launch + title.value = artistItemsPage.title + itemsPage.value = ItemsPage( + items = artistItemsPage.items, + continuation = artistItemsPage.continuation + ) + } + } + + fun loadMore() { + viewModelScope.launch { + val oldItemsPage = itemsPage.value ?: return@launch + val continuation = oldItemsPage.continuation ?: return@launch + val artistItemsContinuationPage = YouTube.browseArtistItemsContinuation(continuation).getOrNull() ?: return@launch + itemsPage.update { + ItemsPage( + items = (oldItemsPage.items + artistItemsContinuationPage.items).distinctBy { it.id }, + continuation = artistItemsContinuationPage.continuation + ) + } + } + } +} 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 db5f873f3..c12b9be0a 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -1,37 +1,26 @@ package com.zionhuang.music.viewmodels -import android.content.Context -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider +import android.app.Application +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.ArtistHeader -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.innertube.models.YTBaseItem -import kotlinx.coroutines.flow.MutableStateFlow +import com.zionhuang.innertube.pages.ArtistPage import kotlinx.coroutines.launch class ArtistViewModel( - context: Context, - val artistId: String, -) : ViewModel() { - val artistHeader = MutableStateFlow(null) - val content = MutableStateFlow>(emptyList()) + application: Application, + savedStateHandle: SavedStateHandle, +) : AndroidViewModel(application) { + val artistId = savedStateHandle.get("artistId")!! + var artistPage by mutableStateOf(null) init { viewModelScope.launch { - YouTube.browse(BrowseEndpoint(browseId = artistId)).onSuccess { browseResult -> - artistHeader.value = browseResult.items.firstOrNull() as? ArtistHeader - content.value = browseResult.items.drop(1) - } + artistPage = YouTube.browseArtist(artistId).getOrNull() } } - - class Factory( - val context: Context, - val artistId: String, - ) : ViewModelProvider.NewInstanceFactory() { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = ArtistViewModel(context, artistId) as T - } } 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 405946e02..6146dd8a3 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -19,7 +19,7 @@ class LibrarySongsViewModel(application: Application) : AndroidViewModel(applica private val songRepository = SongRepository(application) val allSongs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllSongs(sortInfo).flow + songRepository.getAllSongs(sortInfo) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @@ -27,7 +27,7 @@ class LibraryArtistsViewModel(application: Application) : AndroidViewModel(appli private val songRepository = SongRepository(application) val allArtists = ArtistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllArtists(sortInfo).flow + songRepository.getAllArtists(sortInfo) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @@ -35,7 +35,7 @@ class LibraryAlbumsViewModel(application: Application) : AndroidViewModel(applic private val songRepository = SongRepository(application) val allAlbums = AlbumSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllAlbums(sortInfo).flow + songRepository.getAllAlbums(sortInfo) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @@ -49,7 +49,7 @@ class LibraryPlaylistsViewModel(application: Application) : AndroidViewModel(app .stateIn(viewModelScope, SharingStarted.Lazily, 0) val allPlaylists = PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllPlaylists(sortInfo).flow + songRepository.getAllPlaylists(sortInfo) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @@ -57,7 +57,7 @@ class LikedSongsViewModel(application: Application) : AndroidViewModel(applicati private val songRepository = SongRepository(application) val songs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getLikedSongs(sortInfo).flow + songRepository.getLikedSongs(sortInfo) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @@ -65,6 +65,6 @@ class DownloadedSongsViewModel(application: Application) : AndroidViewModel(appl private val songRepository = SongRepository(application) val songs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getDownloadedSongs(sortInfo).flow + songRepository.getDownloadedSongs(sortInfo) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt index 033c4b2e6..210e1149c 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt @@ -11,7 +11,7 @@ class LocalPlaylistViewModel( playlistId: String, ) : ViewModel() { val playlist = SongRepository(context).getPlaylist(playlistId).stateIn(viewModelScope, SharingStarted.Lazily, null) - val playlistSongs = SongRepository(context).getPlaylistSongs(playlistId).flow.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val playlistSongs = SongRepository(context).getPlaylistSongs(playlistId).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) class Factory( val context: Context, @@ -21,4 +21,3 @@ class LocalPlaylistViewModel( override fun create(modelClass: Class): T = LocalPlaylistViewModel(context, playlistId) as T } } - diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt new file mode 100644 index 000000000..4a31e4ccc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt @@ -0,0 +1,51 @@ +package com.zionhuang.music.viewmodels + +import android.app.Application +import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.music.repos.SongRepository +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.* +import kotlinx.coroutines.launch + +@OptIn(ExperimentalCoroutinesApi::class) +class OnlineSearchSuggestionViewModel(app: Application) : AndroidViewModel(app) { + private val songRepository = SongRepository(app) + val query = MutableStateFlow("") + private val _viewState = MutableStateFlow(SearchSuggestionViewState()) + val viewState = _viewState.asStateFlow() + + init { + viewModelScope.launch { + query.flatMapLatest { query -> + if (query.isEmpty()) { + songRepository.getAllSearchHistory().map { history -> + SearchSuggestionViewState( + history = history.map { it.query } + ) + } + } else { + val result = YouTube.getSearchSuggestions(query).getOrNull() + songRepository.getSearchHistory(query).map { searchHistory -> + val history = searchHistory.map { it.query }.take(3) + SearchSuggestionViewState( + history = history, + suggestions = result?.queries?.filter { it !in history }.orEmpty(), + items = result?.recommendedItems.orEmpty() + ) + } + } + }.collect { + _viewState.value = it + } + } + } +} + +data class SearchSuggestionViewState( + val history: List = emptyList(), + val suggestions: List = emptyList(), + val items: List = emptyList(), +) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt index d225589b4..17d134f1e 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt @@ -1,33 +1,54 @@ package com.zionhuang.music.viewmodels +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateMapOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.viewModelScope -import androidx.paging.Pager -import androidx.paging.PagingConfig -import androidx.paging.cachedIn import com.zionhuang.innertube.YouTube -import com.zionhuang.music.repos.YouTubeRepository +import com.zionhuang.innertube.pages.SearchSummaryPage +import com.zionhuang.music.models.ItemsPage import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch class OnlineSearchViewModel( - private val repository: YouTubeRepository, - private val query: String, + savedStateHandle: SavedStateHandle, ) : ViewModel() { + val query = savedStateHandle.get("query")!! val filter = MutableStateFlow(null) + var summaryPage by mutableStateOf(null) + val viewStateMap = mutableStateMapOf() - val pagingData = Pager(PagingConfig(pageSize = 20)) { - filter.value.let { - if (it == null) repository.searchAll(query) - else repository.search(query, it) + init { + viewModelScope.launch { + filter.collect { filter -> + if (filter == null) { + if (summaryPage == null) { + summaryPage = YouTube.searchSummary(query).getOrNull() + } + } else { + if (viewStateMap[filter.value] == null) { + viewStateMap[filter.value] = YouTube.search(query, filter).getOrNull()?.let { + ItemsPage(it.items, it.continuation) + } + } + } + } } - }.flow.cachedIn(viewModelScope) + } - class Factory( - private val repository: YouTubeRepository, - private val query: String, - ) : ViewModelProvider.NewInstanceFactory() { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = OnlineSearchViewModel(repository, query) as T + fun loadMore() { + val filter = filter.value?.value + viewModelScope.launch { + if (filter == null) return@launch + val viewState = viewStateMap[filter] ?: return@launch + val continuation = viewState.continuation + if (continuation != null) { + val searchResult = YouTube.searchContinuation(continuation).getOrNull() ?: return@launch + viewStateMap[filter] = ItemsPage((viewState.items + searchResult.items).distinctBy { it.id }, searchResult.continuation) + } + } } } diff --git a/app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt b/app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt deleted file mode 100644 index 5642abb90..000000000 --- a/app/src/main/java/com/zionhuang/music/youtube/YouTubeAlbum.kt +++ /dev/null @@ -1,135 +0,0 @@ -package com.zionhuang.music.youtube - -import android.content.Context -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.AlbumItem -import com.zionhuang.innertube.models.AlbumOrPlaylistHeader -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.music.db.entities.AlbumEntity -import com.zionhuang.music.db.entities.AlbumWithSongs -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.db.entities.toSong -import com.zionhuang.music.repos.SongRepository - -data class YouTubeAlbum( - val album: AlbumEntity, - val artists: List, - val songs: List, -) - -suspend fun YouTube.getAlbumWithSongs(context: Context, browseId: String, playlistId: String? = null): AlbumWithSongs { - val header = browse(BrowseEndpoint(browseId = browseId)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader ?: throw IllegalStateException("Album header not found") - val songIds = browse(BrowseEndpoint(browseId = "VL${playlistId ?: header.id}")).getOrThrow() - .items - .filterIsInstance() - .map { it.id } - val songs = getQueue(videoIds = songIds).getOrThrow() - return AlbumWithSongs( - album = AlbumEntity( - id = browseId, - title = header.name, - year = header.year, - thumbnailUrl = header.thumbnails.last().url, - songCount = songs.size, - duration = songs.sumOf { it.duration ?: 0 } - ), - artists = header.artists - ?.map { run -> - val artistId = run.navigationEndpoint?.browseEndpoint?.browseId - ?: SongRepository(context).getArtistByName(run.text)?.id - ?: ArtistEntity.generateArtistId() - ArtistEntity( - id = artistId, - name = run.text - ) - }.orEmpty(), - songs = songs.map { it.toSong(context) } - ) -} - -suspend fun AlbumItem.toAlbumWithSongs(context: Context): AlbumWithSongs { - val songIds = YouTube.browse(BrowseEndpoint(browseId = "VL$playlistId")).getOrThrow() - .items - .filterIsInstance() - .map { - it.id - } - val songs = YouTube.getQueue(videoIds = songIds).getOrThrow() - YouTubeAlbum( - album = AlbumEntity( - id = id, - title = title, - year = year, - thumbnailUrl = thumbnails.last().url, - songCount = songs.size, - duration = songs.sumOf { it.duration ?: 0 } - ), - artists = (YouTube.browse(BrowseEndpoint(browseId = id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader) - ?.artists - ?.map { run -> - val artistId = run.navigationEndpoint?.browseEndpoint?.browseId - ?: SongRepository(context).getArtistByName(run.text)?.id - ?: ArtistEntity.generateArtistId() - ArtistEntity( - id = artistId, - name = run.text - ) - }.orEmpty(), - songs = songs - ) - return AlbumWithSongs( - album = AlbumEntity( - id = id, - title = title, - year = year, - thumbnailUrl = thumbnails.last().url, - songCount = songs.size, - duration = songs.sumOf { it.duration ?: 0 } - ), - artists = (YouTube.browse(BrowseEndpoint(browseId = id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader) - ?.artists - ?.map { run -> - val artistId = run.navigationEndpoint?.browseEndpoint?.browseId - ?: SongRepository(context).getArtistByName(run.text)?.id - ?: ArtistEntity.generateArtistId() - ArtistEntity( - id = artistId, - name = run.text - ) - }.orEmpty(), - songs = songs.map { it.toSong(context) } - ) -} - -suspend fun AlbumItem.getYouTubeAlbum(context: Context): YouTubeAlbum { - val songIds = YouTube.browse(BrowseEndpoint(browseId = "VL$playlistId")).getOrThrow() - .items - .filterIsInstance() - .map { - it.id - } - val songs = YouTube.getQueue(videoIds = songIds).getOrThrow() - return YouTubeAlbum( - album = AlbumEntity( - id = id, - title = title, - year = year, - thumbnailUrl = thumbnails.last().url, - songCount = songs.size, - duration = songs.sumOf { it.duration ?: 0 } - ), - artists = (YouTube.browse(BrowseEndpoint(browseId = id)).getOrThrow().items.firstOrNull() as? AlbumOrPlaylistHeader) - ?.artists - ?.map { run -> - val artistId = run.navigationEndpoint?.browseEndpoint?.browseId - ?: SongRepository(context).getArtistByName(run.text)?.id - ?: ArtistEntity.generateArtistId() - ArtistEntity( - id = artistId, - name = run.text - ) - }.orEmpty(), - songs = songs - ) -} \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_explicit.xml b/app/src/main/res/drawable/ic_explicit.xml new file mode 100644 index 000000000..a0958f52d --- /dev/null +++ b/app/src/main/res/drawable/ic_explicit.xml @@ -0,0 +1,9 @@ + + + diff --git a/innertube/build.gradle.kts b/innertube/build.gradle.kts index 59c535e24..aa616431b 100644 --- a/innertube/build.gradle.kts +++ b/innertube/build.gradle.kts @@ -1,41 +1,9 @@ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") + kotlin("jvm") @Suppress("DSL_SCOPE_VIOLATION") alias(libs.plugins.kotlin.serialization) - id("kotlin-parcelize") } -android { - namespace = "com.zionhuang.innertube" - compileSdk = 32 - defaultConfig { - minSdk = 24 - targetSdk = 31 - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - } - buildTypes { - release { - isMinifyEnabled = true - proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 - } - kotlinOptions { - jvmTarget = "1.8" - } -} - -tasks.withType().configureEach { - kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" -} - -val ktor_version: String by project -val logback_version: String by project - dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/innertube/src/main/AndroidManifest.xml b/innertube/src/main/AndroidManifest.xml deleted file mode 100644 index 568741e54..000000000 --- a/innertube/src/main/AndroidManifest.xml +++ /dev/null @@ -1,2 +0,0 @@ - - \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index a10bdef63..1d97e7b26 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -4,9 +4,9 @@ import com.zionhuang.innertube.models.* import com.zionhuang.innertube.models.YouTubeClient.Companion.ANDROID_MUSIC import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.zionhuang.innertube.models.response.* +import com.zionhuang.innertube.pages.* import io.ktor.client.call.* import io.ktor.client.statement.* -import io.ktor.http.* import kotlinx.serialization.json.Json import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive @@ -41,49 +41,53 @@ object YouTube { } suspend fun getSearchSuggestions(query: String): Result = runCatching { - val response = innerTube.getSearchSuggestions(ANDROID_MUSIC, query).body() + val response = innerTube.getSearchSuggestions(WEB_REMIX, query).body() SearchSuggestions( queries = response.contents?.getOrNull(0)?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { content -> - content.searchSuggestionRenderer?.suggestion?.toString() + content.searchSuggestionRenderer?.suggestion?.runs?.joinToString(separator = "") { it.text } }.orEmpty(), - recommendedItems = response.contents?.getOrNull(1)?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { content -> - content.musicTwoColumnItemRenderer?.toItem() + recommendedItems = response.contents?.getOrNull(1)?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { + it.musicResponsiveListItemRenderer?.let { renderer -> + SearchSuggestionPage.fromMusicResponsiveListItemRenderer(renderer) + } }.orEmpty() ) } - suspend fun searchAllType(query: String): Result = runCatching { + suspend fun searchSummary(query: String): Result = runCatching { val response = innerTube.search(WEB_REMIX, query).body() - BrowseResult( - items = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents - ?.flatMap { it.toBaseItems() } - ?.map { - when (it) { - is Header -> it.copy(moreNavigationEndpoint = null) // remove search type arrow link - is SongItem -> it.copy(subtitle = it.subtitle.substringAfter(" • ")) - is AlbumItem -> it.copy(subtitle = it.subtitle.substringAfter(" • ")) - is PlaylistItem -> it.copy(subtitle = it.subtitle.substringAfter(" • ")) - is ArtistItem -> it.copy(subtitle = it.subtitle.substringAfter(" • ")) - else -> it - } - }.orEmpty(), - continuations = null + SearchSummaryPage( + summaries = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { it -> + 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 + ) + }!! ) } - suspend fun search(query: String, filter: SearchFilter): Result = runCatching { + suspend fun search(query: String, filter: SearchFilter): Result = runCatching { val response = innerTube.search(WEB_REMIX, query, filter.value).body() - BrowseResult( - items = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicShelfRenderer?.contents?.mapNotNull { it.toItem() }.orEmpty(), - continuations = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicShelfRenderer?.continuations?.getContinuations() + SearchResult( + items = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicShelfRenderer?.contents?.mapNotNull { + SearchPage.toYTItem(it.musicResponsiveListItemRenderer) + }.orEmpty(), + continuation = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicShelfRenderer?.continuations?.getContinuation() ) } - suspend fun search(continuation: String): Result = runCatching { + suspend fun searchContinuation(continuation: String): Result = runCatching { val response = innerTube.search(WEB_REMIX, continuation = continuation).body() - BrowseResult( - items = response.continuationContents?.musicShelfContinuation?.contents?.mapNotNull { it.toItem() }.orEmpty(), - continuations = response.continuationContents?.musicShelfContinuation?.continuations?.getContinuations() + SearchResult( + items = response.continuationContents?.musicShelfContinuation?.contents + ?.mapNotNull { SearchPage.toYTItem(it.musicResponsiveListItemRenderer) }!!, + continuation = response.continuationContents.musicShelfContinuation.continuations?.getContinuation() ) } @@ -91,40 +95,134 @@ object YouTube { innerTube.player(ANDROID_MUSIC, videoId, playlistId).body() } - suspend fun browse(endpoint: BrowseEndpoint): Result = runCatching { - val browseResult = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params, null).body().toBrowseResult() - if (endpoint.isAlbumEndpoint && browseResult.urlCanonical != null) { - Url(browseResult.urlCanonical).parameters["list"]?.let { playlistId -> - val albumName = (browseResult.items.first() as AlbumOrPlaylistHeader).name - val albumYear = (browseResult.items.first() as AlbumOrPlaylistHeader).year - // replace video items with audio items - return@runCatching browseResult.copy( - items = browseResult.items.subList(0, browseResult.items.indexOfFirst { it is SongItem }) + - browse(BrowseEndpoint(browseId = "VL$playlistId")).getOrThrow().items.filterIsInstance().mapIndexed { index, item -> - item.copy( - subtitle = item.subtitle.split(" • ").lastOrNull().orEmpty(), - index = (index + 1).toString(), - album = Link(text = albumName, navigationEndpoint = endpoint), - albumYear = albumYear - ) - } + - browseResult.items.subList(browseResult.items.indexOfLast { it is SongItem } + 1, browseResult.items.size) - ) - } - } - browseResult + suspend fun browseAlbum(browseId: String): Result = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId).body() + val playlistId = response.microformat?.microformatDataRenderer?.urlCanonical?.substringAfterLast('=')!! + val audioPlaylistResponse = innerTube.browse(WEB_REMIX, "VL$playlistId").body() + AlbumPage( + album = AlbumItem( + browseId = browseId, + playlistId = playlistId, + title = response.header?.musicDetailHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, + artists = response.header.musicDetailHeaderRenderer.subtitle.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + }!!, + year = response.header.musicDetailHeaderRenderer.subtitle.runs.lastOrNull()?.text?.toIntOrNull(), + thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.croppedSquareThumbnailRenderer?.getThumbnailUrl()!! + ), + songs = audioPlaylistResponse.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicPlaylistShelfRenderer?.contents + ?.mapNotNull { + AlbumPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!! + ) } - suspend fun browse(continuation: String): Result = runCatching { - innerTube.browse(WEB_REMIX, continuation = continuation).body().toBrowseResult() + suspend fun browseArtist(browseId: String): Result = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId).body() + ArtistPage( + artist = ArtistItem( + id = browseId, + title = response.header?.musicImmersiveHeaderRenderer?.title?.runs?.firstOrNull()?.text + ?: response.header?.musicVisualHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, + thumbnail = response.header?.musicImmersiveHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() + ?: response.header?.musicVisualHeaderRenderer?.foregroundThumbnail?.getThumbnailUrl()!!, + shuffleEndpoint = response.header?.musicImmersiveHeaderRenderer?.playButton?.buttonRenderer?.navigationEndpoint?.watchEndpoint, + radioEndpoint = response.header?.musicImmersiveHeaderRenderer?.startRadioButton?.buttonRenderer?.navigationEndpoint?.watchEndpoint + ), + sections = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents + ?.mapNotNull(ArtistPage::fromSectionListRendererContent)!!, + description = response.header?.musicImmersiveHeaderRenderer?.description?.runs?.firstOrNull()?.text + ) } - suspend fun browse(continuations: List): Result = - browse(continuations[0]).mapCatching { - it.copy( - continuations = it.continuations.orEmpty() + continuations.drop(1) + suspend fun browseArtistItems(endpoint: BrowseEndpoint): Result = runCatching { + val response = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params).body() + val gridRenderer = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.gridRenderer + if (gridRenderer != null) { + ArtistItemsPage( + title = gridRenderer.header?.gridHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, + items = gridRenderer.items.mapNotNull { + it.musicTwoRowItemRenderer?.let { renderer -> + ArtistItemsPage.fromMusicTwoRowItemRenderer(renderer) + } + }, + continuation = null + ) + } else { + ArtistItemsPage( + title = response.header?.musicHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, + items = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicPlaylistShelfRenderer?.contents?.mapNotNull { + ArtistItemsPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!!, + continuation = response.contents.singleColumnBrowseResultsRenderer.tabs.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicPlaylistShelfRenderer?.continuations?.getContinuation() ) } + } + + suspend fun browseArtistItemsContinuation(continuation: String): Result = runCatching { + val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() + ArtistItemsContinuationPage( + items = response.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { + ArtistItemsContinuationPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!!, + continuation = response.continuationContents.musicPlaylistShelfContinuation.continuations?.getContinuation() + ) + } + + suspend fun browsePlaylist(browseId: String) = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId).body() + PlaylistPage( + playlist = PlaylistItem( + id = browseId, + title = response.header?.musicDetailHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, + author = response.header.musicDetailHeaderRenderer.subtitle.runs?.getOrNull(2)?.let { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + }!!, + songCountText = response.header.musicDetailHeaderRenderer.secondSubtitle.runs?.firstOrNull()?.text, + thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.croppedSquareThumbnailRenderer?.getThumbnailUrl()!!, + playEndpoint = null, + shuffleEndpoint = response.header.musicDetailHeaderRenderer.menu.menuRenderer.topLevelButtons?.firstOrNull()?.buttonRenderer?.navigationEndpoint?.watchPlaylistEndpoint!!, + radioEndpoint = response.header.musicDetailHeaderRenderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MIX" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint!! + ), + songs = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicPlaylistShelfRenderer?.contents?.mapNotNull { + PlaylistPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!!, + songsContinuation = response.contents.singleColumnBrowseResultsRenderer.tabs.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicPlaylistShelfRenderer?.continuations?.getContinuation(), + continuation = response.contents.singleColumnBrowseResultsRenderer.tabs.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.continuations?.getContinuation() + ) + } + + suspend fun browsePlaylistContinuation(continuation: String) = runCatching { + val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() + PlaylistContinuationPage( + songs = response.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { + PlaylistPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!!, + continuation = response.continuationContents.musicPlaylistShelfContinuation.continuations?.getContinuation() + ) + } suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { val response = innerTube.next(WEB_REMIX, endpoint.videoId, endpoint.playlistId, endpoint.playlistSetVideoId, endpoint.index, endpoint.params, continuation).body() @@ -132,10 +230,14 @@ object YouTube { ?: response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs[0].tabRenderer.content?.musicQueueRenderer?.content?.playlistPanelRenderer!! // load automix items playlistPanelRenderer.contents.lastOrNull()?.automixPreviewVideoRenderer?.content?.automixPlaylistVideoRenderer?.navigationEndpoint?.watchPlaylistEndpoint?.let { watchPlaylistEndpoint -> - return@runCatching next(watchPlaylistEndpoint.toWatchEndpoint()).getOrThrow().let { result -> + return@runCatching next(watchPlaylistEndpoint).getOrThrow().let { result -> result.copy( title = playlistPanelRenderer.title, - items = playlistPanelRenderer.contents.mapNotNull { it.playlistPanelVideoRenderer?.toSongItem() } + result.items, + items = playlistPanelRenderer.contents.mapNotNull { + it.playlistPanelVideoRenderer?.let { renderer -> + NextPage.fromPlaylistPanelVideoRenderer(renderer) + } + } + result.items, lyricsEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.getOrNull(1)?.tabRenderer?.endpoint?.browseEndpoint, relatedEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.getOrNull(2)?.tabRenderer?.endpoint?.browseEndpoint, currentIndex = playlistPanelRenderer.currentIndex @@ -144,7 +246,11 @@ object YouTube { } NextResult( title = playlistPanelRenderer.title, - items = playlistPanelRenderer.contents.mapNotNull { it.playlistPanelVideoRenderer?.toSongItem() }, + items = playlistPanelRenderer.contents.mapNotNull { + it.playlistPanelVideoRenderer?.let { renderer -> + NextPage.fromPlaylistPanelVideoRenderer(renderer) + } + }, currentIndex = playlistPanelRenderer.currentIndex, lyricsEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.getOrNull(1)?.tabRenderer?.endpoint?.browseEndpoint, relatedEndpoint = response.contents.singleColumnMusicWatchNextResultsRenderer.tabbedRenderer.watchNextTabbedResultsRenderer.tabs.getOrNull(2)?.tabRenderer?.endpoint?.browseEndpoint, @@ -152,12 +258,21 @@ object YouTube { ) } + suspend fun getLyrics(endpoint: BrowseEndpoint): Result = runCatching { + val response = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params).body() + response.contents?.sectionListRenderer?.contents?.firstOrNull()?.musicDescriptionShelfRenderer?.description?.runs?.firstOrNull()?.text + } + suspend fun getQueue(videoIds: List? = null, playlistId: String? = null): Result> = runCatching { if (videoIds != null) { assert(videoIds.size <= MAX_GET_QUEUE_SIZE) // Max video limit } innerTube.getQueue(WEB_REMIX, videoIds, playlistId).body().queueDatas - .mapNotNull { it.content.playlistPanelVideoRenderer?.toSongItem() } + .mapNotNull { + it.content.playlistPanelVideoRenderer?.let { renderer -> + NextPage.fromPlaylistPanelVideoRenderer(renderer) + } + } } suspend fun generateVisitorData(): Result = runCatching { @@ -190,4 +305,4 @@ object YouTube { const val MAX_GET_QUEUE_SIZE = 1000 const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" -} \ No newline at end of file +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Badges.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Badges.kt new file mode 100644 index 000000000..b081b1c87 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Badges.kt @@ -0,0 +1,13 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class Badges( + val musicInlineBadgeRenderer: MusicInlineBadgeRenderer, +) { + @Serializable + data class MusicInlineBadgeRenderer( + val icon: Icon, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt b/innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt deleted file mode 100644 index 8554126ef..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/BrowseResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.innertube.models - -data class BrowseResult( - val items: List, - val lyrics: String? = null, - val urlCanonical: String? = null, - val continuations: List? = null, // act as a stack for nested continuation -) { - fun addHeader(header: YTBaseItem?) = if (header == null) this else copy(items = listOf(header) + items) -} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt index a079a30f7..eb7ade7d7 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Continuation.kt @@ -1,11 +1,10 @@ -@file:OptIn(ExperimentalSerializationApi::class) - package com.zionhuang.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames +@OptIn(ExperimentalSerializationApi::class) @Serializable data class Continuation( @JsonNames("nextContinuationData", "nextRadioContinuationData") @@ -16,11 +15,3 @@ data class Continuation( val continuation: String, ) } - -fun List.getContinuation() = - get(0).nextContinuationData?.continuation - - -fun List.getContinuations() = - mapNotNull { it.nextContinuationData?.continuation } - .ifEmpty { null } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Endpoint.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Endpoint.kt index 6e253c5e0..d219a2dc2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Endpoint.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Endpoint.kt @@ -1,17 +1,12 @@ package com.zionhuang.innertube.models -import android.os.Parcelable import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_ALBUM import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_ARTIST -import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_PLAYLIST -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -@Parcelize @Serializable -sealed class Endpoint : Parcelable +sealed class Endpoint -@Parcelize @Serializable data class WatchEndpoint( val videoId: String? = null, @@ -21,16 +16,14 @@ data class WatchEndpoint( val index: Int? = null, val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs? = null, ) : Endpoint() { - @Parcelize @Serializable data class WatchEndpointMusicSupportedConfigs( val watchEndpointMusicConfig: WatchEndpointMusicConfig, - ) : Parcelable { - @Parcelize + ) { @Serializable data class WatchEndpointMusicConfig( val musicVideoType: String, - ) : Parcelable { + ) { companion object { const val MUSIC_VIDEO_TYPE_OMV = "MUSIC_VIDEO_TYPE_OMV" const val MUSIC_VIDEO_TYPE_UGC = "MUSIC_VIDEO_TYPE_UGC" @@ -40,23 +33,6 @@ data class WatchEndpoint( } } -@Parcelize -@Serializable -data class WatchPlaylistEndpoint( - val params: String? = null, - val playlistId: String, -) : Endpoint() { - fun toWatchEndpoint() = WatchEndpoint( - videoId = null, - playlistId = playlistId, - playlistSetVideoId = null, - params = params, - index = null, - watchEndpointMusicSupportedConfigs = null - ) -} - -@Parcelize @Serializable data class BrowseEndpoint( val browseId: String, @@ -68,16 +44,14 @@ data class BrowseEndpoint( val isAlbumEndpoint: Boolean get() = browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ALBUM - @Parcelize @Serializable data class BrowseEndpointContextSupportedConfigs( val browseEndpointContextMusicConfig: BrowseEndpointContextMusicConfig, - ) : Parcelable { - @Parcelize + ) { @Serializable data class BrowseEndpointContextMusicConfig( val pageType: String, - ) : Parcelable { + ) { companion object { const val MUSIC_PAGE_TYPE_ALBUM = "MUSIC_PAGE_TYPE_ALBUM" const val MUSIC_PAGE_TYPE_AUDIOBOOK = "MUSIC_PAGE_TYPE_AUDIOBOOK" @@ -89,72 +63,27 @@ data class BrowseEndpoint( } } } - - companion object { - fun artistBrowseEndpoint(artistId: String) = BrowseEndpoint( - browseId = artistId, - browseEndpointContextSupportedConfigs = BrowseEndpointContextSupportedConfigs( - browseEndpointContextMusicConfig = BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig( - pageType = MUSIC_PAGE_TYPE_ARTIST - ) - ) - ) - - fun albumBrowseEndpoint(albumId: String) = BrowseEndpoint( - browseId = albumId, - browseEndpointContextSupportedConfigs = BrowseEndpointContextSupportedConfigs( - browseEndpointContextMusicConfig = BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig( - pageType = MUSIC_PAGE_TYPE_ALBUM - ) - ) - ) - - fun playlistBrowseEndpoint(playlistId: String) = BrowseEndpoint( - browseId = playlistId, - browseEndpointContextSupportedConfigs = BrowseEndpointContextSupportedConfigs( - browseEndpointContextMusicConfig = BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig( - pageType = MUSIC_PAGE_TYPE_PLAYLIST - ) - ) - ) - } } -@Parcelize @Serializable data class SearchEndpoint( val params: String?, val query: String, ) : Endpoint() -@Parcelize @Serializable data class QueueAddEndpoint( val queueInsertPosition: String, val queueTarget: QueueTarget, ) : Endpoint() { - @Parcelize @Serializable data class QueueTarget( val videoId: String? = null, val playlistId: String? = null, - ) : Parcelable - - companion object { - const val INSERT_AFTER_CURRENT_VIDEO = "INSERT_AFTER_CURRENT_VIDEO" - const val INSERT_AT_END = "INSERT_AT_END" - } + ) } -@Parcelize @Serializable data class ShareEntityEndpoint( val serializedShareEntity: String, ) : Endpoint() - -// Custom endpoint -@Parcelize -@Serializable -data class BrowseLocalArtistSongsEndpoint( - val artistId: String, -) : Endpoint() \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt deleted file mode 100644 index 08e711b5a..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Filter.kt +++ /dev/null @@ -1,9 +0,0 @@ -package com.zionhuang.innertube.models - -import kotlinx.serialization.Serializable - -@Serializable -data class Filter( - val text: String, - val searchEndpoint: SearchEndpoint, -) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt index b6f5f5504..795e8b277 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/GridRenderer.kt @@ -11,10 +11,6 @@ data class GridRenderer( data class Header( val gridHeaderRenderer: GridHeaderRenderer, ) { - fun toSectionHeader() = Header( - title = gridHeaderRenderer.title.toString() - ) - @Serializable data class GridHeaderRenderer( val title: Runs, @@ -25,7 +21,5 @@ data class GridRenderer( data class Item( val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, - ) { - fun toBaseItem(): YTBaseItem = musicNavigationButtonRenderer?.toItem() ?: musicTwoRowItemRenderer?.toItem()!! - } -} \ No newline at end of file + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt index 5eb1525f5..529851357 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Icon.kt @@ -5,11 +5,4 @@ import kotlinx.serialization.Serializable @Serializable data class Icon( val iconType: String, -) { - companion object { - const val ICON_MUSIC_NEW_RELEASE = "MUSIC_NEW_RELEASE" - const val ICON_TRENDING_UP = "TRENDING_UP" - const val ICON_STICKER_EMOTICON = "STICKER_EMOTICON" - const val ICON_EXPLORE = "EXPLORE" // custom icon - } -} \ No newline at end of file +) \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/ItemMenu.kt b/innertube/src/main/java/com/zionhuang/innertube/models/ItemMenu.kt deleted file mode 100644 index 0a038b22f..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/ItemMenu.kt +++ /dev/null @@ -1,16 +0,0 @@ -package com.zionhuang.innertube.models - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -data class ItemMenu( - val playEndpoint: NavigationEndpoint?, - val shuffleEndpoint: NavigationEndpoint?, - val radioEndpoint: NavigationEndpoint?, - val playNextEndpoint: NavigationEndpoint?, - val addToQueueEndpoint: NavigationEndpoint?, - val artistEndpoint: NavigationEndpoint?, - val albumEndpoint: NavigationEndpoint?, - val shareEndpoint: NavigationEndpoint?, -) : Parcelable \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Link.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Link.kt deleted file mode 100644 index 492386b39..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Link.kt +++ /dev/null @@ -1,13 +0,0 @@ -package com.zionhuang.innertube.models - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -/** - * A strict form of [Run] - */ -@Parcelize -data class Link( - val text: String, - val navigationEndpoint: T, -) : Parcelable \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt index 46002a89b..34edbb0ab 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Menu.kt @@ -6,26 +6,6 @@ import kotlinx.serialization.Serializable data class Menu( val menuRenderer: MenuRenderer, ) { - private fun findEndpointByIcon(iconType: String): NavigationEndpoint? = - menuRenderer.items.find { - it.menuNavigationItemRenderer?.icon?.iconType == iconType || it.menuServiceItemRenderer?.icon?.iconType == iconType - }?.let { - it.menuNavigationItemRenderer?.navigationEndpoint ?: it.menuServiceItemRenderer?.serviceEndpoint - } ?: menuRenderer.topLevelButtons?.find { - it.buttonRenderer?.icon?.iconType == iconType - }?.buttonRenderer?.navigationEndpoint - - fun toItemMenu() = ItemMenu( - playEndpoint = findEndpointByIcon(ICON_PLAY_ARROW), - shuffleEndpoint = findEndpointByIcon(ICON_SHUFFLE), - radioEndpoint = findEndpointByIcon(ICON_MIX), - playNextEndpoint = findEndpointByIcon(ICON_PLAY_NEXT), - addToQueueEndpoint = findEndpointByIcon(ICON_ADD_TO_QUEUE), - artistEndpoint = findEndpointByIcon(ICON_ARTIST), - albumEndpoint = findEndpointByIcon(ICON_ALBUM), - shareEndpoint = findEndpointByIcon(ICON_SHARE) - ) - @Serializable data class MenuRenderer( val items: List, @@ -62,15 +42,4 @@ data class Menu( ) } } - - companion object { - const val ICON_PLAY_ARROW = "PLAY_ARROW" - const val ICON_SHUFFLE = "MUSIC_SHUFFLE" - const val ICON_MIX = "MIX" - const val ICON_PLAY_NEXT = "QUEUE_PLAY_NEXT" - const val ICON_ADD_TO_QUEUE = "ADD_TO_REMOTE_QUEUE" - const val ICON_ALBUM = "ALBUM" - const val ICON_ARTIST = "ARTIST" - const val ICON_SHARE = "SHARE" - } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt index 1aa680e73..e69616139 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicCarouselShelfRenderer.kt @@ -9,13 +9,6 @@ data class MusicCarouselShelfRenderer( val itemSize: String, val numItemsPerColumn: Int?, ) { - fun getViewType() = when { - contents[0].musicTwoRowItemRenderer != null -> YTBaseItem.ViewType.BLOCK - contents[0].musicResponsiveListItemRenderer != null -> YTBaseItem.ViewType.LIST - contents[0].musicNavigationButtonRenderer != null -> YTBaseItem.ViewType.BLOCK - else -> YTBaseItem.ViewType.LIST - } - @Serializable data class Header( val musicCarouselShelfBasicHeaderRenderer: MusicCarouselShelfBasicHeaderRenderer, @@ -27,11 +20,6 @@ data class MusicCarouselShelfRenderer( val thumbnail: ThumbnailRenderer?, val moreContentButton: Button?, ) - - fun toHeader() = com.zionhuang.innertube.models.Header( - title = musicCarouselShelfBasicHeaderRenderer.title.toString(), - moreNavigationEndpoint = musicCarouselShelfBasicHeaderRenderer.moreContentButton?.buttonRenderer?.navigationEndpoint - ) } @Serializable @@ -39,9 +27,5 @@ data class MusicCarouselShelfRenderer( val musicTwoRowItemRenderer: MusicTwoRowItemRenderer?, val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, val musicNavigationButtonRenderer: MusicNavigationButtonRenderer?, // navigation button in explore tab - ) { - fun toBaseItem(): YTBaseItem? = musicTwoRowItemRenderer?.toItem() - ?: musicResponsiveListItemRenderer?.toItem() - ?: musicNavigationButtonRenderer?.toItem() - } + ) } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt index 57cbc63c8..4f08e6185 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicDescriptionShelfRenderer.kt @@ -8,11 +8,4 @@ data class MusicDescriptionShelfRenderer( val subheader: Runs?, val description: Runs, val footer: Runs?, -) { - fun toSectionHeader() = header?.let { - Header( - title = it.toString(), - subtitle = subheader?.toString() - ) - } -} +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt index 6bfa422e9..30fe502ea 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicNavigationButtonRenderer.kt @@ -9,13 +9,6 @@ data class MusicNavigationButtonRenderer( val iconStyle: IconStyle?, val clickCommand: NavigationEndpoint, ) { - fun toItem() = NavigationItem( - title = buttonText.toString(), - icon = iconStyle?.icon?.iconType, - stripeColor = solid?.leftStripeColor, - navigationEndpoint = clickCommand - ) - @Serializable data class Solid( val leftStripeColor: Long, @@ -25,4 +18,4 @@ data class MusicNavigationButtonRenderer( data class IconStyle( val icon: Icon, ) -} \ No newline at end of file +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt index 7fa0d1aa8..5b4bf6ffa 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicResponsiveListItemRenderer.kt @@ -13,46 +13,29 @@ import kotlinx.serialization.json.JsonNames /** * Typical list item * Used in [MusicCarouselShelfRenderer], [MusicShelfRenderer] - * Appear in quick picks, search results, table items, etc. + * Appears in quick picks, search results, table items, etc. */ @Serializable data class MusicResponsiveListItemRenderer( + val badges: List?, val fixedColumns: List?, val flexColumns: List, val thumbnail: ThumbnailRenderer?, val menu: Menu?, val playlistItemData: PlaylistItemData?, - val index: Runs?, + val overlay: Overlay?, val navigationEndpoint: NavigationEndpoint?, ) { - fun getTitle() = flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text.toString() - fun getSubtitle() = (flexColumns.drop(1) + fixedColumns.orEmpty()) - .filterNot { - it.musicResponsiveListItemFlexColumnRenderer.text?.runs.isNullOrEmpty() - }.joinToString(separator = " • ") { - it.musicResponsiveListItemFlexColumnRenderer.text.toString() - } - - // TODO - private val isRadio: Boolean = false - private val isSong: Boolean + val isSong: Boolean get() = navigationEndpoint == null || navigationEndpoint.watchEndpoint != null || navigationEndpoint.watchPlaylistEndpoint != null - private val isPlaylist: Boolean + val isPlaylist: Boolean get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_PLAYLIST - private val isAlbum: Boolean + val isAlbum: Boolean get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ALBUM || navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_AUDIOBOOK - private val isArtist: Boolean + val isArtist: Boolean get() = navigationEndpoint?.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ARTIST - fun toItem(): YTItem? = when { - isSong -> SongItem.from(this) - isPlaylist -> PlaylistItem.from(this) - isAlbum -> AlbumItem.from(this) - isArtist -> ArtistItem.from(this) - else -> throw UnsupportedOperationException("Unknown item type") - } - @Serializable data class FlexColumn( @JsonNames("musicResponsiveListItemFixedColumnRenderer") @@ -69,4 +52,24 @@ data class MusicResponsiveListItemRenderer( val playlistSetVideoId: String?, val videoId: String, ) + + @Serializable + data class Overlay( + val musicItemThumbnailOverlayRenderer: MusicItemThumbnailOverlayRenderer, + ) { + @Serializable + data class MusicItemThumbnailOverlayRenderer( + val content: Content, + ) { + @Serializable + data class Content( + val musicPlayButtonRenderer: MusicPlayButtonRenderer, + ) { + @Serializable + data class MusicPlayButtonRenderer( + val playNavigationEndpoint: NavigationEndpoint?, + ) + } + } + } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt index be8c9460a..e3026c1e9 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicShelfRenderer.kt @@ -10,17 +10,11 @@ data class MusicShelfRenderer( val moreContentButton: Button?, val continuations: List?, ) { - fun toHeader(): Header? = title?.let { - Header( - title = it.toString(), - moreNavigationEndpoint = bottomEndpoint ?: moreContentButton?.buttonRenderer?.navigationEndpoint - ) - } - @Serializable data class Content( val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer, - ) { - fun toItem(): YTItem? = musicResponsiveListItemRenderer.toItem() - } -} \ No newline at end of file + ) +} + +fun List.getContinuation() = + firstOrNull()?.nextContinuationData?.continuation diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt index 8dc0733e7..c07e06d7e 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt @@ -15,26 +15,19 @@ import kotlinx.serialization.Serializable data class MusicTwoRowItemRenderer( val title: Runs, val subtitle: Runs, + val subtitleBadges: List?, val menu: Menu, val thumbnailRenderer: ThumbnailRenderer, val navigationEndpoint: NavigationEndpoint, - // val thumbnailOverlay: ThumbnailOverlay, (for playing the album directly) + val thumbnailOverlay: MusicResponsiveListItemRenderer.Overlay?, ) { - private val isSong: Boolean + val isSong: Boolean get() = navigationEndpoint.endpoint is WatchEndpoint - private val isPlaylist: Boolean + val isPlaylist: Boolean get() = navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_PLAYLIST - private val isAlbum: Boolean + val isAlbum: Boolean get() = navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ALBUM || navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_AUDIOBOOK - private val isArtist: Boolean + val isArtist: Boolean get() = navigationEndpoint.browseEndpoint?.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType == MUSIC_PAGE_TYPE_ARTIST - - fun toItem(): YTItem = when { - isSong -> SongItem.from(this) - isPlaylist -> PlaylistItem.from(this) - isAlbum -> AlbumItem.from(this) - isArtist -> ArtistItem.from(this) - else -> throw UnsupportedOperationException("Unknown item type") - } -} \ No newline at end of file +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/NavigationEndpoint.kt b/innertube/src/main/java/com/zionhuang/innertube/models/NavigationEndpoint.kt index a1515ca50..83e4f3351 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/NavigationEndpoint.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/NavigationEndpoint.kt @@ -1,28 +1,16 @@ package com.zionhuang.innertube.models -import android.os.Parcelable -import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_ALBUM -import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_ARTIST -import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_PLAYLIST -import com.zionhuang.innertube.models.BrowseEndpoint.BrowseEndpointContextSupportedConfigs.BrowseEndpointContextMusicConfig.Companion.MUSIC_PAGE_TYPE_USER_CHANNEL -import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV -import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_OMV -import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_UGC -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable -@Parcelize @Serializable data class NavigationEndpoint( val watchEndpoint: WatchEndpoint? = null, - val watchPlaylistEndpoint: WatchPlaylistEndpoint? = null, + val watchPlaylistEndpoint: WatchEndpoint? = null, val browseEndpoint: BrowseEndpoint? = null, val searchEndpoint: SearchEndpoint? = null, val queueAddEndpoint: QueueAddEndpoint? = null, val shareEntityEndpoint: ShareEntityEndpoint? = null, - // Custom endpoint - val browseLocalArtistSongsEndpoint: BrowseLocalArtistSongsEndpoint? = null, -) : Parcelable { +) { val endpoint: Endpoint? get() = watchEndpoint ?: watchPlaylistEndpoint @@ -30,21 +18,4 @@ data class NavigationEndpoint( ?: searchEndpoint ?: queueAddEndpoint ?: shareEntityEndpoint - ?: browseLocalArtistSongsEndpoint - - fun getEndpointType(): Int = when (val ep = endpoint) { - is WatchEndpoint -> when (ep.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType) { - MUSIC_VIDEO_TYPE_ATV -> ITEM_SONG - MUSIC_VIDEO_TYPE_OMV, MUSIC_VIDEO_TYPE_UGC -> ITEM_VIDEO - else -> ITEM_UNKNOWN - } - is BrowseEndpoint -> when (ep.browseEndpointContextSupportedConfigs?.browseEndpointContextMusicConfig?.pageType) { - MUSIC_PAGE_TYPE_ALBUM -> ITEM_ALBUM - MUSIC_PAGE_TYPE_PLAYLIST -> ITEM_PLAYLIST - MUSIC_PAGE_TYPE_ARTIST -> ITEM_ARTIST - MUSIC_PAGE_TYPE_USER_CHANNEL -> ITEM_ARTIST - else -> ITEM_UNKNOWN - } - else -> ITEM_UNKNOWN - } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt b/innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt deleted file mode 100644 index 40a8dc921..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/NextResult.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.innertube.models - -data class NextResult( - val title: String? = null, - val items: List, - val currentIndex: Int? = null, - val lyricsEndpoint: BrowseEndpoint? = null, - val relatedEndpoint: BrowseEndpoint? = null, - val continuation: String?, -) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt index 10809b26e..78a6c432a 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelRenderer.kt @@ -19,4 +19,4 @@ data class PlaylistPanelRenderer( val playlistPanelVideoRenderer: PlaylistPanelVideoRenderer?, val automixPreviewVideoRenderer: AutomixPreviewVideoRenderer?, ) -} \ No newline at end of file +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt index 30afb6942..fbaa67be8 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/PlaylistPanelVideoRenderer.kt @@ -1,6 +1,5 @@ package com.zionhuang.innertube.models -import com.zionhuang.innertube.utils.TimeParser import kotlinx.serialization.Serializable @Serializable @@ -9,6 +8,7 @@ data class PlaylistPanelVideoRenderer( val lengthText: Runs?, val longBylineText: Runs?, val shortBylineText: Runs?, + val badges: List?, val videoId: String?, val playlistSetVideoId: String?, val selected: Boolean, @@ -16,27 +16,4 @@ data class PlaylistPanelVideoRenderer( val unplayableText: Runs?, val menu: Menu?, val navigationEndpoint: NavigationEndpoint, -) { - // Best way to get the most detailed song information - fun toSongItem(): SongItem? { - if (videoId == null || title == null || lengthText == null || longBylineText == null || shortBylineText == null || menu == null) return null - val longByLineRuns = longBylineText.runs.splitBySeparator() - return SongItem( - id = videoId, - title = title.toString(), - subtitle = longBylineText.toString(), - artists = longByLineRuns[0].oddElements(), - album = longBylineText.runs - .find { it.navigationEndpoint?.getEndpointType() == ITEM_ALBUM } - ?.toLink(), - albumYear = longByLineRuns.getOrNull(2)?.getOrNull(0)?.text?.toIntOrNull(), - duration = TimeParser.parse(lengthText.runs[0].text), - thumbnails = thumbnail.thumbnails, - menu = menu.toItemMenu(), - navigationEndpoint = navigationEndpoint.copy( - // remove watchEndpoint params so that we can get queue items - watchEndpoint = navigationEndpoint.watchEndpoint?.copy(params = null) - ) - ) - } -} \ No newline at end of file +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt index 53a05dab5..f620ca98f 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Runs.kt @@ -1,29 +1,17 @@ package com.zionhuang.innertube.models -import android.os.Parcelable -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable data class Runs( - val runs: List = emptyList(), -) { - override fun toString() = runs.joinToString(separator = "") { it.text } -} - -fun List.asString() = joinToString(separator = "") { it.text } + val runs: List?, +) -@Parcelize @Serializable data class Run( val text: String, val navigationEndpoint: NavigationEndpoint?, -) : Parcelable { - inline fun toLink(): Link? = - (navigationEndpoint?.endpoint as? T)?.let { - Link(text, it) - } -} +) fun List.splitBySeparator(): List> { val res = mutableListOf>() diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt index fecafcbdd..2952e0688 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SearchSuggestionsSectionRenderer.kt @@ -9,82 +9,12 @@ data class SearchSuggestionsSectionRenderer( @Serializable data class Content( val searchSuggestionRenderer: SearchSuggestionRenderer?, - val musicTwoColumnItemRenderer: MusicTwoColumnItemRenderer?, + val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer?, ) { @Serializable data class SearchSuggestionRenderer( val suggestion: Runs, val navigationEndpoint: NavigationEndpoint, ) - - @Serializable - data class MusicTwoColumnItemRenderer( - val title: Runs, - val subtitle: Runs, - val thumbnail: ThumbnailRenderer, - val menu: Menu, - val navigationEndpoint: NavigationEndpoint, - ) { - fun toItem(): YTItem? = when (navigationEndpoint.getEndpointType()) { - ITEM_SONG -> { - val menu = menu.toItemMenu() - SongItem( - id = navigationEndpoint.watchEndpoint?.videoId!!, - title = title.toString(), - subtitle = subtitle.toString(), - artists = listOf(Run( - text = subtitle.runs.last().text, - navigationEndpoint = menu.artistEndpoint - )), - thumbnails = thumbnail.getThumbnails(), - menu = menu, - navigationEndpoint = navigationEndpoint - ) - } - ITEM_VIDEO -> SongItem( - id = navigationEndpoint.watchEndpoint?.videoId!!, - title = title.toString(), - subtitle = subtitle.toString(), - artists = listOf(subtitle.runs[0]), - thumbnails = thumbnail.getThumbnails(), - menu = menu.toItemMenu(), - navigationEndpoint = navigationEndpoint - ) - ITEM_ALBUM -> { - val menu = menu.toItemMenu() - AlbumItem( - id = navigationEndpoint.browseEndpoint!!.browseId, - playlistId = menu.shuffleEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.radioEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL")!!, - title = title.toString(), - subtitle = subtitle.toString(), - thumbnails = thumbnail.getThumbnails(), - menu = menu, - navigationEndpoint = navigationEndpoint - ) - } - ITEM_PLAYLIST -> { - val menu = menu.toItemMenu() - PlaylistItem( - id = menu.shuffleEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.radioEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL")!!, - title = title.toString(), - subtitle = subtitle.toString(), - thumbnails = thumbnail.getThumbnails(), - menu = menu, - navigationEndpoint = navigationEndpoint - ) - } - ITEM_ARTIST -> ArtistItem( - id = navigationEndpoint.browseEndpoint!!.browseId, - title = title.toString(), - subtitle = subtitle.toString(), - thumbnails = thumbnail.getThumbnails(), - menu = menu.toItemMenu(), - navigationEndpoint = navigationEndpoint - ) - else -> null - } - } } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt index 927e5712e..0c846891b 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt @@ -1,8 +1,5 @@ -@file:OptIn(ExperimentalSerializationApi::class) - package com.zionhuang.innertube.models -import com.zionhuang.innertube.utils.plus import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames @@ -33,15 +30,11 @@ data class SectionListRenderer( val text: Runs?, val uniqueId: String?, ) - - fun toFilter() = Filter( - text = chipCloudChipRenderer.text.toString(), - searchEndpoint = chipCloudChipRenderer.navigationEndpoint.searchEndpoint!! - ) } } } + @OptIn(ExperimentalSerializationApi::class) @Serializable data class Content( @JsonNames("musicImmersiveCarouselShelfRenderer") @@ -50,41 +43,5 @@ data class SectionListRenderer( val musicPlaylistShelfRenderer: MusicPlaylistShelfRenderer?, val musicDescriptionShelfRenderer: MusicDescriptionShelfRenderer?, val gridRenderer: GridRenderer?, - ) { - fun toBaseItems(): List = when { - musicCarouselShelfRenderer != null -> listOfNotNull( - musicCarouselShelfRenderer.header?.toHeader(), - CarouselSection( - id = musicCarouselShelfRenderer.header?.musicCarouselShelfBasicHeaderRenderer?.title.toString() + "_carousel", - items = musicCarouselShelfRenderer.contents.mapNotNull { it.toBaseItem() }, - numItemsPerColumn = musicCarouselShelfRenderer.numItemsPerColumn ?: 1, - itemViewType = musicCarouselShelfRenderer.getViewType() - ) - ) - musicShelfRenderer != null -> musicShelfRenderer.contents?.mapNotNull { it.toItem() }.orEmpty().let { items -> - if (items.isNotEmpty()) musicShelfRenderer.toHeader() + items - else items - } - musicPlaylistShelfRenderer != null -> musicPlaylistShelfRenderer.contents.mapNotNull { it.toItem() } - musicDescriptionShelfRenderer != null -> listOfNotNull( - musicDescriptionShelfRenderer.toSectionHeader(), - DescriptionSection( - description = musicDescriptionShelfRenderer.description.toString() - ) - ) - gridRenderer != null -> if (gridRenderer.items[0].toBaseItem().let { it is NavigationItem && it.stripeColor == null }) { - // bring NavigationItems out to separate items - gridRenderer.header?.toSectionHeader() + gridRenderer.items.map { it.toBaseItem() } - } else { - listOfNotNull( - gridRenderer.header?.toSectionHeader(), - GridSection( - id = gridRenderer.header?.gridHeaderRenderer?.title.toString(), - items = gridRenderer.items.map { it.toBaseItem() } - ) - ) - } - else -> emptyList() - } - } -} \ No newline at end of file + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt index b66d9a1f6..82ec30633 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/ThumbnailRenderer.kt @@ -1,34 +1,24 @@ -@file:OptIn(ExperimentalSerializationApi::class) - package com.zionhuang.innertube.models import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonNames +@OptIn(ExperimentalSerializationApi::class) @Serializable data class ThumbnailRenderer( @JsonNames("croppedSquareThumbnailRenderer") val musicThumbnailRenderer: MusicThumbnailRenderer?, val musicAnimatedThumbnailRenderer: MusicAnimatedThumbnailRenderer?, + val croppedSquareThumbnailRenderer: MusicThumbnailRenderer?, ) { - fun getThumbnails(): List = when { - musicThumbnailRenderer != null -> musicThumbnailRenderer.thumbnail.thumbnails - musicAnimatedThumbnailRenderer != null -> musicAnimatedThumbnailRenderer.backupRenderer.thumbnail.thumbnails - else -> throw UnsupportedOperationException("Unknown thumbnail type") - } - @Serializable data class MusicThumbnailRenderer( val thumbnail: Thumbnails, val thumbnailCrop: String?, val thumbnailScale: String?, ) { - companion object { - const val MUSIC_THUMBNAIL_CROP_UNSPECIFIED = "MUSIC_THUMBNAIL_CROP_UNSPECIFIED" - const val MUSIC_THUMBNAIL_SCALE_ASPECT_FIT = "MUSIC_THUMBNAIL_SCALE_ASPECT_FIT" - const val MUSIC_THUMBNAIL_CROP_CIRCLE = "MUSIC_THUMBNAIL_CROP_CIRCLE" - } + fun getThumbnailUrl() = thumbnail.thumbnails.lastOrNull()?.url } @Serializable @@ -36,4 +26,4 @@ data class ThumbnailRenderer( val animatedThumbnail: Thumbnails, val backupRenderer: MusicThumbnailRenderer, ) -} \ No newline at end of file +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt index 3840f5e1d..cee6ee132 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Thumbnails.kt @@ -1,7 +1,5 @@ package com.zionhuang.innertube.models -import android.os.Parcelable -import kotlinx.parcelize.Parcelize import kotlinx.serialization.Serializable @Serializable @@ -9,12 +7,9 @@ data class Thumbnails( val thumbnails: List, ) -@Parcelize @Serializable data class Thumbnail( val url: String, val width: Int?, val height: Int?, -) : Parcelable { - val isSquare: Boolean get() = width == height -} \ No newline at end of file +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YTBaseItem.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YTBaseItem.kt deleted file mode 100644 index a0e4e0e23..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YTBaseItem.kt +++ /dev/null @@ -1,65 +0,0 @@ -package com.zionhuang.innertube.models - -import android.os.Parcelable -import kotlinx.parcelize.Parcelize - -@Parcelize -sealed class YTBaseItem : Parcelable { - abstract val id: String - - enum class ViewType { - LIST, BLOCK - } -} - -@Parcelize -data class Header( - val title: String, - val subtitle: String? = null, - val moreNavigationEndpoint: NavigationEndpoint? = null, - override val id: String = title, -) : YTBaseItem() - -@Parcelize -data class ArtistHeader( - override val id: String, - val name: String, - val description: String?, - val bannerThumbnails: List?, - val shuffleEndpoint: NavigationEndpoint?, - val radioEndpoint: NavigationEndpoint?, -) : YTBaseItem() - -@Parcelize -data class AlbumOrPlaylistHeader( - override val id: String, // playlistId - val name: String, - val subtitle: String, - val secondSubtitle: String, - val description: String?, - val artists: List?, - val year: Int?, - val thumbnails: List, - val menu: ItemMenu, -) : YTBaseItem() - -@Parcelize -data class CarouselSection( - override val id: String, - val items: List, - val numItemsPerColumn: Int = 1, - val itemViewType: ViewType, -) : YTBaseItem() - -@Parcelize -data class GridSection( - override val id: String, - val items: List, -// val moreNavigationEndpoint: BrowseEndpoint, -) : YTBaseItem() - -@Parcelize -data class DescriptionSection( - override val id: String = "DESCRIPTION", - val description: String, -) : YTBaseItem() \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YTItem.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YTItem.kt index 9f085fb25..e71e088e2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YTItem.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YTItem.kt @@ -1,267 +1,76 @@ package com.zionhuang.innertube.models -import com.zionhuang.innertube.utils.TimeParser -import kotlinx.parcelize.IgnoredOnParcel -import kotlinx.parcelize.Parcelize - -@Parcelize -sealed class YTItem : YTBaseItem() { - abstract override val id: String +sealed class YTItem { + abstract val id: String abstract val title: String - abstract val subtitle: String? - abstract val thumbnails: List - abstract val menu: ItemMenu - abstract val navigationEndpoint: NavigationEndpoint - + abstract val thumbnail: String + abstract val explicit: Boolean abstract val shareLink: String - - interface FromContent { - fun from(item: MusicResponsiveListItemRenderer): T? - fun from(item: MusicTwoRowItemRenderer): T - } } -@Parcelize +data class Artist( + val name: String, + val id: String?, +) + +data class Album( + val name: String, + val id: String, +) + data class SongItem( override val id: String, override val title: String, - override val subtitle: String, - val index: String? = null, - val artists: List, - val album: Link? = null, - val albumYear: Int? = null, + val artists: List, + val album: Album? = null, val duration: Int? = null, - override val thumbnails: List, - override val menu: ItemMenu, - override val navigationEndpoint: NavigationEndpoint, + override val thumbnail: String, + override val explicit: Boolean = false, + val endpoint: WatchEndpoint? = null, ) : YTItem() { - @IgnoredOnParcel - override val shareLink: String = "https://music.youtube.com/watch?v=$id" - - companion object : FromContent { - /** - * Subtitle configurations: - * Video • artist • view count • length - * artist • view count • length - * artist • view count - * artist • (empty) - * - * Note that artist's [Run] may have [navigationEndpoint] null - */ - override fun from(item: MusicResponsiveListItemRenderer): SongItem? { - if (item.menu == null) return null - val menu = item.menu.toItemMenu() - return SongItem( - id = item.playlistItemData?.videoId - ?: item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text?.runs?.firstOrNull()?.navigationEndpoint?.watchEndpoint?.videoId - ?: menu.radioEndpoint?.watchEndpoint?.videoId - ?: return null, - title = item.getTitle(), - subtitle = item.getSubtitle(), - index = item.index?.toString(), - artists = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs - ?.filter { it.navigationEndpoint?.getEndpointType() == ITEM_ARTIST } - ?.ifEmpty { - listOfNotNull( - if (item.fixedColumns != null) { - // Table style - item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs?.getOrNull(0) - } else { - // From search - item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs?.let { - it.getOrNull(it.lastIndex - 4) ?: it.getOrNull(it.lastIndex - 2) - } - } - ) - }.orEmpty(), - album = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs - ?.find { it.navigationEndpoint?.getEndpointType() == ITEM_ALBUM } - ?.toLink(), - duration = item.flexColumns[1].musicResponsiveListItemFlexColumnRenderer.text?.runs?.lastOrNull()?.text?.let { TimeParser.parse(it) } - ?: item.fixedColumns?.firstOrNull()?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text?.let { TimeParser.parse(it) }, - thumbnails = item.thumbnail?.getThumbnails().orEmpty(), - menu = menu, - navigationEndpoint = item.flexColumns[0].musicResponsiveListItemFlexColumnRenderer.text?.runs?.firstOrNull()?.navigationEndpoint!! - ) - } - - override fun from(item: MusicTwoRowItemRenderer): SongItem { - val menu = item.menu.toItemMenu() - return SongItem( - id = item.navigationEndpoint.watchEndpoint?.videoId - ?: menu.radioEndpoint?.watchEndpoint?.videoId!!, - title = item.title.toString(), - subtitle = item.subtitle.toString(), - artists = emptyList(), - thumbnails = item.thumbnailRenderer.getThumbnails(), - menu = menu, - navigationEndpoint = item.navigationEndpoint - ) - } - } + override val shareLink: String + get() = "https://music.youtube.com/watch?v=$id" } -@Parcelize data class AlbumItem( - override val id: String, // browseId + val browseId: String, val playlistId: String, + override val id: String = browseId, override val title: String, - override val subtitle: String, + val artists: List?, val year: Int? = null, - override val thumbnails: List, - override val menu: ItemMenu, - override val navigationEndpoint: NavigationEndpoint, + override val thumbnail: String, + override val explicit: Boolean = false, ) : YTItem() { - @IgnoredOnParcel - override val shareLink: String = "https://music.youtube.com/playlist?list=$id" - - companion object : FromContent { - override fun from(item: MusicResponsiveListItemRenderer): AlbumItem? { - if (item.menu == null) return null - val menu = item.menu.toItemMenu() - return AlbumItem( - id = item.navigationEndpoint!!.browseEndpoint!!.browseId, - playlistId = menu.shuffleEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.radioEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.playNextEndpoint?.queueAddEndpoint?.queueTarget?.playlistId!!, - title = item.getTitle(), - subtitle = item.getSubtitle(), - year = item.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.lastOrNull()?.text?.toIntOrNull(), - thumbnails = item.thumbnail!!.getThumbnails(), - menu = menu, - navigationEndpoint = item.navigationEndpoint - ) - } - - override fun from(item: MusicTwoRowItemRenderer): AlbumItem { - val menu = item.menu.toItemMenu() - return AlbumItem( - id = item.navigationEndpoint.browseEndpoint!!.browseId, - playlistId = menu.shuffleEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.radioEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.playNextEndpoint?.queueAddEndpoint?.queueTarget?.playlistId!!, - title = item.title.toString(), - subtitle = item.subtitle.toString(), - thumbnails = item.thumbnailRenderer.getThumbnails(), - year = item.subtitle.runs.lastOrNull()?.text?.toIntOrNull(), - menu = menu, - navigationEndpoint = item.navigationEndpoint - ) - } - } + override val shareLink: String + get() = "https://music.youtube.com/playlist?list=$playlistId" } -@Parcelize data class PlaylistItem( override val id: String, override val title: String, - override val subtitle: String, - override val thumbnails: List, - override val menu: ItemMenu, - override val navigationEndpoint: NavigationEndpoint, + val author: Artist, + val songCountText: String?, + override val thumbnail: String, + val playEndpoint: WatchEndpoint?, + val shuffleEndpoint: WatchEndpoint, + val radioEndpoint: WatchEndpoint, ) : YTItem() { - @IgnoredOnParcel - override val shareLink: String = "https://music.youtube.com/playlist?list=$id" - - companion object : FromContent { - override fun from(item: MusicResponsiveListItemRenderer): PlaylistItem? { - if (item.menu == null) return null - val menu = item.menu.toItemMenu() - return PlaylistItem( - id = item.navigationEndpoint?.browseEndpoint?.browseId?.removePrefix("VL") - ?: menu.shuffleEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.radioEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL")!!, - title = item.getTitle(), - subtitle = item.getSubtitle(), - thumbnails = item.thumbnail!!.getThumbnails(), - menu = menu, - navigationEndpoint = item.navigationEndpoint!! - ) - } - - override fun from(item: MusicTwoRowItemRenderer): PlaylistItem { - val menu = item.menu.toItemMenu() - return PlaylistItem( - id = item.navigationEndpoint.browseEndpoint?.browseId?.removePrefix("VL") - ?: item.title.runs.getOrNull(0)?.navigationEndpoint?.browseEndpoint?.browseId?.removePrefix("VL") - ?: menu.shuffleEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL") - ?: menu.radioEndpoint?.watchPlaylistEndpoint?.playlistId?.removePrefix("RDAMPL")!!, - title = item.title.toString(), - subtitle = item.subtitle.toString(), - thumbnails = item.thumbnailRenderer.getThumbnails(), - menu = menu, - navigationEndpoint = item.navigationEndpoint - ) - } - } + override val explicit: Boolean + get() = false + override val shareLink: String + get() = "https://music.youtube.com/playlist?list=$id" } -@Parcelize data class ArtistItem( override val id: String, override val title: String, - override val subtitle: String, - override val thumbnails: List, - override val menu: ItemMenu, - override val navigationEndpoint: NavigationEndpoint, + override val thumbnail: String, + val shuffleEndpoint: WatchEndpoint?, + val radioEndpoint: WatchEndpoint?, ) : YTItem() { - @IgnoredOnParcel - override val shareLink: String = "https://music.youtube.com/channel/$id" - - companion object : FromContent { - override fun from(item: MusicResponsiveListItemRenderer): ArtistItem? { - if (item.menu == null) return null - return ArtistItem( - id = item.navigationEndpoint?.browseEndpoint?.browseId!!, - title = item.getTitle(), - subtitle = item.getSubtitle(), - thumbnails = item.thumbnail!!.getThumbnails(), - menu = item.menu.toItemMenu(), - navigationEndpoint = item.navigationEndpoint - ) - } - - override fun from(item: MusicTwoRowItemRenderer): ArtistItem = ArtistItem( - id = item.navigationEndpoint.browseEndpoint?.browseId!!, - title = item.title.toString(), - subtitle = item.subtitle.toString(), - thumbnails = item.thumbnailRenderer.getThumbnails(), - menu = item.menu.toItemMenu(), - navigationEndpoint = item.navigationEndpoint - ) - } + override val explicit: Boolean + get() = false + override val shareLink: String + get() = "https://music.youtube.com/channel/$id" } - -@Parcelize -data class NavigationItem( - val title: String, - override val id: String = title, - val subtitle: String? = null, - val icon: String? = null, - val stripeColor: Long? = null, - val navigationEndpoint: NavigationEndpoint, -) : YTBaseItem() - -@Parcelize -data class SuggestionTextItem( - val query: String, - val source: SuggestionSource = SuggestionSource.YOUTUBE, - override val id: String = query, -) : YTBaseItem() { - enum class SuggestionSource { - LOCAL, YOUTUBE - } -} - -@Parcelize -object Separator : YTBaseItem() { - @IgnoredOnParcel - override val id: String = "SEPARATOR" -} - -const val ITEM_UNKNOWN = -1 -const val ITEM_SONG = 0 -const val ITEM_VIDEO = 1 -const val ITEM_ALBUM = 2 -const val ITEM_PLAYLIST = 3 -const val ITEM_ARTIST = 4 \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt index a4f6cf089..178b29efc 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeLocale.kt @@ -6,4 +6,4 @@ import kotlinx.serialization.Serializable data class YouTubeLocale( val gl: String, // geolocation val hl: String, // host language -) \ No newline at end of file +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt index 53d61899f..2d28dcc54 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt @@ -11,34 +11,6 @@ data class BrowseResponse( val microformat: Microformat?, val responseContext: ResponseContext, ) { - fun toBrowseResult(): BrowseResult = when { - continuationContents != null -> when { - continuationContents.sectionListContinuation != null -> BrowseResult( - items = continuationContents.sectionListContinuation.contents.flatMap { it.toBaseItems() }, - urlCanonical = microformat?.microformatDataRenderer?.urlCanonical, - continuations = continuationContents.sectionListContinuation.continuations?.getContinuations() - ) - continuationContents.musicPlaylistShelfContinuation != null -> BrowseResult( - items = continuationContents.musicPlaylistShelfContinuation.contents.mapNotNull { it.toItem() }, - urlCanonical = microformat?.microformatDataRenderer?.urlCanonical, - continuations = continuationContents.musicPlaylistShelfContinuation.continuations?.getContinuations() - ) - else -> throw UnsupportedOperationException("Unknown continuation type") - } - contents != null -> BrowseResult( - items = contents.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.flatMap { it.toBaseItems() }.orEmpty(), - lyrics = contents.sectionListRenderer?.contents?.firstOrNull()?.musicDescriptionShelfRenderer?.description?.runs?.firstOrNull()?.text, - urlCanonical = microformat?.microformatDataRenderer?.urlCanonical, - continuations = contents.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.musicPlaylistShelfRenderer?.continuations?.getContinuations().orEmpty() - + contents.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.continuations?.getContinuations().orEmpty() - ) - else -> BrowseResult( - items = emptyList(), - urlCanonical = null, - continuations = null - ) - }.addHeader(header?.toHeader()) - @Serializable data class Contents( val singleColumnBrowseResultsRenderer: Tabs?, @@ -67,35 +39,9 @@ data class BrowseResponse( data class Header( val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?, val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?, + val musicVisualHeaderRenderer: MusicVisualHeaderRenderer?, + val musicHeaderRenderer: MusicHeaderRenderer?, ) { - fun toHeader(): YTBaseItem? = when { - musicImmersiveHeaderRenderer != null -> ArtistHeader( - id = musicImmersiveHeaderRenderer.title.toString(), - name = musicImmersiveHeaderRenderer.title.toString(), - description = musicImmersiveHeaderRenderer.description?.toString(), - bannerThumbnails = musicImmersiveHeaderRenderer.thumbnail?.getThumbnails(), - shuffleEndpoint = musicImmersiveHeaderRenderer.playButton?.buttonRenderer?.navigationEndpoint, - radioEndpoint = musicImmersiveHeaderRenderer.startRadioButton?.buttonRenderer?.navigationEndpoint, - ) - musicDetailHeaderRenderer != null -> { - val subtitle = musicDetailHeaderRenderer.subtitle.runs.splitBySeparator() - val menu = musicDetailHeaderRenderer.menu.toItemMenu() - AlbumOrPlaylistHeader( - id = menu.playNextEndpoint?.queueAddEndpoint?.queueTarget?.playlistId - ?: menu.addToQueueEndpoint?.queueAddEndpoint?.queueTarget?.playlistId!!, - name = musicDetailHeaderRenderer.title.toString(), - subtitle = musicDetailHeaderRenderer.subtitle.runs.drop(2).asString(), - secondSubtitle = musicDetailHeaderRenderer.secondSubtitle.toString(), - description = musicDetailHeaderRenderer.description?.toString(), - artists = subtitle.getOrNull(1)?.oddElements(), - year = subtitle.getOrNull(2)?.firstOrNull()?.text?.toIntOrNull(), - thumbnails = musicDetailHeaderRenderer.thumbnail.getThumbnails(), - menu = musicDetailHeaderRenderer.menu.toItemMenu() - ) - } - else -> null - } - @Serializable data class MusicImmersiveHeaderRenderer( val title: Runs, @@ -115,6 +61,18 @@ data class BrowseResponse( val thumbnail: ThumbnailRenderer, val menu: Menu, ) + + @Serializable + data class MusicVisualHeaderRenderer( + val title: Runs, + val foregroundThumbnail: ThumbnailRenderer.MusicThumbnailRenderer, + val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer?, + ) + + @Serializable + data class MusicHeaderRenderer( + val title: Runs, + ) } @Serializable diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt index 4c8266019..4aee02938 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt @@ -1,7 +1,6 @@ package com.zionhuang.innertube.models.response import com.zionhuang.innertube.models.Continuation -import com.zionhuang.innertube.models.YTItem import com.zionhuang.innertube.models.MusicResponsiveListItemRenderer import com.zionhuang.innertube.models.Tabs import kotlinx.serialization.Serializable @@ -28,9 +27,7 @@ data class SearchResponse( @Serializable data class Content( val musicResponsiveListItemRenderer: MusicResponsiveListItemRenderer, - ) { - fun toItem(): YTItem? = musicResponsiveListItemRenderer.toItem() - } + ) } } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/AlbumPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/AlbumPage.kt new file mode 100644 index 000000000..7db06effb --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/AlbumPage.kt @@ -0,0 +1,39 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class AlbumPage( + val album: AlbumItem, + val songs: List, +) { + companion object { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): SongItem? { + return SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId!! + ) + } ?: return null, + duration = renderer.fixedColumns?.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() + ?.text?.parseTime() ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + } +} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsContinuationPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsContinuationPage.kt new file mode 100644 index 000000000..dafc1c762 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsContinuationPage.kt @@ -0,0 +1,41 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class ArtistItemsContinuationPage( + val items: List, + val continuation: String?, +) { + companion object { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): SongItem? { + return SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text + ?.runs?.firstOrNull()?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return null + ) + }, + duration = renderer.fixedColumns?.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text + ?.runs?.firstOrNull() + ?.text?.parseTime() ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null, + endpoint = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint + ) + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt new file mode 100644 index 000000000..04f4b983e --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt @@ -0,0 +1,58 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class ArtistItemsPage( + val title: String, + val items: List, + val continuation: String?, +) { + companion object { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): SongItem? { + return SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text + ?.runs?.firstOrNull()?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return null + ) + }, + duration = renderer.fixedColumns?.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text + ?.runs?.firstOrNull() + ?.text?.parseTime() ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null, + endpoint = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint + ) + } + + fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): AlbumItem? { + return AlbumItem( + browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + playlistId = renderer.thumbnailOverlay?.musicItemThumbnailOverlayRenderer + ?.content?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = null, + year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.subtitleBadges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt new file mode 100644 index 000000000..b37aaa67d --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt @@ -0,0 +1,149 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* + +data class ArtistSection( + val title: String, + val items: List, + val moreEndpoint: BrowseEndpoint?, +) + +data class ArtistPage( + val artist: ArtistItem, + val sections: List, + val description: String?, +) { + companion object { + fun fromSectionListRendererContent(content: SectionListRenderer.Content): ArtistSection? { + return when { + content.musicShelfRenderer != null -> fromMusicShelfRenderer(content.musicShelfRenderer) + content.musicCarouselShelfRenderer != null -> fromMusicCarouselShelfRenderer(content.musicCarouselShelfRenderer) + else -> null + } + } + + private fun fromMusicShelfRenderer(renderer: MusicShelfRenderer): ArtistSection? { + return ArtistSection( + title = renderer.title?.runs?.firstOrNull()?.text ?: return null, + items = renderer.contents?.mapNotNull { + fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + } ?: return null, + moreEndpoint = renderer.title.runs.firstOrNull()?.navigationEndpoint?.browseEndpoint + ) + } + + private fun fromMusicCarouselShelfRenderer(renderer: MusicCarouselShelfRenderer): ArtistSection? { + return ArtistSection( + title = renderer.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: return null, + items = renderer.contents.mapNotNull { + it.musicTwoRowItemRenderer?.let { renderer -> + fromMusicTwoRowItemRenderer(renderer) + } + }, + moreEndpoint = renderer.header.musicCarouselShelfBasicHeaderRenderer.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint + ) + } + + private fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): SongItem? { + return SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() + ?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId!! + ) + }, + duration = null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + + private fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): YTItem? { + return when { + renderer.isSong -> { + SongItem( + id = renderer.navigationEndpoint.watchEndpoint?.videoId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = listOfNotNull(renderer.subtitle.runs?.firstOrNull()?.let { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + }), + album = null, + duration = null, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.subtitleBadges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isAlbum -> { + AlbumItem( + browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + playlistId = renderer.thumbnailOverlay + ?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = null, + year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.subtitleBadges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isPlaylist -> { + // Playlist from YouTube Music + PlaylistItem( + id = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + author = Artist( + name = renderer.subtitle.runs?.lastOrNull()?.text ?: return null, + id = null + ), + songCountText = null, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + playEndpoint = renderer.thumbnailOverlay + ?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint ?: return null, + shuffleEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MIX" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + } + renderer.isArtist -> { + ArtistItem( + id = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + title = renderer.title.runs?.lastOrNull()?.text ?: return null, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + shuffleEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MIX" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + ) + } + else -> null + } + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/NextPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/NextPage.kt new file mode 100644 index 000000000..086e30332 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/NextPage.kt @@ -0,0 +1,42 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class NextResult( + val title: String? = null, + val items: List, + val currentIndex: Int? = null, + val lyricsEndpoint: BrowseEndpoint? = null, + val relatedEndpoint: BrowseEndpoint? = null, + val continuation: String?, +) + +object NextPage { + fun fromPlaylistPanelVideoRenderer(renderer: PlaylistPanelVideoRenderer): SongItem? { + val longByLineRuns = renderer.longBylineText?.runs?.splitBySeparator() ?: return null + return SongItem( + id = renderer.videoId ?: return null, + title = renderer.title?.runs?.firstOrNull()?.text ?: return null, + artists = longByLineRuns.firstOrNull()?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = longByLineRuns.getOrNull(1)?.firstOrNull()?.takeIf { + it.navigationEndpoint?.browseEndpoint != null + }?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId!! + ) + }, + duration = renderer.lengthText?.runs?.firstOrNull()?.text?.parseTime() ?: return null, + thumbnail = renderer.thumbnail.thumbnails.lastOrNull()?.url ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistContinuationPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistContinuationPage.kt new file mode 100644 index 000000000..19aba1f6b --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistContinuationPage.kt @@ -0,0 +1,8 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.SongItem + +data class PlaylistContinuationPage( + val songs: List, + val continuation: String?, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt new file mode 100644 index 000000000..a199dc919 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt @@ -0,0 +1,39 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class PlaylistPage( + val playlist: PlaylistItem, + val songs: List, + val songsContinuation: String?, + val continuation: String?, +) { + companion object { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): SongItem? { + return SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text + ?.runs?.firstOrNull()?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return null + ) + }, + duration = renderer.fixedColumns?.firstOrNull()?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text?.parseTime(), + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt new file mode 100644 index 000000000..65674d493 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt @@ -0,0 +1,104 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class SearchResult( + val items: List, + val continuation: String? = null, +) + +object SearchPage { + fun toYTItem(renderer: MusicResponsiveListItemRenderer): YTItem? { + val secondaryLine = renderer.flexColumns.getOrNull(1) + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.splitBySeparator() + ?: return null + return when { + renderer.isSong -> { + SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + artists = secondaryLine.firstOrNull()?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = secondaryLine.getOrNull(1)?.firstOrNull()?.takeIf { it.navigationEndpoint?.browseEndpoint != null }?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId!! + ) + }, + duration = secondaryLine.lastOrNull()?.firstOrNull()?.text?.parseTime(), + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isArtist -> { + ArtistItem( + id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + title = renderer.flexColumns.firstOrNull()?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + shuffleEndpoint = renderer.menu?.menuRenderer?.items + ?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items + .find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + } + renderer.isAlbum -> { + AlbumItem( + browseId = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + playlistId = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + artists = secondaryLine.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + year = secondaryLine.getOrNull(2)?.firstOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isPlaylist -> { + PlaylistItem( + id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + author = secondaryLine.firstOrNull()?.firstOrNull()?.let { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + songCountText = renderer.flexColumns.getOrNull(1) + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.lastOrNull()?.text ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + playEndpoint = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint ?: return null, + shuffleEndpoint = renderer.menu?.menuRenderer?.items + ?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items + .find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + } + else -> null + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSuggestionPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSuggestionPage.kt new file mode 100644 index 000000000..5dc0bb13d --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSuggestionPage.kt @@ -0,0 +1,74 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* + +object SearchSuggestionPage { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): YTItem? { + return when { + renderer.isSong -> { + SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() + ?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.splitBySeparator() + ?.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return null + ) + }, + duration = null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isArtist -> { + ArtistItem( + id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + title = renderer.flexColumns.firstOrNull()?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + shuffleEndpoint = renderer.menu?.menuRenderer?.items + ?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint, + radioEndpoint = renderer.menu?.menuRenderer?.items + ?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint + ) + } + renderer.isAlbum -> { + val secondaryLine = renderer.flexColumns.getOrNull(1) + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.splitBySeparator() ?: return null + AlbumItem( + browseId = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + playlistId = renderer.menu?.menuRenderer?.items?.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() + ?.text ?: return null, + artists = secondaryLine.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + year = secondaryLine.lastOrNull()?.firstOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + else -> null + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt new file mode 100644 index 000000000..95d1f8951 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt @@ -0,0 +1,108 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.utils.parseTime + +data class SearchSummary( + val title: String, + val items: List, +) + +data class SearchSummaryPage( + val summaries: List, +) { + companion object { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): YTItem? { + val secondaryLine = renderer.flexColumns.getOrNull(1) + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.splitBySeparator() + ?: return null + return when { + renderer.isSong -> { + SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + artists = secondaryLine.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = secondaryLine.getOrNull(2)?.firstOrNull()?.takeIf { it.navigationEndpoint?.browseEndpoint != null }?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId!! + ) + }, + duration = secondaryLine.lastOrNull()?.firstOrNull()?.text?.parseTime(), + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isArtist -> { + ArtistItem( + id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + title = renderer.flexColumns.firstOrNull()?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.text ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + shuffleEndpoint = renderer.menu?.menuRenderer?.items + ?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items + .find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + } + renderer.isAlbum -> { + AlbumItem( + browseId = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + playlistId = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + artists = secondaryLine.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + year = secondaryLine.getOrNull(2)?.firstOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + renderer.isPlaylist -> { + PlaylistItem( + id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.firstOrNull()?.text ?: return null, + author = secondaryLine.getOrNull(1)?.firstOrNull()?.let { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + songCountText = renderer.flexColumns.getOrNull(1) + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs + ?.lastOrNull()?.text ?: return null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + playEndpoint = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchPlaylistEndpoint ?: return null, + shuffleEndpoint = renderer.menu?.menuRenderer?.items + ?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items + .find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" } + ?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + } + else -> null + } + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/Extension.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/Extension.kt deleted file mode 100644 index d59666203..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/Extension.kt +++ /dev/null @@ -1,43 +0,0 @@ -package com.zionhuang.innertube.utils - -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.BrowseEndpoint -import com.zionhuang.innertube.models.BrowseResult -import com.zionhuang.innertube.models.YTBaseItem - -suspend fun YouTube.browseAll(browseEndpoint: BrowseEndpoint): Result> = runCatching { - val items = mutableListOf() - var browseResult: BrowseResult? = null - do { - browseResult = if (browseResult == null) { - browse(browseEndpoint).getOrThrow() - } else { - browse(browseResult.continuations!!).getOrThrow() - } - items.addAll(browseResult.items) - } while (!browseResult?.continuations.isNullOrEmpty()) - items -} - -operator fun E?.plus(list: List): List { - if (this == null) return list - val res = ArrayList(1 + list.size) - res.add(this) - res.addAll(list) - return res -} - -fun List.insertSeparator( - generator: (before: E, after: E) -> E?, -): List { - val result = mutableListOf() - for (i in indices) { - result.add(get(i)) - if (i != size - 1) { - generator(get(i), get(i + 1))?.let { - result.add(it) - } - } - } - return result -} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt deleted file mode 100644 index c379f1f89..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/TimeParser.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.innertube.utils - -object TimeParser { - fun parse(text: String): Int? { - try { - val parts = text.split(":").map { it.toInt() } - if (parts.size == 2) { - return parts[0] * 60 + parts[1] - } - if (parts.size == 3) { - return parts[0] * 1440 + parts[1] * 60 + parts[2] - } - } catch (e: Exception) { - return null - } - return null - } -} \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt index a6da8b408..99e79f68d 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt @@ -1,8 +1,5 @@ package com.zionhuang.innertube.utils -import java.io.UnsupportedEncodingException -import java.net.URL -import java.net.URLDecoder import java.security.MessageDigest fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } @@ -15,65 +12,17 @@ fun parseCookieString(cookie: String): Map = key to value } - -fun isHTTP(url: URL): Boolean { - // Make sure it's HTTP or HTTPS - val protocol = url.protocol - if (protocol != "http" && protocol != "https") { - return false - } - val usesDefaultPort = url.port == url.defaultPort - val setsNoPort = url.port == -1 - return setsNoPort || usesDefaultPort -} - -fun isYoutubeURL(url: URL): Boolean { - val host = url.host - return host.equals("youtube.com", ignoreCase = true) - || host.equals("www.youtube.com", ignoreCase = true) - || host.equals("m.youtube.com", ignoreCase = true) - || host.equals("music.youtube.com", ignoreCase = true) -} - -/** - * Get the value of a URL-query by name. - * - * - * - * If an url-query is give multiple times, only the value of the first query is returned. - * - * - * @param url the url to be used - * @param parameterName the pattern that will be used to check the url - * @return a string that contains the value of the query parameter or `null` if nothing - * was found - */ -fun getQueryValue( - url: URL, - parameterName: String, -): String? { - val urlQuery = url.query - if (urlQuery != null) { - for (param in urlQuery.split("&".toRegex())) { - val params = param.split("=".toRegex(), 2) - val query = try { - URLDecoder.decode(params[0], "UTF-8") - } catch (e: UnsupportedEncodingException) { - // Cannot decode string with UTF-8, using the string without decoding - params[0] - } - if (query == parameterName) { - return try { - URLDecoder.decode(params[1], "UTF-8") - } catch (e: UnsupportedEncodingException) { - // Cannot decode string with UTF-8, using the string without decoding - params[1] - } - } +fun String.parseTime(): Int? { + try { + val parts = split(":").map { it.toInt() } + if (parts.size == 2) { + return parts[0] * 60 + parts[1] } + if (parts.size == 3) { + return parts[0] * 1440 + parts[1] * 60 + parts[2] + } + } catch (e: Exception) { + return null } return null } - -fun isYoutubeChannelMixId(playlistId: String): Boolean = - playlistId.startsWith("RDCM") diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/YouTubeLinkHandler.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/YouTubeLinkHandler.kt deleted file mode 100644 index 3b6bee0e3..000000000 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/YouTubeLinkHandler.kt +++ /dev/null @@ -1,172 +0,0 @@ -package com.zionhuang.innertube.utils - -import java.net.MalformedURLException -import java.net.URI -import java.net.URISyntaxException -import java.net.URL -import java.util.regex.Pattern - -object YouTubeLinkHandler { - private val YOUTUBE_VIDEO_ID_REGEX_PATTERN = Pattern.compile("^([a-zA-Z0-9_-]{11})") - private val SUBPATHS = listOf("embed/", "shorts/", "watch/", "v/", "w/") - private val EXCLUDED_SEGMENTS = Pattern.compile("playlist|watch|attribution_link|watch_popup|embed|feed|select_site") - - /** - * Convert a string to a [URL object][URL]. - * - * - * - * Defaults to HTTP if no protocol is given. - * - * - * @param url the string to be converted to a URL-Object - * @return a [URL object][URL] containing the url - */ - private fun stringToURL(url: String): URL = - try { - URL(url) - } catch (e: MalformedURLException) { - // If no protocol is given try prepending "https://" - if (e.message == "no protocol: $url") URL("https://$url") else throw e - } - - private fun extractVideoId(id: String): String? { - val m = YOUTUBE_VIDEO_ID_REGEX_PATTERN.matcher(id) - return if (m.find()) m.group(1) else null - } - - private fun getIdFromSubpathsInPath(path: String): String? { - for (subpath in SUBPATHS) { - if (path.startsWith(subpath)) { - val id = path.substring(subpath.length) - return extractVideoId(id) - } - } - return null - } - - fun getVideoId(theUrlString: String): String? { - var urlString = theUrlString - try { - val uri = URI(urlString) - val scheme = uri.scheme - if (scheme != null && (scheme == "vnd.youtube" || scheme == "vnd.youtube.launch")) { - val schemeSpecificPart = uri.schemeSpecificPart - urlString = - if (schemeSpecificPart.startsWith("//")) { - extractVideoId(schemeSpecificPart.substring(2))?.let { return it } - "https:$schemeSpecificPart" - } else { - extractVideoId(schemeSpecificPart) ?: return null - } - } - } catch (ignored: URISyntaxException) { - } - val url: URL = try { - stringToURL(urlString) - } catch (e: MalformedURLException) { - return null - } - val host = url.host - var path = url.path - // remove leading "/" of URL-path if URL-path is given - if (path.isNotEmpty()) { - path = path.substring(1) - } - return when (host.uppercase()) { - "WWW.YOUTUBE-NOCOOKIE.COM" -> if (path.startsWith("embed/")) extractVideoId(path.substring(6)) else null - "YOUTUBE.COM", "WWW.YOUTUBE.COM", "M.YOUTUBE.COM", "MUSIC.YOUTUBE.COM" -> { - getIdFromSubpathsInPath(path)?.let { return it } - getQueryValue(url, "v")?.let { - extractVideoId(it) - } - } - "Y2U.BE", "YOUTU.BE" -> { - getQueryValue(url, "v")?.let { extractVideoId(it) } - ?: extractVideoId(path) - } - else -> null - } - } - - /** - * Returns true if path conform to - * custom short channel URLs like youtube.com/yourcustomname - * - * @param splitPath path segments array - * @return true - if value conform to short channel URL, false - not - */ - private fun isCustomShortChannelUrl(splitPath: List): Boolean = - splitPath.size == 1 && !EXCLUDED_SEGMENTS.matcher(splitPath[0]).matches() - - fun getChannelId(url: String): String? { - try { - val urlObj = stringToURL(url) - var path = urlObj.path - - if (!isHTTP(urlObj) || !isYoutubeURL(urlObj)) { - // the URL given is not a Youtube-URL - return null - } - - // remove leading "/" - path = path.substring(1) - var splitPath = path.split("/".toRegex()) - - // Handle custom short channel URLs like youtube.com/yourcustomname - if (isCustomShortChannelUrl(splitPath)) { - path = "c/$path" - splitPath = path.split("/".toRegex()) - } - if (!path.startsWith("user/") && !path.startsWith("channel/") && !path.startsWith("c/")) { - // the URL given is neither a channel nor an user - return null - } - val id = splitPath.getOrNull(1) - if (id == null || !id.matches("[A-Za-z0-9_-]+".toRegex())) { - // The given id is not a Youtube-Video-ID - return null - } - return id - } catch (exception: Exception) { - return null - } - } - - fun getPlaylistId(url: String): String? { - try { - val urlObj = stringToURL(url) - if (!isHTTP(urlObj) || !isYoutubeURL(urlObj)) { - // the url given is not a YouTube-URL - return null - } - val path = urlObj.path - if (path != "/watch" && path != "/playlist") { - // the url given is neither a video nor a playlist URL - return null - } - val listID = getQueryValue(urlObj, "list") ?: return null // the URL given does not include a playlist - if (!listID.matches("[a-zA-Z0-9_-]{10,}".toRegex())) { - // the list-ID given in the URL does not match the list pattern - return null - } - if (isYoutubeChannelMixId(listID) && getQueryValue(urlObj, "v") == null) { - // Video id can't be determined from the channel mix id. - // See YoutubeParsingHelper#extractVideoIdFromMixId - - // Channel Mix without a video id are not supported - return null - } - return listID - } catch (exception: Exception) { - return null - } - } - - fun getBrowseId(url: String): String? = - if (url.startsWith("https://music.youtube.com/browse/")) { - url.substring("https://music.youtube.com/browse/".length) - } else { - null - } -} diff --git a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt index d851067ad..5e1c7c432 100644 --- a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt +++ b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt @@ -1,17 +1,12 @@ package com.zionhuang.innertube -import com.zionhuang.innertube.YouTube.EXPLORE_BROWSE_ID -import com.zionhuang.innertube.YouTube.HOME_BROWSE_ID import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_PLAYLIST import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO -import com.zionhuang.innertube.models.BrowseEndpoint import com.zionhuang.innertube.models.WatchEndpoint -import com.zionhuang.innertube.models.YTItem -import com.zionhuang.innertube.utils.browseAll import io.ktor.client.* import io.ktor.client.engine.okhttp.* import io.ktor.client.request.* @@ -53,8 +48,8 @@ class YouTubeTest { @Test fun `Check 'search' endpoint`() = runBlocking { // Top result with radio link - val searchAllTypeResult = youTube.searchAllType("musi").getOrThrow() - assertTrue(searchAllTypeResult.items.size > 1) + val searchAllTypeResult = youTube.searchSummary("musi").getOrThrow() + assertTrue(searchAllTypeResult.summaries.size > 1) for (filter in listOf( FILTER_SONG, FILTER_VIDEO, @@ -72,18 +67,16 @@ class YouTubeTest { fun `Check search continuation`() = runBlocking { var count = 5 var searchResult = youTube.search(SEARCH_QUERY, FILTER_SONG).getOrThrow() - while (searchResult.continuations != null && count > 0) { + while (searchResult.continuation != null && count > 0) { searchResult.items.forEach { - if (it is YTItem) println(it.title) + println(it.title) } - searchResult = youTube.search(searchResult.continuations!![0]).getOrThrow() + searchResult = youTube.searchContinuation(searchResult.continuation!!).getOrThrow() count -= 1 } searchResult.items.forEach { - if (it is YTItem) println(it.title) + println(it.title) } - // audio book - searchResult = youTube.search("tomori kusunoki", FILTER_ALBUM).getOrThrow() } @Test @@ -94,24 +87,14 @@ class YouTubeTest { @Test fun `Check 'browse' endpoint`() = runBlocking { - var artist = youTube.browse(BrowseEndpoint("UCI6B8NkZKqlFWoiC_xE-hzA")).getOrThrow() - assertTrue(artist.items.isNotEmpty()) - artist = youTube.browse(BrowseEndpoint("UCy2RKLxIOMOfGld_yBYEBLw")).getOrThrow() // Artist that contains audiobook - assertTrue(artist.items.isNotEmpty()) - val album = youTube.browse(BrowseEndpoint("MPREb_oNAdr9eUOfS")).getOrThrow() - assertTrue(album.items.isNotEmpty()) - val playlist = youTube.browse(BrowseEndpoint("VLRDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo")).getOrThrow() - assertTrue(playlist.items.isNotEmpty()) - listOf(HOME_BROWSE_ID, EXPLORE_BROWSE_ID).forEach { browseId -> - val result = youTube.browse(BrowseEndpoint(browseId)).getOrThrow() - assertTrue(result.items.isNotEmpty()) - } - } - - @Test - fun `Check 'browse' continuation`() = runBlocking { - val result = youTube.browseAll(BrowseEndpoint(HOME_BROWSE_ID)).getOrThrow() - assertTrue(result.isNotEmpty()) + var artist = youTube.browseArtist("UCI6B8NkZKqlFWoiC_xE-hzA").getOrThrow() + assertTrue(artist.sections.isNotEmpty()) + artist = youTube.browseArtist("UCy2RKLxIOMOfGld_yBYEBLw").getOrThrow() // Artist that contains audiobook + assertTrue(artist.sections.isNotEmpty()) + val album = youTube.browseAlbum("MPREb_oNAdr9eUOfS").getOrThrow() + assertTrue(album.songs.isNotEmpty()) + val playlist = youTube.browsePlaylist("VLRDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo").getOrThrow() + assertTrue(playlist.songs.isNotEmpty()) } @Test @@ -143,7 +126,7 @@ class YouTubeTest { @Test fun `Check 'get_queue' endpoint`() = runBlocking { var queue = youTube.getQueue(videoIds = VIDEO_IDS).getOrThrow() - assertTrue(queue[0].navigationEndpoint.watchEndpoint!!.videoId == VIDEO_IDS[0]) + assertTrue(queue.isNotEmpty()) queue = youTube.getQueue(playlistId = PLAYLIST_ID).getOrThrow() assertTrue(queue.isNotEmpty()) } @@ -153,24 +136,26 @@ class YouTubeTest { // This playlist has 2900 songs val browseId = "VLPLtAw-mgfCzRwduBTjBHknz5U4_ZM4n6qm" var count = 5 - var result = YouTube.browse(BrowseEndpoint(browseId)).getOrThrow() - while (result.continuations != null && count > 0) { - result.items.forEach { + val playlistPage = YouTube.browsePlaylist(browseId).getOrThrow() + var songs = playlistPage.songs + var continuation = playlistPage.songsContinuation + while (count > 0) { + songs.forEach { println(it.id) } - result = YouTube.browse(result.continuations!!).getOrThrow() - count -= 1 - } - result.items.forEach { - println(it.id) + if (continuation == null) break + val continuationPage = YouTube.browsePlaylistContinuation(continuation).getOrThrow() + songs = continuationPage.songs + continuation = continuationPage.continuation + count-- } } @Test fun lyrics() = runBlocking { val nextResult = YouTube.next(WatchEndpoint(videoId = "NCC6lI0GGy0")).getOrThrow() - val browseResult = YouTube.browse(nextResult.lyricsEndpoint!!).getOrThrow() - assertTrue(browseResult.lyrics != null) + val lyrics = YouTube.getLyrics(nextResult.lyricsEndpoint!!).getOrThrow() + assertTrue(lyrics != null) } companion object { @@ -184,4 +169,4 @@ class YouTubeTest { private const val SEARCH_QUERY = "YOASOBI" } -} \ No newline at end of file +} diff --git a/kugou/build.gradle.kts b/kugou/build.gradle.kts index 3b533af19..f76b24832 100644 --- a/kugou/build.gradle.kts +++ b/kugou/build.gradle.kts @@ -4,15 +4,6 @@ plugins { alias(libs.plugins.kotlin.serialization) } -java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} - -tasks.withType().configureEach { - kotlinOptions.freeCompilerArgs += "-opt-in=kotlin.RequiresOptIn" -} - dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt b/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt index 22970d56e..17de292ed 100644 --- a/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt +++ b/kugou/src/main/java/com/zionhuang/kugou/KuGou.kt @@ -141,7 +141,7 @@ object KuGou { .replace("\\(.*\\)".toRegex(), "") .replace("(.*)".toRegex(), "") - private fun generateKeyword(title: String, artist: String) = normalizeTitle(title) to normalizeArtist(artist) + fun generateKeyword(title: String, artist: String) = normalizeTitle(title) to normalizeArtist(artist) private fun String.normalize(keyword: Pair): String = replace("'", "'").lines().filter { line -> line matches ACCEPTED_REGEX @@ -186,6 +186,7 @@ object KuGou { private fun String.toSimplifiedChinese() = ZhConverterUtil.toSimple(this) private fun String.toTraditionalChinese() = ZhConverterUtil.toTraditional(this) + @Suppress("RegExpRedundantEscape") private val ACCEPTED_REGEX = "\\[(\\d\\d):(\\d\\d)\\.(\\d{2,3})\\].*".toRegex() private val BANNED_REGEX = ".+].+[::].+".toRegex() diff --git a/kugou/src/test/java/Test.kt b/kugou/src/test/java/Test.kt index e4f49e3eb..26d1feed2 100644 --- a/kugou/src/test/java/Test.kt +++ b/kugou/src/test/java/Test.kt @@ -1,4 +1,5 @@ import com.zionhuang.kugou.KuGou +import com.zionhuang.kugou.KuGou.generateKeyword import kotlinx.coroutines.runBlocking import org.junit.Assert.assertTrue import org.junit.Test @@ -6,10 +7,8 @@ import org.junit.Test class Test { @Test fun test() = runBlocking { - val candidates = KuGou.getLyricsCandidate("千年以後 (After A Thousand Years)", "陳零九", 285) + val candidates = KuGou.getLyricsCandidate(generateKeyword("千年以後 (After A Thousand Years)", "陳零九"), 285) assertTrue(candidates != null) - val lyrics = KuGou.getLyrics("楊丞琳", "點水", 259).getOrThrow() - println(lyrics) - assertTrue(lyrics != null) + assertTrue(KuGou.getLyrics("楊丞琳", "點水", 259).isSuccess) } -} \ No newline at end of file +} From 5b8404656904a23871e1d6be6f82505e73f03bed Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Mon, 16 Jan 2023 20:46:38 +0000 Subject: [PATCH 101/323] Create values-DE --- app/src/main/res/values-DE | 1 + 1 file changed, 1 insertion(+) create mode 100644 app/src/main/res/values-DE diff --git a/app/src/main/res/values-DE b/app/src/main/res/values-DE new file mode 100644 index 000000000..8b1378917 --- /dev/null +++ b/app/src/main/res/values-DE @@ -0,0 +1 @@ + From 59925bddbcda2127e051543f7f915a1486e66911 Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Mon, 16 Jan 2023 20:48:14 +0000 Subject: [PATCH 102/323] Delete values-DE --- app/src/main/res/values-DE | 1 - 1 file changed, 1 deletion(-) delete mode 100644 app/src/main/res/values-DE diff --git a/app/src/main/res/values-DE b/app/src/main/res/values-DE deleted file mode 100644 index 8b1378917..000000000 --- a/app/src/main/res/values-DE +++ /dev/null @@ -1 +0,0 @@ - From 0c0b3d4df7446314c8ad0cd984f68e41e5d6da8e Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Mon, 16 Jan 2023 20:54:54 +0000 Subject: [PATCH 103/323] Add files via upload --- app/src/main/res/values-DE/strings.xml | 4130 ++++++++++++++++++++++++ 1 file changed, 4130 insertions(+) create mode 100644 app/src/main/res/values-DE/strings.xml diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml new file mode 100644 index 000000000..bf86585f9 --- /dev/null +++ b/app/src/main/res/values-DE/strings.xml @@ -0,0 +1,4130 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + InnerTune/strings.xml at dev · z-huang/InnerTune + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
+ + + +
+ Skip to content + + + + + + + + + + + + + + +
+ +
+ + + + + + + +
+ + + + + + +
+ + + + + + + + + +
+ + + + + + + + + + + + + + + + + +
+ +
+ + + + z-huang  /   + InnerTune  /   + +
+
+ + + +
+ + +
+
+ Clear Command Palette +
+
+ + + +
+
+ Tip: + Type # to search pull requests +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type # to search issues +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type # to search discussions +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type ! to search projects +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type @ to search teams +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type @ to search people and organizations +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type > to activate command mode +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Go to your accessibility settings to change your keyboard shortcuts +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type author:@me to search your content +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:pr to filter to pull requests +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:issue to filter to issues +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:project to filter to projects +
+
+ Type ? for help and tips +
+
+
+ +
+
+ Tip: + Type is:open to filter to open content +
+
+ Type ? for help and tips +
+
+
+ +
+ +
+
+ We’ve encountered an error and some results aren't available at this time. Type a new search or try again later. +
+
+ + No results matched your search + + + + + + + + + + +
+ + + + + Search for issues and pull requests + + # + + + + Search for issues, pull requests, discussions, and projects + + # + + + + Search for organizations, repositories, and users + + @ + + + + Search for projects + + ! + + + + Search for files + + / + + + + Activate command mode + + > + + + + Search your issues, pull requests, and discussions + + # author:@me + + + + Search your issues, pull requests, and discussions + + # author:@me + + + + Filter to pull requests + + # is:pr + + + + Filter to issues + + # is:issue + + + + Filter to discussions + + # is:discussion + + + + Filter to projects + + # is:project + + + + Filter to open issues, pull requests, and discussions + + # is:open + + + + + + + + + + + + + + + + +
+
+
+ +
+ + + + + + + + + + +
+ + +
+
+
+ + + + + + + + + + + +
+ +
+ +
+ +
+ + + + / + + InnerTune + + + Public +
+ + +
+ +
    + + + +
  • + +
    + + + + + + + Watch + + + 17 + + + +
    +
    +

    Notifications

    + +
    + +
    +
    + + + + + + + + +
    + + +
    +
    +
    + + + + +
    +
    +
    + + + +
  • + +
  • +
    +
    + Fork + 69 + Fork your own copy of z-huang/InnerTune +
    +
    + + + +
    + +
    +
    + + + + + + + +
    + +
    +
    +
    +
    +
  • + +
  • + + +
    +
    +
    + + +
    + + + +
    + +
    +
    + + + + + + + +
    + +
    +
    +
    +
    +
    +
    +
    + +
    + + + +
    + +
    +
    + + + + + + + +
    + +
    +
    +
    +
    +
    +
  • + + + +
+ +
+ +
+
+ + + + +
+ + + + + + +
+ Open in github.dev + Open in a new github.dev tab + + + + + +
+ + +
+ + + + + + + +Permalink + +
+ +
+
+ + + dev + + + + +
+
+
+ Switch branches/tags + +
+ + + +
+ +
+ +
+ + +
+ +
+ + + + + + + + + + + + + + + + + +
+ + +
+
+
+
+ +
+ +
+ + +
+ +
+
+
+

Name already in use

+
+
+ +
+
+
+
+ +
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch? +
+ +
+
+ + +
+
+ + + + Go to file + +
+ + + + +
+
+
+ + + + + + + + + +
+ +
+
+
 
+
+ +
+
 
+ Cannot retrieve contributors at this time +
+
+ + + + + + + + + + + + + +
+ +
+ + +
+ + executable file + + 306 lines (273 sloc) + + 14.5 KB +
+ +
+ + + + +
+ +
+
+
+
+ +
+ +
+
+
+ + + +
+
+ + +
+ +
+
+ +
+ +
+
+ + + +
+ + +
+ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + +
<resources>
<!-- Title -->
<string name="title_home">Home</string>
<string name="title_songs">Songs</string>
<string name="title_artists">Artists</string>
<string name="title_albums">Albums</string>
<string name="title_playlists">Playlists</string>
<string name="title_explore">Explore</string>
<string name="title_settings">Settings</string>
<string name="title_now_playing">Now playing</string>
<string name="title_error_report">Error report</string>
+
<!-- Preference Title -->
<string name="pref_appearance_title">Appearance</string>
<string name="pref_follow_system_accent_title">Follow system theme</string>
<string name="pref_theme_color_title">Theme color</string>
<string name="pref_dark_theme_title">Dark theme</string>
<string name="dark_theme_on">On</string>
<string name="dark_theme_off">Off</string>
<string name="dark_theme_follow_system">Follow system</string>
<string name="pref_default_open_tab_title">Default open tab</string>
<string name="pref_customize_navigation_tabs">Customize navigation tabs</string>
<string name="pref_lyrics_text_position_title">Lyrics text position</string>
<string name="align_left">Left</string>
<string name="align_center">Center</string>
<string name="align_right">Right</string>
+
<string name="pref_content_title">Content</string>
<string name="login">Login</string>
<string name="pref_content_language_title">Default content language</string>
<string name="pref_default_content_country_title">Default content country</string>
<string name="pref_enable_proxy_title">Enable proxy</string>
<string name="pref_proxy_type_title">Proxy type</string>
<string name="pref_proxy_url_title">Proxy URL</string>
<string name="pref_restart_title">Restart to take effect</string>
+
<string name="pref_player_audio_title">Player and audio</string>
<string name="pref_audio_quality_title">Audio quality</string>
<string name="audio_quality_auto">Auto</string>
<string name="audio_quality_high">High</string>
<string name="audio_quality_low">Low</string>
<string name="pref_persistent_queue_title">Persistent queue</string>
<string name="pref_skip_silence_title">Skip silence</string>
<string name="pref_audio_normalization_title">Audio normalization</string>
<string name="pref_equalizer_title">Equalizer</string>
+
<string name="pref_storage_title">Storage</string>
<string name="pref_open_saf_title">View downloaded files in SAF</string>
<string name="pref_open_saf_summary">This may not work in some devices</string>
<string name="pref_cache_title">Cache</string>
<string name="pref_image_max_cache_size_title">Max image cache size</string>
<string name="pref_clear_image_cache_title">Clear image cache</string>
<string name="pref_song_max_cache_size_title">Max song cache size</string>
<string name="size_used">%s used</string>
+
<string name="pref_general_title">General</string>
<string name="pref_auto_download_title">Auto download</string>
<string name="pref_auto_download_summary">Download song when added to library</string>
<string name="pref_auto_add_song_title">Auto add song to library</string>
<string name="pref_auto_add_song_summary">Add song to your library when it completes playing</string>
<string name="pref_expand_on_play_title">Expand bottom player on play</string>
<string name="pref_notification_more_action_title">More actions in notification</string>
<string name="pref_notification_more_action_summary">Show add to library and like buttons</string>
+
<string name="pref_privacy_title">Privacy</string>
<string name="pref_pause_search_history_title">Pause search history</string>
<string name="pref_clear_search_history_title">Clear search history</string>
<string name="clear_search_history_question">Are you sure to clear all search history?</string>
<string name="pref_enable_kugou_title">Enable KuGou lyrics provider</string>
+
<string name="pref_backup_restore_title">Backup and restore</string>
<string name="pref_backup_title">Backup</string>
<string name="pref_restore_title">Restore</string>
+
<string name="pref_about_title">About</string>
<string name="pref_app_version_title">App version</string>
+
<!-- Colors -->
<string name="color_sakura">Sakura</string>
<string name="color_red">Red</string>
<string name="color_pink">Pink</string>
<string name="color_purple">Purple</string>
<string name="color_deep_purple">Deep purple</string>
<string name="color_indigo">Indigo</string>
<string name="color_blue">Blue</string>
<string name="color_light_blue">Light blue</string>
<string name="color_cyan">Cyan</string>
<string name="color_teal">Teal</string>
<string name="color_green">Green</string>
<string name="color_light_green">Light green</string>
<string name="color_lime">Lime</string>
<string name="color_yellow">Yellow</string>
<string name="color_amber">Amber</string>
<string name="color_orange">Orange</string>
<string name="color_deep_orange">Deep orange</string>
<string name="color_brown">Brown</string>
<string name="color_blue_grey">Blue grey</string>
+
<!-- Nav Menu -->
<string name="menu_search">Search</string>
<string name="search_yt_music">Search YouTube Music…</string>
<string name="search_library">Search library…</string>
+
<!-- Built-in playlist -->
<string name="liked_songs">Liked songs</string>
<string name="downloaded_songs">Downloaded songs</string>
+
<!-- Popup Menu -->
<string name="menu_details">Details</string>
<string name="menu_edit">Edit</string>
<string name="menu_start_radio">Start radio</string>
<string name="menu_play">Play</string>
<string name="menu_play_next">Play next</string>
<string name="menu_add_to_queue">Add to queue</string>
<string name="menu_add_to_library">Add to library</string>
<string name="menu_download">Download</string>
<string name="menu_remove_download">Remove download</string>
<string name="menu_import_playlist">Import playlist</string>
<string name="menu_add_to_playlist">Add to playlist</string>
<string name="menu_view_artist">View artist</string>
<string name="menu_view_album">View album</string>
<string name="menu_refetch">Refetch</string>
<string name="menu_share">Share</string>
<string name="menu_delete">Delete</string>
<string name="menu_search_online">Search online</string>
<string name="menu_choose_lyrics">Choose other lyrics</string>
+
<!-- Dialog -->
<string name="dialog_title_details">Details</string>
<string name="media_id">Media id</string>
<string name="mime_type">MIME type</string>
<string name="codecs">Codecs</string>
<string name="bitrate">Bitrate</string>
<string name="sample_rate">Sample rate</string>
<string name="loudness">Loudness</string>
<string name="volume">Volume</string>
<string name="file_size">File size</string>
<string name="unknown">Unknown</string>
+
<string name="dialog_title_edit_lyrics">Edit lyrics</string>
<string name="dialog_title_search_lyrics">Search lyrics</string>
<string name="dialog_title_choose_lyrics">Choose lyrics</string>
+
<string name="dialog_title_edit_song">Edit song</string>
<string name="song_title">Song title</string>
<string name="song_artists">Song artists</string>
<string name="error_song_title_empty">Song title cannot be empty.</string>
<string name="error_song_artist_empty">Song artist cannot be empty.</string>
<string name="dialog_button_save">Save</string>
+
<string name="dialog_title_create_playlist">Create playlist</string>
<string name="text_view_hint_playlist_name">Playlist name</string>
<string name="error_playlist_name_empty">Playlist name cannot be empty.</string>
+
<string name="dialog_title_edit_artist">Edit artist</string>
<string name="text_view_hint_artist_name">Artist name</string>
<string name="error_artist_name_empty">Artist name cannot be empty.</string>
+
<string name="dialog_title_duplicate_artist">Duplicate artists</string>
<string name="dialog_msg_duplicate_artist">Artist %1$s already exists.</string>
+
<string name="dialog_title_choose_playlist">Choose playlist</string>
+
<string name="dialog_title_edit_playlist">Edit playlist</string>
+
<string name="dialog_title_choose_backup_content">Choose backup content</string>
<string name="dialog_title_choose_restore_content">Choose restore content</string>
<string name="choice_preferences">Preferences</string>
<string name="choice_database">Database</string>
<string name="choice_downloaded_songs">Downloaded songs</string>
<string name="message_backup_create_success">Backup created successfully</string>
<string name="message_backup_create_failed">Couldn\'t create backup</string>
<string name="message_restore_failed">Failed to restore backup</string>
+
<!-- Notification -->
<string name="channel_name_playback">Music Player</string>
<string name="channel_name_download">Download</string>
+
<!-- Noun -->
<plurals name="song_count">
<item quantity="one">%d song</item>
<item quantity="other">%d songs</item>
</plurals>
<plurals name="artist_count">
<item quantity="one">%d artist</item>
<item quantity="other">%d artists</item>
</plurals>
<plurals name="album_count">
<item quantity="one">%d album</item>
<item quantity="other">%d albums</item>
</plurals>
<plurals name="playlist_count">
<item quantity="one">%d playlist</item>
<item quantity="other">%d playlists</item>
</plurals>
+
<!-- Button -->
<string name="btn_retry">Retry</string>
<string name="btn_play">Play</string>
<string name="btn_play_all">Play All</string>
<string name="btn_radio">Radio</string>
<string name="btn_shuffle">Shuffle</string>
<string name="btn_copy_stacktrace">Copy stacktrace</string>
<string name="btn_report">Report</string>
<string name="btn_report_on_github">Report on GitHub</string>
+
<!-- Sort Menu -->
<string name="sort_by_create_date">Date added</string>
<string name="sort_by_name">Name</string>
<string name="sort_by_artist">Artist</string>
<string name="sort_by_year">Year</string>
<string name="sort_by_song_count">Song count</string>
<string name="sort_by_length">Length</string>
<string name="sort_by_play_time">Play time</string>
+
<!-- Snackbar -->
<plurals name="snackbar_delete_song">
<item quantity="one">%d song has been deleted.</item>
<item quantity="other">%d songs has been deleted.</item>
</plurals>
<plurals name="n_selected">
<item quantity="other">%d selected</item>
</plurals>
<string name="snackbar_undo">Undo</string>
<string name="snackbar_url_error">Can\'t identify this url.</string>
<plurals name="snackbar_song_play_next">
<item quantity="one">Song will play next</item>
<item quantity="other">%d songs will play next</item>
</plurals>
<plurals name="snackbar_artist_play_next">
<item quantity="one">Artist will play next</item>
<item quantity="other">%d artists will play next</item>
</plurals>
<plurals name="snackbar_album_play_next">
<item quantity="one">Album will play next</item>
<item quantity="other">%d albums will play next</item>
</plurals>
<plurals name="snackbar_playlist_play_next">
<item quantity="one">Playlist will play next</item>
<item quantity="other">%d playlists will play next</item>
</plurals>
<string name="snackbar_play_next">Selected will play next</string>
<plurals name="snackbar_song_added_to_queue">
<item quantity="one">Song added to queue</item>
<item quantity="other">%d songs added to queue</item>
</plurals>
<plurals name="snackbar_artist_added_to_queue">
<item quantity="one">Artist added to queue</item>
<item quantity="other">%d artists added to queue</item>
</plurals>
<plurals name="snackbar_album_added_to_queue">
<item quantity="one">Album added to queue</item>
<item quantity="other">%d albums added to queue</item>
</plurals>
<plurals name="snackbar_playlist_added_to_queue">
<item quantity="one">Playlist added to queue</item>
<item quantity="other">%d playlists added to queue</item>
</plurals>
<string name="snackbar_added_to_queue">Selected added to queue</string>
<string name="snackbar_added_to_library">Added to library</string>
<string name="snackbar_removed_from_library">Removed from library</string>
<string name="snackbar_playlist_imported">Playlist imported</string>
<string name="snackbar_added_to_playlist">Added to %1$s</string>
<plurals name="snackbar_download_song">
<item quantity="one">Start downloading song</item>
<item quantity="other">Start downloading %d songs</item>
</plurals>
<string name="snackbar_removed_download">Removed download</string>
<string name="snackbar_action_view">View</string>
+
<!-- Custom Action -->
<string name="action_like">Like</string>
<string name="action_remove_like">Remove like</string>
<string name="action_add_to_library">Add to library</string>
<string name="action_remove_from_library">Remove from library</string>
+
<!-- Search Filter -->
<string name="search_filter_all">All</string>
<string name="search_filter_songs">Songs</string>
<string name="search_filter_videos">Videos</string>
<string name="search_filter_albums">Albums</string>
<string name="search_filter_artists">Artists</string>
<string name="search_filter_playlists">Playlists</string>
<string name="search_filter_community_playlists">Community playlists</string>
<string name="search_filter_featured_playlists">Featured playlists</string>
+
<string name="system_default">System default</string>
<string name="header_from_your_library">From your library</string>
+
<!-- Error Activity -->
<string name="sorry_for_error">Sorry, that should not have happened.</string>
<string name="copied">Copied to clipboard</string>
+
<!-- Player Error Message -->
<string name="error_no_stream">No stream available</string>
<string name="error_no_internet">No network connection</string>
<string name="error_timeout">Timeout</string>
<string name="error_unknown">Unknown error</string>
+
<!-- Queue Title -->
<string name="queue_all_songs">All songs</string>
<string name="queue_searched_songs">Searched songs</string>
+
<!-- Lyrics View -->
<string name="lyrics_not_found">Lyrics not found</string>
</resources>
+
+ + + +
+ +
+ + + + +
+ + +
+ + +
+
+ + + +
+ +
+ + +
+ +
+ + +
+
+ +
+ + + + + + + + + + + + + + + + + + + +
+ +
+ + + From df6ad14c10dedafcce0b2c7273c18e085daab152 Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Mon, 16 Jan 2023 20:57:21 +0000 Subject: [PATCH 104/323] Delete strings.xml --- app/src/main/res/values-DE/strings.xml | 4130 ------------------------ 1 file changed, 4130 deletions(-) delete mode 100644 app/src/main/res/values-DE/strings.xml diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml deleted file mode 100644 index bf86585f9..000000000 --- a/app/src/main/res/values-DE/strings.xml +++ /dev/null @@ -1,4130 +0,0 @@ - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - InnerTune/strings.xml at dev · z-huang/InnerTune - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
- - - -
- Skip to content - - - - - - - - - - - - - - -
- -
- - - - - - - -
- - - - - - -
- - - - - - - - - -
- - - - - - - - - - - - - - - - - -
- -
- - - - z-huang  /   - InnerTune  /   - -
-
- - - -
- - -
-
- Clear Command Palette -
-
- - - -
-
- Tip: - Type # to search pull requests -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type # to search issues -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type # to search discussions -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type ! to search projects -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type @ to search teams -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type @ to search people and organizations -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type > to activate command mode -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Go to your accessibility settings to change your keyboard shortcuts -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type author:@me to search your content -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type is:pr to filter to pull requests -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type is:issue to filter to issues -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type is:project to filter to projects -
-
- Type ? for help and tips -
-
-
- -
-
- Tip: - Type is:open to filter to open content -
-
- Type ? for help and tips -
-
-
- -
- -
-
- We’ve encountered an error and some results aren't available at this time. Type a new search or try again later. -
-
- - No results matched your search - - - - - - - - - - -
- - - - - Search for issues and pull requests - - # - - - - Search for issues, pull requests, discussions, and projects - - # - - - - Search for organizations, repositories, and users - - @ - - - - Search for projects - - ! - - - - Search for files - - / - - - - Activate command mode - - > - - - - Search your issues, pull requests, and discussions - - # author:@me - - - - Search your issues, pull requests, and discussions - - # author:@me - - - - Filter to pull requests - - # is:pr - - - - Filter to issues - - # is:issue - - - - Filter to discussions - - # is:discussion - - - - Filter to projects - - # is:project - - - - Filter to open issues, pull requests, and discussions - - # is:open - - - - - - - - - - - - - - - - -
-
-
- -
- - - - - - - - - - -
- - -
-
-
- - - - - - - - - - - -
- -
- -
- -
- - - - / - - InnerTune - - - Public -
- - -
- -
    - - - -
  • - -
    - - - - - - - Watch - - - 17 - - - -
    -
    -

    Notifications

    - -
    - -
    -
    - - - - - - - - -
    - - -
    -
    -
    - - - - -
    -
    -
    - - - -
  • - -
  • -
    -
    - Fork - 69 - Fork your own copy of z-huang/InnerTune -
    -
    - - - -
    - -
    -
    - - - - - - - -
    - -
    -
    -
    -
    -
  • - -
  • - - -
    -
    -
    - - -
    - - - -
    - -
    -
    - - - - - - - -
    - -
    -
    -
    -
    -
    -
    -
    - -
    - - - -
    - -
    -
    - - - - - - - -
    - -
    -
    -
    -
    -
    -
  • - - - -
- -
- -
-
- - - - -
- - - - - - -
- Open in github.dev - Open in a new github.dev tab - - - - - -
- - -
- - - - - - - -Permalink - -
- -
-
- - - dev - - - - -
-
-
- Switch branches/tags - -
- - - -
- -
- -
- - -
- -
- - - - - - - - - - - - - - - - - -
- - -
-
-
-
- -
- -
- - -
- -
-
-
-

Name already in use

-
-
- -
-
-
-
- -
A tag already exists with the provided branch name. Many Git commands accept both tag and branch names, so creating this branch may cause unexpected behavior. Are you sure you want to create this branch? -
- -
-
- - -
-
- - - - Go to file - -
- - - - -
-
-
- - - - - - - - - -
- -
-
-
 
-
- -
-
 
- Cannot retrieve contributors at this time -
-
- - - - - - - - - - - - - -
- -
- - -
- - executable file - - 306 lines (273 sloc) - - 14.5 KB -
- -
- - - - -
- -
-
-
-
- -
- -
-
-
- - - -
-
- - -
- -
-
- -
- -
-
- - - -
- - -
- - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -
<resources>
<!-- Title -->
<string name="title_home">Home</string>
<string name="title_songs">Songs</string>
<string name="title_artists">Artists</string>
<string name="title_albums">Albums</string>
<string name="title_playlists">Playlists</string>
<string name="title_explore">Explore</string>
<string name="title_settings">Settings</string>
<string name="title_now_playing">Now playing</string>
<string name="title_error_report">Error report</string>
-
<!-- Preference Title -->
<string name="pref_appearance_title">Appearance</string>
<string name="pref_follow_system_accent_title">Follow system theme</string>
<string name="pref_theme_color_title">Theme color</string>
<string name="pref_dark_theme_title">Dark theme</string>
<string name="dark_theme_on">On</string>
<string name="dark_theme_off">Off</string>
<string name="dark_theme_follow_system">Follow system</string>
<string name="pref_default_open_tab_title">Default open tab</string>
<string name="pref_customize_navigation_tabs">Customize navigation tabs</string>
<string name="pref_lyrics_text_position_title">Lyrics text position</string>
<string name="align_left">Left</string>
<string name="align_center">Center</string>
<string name="align_right">Right</string>
-
<string name="pref_content_title">Content</string>
<string name="login">Login</string>
<string name="pref_content_language_title">Default content language</string>
<string name="pref_default_content_country_title">Default content country</string>
<string name="pref_enable_proxy_title">Enable proxy</string>
<string name="pref_proxy_type_title">Proxy type</string>
<string name="pref_proxy_url_title">Proxy URL</string>
<string name="pref_restart_title">Restart to take effect</string>
-
<string name="pref_player_audio_title">Player and audio</string>
<string name="pref_audio_quality_title">Audio quality</string>
<string name="audio_quality_auto">Auto</string>
<string name="audio_quality_high">High</string>
<string name="audio_quality_low">Low</string>
<string name="pref_persistent_queue_title">Persistent queue</string>
<string name="pref_skip_silence_title">Skip silence</string>
<string name="pref_audio_normalization_title">Audio normalization</string>
<string name="pref_equalizer_title">Equalizer</string>
-
<string name="pref_storage_title">Storage</string>
<string name="pref_open_saf_title">View downloaded files in SAF</string>
<string name="pref_open_saf_summary">This may not work in some devices</string>
<string name="pref_cache_title">Cache</string>
<string name="pref_image_max_cache_size_title">Max image cache size</string>
<string name="pref_clear_image_cache_title">Clear image cache</string>
<string name="pref_song_max_cache_size_title">Max song cache size</string>
<string name="size_used">%s used</string>
-
<string name="pref_general_title">General</string>
<string name="pref_auto_download_title">Auto download</string>
<string name="pref_auto_download_summary">Download song when added to library</string>
<string name="pref_auto_add_song_title">Auto add song to library</string>
<string name="pref_auto_add_song_summary">Add song to your library when it completes playing</string>
<string name="pref_expand_on_play_title">Expand bottom player on play</string>
<string name="pref_notification_more_action_title">More actions in notification</string>
<string name="pref_notification_more_action_summary">Show add to library and like buttons</string>
-
<string name="pref_privacy_title">Privacy</string>
<string name="pref_pause_search_history_title">Pause search history</string>
<string name="pref_clear_search_history_title">Clear search history</string>
<string name="clear_search_history_question">Are you sure to clear all search history?</string>
<string name="pref_enable_kugou_title">Enable KuGou lyrics provider</string>
-
<string name="pref_backup_restore_title">Backup and restore</string>
<string name="pref_backup_title">Backup</string>
<string name="pref_restore_title">Restore</string>
-
<string name="pref_about_title">About</string>
<string name="pref_app_version_title">App version</string>
-
<!-- Colors -->
<string name="color_sakura">Sakura</string>
<string name="color_red">Red</string>
<string name="color_pink">Pink</string>
<string name="color_purple">Purple</string>
<string name="color_deep_purple">Deep purple</string>
<string name="color_indigo">Indigo</string>
<string name="color_blue">Blue</string>
<string name="color_light_blue">Light blue</string>
<string name="color_cyan">Cyan</string>
<string name="color_teal">Teal</string>
<string name="color_green">Green</string>
<string name="color_light_green">Light green</string>
<string name="color_lime">Lime</string>
<string name="color_yellow">Yellow</string>
<string name="color_amber">Amber</string>
<string name="color_orange">Orange</string>
<string name="color_deep_orange">Deep orange</string>
<string name="color_brown">Brown</string>
<string name="color_blue_grey">Blue grey</string>
-
<!-- Nav Menu -->
<string name="menu_search">Search</string>
<string name="search_yt_music">Search YouTube Music…</string>
<string name="search_library">Search library…</string>
-
<!-- Built-in playlist -->
<string name="liked_songs">Liked songs</string>
<string name="downloaded_songs">Downloaded songs</string>
-
<!-- Popup Menu -->
<string name="menu_details">Details</string>
<string name="menu_edit">Edit</string>
<string name="menu_start_radio">Start radio</string>
<string name="menu_play">Play</string>
<string name="menu_play_next">Play next</string>
<string name="menu_add_to_queue">Add to queue</string>
<string name="menu_add_to_library">Add to library</string>
<string name="menu_download">Download</string>
<string name="menu_remove_download">Remove download</string>
<string name="menu_import_playlist">Import playlist</string>
<string name="menu_add_to_playlist">Add to playlist</string>
<string name="menu_view_artist">View artist</string>
<string name="menu_view_album">View album</string>
<string name="menu_refetch">Refetch</string>
<string name="menu_share">Share</string>
<string name="menu_delete">Delete</string>
<string name="menu_search_online">Search online</string>
<string name="menu_choose_lyrics">Choose other lyrics</string>
-
<!-- Dialog -->
<string name="dialog_title_details">Details</string>
<string name="media_id">Media id</string>
<string name="mime_type">MIME type</string>
<string name="codecs">Codecs</string>
<string name="bitrate">Bitrate</string>
<string name="sample_rate">Sample rate</string>
<string name="loudness">Loudness</string>
<string name="volume">Volume</string>
<string name="file_size">File size</string>
<string name="unknown">Unknown</string>
-
<string name="dialog_title_edit_lyrics">Edit lyrics</string>
<string name="dialog_title_search_lyrics">Search lyrics</string>
<string name="dialog_title_choose_lyrics">Choose lyrics</string>
-
<string name="dialog_title_edit_song">Edit song</string>
<string name="song_title">Song title</string>
<string name="song_artists">Song artists</string>
<string name="error_song_title_empty">Song title cannot be empty.</string>
<string name="error_song_artist_empty">Song artist cannot be empty.</string>
<string name="dialog_button_save">Save</string>
-
<string name="dialog_title_create_playlist">Create playlist</string>
<string name="text_view_hint_playlist_name">Playlist name</string>
<string name="error_playlist_name_empty">Playlist name cannot be empty.</string>
-
<string name="dialog_title_edit_artist">Edit artist</string>
<string name="text_view_hint_artist_name">Artist name</string>
<string name="error_artist_name_empty">Artist name cannot be empty.</string>
-
<string name="dialog_title_duplicate_artist">Duplicate artists</string>
<string name="dialog_msg_duplicate_artist">Artist %1$s already exists.</string>
-
<string name="dialog_title_choose_playlist">Choose playlist</string>
-
<string name="dialog_title_edit_playlist">Edit playlist</string>
-
<string name="dialog_title_choose_backup_content">Choose backup content</string>
<string name="dialog_title_choose_restore_content">Choose restore content</string>
<string name="choice_preferences">Preferences</string>
<string name="choice_database">Database</string>
<string name="choice_downloaded_songs">Downloaded songs</string>
<string name="message_backup_create_success">Backup created successfully</string>
<string name="message_backup_create_failed">Couldn\'t create backup</string>
<string name="message_restore_failed">Failed to restore backup</string>
-
<!-- Notification -->
<string name="channel_name_playback">Music Player</string>
<string name="channel_name_download">Download</string>
-
<!-- Noun -->
<plurals name="song_count">
<item quantity="one">%d song</item>
<item quantity="other">%d songs</item>
</plurals>
<plurals name="artist_count">
<item quantity="one">%d artist</item>
<item quantity="other">%d artists</item>
</plurals>
<plurals name="album_count">
<item quantity="one">%d album</item>
<item quantity="other">%d albums</item>
</plurals>
<plurals name="playlist_count">
<item quantity="one">%d playlist</item>
<item quantity="other">%d playlists</item>
</plurals>
-
<!-- Button -->
<string name="btn_retry">Retry</string>
<string name="btn_play">Play</string>
<string name="btn_play_all">Play All</string>
<string name="btn_radio">Radio</string>
<string name="btn_shuffle">Shuffle</string>
<string name="btn_copy_stacktrace">Copy stacktrace</string>
<string name="btn_report">Report</string>
<string name="btn_report_on_github">Report on GitHub</string>
-
<!-- Sort Menu -->
<string name="sort_by_create_date">Date added</string>
<string name="sort_by_name">Name</string>
<string name="sort_by_artist">Artist</string>
<string name="sort_by_year">Year</string>
<string name="sort_by_song_count">Song count</string>
<string name="sort_by_length">Length</string>
<string name="sort_by_play_time">Play time</string>
-
<!-- Snackbar -->
<plurals name="snackbar_delete_song">
<item quantity="one">%d song has been deleted.</item>
<item quantity="other">%d songs has been deleted.</item>
</plurals>
<plurals name="n_selected">
<item quantity="other">%d selected</item>
</plurals>
<string name="snackbar_undo">Undo</string>
<string name="snackbar_url_error">Can\'t identify this url.</string>
<plurals name="snackbar_song_play_next">
<item quantity="one">Song will play next</item>
<item quantity="other">%d songs will play next</item>
</plurals>
<plurals name="snackbar_artist_play_next">
<item quantity="one">Artist will play next</item>
<item quantity="other">%d artists will play next</item>
</plurals>
<plurals name="snackbar_album_play_next">
<item quantity="one">Album will play next</item>
<item quantity="other">%d albums will play next</item>
</plurals>
<plurals name="snackbar_playlist_play_next">
<item quantity="one">Playlist will play next</item>
<item quantity="other">%d playlists will play next</item>
</plurals>
<string name="snackbar_play_next">Selected will play next</string>
<plurals name="snackbar_song_added_to_queue">
<item quantity="one">Song added to queue</item>
<item quantity="other">%d songs added to queue</item>
</plurals>
<plurals name="snackbar_artist_added_to_queue">
<item quantity="one">Artist added to queue</item>
<item quantity="other">%d artists added to queue</item>
</plurals>
<plurals name="snackbar_album_added_to_queue">
<item quantity="one">Album added to queue</item>
<item quantity="other">%d albums added to queue</item>
</plurals>
<plurals name="snackbar_playlist_added_to_queue">
<item quantity="one">Playlist added to queue</item>
<item quantity="other">%d playlists added to queue</item>
</plurals>
<string name="snackbar_added_to_queue">Selected added to queue</string>
<string name="snackbar_added_to_library">Added to library</string>
<string name="snackbar_removed_from_library">Removed from library</string>
<string name="snackbar_playlist_imported">Playlist imported</string>
<string name="snackbar_added_to_playlist">Added to %1$s</string>
<plurals name="snackbar_download_song">
<item quantity="one">Start downloading song</item>
<item quantity="other">Start downloading %d songs</item>
</plurals>
<string name="snackbar_removed_download">Removed download</string>
<string name="snackbar_action_view">View</string>
-
<!-- Custom Action -->
<string name="action_like">Like</string>
<string name="action_remove_like">Remove like</string>
<string name="action_add_to_library">Add to library</string>
<string name="action_remove_from_library">Remove from library</string>
-
<!-- Search Filter -->
<string name="search_filter_all">All</string>
<string name="search_filter_songs">Songs</string>
<string name="search_filter_videos">Videos</string>
<string name="search_filter_albums">Albums</string>
<string name="search_filter_artists">Artists</string>
<string name="search_filter_playlists">Playlists</string>
<string name="search_filter_community_playlists">Community playlists</string>
<string name="search_filter_featured_playlists">Featured playlists</string>
-
<string name="system_default">System default</string>
<string name="header_from_your_library">From your library</string>
-
<!-- Error Activity -->
<string name="sorry_for_error">Sorry, that should not have happened.</string>
<string name="copied">Copied to clipboard</string>
-
<!-- Player Error Message -->
<string name="error_no_stream">No stream available</string>
<string name="error_no_internet">No network connection</string>
<string name="error_timeout">Timeout</string>
<string name="error_unknown">Unknown error</string>
-
<!-- Queue Title -->
<string name="queue_all_songs">All songs</string>
<string name="queue_searched_songs">Searched songs</string>
-
<!-- Lyrics View -->
<string name="lyrics_not_found">Lyrics not found</string>
</resources>
-
- - - -
- -
- - - - -
- - -
- - -
-
- - - -
- -
- - -
- -
- - -
-
- -
- - - - - - - - - - - - - - - - - - - -
- -
- - - From 1ca789c6f16fa342b83a4f523400f6b7f7ea99ea Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Mon, 16 Jan 2023 21:06:25 +0000 Subject: [PATCH 105/323] Add files via upload --- app/src/main/res/values-DE/strings.xml | 306 +++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 app/src/main/res/values-DE/strings.xml diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml new file mode 100644 index 000000000..a2af0e499 --- /dev/null +++ b/app/src/main/res/values-DE/strings.xml @@ -0,0 +1,306 @@ + + + Home + Songs + Artists + Albums + Playlists + Explore + Settings + Now playing + Error report + + + Appearance + Follow system theme + Theme color + Dark theme + On + Off + Follow system + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Content + Login + Default content language + Default content country + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + View downloaded files in SAF + This may not work in some devices + Cache + Max image cache size + Clear image cache + Max song cache size + %s used + + General + Auto download + Download song when added to library + Auto add song to library + Add song to your library when it completes playing + Expand bottom player on play + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + + About + App version + + + Sakura + Red + Pink + Purple + Deep purple + Indigo + Blue + Light blue + Cyan + Teal + Green + Light green + Lime + Yellow + Amber + Orange + Deep orange + Brown + Blue grey + + + Search + Search YouTube Music… + Search library… + + + Liked songs + Downloaded songs + + + Details + Edit + Start radio + Play + Play next + Add to queue + Add to library + Download + Remove download + Import playlist + Add to playlist + View artist + View album + Refetch + Share + Delete + Search online + Choose other lyrics + + + Details + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + + Edit lyrics + Search lyrics + Choose lyrics + + Edit song + Song title + Song artists + Song title cannot be empty. + Song artist cannot be empty. + Save + + Create playlist + Playlist name + Playlist name cannot be empty. + + Edit artist + Artist name + Artist name cannot be empty. + + Duplicate artists + Artist %1$s already exists. + + Choose playlist + + Edit playlist + + Choose backup content + Choose restore content + Preferences + Database + Downloaded songs + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + + Music Player + Download + + + + %d song + %d songs + + + %d artist + %d artists + + + %d album + %d albums + + + %d playlist + %d playlists + + + + Retry + Play + Play All + Radio + Shuffle + Copy stacktrace + Report + Report on GitHub + + + Date added + Name + Artist + Year + Song count + Length + Play time + + + + %d song has been deleted. + %d songs has been deleted. + + + %d selected + + Undo + Can\'t identify this url. + + Song will play next + %d songs will play next + + + Artist will play next + %d artists will play next + + + Album will play next + %d albums will play next + + + Playlist will play next + %d playlists will play next + + Selected will play next + + Song added to queue + %d songs added to queue + + + Artist added to queue + %d artists added to queue + + + Album added to queue + %d albums added to queue + + + Playlist added to queue + %d playlists added to queue + + Selected added to queue + Added to library + Removed from library + Playlist imported + Added to %1$s + + Start downloading song + Start downloading %d songs + + Removed download + View + + + Like + Remove like + Add to library + Remove from library + + + All + Songs + Videos + Albums + Artists + Playlists + Community playlists + Featured playlists + + System default + From your library + + + Sorry, that should not have happened. + Copied to clipboard + + + No stream available + No network connection + Timeout + Unknown error + + + All songs + Searched songs + + + Lyrics not found + From 2e873e431178a928be1cc03ed9507adc1160a230 Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Mon, 16 Jan 2023 21:46:44 +0000 Subject: [PATCH 106/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 282 ++++++++++++------------- 1 file changed, 141 insertions(+), 141 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index a2af0e499..df5d8ad8a 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -1,180 +1,180 @@ - Home - Songs - Artists - Albums - Playlists - Explore - Settings - Now playing - Error report + Startseite + Lieder + Künstler + Alben + Wiedergabelisten + Erkunden + Einstellungen + Jetzt spielen + Fehlerbericht - Appearance - Follow system theme - Theme color - Dark theme - On - Off - Follow system - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Content - Login - Default content language - Default content country - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization + Erscheinungsbild + Systemthema folgen + Themenfarbe + Dunkles Thema + An + Aus + System folgen + Standardmäßig geöffnete Registerkarte + Anpassen der Navigationsregisterkarten + Position des Liedtextes + Links + Mitte + Rechts + + Inhalt + Anmeldung + Standard-Inhaltssprache + Standard-Inhaltsland + Proxy einschalten + Proxy-Typ + Proxy-URL + Neustart, damit er wirksam wird + + Player und Audio + Tonqualität + Automatisch + Hoch + Niedrig + Dauerhafte Warteschlange + Stille überspringen + Audio-Normalisierung Equalizer - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Auto download - Download song when added to library - Auto add song to library - Add song to your library when it completes playing - Expand bottom player on play - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - About - App version + Speicher + Heruntergeladene Dateien in SAF anzeigen + Dies kann bei einigen Geräten nicht funktionieren + Zwischenspeicher + MMaximale Größe des Bild-Caches + Bild-Cache löschen + Maximale Größe des Song-Cache + %s verwendet + + Allgemein + Automatisches Herunterladen + Lied herunterladen, wenn es zur Bibliothek hinzugefügt wird + Lied automatisch zur Bibliothek hinzufügen + Lied zu Ihrer Bibliothek hinzufügen, wenn es fertig abgespielt ist + Erweitern des unteren Spielers im Spiel + Weitere Aktionen in der Benachrichtigung + Schaltflächen "Zur Bibliothek hinzufügen" und "Gefällt mir" anzeigen + + Privatsphäre + Suchverlauf anhalten + Suchverlauf löschen + Sind Sie sicher, dass Sie den gesamten Suchverlauf löschen? + KuGou-Liedtextanbieter aktivieren + + Sichern und Wiederherstellen + Datensicherung + Wiederherstellen + + Über + App-Version Sakura - Red + Rot Pink - Purple - Deep purple + Lila + Dunkelviolett Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber + Blau + Hellblau + Türkis + Türkisblau + Grün + Hellgrün + Limette + Gelb + Bernstein Orange - Deep orange - Brown - Blue grey + Dunkelorange + Braun + Blau-grau - Search - Search YouTube Music… - Search library… + Suche + YouTube Musik durchsuchen... + Bibliothek durchsuchen... - Liked songs - Downloaded songs + Beliebte Titel + Heruntergeladene Titel Details - Edit - Start radio - Play - Play next - Add to queue - Add to library - Download - Remove download - Import playlist - Add to playlist - View artist - View album - Refetch - Share - Delete - Search online - Choose other lyrics + Bearbeiten + Radio starten + Spielen + Als nächstes wiedergeben + Zu Warteschlange hinzufügen + Zur Bibliothek hinzufügen + Herunterladen + Download entfernen + Wiedergabeliste importieren + Zur Wiedergabeliste hinzufügen + Künstler ansehen + Album ansehen + Neu laden + Teilen + Löschen + Online-Suche + Andere Texte auswählen Details - Media id + Medien-ID MIME type Codecs Bitrate - Sample rate - Loudness + Abtastfrequenz + Lautstärke Volume - File size - Unknown + Dateigröße + Unbekannt - Edit lyrics - Search lyrics - Choose lyrics + Text bearbeiten + Songtext suchen + Songtext auswählen - Edit song - Song title - Song artists - Song title cannot be empty. - Song artist cannot be empty. - Save + Lied bearbeiten + Songtitel + Song-Künstler + Der Songtitel darf nicht leer sein. + Songinterpret darf nicht leer sein. + Speichern - Create playlist - Playlist name - Playlist name cannot be empty. + Wiedergabeliste erstellen + Name der Wiedergabeliste + Der Name der Wiedergabeliste darf nicht leer sein. - Edit artist - Artist name - Artist name cannot be empty. + Künstler bearbeiten + Name des Künstlers + Der Name des Künstlers darf nicht leer sein. - Duplicate artists - Artist %1$s already exists. + Doppelter Künstler + Künstler %1$s existiert bereits. - Choose playlist + Wiedergabeliste auswählen - Edit playlist + Wiedergabeliste bearbeiten - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup + Wählen Sie den Sicherungsinhalt + Wählen Sie Inhalt wiederherstellen + Einstellungen + Datenbank + Heruntergeladene Lieder + Sicherung erfolgreich erstellt + Konnte keine Sicherung erstellen + Wiederherstellung der Sicherung fehlgeschlagen - Music Player - Download + Musik-Player + Herunterladen From deee830e3faf8a3665e3367e75d4608f826363ee Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Tue, 17 Jan 2023 11:25:17 +0000 Subject: [PATCH 107/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 40 +++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index df5d8ad8a..81cffcccf 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -178,40 +178,40 @@ - %d song - %d songs + %d Lied + %d Lied - %d artist - %d artists + %d Künstler + %d Künstler - %d album - %d albums + %d Album + %d Alben - %d playlist - %d playlists + %d Wiedergabeliste + %d Wiedergabelisten - Retry - Play - Play All + Wiederholen + Spielen + Alles abspielen Radio Shuffle - Copy stacktrace - Report - Report on GitHub + Stapelspur kopieren + Bericht + Bericht auf GitHub - Date added + Datum hinzugefügt Name - Artist - Year - Song count - Length - Play time + Künstler + Jahr + Anzahl der Lieder + Länge + Spielzeit From ae21e5bb99d5c196a25d2ea02685dbf58813355b Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:19:44 +0000 Subject: [PATCH 108/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 32 +++++++++++++------------- 1 file changed, 16 insertions(+), 16 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 81cffcccf..e34c43b6c 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -215,34 +215,34 @@ - %d song has been deleted. - %d songs has been deleted. + %d Song wurde gelöscht. + %d Lieder wurden gelöscht. - %d selected + %d ausgewählt - Undo - Can\'t identify this url. + Rückgängig machen + Kann diese Url nicht identifizieren. - Song will play next - %d songs will play next + Song wird als nächstes gespielt + %d Lieder werden als nächstes gespielt - Artist will play next - %d artists will play next + Der Künstler spielt als nächstes + %d Künstler werden als nächstes spielen - Album will play next - %d albums will play next + Album wird als nächstes gespielt + %d Alben werden als nächstes gespielt - Playlist will play next - %d playlists will play next + Playlist wird als nächstes abgespielt + %d Wiedergabelisten werden als nächstes abgespielt - Selected will play next + Ausgewählte spielen als nächstes - Song added to queue - %d songs added to queue + Lied zur Warteschlange hinzugefügt + %d Lieder zur Warteschlange hinzugefügt Artist added to queue From 27f1f2266f7bbc8d5b371bab667b96278bd1c682 Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:27:39 +0000 Subject: [PATCH 109/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 36 +++++++++++++------------- 1 file changed, 18 insertions(+), 18 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index e34c43b6c..47c21cb61 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -245,33 +245,33 @@ %d Lieder zur Warteschlange hinzugefügt - Artist added to queue - %d artists added to queue + Künstler zur Warteschlange hinzugefügt + %d Künstler zur Warteschlange hinzugefügt - Album added to queue - %d albums added to queue + Album zur Warteschlange hinzugefügt + %d Alben zur Warteschlange hinzugefügt - Playlist added to queue - %d playlists added to queue + Wiedergabeliste zur Warteschlange hinzugefügt + %d Wiedergabelisten zur Warteschlange hinzugefügt - Selected added to queue - Added to library - Removed from library - Playlist imported - Added to %1$s + Ausgewählte zur Warteschlange hinzugefügt + Zur Bibliothek hinzugefügt + Aus der Bibliothek entfernt + Wiedergabeliste importiert + Hinzugefügt zu %1$s - Start downloading song - Start downloading %d songs + Herunterladen des Songs starten + Herunterladen von %d Titeln beginnen - Removed download - View + Download entfernt + Ansicht - Like - Remove like - Add to library + Favoriten + Entfernen Favoriten + Zur Bibliothek hinzufügen Remove from library From 937880095c68710d73768edfd5529e54ae82ae7c Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Tue, 17 Jan 2023 16:46:32 +0000 Subject: [PATCH 110/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 40 +++++++++++++------------- 1 file changed, 20 insertions(+), 20 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 47c21cb61..f207cf247 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -270,37 +270,37 @@ Favoriten - Entfernen Favoriten + Entfernen aus Favoriten Zur Bibliothek hinzufügen - Remove from library + Aus der Bibliothek entfernen - All - Songs + Alles + Lieder Videos - Albums - Artists - Playlists - Community playlists - Featured playlists + Alben + Künstler + Wiedergabelisten + Community-Wiedergabelisten + Ausgewählte Wiedergabelisten - System default - From your library + Systemvorgabe + Aus der Bibliothek - Sorry, that should not have happened. - Copied to clipboard + Tut mir leid, das hätte nicht passieren dürfen. + In die Zwischenablage kopiert - No stream available - No network connection - Timeout - Unknown error + Kein Stream verfügbar + Keine Netzverbindung + Zeitüberschreitung + Unbekannter Fehler - All songs - Searched songs + Alle Lieder + Gesuchte Lieder - Lyrics not found + Liedtext nicht gefunden From 485f11689121d30680dcd689d063e5fa871c5bdf Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 18 Jan 2023 22:01:51 +0800 Subject: [PATCH 111/323] Remove SongRepository and rewrite database --- app/build.gradle.kts | 17 +- .../1.json | 0 .../2.json | 0 .../3.json | 0 .../4.json | 0 .../5.json | 0 app/src/main/AndroidManifest.xml | 10 - app/src/main/java/com/zionhuang/music/App.kt | 4 +- .../java/com/zionhuang/music/MainActivity.kt | 24 +- .../music/constants/MediaSessionConstants.kt | 2 +- .../music/constants/PreferenceKeys.kt | 6 +- .../java/com/zionhuang/music/db/Converters.kt | 2 +- .../com/zionhuang/music/db/DatabaseDao.kt | 430 ++++++++++++ .../com/zionhuang/music/db/MusicDatabase.kt | 56 +- .../com/zionhuang/music/db/daos/AlbumDao.kt | 88 --- .../com/zionhuang/music/db/daos/ArtistDao.kt | 78 --- .../zionhuang/music/db/daos/DownloadDao.kt | 18 - .../com/zionhuang/music/db/daos/FormatDao.kt | 26 - .../com/zionhuang/music/db/daos/LyricsDao.kt | 32 - .../zionhuang/music/db/daos/PlaylistDao.kt | 108 --- .../music/db/daos/SearchHistoryDao.kt | 26 - .../com/zionhuang/music/db/daos/SongDao.kt | 150 ----- .../zionhuang/music/db/entities/Playlist.kt | 3 - .../music/db/entities/PlaylistEntity.kt | 3 + .../zionhuang/music/db/entities/SongEntity.kt | 14 +- .../java/com/zionhuang/music/di/AppModule.kt | 27 + .../download/DownloadBroadcastReceiver.kt | 35 - .../zionhuang/music/lyrics/LyricsHelper.kt | 27 +- .../music/models/sortInfo/SortInfo.kt | 4 + .../zionhuang/music/playback/MusicService.kt | 46 +- .../music/playback/PlayerConnection.kt | 15 +- .../zionhuang/music/playback/SongPlayer.kt | 106 +-- .../zionhuang/music/provider/SongsProvider.kt | 36 +- .../zionhuang/music/repos/SongRepository.kt | 613 ------------------ .../music/repos/base/LocalRepository.kt | 150 ----- .../com/zionhuang/music/ui/component/Items.kt | 1 + .../zionhuang/music/ui/component/Lyrics.kt | 37 +- .../com/zionhuang/music/ui/menu/SongMenu.kt | 51 +- .../zionhuang/music/ui/menu/YouTubeMenu.kt | 41 +- .../zionhuang/music/ui/screens/AlbumScreen.kt | 4 +- .../music/ui/screens/ArtistItemsScreen.kt | 6 +- .../music/ui/screens/ArtistScreen.kt | 6 +- .../music/ui/screens/LocalPlaylistScreen.kt | 9 +- .../music/ui/screens/LocalSearchScreen.kt | 8 +- .../music/ui/screens/OnlineSearchResult.kt | 6 +- .../music/ui/screens/OnlineSearchScreen.kt | 29 +- .../ui/screens/library/LibraryAlbumsScreen.kt | 4 +- .../screens/library/LibraryArtistsScreen.kt | 17 +- .../screens/library/LibraryPlaylistsScreen.kt | 19 +- .../ui/screens/library/LibrarySongsScreen.kt | 4 +- .../ui/screens/settings/BackupAndRestore.kt | 79 +-- .../ui/screens/settings/PrivacySettings.kt | 12 +- .../ui/screens/settings/StorageSettings.kt | 8 +- .../zionhuang/music/utils/DownloadUtils.kt | 11 + .../music/viewmodels/AlbumViewModel.kt | 18 +- .../music/viewmodels/ArtistItemsViewModel.kt | 9 +- .../music/viewmodels/ArtistViewModel.kt | 11 +- .../viewmodels/BackupRestoreViewModel.kt | 90 +++ .../music/viewmodels/LibraryViewModels.kt | 90 ++- .../viewmodels/LocalPlaylistViewModel.kt | 31 +- .../music/viewmodels/LocalSearchViewModel.kt | 35 +- .../music/viewmodels/LyricsMenuViewModel.kt | 27 +- .../music/viewmodels/MainViewModel.kt | 22 +- .../OnlineSearchSuggestionViewModel.kt | 39 +- .../music/viewmodels/OnlineSearchViewModel.kt | 5 +- build.gradle.kts | 4 + settings.gradle.kts | 7 +- 67 files changed, 1080 insertions(+), 1816 deletions(-) rename app/schemas/{com.zionhuang.music.db.MusicDatabase => com.zionhuang.music.db.InternalDatabase}/1.json (100%) rename app/schemas/{com.zionhuang.music.db.MusicDatabase => com.zionhuang.music.db.InternalDatabase}/2.json (100%) rename app/schemas/{com.zionhuang.music.db.MusicDatabase => com.zionhuang.music.db.InternalDatabase}/3.json (100%) rename app/schemas/{com.zionhuang.music.db.MusicDatabase => com.zionhuang.music.db.InternalDatabase}/4.json (100%) rename app/schemas/{com.zionhuang.music.db.MusicDatabase => com.zionhuang.music.db.InternalDatabase}/5.json (100%) create mode 100644 app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt delete mode 100644 app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt create mode 100644 app/src/main/java/com/zionhuang/music/di/AppModule.kt delete mode 100644 app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt delete mode 100644 app/src/main/java/com/zionhuang/music/repos/SongRepository.kt delete mode 100644 app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt create mode 100644 app/src/main/java/com/zionhuang/music/utils/DownloadUtils.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index e6eb580c2..152dab8ab 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -2,6 +2,7 @@ plugins { id("com.android.application") kotlin("android") kotlin("kapt") + id("com.google.dagger.hilt.android") } android { @@ -66,9 +67,18 @@ android { } } +kapt { + correctErrorTypes = true + + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } +} + dependencies { implementation(libs.activity) implementation(libs.navigation) + implementation(libs.hilt.navigation) implementation(libs.compose.runtime) implementation(libs.compose.foundation) @@ -101,10 +111,13 @@ dependencies { kapt(libs.room.compiler) implementation(libs.room.ktx) + implementation(libs.apache.lang3) + + implementation(libs.hilt) + kapt(libs.hilt.compiler) + implementation(projects.innertube) implementation(projects.kugou) - implementation(libs.apache.lang3) - coreLibraryDesugaring(libs.desugaring) } diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/1.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/1.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/1.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/1.json diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/2.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/2.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/2.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/2.json diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/3.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/3.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/3.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/3.json diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/4.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/4.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/4.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/4.json diff --git a/app/schemas/com.zionhuang.music.db.MusicDatabase/5.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/5.json similarity index 100% rename from app/schemas/com.zionhuang.music.db.MusicDatabase/5.json rename to app/schemas/com.zionhuang.music.db.InternalDatabase/5.json diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 3515a0eb5..67cf164fb 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -138,16 +138,6 @@
- - - - - - - diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index deba52d60..0b60a081d 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -17,12 +17,14 @@ import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.getEnum import com.zionhuang.music.extensions.sharedPreferences import com.zionhuang.music.extensions.toInetSocketAddress +import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.launch import java.net.Proxy import java.util.* +@HiltAndroidApp class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPreferenceChangeListener { @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { @@ -90,7 +92,7 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere .diskCache( DiskCache.Builder() .directory(cacheDir.resolve("coil")) - .maxSizeBytes(sharedPreferences.getInt(IMAGE_MAX_CACHE_SIZE, 512) * 1024 * 1024L) + .maxSizeBytes(sharedPreferences.getInt(MAX_IMAGE_CACHE_SIZE, 512) * 1024 * 1024L) .build() ) .build() diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 43817ca95..22f66637a 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -10,7 +10,6 @@ import android.os.Bundle import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.setContent -import androidx.activity.viewModels import androidx.compose.animation.core.* import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme @@ -45,11 +44,12 @@ import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player import com.valentinilk.shimmer.LocalShimmerTheme import com.zionhuang.music.constants.* +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.SearchHistory import com.zionhuang.music.extensions.* import com.zionhuang.music.playback.MusicService import com.zionhuang.music.playback.MusicService.MusicBinder import com.zionhuang.music.playback.PlayerConnection -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.component.shimmer.ShimmerTheme import com.zionhuang.music.ui.player.BottomSheetPlayer @@ -61,18 +61,20 @@ import com.zionhuang.music.ui.screens.library.LibrarySongsScreen import com.zionhuang.music.ui.screens.settings.* import com.zionhuang.music.ui.theme.* import com.zionhuang.music.utils.NavigationTabHelper -import com.zionhuang.music.viewmodels.MainViewModel +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch +import javax.inject.Inject +@AndroidEntryPoint class MainActivity : ComponentActivity() { - @Suppress("unused") - private val viewModel: MainViewModel by viewModels() + @Inject + lateinit var database: MusicDatabase private var playerConnection by mutableStateOf(null) private val serviceConnection = object : ServiceConnection { override fun onServiceConnected(name: ComponentName?, service: IBinder?) { if (service is MusicBinder) { - playerConnection = PlayerConnection(this@MainActivity, service) + playerConnection = PlayerConnection(database, service) } } @@ -102,6 +104,7 @@ class MainActivity : ComponentActivity() { CompositionLocalProvider( LocalSharedPreferences provides sharedPreferences, LocalSharedPreferencesKeyFlow provides sharedPreferences.keyFlow, + LocalDatabase provides database ) { val coroutineScope = rememberCoroutineScope() val darkTheme by mutablePreferenceState(key = DARK_THEME, defaultValue = DarkMode.AUTO) @@ -171,8 +174,8 @@ class MainActivity : ComponentActivity() { selection = TextRange(query.length) )) navController.navigate("search/$query") - coroutineScope.launch { - SongRepository(this@MainActivity).insertSearchHistory(query) + database.query { + insert(SearchHistory(query = query)) } } } @@ -347,9 +350,7 @@ class MainActivity : ComponentActivity() { } ) ) { backStackEntry -> - LocalPlaylistScreen( - playlistId = backStackEntry.arguments?.getString("playlistId")!! - ) + LocalPlaylistScreen() } composable( route = "search/{query}", @@ -491,5 +492,6 @@ class MainActivity : ComponentActivity() { const val ACTION_SHOW_BOTTOM_SHEET = "show_bottom_sheet" +val LocalDatabase = staticCompositionLocalOf { error("No database provided") } val LocalPlayerConnection = staticCompositionLocalOf { error("No PlayerConnection provided") } val LocalPlayerAwareWindowInsets = compositionLocalOf { error("No WindowInsets provided") } diff --git a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt index f9717e2c8..19c74975c 100644 --- a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt @@ -10,4 +10,4 @@ object MediaSessionConstants { const val ACTION_UNLIKE = "action_unlike" const val ACTION_TOGGLE_SHUFFLE = "action_shuffle" -} \ No newline at end of file +} 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 ce635823c..f142f39c6 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -15,8 +15,8 @@ const val PERSISTENT_QUEUE = "PERSISTENT_QUEUE" const val SKIP_SILENCE = "SKIP_SILENCE" const val AUDIO_NORMALIZATION = "AUDIO_NORMALIZATION" -const val IMAGE_MAX_CACHE_SIZE = "IMAGE_MAX_CACHE_SIZE" -const val SONG_MAX_CACHE_SIZE = "SONG_MAX_CACHE_SIZE" +const val MAX_IMAGE_CACHE_SIZE = "MAX_IMAGE_CACHE_SIZE" +const val MAX_SONG_CACHE_SIZE = "MAX_SONG_CACHE_SIZE" const val AUTO_ADD_TO_LIBRARY = "AUTO_ADD_TO_LIBRARY" const val AUTO_DOWNLOAD = "AUTO_DOWNLOAD" @@ -242,4 +242,4 @@ val CountryCodeToName = mapOf( "VN" to "Vietnam", "YE" to "Yemen", "ZW" to "Zimbabwe", -) \ No newline at end of file +) diff --git a/app/src/main/java/com/zionhuang/music/db/Converters.kt b/app/src/main/java/com/zionhuang/music/db/Converters.kt index ad9470910..b29ebbd7b 100644 --- a/app/src/main/java/com/zionhuang/music/db/Converters.kt +++ b/app/src/main/java/com/zionhuang/music/db/Converters.kt @@ -13,4 +13,4 @@ class Converters { @TypeConverter fun dateToTimestamp(date: LocalDateTime): Long = date.atZone(ZoneOffset.UTC).toInstant().toEpochMilli() -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt new file mode 100644 index 000000000..79ca2170d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -0,0 +1,430 @@ +package com.zionhuang.music.db + +import androidx.room.* +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.pages.AlbumPage +import com.zionhuang.innertube.pages.ArtistPage +import com.zionhuang.music.db.entities.* +import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED +import com.zionhuang.music.extensions.reversed +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.models.sortInfo.* +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.ui.utils.resize +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.runBlocking +import java.time.LocalDateTime + +@Dao +interface DatabaseDao { + @Query("SELECT id FROM song") + fun allSongId(): Flow> + + @Query("SELECT id FROM song WHERE liked") + fun allLikedSongId(): Flow> + + @Query("SELECT id FROM album") + fun allAlbumId(): Flow> + + @Query("SELECT id FROM playlist") + fun allPlaylistId(): Flow> + + @Transaction + @Query("SELECT * FROM song ORDER BY rowId DESC") + fun songsByRowIdDesc(): Flow> + + @Transaction + @Query("SELECT * FROM song ORDER BY create_date DESC") + fun songsByCreateDateDesc(): Flow> + + @Transaction + @Query("SELECT * FROM song ORDER BY title DESC") + fun songsByNameDesc(): Flow> + + @Transaction + @Query("SELECT * FROM song ORDER BY totalPlayTime DESC") + fun songsByPlayTimeDesc(): Flow> + + fun songs(sortType: SongSortType, descending: Boolean) = + when (sortType) { + SongSortType.CREATE_DATE -> songsByCreateDateDesc() + SongSortType.NAME -> songsByNameDesc() + SongSortType.ARTIST -> songsByRowIdDesc().map { songs -> + songs.sortedWith(compareBy { song -> + song.artists.joinToString(separator = "") { it.name } + }) + } + SongSortType.PLAY_TIME -> songsByPlayTimeDesc() + }.map { it.reversed(!descending) } + + fun likedSongs(sortType: SongSortType, descending: Boolean) = + songs(sortType, descending).map { songs -> + songs.filter { it.song.liked } + } + + @Query("SELECT COUNT(1) FROM song WHERE liked") + fun likedSongsCount(): Flow + + fun downloadedSongs(sortType: SongSortType, descending: Boolean) = + songs(sortType, descending).map { songs -> + songs.filter { it.song.downloadState == STATE_DOWNLOADED } + } + + @Query("SELECT COUNT(*) FROM song WHERE download_state = $STATE_DOWNLOADED") + fun downloadedSongsCount(): Flow + + @Transaction + @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId") + fun albumSongs(albumId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM playlist_song_map JOIN song ON playlist_song_map.songId = song.id WHERE playlistId = :playlistId ORDER BY position") + fun playlistSongs(playlistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY create_date DESC") + fun artistSongsByCreateDateDesc(artistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY title DESC") + fun artistSongsByNameDesc(artistId: String): Flow> + + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY totalPlayTime DESC") + fun artistSongsByPlayTimeDesc(artistId: String): Flow> + + fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) = + when (sortType) { + ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateDesc(artistId) + ArtistSongSortType.NAME -> artistSongsByNameDesc(artistId) + ArtistSongSortType.PLAY_TIME -> artistSongsByPlayTimeDesc(artistId) + }.map { it.reversed(!descending) } + + @Transaction + @Query("SELECT * FROM song WHERE id = :songId") + fun song(songId: String?): Flow + + @Query("SELECT * FROM format WHERE id = :id") + fun format(id: String?): Flow + + @Query("SELECT * FROM lyrics WHERE id = :id") + fun lyrics(id: String?): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist ORDER BY rowId DESC") + fun artistsByCreateDateDesc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist ORDER BY name DESC") + fun artistsByNameDesc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist ORDER BY songCount DESC") + fun artistsBySongCountDesc(): Flow> + + fun artists(sortType: ArtistSortType, descending: Boolean) = + when (sortType) { + ArtistSortType.CREATE_DATE -> artistsByCreateDateDesc() + ArtistSortType.NAME -> artistsByNameDesc() + ArtistSortType.SONG_COUNT -> artistsBySongCountDesc() + }.map { it.reversed(!descending) } + + @Query("SELECT * FROM artist WHERE id = :id") + fun artist(id: String): Flow + + @Transaction + @Query("SELECT * FROM album ORDER BY rowId DESC") + fun albumsByRowIdDesc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY createDate DESC") + fun albumsByCreateDateDesc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY title DESC") + fun albumsByNameDesc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY year DESC") + fun albumsByYearDesc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY songCount DESC") + fun albumsBySongCountDesc(): Flow> + + @Transaction + @Query("SELECT * FROM album ORDER BY duration DESC") + fun albumsByLengthDesc(): Flow> + + fun albums(sortType: AlbumSortType, descending: Boolean) = + when (sortType) { + AlbumSortType.CREATE_DATE -> albumsByCreateDateDesc() + AlbumSortType.NAME -> albumsByNameDesc() + AlbumSortType.ARTIST -> albumsByRowIdDesc().map { albums -> + albums.sortedWith(compareBy { album -> + album.artists.joinToString(separator = "") { it.name } + }) + } + AlbumSortType.YEAR -> albumsByYearDesc() + AlbumSortType.SONG_COUNT -> albumsBySongCountDesc() + AlbumSortType.LENGTH -> albumsByLengthDesc() + }.map { it.reversed(!descending) } + + @Transaction + @Query("SELECT * FROM album WHERE id = :id") + fun album(id: String): Album? + + @Transaction + @Query("SELECT * FROM album WHERE id = :albumId") + fun albumWithSongs(albumId: String): Flow + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY createDate DESC") + fun playlistsByCreateDateDesc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY name DESC") + fun playlistsByNameDesc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY songCount DESC") + fun playlistsBySongCountDesc(): Flow> + + fun playlists(sortType: PlaylistSortType, descending: Boolean) = + when (sortType) { + PlaylistSortType.CREATE_DATE -> playlistsByCreateDateDesc() + PlaylistSortType.NAME -> playlistsByNameDesc() + PlaylistSortType.SONG_COUNT -> playlistsBySongCountDesc() + }.map { it.reversed(!descending) } + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE id = :playlistId") + fun playlist(playlistId: String): Flow + + @Transaction + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND download_state = $STATE_DOWNLOADED LIMIT :previewSize") + fun searchDownloadedSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchAlbums(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") + fun searchPlaylists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + + @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") + fun searchHistory(query: String = ""): Flow> + + @Query("DELETE FROM search_history") + fun clearSearchHistory() + + @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") + fun incrementTotalPlayTime(songId: String, playTime: Long) + + @Query(""" + UPDATE playlist_song_map SET position = + CASE + WHEN position < :fromPosition THEN position + 1 + WHEN position > :fromPosition THEN position - 1 + ELSE :toPosition + END + WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition,:toPosition) AND MAX(:fromPosition,:toPosition) + """) + fun move(playlistId: Long, fromPosition: Int, toPosition: Int) + + @Query("SELECT * FROM artist WHERE name = :name") + fun artistByName(name: String): ArtistEntity? + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(song: SongEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(artist: ArtistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(album: AlbumEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(playlist: PlaylistEntity) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: SongAlbumMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: AlbumArtistMap) + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: PlaylistSongMap) + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchHistory: SearchHistory) + + @Transaction + fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) { + insert(mediaMetadata.toSongEntity().let(block)) + mediaMetadata.artists.forEachIndexed { index, artist -> + val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId() + insert(ArtistEntity( + id = artistId, + name = artist.name + )) + insert(SongArtistMap( + songId = mediaMetadata.id, + artistId = artistId, + position = index + )) + } + } + + @Transaction + fun insert(albumPage: AlbumPage) { + insert(AlbumEntity( + 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).forEach(::insert) + albumPage.songs.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 + ) + }?.forEach(::insert) + albumPage.album.artists?.mapIndexed { index, artist -> + AlbumArtistMap( + albumId = albumPage.album.browseId, + artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId(), + order = index + ) + }?.forEach(::insert) + } + + @Transaction + fun insert(albumWithSongs: AlbumWithSongs) { + insert(albumWithSongs.album) + 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(map: PlaylistSongMap) + + fun update(artist: ArtistEntity, artistPage: ArtistPage) { + update(artist.copy( + name = artistPage.artist.title, + thumbnailUrl = artistPage.artist.thumbnail.resize(400, 400), + lastUpdateTime = LocalDateTime.now() + )) + } + + @Upsert + fun upsert(map: SongAlbumMap) + + @Upsert + fun upsert(lyrics: LyricsEntity) + + @Upsert + fun upsert(format: FormatEntity) + + @Delete + fun delete(song: SongEntity) + + @Delete + fun delete(artist: ArtistEntity) + + @Delete + fun delete(album: AlbumEntity) + + @Delete + fun delete(lyrics: LyricsEntity) + + @Delete + fun delete(searchHistory: SearchHistory) + + @Query("SELECT * FROM playlist_song_map WHERE songId = :songId") + fun playlistSongMaps(songId: String): List + + @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") + fun playlistSongMaps(playlistId: String, from: Int): List + + @Query("SELECT COUNT(1) FROM song_artist_map WHERE artistId = :id") + fun artistSongCount(id: String): Int + + @Transaction + fun verifyPlaylistSongPosition(playlistId: String, from: Int) { + val maps = playlistSongMaps(playlistId, from) + var position = if (from <= 0) 0 else maps[0].position + maps.map { it.copy(position = position++) }.forEach(::update) + } + + @Transaction + fun delete(song: Song) { + if (song.album != null) return + delete(song.song) + song.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) + playlistSongMaps(song.id) + .groupBy { it.playlistId } + .mapValues { entry -> + entry.value.minOf { it.position } - 1 + } + .forEach { (playlistId, position) -> + verifyPlaylistSongPosition(playlistId, position) + } + } + + @Transaction + fun delete(album: Album) { + runBlocking(Dispatchers.IO) { + albumSongs(album.id).first() + }.map { + it.copy(album = null) + }.forEach(::delete) + delete(album.album) + album.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) + } +} 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 41a2afd37..dc790e98c 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -1,21 +1,42 @@ package com.zionhuang.music.db -import android.content.Context import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT import androidx.core.content.contentValuesOf import androidx.room.* import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase -import com.zionhuang.music.db.daos.* import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId import com.zionhuang.music.extensions.toSQLiteQuery +import com.zionhuang.music.models.sortInfo.* import java.time.Instant import java.time.ZoneOffset import java.util.* +class MusicDatabase( + val delegate: InternalDatabase, +) : DatabaseDao by delegate.dao { + fun query(block: MusicDatabase.() -> Unit) = with(delegate) { + queryExecutor.execute { + block(this@MusicDatabase) + } + } + + fun transaction(block: MusicDatabase.() -> Unit) = with(delegate) { + transactionExecutor.execute { + runInTransaction { + block(this@MusicDatabase) + } + } + } + + fun checkpoint() { + delegate.query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) + } +} + @Database( entities = [ SongEntity::class, @@ -45,34 +66,11 @@ import java.util.* ] ) @TypeConverters(Converters::class) -abstract class MusicDatabase : RoomDatabase() { - abstract val songDao: SongDao - abstract val artistDao: ArtistDao - abstract val albumDao: AlbumDao - abstract val playlistDao: PlaylistDao - abstract val downloadDao: DownloadDao - abstract val searchHistoryDao: SearchHistoryDao - abstract val formatDao: FormatDao - abstract val lyricsDao: LyricsDao +abstract class InternalDatabase : RoomDatabase() { + abstract val dao: DatabaseDao companion object { const val DB_NAME = "song.db" - - @Volatile - var INSTANCE: MusicDatabase? = null - - fun getInstance(context: Context): MusicDatabase { - if (INSTANCE == null) { - synchronized(MusicDatabase::class.java) { - if (INSTANCE == null) { - INSTANCE = Room.databaseBuilder(context, MusicDatabase::class.java, DB_NAME) - .addMigrations(MIGRATION_1_2) - .build() - } - } - } - return INSTANCE!! - } } } @@ -215,7 +213,3 @@ val MIGRATION_1_2 = object : Migration(1, 2) { } } } - -fun RoomDatabase.checkpoint() { - query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) -} diff --git a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt deleted file mode 100644 index 6d931a077..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/AlbumDao.kt +++ /dev/null @@ -1,88 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.AlbumSortType -import com.zionhuang.music.models.sortInfo.ISortInfo -import kotlinx.coroutines.flow.Flow - -@Dao -interface AlbumDao { - @Query("SELECT id FROM album") - fun getAllAlbumId(): Flow> - - @Transaction - @RawQuery(observedEntities = [AlbumEntity::class, AlbumArtistMap::class]) - fun getAlbumsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllAlbumsAsFlow(sortInfo: ISortInfo) = getAlbumsAsFlow((QUERY_ALL_ALBUM + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM album") - suspend fun getAlbumCount(): Int - - @Transaction - @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%'") - fun searchAlbums(query: String): Flow> - - @Transaction - @Query("SELECT * FROM album WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") - fun searchAlbumsPreview(query: String, previewSize: Int): Flow> - - @Transaction - @Query("SELECT * FROM album WHERE id = :id") - suspend fun getAlbumById(id: String): Album? - - @Transaction - @Query("SELECT * FROM album WHERE id = :id") - suspend fun getAlbumWithSongs(id: String): AlbumWithSongs? - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(album: AlbumEntity): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(albumArtistMap: AlbumArtistMap): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertAlbumArtistMaps(albumArtistMaps: List) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertSongAlbumMaps(songAlbumMaps: List): List - - @Update - suspend fun update(album: AlbumEntity) - - @Update - suspend fun update(songAlbumMaps: List) - - suspend fun upsert(songAlbumMaps: List) { - insertSongAlbumMaps(songAlbumMaps) - .withIndex() - .mapNotNull { if (it.value == -1L) songAlbumMaps[it.index] else null } - .let { update(it) } - } - - @Query("DELETE FROM album WHERE id = :albumId") - suspend fun delete(albumId: String) - - @Delete - suspend fun delete(album: AlbumEntity) - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - AlbumSortType.CREATE_DATE -> "rowid" - AlbumSortType.NAME -> "album.title" - AlbumSortType.ARTIST -> throw IllegalArgumentException("Unexpected album sort type.") - AlbumSortType.YEAR -> "album.year" - AlbumSortType.SONG_COUNT -> "album.songCount" - AlbumSortType.LENGTH -> "album.duration" - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ALL_ALBUM = "SELECT * FROM album" - private const val QUERY_ORDER = " ORDER BY %s %s" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt deleted file mode 100644 index 7451c333a..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/ArtistDao.kt +++ /dev/null @@ -1,78 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.Artist -import com.zionhuang.music.db.entities.ArtistEntity -import com.zionhuang.music.db.entities.SongArtistMap -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.ArtistSortType -import com.zionhuang.music.models.sortInfo.ISortInfo -import kotlinx.coroutines.flow.Flow - -@Dao -interface ArtistDao { - @Transaction - @RawQuery(observedEntities = [ArtistEntity::class, SongArtistMap::class]) - fun getArtistsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllArtistsAsFlow(sortInfo: ISortInfo) = getArtistsAsFlow((QUERY_ALL_ARTIST + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM artist") - suspend fun getArtistCount(): Int - - @Query("SELECT * FROM artist WHERE id = :id") - suspend fun getArtistById(id: String): ArtistEntity? - - @Query("SELECT * FROM artist WHERE name = :name") - suspend fun getArtistByName(name: String): ArtistEntity? - - @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :id") - suspend fun getArtistSongCount(id: String): Int - - @Transaction - @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%'") - fun searchArtists(query: String): Flow> - - @Transaction - @Query("SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") - fun searchArtistsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT EXISTS(SELECT * FROM artist WHERE id = :id)") - suspend fun hasArtist(id: String): Boolean - - @Query("DELETE FROM song_artist_map WHERE songId = :songId") - suspend fun deleteSongArtistMaps(songId: String) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(artist: ArtistEntity): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertArtists(artists: List) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songArtistMap: SongArtistMap): Long - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insertSongArtistMaps(songArtistMaps: List) - - @Update - suspend fun update(artist: ArtistEntity) - - @Delete - suspend fun delete(artists: List) - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - ArtistSortType.CREATE_DATE -> "rowid" - ArtistSortType.NAME -> "artist.name" - else -> throw IllegalArgumentException("Unexpected artist sort type.") - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ALL_ARTIST = "SELECT *, (SELECT COUNT(*) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist" - private const val QUERY_ORDER = " ORDER BY %s %s" - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt deleted file mode 100644 index 33f542433..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/DownloadDao.kt +++ /dev/null @@ -1,18 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.Query -import com.zionhuang.music.db.entities.DownloadEntity - -@Dao -interface DownloadDao { - @Query("SELECT * FROM download WHERE id = :downloadId") - suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? - - @Insert - suspend fun insert(entity: DownloadEntity) - - @Query("DELETE FROM download WHERE id = :downloadId") - suspend fun delete(downloadId: Long) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt deleted file mode 100644 index cd1e37699..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/FormatDao.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import com.zionhuang.music.db.entities.FormatEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface FormatDao { - @Query("SELECT * FROM format WHERE id = :id") - suspend fun getSongFormat(id: String?): FormatEntity? - - @Query("SELECT * FROM format WHERE id = :id") - fun getSongFormatAsFlow(id: String?): Flow - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(format: FormatEntity): Long - - @Update - suspend fun update(format: FormatEntity) - - suspend fun upsert(format: FormatEntity) { - if (insert(format) == -1L) { - update(format) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt deleted file mode 100644 index d87c29dea..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/LyricsDao.kt +++ /dev/null @@ -1,32 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import com.zionhuang.music.db.entities.LyricsEntity -import kotlinx.coroutines.flow.Flow - -@Dao -interface LyricsDao { - @Query("SELECT * FROM lyrics WHERE id = :id") - suspend fun getLyrics(id: String?): LyricsEntity? - - @Query("SELECT * FROM lyrics WHERE id = :id") - fun getLyricsAsFlow(id: String?): Flow - - @Query("SELECT EXISTS (SELECT 1 FROM lyrics WHERE id = :id)") - suspend fun hasLyrics(id: String): Boolean - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(lyrics: LyricsEntity): Long - - @Update - suspend fun update(lyrics: LyricsEntity) - - suspend fun upsert(lyrics: LyricsEntity) { - if (insert(lyrics) == -1L) { - update(lyrics) - } - } - - @Query("DELETE FROM lyrics WHERE id = :songId") - suspend fun deleteLyrics(songId: String) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt deleted file mode 100644 index 91e4b01d1..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/PlaylistDao.kt +++ /dev/null @@ -1,108 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.PlaylistSongMap -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.ISortInfo -import com.zionhuang.music.models.sortInfo.PlaylistSortType -import kotlinx.coroutines.flow.Flow - -@Dao -interface PlaylistDao { - @Query("SELECT id FROM playlist") - fun getAllPlaylistId(): Flow> - - @Transaction - @RawQuery(observedEntities = [PlaylistEntity::class, PlaylistSongMap::class]) - fun getPlaylistsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllPlaylistsAsFlow(sortInfo: ISortInfo): Flow> = getPlaylistsAsFlow((QUERY_ALL_PLAYLIST + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM playlist") - suspend fun getPlaylistCount(): Int - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE id = :playlistId") - suspend fun getPlaylistById(playlistId: String): Playlist - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE id = :playlistId") - fun getPlaylist(playlistId: String): Flow - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE name LIKE '%' || :query || '%'") - fun searchPlaylists(query: String): Flow> - - @Transaction - @Query("$QUERY_ALL_PLAYLIST WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") - fun searchPlaylistsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position = :position") - suspend fun getPlaylistSongMap(playlistId: String, position: Int): PlaylistSongMap? - - @Query("SELECT * FROM playlist_song_map WHERE songId IN (:songIds)") - suspend fun getPlaylistSongMaps(songIds: List): List - - @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") - suspend fun getPlaylistSongMaps(playlistId: String, from: Int): List - - @Query("UPDATE playlist_song_map SET position = position - 1 WHERE playlistId = :playlistId AND :from <= position") - suspend fun decrementSongPositions(playlistId: String, from: Int) - - @Query("UPDATE playlist_song_map SET position = position - 1 WHERE playlistId = :playlistId AND :from <= position AND position <= :to") - suspend fun decrementSongPositions(playlistId: String, from: Int, to: Int) - - @Query("UPDATE playlist_song_map SET position = position + 1 WHERE playlistId = :playlistId AND :from <= position AND position <= :to") - suspend fun incrementSongPositions(playlistId: String, from: Int, to: Int) - - suspend fun renewSongPositions(playlistId: String, from: Int) { - val maps = getPlaylistSongMaps(playlistId, from) - if (maps.isEmpty()) return - var position = if (from <= 0) 0 else maps[0].position - update(maps.map { it.copy(position = position++) }) - } - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(playlist: PlaylistEntity): Long - - @Insert - suspend fun insert(playlistSongMaps: List) - - @Update - suspend fun update(playlist: PlaylistEntity) - - @Update - suspend fun update(playlistSongMap: PlaylistSongMap) - - @Update - suspend fun update(playlistSongMaps: List) - - @Delete - suspend fun delete(playlists: List) - - - suspend fun deletePlaylistSong(playlistId: String, position: Int) = deletePlaylistSong(playlistId, listOf(position)) - - @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId AND position IN (:position)") - suspend fun deletePlaylistSong(playlistId: String, position: List) - - @Query("SELECT MAX(position) FROM playlist_song_map WHERE playlistId = :playlistId") - suspend fun getPlaylistMaxId(playlistId: String): Int? - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - PlaylistSortType.CREATE_DATE -> "rowid" - PlaylistSortType.NAME -> "playlist.name" - PlaylistSortType.SONG_COUNT -> throw IllegalArgumentException("Unexpected playlist sort type.") - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ALL_PLAYLIST = "SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist" - private const val QUERY_ORDER = " ORDER BY %s %s" - } -} diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt deleted file mode 100644 index dae8eb800..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/SearchHistoryDao.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.Dao -import androidx.room.Insert -import androidx.room.OnConflictStrategy -import androidx.room.Query -import com.zionhuang.music.db.entities.SearchHistory -import kotlinx.coroutines.flow.Flow - -@Dao -interface SearchHistoryDao { - @Query("SELECT * FROM search_history ORDER BY id DESC") - fun getAllHistory(): Flow> - - @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") - fun getHistory(query: String): Flow> - - @Insert(onConflict = OnConflictStrategy.REPLACE) - suspend fun insert(searchHistory: SearchHistory) - - @Query("DELETE FROM search_history WHERE `query` = :query") - suspend fun delete(query: String) - - @Query("DELETE FROM search_history") - suspend fun clearHistory() -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt b/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt deleted file mode 100644 index e1c6e2324..000000000 --- a/app/src/main/java/com/zionhuang/music/db/daos/SongDao.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.zionhuang.music.db.daos - -import androidx.room.* -import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.ISortInfo -import com.zionhuang.music.models.sortInfo.SongSortType -import kotlinx.coroutines.flow.Flow - -@Dao -interface SongDao { - @Query("SELECT id FROM song") - fun getAllSongId(): Flow> - - @Query("SELECT id FROM song WHERE liked") - fun getAllLikedSongId(): Flow> - - @Transaction - @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class]) - suspend fun getSongsAsList(query: SupportSQLiteQuery): List - - @Transaction - @RawQuery(observedEntities = [SongEntity::class, ArtistEntity::class, AlbumEntity::class, SongArtistMap::class, SongAlbumMap::class]) - fun getSongsAsFlow(query: SupportSQLiteQuery): Flow> - - fun getAllSongsAsFlow(sortInfo: ISortInfo): Flow> = getSongsAsFlow((QUERY_ALL_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song WHERE NOT isTrash") - suspend fun getSongCount(): Int - - suspend fun getArtistSongsAsList(artistId: String, sortInfo: ISortInfo): List = getSongsAsList((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery()) - fun getArtistSongsAsFlow(artistId: String, sortInfo: ISortInfo) = getSongsAsFlow((QUERY_ARTIST_SONG.format(artistId) + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song_artist_map WHERE artistId = :artistId") - suspend fun getArtistSongCount(artistId: String): Int - - suspend fun getArtistSongsPreview(artistId: String): Flow> = getSongsAsFlow((QUERY_ARTIST_SONG.format(artistId) + " LIMIT 5").toSQLiteQuery()) - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT song.* FROM song JOIN song_album_map ON song.id = song_album_map.songId WHERE song_album_map.albumId = :albumId") - suspend fun getAlbumSongs(albumId: String): List - - @Transaction - @Query(QUERY_PLAYLIST_SONGS) - suspend fun getPlaylistSongsAsList(playlistId: String): List - - @Transaction - @Query(QUERY_PLAYLIST_SONGS) - fun getPlaylistSongsAsFlow(playlistId: String): Flow> - - fun getLikedSongs(sortInfo: ISortInfo) = getSongsAsFlow((QUERY_LIKED_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song WHERE liked") - fun getLikedSongCount(): Flow - - fun getDownloadedSongsAsFlow(sortInfo: ISortInfo) = getSongsAsFlow((QUERY_DOWNLOADED_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - suspend fun getDownloadedSongsAsList(sortInfo: ISortInfo) = getSongsAsList((QUERY_DOWNLOADED_SONG + getSortQuery(sortInfo)).toSQLiteQuery()) - - @Query("SELECT COUNT(*) FROM song WHERE download_state = $STATE_DOWNLOADED") - fun getDownloadedSongCount(): Flow - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE id = :songId") - suspend fun getSong(songId: String?): Song? - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE id = :songId") - fun getSongAsFlow(songId: String?): Flow - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash") - fun searchSongs(query: String): Flow> - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED AND title LIKE '%' || :query || '%'") - fun searchDownloadedSongs(query: String): Flow> - - @Transaction - @SuppressWarnings(RoomWarnings.CURSOR_MISMATCH) - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND NOT isTrash LIMIT :previewSize") - fun searchSongsPreview(query: String, previewSize: Int): Flow> - - @Query("SELECT EXISTS (SELECT 1 FROM song WHERE id = :songId)") - suspend fun hasSong(songId: String): Boolean - - @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") - suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) - - @Query("UPDATE song SET duration = :duration WHERE id = :songId") - suspend fun updateSongDuration(songId: String, duration: Int) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(songs: List) - - @Insert(onConflict = OnConflictStrategy.IGNORE) - suspend fun insert(song: SongEntity) - - @Update - suspend fun update(song: SongEntity) - - @Update - suspend fun update(songs: List) - - @Query("DELETE FROM song WHERE id = :songId") - suspend fun delete(songId: String) - - @Delete - suspend fun delete(songs: List) - - fun getSortQuery(sortInfo: ISortInfo) = QUERY_ORDER.format( - when (sortInfo.type) { - SongSortType.CREATE_DATE -> "song.create_date" - SongSortType.NAME -> "song.title" - SongSortType.PLAY_TIME -> "song.totalPlayTime" - else -> throw IllegalArgumentException("Unexpected song sort type.") - }, - if (sortInfo.isDescending) "DESC" else "ASC" - ) - - companion object { - private const val QUERY_ORDER = " ORDER BY %s %s" - private const val QUERY_ALL_SONG = "SELECT * FROM song WHERE NOT isTrash" - private const val QUERY_ARTIST_SONG = - """ - SELECT song.* - FROM song_artist_map - JOIN song - ON song_artist_map.songId = song.id - WHERE artistId = "%s" AND NOT song.isTrash - """ - private const val QUERY_PLAYLIST_SONGS = - """ - SELECT song.*, playlist_song_map.position - FROM playlist_song_map - JOIN song - ON playlist_song_map.songId = song.id - WHERE playlistId = :playlistId AND NOT song.isTrash - ORDER BY playlist_song_map.position - """ - private const val QUERY_LIKED_SONG = "SELECT * FROM song WHERE liked" - private const val QUERY_DOWNLOADED_SONG = "SELECT * FROM song WHERE download_state = $STATE_DOWNLOADED" - } -} diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt index 9f5501bd2..f1ee6006c 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Playlist.kt @@ -26,6 +26,3 @@ data class Playlist( override val id: String get() = playlist.id } - -const val LIKED_PLAYLIST_ID = "LP_LIKED" -const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt index 3afd7c0d8..1b2ee150b 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt @@ -25,6 +25,9 @@ data class PlaylistEntity( get() = !isLocalPlaylist companion object { + const val LIKED_PLAYLIST_ID = "LP_LIKED" + const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" + fun generatePlaylistId() = "LP" + 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 36521240c..cf549a794 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 @@ -24,9 +24,13 @@ data class SongEntity( val createDate: LocalDateTime = LocalDateTime.now(), @ColumnInfo(name = "modify_date") val modifyDate: LocalDateTime = LocalDateTime.now(), -) +) { + fun toggleLike() = copy(liked = !liked) -const val STATE_NOT_DOWNLOADED = 0 -const val STATE_PREPARING = 1 -const val STATE_DOWNLOADING = 2 -const val STATE_DOWNLOADED = 3 + companion object { + const val STATE_NOT_DOWNLOADED = 0 + const val STATE_PREPARING = 1 + const val STATE_DOWNLOADING = 2 + const val STATE_DOWNLOADED = 3 + } +} diff --git a/app/src/main/java/com/zionhuang/music/di/AppModule.kt b/app/src/main/java/com/zionhuang/music/di/AppModule.kt new file mode 100644 index 000000000..018a08e06 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/di/AppModule.kt @@ -0,0 +1,27 @@ +package com.zionhuang.music.di + +import android.content.Context +import androidx.room.Room +import com.zionhuang.music.db.InternalDatabase +import com.zionhuang.music.db.MIGRATION_1_2 +import com.zionhuang.music.db.MusicDatabase +import dagger.Module +import dagger.Provides +import dagger.hilt.InstallIn +import dagger.hilt.android.qualifiers.ApplicationContext +import dagger.hilt.components.SingletonComponent +import javax.inject.Singleton + +@Module +@InstallIn(SingletonComponent::class) +object AppModule { + @Singleton + @Provides + fun provideDatabase(@ApplicationContext context: Context): MusicDatabase { + return MusicDatabase( + delegate = Room.databaseBuilder(context, InternalDatabase::class.java, InternalDatabase.DB_NAME) + .addMigrations(MIGRATION_1_2) + .build() + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt b/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt deleted file mode 100644 index 7bb930546..000000000 --- a/app/src/main/java/com/zionhuang/music/download/DownloadBroadcastReceiver.kt +++ /dev/null @@ -1,35 +0,0 @@ -package com.zionhuang.music.download - -import android.app.DownloadManager -import android.app.DownloadManager.* -import android.content.BroadcastReceiver -import android.content.Context -import android.content.Intent -import androidx.core.content.getSystemService -import com.zionhuang.music.extensions.get -import com.zionhuang.music.repos.SongRepository -import kotlinx.coroutines.DelicateCoroutinesApi -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.GlobalScope -import kotlinx.coroutines.launch - -class DownloadBroadcastReceiver : BroadcastReceiver() { - @OptIn(DelicateCoroutinesApi::class) - override fun onReceive(context: Context, intent: Intent) { - val downloadManager = context.getSystemService()!! - val songRepository = SongRepository(context) - - when (intent.action) { - ACTION_DOWNLOAD_COMPLETE -> { - val id = intent.getLongExtra(EXTRA_DOWNLOAD_ID, -1) - if (id == -1L) return - downloadManager.query(Query().setFilterById(id)).use { cursor -> - val success = cursor.moveToFirst() && cursor.get(COLUMN_STATUS) == STATUS_SUCCESSFUL - GlobalScope.launch(IO) { - songRepository.onDownloadComplete(id, success) - } - } - } - } - } -} \ 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 7b2d1a515..1a981a51e 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt @@ -2,22 +2,21 @@ package com.zionhuang.music.lyrics import android.content.Context import android.util.LruCache -import com.zionhuang.music.db.entities.LyricsEntity +import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.repos.SongRepository +import dagger.hilt.android.qualifiers.ApplicationContext +import javax.inject.Inject -object LyricsHelper { +class LyricsHelper @Inject constructor( + @ApplicationContext private val context: Context, +) { private val lyricsProviders = listOf(KuGouLyricsProvider, YouTubeLyricsProvider) - - private const val MAX_CACHE_SIZE = 3 private val cache = LruCache>(MAX_CACHE_SIZE) - suspend fun loadLyrics(context: Context, mediaMetadata: MediaMetadata) { - val songRepository = SongRepository(context) + suspend fun loadLyrics(mediaMetadata: MediaMetadata): String { val cached = cache.get(mediaMetadata.id)?.firstOrNull() if (cached != null) { - songRepository.upsert(LyricsEntity(mediaMetadata.id, cached.lyrics)) - return + return cached.lyrics } lyricsProviders.forEach { provider -> if (provider.isEnabled(context)) { @@ -27,18 +26,16 @@ object LyricsHelper { mediaMetadata.artists.joinToString { it.name }, mediaMetadata.duration ).onSuccess { lyrics -> - songRepository.upsert(LyricsEntity(mediaMetadata.id, lyrics)) - return + return lyrics }.onFailure { it.printStackTrace() } } } - songRepository.upsert(LyricsEntity(mediaMetadata.id, LyricsEntity.LYRICS_NOT_FOUND)) + return LYRICS_NOT_FOUND } suspend fun getAllLyrics( - context: Context, mediaId: String, songTitle: String, songArtists: String, @@ -64,6 +61,10 @@ object LyricsHelper { } cache.put(cacheKey, allResult) } + + companion object { + private const val MAX_CACHE_SIZE = 3 + } } data class LyricsResult( diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt index aa85cbe50..fc5335594 100644 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt +++ b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt @@ -15,6 +15,10 @@ enum class ArtistSortType : SortType { CREATE_DATE, NAME, SONG_COUNT } +enum class ArtistSongSortType : SortType { + CREATE_DATE, NAME, PLAY_TIME +} + enum class AlbumSortType : SortType { CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH } 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 ea5bc5b33..27f6dae68 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -21,27 +21,31 @@ import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.zionhuang.music.R -import com.zionhuang.music.db.entities.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.LIKED_PLAYLIST_ID -import com.zionhuang.music.models.sortInfo.AlbumSortInfoPreference -import com.zionhuang.music.models.sortInfo.ArtistSortInfoPreference -import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference +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.lyrics.LyricsHelper +import com.zionhuang.music.models.sortInfo.SongSortType import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.SongRepository +import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* import kotlinx.coroutines.flow.first +import javax.inject.Inject +@AndroidEntryPoint class MusicService : MediaBrowserServiceCompat() { - private val coroutineScope = CoroutineScope(Dispatchers.Main) + Job() + @Inject + lateinit var database: MusicDatabase + @Inject + lateinit var lyricsHelper: LyricsHelper + private val coroutineScope = CoroutineScope(Dispatchers.Main) + Job() private val binder = MusicBinder() - private val songRepository by lazy { SongRepository(this) } private lateinit var songPlayer: SongPlayer override fun onCreate() { super.onCreate() - songPlayer = SongPlayer(this, coroutineScope, object : PlayerNotificationManager.NotificationListener { + songPlayer = SongPlayer(this, database, lyricsHelper, coroutineScope, object : PlayerNotificationManager.NotificationListener { override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { stopForeground(STOP_FOREGROUND_REMOVE) } @@ -109,52 +113,52 @@ class MusicService : MediaBrowserServiceCompat() { )) SONG -> { result.detach() - result.sendResult(songRepository.getAllSongs(SongSortInfoPreference).first().map { + result.sendResult(database.songsByCreateDateDesc().first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) } ARTIST -> { result.detach() - result.sendResult(songRepository.getAllArtists(ArtistSortInfoPreference).first().map { artist -> + result.sendResult(database.artistsByCreateDateDesc().first().map { artist -> mediaBrowserItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.song_count, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri()) }.toMutableList()) } ALBUM -> { result.detach() - result.sendResult(songRepository.getAllAlbums(AlbumSortInfoPreference).first().map { album -> + result.sendResult(database.albumsByCreateDateDesc().first().map { album -> mediaBrowserItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri()) }.toMutableList()) } PLAYLIST -> { result.detach() - val likedSongCount = songRepository.getLikedSongCount().first() - val downloadedSongCount = songRepository.getDownloadedSongCount().first() + val likedSongCount = database.likedSongsCount().first() + val downloadedSongCount = database.downloadedSongsCount().first() result.sendResult((listOf( mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.song_count, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.song_count, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) - ) + songRepository.getAllPlaylists(PlaylistSortInfoPreference).first().filter { it.playlist.isLocalPlaylist }.map { playlist -> + ) + database.playlistsByCreateDateDesc().first().filter { it.playlist.isLocalPlaylist }.map { playlist -> mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount), playlist.playlist.thumbnailUrl?.toUri() ?: playlist.thumbnails.firstOrNull()?.toUri()) }).toMutableList()) } else -> when { parentId.startsWith("$ARTIST/") -> { result.detach() - result.sendResult(songRepository.getArtistSongs(parentId.removePrefix("$ARTIST/"), SongSortInfoPreference).first().map { + result.sendResult(database.artistSongsByCreateDateDesc(parentId.removePrefix("$ARTIST/")).first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) } parentId.startsWith("$ALBUM/") -> { result.detach() - result.sendResult(songRepository.getAlbumSongs(parentId.removePrefix("$ALBUM/")).map { + result.sendResult(database.albumSongs(parentId.removePrefix("$ALBUM/")).first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) } parentId.startsWith("$PLAYLIST/") -> { result.detach() result.sendResult(when (val playlistId = parentId.removePrefix("$PLAYLIST/")) { - LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference) - DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference) - else -> songRepository.getPlaylistSongs(playlistId) + LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) + DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, true) + else -> database.playlistSongs(playlistId) }.first().map { MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) }.toMutableList()) 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 92547c1ce..3bf23369f 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -1,10 +1,10 @@ package com.zionhuang.music.playback -import android.content.Context import android.graphics.Bitmap import com.google.android.exoplayer2.MediaItem import com.google.android.exoplayer2.Player.* import com.google.android.exoplayer2.Timeline +import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.extensions.currentMetadata import com.zionhuang.music.extensions.getCurrentQueueIndex import com.zionhuang.music.extensions.getQueueWindows @@ -12,14 +12,15 @@ import com.zionhuang.music.extensions.metadata import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.MusicService.MusicBinder import com.zionhuang.music.playback.queues.Queue -import com.zionhuang.music.repos.SongRepository import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.flatMapLatest @OptIn(ExperimentalCoroutinesApi::class) -class PlayerConnection(context: Context, val binder: MusicBinder) : Listener { - val songRepository by lazy { SongRepository(context) } +class PlayerConnection( + val database: MusicDatabase, + val binder: MusicBinder, +) : Listener { val songPlayer = binder.songPlayer val player = binder.player @@ -27,13 +28,13 @@ class PlayerConnection(context: Context, val binder: MusicBinder) : Listener { val playWhenReady = MutableStateFlow(false) val mediaMetadata = MutableStateFlow(null) val currentSong = mediaMetadata.flatMapLatest { - songRepository.getSongById(it?.id) + database.song(it?.id) } val currentLyrics = mediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getLyrics(mediaMetadata?.id) + database.lyrics(mediaMetadata?.id) } val currentFormat = mediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongFormat(mediaMetadata?.id) + database.format(mediaMetadata?.id) } val queueTitle = MutableStateFlow(null) diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 6a5c59ff8..72692dc61 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -19,8 +19,6 @@ import androidx.core.app.NotificationCompat import androidx.core.content.edit import androidx.core.content.getSystemService import androidx.core.net.toUri -import androidx.core.util.component1 -import androidx.core.util.component2 import com.google.android.exoplayer2.* import com.google.android.exoplayer2.C.WAKE_MODE_NETWORK import com.google.android.exoplayer2.PlaybackException.* @@ -59,11 +57,17 @@ import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE -import com.zionhuang.music.db.entities.* +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.FormatEntity +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.Song +import com.zionhuang.music.db.entities.SongEntity +import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.* import com.zionhuang.music.lyrics.LyricsHelper import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference +import com.zionhuang.music.models.sortInfo.SongSortType import com.zionhuang.music.playback.MusicService.Companion.ALBUM import com.zionhuang.music.playback.MusicService.Companion.ARTIST import com.zionhuang.music.playback.MusicService.Companion.PLAYLIST @@ -72,7 +76,6 @@ import com.zionhuang.music.playback.queues.EmptyQueue import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.utils.resize import com.zionhuang.music.utils.InfoCache import com.zionhuang.music.utils.enumPreference @@ -95,10 +98,11 @@ import kotlin.math.roundToInt @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) class SongPlayer( private val context: Context, + private val database: MusicDatabase, + private val lyricsHelper: LyricsHelper, private val scope: CoroutineScope, notificationListener: PlayerNotificationManager.NotificationListener, ) : Listener, PlaybackStatsListener.Callback { - private val songRepository = SongRepository(context) private val connectivityManager = context.getSystemService()!! val bitmapProvider = BitmapProvider(context) @@ -110,16 +114,16 @@ class SongPlayer( val currentMediaMetadata = MutableStateFlow(null) private val currentSongFlow = currentMediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongById(mediaMetadata?.id) + database.song(mediaMetadata?.id) } private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> - songRepository.getSongFormat(mediaMetadata?.id) + database.format(mediaMetadata?.id) } var currentSong: Song? = null private val showLyrics = context.sharedPreferences.booleanFlow(SHOW_LYRICS, false) - private val cacheEvictor = when (val cacheSize = context.sharedPreferences.getInt(SONG_MAX_CACHE_SIZE, 1024)) { + private val cacheEvictor = when (val cacheSize = context.sharedPreferences.getInt(MAX_SONG_CACHE_SIZE, 1024)) { -1 -> NoOpCacheEvictor() else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) } @@ -156,7 +160,7 @@ class SongPlayer( when (path.firstOrNull()) { SONG -> { val songId = path.getOrNull(1) ?: return@launch - val allSongs = songRepository.getAllSongs(SongSortInfoPreference).first() + val allSongs = database.songsByCreateDateDesc().first() playQueue(ListQueue( title = context.getString(R.string.queue_all_songs), items = allSongs.map { it.toMediaItem() }, @@ -166,8 +170,8 @@ class SongPlayer( ARTIST -> { val songId = path.getOrNull(2) ?: return@launch val artistId = path.getOrNull(1) ?: return@launch - val artist = songRepository.getArtistById(artistId) ?: return@launch - val songs = songRepository.getArtistSongs(artistId, SongSortInfoPreference).first() + val artist = database.artist(artistId).first() ?: return@launch + val songs = database.artistSongsByCreateDateDesc(artistId).first() playQueue(ListQueue( title = artist.name, items = songs.map { it.toMediaItem() }, @@ -177,27 +181,26 @@ class SongPlayer( ALBUM -> { val songId = path.getOrNull(2) ?: return@launch val albumId = path.getOrNull(1) ?: return@launch - val album = songRepository.getAlbum(albumId) ?: return@launch - val songs = songRepository.getAlbumSongs(albumId) + val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@launch playQueue(ListQueue( - title = album.album.title, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map { it.toMediaItem() }, + startIndex = albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 ), playWhenReady) } PLAYLIST -> { val songId = path.getOrNull(2) ?: return@launch val playlistId = path.getOrNull(1) ?: return@launch val songs = when (playlistId) { - LIKED_PLAYLIST_ID -> songRepository.getLikedSongs(SongSortInfoPreference).first() - DOWNLOADED_PLAYLIST_ID -> songRepository.getDownloadedSongs(SongSortInfoPreference).first() - else -> songRepository.getPlaylistSongs(playlistId).first() + LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true).first() + DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, descending = true).first() + else -> database.playlistSongs(playlistId).first() } playQueue(ListQueue( title = when (playlistId) { LIKED_PLAYLIST_ID -> context.getString(R.string.liked_songs) DOWNLOADED_PLAYLIST_ID -> context.getString(R.string.downloaded_songs) - else -> songRepository.getPlaylistById(playlistId).playlist.name + else -> database.playlist(playlistId).first()?.playlist?.name ?: return@launch }, items = songs.map { it.toMediaItem() }, startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 @@ -357,10 +360,10 @@ class SongPlayer( } scope.launch { combine(currentMediaMetadata.distinctUntilChangedBy { it?.id }, showLyrics) { mediaMetadata, showLyrics -> - Pair(mediaMetadata, showLyrics) + mediaMetadata to showLyrics }.collectLatest { (mediaMetadata, showLyrics) -> - if (showLyrics && mediaMetadata != null && !songRepository.hasLyrics(mediaMetadata.id)) { - LyricsHelper.loadLyrics(context, mediaMetadata) + if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { + lyricsHelper.loadLyrics(mediaMetadata) } } } @@ -425,10 +428,10 @@ class SongPlayer( // Check whether format exists so that users from older version can view format details // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently - val playedFormat = songRepository.getSongFormat(mediaId).firstOrNull() - val song = songRepository.getSongById(mediaId).firstOrNull() + val playedFormat = database.format(mediaId).firstOrNull() + val song = database.song(mediaId).firstOrNull() if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { - return@runBlocking dataSpec.withUri(songRepository.getSongFile(mediaId).toUri()) + // TODO } val playerResponse = withContext(IO) { @@ -463,16 +466,18 @@ class SongPlayer( } } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) - songRepository.upsert(FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - )) + database.query { + upsert(FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + )) + } InfoCache.putInfo(mediaId, format.url, playerResponse.streamingData!!.expiresInSeconds * 1000L) dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } @@ -551,35 +556,32 @@ class SongPlayer( } fun toggleLibrary() { - scope.launch { + database.query { val song = currentSong - val mediaMetadata = currentMediaMetadata.value ?: return@launch + val mediaMetadata = currentMediaMetadata.value ?: return@query if (song == null) { - songRepository.addSong(mediaMetadata) + insert(mediaMetadata) } else { - songRepository.deleteSong(song) + delete(song) } } } fun toggleLike() { - scope.launch { + database.query { val song = currentSong - val mediaMetadata = currentMediaMetadata.value ?: return@launch + val mediaMetadata = currentMediaMetadata.value ?: return@query if (song == null) { - songRepository.addSong(mediaMetadata) - songRepository.getSongById(mediaMetadata.id).firstOrNull()?.let { - songRepository.toggleLiked(it) - } + insert(mediaMetadata, SongEntity::toggleLike) } else { - songRepository.toggleLiked(song) + update(song.song.toggleLike()) } } } private fun addToLibrary(mediaMetadata: MediaMetadata) { - scope.launch { - songRepository.addSong(mediaMetadata) + database.query { + insert(mediaMetadata) } } @@ -659,8 +661,8 @@ class SongPlayer( override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem - scope.launch { - songRepository.incrementSongTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + database.query { + incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) } } diff --git a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt index 8b79d928e..020c88d3e 100644 --- a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt @@ -10,18 +10,24 @@ import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider import com.google.android.exoplayer2.util.FileTypes import com.zionhuang.music.R -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.models.sortInfo.SongSortType +import com.zionhuang.music.utils.getSongFile +import dagger.hilt.EntryPoint +import dagger.hilt.InstallIn +import dagger.hilt.android.EntryPointAccessors +import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException import java.time.ZoneOffset class SongsProvider : DocumentsProvider() { - private lateinit var songRepository: SongRepository + lateinit var entryPoint: SongsProviderEntryPoint + val database: MusicDatabase get() = entryPoint.provideDatabase() override fun onCreate(): Boolean { - songRepository = SongRepository(context!!) + entryPoint = EntryPointAccessors.fromApplication(context!!, SongsProviderEntryPoint::class.java) return true } @@ -45,8 +51,8 @@ class SongsProvider : DocumentsProvider() { .add(Document.COLUMN_DISPLAY_NAME, context!!.getString(R.string.app_name)) .add(Document.COLUMN_MIME_TYPE, MIME_TYPE_DIR) else -> { - val song = songRepository.getSongById(documentId).first() ?: throw FileNotFoundException() - val format = songRepository.getSongFormat(documentId).first() ?: throw FileNotFoundException() + val song = database.song(documentId).first() ?: throw FileNotFoundException() + val format = database.format(documentId).first() ?: throw FileNotFoundException() newRow() .add(Document.COLUMN_DOCUMENT_ID, documentId) .add(Document.COLUMN_DISPLAY_NAME, song.song.title) @@ -61,8 +67,8 @@ class SongsProvider : DocumentsProvider() { override fun queryChildDocuments(parentDocumentId: String, projection: Array?, sortOrder: String?): Cursor = runBlocking { MatrixCursor(DEFAULT_DOCUMENT_PROJECTION).apply { when (parentDocumentId) { - ROOT_DOC -> songRepository.getDownloadedSongs(SongSortInfoPreference).first().forEach { song -> - val format = songRepository.getSongFormat(song.id).first() + ROOT_DOC -> database.downloadedSongs(SongSortType.CREATE_DATE, true).first().forEach { song -> + val format = database.format(song.id).first() if (format != null) { newRow() .add(Document.COLUMN_DOCUMENT_ID, song.id) @@ -80,8 +86,8 @@ class SongsProvider : DocumentsProvider() { MatrixCursor(projection ?: DEFAULT_DOCUMENT_PROJECTION).apply { when (rootId) { ROOT -> { - songRepository.searchDownloadedSongs(query).first().forEach { song -> - val format = songRepository.getSongFormat(song.id).first() + database.searchDownloadedSongs(query).first().forEach { song -> + val format = database.format(song.id).first() if (format != null) { newRow() .add(Document.COLUMN_DOCUMENT_ID, song.id) @@ -97,12 +103,12 @@ class SongsProvider : DocumentsProvider() { } override fun openDocument(documentId: String, mode: String, signal: CancellationSignal?): ParcelFileDescriptor = runBlocking { - val file = songRepository.getSongFile(documentId) + val file = getSongFile(context!!, documentId) ParcelFileDescriptor.open(file, ParcelFileDescriptor.parseMode(mode)) } override fun isChildDocument(parentDocumentId: String, documentId: String): Boolean = runBlocking { - val song = songRepository.getSongById(documentId).first() + val song = database.song(documentId).first() song != null && parentDocumentId == ROOT_DOC } @@ -127,6 +133,12 @@ class SongsProvider : DocumentsProvider() { else -> "" } + @EntryPoint + @InstallIn(SingletonComponent::class) + interface SongsProviderEntryPoint { + fun provideDatabase(): MusicDatabase + } + companion object { const val ROOT = "root" const val ROOT_DOC = "root_dir" diff --git a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt b/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt deleted file mode 100644 index f40f3f1f8..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/SongRepository.kt +++ /dev/null @@ -1,613 +0,0 @@ -package com.zionhuang.music.repos - -import android.content.Context -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.YouTube.MAX_GET_QUEUE_SIZE -import com.zionhuang.innertube.models.* -import com.zionhuang.music.constants.AUTO_ADD_TO_LIBRARY -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.db.entities.Album -import com.zionhuang.music.db.entities.Artist -import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId -import com.zionhuang.music.extensions.div -import com.zionhuang.music.extensions.preference -import com.zionhuang.music.extensions.reversed -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.* -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.repos.base.LocalRepository -import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.utils.md5 -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.withContext -import java.io.File -import java.time.LocalDateTime - -class SongRepository(private val context: Context) : LocalRepository { - private val database = MusicDatabase.getInstance(context) - private val songDao = database.songDao - private val artistDao = database.artistDao - private val albumDao = database.albumDao - private val playlistDao = database.playlistDao - private val downloadDao = database.downloadDao - private val searchHistoryDao = database.searchHistoryDao - private val formatDao = database.formatDao - private val lyricsDao = database.lyricsDao - - private var autoDownload by context.preference(AUTO_ADD_TO_LIBRARY, false) - - override fun getAllSongId(): Flow> = songDao.getAllSongId() - override fun getAllLikedSongId(): Flow> = songDao.getAllLikedSongId() - override fun getAllAlbumId(): Flow> = albumDao.getAllAlbumId() - override fun getAllPlaylistId(): Flow> = playlistDao.getAllPlaylistId() - - /** - * Browse - */ - override fun getAllSongs(sortInfo: ISortInfo) = - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getAllSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getAllSongsAsFlow(sortInfo) - } - - override suspend fun getSongCount() = withContext(IO) { songDao.getSongCount() } - - override fun getAllArtists(sortInfo: ISortInfo) = - if (sortInfo.type == ArtistSortType.SONG_COUNT) { - artistDao.getAllArtistsAsFlow(SortInfo(ArtistSortType.CREATE_DATE, true)).map { list -> - list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) - } - } else { - artistDao.getAllArtistsAsFlow(sortInfo) - } - - override suspend fun getArtistCount() = withContext(IO) { artistDao.getArtistCount() } - - override suspend fun getArtistSongsPreview(artistId: String) = songDao.getArtistSongsPreview(artistId) - - override fun getArtistSongs(artistId: String, sortInfo: ISortInfo) = - songDao.getArtistSongsAsFlow(artistId, if (sortInfo.type == SongSortType.ARTIST) SortInfo(SongSortType.CREATE_DATE, sortInfo.isDescending) else sortInfo) - - override suspend fun getArtistSongCount(artistId: String) = withContext(IO) { songDao.getArtistSongCount(artistId) } - - override fun getAllAlbums(sortInfo: ISortInfo) = - if (sortInfo.type == AlbumSortType.ARTIST) { - albumDao.getAllAlbumsAsFlow(SortInfo(AlbumSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - albumDao.getAllAlbumsAsFlow(sortInfo) - } - - override suspend fun getAlbumCount() = withContext(IO) { albumDao.getAlbumCount() } - override suspend fun getAlbumSongs(albumId: String) = withContext(IO) { - songDao.getAlbumSongs(albumId) - } - - override fun getAllPlaylists(sortInfo: ISortInfo) = - if (sortInfo.type == PlaylistSortType.SONG_COUNT) { - playlistDao.getAllPlaylistsAsFlow(SortInfo(PlaylistSortType.CREATE_DATE, true)).map { list -> - list.sortedBy { it.songCount }.reversed(sortInfo.isDescending) - } - } else { - playlistDao.getAllPlaylistsAsFlow(sortInfo) - } - - override fun getPlaylistSongs(playlistId: String) = songDao.getPlaylistSongsAsFlow(playlistId) - - override fun getLikedSongs(sortInfo: ISortInfo) = - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getLikedSongs(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getLikedSongs(sortInfo) - } - - override fun getLikedSongCount(): Flow = songDao.getLikedSongCount() - - override fun getDownloadedSongs(sortInfo: ISortInfo) = - if (sortInfo.type == SongSortType.ARTIST) { - songDao.getDownloadedSongsAsFlow(SortInfo(SongSortType.CREATE_DATE, true)).map { list -> - list.sortedWith(compareBy(String.CASE_INSENSITIVE_ORDER) { song -> - song.artists.joinToString(separator = "") { it.name } - }).reversed(sortInfo.isDescending) - } - } else { - songDao.getDownloadedSongsAsFlow(sortInfo) - } - - override fun getDownloadedSongCount(): Flow = songDao.getDownloadedSongCount() - - /** - * Search - */ - override fun searchAll(query: String): Flow> = combine( - songDao.searchSongsPreview(query, PREVIEW_SIZE), - artistDao.searchArtistsPreview(query, PREVIEW_SIZE), - albumDao.searchAlbumsPreview(query, PREVIEW_SIZE), - playlistDao.searchPlaylistsPreview(query, PREVIEW_SIZE) - ) { songResult, artistResult, albumResult, playlistResult -> songResult + artistResult + albumResult + playlistResult } - - override fun searchSongs(query: String) = songDao.searchSongs(query) - override fun searchDownloadedSongs(query: String) = songDao.searchDownloadedSongs(query) - override fun searchArtists(query: String) = artistDao.searchArtists(query) - override fun searchAlbums(query: String) = albumDao.searchAlbums(query) - override fun searchPlaylists(query: String) = playlistDao.searchPlaylists(query) - - /** - * Song - */ - override suspend fun addSongs(songs: List) = withContext(IO) { - songs.forEach { mediaMetadata -> - songDao.getSong(mediaMetadata.id)?.let { song -> - if (song.song.downloadState == STATE_NOT_DOWNLOADED && autoDownload) { - downloadSong(mediaMetadata.id) - } - return@forEach - } - songDao.insert(mediaMetadata.toSongEntity()) - mediaMetadata.artists.forEachIndexed { index, artist -> - val artistId = artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId() - artistDao.insert(ArtistEntity( - id = artistId, - name = artist.name - )) - artistDao.insert(SongArtistMap( - songId = mediaMetadata.id, - artistId = artistId, - position = index - )) - } - if (autoDownload) downloadSong(mediaMetadata.id) - } - } - - override suspend fun refetchSongs(songs: List) = withContext(IO) { - val map = songs.associateBy { it.id } - val songItems = songs.chunked(MAX_GET_QUEUE_SIZE).flatMap { chunk -> - YouTube.getQueue(chunk.map { it.id }).getOrThrow() - } - songDao.update(songItems.map { item -> - map[item.id]!!.song.copy( - id = item.id, - title = item.title, - duration = item.duration!!, - thumbnailUrl = item.thumbnail, - albumId = item.album?.id, - albumName = item.album?.name, - modifyDate = LocalDateTime.now() - ) - }) - val songArtistMaps = songItems.flatMap { song -> - song.artists.mapIndexed { index, artist -> - val artistId = (artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId()).also { - artistDao.insert(ArtistEntity( - id = it, - name = artist.name - )) - } - SongArtistMap( - songId = song.id, - artistId = artistId, - position = index - ) - } - } - songs.forEach { song -> - artistDao.deleteSongArtistMaps(songId = song.id) - } - artistDao.insertSongArtistMaps(songArtistMaps) - artistDao.delete( - songs - .flatMap { it.artists } - .distinctBy { it.id } - .filter { artistDao.getArtistSongCount(it.id) == 0 } - ) - } - - override fun getSongById(songId: String?) = songDao.getSongAsFlow(songId) - - override fun getSongFile(songId: String): File { - val mediaDir = context.getExternalFilesDir(null)!! / "media" - if (!mediaDir.isDirectory) mediaDir.mkdirs() - return mediaDir / md5(songId) - } - - private fun getSongTempFile(songId: String): File { - val mediaDir = context.getExternalFilesDir(null)!! / "media" - if (!mediaDir.isDirectory) mediaDir.mkdirs() - return mediaDir / (md5(songId) + ".tmp") - } - - override suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) = withContext(IO) { - songDao.incrementSongTotalPlayTime(songId, playTime) - } - - override suspend fun updateSongDuration(songId: String, duration: Int) = withContext(IO) { - songDao.updateSongDuration(songId, duration) - } - - override suspend fun updateSongTitle(song: Song, newTitle: String) = withContext(IO) { - songDao.update(song.song.copy( - title = newTitle, - modifyDate = LocalDateTime.now() - )) - } - - override suspend fun toggleLiked(songs: List) = withContext(IO) { - songDao.update(songs.map { - it.song.copy( - liked = !it.song.liked, - modifyDate = LocalDateTime.now() - ) - }) - } - - override suspend fun downloadSongs(songIds: List) = withContext(IO) { - // TODO - } - - override suspend fun onDownloadComplete(downloadId: Long, success: Boolean): Unit = withContext(IO) { - getDownloadEntity(downloadId)?.songId?.let { songId -> - songDao.getSong(songId)?.let { song -> - songDao.update(song.song.copy(downloadState = if (success) STATE_DOWNLOADED else STATE_NOT_DOWNLOADED)) - getSongTempFile(songId).renameTo(getSongFile(songId)) - } - removeDownloadEntity(downloadId) - } - } - - override suspend fun validateDownloads() { - getDownloadedSongs(SongSortInfoPreference).first().forEach { song -> - if (!getSongFile(song.id).exists() && !getSongTempFile(song.id).exists()) { - songDao.update(song.song.copy(downloadState = STATE_NOT_DOWNLOADED)) - } - } - } - - override suspend fun removeDownloads(songs: List) = withContext(IO) { - songs.forEach { song -> - if (getSongFile(song.song.id).exists()) { - getSongFile(song.song.id).delete() - } - songDao.update(song.song.copy(downloadState = STATE_NOT_DOWNLOADED)) - } - } - - override suspend fun moveToTrash(songs: List) = withContext(IO) { - songDao.update(songs.map { it.song.copy(isTrash = true) }) - } - - override suspend fun restoreFromTrash(songs: List) = withContext(IO) { - songDao.update(songs.map { it.song.copy(isTrash = false) }) - } - - override suspend fun deleteSong(songId: String) { - songDao.delete(songId) - } - - override suspend fun deleteSongs(songs: List) = withContext(IO) { - val deletableSongs = songs.filter { it.album == null } - val renewPlaylists = playlistDao.getPlaylistSongMaps(deletableSongs.map { it.id }).groupBy { it.playlistId }.mapValues { entry -> - entry.value.minOf { it.position } - 1 - } - songDao.delete(deletableSongs.map { it.song }) - deletableSongs.forEach { song -> - getSongFile(song.song.id).delete() - } - artistDao.delete(deletableSongs - .flatMap { it.artists } - .distinctBy { it.id } - .filter { artistDao.getArtistSongCount(it.id) == 0 }) - renewPlaylists.forEach { (playlistId, position) -> - playlistDao.renewSongPositions(playlistId, position) - } - } - - /** - * Artist - */ - override suspend fun getArtistById(artistId: String): ArtistEntity? = withContext(IO) { - artistDao.getArtistById(artistId) - } - - override suspend fun getArtistByName(name: String): ArtistEntity? = withContext(IO) { - artistDao.getArtistByName(name) - } - - override suspend fun refetchArtists(artists: List) = withContext(IO) { - artists.filter { artist -> - artist.isYouTubeArtist - }.forEach { artist -> - val artistPage = YouTube.browseArtist(artist.id).getOrThrow() - artistDao.update(artist.copy( - name = artistPage.artist.title, - thumbnailUrl = artistPage.artist.thumbnail.resize(400, 400), - lastUpdateTime = LocalDateTime.now() - )) - } - } - - override suspend fun updateArtist(artist: ArtistEntity) = withContext(IO) { - artistDao.update(artist) - } - - /** - * Album - */ - override suspend fun addAlbums(albums: List) = withContext(IO) { - albums.filter { - albumDao.getAlbumById(it.id) == null - }.forEach { album -> - val albumPage = YouTube.browseAlbum(album.browseId).getOrThrow() - albumDao.insert(AlbumEntity( - 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 } - )) - addSongs(albumPage.songs.map(SongItem::toMediaMetadata)) - albumDao.upsert(albumPage.songs.mapIndexed { index, song -> - SongAlbumMap( - songId = song.id, - albumId = album.id, - index = index - ) - }) - artistDao.insertArtists(albumPage.album.artists!!.map { artist -> - ArtistEntity( - id = artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId(), - name = artist.name - ) - }) - albumDao.insertAlbumArtistMaps(albumPage.album.artists!!.mapIndexed { index, artist -> - AlbumArtistMap( - albumId = album.id, - artistId = artist.id ?: getArtistByName(artist.name)?.id ?: generateArtistId(), - order = index - ) - }) - } - } - - override suspend fun getAlbum(albumId: String) = withContext(IO) { - albumDao.getAlbumById(albumId) - } - - override suspend fun getAlbumWithSongs(albumId: String) = withContext(IO) { - albumDao.getAlbumWithSongs(albumId) - } - - override suspend fun deleteAlbum(albumId: String) = withContext(IO) { - albumDao.delete(albumId) - } - - override suspend fun deleteAlbums(albums: List) = withContext(IO) { - albums.forEach { album -> - val songs = songDao.getAlbumSongs(album.id).map { it.copy(album = null) } - albumDao.delete(album.album) - deleteSongs(songs) - artistDao.delete(album.artists.filter { artistDao.getArtistSongCount(it.id) == 0 }) - } - } - - /** - * Playlist - */ - override suspend fun addPlaylists(playlists: List) = withContext(IO) { - playlists.forEach { playlist -> - val playlistPage = YouTube.browsePlaylist("VL${playlist.id}").getOrThrow() - playlistDao.insert(PlaylistEntity( - id = playlistPage.playlist.id, - name = playlistPage.playlist.title, - author = playlistPage.playlist.author.name, - authorId = playlistPage.playlist.author.id, - thumbnailUrl = playlistPage.playlist.thumbnail - )) - } - } - - override suspend fun importPlaylists(playlists: List) = withContext(IO) { - playlists.forEach { playlist -> - val playlistId = generatePlaylistId() - val playlistPage = YouTube.browsePlaylist("VL${playlist.id}").getOrThrow() - playlistDao.insert(PlaylistEntity( - id = playlistId, - name = playlistPage.playlist.title, - thumbnailUrl = playlistPage.playlist.thumbnail - )) - var index = 0 - var songs: List = playlistPage.songs - var continuation = playlistPage.songsContinuation - while (true) { - playlistDao.insert(songs.map { - PlaylistSongMap( - playlistId = playlistId, - songId = it.id, - position = index++ - ) - }) - if (continuation == null) break - val continuationPage = YouTube.browsePlaylistContinuation(continuation).getOrThrow() - songs = continuationPage.songs - continuation = continuationPage.continuation - } - } - } - - override suspend fun insertPlaylist(playlist: PlaylistEntity): Unit = withContext(IO) { playlistDao.insert(playlist) } - - override fun getPlaylist(playlistId: String): Flow = playlistDao.getPlaylist(playlistId) - - private suspend fun addSongsToPlaylist(playlistId: String, songIds: List) { - var maxId = playlistDao.getPlaylistMaxId(playlistId) ?: -1 - playlistDao.insert(songIds.map { songId -> - PlaylistSongMap( - playlistId = playlistId, - songId = songId, - position = ++maxId - ) - }) - } - - override suspend fun addToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { - val songIds = items.flatMap { item -> - when (item) { - is Song -> listOf(item.id) - is Album -> getAlbumSongs(item.id).map { it.id } - is Artist -> getArtistSongs(item.id, SongSortInfoPreference).first().map { it.id } - is Playlist -> if (item.playlist.isLocalPlaylist) { - getPlaylistSongs(item.id).first().map { it.id } - } else { - emptyList() - } - } - } - addSongsToPlaylist(playlist.id, songIds) - } - - override suspend fun addYTItemsToPlaylist(playlist: PlaylistEntity, items: List) = withContext(IO) { - val songs = items.flatMap { item -> - when (item) { - is SongItem -> listOf(item) - is AlbumItem -> YouTube.browseAlbum("VL${item.playlistId}").getOrThrow().songs - is PlaylistItem -> YouTube.getQueue(playlistId = item.id).getOrThrow() - is ArtistItem -> emptyList() - } - }.map(SongItem::toMediaMetadata) - addSongs(songs) - addSongsToPlaylist(playlist.id, songs.map { it.id }) - } - - override suspend fun addMediaMetadataToPlaylist(playlist: PlaylistEntity, mediaMetadata: MediaMetadata) = withContext(IO) { - addSong(mediaMetadata) - addSongsToPlaylist(playlist.id, listOf(mediaMetadata.id)) - } - - override suspend fun refetchPlaylists(playlists: List): Unit = withContext(IO) { - playlists.filter { playlist -> - playlist.playlist.isYouTubePlaylist - }.forEach { playlist -> - val playlistPage = YouTube.browsePlaylist("VL${playlist.id}").getOrThrow() - playlistDao.update(PlaylistEntity( - id = playlistPage.playlist.id, - name = playlistPage.playlist.title, - author = playlistPage.playlist.author.name, - authorId = playlistPage.playlist.author.id, - thumbnailUrl = playlistPage.playlist.thumbnail, - lastUpdateTime = LocalDateTime.now() - )) - } - } - - override suspend fun downloadPlaylists(playlists: List) = withContext(IO) { - downloadSongs(playlists - .filter { it.playlist.isLocalPlaylist } - .flatMap { getPlaylistSongs(it.id).first() } - .map { it.id } - .distinct()) - } - - override suspend fun getPlaylistById(playlistId: String): Playlist = withContext(IO) { - playlistDao.getPlaylistById(playlistId) - } - - override suspend fun updatePlaylist(playlist: PlaylistEntity) = withContext(IO) { playlistDao.update(playlist) } - - override suspend fun movePlaylistItems(playlistId: String, from: Int, to: Int) = withContext(IO) { - val target = playlistDao.getPlaylistSongMap(playlistId, from) ?: return@withContext - if (to < from) { - playlistDao.incrementSongPositions(playlistId, to, from - 1) - } else if (from < to) { - playlistDao.decrementSongPositions(playlistId, from + 1, to) - } - playlistDao.update(target.copy(position = to)) - } - - override suspend fun removeSongFromPlaylist(playlistId: String, position: Int) = withContext(IO) { - playlistDao.deletePlaylistSong(playlistId, position) - playlistDao.decrementSongPositions(playlistId, position + 1) - } - - override suspend fun removeSongsFromPlaylist(playlistId: String, positions: List) = withContext(IO) { - playlistDao.deletePlaylistSong(playlistId, positions) - playlistDao.renewSongPositions(playlistId, positions.minOrNull()!! - 1) - } - - override suspend fun deletePlaylists(playlists: List) = withContext(IO) { - playlistDao.delete(playlists) - } - - /** - * Download - */ - override suspend fun addDownloadEntity(item: DownloadEntity) = withContext(IO) { downloadDao.insert(item) } - override suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? = withContext(IO) { downloadDao.getDownloadEntity(downloadId) } - override suspend fun removeDownloadEntity(downloadId: Long) = withContext(IO) { downloadDao.delete(downloadId) } - - /** - * Search history - */ - override fun getAllSearchHistory() = searchHistoryDao.getAllHistory() - - override fun getSearchHistory(query: String) = searchHistoryDao.getHistory(query) - - override suspend fun insertSearchHistory(query: String) = withContext(IO) { - searchHistoryDao.insert(SearchHistory(query = query)) - } - - override suspend fun deleteSearchHistory(query: String) = withContext(IO) { - searchHistoryDao.delete(query) - } - - override suspend fun clearSearchHistory() { - searchHistoryDao.clearHistory() - } - - /** - * Format - */ - override fun getSongFormat(songId: String?) = formatDao.getSongFormatAsFlow(songId) - - override suspend fun upsert(format: FormatEntity) = withContext(IO) { - formatDao.upsert(format) - } - - /** - * Lyrics - */ - override fun getLyrics(songId: String?): Flow = - lyricsDao.getLyricsAsFlow(songId) - - override suspend fun hasLyrics(songId: String): Boolean = withContext(IO) { - lyricsDao.hasLyrics(songId) - } - - override suspend fun upsert(lyrics: LyricsEntity) = withContext(IO) { - lyricsDao.upsert(lyrics) - } - - override suspend fun deleteLyrics(songId: String) = lyricsDao.deleteLyrics(songId) - - companion object { - const val PREVIEW_SIZE = 3 - } -} diff --git a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt b/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt deleted file mode 100644 index 4b29e20f0..000000000 --- a/app/src/main/java/com/zionhuang/music/repos/base/LocalRepository.kt +++ /dev/null @@ -1,150 +0,0 @@ -package com.zionhuang.music.repos.base - -import com.zionhuang.innertube.models.AlbumItem -import com.zionhuang.innertube.models.PlaylistItem -import com.zionhuang.innertube.models.YTItem -import com.zionhuang.music.db.entities.* -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.* -import kotlinx.coroutines.flow.Flow -import java.io.File - -interface LocalRepository { - fun getAllSongId(): Flow> - fun getAllLikedSongId(): Flow> - fun getAllAlbumId(): Flow> - fun getAllPlaylistId(): Flow> - - /** - * Browse - */ - fun getAllSongs(sortInfo: ISortInfo): Flow> - suspend fun getSongCount(): Int - - fun getAllArtists(sortInfo: ISortInfo): Flow> - suspend fun getArtistCount(): Int - - suspend fun getArtistSongsPreview(artistId: String): Flow> - fun getArtistSongs(artistId: String, sortInfo: ISortInfo): Flow> - suspend fun getArtistSongCount(artistId: String): Int - - fun getAllAlbums(sortInfo: ISortInfo): Flow> - suspend fun getAlbumCount(): Int - suspend fun getAlbumSongs(albumId: String): List - - fun getAllPlaylists(sortInfo: ISortInfo): Flow> - - fun getPlaylistSongs(playlistId: String): Flow> - - fun getLikedSongs(sortInfo: ISortInfo): Flow> - fun getLikedSongCount(): Flow - fun getDownloadedSongs(sortInfo: ISortInfo): Flow> - fun getDownloadedSongCount(): Flow - - /** - * Search - */ - fun searchAll(query: String): Flow> - fun searchSongs(query: String): Flow> - fun searchDownloadedSongs(query: String): Flow> - fun searchArtists(query: String): Flow> - fun searchAlbums(query: String): Flow> - fun searchPlaylists(query: String): Flow> - - /** - * Song - */ - suspend fun addSong(mediaMetadata: MediaMetadata) = addSongs(listOf(mediaMetadata)) - suspend fun addSongs(songs: List) - suspend fun refetchSong(song: Song) = refetchSongs(listOf(song)) - suspend fun refetchSongs(songs: List) - fun getSongById(songId: String?): Flow - fun getSongFile(songId: String): File - suspend fun incrementSongTotalPlayTime(songId: String, playTime: Long) - suspend fun updateSongDuration(songId: String, duration: Int) - suspend fun updateSongTitle(song: Song, newTitle: String) - suspend fun toggleLiked(song: Song) = toggleLiked(listOf(song)) - suspend fun toggleLiked(songs: List) - suspend fun downloadSong(songId: String) = downloadSongs(listOf(songId)) - suspend fun downloadSongs(songIds: List) - suspend fun onDownloadComplete(downloadId: Long, success: Boolean) - suspend fun validateDownloads() - suspend fun removeDownloads(songs: List) - suspend fun moveToTrash(songs: List) - suspend fun restoreFromTrash(songs: List) - suspend fun deleteSong(songId: String) - suspend fun deleteSong(song: Song) = deleteSongs(listOf(song)) - suspend fun deleteSongs(songs: List) - - /** - * Artist - */ - suspend fun getArtistById(artistId: String): ArtistEntity? - suspend fun getArtistByName(name: String): ArtistEntity? - suspend fun refetchArtist(artist: ArtistEntity) = refetchArtists(listOf(artist)) - suspend fun refetchArtists(artists: List) - suspend fun updateArtist(artist: ArtistEntity) - - /** - * Album - */ - suspend fun addAlbum(album: AlbumItem) = addAlbums(listOf(album)) - suspend fun addAlbums(albums: List) - suspend fun getAlbum(albumId: String): Album? - suspend fun getAlbumWithSongs(albumId: String): AlbumWithSongs? - suspend fun deleteAlbum(albumId: String) - suspend fun deleteAlbums(albums: List) - - /** - * Playlist - */ - suspend fun addPlaylist(playlist: PlaylistItem) = addPlaylists(listOf(playlist)) - suspend fun addPlaylists(playlists: List) - suspend fun importPlaylist(playlist: PlaylistItem) = importPlaylists(listOf(playlist)) - suspend fun importPlaylists(playlists: List) - suspend fun insertPlaylist(playlist: PlaylistEntity) - fun getPlaylist(playlistId: String): Flow - suspend fun addToPlaylist(playlist: PlaylistEntity, item: LocalItem) = addToPlaylist(playlist, listOf(item)) - suspend fun addToPlaylist(playlist: PlaylistEntity, items: List) - suspend fun addYouTubeItemToPlaylist(playlist: PlaylistEntity, item: YTItem) = addYTItemsToPlaylist(playlist, listOf(item)) - suspend fun addYTItemsToPlaylist(playlist: PlaylistEntity, items: List) - suspend fun addMediaMetadataToPlaylist(playlist: PlaylistEntity, mediaMetadata: MediaMetadata) - suspend fun refetchPlaylists(playlists: List) - suspend fun downloadPlaylists(playlists: List) - suspend fun getPlaylistById(playlistId: String): Playlist - suspend fun updatePlaylist(playlist: PlaylistEntity) - suspend fun movePlaylistItems(playlistId: String, from: Int, to: Int) - suspend fun removeSongFromPlaylist(playlistId: String, position: Int) - suspend fun removeSongsFromPlaylist(playlistId: String, positions: List) - suspend fun deletePlaylists(playlists: List) - - /** - * Download - */ - suspend fun addDownloadEntity(item: DownloadEntity) - suspend fun getDownloadEntity(downloadId: Long): DownloadEntity? - suspend fun removeDownloadEntity(downloadId: Long) - - /** - * Search history - */ - fun getAllSearchHistory(): Flow> - fun getSearchHistory(query: String): Flow> - suspend fun insertSearchHistory(query: String) - suspend fun deleteSearchHistory(query: String) - suspend fun clearSearchHistory() - - /** - * Format - */ - fun getSongFormat(songId: String?): Flow - suspend fun upsert(format: FormatEntity) - - /** - * Lyrics - */ - fun getLyrics(songId: String?): Flow - suspend fun hasLyrics(songId: String): Boolean - suspend fun upsert(lyrics: LyricsEntity) - suspend fun deleteLyrics(songId: String) -} 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 e5e368dc0..2987c224a 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 @@ -525,6 +525,7 @@ inline fun YouTubeGridItem( maxLines = 2, overflow = TextOverflow.Ellipsis, textAlign = if (item is ArtistItem) TextAlign.Center else TextAlign.Start, + modifier = Modifier.fillMaxWidth() ) Row(verticalAlignment = Alignment.CenterVertically) { 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 1936604bf..3941b7c1b 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 @@ -31,29 +31,26 @@ 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.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.ui.component.shimmer.ShimmerHost -import com.zionhuang.music.ui.component.shimmer.TextPlaceholder -import com.zionhuang.music.ui.screens.settings.LyricsPosition -import com.zionhuang.music.ui.utils.fadingEdge import com.zionhuang.music.constants.LYRICS_TEXT_POSITION import com.zionhuang.music.db.entities.LyricsEntity import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND import com.zionhuang.music.extensions.mutablePreferenceState -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.lyrics.LyricsEntry import com.zionhuang.music.lyrics.LyricsEntry.Companion.HEAD_LYRICS_ENTRY -import com.zionhuang.music.lyrics.LyricsHelper 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.screens.settings.LyricsPosition +import com.zionhuang.music.ui.utils.fadingEdge import com.zionhuang.music.viewmodels.LyricsMenuViewModel -import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.delay import kotlinx.coroutines.isActive -import kotlinx.coroutines.launch import kotlin.time.Duration.Companion.seconds @Composable @@ -65,7 +62,6 @@ fun Lyrics( val playerConnection = LocalPlayerConnection.current ?: return val menuState = LocalMenuState.current val density = LocalDensity.current - val coroutineScope = rememberCoroutineScope() val lyricsTextPosition by mutablePreferenceState(LYRICS_TEXT_POSITION, LyricsPosition.CENTER) @@ -239,7 +235,6 @@ fun Lyrics( LyricsMenu( lyricsProvider = { lyricsEntity?.lyrics }, mediaMetadataProvider = mediaMetadataProvider, - coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) } @@ -258,11 +253,11 @@ fun Lyrics( fun LyricsMenu( lyricsProvider: () -> String?, mediaMetadataProvider: () -> MediaMetadata, - coroutineScope: CoroutineScope, onDismiss: () -> Unit, - viewModel: LyricsMenuViewModel = viewModel(), + viewModel: LyricsMenuViewModel = hiltViewModel(), ) { val context = LocalContext.current + val database = LocalDatabase.current var showEditDialog by rememberSaveable { mutableStateOf(false) @@ -276,8 +271,8 @@ fun LyricsMenu( initialTextFieldValue = TextFieldValue(lyricsProvider().orEmpty()), singleLine = false, onDone = { - coroutineScope.launch { - SongRepository(context).upsert(LyricsEntity( + database.query { + upsert(LyricsEntity( id = mediaMetadataProvider().id, lyrics = it )) @@ -391,8 +386,8 @@ fun LyricsMenu( .clickable { onDismiss() viewModel.cancelSearch() - coroutineScope.launch { - SongRepository(context).upsert(LyricsEntity( + database.query { + upsert(LyricsEntity( id = searchMediaMetadata.id, lyrics = result.lyrics )) @@ -478,11 +473,7 @@ fun LyricsMenu( icon = R.drawable.ic_cached, title = R.string.menu_refetch ) { - val mediaMetadata = mediaMetadataProvider() - coroutineScope.launch { - SongRepository(context).deleteLyrics(mediaMetadata.id) - LyricsHelper.loadLyrics(context, mediaMetadata) - } + viewModel.refetchLyrics(mediaMetadataProvider()) onDismiss() } GridMenuItem( 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 ad7bd9452..f08511a4b 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 @@ -23,18 +23,18 @@ import androidx.compose.ui.unit.sp import androidx.navigation.NavController import coil.compose.AsyncImage import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ListThumbnailSize import com.zionhuang.music.db.entities.Playlist import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.* import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch @@ -48,8 +48,8 @@ fun SongMenu( onDismiss: () -> Unit, ) { val context = LocalContext.current - val songRepository = SongRepository(context) - val songState = songRepository.getSongById(originalSong.id).collectAsState(initial = originalSong) + val database = LocalDatabase.current + val songState = database.song(originalSong.id).collectAsState(initial = originalSong) val song = songState.value ?: originalSong var showChoosePlaylistDialog by rememberSaveable { @@ -69,7 +69,7 @@ fun SongMenu( } LaunchedEffect(Unit) { - SongRepository(context).getAllPlaylists(PlaylistSortInfoPreference).collect { + database.playlistsByCreateDateDesc().collect { playlists = it } } @@ -102,7 +102,13 @@ fun SongMenu( showChoosePlaylistDialog = false onDismiss() coroutineScope.launch { - SongRepository(context).addToPlaylist(playlist.playlist, song) + database.query { + insert(PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = playlist.songCount + )) + } } } ) @@ -116,8 +122,8 @@ fun SongMenu( title = { Text(text = stringResource(R.string.dialog_title_create_playlist)) }, onDismiss = { showCreatePlaylistDialog = false }, onDone = { playlistName -> - coroutineScope.launch { - SongRepository(context).insertPlaylist(PlaylistEntity( + database.query { + insert(PlaylistEntity( name = playlistName )) } @@ -180,8 +186,8 @@ fun SongMenu( trailingContent = { IconButton( onClick = { - coroutineScope.launch { - songRepository.toggleLiked(song) + database.query { + insert(song.song.toggleLike()) } } ) { @@ -208,22 +214,22 @@ fun SongMenu( icon = R.drawable.ic_radio, title = R.string.menu_start_radio ) { - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) onDismiss() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) } GridMenuItem( icon = R.drawable.ic_playlist_play, title = R.string.menu_play_next ) { - playerConnection.playNext(song.toMediaItem()) onDismiss() + playerConnection.playNext(song.toMediaItem()) } GridMenuItem( icon = R.drawable.ic_queue_music, title = R.string.menu_add_to_queue ) { - playerConnection.addToQueue((song.toMediaItem())) onDismiss() + playerConnection.addToQueue((song.toMediaItem())) } GridMenuItem( icon = R.drawable.ic_edit, @@ -261,39 +267,30 @@ fun SongMenu( icon = R.drawable.ic_album, title = R.string.menu_view_album ) { - navController.navigate("album/${song.song.albumId}") onDismiss() + navController.navigate("album/${song.song.albumId}") } } GridMenuItem( icon = R.drawable.ic_share, title = R.string.menu_share ) { + onDismiss() val intent = Intent().apply { action = Intent.ACTION_SEND type = "text/plain" putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${song.id}") } context.startActivity(Intent.createChooser(intent, null)) - onDismiss() - } - GridMenuItem( - icon = R.drawable.ic_cached, - title = R.string.menu_refetch - ) { - coroutineScope.launch { - songRepository.refetchSong(song) - } - onDismiss() } GridMenuItem( icon = R.drawable.ic_delete, title = R.string.menu_delete ) { - coroutineScope.launch { - songRepository.deleteSong(song) - } onDismiss() + database.query { + delete(song) + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt index efb5bcae9..b2ae6da8f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt @@ -14,12 +14,14 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem import com.zionhuang.innertube.models.ArtistItem import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.extensions.toMediaItem @@ -27,12 +29,13 @@ import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository 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.viewmodels.MainViewModel import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch @Composable @@ -41,11 +44,11 @@ fun YouTubeSongMenu( navController: NavController, playerConnection: PlayerConnection, coroutineScope: CoroutineScope, - mainViewModel: MainViewModel = viewModel(), + mainViewModel: MainViewModel = hiltViewModel(), onDismiss: () -> Unit, ) { val context = LocalContext.current - val songRepository = SongRepository(context) + val database = LocalDatabase.current val librarySongIds by mainViewModel.librarySongIds.collectAsState() val addedToLibrary = remember(librarySongIds) { song.id in librarySongIds @@ -137,8 +140,12 @@ fun YouTubeSongMenu( icon = R.drawable.ic_library_add_check, title = R.string.action_remove_from_library ) { - coroutineScope.launch { - songRepository.deleteSong(song.id) + coroutineScope.launch(Dispatchers.IO) { + database.song(song.id).first()?.let { song -> + database.query { + delete(song) + } + } } } } else { @@ -146,8 +153,8 @@ fun YouTubeSongMenu( icon = R.drawable.ic_library_add, title = R.string.action_add_to_library ) { - coroutineScope.launch { - songRepository.addSong(song.toMediaMetadata()) + database.query { + insert(song.toMediaMetadata()) } } } @@ -208,11 +215,11 @@ fun YouTubeAlbumMenu( navController: NavController, playerConnection: PlayerConnection, coroutineScope: CoroutineScope, - mainViewModel: MainViewModel = viewModel(), + mainViewModel: MainViewModel = hiltViewModel(), onDismiss: () -> Unit, ) { val context = LocalContext.current - val songRepository = SongRepository(context) + val database = LocalDatabase.current val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() val addedToLibrary = remember(libraryAlbumIds) { album.id in libraryAlbumIds @@ -303,8 +310,10 @@ fun YouTubeAlbumMenu( icon = R.drawable.ic_library_add_check, title = R.string.action_remove_from_library ) { - coroutineScope.launch { - songRepository.deleteAlbum(album.id) + database.query { + album(album.id)?.let { + delete(it.album) + } } } } else { @@ -312,8 +321,12 @@ fun YouTubeAlbumMenu( icon = R.drawable.ic_library_add, title = R.string.action_add_to_library ) { - coroutineScope.launch { - songRepository.addAlbum(album) + coroutineScope.launch(Dispatchers.IO) { + YouTube.browseAlbum(album.browseId).onSuccess { albumPage -> + database.query { + insert(albumPage) + } + } } } } 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 954e84edb..4bcd386ff 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 @@ -24,7 +24,7 @@ import androidx.compose.ui.text.withStyle import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEachIndexed -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -50,7 +50,7 @@ import com.zionhuang.music.viewmodels.AlbumViewState @Composable fun AlbumScreen( navController: NavController, - viewModel: AlbumViewModel = viewModel(), + viewModel: AlbumViewModel = hiltViewModel(), ) { val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt index 0911ae9fc..fb7c123e7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt @@ -21,7 +21,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.models.* import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -48,8 +48,8 @@ import com.zionhuang.music.viewmodels.MainViewModel fun ArtistItemsScreen( navController: NavController, appBarConfig: AppBarConfig, - viewModel: ArtistItemsViewModel = viewModel(), - mainViewModel: MainViewModel = viewModel(), + viewModel: ArtistItemsViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt index a10255c37..1e8a687ee 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt @@ -18,7 +18,7 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.util.fastForEach -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage import com.valentinilk.shimmer.shimmer @@ -47,8 +47,8 @@ import com.zionhuang.music.viewmodels.MainViewModel fun ArtistScreen( navController: NavController, appBarConfig: AppBarConfig, - viewModel: ArtistViewModel = viewModel(), - mainViewModel: MainViewModel = viewModel(), + viewModel: ArtistViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt index da174cc4e..0f155a895 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt @@ -7,9 +7,8 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.remember -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.util.fastSumBy -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.ui.component.SongListItem @@ -17,11 +16,7 @@ import com.zionhuang.music.viewmodels.LocalPlaylistViewModel @Composable fun LocalPlaylistScreen( - playlistId: String, - viewModel: LocalPlaylistViewModel = viewModel(factory = LocalPlaylistViewModel.Factory( - context = LocalContext.current, - playlistId = playlistId - )), + viewModel: LocalPlaylistViewModel = hiltViewModel(), ) { val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt index 17ff3c765..1873553a8 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt @@ -15,12 +15,10 @@ import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.ui.component.* -import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.constants.CONTENT_TYPE_LIST import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.db.entities.Album @@ -29,6 +27,8 @@ import com.zionhuang.music.db.entities.Playlist import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.* +import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.viewmodels.LocalFilter import com.zionhuang.music.viewmodels.LocalSearchViewModel @@ -37,7 +37,7 @@ import com.zionhuang.music.viewmodels.LocalSearchViewModel fun LocalSearchScreen( query: String, navController: NavController, - viewModel: LocalSearchViewModel = viewModel(), + viewModel: LocalSearchViewModel = hiltViewModel(), ) { val context = LocalContext.current val menuState = LocalMenuState.current diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt index 7510bcd59..eedebd5b1 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt @@ -16,7 +16,7 @@ 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.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ALBUM import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_ARTIST @@ -49,8 +49,8 @@ import kotlinx.coroutines.launch @Composable fun OnlineSearchResult( navController: NavController, - viewModel: OnlineSearchViewModel = viewModel(), - mainViewModel: MainViewModel = viewModel(), + viewModel: OnlineSearchViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current val playerConnection = LocalPlayerConnection.current ?: return diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt index bf9aa3608..29f49568f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt @@ -10,25 +10,23 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.models.* +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.SuggestionItemHeight import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlineSearchSuggestionViewModel -import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -38,11 +36,10 @@ fun OnlineSearchScreen( navController: NavController, onSearch: (String) -> Unit, onDismiss: () -> Unit, - viewModel: OnlineSearchSuggestionViewModel = viewModel(), - mainViewModel: MainViewModel = viewModel(), + viewModel: OnlineSearchSuggestionViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() + val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() @@ -61,24 +58,24 @@ fun OnlineSearchScreen( LazyColumn { items( items = viewState.history, - key = { it } - ) { query -> + key = { it.query } + ) { history -> SuggestionItem( - query = query, + query = history.query, online = false, onClick = { - onSearch(query) + onSearch(history.query) onDismiss() }, onDelete = { - coroutineScope.launch { - SongRepository(context).deleteSearchHistory(query) + database.query { + delete(history) } }, onFillTextField = { onTextFieldValueChange(TextFieldValue( - text = query, - selection = TextRange(query.length) + text = history.query, + selection = TextRange(history.query.length) )) }, modifier = Modifier.animateItemPlacement() 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 b543b482b..fdf340d3f 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 @@ -19,7 +19,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection @@ -35,7 +35,7 @@ import com.zionhuang.music.viewmodels.LibraryAlbumsViewModel @Composable fun LibraryAlbumsScreen( navController: NavController, - viewModel: LibraryAlbumsViewModel = viewModel(), + viewModel: LibraryAlbumsViewModel = hiltViewModel(), ) { val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() 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 5db812896..d0cbb8e3c 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 @@ -12,44 +12,31 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi 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.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.mutablePreferenceState import com.zionhuang.music.models.sortInfo.ArtistSortType -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.ArtistListItem import com.zionhuang.music.ui.component.ResizableIconButton import com.zionhuang.music.viewmodels.LibraryArtistsViewModel -import java.time.Duration -import java.time.LocalDateTime @OptIn(ExperimentalFoundationApi::class) @Composable fun LibraryArtistsScreen( navController: NavController, - viewModel: LibraryArtistsViewModel = viewModel(), + viewModel: LibraryArtistsViewModel = hiltViewModel(), ) { - val context = LocalContext.current val artists by viewModel.allArtists.collectAsState() - LaunchedEffect(artists) { - SongRepository(context).refetchArtists( - artists.map { it.artist }.filter { - it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) - } - ) - } - Box( modifier = Modifier.fillMaxSize() ) { 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 82a62ea58..7b15dccaa 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 @@ -13,39 +13,36 @@ import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi 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.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +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.* -import com.zionhuang.music.db.entities.DOWNLOADED_PLAYLIST_ID -import com.zionhuang.music.db.entities.LIKED_PLAYLIST_ID 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.extensions.mutablePreferenceState import com.zionhuang.music.models.sortInfo.PlaylistSortType -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.ListItem import com.zionhuang.music.ui.component.PlaylistListItem import com.zionhuang.music.ui.component.ResizableIconButton import com.zionhuang.music.ui.component.TextFieldDialog import com.zionhuang.music.viewmodels.LibraryPlaylistsViewModel -import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun LibraryPlaylistsScreen( navController: NavController, - viewModel: LibraryPlaylistsViewModel = viewModel(), + viewModel: LibraryPlaylistsViewModel = hiltViewModel(), ) { - val context = LocalContext.current - val coroutineScope = rememberCoroutineScope() + val database = LocalDatabase.current val likedSongCount by viewModel.likedSongCount.collectAsState() val downloadedSongCount by viewModel.downloadedSongCount.collectAsState() val playlists by viewModel.allPlaylists.collectAsState() @@ -60,8 +57,8 @@ fun LibraryPlaylistsScreen( title = { Text(text = stringResource(R.string.dialog_title_create_playlist)) }, onDismiss = { showAddPlaylistDialog = false }, onDone = { playlistName -> - coroutineScope.launch { - SongRepository(context).insertPlaylist(PlaylistEntity( + database.query { + insert(PlaylistEntity( name = playlistName )) } 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 64b0c9fae..f89ea934e 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 @@ -20,7 +20,7 @@ import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.lifecycle.viewmodel.compose.viewModel +import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection @@ -38,7 +38,7 @@ import com.zionhuang.music.viewmodels.LibrarySongsViewModel @Composable fun LibrarySongsScreen( navController: NavController, - viewModel: LibrarySongsViewModel = viewModel(), + viewModel: LibrarySongsViewModel = hiltViewModel(), ) { val context = LocalContext.current val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt index a4a77487a..446804e96 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt @@ -1,7 +1,5 @@ package com.zionhuang.music.ui.screens.settings -import android.content.Intent -import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column @@ -13,86 +11,27 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource +import androidx.hilt.navigation.compose.hiltViewModel import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.checkpoint -import com.zionhuang.music.extensions.zipInputStream -import com.zionhuang.music.extensions.zipOutputStream -import com.zionhuang.music.playback.MusicService -import com.zionhuang.music.playback.SongPlayer import com.zionhuang.music.ui.component.PreferenceEntry -import java.io.File -import java.io.FileInputStream -import java.io.FileOutputStream +import com.zionhuang.music.viewmodels.BackupRestoreViewModel import java.time.LocalDateTime import java.time.format.DateTimeFormatter -import java.util.zip.ZipEntry -import kotlin.system.exitProcess @Composable -fun BackupAndRestore() { +fun BackupAndRestore( + viewModel: BackupRestoreViewModel = hiltViewModel(), +) { val context = LocalContext.current val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - context.applicationContext.contentResolver.openOutputStream(uri)?.use { - it.buffered().zipOutputStream().use { outputStream -> - File( - File(context.filesDir.parentFile, "shared_prefs"), - "${context.packageName}_preferences.xml" - ).inputStream().buffered().use { inputStream -> - outputStream.putNextEntry(ZipEntry(PREF_NAME)) - inputStream.copyTo(outputStream) - } - val database = MusicDatabase.getInstance(context) - database.checkpoint() - FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> - outputStream.putNextEntry(ZipEntry(MusicDatabase.DB_NAME)) - inputStream.copyTo(outputStream) - } - } - } - }.onSuccess { - Toast.makeText(context, R.string.message_backup_create_success, Toast.LENGTH_SHORT).show() - }.onFailure { - Toast.makeText(context, R.string.message_backup_create_failed, Toast.LENGTH_SHORT).show() + if (uri != null) { + viewModel.backup(uri) } } val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri == null) return@rememberLauncherForActivityResult - runCatching { - context.applicationContext.contentResolver.openInputStream(uri)?.use { - it.zipInputStream().use { inputStream -> - var entry = inputStream.nextEntry - while (entry != null) { - when (entry.name) { - PREF_NAME -> { - File( - File(context.filesDir.parentFile, "shared_prefs"), - "${context.packageName}_preferences.xml" - ).outputStream().use { outputStream -> - inputStream.copyTo(outputStream) - } - } - MusicDatabase.DB_NAME -> { - val database = MusicDatabase.getInstance(context) - database.checkpoint() - database.close() - FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> - inputStream.copyTo(outputStream) - } - } - } - entry = inputStream.nextEntry - } - } - } - context.stopService(Intent(context, MusicService::class.java)) - context.filesDir.resolve(SongPlayer.PERSISTENT_QUEUE_FILE).delete() - exitProcess(0) - }.onFailure { - Toast.makeText(context, R.string.message_restore_failed, Toast.LENGTH_SHORT).show() + if (uri != null) { + viewModel.restore(uri) } } Column( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index 5e3ecfd77..8f5e8edb7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -10,25 +10,21 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.* import androidx.compose.ui.Modifier -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.ENABLE_KUGOU import com.zionhuang.music.constants.PAUSE_SEARCH_HISTORY import com.zionhuang.music.extensions.mutablePreferenceState -import com.zionhuang.music.repos.SongRepository import com.zionhuang.music.ui.component.DefaultDialog import com.zionhuang.music.ui.component.PreferenceEntry import com.zionhuang.music.ui.component.SwitchPreference -import kotlinx.coroutines.launch @Composable fun PrivacySettings() { - val context = LocalContext.current - val songRepository = SongRepository(context) - val coroutineScope = rememberCoroutineScope() + val database = LocalDatabase.current val (pauseSearchHistory, onPauseSearchHistoryChange) = mutablePreferenceState(key = PAUSE_SEARCH_HISTORY, defaultValue = false) val (enableKugou, onEnableKugouChange) = mutablePreferenceState(key = ENABLE_KUGOU, defaultValue = true) @@ -56,8 +52,8 @@ fun PrivacySettings() { TextButton( onClick = { showClearHistoryDialog = false - coroutineScope.launch { - songRepository.clearSearchHistory() + database.query { + clearSearchHistory() } } ) { 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 93f1c413d..6848b3c9f 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 @@ -19,8 +19,8 @@ import coil.imageLoader import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.constants.IMAGE_MAX_CACHE_SIZE -import com.zionhuang.music.constants.SONG_MAX_CACHE_SIZE +import com.zionhuang.music.constants.MAX_IMAGE_CACHE_SIZE +import com.zionhuang.music.constants.MAX_SONG_CACHE_SIZE import com.zionhuang.music.extensions.mutablePreferenceState import com.zionhuang.music.ui.component.ListPreference import com.zionhuang.music.ui.component.PreferenceEntry @@ -56,8 +56,8 @@ fun StorageSettings() { } } - val (maxImageCacheSize, onMaxImageCacheSizeChange) = mutablePreferenceState(key = IMAGE_MAX_CACHE_SIZE, defaultValue = 512) - val (maxSongCacheSize, onMaxSongCacheSizeChange) = mutablePreferenceState(key = SONG_MAX_CACHE_SIZE, defaultValue = 1024) + val (maxImageCacheSize, onMaxImageCacheSizeChange) = mutablePreferenceState(key = MAX_IMAGE_CACHE_SIZE, defaultValue = 512) + val (maxSongCacheSize, onMaxSongCacheSizeChange) = mutablePreferenceState(key = MAX_SONG_CACHE_SIZE, defaultValue = 1024) Column( Modifier diff --git a/app/src/main/java/com/zionhuang/music/utils/DownloadUtils.kt b/app/src/main/java/com/zionhuang/music/utils/DownloadUtils.kt new file mode 100644 index 000000000..cbb1805cc --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/DownloadUtils.kt @@ -0,0 +1,11 @@ +package com.zionhuang.music.utils + +import android.content.Context +import com.zionhuang.music.extensions.div +import java.io.File + +fun getSongFile(context: Context, songId: String): File { + val mediaDir = context.getExternalFilesDir(null)!! / "media" + if (!mediaDir.isDirectory) mediaDir.mkdirs() + return mediaDir / md5(songId) +} 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 a550606ad..146e30b4d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt @@ -1,29 +1,31 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel 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.repos.SongRepository +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import javax.inject.Inject -class AlbumViewModel( - application: Application, +@HiltViewModel +class AlbumViewModel @Inject constructor( + database: MusicDatabase, savedStateHandle: SavedStateHandle, -) : AndroidViewModel(application) { - val songRepository = SongRepository(application) +) : ViewModel() { val albumId = savedStateHandle.get("albumId")!! private val _viewState = MutableStateFlow(null) val viewState = _viewState.asStateFlow() init { viewModelScope.launch { - _viewState.value = songRepository.getAlbumWithSongs(albumId)?.let { + _viewState.value = database.albumWithSongs(albumId).first()?.let { AlbumViewState.Local(it) } ?: YouTube.browseAlbum(albumId).getOrNull()?.let { AlbumViewState.Remote(it) 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 1a5e05d0a..43f3354c0 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt @@ -6,15 +6,18 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.BrowseEndpoint import com.zionhuang.music.models.ItemsPage +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject -class ArtistItemsViewModel( +@HiltViewModel +class ArtistItemsViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { - val browseId = savedStateHandle.get("browseId")!! - val params = savedStateHandle.get("params") + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") val title = MutableStateFlow("") val itemsPage = MutableStateFlow(null) 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 c12b9be0a..377b40841 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -1,20 +1,21 @@ package com.zionhuang.music.viewmodels -import android.app.Application import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue -import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.ArtistPage +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch +import javax.inject.Inject -class ArtistViewModel( - application: Application, +@HiltViewModel +class ArtistViewModel @Inject constructor( savedStateHandle: SavedStateHandle, -) : AndroidViewModel(application) { +) : ViewModel() { val artistId = savedStateHandle.get("artistId")!! var artistPage by mutableStateOf(null) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt new file mode 100644 index 000000000..9a411ce10 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -0,0 +1,90 @@ +package com.zionhuang.music.viewmodels + +import android.app.Application +import android.content.Intent +import android.net.Uri +import android.widget.Toast +import androidx.lifecycle.AndroidViewModel +import com.zionhuang.music.App +import com.zionhuang.music.R +import com.zionhuang.music.db.InternalDatabase +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.extensions.zipInputStream +import com.zionhuang.music.extensions.zipOutputStream +import com.zionhuang.music.playback.MusicService +import com.zionhuang.music.playback.SongPlayer +import com.zionhuang.music.ui.screens.settings.PREF_NAME +import dagger.hilt.android.lifecycle.HiltViewModel +import java.io.File +import java.io.FileInputStream +import java.io.FileOutputStream +import java.util.zip.ZipEntry +import javax.inject.Inject +import kotlin.system.exitProcess + +@HiltViewModel +class BackupRestoreViewModel @Inject constructor( + application: Application, + val database: MusicDatabase, +) : AndroidViewModel(application) { + val app = getApplication() + fun backup(uri: Uri) { + runCatching { + app.applicationContext.contentResolver.openOutputStream(uri)?.use { + it.buffered().zipOutputStream().use { outputStream -> + File( + File(app.filesDir.parentFile, "shared_prefs"), + "${app.packageName}_preferences.xml" + ).inputStream().buffered().use { inputStream -> + outputStream.putNextEntry(ZipEntry(PREF_NAME)) + inputStream.copyTo(outputStream) + } + database.checkpoint() + FileInputStream(database.delegate.openHelper.writableDatabase.path).use { inputStream -> + outputStream.putNextEntry(ZipEntry(InternalDatabase.DB_NAME)) + inputStream.copyTo(outputStream) + } + } + } + }.onSuccess { + Toast.makeText(app, R.string.message_backup_create_success, Toast.LENGTH_SHORT).show() + }.onFailure { + Toast.makeText(app, R.string.message_backup_create_failed, Toast.LENGTH_SHORT).show() + } + } + + fun restore(uri: Uri) { + runCatching { + this.app.applicationContext.contentResolver.openInputStream(uri)?.use { + it.zipInputStream().use { inputStream -> + var entry = inputStream.nextEntry + while (entry != null) { + when (entry.name) { + PREF_NAME -> { + File( + File(app.filesDir.parentFile, "shared_prefs"), + "${app.packageName}_preferences.xml" + ).outputStream().use { outputStream -> + inputStream.copyTo(outputStream) + } + } + InternalDatabase.DB_NAME -> { + database.checkpoint() + database.delegate.close() + FileOutputStream(database.delegate.openHelper.writableDatabase.path).use { outputStream -> + inputStream.copyTo(outputStream) + } + } + } + entry = inputStream.nextEntry + } + } + } + app.stopService(Intent(app, MusicService::class.java)) + app.filesDir.resolve(SongPlayer.PERSISTENT_QUEUE_FILE).delete() + exitProcess(0) + }.onFailure { + Toast.makeText(app, R.string.message_restore_failed, Toast.LENGTH_SHORT).show() + } + } +} 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 6146dd8a3..6627348bd 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -2,69 +2,99 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.models.sortInfo.AlbumSortInfoPreference import com.zionhuang.music.models.sortInfo.ArtistSortInfoPreference import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference import com.zionhuang.music.models.sortInfo.SongSortInfoPreference -import com.zionhuang.music.repos.SongRepository +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.stateIn - -class LibrarySongsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - +import kotlinx.coroutines.launch +import java.time.Duration +import java.time.LocalDateTime +import javax.inject.Inject + +@HiltViewModel +class LibrarySongsViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val allSongs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllSongs(sortInfo) + database.songs(sortInfo.type, sortInfo.isDescending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } -class LibraryArtistsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - +@HiltViewModel +class LibraryArtistsViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val allArtists = ArtistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllArtists(sortInfo) + database.artists(sortInfo.type, sortInfo.isDescending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) -} -class LibraryAlbumsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) + init { + viewModelScope.launch { + allArtists.collect { artists -> + artists + .map { it.artist } + .filter { + it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) + } + .forEach { artist -> + YouTube.browseArtist(artist.id).onSuccess { artistPage -> + database.query { + update(artist, artistPage) + } + } + } + } + } + } +} +@HiltViewModel +class LibraryAlbumsViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val allAlbums = AlbumSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllAlbums(sortInfo) + database.albums(sortInfo.type, sortInfo.isDescending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } -class LibraryPlaylistsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - - val likedSongCount = songRepository.getLikedSongCount() +@HiltViewModel +class LibraryPlaylistsViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { + val likedSongCount = database.likedSongsCount() .stateIn(viewModelScope, SharingStarted.Lazily, 0) - val downloadedSongCount = songRepository.getDownloadedSongCount() + val downloadedSongCount = database.downloadedSongsCount() .stateIn(viewModelScope, SharingStarted.Lazily, 0) val allPlaylists = PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getAllPlaylists(sortInfo) + database.playlists(sortInfo.type, sortInfo.isDescending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } -class LikedSongsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - +@HiltViewModel +class LikedSongsViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val songs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getLikedSongs(sortInfo) + database.likedSongs(sortInfo.type, sortInfo.isDescending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } -class DownloadedSongsViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) - +@HiltViewModel +class DownloadedSongsViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val songs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - songRepository.getDownloadedSongs(sortInfo) + database.downloadedSongs(sortInfo.type, sortInfo.isDescending) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt index 210e1149c..03f7a070d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt @@ -1,23 +1,22 @@ package com.zionhuang.music.viewmodels -import android.content.Context -import androidx.lifecycle.* -import com.zionhuang.music.repos.SongRepository +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject -class LocalPlaylistViewModel( - context: Context, - playlistId: String, +@HiltViewModel +class LocalPlaylistViewModel @Inject constructor( + database: MusicDatabase, + savedStateHandle: SavedStateHandle, ) : ViewModel() { - val playlist = SongRepository(context).getPlaylist(playlistId).stateIn(viewModelScope, SharingStarted.Lazily, null) - val playlistSongs = SongRepository(context).getPlaylistSongs(playlistId).stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - - class Factory( - val context: Context, - val playlistId: String, - ) : ViewModelProvider.NewInstanceFactory() { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = LocalPlaylistViewModel(context, playlistId) as T - } + val playlistId = savedStateHandle.get("playlistId")!! + val playlist = database.playlist(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + val playlistSongs = database.playlistSongs(playlistId) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt index 74852eba5..18c59a4d0 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalSearchViewModel.kt @@ -1,18 +1,22 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.* -import com.zionhuang.music.repos.SongRepository +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) -class LocalSearchViewModel(application: Application) : AndroidViewModel(application) { - private val songRepository = SongRepository(application) +@HiltViewModel +class LocalSearchViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val query = MutableStateFlow("") val filter = MutableStateFlow(LocalFilter.ALL) + val result = combine(query, filter) { query, filter -> query to filter }.flatMapLatest { (query, filter) -> @@ -20,11 +24,18 @@ class LocalSearchViewModel(application: Application) : AndroidViewModel(applicat flowOf(LocalSearchResult("", filter, emptyMap())) } else { when (filter) { - LocalFilter.ALL -> songRepository.searchAll(query) - LocalFilter.SONG -> songRepository.searchSongs(query) - LocalFilter.ALBUM -> songRepository.searchAlbums(query) - LocalFilter.ARTIST -> songRepository.searchArtists(query) - LocalFilter.PLAYLIST -> songRepository.searchPlaylists(query) + LocalFilter.ALL -> combine( + database.searchSongs(query, PREVIEW_SIZE), + database.searchAlbums(query, PREVIEW_SIZE), + database.searchArtists(query, PREVIEW_SIZE), + database.searchPlaylists(query, PREVIEW_SIZE), + ) { songs, albums, artists, playlists -> + songs + albums + artists + playlists + } + LocalFilter.SONG -> database.searchSongs(query) + LocalFilter.ALBUM -> database.searchAlbums(query) + LocalFilter.ARTIST -> database.searchArtists(query) + LocalFilter.PLAYLIST -> database.searchPlaylists(query) }.map { list -> LocalSearchResult( query = query, @@ -40,6 +51,10 @@ class LocalSearchViewModel(application: Application) : AndroidViewModel(applicat } } }.stateIn(viewModelScope, SharingStarted.Lazily, LocalSearchResult("", filter.value, emptyMap())) + + companion object { + const val PREVIEW_SIZE = 3 + } } enum class LocalFilter { diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt index bc037588b..338afdac2 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt @@ -1,17 +1,25 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.LyricsEntity import com.zionhuang.music.lyrics.LyricsHelper import com.zionhuang.music.lyrics.LyricsResult +import com.zionhuang.music.models.MediaMetadata +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import javax.inject.Inject -class LyricsMenuViewModel(val app: Application) : AndroidViewModel(app) { +@HiltViewModel +class LyricsMenuViewModel @Inject constructor( + private val lyricsHelper: LyricsHelper, + val database: MusicDatabase, +) : ViewModel() { private var job: Job? = null val results = MutableStateFlow(emptyList()) val isLoading = MutableStateFlow(false) @@ -21,7 +29,7 @@ class LyricsMenuViewModel(val app: Application) : AndroidViewModel(app) { results.value = emptyList() job?.cancel() job = viewModelScope.launch(Dispatchers.IO) { - LyricsHelper.getAllLyrics(app, mediaId, title, artist, duration) { result -> + lyricsHelper.getAllLyrics(mediaId, title, artist, duration) { result -> results.update { it + result } @@ -34,4 +42,13 @@ class LyricsMenuViewModel(val app: Application) : AndroidViewModel(app) { job?.cancel() job = null } -} \ No newline at end of file + + fun refetchLyrics(mediaMetadata: MediaMetadata) { + viewModelScope.launch { + val lyrics = lyricsHelper.loadLyrics(mediaMetadata) + database.query { + upsert(LyricsEntity(mediaMetadata.id, lyrics)) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/MainViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/MainViewModel.kt index 411d0ca24..2be9f2065 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/MainViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/MainViewModel.kt @@ -1,29 +1,31 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope -import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn +import javax.inject.Inject -class MainViewModel(application: Application) : AndroidViewModel(application) { - val songRepository = SongRepository(application) - - val librarySongIds = songRepository.getAllSongId() +@HiltViewModel +class MainViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { + val librarySongIds = database.allSongId() .map(List::toHashSet) .stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) - val likedSongIds = songRepository.getAllLikedSongId() + val likedSongIds = database.allLikedSongId() .map(List::toHashSet) .stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) - val libraryAlbumIds = songRepository.getAllAlbumId() + val libraryAlbumIds = database.allAlbumId() .map(List::toHashSet) .stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) - val libraryPlaylistIds = songRepository.getAllPlaylistId() + val libraryPlaylistIds = database.allPlaylistId() .map(List::toHashSet) .stateIn(viewModelScope, SharingStarted.Lazily, emptySet()) } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt index 4a31e4ccc..931487476 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt @@ -1,18 +1,22 @@ package com.zionhuang.music.viewmodels -import android.app.Application -import androidx.lifecycle.AndroidViewModel +import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YTItem -import com.zionhuang.music.repos.SongRepository +import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.SearchHistory +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch +import javax.inject.Inject @OptIn(ExperimentalCoroutinesApi::class) -class OnlineSearchSuggestionViewModel(app: Application) : AndroidViewModel(app) { - private val songRepository = SongRepository(app) +@HiltViewModel +class OnlineSearchSuggestionViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { val query = MutableStateFlow("") private val _viewState = MutableStateFlow(SearchSuggestionViewState()) val viewState = _viewState.asStateFlow() @@ -21,21 +25,24 @@ class OnlineSearchSuggestionViewModel(app: Application) : AndroidViewModel(app) viewModelScope.launch { query.flatMapLatest { query -> if (query.isEmpty()) { - songRepository.getAllSearchHistory().map { history -> + database.searchHistory().map { history -> SearchSuggestionViewState( - history = history.map { it.query } + history = history ) } } else { val result = YouTube.getSearchSuggestions(query).getOrNull() - songRepository.getSearchHistory(query).map { searchHistory -> - val history = searchHistory.map { it.query }.take(3) - SearchSuggestionViewState( - history = history, - suggestions = result?.queries?.filter { it !in history }.orEmpty(), - items = result?.recommendedItems.orEmpty() - ) - } + database.searchHistory(query) + .map { it.take(3) } + .map { history -> + SearchSuggestionViewState( + history = history, + suggestions = result?.queries?.filter { query -> + history.none { it.query == query } + }.orEmpty(), + items = result?.recommendedItems.orEmpty() + ) + } } }.collect { _viewState.value = it @@ -45,7 +52,7 @@ class OnlineSearchSuggestionViewModel(app: Application) : AndroidViewModel(app) } data class SearchSuggestionViewState( - val history: List = emptyList(), + val history: List = emptyList(), val suggestions: List = emptyList(), val items: List = emptyList(), ) diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt index 17d134f1e..3baf5146a 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt @@ -10,10 +10,13 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.SearchSummaryPage import com.zionhuang.music.models.ItemsPage +import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import javax.inject.Inject -class OnlineSearchViewModel( +@HiltViewModel +class OnlineSearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { val query = savedStateHandle.get("query")!! diff --git a/build.gradle.kts b/build.gradle.kts index 3507d39ad..f987d8442 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -1,3 +1,7 @@ +plugins { + id("com.google.dagger.hilt.android").version("2.44").apply(false) +} + buildscript { repositories { google() diff --git a/settings.gradle.kts b/settings.gradle.kts index 848ac8912..a171c22b9 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -16,6 +16,7 @@ dependencyResolutionManagement { library("activity", "androidx.activity", "activity-compose").version("1.5.1") library("navigation", "androidx.navigation", "navigation-compose").version("2.5.3") + library("hilt-navigation", "androidx.hilt", "hilt-navigation-compose").version("1.0.0") version("compose-compiler", "1.3.2") version("compose", "1.3.0") @@ -49,13 +50,17 @@ dependencyResolutionManagement { library("paging-runtime", "androidx.paging", "paging-runtime").version("3.1.1") library("paging-compose", "androidx.paging", "paging-compose").version("1.0.0-alpha17") - version("room", "2.4.3") + version("room", "2.5.0") library("room-runtime", "androidx.room", "room-runtime").versionRef("room") library("room-compiler", "androidx.room", "room-compiler").versionRef("room") library("room-ktx", "androidx.room", "room-ktx").versionRef("room") library("apache-lang3", "org.apache.commons", "commons-lang3").version("3.12.0") + version("hilt", "2.44") + library("hilt", "com.google.dagger", "hilt-android").versionRef("hilt") + library("hilt-compiler", "com.google.dagger", "hilt-android-compiler").versionRef("hilt") + version("ktor", "2.2.2") library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor") library("ktor-client-okhttp", "io.ktor", "ktor-client-okhttp").versionRef("ktor") From 74db0275be3f2a98e9c48fe929c5a6987a1218eb Mon Sep 17 00:00:00 2001 From: Alliba <103368536+Alliba@users.noreply.github.com> Date: Wed, 18 Jan 2023 18:50:14 +0100 Subject: [PATCH 112/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index f207cf247..b025da6c8 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -219,6 +219,7 @@ %d Lieder wurden gelöscht. + %d ausgewählt %d ausgewählt Rückgängig machen From 05af74f34d195bb1380f3e3c9242a525e4ee9dbc Mon Sep 17 00:00:00 2001 From: siggi1984 <103368536+siggi1984@users.noreply.github.com> Date: Thu, 19 Jan 2023 21:41:41 +0100 Subject: [PATCH 113/323] Update strings.xml --- app/src/main/res/values-DE/strings.xml | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index b025da6c8..d7d370e87 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -1,7 +1,7 @@ Startseite - Lieder + Titel Künstler Alben Wiedergabelisten @@ -48,7 +48,7 @@ Heruntergeladene Dateien in SAF anzeigen Dies kann bei einigen Geräten nicht funktionieren Zwischenspeicher - MMaximale Größe des Bild-Caches + Maximale Größe des Bild-Caches Bild-Cache löschen Maximale Größe des Song-Cache %s verwendet @@ -109,7 +109,7 @@ Details Bearbeiten Radio starten - Spielen + Wiedergegeben Als nächstes wiedergeben Zu Warteschlange hinzufügen Zur Bibliothek hinzufügen @@ -141,7 +141,7 @@ Songtext suchen Songtext auswählen - Lied bearbeiten + Titel bearbeiten Songtitel Song-Künstler Der Songtitel darf nicht leer sein. @@ -167,7 +167,7 @@ Wählen Sie Inhalt wiederherstellen Einstellungen Datenbank - Heruntergeladene Lieder + Heruntergeladene Titel Sicherung erfolgreich erstellt Konnte keine Sicherung erstellen Wiederherstellung der Sicherung fehlgeschlagen @@ -216,7 +216,7 @@ %d Song wurde gelöscht. - %d Lieder wurden gelöscht. + %d Songs wurden gelöscht. %d ausgewählt @@ -226,7 +226,7 @@ Kann diese Url nicht identifizieren. Song wird als nächstes gespielt - %d Lieder werden als nächstes gespielt + %d Songs werden als nächstes gespielt Der Künstler spielt als nächstes @@ -277,7 +277,7 @@ Alles - Lieder + Titel Videos Alben Künstler @@ -299,8 +299,8 @@ Unbekannter Fehler - Alle Lieder - Gesuchte Lieder + Alle Titel + Gesuchte Titel Liedtext nicht gefunden From ac96c54cba9507a5a75bd7a9db6c1a3ded66c0c4 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 20 Jan 2023 13:14:26 +0800 Subject: [PATCH 114/323] Use DataStore --- app/build.gradle.kts | 1 + app/src/main/java/com/zionhuang/music/App.kt | 68 +- .../java/com/zionhuang/music/MainActivity.kt | 652 +++++++++--------- .../music/constants/PreferenceKeys.kt | 97 ++- .../com/zionhuang/music/db/DatabaseDao.kt | 2 +- .../com/zionhuang/music/db/MusicDatabase.kt | 1 - .../com/zionhuang/music/extensions/AppExt.kt | 7 - .../zionhuang/music/extensions/ContextExt.kt | 10 - .../zionhuang/music/extensions/CursorExt.kt | 26 - .../music/extensions/SharedPreferencesExt.kt | 169 ----- .../zionhuang/music/extensions/StringExt.kt | 8 + .../music/lyrics/KuGouLyricsProvider.kt | 9 +- .../sortInfo/AlbumSortInfoPreference.kt | 19 - .../sortInfo/ArtistSortInfoPreference.kt | 19 - .../music/models/sortInfo/ISortInfo.kt | 6 - .../sortInfo/PlaylistSortInfoPreference.kt | 19 - .../models/sortInfo/SongSortInfoPreference.kt | 19 - .../music/models/sortInfo/SortInfo.kt | 28 - .../models/sortInfo/SortInfoPreference.kt | 23 - .../zionhuang/music/playback/MusicService.kt | 2 +- .../zionhuang/music/playback/SongPlayer.kt | 60 +- .../zionhuang/music/provider/SongsProvider.kt | 2 +- .../zionhuang/music/ui/component/AppBar.kt | 10 +- .../zionhuang/music/ui/component/Lyrics.kt | 6 +- .../com/zionhuang/music/ui/player/Queue.kt | 8 +- .../zionhuang/music/ui/player/Thumbnail.kt | 6 +- .../ui/screens/library/LibraryAlbumsScreen.kt | 20 +- .../screens/library/LibraryArtistsScreen.kt | 12 +- .../screens/library/LibraryPlaylistsScreen.kt | 20 +- .../ui/screens/library/LibrarySongsScreen.kt | 20 +- .../ui/screens/settings/AppearanceSettings.kt | 14 +- .../ui/screens/settings/ContentSettings.kt | 15 +- .../ui/screens/settings/GeneralSettings.kt | 18 +- .../ui/screens/settings/PlayerSettings.kt | 19 +- .../ui/screens/settings/PrivacySettings.kt | 10 +- .../ui/screens/settings/StorageSettings.kt | 10 +- .../com/zionhuang/music/utils/DataStore.kt | 101 +++ .../com/zionhuang/music/utils/InfoCache.kt | 2 +- .../music/utils/NavigationTabHelper.kt | 21 - .../com/zionhuang/music/utils/Preference.kt | 33 - .../music/viewmodels/LibraryViewModels.kt | 91 ++- settings.gradle.kts | 1 + 42 files changed, 729 insertions(+), 955 deletions(-) delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/AppExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt delete mode 100644 app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt create mode 100644 app/src/main/java/com/zionhuang/music/utils/DataStore.kt delete mode 100644 app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt delete mode 100644 app/src/main/java/com/zionhuang/music/utils/Preference.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 152dab8ab..7ebbff7b4 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -79,6 +79,7 @@ dependencies { implementation(libs.activity) implementation(libs.navigation) implementation(libs.hilt.navigation) + implementation(libs.datastore) implementation(libs.compose.runtime) implementation(libs.compose.foundation) diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index 0b60a081d..2fa14c868 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -1,12 +1,11 @@ package com.zionhuang.music import android.app.Application -import android.content.SharedPreferences import android.os.Build import android.util.Log import android.widget.Toast import android.widget.Toast.LENGTH_SHORT -import androidx.core.content.edit +import androidx.datastore.preferences.core.edit import coil.ImageLoader import coil.ImageLoaderFactory import coil.disk.DiskCache @@ -14,32 +13,31 @@ import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.YouTubeLocale import com.zionhuang.kugou.KuGou import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.getEnum -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.extensions.toInetSocketAddress +import com.zionhuang.music.extensions.* +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.get import dagger.hilt.android.HiltAndroidApp import kotlinx.coroutines.DelicateCoroutinesApi import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch import java.net.Proxy import java.util.* @HiltAndroidApp -class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPreferenceChangeListener { +class App : Application(), ImageLoaderFactory { @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { super.onCreate() - INSTANCE = this val locale = Locale.getDefault() val languageTag = locale.toLanguageTag().replace("-Hant", "") // replace zh-Hant-* to zh-* YouTube.locale = YouTubeLocale( - gl = sharedPreferences.getString(CONTENT_COUNTRY, SYSTEM_DEFAULT) - .takeIf { it != SYSTEM_DEFAULT } + gl = dataStore[ContentCountryKey]?.takeIf { it != SYSTEM_DEFAULT } ?: locale.country.takeIf { it in CountryCodeToName } ?: "US", - hl = sharedPreferences.getString(CONTENT_LANGUAGE, SYSTEM_DEFAULT) - .takeIf { it != SYSTEM_DEFAULT } + hl = dataStore[ContentLanguageKey]?.takeIf { it != SYSTEM_DEFAULT } ?: locale.language.takeIf { it in LanguageCodeToName } ?: languageTag.takeIf { it in LanguageCodeToName } ?: "en" @@ -49,11 +47,11 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere } Log.d("App", "${YouTube.locale}") - if (sharedPreferences.getBoolean(PROXY_ENABLED, false)) { + if (dataStore[ProxyEnabledKey] == true) { try { YouTube.proxy = Proxy( - sharedPreferences.getEnum(PROXY_TYPE, Proxy.Type.HTTP), - sharedPreferences.getString(PROXY_URL, "")!!.toInetSocketAddress() + dataStore[ProxyTypeKey].toEnum(defaultValue = Proxy.Type.HTTP), + dataStore[ProxyUrlKey]!!.toInetSocketAddress() ) } catch (e: Exception) { Toast.makeText(this, "Failed to parse proxy url.", LENGTH_SHORT).show() @@ -62,26 +60,24 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere } GlobalScope.launch { - YouTube.visitorData = sharedPreferences.getString(VISITOR_DATA, null) - ?: YouTube.generateVisitorData().getOrNull()?.also { - sharedPreferences.edit { - putString(VISITOR_DATA, it) - } - } ?: YouTube.DEFAULT_VISITOR_DATA + dataStore.data + .map { it[VisitorDataKey] } + .distinctUntilChanged() + .collect { visitorData -> + YouTube.visitorData = visitorData ?: YouTube.generateVisitorData().getOrNull()?.also { newVisitorData -> + dataStore.edit { settings -> + settings[VisitorDataKey] = newVisitorData + } + } ?: YouTube.DEFAULT_VISITOR_DATA + } } - YouTube.cookie = sharedPreferences.getString(INNERTUBE_COOKIE, null) - sharedPreferences.registerOnSharedPreferenceChangeListener(this) - } - - override fun onSharedPreferenceChanged(sharedPreferences: SharedPreferences, key: String) { - when (key) { - VISITOR_DATA -> { - YouTube.visitorData = sharedPreferences.getString(VISITOR_DATA, null) - ?: YouTube.DEFAULT_VISITOR_DATA - } - INNERTUBE_COOKIE -> { - YouTube.cookie = sharedPreferences.getString(INNERTUBE_COOKIE, null) - } + GlobalScope.launch { + dataStore.data + .map { it[InnerTubeCookieKey] } + .distinctUntilChanged() + .collect { cookie -> + YouTube.cookie = cookie + } } } @@ -92,12 +88,8 @@ class App : Application(), ImageLoaderFactory, SharedPreferences.OnSharedPrefere .diskCache( DiskCache.Builder() .directory(cacheDir.resolve("coil")) - .maxSizeBytes(sharedPreferences.getInt(MAX_IMAGE_CACHE_SIZE, 512) * 1024 * 1024L) + .maxSizeBytes((dataStore[MaxImageCacheSizeKey] ?: 512) * 1024 * 1024L) .build() ) .build() - - companion object { - lateinit var INSTANCE: App - } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 22f66637a..7545dd6dc 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -60,7 +60,10 @@ import com.zionhuang.music.ui.screens.library.LibraryPlaylistsScreen import com.zionhuang.music.ui.screens.library.LibrarySongsScreen import com.zionhuang.music.ui.screens.settings.* import com.zionhuang.music.ui.theme.* -import com.zionhuang.music.utils.NavigationTabHelper +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 dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch @@ -101,374 +104,365 @@ class MainActivity : ComponentActivity() { WindowCompat.setDecorFitsSystemWindows(window, false) setContent { - CompositionLocalProvider( - LocalSharedPreferences provides sharedPreferences, - LocalSharedPreferencesKeyFlow provides sharedPreferences.keyFlow, - LocalDatabase provides database - ) { - val coroutineScope = rememberCoroutineScope() - val darkTheme by mutablePreferenceState(key = DARK_THEME, defaultValue = DarkMode.AUTO) - val isSystemInDarkTheme = isSystemInDarkTheme() - val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { - if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON - } - LaunchedEffect(useDarkTheme) { - setSystemBarAppearance(useDarkTheme) - } - var themeColor by rememberSaveable(stateSaver = ColorSaver) { - mutableStateOf(DefaultThemeColor) - } + val coroutineScope = rememberCoroutineScope() + val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val isSystemInDarkTheme = isSystemInDarkTheme() + val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { + if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON + } + LaunchedEffect(useDarkTheme) { + setSystemBarAppearance(useDarkTheme) + } + var themeColor by rememberSaveable(stateSaver = ColorSaver) { + mutableStateOf(DefaultThemeColor) + } - DisposableEffect(playerConnection?.binder, isSystemInDarkTheme) { - playerConnection?.onBitmapChanged = { bitmap -> - coroutineScope.launch(Dispatchers.IO) { - themeColor = bitmap?.extractThemeColor() ?: DefaultThemeColor - } - } - onDispose { - playerConnection?.onBitmapChanged = {} + DisposableEffect(playerConnection?.binder, isSystemInDarkTheme) { + playerConnection?.onBitmapChanged = { bitmap -> + coroutineScope.launch(Dispatchers.IO) { + themeColor = bitmap?.extractThemeColor() ?: DefaultThemeColor } } + onDispose { + playerConnection?.onBitmapChanged = {} + } + } - InnerTuneTheme( - darkTheme = useDarkTheme, - themeColor = themeColor + InnerTuneTheme( + darkTheme = useDarkTheme, + themeColor = themeColor + ) { + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background) ) { - BoxWithConstraints( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - ) { - val navController = rememberNavController() - val navBackStackEntry by navController.currentBackStackEntryAsState() - val route = remember(navBackStackEntry) { navBackStackEntry?.destination?.route } + val navController = rememberNavController() + val navBackStackEntry by navController.currentBackStackEntryAsState() + val route = remember(navBackStackEntry) { navBackStackEntry?.destination?.route } - val navigationItems = remember { - val enabledNavItems = NavigationTabHelper.getConfig(this@MainActivity) - listOf(Screen.Home, Screen.Songs, Screen.Artists, Screen.Albums, Screen.Playlists) - .filterIndexed { index, _ -> - enabledNavItems[index] - } - } - val defaultOpenTab = remember { - sharedPreferences.getEnum(DEFAULT_OPEN_TAB, NavigationTab.HOME) - } + val navigationItems = remember { + listOf(Screen.Home, Screen.Songs, Screen.Artists, Screen.Albums, Screen.Playlists) + } + val defaultOpenTab = remember { + dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) + } - val (isSearchExpanded, onSearchExpandedChange) = rememberSaveable { mutableStateOf(false) } - val (textFieldValue, onTextFieldValueChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } - val appBarConfig = remember(navBackStackEntry) { - when { - route == null || navigationItems.any { it.route == route } -> searchAppBarConfig() - route.startsWith("search/") -> onlineSearchResultAppBarConfig(navBackStackEntry?.arguments?.getString("query").orEmpty()) - route.startsWith("album/") -> albumAppBarConfig() - route.startsWith("artist/") -> artistAppBarConfig() - route.startsWith("playlist/") -> playlistAppBarConfig() - route.startsWith("settings") -> settingsAppBarConfig(route) - else -> defaultAppBarConfig() - } + val (isSearchExpanded, onSearchExpandedChange) = rememberSaveable { mutableStateOf(false) } + val (textFieldValue, onTextFieldValueChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } + val appBarConfig = remember(navBackStackEntry) { + when { + route == null || navigationItems.any { it.route == route } -> searchAppBarConfig() + route.startsWith("search/") -> onlineSearchResultAppBarConfig(navBackStackEntry?.arguments?.getString("query").orEmpty()) + route.startsWith("album/") -> albumAppBarConfig() + route.startsWith("artist/") -> artistAppBarConfig() + route.startsWith("playlist/") -> playlistAppBarConfig() + route.startsWith("settings") -> settingsAppBarConfig(route) + else -> defaultAppBarConfig() } - val onSearch: (String) -> Unit = remember { - { query -> - onTextFieldValueChange(TextFieldValue( - text = query, - selection = TextRange(query.length) - )) - navController.navigate("search/$query") - database.query { - insert(SearchHistory(query = query)) - } + } + val onSearch: (String) -> Unit = remember { + { query -> + onTextFieldValueChange(TextFieldValue( + text = query, + selection = TextRange(query.length) + )) + navController.navigate("search/$query") + database.query { + insert(SearchHistory(query = query)) } } + } - val shouldShowNavigationBar = remember(navBackStackEntry, isSearchExpanded) { - route == null || navigationItems.fastAny { it.route == route } && !isSearchExpanded - } - val navigationBarHeight by animateDpAsState( - targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp, - animationSpec = NavigationBarAnimationSpec - ) + val shouldShowNavigationBar = remember(navBackStackEntry, isSearchExpanded) { + route == null || navigationItems.fastAny { it.route == route } && !isSearchExpanded + } + val navigationBarHeight by animateDpAsState( + targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp, + animationSpec = NavigationBarAnimationSpec + ) - val density = LocalDensity.current - val windowsInsets = WindowInsets.systemBars - val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } - val playerBottomSheetState = rememberBottomSheetState( - dismissedBound = 0.dp, - collapsedBound = bottomInset + (if (shouldShowNavigationBar) NavigationBarHeight else 0.dp) + MiniPlayerHeight, - expandedBound = maxHeight, - ) + val playerBottomSheetState = rememberBottomSheetState( + dismissedBound = 0.dp, + collapsedBound = bottomInset + (if (shouldShowNavigationBar) NavigationBarHeight else 0.dp) + MiniPlayerHeight, + expandedBound = maxHeight, + ) - val playerAwareWindowInsets = remember(bottomInset, shouldShowNavigationBar, playerBottomSheetState.isDismissed) { - var bottom = bottomInset - if (shouldShowNavigationBar) bottom += NavigationBarHeight - if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight - windowsInsets - .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .add(WindowInsets( - top = AppBarHeight, - bottom = bottom - )) - } + val playerAwareWindowInsets = remember(bottomInset, shouldShowNavigationBar, playerBottomSheetState.isDismissed) { + var bottom = bottomInset + if (shouldShowNavigationBar) bottom += NavigationBarHeight + if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight + windowsInsets + .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) + .add(WindowInsets( + top = AppBarHeight, + bottom = bottom + )) + } - val scrollBehavior = appBarScrollBehavior( - canScroll = { - route?.startsWith("search/") == false && - (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) - } - ) + val scrollBehavior = appBarScrollBehavior( + canScroll = { + route?.startsWith("search/") == false && + (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) + } + ) - LaunchedEffect(route) { - onSearchExpandedChange(false) - if (navigationItems.any { it.route == route }) { - onTextFieldValueChange(TextFieldValue()) - } + LaunchedEffect(route) { + onSearchExpandedChange(false) + if (navigationItems.any { it.route == route }) { + onTextFieldValueChange(TextFieldValue()) + } - val heightOffset = scrollBehavior.state.heightOffset - animate( - initialValue = heightOffset, - targetValue = 0f - ) { value, _ -> - scrollBehavior.state.heightOffset = value - } + val heightOffset = scrollBehavior.state.heightOffset + animate( + initialValue = heightOffset, + targetValue = 0f + ) { value, _ -> + scrollBehavior.state.heightOffset = value } + } - LaunchedEffect(playerConnection) { - val player = playerConnection?.player ?: return@LaunchedEffect - if (player.currentMediaItem == null) { - if (!playerBottomSheetState.isDismissed) { - playerBottomSheetState.dismiss() - } - } else { - if (playerBottomSheetState.isDismissed) { - playerBottomSheetState.collapseSoft() - } + LaunchedEffect(playerConnection) { + val player = playerConnection?.player ?: return@LaunchedEffect + if (player.currentMediaItem == null) { + if (!playerBottomSheetState.isDismissed) { + playerBottomSheetState.dismiss() + } + } else { + if (playerBottomSheetState.isDismissed) { + playerBottomSheetState.collapseSoft() } } + } - val expandOnPlay by mutablePreferenceState(key = EXPAND_ON_PLAY, defaultValue = false) + val expandOnPlay by rememberPreference(ExpandOnPlayKey, defaultValue = false) - DisposableEffect(playerConnection, playerBottomSheetState) { - val player = playerConnection?.player ?: return@DisposableEffect onDispose { } - val listener = object : Player.Listener { - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null && playerBottomSheetState.isDismissed) { - if (expandOnPlay) { - playerBottomSheetState.expandSoft() - } else { - playerBottomSheetState.collapseSoft() - } + DisposableEffect(playerConnection, playerBottomSheetState) { + val player = playerConnection?.player ?: return@DisposableEffect onDispose { } + val listener = object : Player.Listener { + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason == Player.MEDIA_ITEM_TRANSITION_REASON_PLAYLIST_CHANGED && mediaItem != null && playerBottomSheetState.isDismissed) { + if (expandOnPlay) { + playerBottomSheetState.expandSoft() + } else { + playerBottomSheetState.collapseSoft() } } } - player.addListener(listener) - onDispose { - player.removeListener(listener) - } } + player.addListener(listener) + onDispose { + player.removeListener(listener) + } + } - CompositionLocalProvider( - LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background), - LocalPlayerConnection provides playerConnection, - LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, - LocalShimmerTheme provides ShimmerTheme + CompositionLocalProvider( + LocalDatabase provides database, + LocalContentColor provides contentColorFor(MaterialTheme.colorScheme.background), + LocalPlayerConnection provides playerConnection, + LocalPlayerAwareWindowInsets provides playerAwareWindowInsets, + LocalShimmerTheme provides ShimmerTheme + ) { + NavHost( + navController = navController, + startDestination = when (defaultOpenTab) { + NavigationTab.HOME -> Screen.Home + NavigationTab.SONG -> Screen.Songs + NavigationTab.ARTIST -> Screen.Artists + NavigationTab.ALBUM -> Screen.Albums + NavigationTab.PLAYLIST -> Screen.Playlists + }.route, + modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { - NavHost( - navController = navController, - startDestination = when (defaultOpenTab) { - NavigationTab.HOME -> Screen.Home - NavigationTab.SONG -> Screen.Songs - NavigationTab.ARTIST -> Screen.Artists - NavigationTab.ALBUM -> Screen.Albums - NavigationTab.PLAYLIST -> Screen.Playlists - }.route, - modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) + composable(Screen.Home.route) { + HomeScreen(navController) + } + composable(Screen.Songs.route) { + LibrarySongsScreen(navController) + } + composable(Screen.Artists.route) { + LibraryArtistsScreen(navController) + } + composable(Screen.Albums.route) { + LibraryAlbumsScreen(navController) + } + composable(Screen.Playlists.route) { + LibraryPlaylistsScreen(navController) + } + composable( + route = "album/{albumId}?playlistId={playlistId}", + arguments = listOf( + navArgument("albumId") { + type = NavType.StringType + }, + navArgument("playlistId") { + type = NavType.StringType + nullable = true + } + ) ) { - composable(Screen.Home.route) { - HomeScreen(navController) - } - composable(Screen.Songs.route) { - LibrarySongsScreen(navController) - } - composable(Screen.Artists.route) { - LibraryArtistsScreen(navController) - } - composable(Screen.Albums.route) { - LibraryAlbumsScreen(navController) - } - composable(Screen.Playlists.route) { - LibraryPlaylistsScreen(navController) - } - composable( - route = "album/{albumId}?playlistId={playlistId}", - arguments = listOf( - navArgument("albumId") { - type = NavType.StringType - }, - navArgument("playlistId") { - type = NavType.StringType - nullable = true - } - ) - ) { - AlbumScreen(navController) - } - composable( - route = "artist/{artistId}", - arguments = listOf( - navArgument("artistId") { - type = NavType.StringType - } - ) - ) { - ArtistScreen( - navController = navController, - appBarConfig = appBarConfig - ) - } - composable( - route = "artistItems/{browseId}?params={params}", - arguments = listOf( - navArgument("browseId") { - type = NavType.StringType - }, - navArgument("params") { - type = NavType.StringType - nullable = true - } - ) - ) { - ArtistItemsScreen( - navController = navController, - appBarConfig = appBarConfig - ) - } - composable( - route = "playlist/{playlistId}", - arguments = listOf( - navArgument("playlistId") { - type = NavType.StringType - } - ) - ) { backStackEntry -> - LocalPlaylistScreen() - } - composable( - route = "search/{query}", - arguments = listOf( - navArgument("query") { - type = NavType.StringType - } - ) - ) { backStackEntry -> - OnlineSearchResult( - navController = navController - ) - } + AlbumScreen(navController) + } + composable( + route = "artist/{artistId}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { + ArtistScreen( + navController = navController, + appBarConfig = appBarConfig + ) + } + composable( + route = "artistItems/{browseId}?params={params}", + arguments = listOf( + navArgument("browseId") { + type = NavType.StringType + }, + navArgument("params") { + type = NavType.StringType + nullable = true + } + ) + ) { + ArtistItemsScreen( + navController = navController, + appBarConfig = appBarConfig + ) + } + composable( + route = "playlist/{playlistId}", + arguments = listOf( + navArgument("playlistId") { + type = NavType.StringType + } + ) + ) { + LocalPlaylistScreen() + } + composable( + route = "search/{query}", + arguments = listOf( + navArgument("query") { + type = NavType.StringType + } + ) + ) { + OnlineSearchResult( + navController = navController + ) + } - composable("settings") { - SettingsScreen(navController) - } - composable("settings/appearance") { - AppearanceSettings() - } - composable("settings/content") { - ContentSettings() - } - composable("settings/player") { - PlayerSettings() - } - composable("settings/storage") { - StorageSettings() - } - composable("settings/general") { - GeneralSettings() - } - composable("settings/privacy") { - PrivacySettings() - } - composable("settings/backup_restore") { - BackupAndRestore() - } - composable("settings/about") { - AboutScreen() - } + composable("settings") { + SettingsScreen(navController) + } + composable("settings/appearance") { + AppearanceSettings() + } + composable("settings/content") { + ContentSettings() + } + composable("settings/player") { + PlayerSettings() + } + composable("settings/storage") { + StorageSettings() + } + composable("settings/general") { + GeneralSettings() + } + composable("settings/privacy") { + PrivacySettings() + } + composable("settings/backup_restore") { + BackupAndRestore() } + composable("settings/about") { + AboutScreen() + } + } - AppBar( - appBarConfig = appBarConfig, - textFieldValue = textFieldValue, - onTextFieldValueChange = onTextFieldValueChange, - isSearchExpanded = isSearchExpanded, - onSearchExpandedChange = onSearchExpandedChange, - scrollBehavior = scrollBehavior, - navController = navController, - localSearchScreen = { query, _ -> - LocalSearchScreen( - query = query, - navController = navController - ) - }, - onlineSearchScreen = { query, onDismiss -> - OnlineSearchScreen( - query = query, - onTextFieldValueChange = onTextFieldValueChange, - navController = navController, - onSearch = onSearch, - onDismiss = onDismiss - ) - }, - onSearchOnline = onSearch - ) + AppBar( + appBarConfig = appBarConfig, + textFieldValue = textFieldValue, + onTextFieldValueChange = onTextFieldValueChange, + isSearchExpanded = isSearchExpanded, + onSearchExpandedChange = onSearchExpandedChange, + scrollBehavior = scrollBehavior, + navController = navController, + localSearchScreen = { query, _ -> + LocalSearchScreen( + query = query, + navController = navController + ) + }, + onlineSearchScreen = { query, onDismiss -> + OnlineSearchScreen( + query = query, + onTextFieldValueChange = onTextFieldValueChange, + navController = navController, + onSearch = onSearch, + onDismiss = onDismiss + ) + }, + onSearchOnline = onSearch + ) - BottomSheetPlayer( - state = playerBottomSheetState, - navController = navController - ) + BottomSheetPlayer( + state = playerBottomSheetState, + navController = navController + ) - NavigationBar( - modifier = Modifier - .align(Alignment.BottomCenter) - .offset { - if (navigationBarHeight == 0.dp) { - IntOffset(x = 0, y = (bottomInset + NavigationBarHeight).roundToPx()) - } else { - val slideOffset = (bottomInset + NavigationBarHeight) * playerBottomSheetState.progress.coerceIn(0f, 1f) - val hideOffset = (bottomInset + NavigationBarHeight) * (1 - navigationBarHeight / NavigationBarHeight) - IntOffset( - x = 0, - y = (slideOffset + hideOffset).roundToPx() - ) - } + NavigationBar( + modifier = Modifier + .align(Alignment.BottomCenter) + .offset { + if (navigationBarHeight == 0.dp) { + IntOffset(x = 0, y = (bottomInset + NavigationBarHeight).roundToPx()) + } else { + val slideOffset = (bottomInset + NavigationBarHeight) * playerBottomSheetState.progress.coerceIn(0f, 1f) + val hideOffset = (bottomInset + NavigationBarHeight) * (1 - navigationBarHeight / NavigationBarHeight) + IntOffset( + x = 0, + y = (slideOffset + hideOffset).roundToPx() + ) } - ) { - navigationItems.fastForEach { screen -> - NavigationBarItem( - selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true, - icon = { - Icon( - painter = painterResource(screen.iconId), - contentDescription = null - ) - }, - label = { Text(stringResource(screen.titleId)) }, - onClick = { - navController.navigate(screen.route) { - popUpTo(navController.graph.startDestinationId) { - saveState = true - } - launchSingleTop = true - restoreState = true + } + ) { + navigationItems.fastForEach { screen -> + NavigationBarItem( + selected = navBackStackEntry?.destination?.hierarchy?.any { it.route == screen.route } == true, + icon = { + Icon( + painter = painterResource(screen.iconId), + contentDescription = null + ) + }, + label = { Text(stringResource(screen.titleId)) }, + onClick = { + navController.navigate(screen.route) { + popUpTo(navController.graph.startDestinationId) { + saveState = true } + launchSingleTop = true + restoreState = true } - ) - } + } + ) } - - BottomSheetMenu( - state = LocalMenuState.current, - modifier = Modifier.align(Alignment.BottomCenter) - ) } + + BottomSheetMenu( + state = LocalMenuState.current, + modifier = Modifier.align(Alignment.BottomCenter) + ) } } } 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 f142f39c6..6c632618a 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -1,57 +1,82 @@ package com.zionhuang.music.constants -const val DARK_THEME = "DARK_THEME" -const val DEFAULT_OPEN_TAB = "DEFAULT_OPEN_TAB" +import androidx.datastore.preferences.core.booleanPreferencesKey +import androidx.datastore.preferences.core.floatPreferencesKey +import androidx.datastore.preferences.core.intPreferencesKey +import androidx.datastore.preferences.core.stringPreferencesKey + +val DarkModeKey = stringPreferencesKey("darkMode") +val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT" -const val CONTENT_LANGUAGE = "CONTENT_LANGUAGE" -const val CONTENT_COUNTRY = "CONTENT_COUNTRY" -const val PROXY_ENABLED = "PROXY_ENABLED" -const val PROXY_URL = "PROXY_URL" -const val PROXY_TYPE = "PROXY_TYPE" +val ContentLanguageKey = stringPreferencesKey("contentLanguage") +val ContentCountryKey = stringPreferencesKey("contentCountry") +val ProxyEnabledKey = booleanPreferencesKey("proxyEnabled") +val ProxyUrlKey = stringPreferencesKey("proxyUrl") +val ProxyTypeKey = stringPreferencesKey("proxyType") + +val AudioQualityKey = stringPreferencesKey("audioQuality") +val PersistentQueueKey = booleanPreferencesKey("persistentQueue") +val SkipSilenceKey = booleanPreferencesKey("skipSilence") +val AudioNormalizationKey = booleanPreferencesKey("audioNormalization") + +val MaxImageCacheSizeKey = intPreferencesKey("maxImageCacheSize") +val MaxSongCacheSizeKey = intPreferencesKey("maxSongCacheSize") -const val AUDIO_QUALITY = "AUDIO_QUALITY" -const val PERSISTENT_QUEUE = "PERSISTENT_QUEUE" -const val SKIP_SILENCE = "SKIP_SILENCE" -const val AUDIO_NORMALIZATION = "AUDIO_NORMALIZATION" +val AutoAddToLibraryKey = booleanPreferencesKey("autoAddToLibrary") +val AutoDownloadKey = booleanPreferencesKey("autoDownload") +val ExpandOnPlayKey = booleanPreferencesKey("expandOnPlay") +val NotificationMoreActionKey = booleanPreferencesKey("notificationMoreAction") -const val MAX_IMAGE_CACHE_SIZE = "MAX_IMAGE_CACHE_SIZE" -const val MAX_SONG_CACHE_SIZE = "MAX_SONG_CACHE_SIZE" +val PauseSearchHistory = booleanPreferencesKey("pauseSearchHistory") +val EnableKugouKey = booleanPreferencesKey("enableKugou") -const val AUTO_ADD_TO_LIBRARY = "AUTO_ADD_TO_LIBRARY" -const val AUTO_DOWNLOAD = "AUTO_DOWNLOAD" -const val EXPAND_ON_PLAY = "EXPAND_ON_PLAY" -const val NOTIFICATION_MORE_ACTION = "NOTIFICATION_MORE_ACTION" +val SongSortTypeKey = stringPreferencesKey("songSortType") +val SongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val ArtistSortTypeKey = stringPreferencesKey("artistSortType") +val ArtistSortDescendingKey = booleanPreferencesKey("artistSortDescending") +val AlbumSortTypeKey = stringPreferencesKey("albumSortType") +val AlbumSortDescendingKey = booleanPreferencesKey("albumSortDescending") +val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType") +val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") -const val PAUSE_SEARCH_HISTORY = "PAUSE_SEARCH_HISTORY" -const val ENABLE_KUGOU = "ENABLE_KUGOU" +enum class SongSortType { + CREATE_DATE, NAME, ARTIST, PLAY_TIME +} + +enum class ArtistSortType { + CREATE_DATE, NAME, SONG_COUNT +} -const val SONG_SORT_TYPE = "SONG_SORT_TYPE" -const val SONG_SORT_DESCENDING = "SONG_SORT_DESC" -const val ARTIST_SORT_TYPE = "ARTIST_SORT_TYPE" -const val ARTIST_SORT_DESCENDING = "ARTIST_SORT_DESC" -const val ALBUM_SORT_TYPE = "ALBUM_SORT_TYPE" -const val ALBUM_SORT_DESCENDING = "ALBUM_SORT_DESC" -const val PLAYLIST_SORT_TYPE = "PLAYLIST_SORT_TYPE" -const val PLAYLIST_SORT_DESCENDING = "PLAYLIST_SORT_DESC" +enum class ArtistSongSortType { + CREATE_DATE, NAME, PLAY_TIME +} + +enum class AlbumSortType { + CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH +} + +enum class PlaylistSortType { + CREATE_DATE, NAME, SONG_COUNT +} -const val SHOW_LYRICS = "SHOW_LYRICS" -const val LYRICS_TEXT_POSITION = "LRC_TEXT_POS" +val ShowLyricsKey = booleanPreferencesKey("showLyrics") +val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") -const val NAV_TAB_CONFIG = "NAV_TAB_CONFIG" +val NavTabConfigKey = stringPreferencesKey("navTabConfig") -const val PLAYER_VOLUME = "PLAYER_VOLUME" +val PlayerVolumeKey = floatPreferencesKey("playerVolume") -const val SEARCH_SOURCE = "SEARCH_SOURCE" +val SearchSourceKey = stringPreferencesKey("searchSource") enum class SearchSource { LOCAL, ONLINE } -const val VISITOR_DATA = "visitor_data" -const val INNERTUBE_COOKIE = "innertube_cookie" -const val ACCOUNT_NAME = "account_name" -const val ACCOUNT_EMAIL = "account_email" +val VisitorDataKey = stringPreferencesKey("visitorData") +val InnerTubeCookieKey = stringPreferencesKey("innerTubeCookie") +val AccountNameKey = stringPreferencesKey("accountName") +val AccountEmailKey = stringPreferencesKey("accountEmail") val LanguageCodeToName = mapOf( "af" to "Afrikaans", 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 79ca2170d..b70f242ac 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -4,11 +4,11 @@ import androidx.room.* import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.innertube.pages.ArtistPage +import com.zionhuang.music.constants.* import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.reversed import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.* import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.ui.utils.resize import kotlinx.coroutines.Dispatchers 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 dc790e98c..601de06a1 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -10,7 +10,6 @@ import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId import com.zionhuang.music.extensions.toSQLiteQuery -import com.zionhuang.music.models.sortInfo.* import java.time.Instant import java.time.ZoneOffset import java.util.* diff --git a/app/src/main/java/com/zionhuang/music/extensions/AppExt.kt b/app/src/main/java/com/zionhuang/music/extensions/AppExt.kt deleted file mode 100644 index d84c0421c..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/AppExt.kt +++ /dev/null @@ -1,7 +0,0 @@ -package com.zionhuang.music.extensions - -import android.app.Application -import com.zionhuang.music.App - -fun getApplication(): Application = App.INSTANCE - diff --git a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt deleted file mode 100644 index 4ee849b6a..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/ContextExt.kt +++ /dev/null @@ -1,10 +0,0 @@ -package com.zionhuang.music.extensions - -import android.content.Context -import android.content.SharedPreferences -import com.zionhuang.music.utils.Preference - -val Context.sharedPreferences: SharedPreferences - get() = getSharedPreferences("preferences", Context.MODE_PRIVATE) - -fun Context.preference(key: String, defaultValue: T) = Preference(this, key, defaultValue) diff --git a/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt b/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt deleted file mode 100644 index 532389486..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/CursorExt.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.zionhuang.music.extensions - -import android.database.Cursor - -fun Cursor.forEach(action: Cursor.() -> Unit) = use { - if (moveToFirst()) { - do { - action(this) - } while (moveToNext()) - } -} - -inline operator fun Cursor.get(name: String): T { - val index = getColumnIndexOrThrow(name) - return when (T::class) { - Short::class -> getShort(index) as T - Int::class -> getInt(index) as T - Long::class -> getLong(index) as T - Boolean::class -> (getInt(index) == 1) as T - String::class -> getString(index) as T - Float::class -> getFloat(index) as T - Double::class -> getDouble(index) as T - ByteArray::class -> getBlob(index) as T - else -> throw IllegalStateException("Unknown class ${T::class.java.simpleName}") - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt b/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt deleted file mode 100644 index b95a24a8a..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/SharedPreferencesExt.kt +++ /dev/null @@ -1,169 +0,0 @@ -package com.zionhuang.music.extensions - -import android.content.SharedPreferences -import androidx.compose.runtime.* -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.edit -import kotlinx.coroutines.channels.awaitClose -import kotlinx.coroutines.flow.* - -inline fun > SharedPreferences.getEnum(key: String, defaultValue: E): E = getString(key, null)?.let { - try { - enumValueOf(it) - } catch (e: IllegalArgumentException) { - null - } -} ?: defaultValue - -inline fun > SharedPreferences.Editor.putEnum(key: String, value: T) = putString(key, value.name) - -@Suppress("UNCHECKED_CAST") -fun SharedPreferences.get(key: String, defaultValue: T): T = when (defaultValue::class) { - Boolean::class -> getBoolean(key, defaultValue as Boolean) - Float::class -> getFloat(key, defaultValue as Float) - Int::class -> getInt(key, defaultValue as Int) - Long::class -> getLong(key, defaultValue as Long) - String::class -> getString(key, defaultValue as String) - else -> throw IllegalArgumentException("Unexpected type: ${defaultValue::class.java.name}") -} as T - -operator fun SharedPreferences.set(key: String, value: T) { - edit { - when (value::class) { - Boolean::class -> putBoolean(key, value as Boolean) - Float::class -> putFloat(key, value as Float) - Int::class -> putInt(key, value as Int) - Long::class -> putLong(key, value as Long) - String::class -> putString(key, value as String) - } - } -} - -val SharedPreferences.keyFlow: Flow - get() = callbackFlow { - val listener = SharedPreferences.OnSharedPreferenceChangeListener { _, key: String? -> - trySend(key) - } - registerOnSharedPreferenceChangeListener(listener) - awaitClose { - unregisterOnSharedPreferenceChangeListener(listener) - } - } - -fun SharedPreferences.booleanFlow(key: String, defaultValue: Boolean) = keyFlow - .filter { it == key || it == null } - .onStart { emit("init trigger") } - .map { getBoolean(key, defaultValue) } - .conflate() - -@Composable -fun preferenceState(key: String, defaultValue: Boolean) = - LocalContext.current.sharedPreferences.booleanFlow(key, defaultValue).collectAsState( - initial = LocalContext.current.sharedPreferences.getBoolean(key, defaultValue) - ) - -inline fun > SharedPreferences.enumFlow(key: String, defaultValue: E) = keyFlow - .filter { it == key || it == null } - .onStart { emit("init trigger") } - .map { getEnum(key, defaultValue) } - .conflate() - - -val LocalSharedPreferences = staticCompositionLocalOf { error("SharedPreferences not provided") } -val LocalSharedPreferencesKeyFlow = staticCompositionLocalOf> { error("SharedPreferences key flow not provided") } - -class PerferenceMutableState( - val state: State, - val onChange: (T) -> Unit, -) : MutableState { - override var value: T = state.value - override fun component1(): T = state.value - override fun component2(): (T) -> Unit = onChange -} - -@Composable -fun mutablePreferenceState(key: String, defaultValue: Int): PerferenceMutableState { - val sharedPreferences = LocalSharedPreferences.current - val keyFlow = LocalSharedPreferencesKeyFlow.current - return PerferenceMutableState( - state = produceState(initialValue = LocalSharedPreferences.current.getInt(key, defaultValue)) { - keyFlow.filter { it == null || it == key }.collect { - value = try { - sharedPreferences.getInt(key, defaultValue) - } catch (e: Exception) { - e.printStackTrace() - defaultValue - } - } - }, - onChange = { value -> - sharedPreferences.edit { - putInt(key, value) - } - } - ) -} - -@Composable -fun mutablePreferenceState(key: String, defaultValue: Boolean): PerferenceMutableState { - val sharedPreferences = LocalSharedPreferences.current - val keyFlow = LocalSharedPreferencesKeyFlow.current - return PerferenceMutableState( - state = produceState(initialValue = LocalSharedPreferences.current.getBoolean(key, defaultValue)) { - keyFlow.filter { it == null || it == key }.collect { - value = try { - sharedPreferences.getBoolean(key, defaultValue) - } catch (e: Exception) { - e.printStackTrace() - defaultValue - } - } - }, - onChange = { value -> - sharedPreferences.edit { - putBoolean(key, value) - } - } - ) -} - -@Composable -fun mutablePreferenceState(key: String, defaultValue: String): PerferenceMutableState { - val sharedPreferences = LocalSharedPreferences.current - val keyFlow = LocalSharedPreferencesKeyFlow.current - return PerferenceMutableState( - state = produceState(initialValue = LocalSharedPreferences.current.getString(key, defaultValue)!!) { - keyFlow.filter { it == null || it == key }.collect { - value = try { - sharedPreferences.getString(key, defaultValue)!! - } catch (e: Exception) { - e.printStackTrace() - defaultValue - } - } - }, - onChange = { value -> - sharedPreferences.edit { - putString(key, value) - } - } - ) -} - -@Composable -inline fun > mutablePreferenceState(key: String, defaultValue: T): PerferenceMutableState { - val sharedPreferences = LocalSharedPreferences.current - val keyFlow = LocalSharedPreferencesKeyFlow.current - return PerferenceMutableState( - state = produceState(initialValue = LocalSharedPreferences.current.getEnum(key, defaultValue)) { - keyFlow.filter { it == null || it == key }.collect { - value = sharedPreferences.getEnum(key, defaultValue) - } - }, - onChange = { value -> - sharedPreferences.edit { - putEnum(key, value) - } - } - ) -} diff --git a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt index 2d47cd77f..5e63570f5 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/StringExt.kt @@ -4,6 +4,14 @@ import androidx.sqlite.db.SimpleSQLiteQuery import java.net.InetSocketAddress import java.net.InetSocketAddress.createUnresolved +inline fun > String?.toEnum(defaultValue: T): T = + if (this == null) defaultValue + else try { + enumValueOf(this) + } catch (e: IllegalArgumentException) { + defaultValue + } + fun String.toSQLiteQuery(): SimpleSQLiteQuery = SimpleSQLiteQuery(this) fun String.toInetSocketAddress(): InetSocketAddress { diff --git a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt index e5dab5ef7..7558df262 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt @@ -2,13 +2,14 @@ package com.zionhuang.music.lyrics import android.content.Context import com.zionhuang.kugou.KuGou -import com.zionhuang.music.constants.ENABLE_KUGOU -import com.zionhuang.music.extensions.sharedPreferences +import com.zionhuang.music.constants.EnableKugouKey +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.get object KuGouLyricsProvider : LyricsProvider { override val name = "Kugou" override fun isEnabled(context: Context): Boolean = - context.sharedPreferences.getBoolean(ENABLE_KUGOU, true) + context.dataStore[EnableKugouKey] ?: true override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = KuGou.getLyrics(title, artist, duration) @@ -16,4 +17,4 @@ object KuGouLyricsProvider : LyricsProvider { override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int, callback: (String) -> Unit) { KuGou.getAllLyrics(title, artist, duration, callback) } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt deleted file mode 100644 index 34ca7c991..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/AlbumSortInfoPreference.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.constants.ALBUM_SORT_DESCENDING -import com.zionhuang.music.constants.ALBUM_SORT_TYPE -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.Preference -import com.zionhuang.music.utils.enumPreference - -object AlbumSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type by enumPreference(context, ALBUM_SORT_TYPE, AlbumSortType.CREATE_DATE) - override var isDescending by Preference(context, ALBUM_SORT_DESCENDING, true) - override val typeFlow = context.sharedPreferences.enumFlow(ALBUM_SORT_TYPE, AlbumSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(ALBUM_SORT_DESCENDING, true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt deleted file mode 100644 index 2937c2290..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/ArtistSortInfoPreference.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.constants.ARTIST_SORT_DESCENDING -import com.zionhuang.music.constants.ARTIST_SORT_TYPE -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.Preference -import com.zionhuang.music.utils.enumPreference - -object ArtistSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type: ArtistSortType by enumPreference(context, ARTIST_SORT_TYPE, ArtistSortType.CREATE_DATE) - override var isDescending by Preference(context, ARTIST_SORT_DESCENDING, true) - override val typeFlow = context.sharedPreferences.enumFlow(ARTIST_SORT_TYPE, ArtistSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(ARTIST_SORT_DESCENDING, true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt deleted file mode 100644 index fb5f55143..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/ISortInfo.kt +++ /dev/null @@ -1,6 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -interface ISortInfo { - val type: T - val isDescending: Boolean -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt deleted file mode 100644 index 04d84fff0..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/PlaylistSortInfoPreference.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.constants.PLAYLIST_SORT_DESCENDING -import com.zionhuang.music.constants.PLAYLIST_SORT_TYPE -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.Preference -import com.zionhuang.music.utils.enumPreference - -object PlaylistSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type by enumPreference(context, PLAYLIST_SORT_TYPE, PlaylistSortType.CREATE_DATE) - override var isDescending by Preference(context, PLAYLIST_SORT_DESCENDING, true) - override val typeFlow = context.sharedPreferences.enumFlow(PLAYLIST_SORT_TYPE, PlaylistSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(PLAYLIST_SORT_DESCENDING, true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt deleted file mode 100644 index 27df9a5bf..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SongSortInfoPreference.kt +++ /dev/null @@ -1,19 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import android.content.Context -import com.zionhuang.music.constants.SONG_SORT_DESCENDING -import com.zionhuang.music.constants.SONG_SORT_TYPE -import com.zionhuang.music.extensions.booleanFlow -import com.zionhuang.music.extensions.enumFlow -import com.zionhuang.music.extensions.getApplication -import com.zionhuang.music.extensions.sharedPreferences -import com.zionhuang.music.utils.Preference -import com.zionhuang.music.utils.enumPreference - -object SongSortInfoPreference : SortInfoPreference() { - val context: Context get() = getApplication() - override var type by enumPreference(context, SONG_SORT_TYPE, SongSortType.CREATE_DATE) - override var isDescending by Preference(context, SONG_SORT_DESCENDING, true) - override val typeFlow = context.sharedPreferences.enumFlow(SONG_SORT_TYPE, SongSortType.CREATE_DATE) - override val isDescendingFlow = context.sharedPreferences.booleanFlow(SONG_SORT_DESCENDING, true) -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt deleted file mode 100644 index fc5335594..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfo.kt +++ /dev/null @@ -1,28 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -data class SortInfo( - override val type: T, - override val isDescending: Boolean, -) : ISortInfo - -interface SortType - -enum class SongSortType : SortType { - CREATE_DATE, NAME, ARTIST, PLAY_TIME -} - -enum class ArtistSortType : SortType { - CREATE_DATE, NAME, SONG_COUNT -} - -enum class ArtistSongSortType : SortType { - CREATE_DATE, NAME, PLAY_TIME -} - -enum class AlbumSortType : SortType { - CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH -} - -enum class PlaylistSortType : SortType { - CREATE_DATE, NAME, SONG_COUNT -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt b/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt deleted file mode 100644 index 1c0655271..000000000 --- a/app/src/main/java/com/zionhuang/music/models/sortInfo/SortInfoPreference.kt +++ /dev/null @@ -1,23 +0,0 @@ -package com.zionhuang.music.models.sortInfo - -import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.combine - -abstract class SortInfoPreference : ISortInfo { - abstract override var type: T - abstract override var isDescending: Boolean - protected abstract val typeFlow: Flow - protected abstract val isDescendingFlow: Flow - - fun toggleIsDescending() { - isDescending = !isDescending - } - - val currentInfo: SortInfo - get() = SortInfo(type, isDescending) - - val flow: Flow> - get() = typeFlow.combine(isDescendingFlow) { type, isDescending -> - SortInfo(type, isDescending) - } -} \ No newline at end of file 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 27f6dae68..b1739411e 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -21,11 +21,11 @@ import com.google.android.exoplayer2.ExoPlayer import com.google.android.exoplayer2.ui.PlayerNotificationManager import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.zionhuang.music.R +import com.zionhuang.music.constants.SongSortType 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.lyrics.LyricsHelper -import com.zionhuang.music.models.sortInfo.SongSortType import com.zionhuang.music.models.toMediaMetadata import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.* diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 72692dc61..cf4054507 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -16,9 +16,9 @@ import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat.* import android.util.Pair import androidx.core.app.NotificationCompat -import androidx.core.content.edit import androidx.core.content.getSystemService import androidx.core.net.toUri +import androidx.datastore.preferences.core.edit import com.google.android.exoplayer2.* import com.google.android.exoplayer2.C.WAKE_MODE_NETWORK import com.google.android.exoplayer2.PlaybackException.* @@ -67,7 +67,6 @@ import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.* import com.zionhuang.music.lyrics.LyricsHelper import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.sortInfo.SongSortType import com.zionhuang.music.playback.MusicService.Companion.ALBUM import com.zionhuang.music.playback.MusicService.Companion.ARTIST import com.zionhuang.music.playback.MusicService.Companion.PLAYLIST @@ -77,8 +76,7 @@ import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.utils.InfoCache -import com.zionhuang.music.utils.enumPreference +import com.zionhuang.music.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.* @@ -106,8 +104,8 @@ class SongPlayer( private val connectivityManager = context.getSystemService()!! val bitmapProvider = BitmapProvider(context) - private var autoAddSong by context.preference(AUTO_ADD_TO_LIBRARY, true) - private var audioQuality by enumPreference(context, AUDIO_QUALITY, AudioQuality.AUTO) + private val autoAddSong by preference(context, AutoAddToLibraryKey, true) + private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) private var currentQueue: Queue = EmptyQueue var queueTitle: String? = null @@ -121,9 +119,7 @@ class SongPlayer( } var currentSong: Song? = null - private val showLyrics = context.sharedPreferences.booleanFlow(SHOW_LYRICS, false) - - private val cacheEvictor = when (val cacheSize = context.sharedPreferences.getInt(MAX_SONG_CACHE_SIZE, 1024)) { + private val cacheEvictor = when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { -1 -> NoOpCacheEvictor() else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) } @@ -144,7 +140,7 @@ class SongPlayer( } private val normalizeFactor = MutableStateFlow(1f) - val playerVolume = MutableStateFlow(context.sharedPreferences.getFloat(PLAYER_VOLUME, 1f)) + val playerVolume = MutableStateFlow(context.dataStore[PlayerVolumeKey]?.coerceIn(0f, 1f) ?: 1f) val mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { isActive = true @@ -308,7 +304,7 @@ class SongPlayer( override fun getCustomActions(player: Player): List { val actions = mutableListOf() - if (player.currentMetadata != null && context.sharedPreferences.getBoolean(NOTIFICATION_MORE_ACTION, true)) { + if (player.currentMetadata != null && context.dataStore[NotificationMoreActionKey] != false) { actions.add(if (currentSong == null) ACTION_ADD_TO_LIBRARY else ACTION_REMOVE_FROM_LIBRARY) actions.add(if (currentSong?.song?.liked == true) ACTION_UNLIKE else ACTION_LIKE) } @@ -340,11 +336,9 @@ class SongPlayer( } } scope.launch { - combine(playerVolume, normalizeFactor) { playerVolume, normalizeFactor -> - playerVolume * normalizeFactor - }.debounce(1000).collect { - context.sharedPreferences.edit { - putFloat(PLAYER_VOLUME, it) + playerVolume.debounce(1000).collect { volume -> + context.dataStore.edit { settings -> + settings[PlayerVolumeKey] = volume } } } @@ -359,7 +353,10 @@ class SongPlayer( } } scope.launch { - combine(currentMediaMetadata.distinctUntilChangedBy { it?.id }, showLyrics) { mediaMetadata, showLyrics -> + combine( + currentMediaMetadata.distinctUntilChangedBy { it?.id }, + context.dataStore.data.map { it[ShowLyricsKey] ?: false }.distinctUntilChanged() + ) { mediaMetadata, showLyrics -> mediaMetadata to showLyrics }.collectLatest { (mediaMetadata, showLyrics) -> if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { @@ -368,12 +365,20 @@ class SongPlayer( } } scope.launch { - context.sharedPreferences.booleanFlow(SKIP_SILENCE, true).collectLatest { - player.skipSilenceEnabled = it - } + context.dataStore.data + .map { it[SkipSilenceKey] ?: true } + .distinctUntilChanged() + .collectLatest { + player.skipSilenceEnabled = it + } } scope.launch { - combine(currentFormat, context.sharedPreferences.booleanFlow(AUDIO_NORMALIZATION, true)) { format, normalizeAudio -> + combine( + currentFormat, + context.dataStore.data + .map { it[AudioNormalizationKey] ?: true } + .distinctUntilChanged() + ) { format, normalizeAudio -> format to normalizeAudio }.collectLatest { (format, normalizeAudio) -> normalizeFactor.value = if (normalizeAudio && format?.loudnessDb != null) { @@ -384,11 +389,14 @@ class SongPlayer( } } scope.launch { - context.sharedPreferences.booleanFlow(NOTIFICATION_MORE_ACTION, true).collectLatest { - playerNotificationManager.invalidate() - } + context.dataStore.data + .map { it[NotificationMoreActionKey] ?: true } + .distinctUntilChanged() + .collectLatest { + playerNotificationManager.invalidate() + } } - if (context.sharedPreferences.getBoolean(PERSISTENT_QUEUE, true)) { + if (context.dataStore[PersistentQueueKey] != false) { runCatching { context.filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> ObjectInputStream(fis).use { oos -> @@ -703,7 +711,7 @@ class SongPlayer( } fun onDestroy() { - if (context.sharedPreferences.getBoolean(PERSISTENT_QUEUE, true)) { + if (context.dataStore[PersistentQueueKey] != false) { saveQueueToDisk() } mediaSession.apply { diff --git a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt index 020c88d3e..e5b499a70 100644 --- a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt @@ -10,8 +10,8 @@ import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider import com.google.android.exoplayer2.util.FileTypes import com.zionhuang.music.R +import com.zionhuang.music.constants.SongSortType import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.models.sortInfo.SongSortType import com.zionhuang.music.utils.getSongFile import dagger.hilt.EntryPoint import dagger.hilt.InstallIn diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt index 3f399dce6..31b4d1195 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt @@ -37,12 +37,12 @@ import androidx.compose.ui.unit.dp import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.zionhuang.music.R -import com.zionhuang.music.ui.utils.canNavigateUp import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.ui.utils.canNavigateUp +import com.zionhuang.music.utils.rememberEnumPreference import kotlin.math.roundToInt -@OptIn(ExperimentalMaterial3Api::class, ExperimentalAnimationApi::class) +@OptIn(ExperimentalMaterial3Api::class) @Composable fun AppBar( appBarConfig: AppBarConfig, @@ -67,7 +67,7 @@ fun AppBar( } } - val (searchSource, onSearchSourceChange) = mutablePreferenceState(SEARCH_SOURCE, SearchSource.ONLINE) + var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) val expandTransition = updateTransition(targetState = isSearchExpanded || !appBarConfig.searchable, "searchExpanded") val searchTransitionProgress by expandTransition.animateFloat(label = "") { if (it) 1f else 0f } @@ -268,7 +268,7 @@ fun AppBar( IconButton( onClick = { - onSearchSourceChange(if (searchSource == SearchSource.ONLINE) SearchSource.LOCAL else SearchSource.ONLINE) + searchSource = if (searchSource == SearchSource.ONLINE) SearchSource.LOCAL else SearchSource.ONLINE } ) { Icon( 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 3941b7c1b..fe93162d9 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 @@ -35,10 +35,9 @@ import androidx.hilt.navigation.compose.hiltViewModel import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.constants.LYRICS_TEXT_POSITION +import com.zionhuang.music.constants.LyricsTextPositionKey import com.zionhuang.music.db.entities.LyricsEntity import com.zionhuang.music.db.entities.LyricsEntity.Companion.LYRICS_NOT_FOUND -import com.zionhuang.music.extensions.mutablePreferenceState import com.zionhuang.music.lyrics.LyricsEntry import com.zionhuang.music.lyrics.LyricsEntry.Companion.HEAD_LYRICS_ENTRY import com.zionhuang.music.lyrics.LyricsUtils.findCurrentLineIndex @@ -48,6 +47,7 @@ import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.component.shimmer.TextPlaceholder 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 kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -63,7 +63,7 @@ fun Lyrics( val menuState = LocalMenuState.current val density = LocalDensity.current - val lyricsTextPosition by mutablePreferenceState(LYRICS_TEXT_POSITION, LyricsPosition.CENTER) + val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) val lyrics = remember(lyricsEntity) { diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 2a5110268..29443f054 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -36,15 +36,15 @@ import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight -import com.zionhuang.music.constants.SHOW_LYRICS +import com.zionhuang.music.constants.ShowLyricsKey import com.zionhuang.music.extensions.metadata -import com.zionhuang.music.extensions.mutablePreferenceState import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.ui.component.* import com.zionhuang.music.utils.joinByBullet import com.zionhuang.music.utils.makeTimeString +import com.zionhuang.music.utils.rememberPreference @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @Composable @@ -71,7 +71,7 @@ fun Queue( val currentSong by playerConnection.currentSong.collectAsState(initial = null) val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) - val (showLyrics, onShowLyricsChange) = mutablePreferenceState(SHOW_LYRICS, defaultValue = false) + var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) var showDetailsDialog by rememberSaveable { mutableStateOf(false) @@ -157,7 +157,7 @@ fun Queue( contentDescription = null ) } - IconButton(onClick = { onShowLyricsChange(!showLyrics) }) { + IconButton(onClick = { showLyrics = !showLyrics }) { Icon( painter = painterResource(R.drawable.ic_lyrics), contentDescription = null, 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 4ac8aab64..f255ccd46 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 @@ -14,11 +14,11 @@ import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp import coil.compose.AsyncImage -import com.zionhuang.music.constants.SHOW_LYRICS +import com.zionhuang.music.constants.ShowLyricsKey import com.zionhuang.music.constants.ThumbnailCornerRadius -import com.zionhuang.music.extensions.preferenceState import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.ui.component.Lyrics +import com.zionhuang.music.utils.rememberPreference @Composable fun Thumbnail( @@ -27,7 +27,7 @@ fun Thumbnail( modifier: Modifier = Modifier, ) { mediaMetadata ?: return - val showLyrics by preferenceState(SHOW_LYRICS, false) + val showLyrics by rememberPreference(ShowLyricsKey, false) val currentView = LocalView.current 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 fdf340d3f..f9a424ceb 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 @@ -25,10 +25,10 @@ import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.mutablePreferenceState -import com.zionhuang.music.models.sortInfo.AlbumSortType import com.zionhuang.music.ui.component.AlbumListItem import com.zionhuang.music.ui.component.ResizableIconButton +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryAlbumsViewModel @OptIn(ExperimentalFoundationApi::class) @@ -82,9 +82,9 @@ fun LibraryAlbumsScreen( fun AlbumHeader( itemCount: Int, ) { - val (sortType, onSortTypeChange) = mutablePreferenceState(ALBUM_SORT_TYPE, AlbumSortType.CREATE_DATE) - val (sortDescending, onSortDescendingChange) = mutablePreferenceState(ALBUM_SORT_DESCENDING, true) - val (menuExpanded, onMenuExpandedChange) = remember { mutableStateOf(false) } + var sortType by rememberEnumPreference(AlbumSortTypeKey, AlbumSortType.CREATE_DATE) + var sortDescending by rememberPreference(AlbumSortDescendingKey, true) + var menuExpanded by remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, @@ -107,14 +107,14 @@ fun AlbumHeader( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false) ) { - onMenuExpandedChange(!menuExpanded) + menuExpanded = !menuExpanded } .padding(horizontal = 4.dp, vertical = 8.dp) ) DropdownMenu( expanded = menuExpanded, - onDismissRequest = { onMenuExpandedChange(false) }, + onDismissRequest = { menuExpanded = false }, modifier = Modifier.widthIn(min = 172.dp) ) { listOf( @@ -140,8 +140,8 @@ fun AlbumHeader( ) }, onClick = { - onSortTypeChange(type) - onMenuExpandedChange(false) + sortType = type + menuExpanded = false } ) } @@ -153,7 +153,7 @@ fun AlbumHeader( modifier = Modifier .size(32.dp) .padding(8.dp), - onClick = { onSortDescendingChange(!sortDescending) } + onClick = { sortDescending = !sortDescending } ) Spacer(Modifier.weight(1f)) 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 d0cbb8e3c..daef38e80 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 @@ -23,10 +23,10 @@ import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.mutablePreferenceState -import com.zionhuang.music.models.sortInfo.ArtistSortType import com.zionhuang.music.ui.component.ArtistListItem import com.zionhuang.music.ui.component.ResizableIconButton +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryArtistsViewModel @OptIn(ExperimentalFoundationApi::class) @@ -74,8 +74,8 @@ fun LibraryArtistsScreen( fun ArtistHeader( itemCount: Int, ) { - val (sortType, onSortTypeChange) = mutablePreferenceState(ARTIST_SORT_TYPE, ArtistSortType.CREATE_DATE) - val (sortDescending, onSortDescendingChange) = mutablePreferenceState(ARTIST_SORT_DESCENDING, true) + var sortType by rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) + var sortDescending by rememberPreference(ArtistSortDescendingKey, true) val (menuExpanded, onMenuExpandedChange) = remember { mutableStateOf(false) } Row( @@ -126,7 +126,7 @@ fun ArtistHeader( ) }, onClick = { - onSortTypeChange(type) + sortType = type onMenuExpandedChange(false) } ) @@ -139,7 +139,7 @@ fun ArtistHeader( modifier = Modifier .size(32.dp) .padding(8.dp), - onClick = { onSortDescendingChange(!sortDescending) } + onClick = { sortDescending = !sortDescending } ) Spacer(Modifier.weight(1f)) 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 7b15dccaa..03869bc0a 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 @@ -28,12 +28,12 @@ import com.zionhuang.music.constants.* 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.extensions.mutablePreferenceState -import com.zionhuang.music.models.sortInfo.PlaylistSortType import com.zionhuang.music.ui.component.ListItem import com.zionhuang.music.ui.component.PlaylistListItem import com.zionhuang.music.ui.component.ResizableIconButton import com.zionhuang.music.ui.component.TextFieldDialog +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryPlaylistsViewModel @OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @@ -163,9 +163,9 @@ fun LibraryPlaylistsScreen( fun PlaylistHeader( itemCount: Int, ) { - val (sortType, onSortTypeChange) = mutablePreferenceState(PLAYLIST_SORT_TYPE, PlaylistSortType.CREATE_DATE) - val (sortDescending, onSortDescendingChange) = mutablePreferenceState(PLAYLIST_SORT_DESCENDING, true) - val (menuExpanded, onMenuExpandedChange) = remember { mutableStateOf(false) } + var sortType by rememberEnumPreference(PlaylistSortTypeKey, PlaylistSortType.CREATE_DATE) + var sortDescending by rememberPreference(PlaylistSortDescendingKey, true) + var menuExpanded by remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, @@ -185,14 +185,14 @@ fun PlaylistHeader( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false) ) { - onMenuExpandedChange(!menuExpanded) + menuExpanded = !menuExpanded } .padding(horizontal = 4.dp, vertical = 8.dp) ) DropdownMenu( expanded = menuExpanded, - onDismissRequest = { onMenuExpandedChange(false) }, + onDismissRequest = { menuExpanded = false }, modifier = Modifier.widthIn(min = 172.dp) ) { listOf( @@ -215,8 +215,8 @@ fun PlaylistHeader( ) }, onClick = { - onSortTypeChange(type) - onMenuExpandedChange(false) + sortType = type + menuExpanded = false } ) } @@ -228,7 +228,7 @@ fun PlaylistHeader( modifier = Modifier .size(32.dp) .padding(8.dp), - onClick = { onSortDescendingChange(!sortDescending) } + onClick = { sortDescending = !sortDescending } ) Spacer(Modifier.weight(1f)) 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 f89ea934e..ab40b1125 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 @@ -26,12 +26,12 @@ import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.mutablePreferenceState import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.models.sortInfo.SongSortType import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibrarySongsViewModel @OptIn(ExperimentalFoundationApi::class) @@ -137,9 +137,9 @@ fun LibrarySongsScreen( fun SongHeader( itemCount: Int, ) { - val (sortType, onSortTypeChange) = mutablePreferenceState(SONG_SORT_TYPE, SongSortType.CREATE_DATE) - val (sortDescending, onSortDescendingChange) = mutablePreferenceState(SONG_SORT_DESCENDING, true) - val (menuExpanded, onMenuExpandedChange) = remember { mutableStateOf(false) } + var sortType by rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) + var sortDescending by rememberPreference(SongSortDescendingKey, true) + var menuExpanded by remember { mutableStateOf(false) } Row( verticalAlignment = Alignment.CenterVertically, @@ -160,14 +160,14 @@ fun SongHeader( interactionSource = remember { MutableInteractionSource() }, indication = rememberRipple(bounded = false) ) { - onMenuExpandedChange(!menuExpanded) + menuExpanded = !menuExpanded } .padding(horizontal = 4.dp, vertical = 8.dp) ) DropdownMenu( expanded = menuExpanded, - onDismissRequest = { onMenuExpandedChange(false) }, + onDismissRequest = { menuExpanded = false }, modifier = Modifier.widthIn(min = 172.dp) ) { listOf( @@ -191,8 +191,8 @@ fun SongHeader( ) }, onClick = { - onSortTypeChange(type) - onMenuExpandedChange(false) + sortType = type + menuExpanded = false } ) } @@ -204,7 +204,7 @@ fun SongHeader( modifier = Modifier .size(32.dp) .padding(8.dp), - onClick = { onSortDescendingChange(!sortDescending) } + onClick = { sortDescending = !sortDescending } ) Spacer(Modifier.weight(1f)) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt index 1781e8a2a..b2650396c 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -10,17 +10,17 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.constants.DARK_THEME -import com.zionhuang.music.constants.DEFAULT_OPEN_TAB -import com.zionhuang.music.constants.LYRICS_TEXT_POSITION -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.constants.DarkModeKey +import com.zionhuang.music.constants.DefaultOpenTabKey +import com.zionhuang.music.constants.LyricsTextPositionKey import com.zionhuang.music.ui.component.EnumListPreference +import com.zionhuang.music.utils.rememberEnumPreference @Composable fun AppearanceSettings() { - val (darkMode, onDarkModeChange) = mutablePreferenceState(key = DARK_THEME, defaultValue = DarkMode.AUTO) - val (defaultOpenTab, onDefaultOpenTabChange) = mutablePreferenceState(key = DEFAULT_OPEN_TAB, defaultValue = NavigationTab.HOME) - val (lyricsPosition, onLyricsPositionChange) = mutablePreferenceState(key = LYRICS_TEXT_POSITION, defaultValue = LyricsPosition.CENTER) + val (darkMode, onDarkModeChange) = rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference(DefaultOpenTabKey, defaultValue = NavigationTab.HOME) + val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) Column( Modifier diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt index 13bdca9f9..e20fe649f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt @@ -10,21 +10,22 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R +import com.zionhuang.music.constants.* import com.zionhuang.music.ui.component.EditTextPreference import com.zionhuang.music.ui.component.ListPreference import com.zionhuang.music.ui.component.PreferenceGroupTitle import com.zionhuang.music.ui.component.SwitchPreference -import com.zionhuang.music.constants.* -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference import java.net.Proxy @Composable fun ContentSettings() { - val (contentLanguage, onContentLanguageChange) = mutablePreferenceState(key = CONTENT_LANGUAGE, defaultValue = "system") - val (contentCountry, onContentCountryChange) = mutablePreferenceState(key = CONTENT_COUNTRY, defaultValue = "system") - val (proxyEnabled, onProxyEnabledChange) = mutablePreferenceState(key = PROXY_ENABLED, defaultValue = false) - val (proxyType, onProxyTypeChange) = mutablePreferenceState(key = PROXY_TYPE, defaultValue = Proxy.Type.HTTP) - val (proxyUrl, onProxyUrlChange) = mutablePreferenceState(key = PROXY_URL, defaultValue = "host:port") + val (contentLanguage, onContentLanguageChange) = rememberPreference(key = ContentLanguageKey, defaultValue = "system") + val (contentCountry, onContentCountryChange) = rememberPreference(key = ContentCountryKey, defaultValue = "system") + val (proxyEnabled, onProxyEnabledChange) = rememberPreference(key = ProxyEnabledKey, defaultValue = false) + val (proxyType, onProxyTypeChange) = rememberEnumPreference(key = ProxyTypeKey, defaultValue = Proxy.Type.HTTP) + val (proxyUrl, onProxyUrlChange) = rememberPreference(key = ProxyUrlKey, defaultValue = "host:port") Column( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt index ea95329c0..cecc145e9 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt @@ -10,19 +10,19 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.constants.AUTO_ADD_TO_LIBRARY -import com.zionhuang.music.constants.AUTO_DOWNLOAD -import com.zionhuang.music.constants.EXPAND_ON_PLAY -import com.zionhuang.music.constants.NOTIFICATION_MORE_ACTION -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.constants.AutoAddToLibraryKey +import com.zionhuang.music.constants.AutoDownloadKey +import com.zionhuang.music.constants.ExpandOnPlayKey +import com.zionhuang.music.constants.NotificationMoreActionKey import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberPreference @Composable fun GeneralSettings() { - val (autoAddToLibrary, onAutoAddToLibraryChange) = mutablePreferenceState(key = AUTO_ADD_TO_LIBRARY, defaultValue = true) - val (autoDownload, onAutoDownloadChange) = mutablePreferenceState(key = AUTO_DOWNLOAD, defaultValue = false) - val (expandOnPlay, onExpandOnPlayChange) = mutablePreferenceState(key = EXPAND_ON_PLAY, defaultValue = false) - val (notificationMoreAction, onNotificationMoreActionChange) = mutablePreferenceState(key = NOTIFICATION_MORE_ACTION, defaultValue = true) + val (autoAddToLibrary, onAutoAddToLibraryChange) = rememberPreference(key = AutoAddToLibraryKey, defaultValue = true) + val (autoDownload, onAutoDownloadChange) = rememberPreference(key = AutoDownloadKey, defaultValue = false) + val (expandOnPlay, onExpandOnPlayChange) = rememberPreference(key = ExpandOnPlayKey, defaultValue = false) + val (notificationMoreAction, onNotificationMoreActionChange) = rememberPreference(key = NotificationMoreActionKey, defaultValue = true) Column( Modifier diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt index e0fd2d44a..e0d7555c7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt @@ -10,20 +10,21 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.constants.AUDIO_NORMALIZATION -import com.zionhuang.music.constants.AUDIO_QUALITY -import com.zionhuang.music.constants.PERSISTENT_QUEUE -import com.zionhuang.music.constants.SKIP_SILENCE -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.constants.AudioNormalizationKey +import com.zionhuang.music.constants.AudioQualityKey +import com.zionhuang.music.constants.PersistentQueueKey +import com.zionhuang.music.constants.SkipSilenceKey import com.zionhuang.music.ui.component.EnumListPreference import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference @Composable fun PlayerSettings() { - val (audioQuality, onAudioQualityChange) = mutablePreferenceState(key = AUDIO_QUALITY, defaultValue = AudioQuality.AUTO) - val (persistentQueue, onPersistentQueueChange) = mutablePreferenceState(key = PERSISTENT_QUEUE, defaultValue = true) - val (skipSilence, onSkipSilenceChange) = mutablePreferenceState(key = SKIP_SILENCE, defaultValue = true) - val (audioNormalization, onAudioNormalizationChange) = mutablePreferenceState(key = AUDIO_NORMALIZATION, defaultValue = true) + val (audioQuality, onAudioQualityChange) = rememberEnumPreference(key = AudioQualityKey, defaultValue = AudioQuality.AUTO) + val (persistentQueue, onPersistentQueueChange) = rememberPreference(key = PersistentQueueKey, defaultValue = true) + val (skipSilence, onSkipSilenceChange) = rememberPreference(key = SkipSilenceKey, defaultValue = true) + val (audioNormalization, onAudioNormalizationChange) = rememberPreference(key = AudioNormalizationKey, defaultValue = true) Column( Modifier diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index 8f5e8edb7..70e55c014 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -15,18 +15,18 @@ import androidx.compose.ui.unit.dp import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.constants.ENABLE_KUGOU -import com.zionhuang.music.constants.PAUSE_SEARCH_HISTORY -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.constants.EnableKugouKey +import com.zionhuang.music.constants.PauseSearchHistory import com.zionhuang.music.ui.component.DefaultDialog import com.zionhuang.music.ui.component.PreferenceEntry import com.zionhuang.music.ui.component.SwitchPreference +import com.zionhuang.music.utils.rememberPreference @Composable fun PrivacySettings() { val database = LocalDatabase.current - val (pauseSearchHistory, onPauseSearchHistoryChange) = mutablePreferenceState(key = PAUSE_SEARCH_HISTORY, defaultValue = false) - val (enableKugou, onEnableKugouChange) = mutablePreferenceState(key = ENABLE_KUGOU, defaultValue = true) + val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistory, defaultValue = false) + val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) var showClearHistoryDialog by remember { mutableStateOf(false) 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 6848b3c9f..f912c198a 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 @@ -19,13 +19,13 @@ import coil.imageLoader import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.constants.MAX_IMAGE_CACHE_SIZE -import com.zionhuang.music.constants.MAX_SONG_CACHE_SIZE -import com.zionhuang.music.extensions.mutablePreferenceState +import com.zionhuang.music.constants.MaxImageCacheSizeKey +import com.zionhuang.music.constants.MaxSongCacheSizeKey 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.rememberPreference import kotlinx.coroutines.delay import kotlinx.coroutines.isActive @@ -56,8 +56,8 @@ fun StorageSettings() { } } - val (maxImageCacheSize, onMaxImageCacheSizeChange) = mutablePreferenceState(key = MAX_IMAGE_CACHE_SIZE, defaultValue = 512) - val (maxSongCacheSize, onMaxSongCacheSizeChange) = mutablePreferenceState(key = MAX_SONG_CACHE_SIZE, defaultValue = 1024) + val (maxImageCacheSize, onMaxImageCacheSizeChange) = rememberPreference(key = MaxImageCacheSizeKey, defaultValue = 512) + val (maxSongCacheSize, onMaxSongCacheSizeChange) = rememberPreference(key = MaxSongCacheSizeKey, defaultValue = 1024) Column( Modifier diff --git a/app/src/main/java/com/zionhuang/music/utils/DataStore.kt b/app/src/main/java/com/zionhuang/music/utils/DataStore.kt new file mode 100644 index 000000000..0089cc734 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/DataStore.kt @@ -0,0 +1,101 @@ +package com.zionhuang.music.utils + +import android.content.Context +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.datastore.core.DataStore +import androidx.datastore.preferences.core.Preferences +import androidx.datastore.preferences.core.edit +import androidx.datastore.preferences.preferencesDataStore +import com.zionhuang.music.extensions.toEnum +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.properties.ReadOnlyProperty + +val Context.dataStore: DataStore by preferencesDataStore(name = "settings") + +operator fun DataStore.get(key: Preferences.Key): T? = + runBlocking(Dispatchers.IO) { + data.first()[key] + } + +fun preference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key] ?: defaultValue } + +inline fun > enumPreference( + context: Context, + key: Preferences.Key, + defaultValue: T, +) = ReadOnlyProperty { _, _ -> context.dataStore[key].toEnum(defaultValue) } + +@Composable +fun rememberPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val state = remember { + context.dataStore.data + .map { it[key] ?: defaultValue } + .distinctUntilChanged() + }.collectAsState(context.dataStore[key] ?: defaultValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value + } + } + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} + +@Composable +inline fun > rememberEnumPreference( + key: Preferences.Key, + defaultValue: T, +): MutableState { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val initialValue = context.dataStore[key].toEnum(defaultValue = defaultValue) + val state = remember { + context.dataStore.data + .map { it[key].toEnum(defaultValue = defaultValue) } + .distinctUntilChanged() + }.collectAsState(initialValue) + + return remember { + object : MutableState { + override var value: T + get() = state.value + set(value) { + coroutineScope.launch { + context.dataStore.edit { + it[key] = value.name + } + } + } + + override fun component1() = value + override fun component2(): (T) -> Unit = { value = it } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt b/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt index 05c4b38f2..35cbab6e4 100644 --- a/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt +++ b/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt @@ -83,4 +83,4 @@ object InfoCache { private val expireTimestamp: Long = System.currentTimeMillis() + timeoutMillis val isExpired: Boolean get() = System.currentTimeMillis() > expireTimestamp } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt b/app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt deleted file mode 100644 index 29ff0d5aa..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/NavigationTabHelper.kt +++ /dev/null @@ -1,21 +0,0 @@ -package com.zionhuang.music.utils - -import android.content.Context -import androidx.core.content.edit -import com.zionhuang.music.constants.NAV_TAB_CONFIG -import com.zionhuang.music.extensions.sharedPreferences - -object NavigationTabHelper { - fun getConfig(context: Context): BooleanArray = try { - context.sharedPreferences.getString(NAV_TAB_CONFIG, null)!! - .split(",") - .map { it == "true" } - .toBooleanArray() - } catch (e: Exception) { - BooleanArray(5) { true } - } - - fun setConfig(context: Context, enabledItems: BooleanArray) = context.sharedPreferences.edit { - putString(NAV_TAB_CONFIG, enabledItems.joinToString(",")) - } -} diff --git a/app/src/main/java/com/zionhuang/music/utils/Preference.kt b/app/src/main/java/com/zionhuang/music/utils/Preference.kt deleted file mode 100644 index f386deba2..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/Preference.kt +++ /dev/null @@ -1,33 +0,0 @@ -@file:Suppress("UNCHECKED_CAST") - -package com.zionhuang.music.utils - -import android.content.Context -import android.content.SharedPreferences -import androidx.core.content.edit -import com.zionhuang.music.extensions.* -import kotlin.properties.ReadWriteProperty -import kotlin.reflect.KProperty - -open class Preference( - context: Context, - private val key: String, - private val defaultValue: T, -) : ReadWriteProperty { - protected var sharedPreferences: SharedPreferences = context.sharedPreferences - - protected open fun getPreferenceValue(): T = sharedPreferences.get(key, defaultValue) - protected open fun setPreferenceValue(value: T) { - sharedPreferences[key] = value - } - - override operator fun getValue(thisRef: Any?, property: KProperty<*>): T = getPreferenceValue() - override operator fun setValue(thisRef: Any?, property: KProperty<*>, value: T) = setPreferenceValue(value) -} - -inline fun > enumPreference(context: Context, key: String, defaultValue: E): Preference = object : Preference(context, key, defaultValue) { - override fun getPreferenceValue(): E = sharedPreferences.getEnum(key, defaultValue) - override fun setPreferenceValue(value: E) { - sharedPreferences.edit { putEnum(key, value) } - } -} \ No newline at end of file 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 6627348bd..96e073a43 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -2,19 +2,18 @@ package com.zionhuang.music.viewmodels +import android.content.Context import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube +import com.zionhuang.music.constants.* import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.models.sortInfo.AlbumSortInfoPreference -import com.zionhuang.music.models.sortInfo.ArtistSortInfoPreference -import com.zionhuang.music.models.sortInfo.PlaylistSortInfoPreference -import com.zionhuang.music.models.sortInfo.SongSortInfoPreference +import com.zionhuang.music.extensions.toEnum +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.flatMapLatest -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime @@ -22,20 +21,34 @@ import javax.inject.Inject @HiltViewModel class LibrarySongsViewModel @Inject constructor( + @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { - val allSongs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - database.songs(sortInfo.type, sortInfo.isDescending) - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val allSongs = context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.songs(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @HiltViewModel class LibraryArtistsViewModel @Inject constructor( + @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { - val allArtists = ArtistSortInfoPreference.flow.flatMapLatest { sortInfo -> - database.artists(sortInfo.type, sortInfo.isDescending) - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val allArtists = context.dataStore.data + .map { + it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE) to (it[ArtistSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.artists(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { viewModelScope.launch { @@ -59,15 +72,23 @@ class LibraryArtistsViewModel @Inject constructor( @HiltViewModel class LibraryAlbumsViewModel @Inject constructor( + @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { - val allAlbums = AlbumSortInfoPreference.flow.flatMapLatest { sortInfo -> - database.albums(sortInfo.type, sortInfo.isDescending) - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val allAlbums = context.dataStore.data + .map { + it[AlbumSortTypeKey].toEnum(AlbumSortType.CREATE_DATE) to (it[AlbumSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.albums(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @HiltViewModel class LibraryPlaylistsViewModel @Inject constructor( + @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { val likedSongCount = database.likedSongsCount() @@ -76,25 +97,45 @@ class LibraryPlaylistsViewModel @Inject constructor( val downloadedSongCount = database.downloadedSongsCount() .stateIn(viewModelScope, SharingStarted.Lazily, 0) - val allPlaylists = PlaylistSortInfoPreference.flow.flatMapLatest { sortInfo -> - database.playlists(sortInfo.type, sortInfo.isDescending) - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val allPlaylists = context.dataStore.data + .map { + it[PlaylistSortTypeKey].toEnum(PlaylistSortType.CREATE_DATE) to (it[PlaylistSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.playlists(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @HiltViewModel class LikedSongsViewModel @Inject constructor( + @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { - val songs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - database.likedSongs(sortInfo.type, sortInfo.isDescending) - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val songs = context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.likedSongs(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } @HiltViewModel class DownloadedSongsViewModel @Inject constructor( + @ApplicationContext context: Context, database: MusicDatabase, ) : ViewModel() { - val songs = SongSortInfoPreference.flow.flatMapLatest { sortInfo -> - database.downloadedSongs(sortInfo.type, sortInfo.isDescending) - }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val songs = context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.downloadedSongs(sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } diff --git a/settings.gradle.kts b/settings.gradle.kts index a171c22b9..93addff77 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -17,6 +17,7 @@ dependencyResolutionManagement { library("activity", "androidx.activity", "activity-compose").version("1.5.1") library("navigation", "androidx.navigation", "navigation-compose").version("2.5.3") library("hilt-navigation", "androidx.hilt", "hilt-navigation-compose").version("1.0.0") + library("datastore", "androidx.datastore", "datastore-preferences").version("1.0.0") version("compose-compiler", "1.3.2") version("compose", "1.3.0") From 757e2a2eaa3d72b90bb8b63a02012460e0707c44 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 20 Jan 2023 14:29:34 +0800 Subject: [PATCH 115/323] Remove InfoCache --- .../zionhuang/music/playback/SongPlayer.kt | 15 +--- .../com/zionhuang/music/utils/InfoCache.kt | 86 ------------------- 2 files changed, 4 insertions(+), 97 deletions(-) delete mode 100644 app/src/main/java/com/zionhuang/music/utils/InfoCache.kt diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index cf4054507..989cb56ae 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -14,7 +14,6 @@ import android.os.ResultReceiver import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat.* -import android.util.Pair import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.core.net.toUri @@ -76,7 +75,10 @@ import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.utils.* +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.enumPreference +import com.zionhuang.music.utils.get +import com.zionhuang.music.utils.preference import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.* @@ -248,10 +250,6 @@ class SongPlayer( } ) setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata!!.toMediaDescription(context) } - setErrorMessageProvider { e -> // e is ExoPlaybackException - val cause = e.cause?.cause as? PlaybackException // what we throw from resolving data source - Pair(cause?.errorCode ?: e.errorCode, cause?.message ?: e.message) - } setQueueEditor(object : MediaSessionConnector.QueueEditor { override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = false override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat) = throw UnsupportedOperationException() @@ -430,10 +428,6 @@ class SongPlayer( return@runBlocking dataSpec } - InfoCache.getInfo(mediaId)?.let { url -> - return@runBlocking dataSpec.withUri(url.toUri()) - } - // Check whether format exists so that users from older version can view format details // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently val playedFormat = database.format(mediaId).firstOrNull() @@ -486,7 +480,6 @@ class SongPlayer( loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb )) } - InfoCache.putInfo(mediaId, format.url, playerResponse.streamingData!!.expiresInSeconds * 1000L) dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } }) diff --git a/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt b/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt deleted file mode 100644 index 35cbab6e4..000000000 --- a/app/src/main/java/com/zionhuang/music/utils/InfoCache.kt +++ /dev/null @@ -1,86 +0,0 @@ -package com.zionhuang.music.utils - -import androidx.collection.LruCache -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.withContext -import java.util.concurrent.TimeUnit.HOURS -import java.util.concurrent.TimeUnit.MILLISECONDS - -object InfoCache { - private const val MAX_ITEMS_ON_CACHE = 60 - - /** - * Trim the cache to this size. - */ - private const val TRIM_CACHE_TO = 30 - - private val LRU_CACHE = LruCache(MAX_ITEMS_ON_CACHE) - - private fun keyOf(url: String): String = url - - private fun removeStaleCache() { - LRU_CACHE.snapshot().forEach { (key, data) -> - if (data != null && data.isExpired) { - LRU_CACHE.remove(key) - } - } - } - - @Suppress("UNCHECKED_CAST") - fun getInfo(key: String): T? { - val data = LRU_CACHE[key] ?: return null - if (data.isExpired) { - LRU_CACHE.remove(key) - return null - } - return data.info as? T - } - - private fun getFromKey(id: String): Any? = synchronized(LRU_CACHE) { - getInfo(keyOf(id)) - } - - fun putInfo(id: String, info: Any, expirationMillis: Long = MILLISECONDS.convert(1, HOURS)) { - synchronized(LRU_CACHE) { - val data = CacheData(info, expirationMillis) - LRU_CACHE.put(keyOf(id), data) - } - } - - fun removeInfo(id: String) { - synchronized(LRU_CACHE) { LRU_CACHE.remove(keyOf(id)) } - } - - fun clearCache() = synchronized(LRU_CACHE) { - LRU_CACHE.evictAll() - } - - fun trimCache() = synchronized(LRU_CACHE) { - removeStaleCache() - LRU_CACHE.trimToSize(TRIM_CACHE_TO) - } - - val size: Int - get() = synchronized(LRU_CACHE) { - LRU_CACHE.size() - } - - suspend fun checkCache(id: String, forceReload: Boolean = false, loadFromNetwork: suspend () -> T): T = - if (!forceReload) { - loadFromCache(id) - } else { - null - } ?: withContext(IO) { - loadFromNetwork().also { - putInfo(id, it) - } - } - - @Suppress("UNCHECKED_CAST") - private fun loadFromCache(id: String): T? = getFromKey(id) as? T - - private class CacheData(val info: Any, timeoutMillis: Long) { - private val expireTimestamp: Long = System.currentTimeMillis() + timeoutMillis - val isExpired: Boolean get() = System.currentTimeMillis() > expireTimestamp - } -} From c406f28510bcc47e4f44ef0bf8f6bc36acda6151 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 20 Jan 2023 14:32:40 +0800 Subject: [PATCH 116/323] Fix lyrics not loaded --- .../main/java/com/zionhuang/music/lyrics/LyricsHelper.kt | 2 +- .../main/java/com/zionhuang/music/playback/SongPlayer.kt | 9 ++++++++- .../zionhuang/music/viewmodels/LyricsMenuViewModel.kt | 2 +- 3 files changed, 10 insertions(+), 3 deletions(-) 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 1a981a51e..86b7f0efb 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt @@ -13,7 +13,7 @@ class LyricsHelper @Inject constructor( private val lyricsProviders = listOf(KuGouLyricsProvider, YouTubeLyricsProvider) private val cache = LruCache>(MAX_CACHE_SIZE) - suspend fun loadLyrics(mediaMetadata: MediaMetadata): String { + suspend fun getLyrics(mediaMetadata: MediaMetadata): String { val cached = cache.get(mediaMetadata.id)?.firstOrNull() if (cached != null) { return cached.lyrics diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 989cb56ae..599a1ca9e 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -58,6 +58,7 @@ import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.FormatEntity +import com.zionhuang.music.db.entities.LyricsEntity 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.Song @@ -358,7 +359,13 @@ class SongPlayer( mediaMetadata to showLyrics }.collectLatest { (mediaMetadata, showLyrics) -> if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { - lyricsHelper.loadLyrics(mediaMetadata) + val lyrics = lyricsHelper.getLyrics(mediaMetadata) + database.query { + upsert(LyricsEntity( + id = mediaMetadata.id, + lyrics = lyrics + )) + } } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt index 338afdac2..33cd7c286 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt @@ -45,7 +45,7 @@ class LyricsMenuViewModel @Inject constructor( fun refetchLyrics(mediaMetadata: MediaMetadata) { viewModelScope.launch { - val lyrics = lyricsHelper.loadLyrics(mediaMetadata) + val lyrics = lyricsHelper.getLyrics(mediaMetadata) database.query { upsert(LyricsEntity(mediaMetadata.id, lyrics)) } From 17da711babcfda8e1225a51f590011fd3975bff7 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 20 Jan 2023 14:33:29 +0800 Subject: [PATCH 117/323] Fix playing indicator icon color --- .../java/com/zionhuang/music/ui/component/PlayingIndicator.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt b/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt index 642ce2f5b..1874f9a04 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/PlayingIndicator.kt @@ -99,7 +99,7 @@ fun PlayingIndicatorBox( Icon( painter = painterResource(R.drawable.ic_play), contentDescription = null, - tint = Color.White + tint = color ) } } From aed63c51fe9bbedd02bfd2ba4cc587a7ae48799e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 20 Jan 2023 15:58:05 +0800 Subject: [PATCH 118/323] Add toggle library button in album screen --- .../com/zionhuang/music/db/DatabaseDao.kt | 17 +- .../zionhuang/music/ui/component/AppBar.kt | 4 +- .../zionhuang/music/ui/menu/YouTubeMenu.kt | 5 +- .../zionhuang/music/ui/screens/AlbumScreen.kt | 314 ++++++++++++------ .../music/viewmodels/AlbumViewModel.kt | 7 +- 5 files changed, 232 insertions(+), 115 deletions(-) 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 b70f242ac..e5aba5d80 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -11,11 +11,8 @@ import com.zionhuang.music.extensions.reversed import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.ui.utils.resize -import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow -import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.map -import kotlinx.coroutines.runBlocking import java.time.LocalDateTime @Dao @@ -175,7 +172,7 @@ interface DatabaseDao { @Transaction @Query("SELECT * FROM album WHERE id = :id") - fun album(id: String): Album? + fun album(id: String): Flow @Transaction @Query("SELECT * FROM album WHERE id = :albumId") @@ -418,13 +415,9 @@ interface DatabaseDao { } @Transaction - fun delete(album: Album) { - runBlocking(Dispatchers.IO) { - albumSongs(album.id).first() - }.map { - it.copy(album = null) - }.forEach(::delete) - delete(album.album) - album.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) + fun delete(albumWithSongs: AlbumWithSongs) { + albumWithSongs.songs.map { it.copy(album = null) }.forEach(::delete) + delete(albumWithSongs.album) + albumWithSongs.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt index 31b4d1195..d26ea1954 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt @@ -105,7 +105,9 @@ fun AppBar( AnimatedVisibility( visible = isSearchExpanded, - enter = fadeIn(tween(easing = LinearOutSlowInEasing)) + slideInVertically(tween()) { with(density) { -AppBarHeight.toPx().roundToInt() } }, + enter = fadeIn(tween(easing = LinearOutSlowInEasing)) + slideInVertically(tween()) { + with(density) { -AppBarHeight.toPx().roundToInt() } + }, exit = fadeOut() ) { BackHandler { diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt index b2ae6da8f..90e47065a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt @@ -37,6 +37,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking @Composable fun YouTubeSongMenu( @@ -311,7 +312,9 @@ fun YouTubeAlbumMenu( title = R.string.action_remove_from_library ) { database.query { - album(album.id)?.let { + runBlocking(Dispatchers.IO) { + album(album.id).first() + }?.let { delete(it.album) } } 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 4bcd386ff..7738d48b4 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 @@ -27,12 +27,17 @@ import androidx.compose.ui.util.fastForEachIndexed import androidx.hilt.navigation.compose.hiltViewModel 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.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.AlbumWithSongs +import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.component.AutoResizeText @@ -45,6 +50,9 @@ import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.component.shimmer.TextPlaceholder 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) @Composable @@ -57,6 +65,7 @@ fun AlbumScreen( val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val viewState by viewModel.viewState.collectAsState() + val inLibrary by viewModel.inLibrary.collectAsState() LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() @@ -65,45 +74,10 @@ fun AlbumScreen( when (viewState) { is AlbumViewState.Local -> { item { - AlbumHeader( - title = viewState.albumWithSongs.album.title, - artists = { - val annotatedString = buildAnnotatedString { - withStyle( - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal - ).toSpanStyle() - ) { - viewState.albumWithSongs.artists.fastForEachIndexed { index, artist -> - pushStringAnnotation(artist.id, artist.name) - append(artist.name) - pop() - if (index != viewState.albumWithSongs.artists.lastIndex) { - append(", ") - } - } - } - } - ClickableText(annotatedString) { offset -> - annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> - navController.navigate("artist/${range.tag}") - } - } - }, - year = viewState.albumWithSongs.album.year, - thumbnail = viewState.albumWithSongs.album.thumbnailUrl, - onPlay = { - playerConnection.playQueue(ListQueue( - title = viewState.albumWithSongs.album.title, - items = viewState.albumWithSongs.songs.map { it.toMediaItem() } - )) - }, - onShuffle = { - playerConnection.playQueue(ListQueue( - title = viewState.albumWithSongs.album.title, - items = viewState.albumWithSongs.songs.shuffled().map { it.toMediaItem() } - )) - } + LocalAlbumHeader( + albumWithSongs = viewState.albumWithSongs, + inLibrary = inLibrary, + navController = navController ) } @@ -130,49 +104,10 @@ fun AlbumScreen( } is AlbumViewState.Remote -> { item { - AlbumHeader( - title = viewState.albumPage.album.title, - artists = { - val annotatedString = buildAnnotatedString { - withStyle( - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal - ).toSpanStyle() - ) { - viewState.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 != viewState.albumPage.album.artists?.lastIndex) { - append(", ") - } - } - } - } - ClickableText(annotatedString) { offset -> - annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> - navController.navigate("artist/${range.tag}") - } - } - }, - year = viewState.albumPage.album.year, - thumbnail = viewState.albumPage.album.thumbnail, - onPlay = { - playerConnection.playQueue(ListQueue( - title = viewState.albumPage.album.title, - items = viewState.albumPage.songs.map { it.toMediaItem() } - )) - }, - onShuffle = { - playerConnection.playQueue(ListQueue( - title = viewState.albumPage.album.title, - items = viewState.albumPage.songs.shuffled().map { it.toMediaItem() } - )) - } + RemoteAlbumHeader( + albumPage = viewState.albumPage, + inLibrary = inLibrary, + navController = navController ) } @@ -255,14 +190,14 @@ fun AlbumScreen( } @Composable -inline fun AlbumHeader( - title: String, - artists: @Composable () -> Unit, - year: Int? = null, - thumbnail: String?, - noinline onPlay: () -> Unit, - noinline onShuffle: () -> Unit, +fun LocalAlbumHeader( + albumWithSongs: AlbumWithSongs, + inLibrary: Boolean, + navController: NavController, ) { + val playerConnection = LocalPlayerConnection.current ?: return + val database = LocalDatabase.current + Column( modifier = Modifier.padding(12.dp) ) { @@ -270,7 +205,7 @@ inline fun AlbumHeader( verticalAlignment = Alignment.CenterVertically ) { AsyncImage( - model = thumbnail, + model = albumWithSongs.album.thumbnailUrl, contentDescription = null, modifier = Modifier .size(AlbumThumbnailSize) @@ -283,27 +218,200 @@ inline fun AlbumHeader( verticalArrangement = Arrangement.Center, ) { AutoResizeText( - text = title, + text = albumWithSongs.album.title, fontWeight = FontWeight.Bold, maxLines = 2, overflow = TextOverflow.Ellipsis, fontSizeRange = FontSizeRange(16.sp, 22.sp) ) - artists() + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal + ).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}") + } + } - year?.let { year -> + if (albumWithSongs.album.year != null) { Text( - text = year.toString(), + text = albumWithSongs.album.year.toString(), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Normal ) } Row { - IconButton(onClick = { /*TODO*/ }) { + IconButton( + onClick = { + database.query { + if (inLibrary) { + delete(albumWithSongs) + } else { + insert(albumWithSongs) + } + } + } + ) { + Icon( + painter = painterResource(if (inLibrary) R.drawable.ic_library_add_check else R.drawable.ic_library_add), + contentDescription = null + ) + } + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row { + 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.ic_play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.btn_play) + ) + } + + Spacer(Modifier.width(12.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.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_shuffle)) + } + } + } +} + +@Composable +fun RemoteAlbumHeader( + albumPage: AlbumPage, + inLibrary: Boolean, + navController: NavController, +) { + val playerConnection = LocalPlayerConnection.current ?: return + 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 + ).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(", ") + } + } + } + } + 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 + ) + } + + Row { + IconButton( + onClick = { + database.query { + if (inLibrary) { + runBlocking(Dispatchers.IO) { + albumWithSongs(albumPage.album.browseId).first() + }?.let { + delete(it) + } + } else { + insert(albumPage) + } + } + } + ) { Icon( - painter = painterResource(R.drawable.ic_library_add_check), + painter = painterResource(if (inLibrary) R.drawable.ic_library_add_check else R.drawable.ic_library_add), contentDescription = null ) } @@ -315,7 +423,12 @@ inline fun AlbumHeader( Row { Button( - onClick = onPlay, + onClick = { + playerConnection.playQueue(ListQueue( + title = albumPage.album.title, + items = albumPage.songs.map(SongItem::toMediaItem) + )) + }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) ) { @@ -333,7 +446,12 @@ inline fun AlbumHeader( Spacer(Modifier.width(12.dp)) OutlinedButton( - onClick = onShuffle, + onClick = { + playerConnection.playQueue(ListQueue( + title = albumPage.album.title, + items = albumPage.songs.shuffled().map(SongItem::toMediaItem) + )) + }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) ) { 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 146e30b4d..ecc03e684 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt @@ -8,9 +8,7 @@ import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.AlbumWithSongs import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @@ -22,6 +20,9 @@ class AlbumViewModel @Inject constructor( 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) init { viewModelScope.launch { From 44f20a7d20da0f98b3ba0023f567b30dbf079ca8 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 20 Jan 2023 16:33:06 +0800 Subject: [PATCH 119/323] Use Timber --- app/build.gradle.kts | 5 +-- app/src/main/java/com/zionhuang/music/App.kt | 4 +-- .../AnyExt.kt => utils/ComposeDebugUtils.kt} | 31 ++++--------------- settings.gradle.kts | 2 ++ 4 files changed, 13 insertions(+), 29 deletions(-) rename app/src/main/java/com/zionhuang/music/{extensions/AnyExt.kt => utils/ComposeDebugUtils.kt} (86%) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7ebbff7b4..7a32bd11f 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -93,14 +93,13 @@ dependencies { implementation(libs.viewmodel.compose) implementation(libs.material3) + implementation(libs.palette) implementation(projects.materialColorUtilities) implementation(libs.coil) implementation(libs.shimmer) - implementation(libs.palette) - implementation(libs.exoplayer) implementation(libs.exoplayer.mediasession) implementation(libs.exoplayer.okhttp) @@ -121,4 +120,6 @@ dependencies { implementation(projects.kugou) coreLibraryDesugaring(libs.desugaring) + + implementation(libs.timber) } diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index 2fa14c868..431831f51 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -2,7 +2,6 @@ package com.zionhuang.music import android.app.Application import android.os.Build -import android.util.Log import android.widget.Toast import android.widget.Toast.LENGTH_SHORT import androidx.datastore.preferences.core.edit @@ -22,6 +21,7 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.launch +import timber.log.Timber import java.net.Proxy import java.util.* @@ -30,6 +30,7 @@ class App : Application(), ImageLoaderFactory { @OptIn(DelicateCoroutinesApi::class) override fun onCreate() { super.onCreate() + Timber.plant(Timber.DebugTree()) val locale = Locale.getDefault() val languageTag = locale.toLanguageTag().replace("-Hant", "") // replace zh-Hant-* to zh-* @@ -45,7 +46,6 @@ class App : Application(), ImageLoaderFactory { if (languageTag == "zh-TW") { KuGou.useTraditionalChinese = true } - Log.d("App", "${YouTube.locale}") if (dataStore[ProxyEnabledKey] == true) { try { diff --git a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt b/app/src/main/java/com/zionhuang/music/utils/ComposeDebugUtils.kt similarity index 86% rename from app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt rename to app/src/main/java/com/zionhuang/music/utils/ComposeDebugUtils.kt index cc52a4feb..511d69773 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/AnyExt.kt +++ b/app/src/main/java/com/zionhuang/music/utils/ComposeDebugUtils.kt @@ -1,7 +1,9 @@ -package com.zionhuang.music.extensions +package com.zionhuang.music.utils -import android.util.Log -import androidx.compose.runtime.* +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.Stable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.composed import androidx.compose.ui.draw.drawWithCache @@ -14,30 +16,9 @@ import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.lerp import androidx.compose.ui.platform.debugInspectorInfo import androidx.compose.ui.unit.dp -import com.zionhuang.music.BuildConfig import kotlinx.coroutines.delay import kotlin.math.min -val Any.TAG: String - get() = javaClass.simpleName - -fun Any.logd(msg: String) { - Log.d(TAG, msg) -} - -class Ref(var value: Int) - -// Note the inline function below which ensures that this function is essentially -// copied at the call site to ensure that its logging only recompositions from the -// original call site. -@Composable -inline fun LogCompositions(tag: String, msg: String) { - if (BuildConfig.DEBUG) { - val ref = remember { Ref(0) } - SideEffect { ref.value++ } - Log.d(tag, "Compositions: $msg ${ref.value}") - } -} /** * A [Modifier] that draws a border around elements that are recomposing. The border increases in * size and interpolates from red to green as more recompositions occur before a timeout. @@ -115,4 +96,4 @@ private val recomposeModifier = ) } } - } \ No newline at end of file + } diff --git a/settings.gradle.kts b/settings.gradle.kts index 93addff77..abd3662c2 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -76,6 +76,8 @@ dependencyResolutionManagement { library("desugaring", "com.android.tools", "desugar_jdk_libs").version("1.1.5") library("junit", "junit", "junit").version("4.13.2") + + library("timber", "com.jakewharton.timber", "timber").version("4.7.1") } } } From d9b107fbac5032f01cafb20fd176694ee0a6c7c3 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 21 Jan 2023 11:47:49 +0800 Subject: [PATCH 120/323] Add playlist screen --- .../java/com/zionhuang/music/MainActivity.kt | 22 +- .../com/zionhuang/music/db/DatabaseDao.kt | 3 + .../com/zionhuang/music/db/MusicDatabase.kt | 8 +- .../com/zionhuang/music/ui/menu/SongMenu.kt | 2 +- .../zionhuang/music/ui/screens/AlbumScreen.kt | 8 +- .../music/ui/screens/LocalPlaylistScreen.kt | 47 --- .../ui/screens/{Screens.kt => Screen.kt} | 0 .../screens/{ => artist}/ArtistItemsScreen.kt | 6 +- .../ui/screens/{ => artist}/ArtistScreen.kt | 4 +- .../ui/screens/library/LibrarySongsScreen.kt | 7 +- .../screens/playlist/LocalPlaylistScreen.kt | 252 +++++++++++++++ .../screens/playlist/OnlinePlaylistScreen.kt | 306 ++++++++++++++++++ .../screens/{ => search}/LocalSearchScreen.kt | 2 +- .../{ => search}/OnlineSearchResult.kt | 4 +- .../{ => search}/OnlineSearchScreen.kt | 2 +- .../viewmodels/BackupRestoreViewModel.kt | 6 +- .../viewmodels/OnlinePlaylistViewModel.kt | 45 +++ app/src/main/res/values/strings.xml | 2 + .../java/com/zionhuang/innertube/YouTube.kt | 6 +- .../zionhuang/innertube/pages/ArtistPage.kt | 2 +- .../zionhuang/innertube/pages/PlaylistPage.kt | 3 +- .../zionhuang/innertube/pages/SearchPage.kt | 12 +- .../innertube/pages/SearchSummaryPage.kt | 2 +- .../com/zionhuang/innertube/YouTubeTest.kt | 6 +- 24 files changed, 667 insertions(+), 90 deletions(-) delete mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt rename app/src/main/java/com/zionhuang/music/ui/screens/{Screens.kt => Screen.kt} (100%) rename app/src/main/java/com/zionhuang/music/ui/screens/{ => artist}/ArtistItemsScreen.kt (98%) rename app/src/main/java/com/zionhuang/music/ui/screens/{ => artist}/ArtistScreen.kt (99%) create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt rename app/src/main/java/com/zionhuang/music/ui/screens/{ => search}/LocalSearchScreen.kt (99%) rename app/src/main/java/com/zionhuang/music/ui/screens/{ => search}/OnlineSearchResult.kt (98%) rename app/src/main/java/com/zionhuang/music/ui/screens/{ => search}/OnlineSearchScreen.kt (99%) create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 7545dd6dc..76cd0661d 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -54,10 +54,17 @@ import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.component.shimmer.ShimmerTheme import com.zionhuang.music.ui.player.BottomSheetPlayer import com.zionhuang.music.ui.screens.* +import com.zionhuang.music.ui.screens.artist.ArtistItemsScreen +import com.zionhuang.music.ui.screens.artist.ArtistScreen 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.LocalPlaylistScreen +import com.zionhuang.music.ui.screens.playlist.OnlinePlaylistScreen +import com.zionhuang.music.ui.screens.search.LocalSearchScreen +import com.zionhuang.music.ui.screens.search.OnlineSearchResult +import com.zionhuang.music.ui.screens.search.OnlineSearchScreen import com.zionhuang.music.ui.screens.settings.* import com.zionhuang.music.ui.theme.* import com.zionhuang.music.utils.dataStore @@ -344,8 +351,19 @@ class MainActivity : ComponentActivity() { type = NavType.StringType } ) - ) { - LocalPlaylistScreen() + ) { backStackEntry -> + val playlistId = backStackEntry.arguments?.getString("playlistId")!! + if (playlistId.startsWith("LP")) { + LocalPlaylistScreen( + appBarConfig = appBarConfig, + navController = navController + ) + } else { + OnlinePlaylistScreen( + appBarConfig = appBarConfig, + navController = navController + ) + } } composable( route = "search/{query}", 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 e5aba5d80..f2614c8ed 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -377,6 +377,9 @@ interface DatabaseDao { @Delete fun delete(album: AlbumEntity) + @Delete + fun delete(playlist: PlaylistEntity) + @Delete fun delete(lyrics: LyricsEntity) 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 601de06a1..1873bf3ed 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -6,6 +6,7 @@ import androidx.room.* import androidx.room.migration.Migration import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId @@ -15,8 +16,11 @@ import java.time.ZoneOffset import java.util.* class MusicDatabase( - val delegate: InternalDatabase, + private val delegate: InternalDatabase, ) : DatabaseDao by delegate.dao { + val openHelper: SupportSQLiteOpenHelper + get() = delegate.openHelper + fun query(block: MusicDatabase.() -> Unit) = with(delegate) { queryExecutor.execute { block(this@MusicDatabase) @@ -34,6 +38,8 @@ class MusicDatabase( fun checkpoint() { delegate.query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) } + + fun close() = delegate.close() } @Database( 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 f08511a4b..81452a244 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 @@ -187,7 +187,7 @@ fun SongMenu( IconButton( onClick = { database.query { - insert(song.song.toggleLike()) + update(song.song.toggleLike()) } } ) { 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 7738d48b4..0c2a875f3 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 @@ -278,7 +278,7 @@ fun LocalAlbumHeader( Spacer(Modifier.height(12.dp)) - Row { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { playerConnection.playQueue(ListQueue( @@ -300,8 +300,6 @@ fun LocalAlbumHeader( ) } - Spacer(Modifier.width(12.dp)) - OutlinedButton( onClick = { playerConnection.playQueue(ListQueue( @@ -421,7 +419,7 @@ fun RemoteAlbumHeader( Spacer(Modifier.height(12.dp)) - Row { + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { playerConnection.playQueue(ListQueue( @@ -443,8 +441,6 @@ fun RemoteAlbumHeader( ) } - Spacer(Modifier.width(12.dp)) - OutlinedButton( onClick = { playerConnection.playQueue(ListQueue( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt deleted file mode 100644 index 0f155a895..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/screens/LocalPlaylistScreen.kt +++ /dev/null @@ -1,47 +0,0 @@ -package com.zionhuang.music.ui.screens - -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.lazy.LazyColumn -import androidx.compose.foundation.lazy.items -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.remember -import androidx.compose.ui.util.fastSumBy -import androidx.hilt.navigation.compose.hiltViewModel -import com.zionhuang.music.LocalPlayerAwareWindowInsets -import com.zionhuang.music.LocalPlayerConnection -import com.zionhuang.music.ui.component.SongListItem -import com.zionhuang.music.viewmodels.LocalPlaylistViewModel - -@Composable -fun LocalPlaylistScreen( - viewModel: LocalPlaylistViewModel = hiltViewModel(), -) { - val playerConnection = LocalPlayerConnection.current ?: return - val playWhenReady by playerConnection.playWhenReady.collectAsState() - val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - - val playlist by viewModel.playlist.collectAsState() - val songs by viewModel.playlistSongs.collectAsState() - val songCount = remember(songs) { - songs.size - } - val playlistLength = remember(songs) { - songs.fastSumBy { it.song.duration } - } - - LazyColumn( - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() - ) { - items( - items = songs - ) { song -> - SongListItem( - song = song, - isPlaying = song.id == mediaMetadata?.id, - playWhenReady = playWhenReady - ) - } - } -} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt b/app/src/main/java/com/zionhuang/music/ui/screens/Screen.kt similarity index 100% rename from app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/Screen.kt diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt similarity index 98% rename from app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt index fb7c123e7..638f04b05 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistItemsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistItemsScreen.kt @@ -1,4 +1,4 @@ -package com.zionhuang.music.ui.screens +package com.zionhuang.music.ui.screens.artist import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -137,7 +137,7 @@ fun ArtistItemsScreen( is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} + is PlaylistItem -> navController.navigate("playlist/${item.id}") } }, onLongClick = { @@ -271,7 +271,7 @@ fun ArtistItemsScreen( is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} + is PlaylistItem -> navController.navigate("playlist/${item.id}") } } ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt similarity index 99% rename from app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt index 1e8a687ee..c8267d02a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/ArtistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt @@ -1,4 +1,4 @@ -package com.zionhuang.music.ui.screens +package com.zionhuang.music.ui.screens.artist import androidx.compose.foundation.* import androidx.compose.foundation.layout.* @@ -307,7 +307,7 @@ fun ArtistScreen( is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} + is PlaylistItem -> navController.navigate("playlist/${item.id}") } }, onLongClick = { 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 ab40b1125..8a82a50e6 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 @@ -53,12 +53,7 @@ fun LibrarySongsScreen( modifier = Modifier.fillMaxSize() ) { LazyColumn( - contentPadding = WindowInsets.systemBars - .add(WindowInsets( - top = AppBarHeight, - bottom = NavigationBarHeight + MiniPlayerHeight - )) - .asPaddingValues() + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { item( key = "header", diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt new file mode 100644 index 000000000..38145b284 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -0,0 +1,252 @@ +package com.zionhuang.music.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +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.compose.ui.util.fastSumBy +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +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.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.* +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.utils.makeTimeString +import com.zionhuang.music.viewmodels.LocalPlaylistViewModel + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun LocalPlaylistScreen( + appBarConfig: AppBarConfig, + navController: NavController, + viewModel: LocalPlaylistViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() + val playlistLength = remember(songs) { + songs.fastSumBy { it.song.duration } + } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(playlist) { + appBarConfig.title = { + Text( + text = playlist?.playlist?.name.orEmpty(), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + playlist?.let { playlist -> + item { + if (playlist.songCount == 0) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .fillMaxSize() + .padding(12.dp) + ) { + Image( + painter = painterResource(id = R.drawable.ic_music_note), + contentDescription = null, + colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), + modifier = Modifier.size(64.dp) + ) + + Spacer(Modifier.height(12.dp)) + + Text( + text = stringResource(R.string.playlist_empty), + style = MaterialTheme.typography.bodyLarge + ) + } + } else { + Column( + verticalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically + ) { + if (playlist.thumbnails.size == 1) { + AsyncImage( + model = playlist.thumbnails[0], + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } else if (playlist.thumbnails.size > 1) { + Box( + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).fastForEachIndexed { index, alignment -> + AsyncImage( + model = playlist.thumbnails.getOrNull(index), + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(AlbumThumbnailSize / 2) + ) + } + } + } + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist.playlist.name, + fontWeight = FontWeight.Bold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + fontSizeRange = FontSizeRange(16.sp, 22.sp) + ) + + Text( + text = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + + Text( + text = makeTimeString(playlistLength * 1000L), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + } + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue(ListQueue( + title = playlist.playlist.name, + items = songs.map(Song::toMediaItem) + )) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_play), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_play)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue(ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map(Song::toMediaItem) + )) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_shuffle)) + } + } + } + } + } + } + + itemsIndexed( + items = songs + ) { index, song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(ListQueue( + title = playlist!!.playlist.name, + items = songs.map { it.toMediaItem() }, + startIndex = index + )) + } + .animateItemPlacement() + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt new file mode 100644 index 000000000..6620fa31c --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -0,0 +1,306 @@ +package com.zionhuang.music.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.ClickableText +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.buildAnnotatedString +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.text.withStyle +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import coil.compose.AsyncImage +import com.zionhuang.innertube.models.* +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.ThumbnailCornerRadius +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.* +import com.zionhuang.music.ui.component.shimmer.* +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.MainViewModel +import com.zionhuang.music.viewmodels.OnlinePlaylistViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun OnlinePlaylistScreen( + appBarConfig: AppBarConfig, + navController: NavController, + viewModel: OnlinePlaylistViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val librarySongIds by mainViewModel.librarySongIds.collectAsState() + val likedSongIds by mainViewModel.likedSongIds.collectAsState() + + val playlist by viewModel.playlist.collectAsState() + val itemsPage by viewModel.itemsPage.collectAsState() + + val lazyListState = rememberLazyListState() + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(playlist) { + appBarConfig.title = { + Text( + text = playlist?.title.orEmpty(), + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + LaunchedEffect(lazyListState) { + snapshotFlow { + lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } + }.collect { shouldLoadMore -> + if (!shouldLoadMore) return@collect + viewModel.loadMore() + } + } + + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + playlist.let { playlist -> + if (playlist != null) { + item { + Column( + modifier = Modifier.padding(12.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically + ) { + AsyncImage( + model = playlist.thumbnail, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist.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 + ).toSpanStyle() + ) { + if (playlist.author.id != null) { + pushStringAnnotation(playlist.author.id!!, playlist.author.name) + append(playlist.author.name) + pop() + } else { + append(playlist.author.name) + } + } + } + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } + } + + playlist.songCountText?.let { songCountText -> + Text( + text = songCountText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } + } + } + + Spacer(Modifier.height(12.dp)) + + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_shuffle)) + } + + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_radio)) + } + } + } + } + + items( + items = itemsPage?.items.orEmpty(), + key = { it.id } + ) { song -> + if (song !is SongItem) return@items + YouTubeListItem( + item = song, + badges = { + if (song.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in librarySongIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == song.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + playerConnection.playQueue(YouTubeQueue(song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) + } + + if (itemsPage?.continuation != null) { + item(key = "loading") { + ShimmerHost { + repeat(3) { + ListItemPlaceHolder() + } + } + } + } + } 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() + } + } + + Spacer(Modifier.padding(8.dp)) + + Row { + ButtonPlaceholder(Modifier.weight(1f)) + + Spacer(Modifier.width(12.dp)) + + ButtonPlaceholder(Modifier.weight(1f)) + } + } + + repeat(6) { + ListItemPlaceHolder() + } + } + } + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt similarity index 99% rename from app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt index 1873553a8..c416b6e48 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/LocalSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/LocalSearchScreen.kt @@ -1,4 +1,4 @@ -package com.zionhuang.music.ui.screens +package com.zionhuang.music.ui.screens.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt similarity index 98% rename from app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt index eedebd5b1..08449973e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchResult.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchResult.kt @@ -1,4 +1,4 @@ -package com.zionhuang.music.ui.screens +package com.zionhuang.music.ui.screens.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable @@ -167,7 +167,7 @@ fun OnlineSearchResult( is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> {} + is PlaylistItem -> navController.navigate("playlist/${item.id}") } } .animateItemPlacement() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt similarity index 99% rename from app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt index 29f49568f..67abccdad 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt @@ -1,4 +1,4 @@ -package com.zionhuang.music.ui.screens +package com.zionhuang.music.ui.screens.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable 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 9a411ce10..2ad13657d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -40,7 +40,7 @@ class BackupRestoreViewModel @Inject constructor( inputStream.copyTo(outputStream) } database.checkpoint() - FileInputStream(database.delegate.openHelper.writableDatabase.path).use { inputStream -> + FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> outputStream.putNextEntry(ZipEntry(InternalDatabase.DB_NAME)) inputStream.copyTo(outputStream) } @@ -70,8 +70,8 @@ class BackupRestoreViewModel @Inject constructor( } InternalDatabase.DB_NAME -> { database.checkpoint() - database.delegate.close() - FileOutputStream(database.delegate.openHelper.writableDatabase.path).use { outputStream -> + database.close() + FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> inputStream.copyTo(outputStream) } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt new file mode 100644 index 000000000..98bc67864 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -0,0 +1,45 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.music.models.ItemsPage +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.update +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class OnlinePlaylistViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val playlistId = savedStateHandle.get("playlistId")!! + + val playlist = MutableStateFlow(null) + val itemsPage = MutableStateFlow(null) + + init { + viewModelScope.launch { + val playlistPage = YouTube.browsePlaylist(playlistId).getOrNull() ?: return@launch + playlist.value = playlistPage.playlist + itemsPage.value = ItemsPage(playlistPage.songs, playlistPage.songsContinuation) + } + } + + fun loadMore() { + viewModelScope.launch { + val oldItemsPage = itemsPage.value ?: return@launch + val continuation = oldItemsPage.continuation ?: return@launch + val playlistContinuationPage = YouTube.browsePlaylistContinuation(continuation).getOrNull() ?: return@launch + itemsPage.update { + ItemsPage( + items = (oldItemsPage.items + playlistContinuationPage.songs).distinctBy { it.id }, + continuation = playlistContinuationPage.continuation + ) + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 486ef3e0b..f3dbc4aee 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -310,4 +310,6 @@ No results found + + The playlist is empty diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 1d97e7b26..953839d0a 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -181,11 +181,11 @@ object YouTube { ) } - suspend fun browsePlaylist(browseId: String) = runCatching { - val response = innerTube.browse(WEB_REMIX, browseId).body() + suspend fun browsePlaylist(playlistId: String) = runCatching { + val response = innerTube.browse(WEB_REMIX, "VL$playlistId").body() PlaylistPage( playlist = PlaylistItem( - id = browseId, + id = playlistId, title = response.header?.musicDetailHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, author = response.header.musicDetailHeaderRenderer.subtitle.runs?.getOrNull(2)?.let { Artist( diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt index b37aaa67d..0e1ba7d7e 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt @@ -109,7 +109,7 @@ data class ArtistPage( renderer.isPlaylist -> { // Playlist from YouTube Music PlaylistItem( - id = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + id = renderer.navigationEndpoint.browseEndpoint?.browseId?.removePrefix("VL") ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, author = Artist( name = renderer.subtitle.runs?.lastOrNull()?.text ?: return null, diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt index a199dc919..d780adb62 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/PlaylistPage.kt @@ -32,7 +32,8 @@ data class PlaylistPage( thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, explicit = renderer.badges?.find { it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" - } != null + } != null, + endpoint = renderer.overlay?.musicItemThumbnailOverlayRenderer?.content?.musicPlayButtonRenderer?.playNavigationEndpoint?.watchEndpoint ) } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt index 65674d493..a44bd3fb6 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchPage.kt @@ -74,16 +74,16 @@ object SearchPage { } renderer.isPlaylist -> { PlaylistItem( - id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + id = renderer.navigationEndpoint?.browseEndpoint?.browseId?.removePrefix("VL") ?: return null, title = renderer.flexColumns.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs ?.firstOrNull()?.text ?: return null, author = secondaryLine.firstOrNull()?.firstOrNull()?.let { - Artist( - name = it.text, - id = it.navigationEndpoint?.browseEndpoint?.browseId - ) - } ?: return null, + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, songCountText = renderer.flexColumns.getOrNull(1) ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs ?.lastOrNull()?.text ?: return null, diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt index 95d1f8951..d1877bee2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/SearchSummaryPage.kt @@ -77,7 +77,7 @@ data class SearchSummaryPage( } renderer.isPlaylist -> { PlaylistItem( - id = renderer.navigationEndpoint?.browseEndpoint?.browseId ?: return null, + id = renderer.navigationEndpoint?.browseEndpoint?.browseId?.removePrefix("VL") ?: return null, title = renderer.flexColumns.firstOrNull() ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs ?.firstOrNull()?.text ?: return null, diff --git a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt index 5e1c7c432..c09868398 100644 --- a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt +++ b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt @@ -93,7 +93,7 @@ class YouTubeTest { assertTrue(artist.sections.isNotEmpty()) val album = youTube.browseAlbum("MPREb_oNAdr9eUOfS").getOrThrow() assertTrue(album.songs.isNotEmpty()) - val playlist = youTube.browsePlaylist("VLRDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo").getOrThrow() + val playlist = youTube.browsePlaylist("RDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo").getOrThrow() assertTrue(playlist.songs.isNotEmpty()) } @@ -134,9 +134,9 @@ class YouTubeTest { @Test fun `Browse playlist`() = runBlocking { // This playlist has 2900 songs - val browseId = "VLPLtAw-mgfCzRwduBTjBHknz5U4_ZM4n6qm" + val playlistId = "PLtAw-mgfCzRwduBTjBHknz5U4_ZM4n6qm" var count = 5 - val playlistPage = YouTube.browsePlaylist(browseId).getOrThrow() + val playlistPage = YouTube.browsePlaylist(playlistId).getOrThrow() var songs = playlistPage.songs var continuation = playlistPage.songsContinuation while (count > 0) { From 31740058262c6f84416741441a8d46125f0c357e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 22 Jan 2023 12:28:17 +0800 Subject: [PATCH 121/323] Show new release albums in home screen --- .../java/com/zionhuang/music/MainActivity.kt | 3 + .../music/constants/ComposeConstants.kt | 3 +- .../com/zionhuang/music/db/DatabaseDao.kt | 2 +- .../zionhuang/music/models/MediaMetadata.kt | 8 +- .../zionhuang/music/playback/MusicService.kt | 8 +- .../zionhuang/music/playback/SongPlayer.kt | 5 +- .../component/shimmer/GridItemPlaceholder.kt | 19 ++- .../zionhuang/music/ui/screens/AlbumScreen.kt | 6 +- .../zionhuang/music/ui/screens/HomeScreen.kt | 114 +++++++++++++++++- .../music/ui/screens/NewReleaseScreen.kt | 113 +++++++++++++++++ .../ui/screens/artist/ArtistItemsScreen.kt | 13 +- .../music/ui/screens/artist/ArtistScreen.kt | 2 +- .../screens/playlist/OnlinePlaylistScreen.kt | 3 +- .../ui/screens/search/OnlineSearchResult.kt | 2 +- .../ui/screens/search/OnlineSearchScreen.kt | 2 +- .../screens/{ => settings}/SettingsScreen.kt | 2 +- .../music/viewmodels/HomeViewModel.kt | 25 ++++ .../music/viewmodels/NewReleaseViewModel.kt | 25 ++++ app/src/main/res/values/strings.xml | 1 + .../java/com/zionhuang/innertube/YouTube.kt | 23 +++- .../innertube/pages/NewReleaseAlbumPage.kt | 27 +++++ 21 files changed, 373 insertions(+), 33 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt rename app/src/main/java/com/zionhuang/music/ui/screens/{ => settings}/SettingsScreen.kt (98%) create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 76cd0661d..b8c1d5b78 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -300,6 +300,9 @@ class MainActivity : ComponentActivity() { composable(Screen.Playlists.route) { LibraryPlaylistsScreen(navController) } + composable("new_release") { + NewReleaseScreen(navController) + } composable( route = "album/{albumId}?playlistId={playlistId}", arguments = listOf( diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt index 3b8ec2376..8c0426ae9 100644 --- a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt @@ -11,7 +11,6 @@ const val CONTENT_TYPE_SONG = 2 const val CONTENT_TYPE_ARTIST = 3 const val CONTENT_TYPE_ALBUM = 4 const val CONTENT_TYPE_PLAYLIST = 5 -const val CONTENT_TYPE_SHIMMER = -1 val NavigationBarHeight = 80.dp val MiniPlayerHeight = 64.dp @@ -22,7 +21,7 @@ val ListItemHeight = 64.dp val SuggestionItemHeight = 56.dp val SearchFilterHeight = 48.dp val ListThumbnailSize = 48.dp -val GridThumbnailHeight = 144.dp +val GridThumbnailHeight = 128.dp val AlbumThumbnailSize = 144.dp val ThumbnailCornerRadius = 6.dp 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 f2614c8ed..d6a36b9ef 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -354,7 +354,7 @@ interface DatabaseDao { fun update(artist: ArtistEntity, artistPage: ArtistPage) { update(artist.copy( name = artistPage.artist.title, - thumbnailUrl = artistPage.artist.thumbnail.resize(400, 400), + thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544), lastUpdateTime = LocalDateTime.now() )) } diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt index 65068293b..54aafb146 100644 --- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -1,6 +1,5 @@ package com.zionhuang.music.models -import android.content.Context import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.MediaMetadataCompat.* import androidx.compose.runtime.Immutable @@ -9,7 +8,6 @@ import androidx.core.os.bundleOf import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.db.entities.* import com.zionhuang.music.ui.utils.resize -import kotlin.math.roundToInt @Immutable data class MediaMetadata( @@ -30,12 +28,12 @@ data class MediaMetadata( val title: String, ) - fun toMediaDescription(context: Context): MediaDescriptionCompat = builder + fun toMediaDescription(): MediaDescriptionCompat = builder .setMediaId(id) .setTitle(title) .setSubtitle(artists.joinToString { it.name }) .setDescription(artists.joinToString { it.name }) - .setIconUri(thumbnailUrl?.resize((512 * context.resources.displayMetrics.density).roundToInt(), null)?.toUri()) + .setIconUri(thumbnailUrl?.resize(544, 544)?.toUri()) .setExtras(bundleOf( METADATA_KEY_DURATION to duration * 1000L, METADATA_KEY_ARTIST to artists.joinToString { it.name }, @@ -91,7 +89,7 @@ fun SongItem.toMediaMetadata() = MediaMetadata( ) }, duration = duration ?: -1, - thumbnailUrl = thumbnail, + thumbnailUrl = thumbnail.resize(544, 544), album = album?.let { MediaMetadata.Album( id = it.id, 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 b1739411e..a42a01ce3 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -114,7 +114,7 @@ class MusicService : MediaBrowserServiceCompat() { SONG -> { result.detach() result.sendResult(database.songsByCreateDateDesc().first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) + MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) }.toMutableList()) } ARTIST -> { @@ -144,13 +144,13 @@ class MusicService : MediaBrowserServiceCompat() { parentId.startsWith("$ARTIST/") -> { result.detach() result.sendResult(database.artistSongsByCreateDateDesc(parentId.removePrefix("$ARTIST/")).first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) + MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) }.toMutableList()) } parentId.startsWith("$ALBUM/") -> { result.detach() result.sendResult(database.albumSongs(parentId.removePrefix("$ALBUM/")).first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) + MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) }.toMutableList()) } parentId.startsWith("$PLAYLIST/") -> { @@ -160,7 +160,7 @@ class MusicService : MediaBrowserServiceCompat() { DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, true) else -> database.playlistSongs(playlistId) }.first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(this@MusicService), FLAG_PLAYABLE) + MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) }.toMutableList()) } else -> { diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 599a1ca9e..291b0cde3 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -91,7 +91,6 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import kotlin.math.min import kotlin.math.pow -import kotlin.math.roundToInt /** * A wrapper around [ExoPlayer] @@ -250,7 +249,7 @@ class SongPlayer( ).build() } ) - setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata!!.toMediaDescription(context) } + setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata!!.toMediaDescription() } setQueueEditor(object : MediaSessionConnector.QueueEditor { override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = false override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat) = throw UnsupportedOperationException() @@ -273,7 +272,7 @@ class SongPlayer( override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? = player.currentMetadata?.thumbnailUrl?.let { url -> - bitmapProvider.load(url.resize((512 * context.resources.displayMetrics.density).roundToInt(), null)) { + bitmapProvider.load(url.resize(544, 544)) { callback.onBitmap(it) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt index 9ad6cd9bc..66897e7ed 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/shimmer/GridItemPlaceholder.kt @@ -16,13 +16,26 @@ import com.zionhuang.music.constants.ThumbnailCornerRadius fun GridItemPlaceHolder( modifier: Modifier = Modifier, thumbnailShape: Shape = RoundedCornerShape(ThumbnailCornerRadius), + fillMaxWidth: Boolean = false, ) { Column( - modifier = modifier.padding(12.dp) + modifier = if (fillMaxWidth) { + modifier + .padding(12.dp) + .fillMaxWidth() + } else { + modifier + .padding(12.dp) + .width(GridThumbnailHeight) + } ) { Spacer( - modifier = Modifier - .size(GridThumbnailHeight) + modifier = if (fillMaxWidth) { + Modifier.fillMaxWidth() + } else { + Modifier.height(GridThumbnailHeight) + } + .aspectRatio(1f) .clip(thumbnailShape) .background(MaterialTheme.colorScheme.onSurface) ) 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 0c2a875f3..761c2781e 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 @@ -228,7 +228,8 @@ fun LocalAlbumHeader( val annotatedString = buildAnnotatedString { withStyle( style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground ).toSpanStyle() ) { albumWithSongs.artists.fastForEachIndexed { index, artist -> @@ -361,7 +362,8 @@ fun RemoteAlbumHeader( val annotatedString = buildAnnotatedString { withStyle( style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground ).toSpanStyle() ) { albumPage.album.artists?.fastForEachIndexed { index, artist -> 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 f37c971d2..c335c8b94 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 @@ -1,29 +1,58 @@ package com.zionhuang.music.ui.screens +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.material3.surfaceColorAtElevation import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip 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.models.* import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.viewmodels.HomeViewModel +import com.zionhuang.music.viewmodels.MainViewModel +@OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( navController: NavController, + viewModel: HomeViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), ) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() + + val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + + val coroutineScope = rememberCoroutineScope() + BoxWithConstraints( modifier = Modifier.fillMaxSize() ) { @@ -34,7 +63,7 @@ fun HomeScreen( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .padding(16.dp) + .padding(horizontal = 12.dp, vertical = 6.dp) .widthIn(min = 84.dp) .clip(RoundedCornerShape(6.dp)) .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) @@ -56,6 +85,87 @@ fun HomeScreen( ) } } + + if (newReleaseAlbums.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("new_release") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.new_release_albums), + style = MaterialTheme.typography.headlineSmall + ) + } + + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null + ) + } + } + + item { + LazyRow { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + badges = { + if (album.id in libraryAlbumIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (album.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == album.id, + playWhenReady = playWhenReady, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + YouTubeAlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } + } + } + } } } -} \ No newline at end of file +} 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 new file mode 100644 index 000000000..6a2a981e2 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/NewReleaseScreen.kt @@ -0,0 +1,113 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.* +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.constants.GridThumbnailHeight +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.component.shimmer.GridItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.viewmodels.MainViewModel +import com.zionhuang.music.viewmodels.NewReleaseViewModel + +@OptIn(ExperimentalFoundationApi::class) +@Composable +fun NewReleaseScreen( + navController: NavController, + viewModel: NewReleaseViewModel = hiltViewModel(), + mainViewModel: MainViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() + + val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + badges = { + if (album.id in libraryAlbumIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (album.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.album?.id == album.id, + playWhenReady = playWhenReady, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + YouTubeAlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + if (newReleaseAlbums.isEmpty()) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } +} 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 638f04b05..868811bc4 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 @@ -122,6 +122,15 @@ fun ArtistItemsScreen( .padding(end = 2.dp) ) } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, isPlaying = when (item) { is SongItem -> mediaMetadata?.id == item.id @@ -135,7 +144,7 @@ fun ArtistItemsScreen( onClick = { when (item) { is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") is PlaylistItem -> navController.navigate("playlist/${item.id}") } @@ -269,7 +278,7 @@ fun ArtistItemsScreen( .clickable { when (item) { is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") is PlaylistItem -> navController.navigate("playlist/${item.id}") } 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 c8267d02a..5939ce343 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 @@ -305,7 +305,7 @@ fun ArtistScreen( onClick = { when (item) { is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") is PlaylistItem -> navController.navigate("playlist/${item.id}") } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 6620fa31c..3917f29eb 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -119,7 +119,8 @@ fun OnlinePlaylistScreen( val annotatedString = buildAnnotatedString { withStyle( style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground ).toSpanStyle() ) { if (playlist.author.id != null) { 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 08449973e..8cd93f41b 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 @@ -165,7 +165,7 @@ fun OnlineSearchResult( .clickable { when (item) { is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") is PlaylistItem -> navController.navigate("playlist/${item.id}") } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt index 67abccdad..82c623744 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt @@ -162,7 +162,7 @@ fun OnlineSearchScreen( onDismiss() } is AlbumItem -> { - navController.navigate("album/${item.id}?playlistId=${item.playlistId}") + navController.navigate("album/${item.id}") onDismiss() } is ArtistItem -> { diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/SettingsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt similarity index 98% rename from app/src/main/java/com/zionhuang/music/ui/screens/SettingsScreen.kt rename to app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt index 884d9ca74..594549d67 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/SettingsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt @@ -1,4 +1,4 @@ -package com.zionhuang.music.ui.screens +package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.asPaddingValues diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt new file mode 100644 index 000000000..fd2101df4 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt @@ -0,0 +1,25 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.AlbumItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class HomeViewModel @Inject constructor() : ViewModel() { + private val _newReleaseAlbums = MutableStateFlow>(emptyList()) + val newReleaseAlbums = _newReleaseAlbums.asStateFlow() + + init { + viewModelScope.launch { + YouTube.newReleaseAlbumsPreview().onSuccess { + _newReleaseAlbums.value = 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 new file mode 100644 index 000000000..8153e8cb6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt @@ -0,0 +1,25 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.AlbumItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class NewReleaseViewModel @Inject constructor() : ViewModel() { + private val _newReleaseAlbums = MutableStateFlow>(emptyList()) + val newReleaseAlbums = _newReleaseAlbums.asStateFlow() + + init { + viewModelScope.launch { + YouTube.newReleaseAlbums().onSuccess { + _newReleaseAlbums.value = it + } + } + } +} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f3dbc4aee..16cc16928 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -312,4 +312,5 @@ No results found The playlist is empty + New release albums diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 953839d0a..8b3e7032d 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -214,6 +214,24 @@ object YouTube { ) } + suspend fun newReleaseAlbumsPreview(): Result> = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_explore").body() + response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.getOrNull(1)?.musicCarouselShelfRenderer?.contents?.mapNotNull { + it.musicTwoRowItemRenderer?.let { renderer -> + NewReleaseAlbumPage.fromMusicTwoRowItemRenderer(renderer) + } + }.orEmpty() + } + + suspend fun newReleaseAlbums(): Result> = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_new_releases_albums").body() + response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.gridRenderer?.items?.mapNotNull { + it.musicTwoRowItemRenderer?.let { renderer -> + NewReleaseAlbumPage.fromMusicTwoRowItemRenderer(renderer) + } + }.orEmpty() + } + suspend fun browsePlaylistContinuation(continuation: String) = runCatching { val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() PlaylistContinuationPage( @@ -299,10 +317,7 @@ object YouTube { } } - const val HOME_BROWSE_ID = "FEmusic_home" - const val EXPLORE_BROWSE_ID = "FEmusic_explore" - - const val MAX_GET_QUEUE_SIZE = 1000 + private const val MAX_GET_QUEUE_SIZE = 1000 const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" } diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt new file mode 100644 index 000000000..fbf840d61 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt @@ -0,0 +1,27 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* + +object NewReleaseAlbumPage { + fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): AlbumItem? { + return AlbumItem( + browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + playlistId = renderer.thumbnailOverlay + ?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = renderer.subtitle.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + year = renderer.subtitle.runs.lastOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.subtitleBadges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } +} From ee7cad93159771438eac6038ff2a3764ac2297d3 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 22 Jan 2023 14:44:47 +0800 Subject: [PATCH 122/323] Show most played songs in home screen --- .../music/constants/ComposeConstants.kt | 2 +- .../music/constants/PreferenceKeys.kt | 2 +- .../com/zionhuang/music/db/DatabaseDao.kt | 9 +- .../zionhuang/music/ui/screens/HomeScreen.kt | 106 ++++++++++++++++-- .../ui/screens/library/LibrarySongsScreen.kt | 4 +- .../utils/LazyGridSnapLayoutInfoProvider.kt | 72 ++++++++++++ .../music/viewmodels/HomeViewModel.kt | 10 +- app/src/main/res/values/strings.xml | 1 + 8 files changed, 183 insertions(+), 23 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt index 8c0426ae9..9ab47e771 100644 --- a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt @@ -21,7 +21,7 @@ val ListItemHeight = 64.dp val SuggestionItemHeight = 56.dp val SearchFilterHeight = 48.dp val ListThumbnailSize = 48.dp -val GridThumbnailHeight = 128.dp +val GridThumbnailHeight = 116.dp val AlbumThumbnailSize = 144.dp val ThumbnailCornerRadius = 6.dp 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 6c632618a..ad331adbf 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -41,7 +41,7 @@ val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType") val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") enum class SongSortType { - CREATE_DATE, NAME, ARTIST, PLAY_TIME + CREATE_DATE, NAME, ARTIST } enum class ArtistSortType { 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 d6a36b9ef..9f96849fb 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -41,10 +41,6 @@ interface DatabaseDao { @Query("SELECT * FROM song ORDER BY title DESC") fun songsByNameDesc(): Flow> - @Transaction - @Query("SELECT * FROM song ORDER BY totalPlayTime DESC") - fun songsByPlayTimeDesc(): Flow> - fun songs(sortType: SongSortType, descending: Boolean) = when (sortType) { SongSortType.CREATE_DATE -> songsByCreateDateDesc() @@ -54,7 +50,6 @@ interface DatabaseDao { song.artists.joinToString(separator = "") { it.name } }) } - SongSortType.PLAY_TIME -> songsByPlayTimeDesc() }.map { it.reversed(!descending) } fun likedSongs(sortType: SongSortType, descending: Boolean) = @@ -62,6 +57,10 @@ interface DatabaseDao { songs.filter { it.song.liked } } + @Transaction + @Query("SELECT * FROM song ORDER BY totalPlayTime DESC LIMIT 20") + fun mostPlayedSongs(): Flow> + @Query("SELECT COUNT(1) FROM song WHERE liked") fun likedSongsCount(): Flow 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 c335c8b94..47c651ce3 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 @@ -1,22 +1,18 @@ package com.zionhuang.music.ui.screens -import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.* +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.grid.rememberLazyGridState import androidx.compose.foundation.lazy.items import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.surfaceColorAtElevation -import androidx.compose.runtime.Composable -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue -import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.material3.* +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -29,9 +25,15 @@ import com.zionhuang.innertube.models.* import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.YouTubeGridItem +import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.ui.menu.YouTubeAlbumMenu +import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider import com.zionhuang.music.viewmodels.HomeViewModel import com.zionhuang.music.viewmodels.MainViewModel @@ -49,13 +51,26 @@ fun HomeScreen( val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() + val mostPlayedSongs by viewModel.mostPlayedSongs.collectAsState() val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() val coroutineScope = rememberCoroutineScope() + val mostPlayedLazyGridState = rememberLazyGridState() + BoxWithConstraints( modifier = Modifier.fillMaxSize() ) { + val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val snapLayoutInfoProvider = remember(mostPlayedLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = mostPlayedLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } + ) + } + LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { @@ -86,6 +101,73 @@ fun HomeScreen( } } + if (mostPlayedSongs.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.most_played_songs), + style = MaterialTheme.typography.headlineSmall + ) + } + } + } + + item { + LazyHorizontalGrid( + state = mostPlayedLazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + ) { + items( + items = mostPlayedSongs, + key = { it.id } + ) { song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .width(maxWidth * horizontalLazyGridItemWidthFactor) + .combinedClickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + ) + } + } + } + } + if (newReleaseAlbums.isNotEmpty()) { item { Row( 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 8a82a50e6..860c6467c 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 @@ -146,7 +146,6 @@ fun SongHeader( 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 }), color = MaterialTheme.colorScheme.primary, style = MaterialTheme.typography.labelLarge, @@ -168,8 +167,7 @@ fun SongHeader( listOf( SongSortType.CREATE_DATE to R.string.sort_by_create_date, SongSortType.NAME to R.string.sort_by_name, - SongSortType.ARTIST to R.string.sort_by_artist, - SongSortType.PLAY_TIME to R.string.sort_by_play_time + SongSortType.ARTIST to R.string.sort_by_artist ).forEach { (type, text) -> DropdownMenuItem( text = { diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt new file mode 100644 index 000000000..81b583aa0 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt @@ -0,0 +1,72 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.lazy.grid.LazyGridItemInfo +import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo +import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.ui.unit.Density +import androidx.compose.ui.util.fastForEach +import androidx.compose.ui.util.fastSumBy + +fun Density.calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } + + val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) + val itemCurrentPosition = item.offset.x.toFloat() + + return itemCurrentPosition - desiredDistance +} + +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + lazyGridState: LazyGridState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) }, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + + private val layoutInfo: LazyGridLayoutInfo + get() = lazyGridState.layoutInfo + + // Single page snapping is the default + override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f + + override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = + calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } + + override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) { + if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.fastSumBy { it.size.width } / visibleItemsInfo.size.toFloat() + } else { + 0f + } + } +} 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 fd2101df4..dcd3a8c8c 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt @@ -4,14 +4,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.music.db.MusicDatabase import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.asStateFlow +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel -class HomeViewModel @Inject constructor() : ViewModel() { +class HomeViewModel @Inject constructor( + database: MusicDatabase, +) : ViewModel() { + val mostPlayedSongs = database.mostPlayedSongs() + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + private val _newReleaseAlbums = MutableStateFlow>(emptyList()) val newReleaseAlbums = _newReleaseAlbums.asStateFlow() diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 16cc16928..bcd1f5d74 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -313,4 +313,5 @@ The playlist is empty New release albums + Most played songs From 0dcd2685cdf39573d307bb51cb073e316b228146 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 22 Jan 2023 14:47:11 +0800 Subject: [PATCH 123/323] Don't show album text in song item --- .../com/zionhuang/music/ui/component/Items.kt | 25 ------------------- 1 file changed, 25 deletions(-) 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 2987c224a..6970dbe6b 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 @@ -140,30 +140,6 @@ inline fun GridItem( } } -@Composable -inline fun GridItem( - modifier: Modifier = Modifier, - title: String, - subtitle: String, - crossinline badges: @Composable RowScope.() -> Unit = {}, - thumbnailContent: @Composable () -> Unit, -) = GridItem( - modifier = modifier, - title = title, - subtitle = { - badges() - - Text( - text = subtitle, - color = MaterialTheme.colorScheme.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - }, - thumbnailContent = thumbnailContent, -) - @Composable inline fun SongListItem( song: Song, @@ -177,7 +153,6 @@ inline fun SongListItem( title = song.song.title, subtitle = joinByBullet( song.artists.joinToString(), - song.album?.title, makeTimeString(song.song.duration * 1000L) ), badges = { From bebef957db539e61836707127460e6dd92bdae43 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 22 Jan 2023 18:09:09 +0800 Subject: [PATCH 124/323] Add random fab in home screen --- .../music/constants/ComposeConstants.kt | 2 +- .../music/playback/MediaSessionConnection.kt | 92 ------------------- .../zionhuang/music/ui/screens/HomeScreen.kt | 26 ++++++ app/src/main/res/drawable/ic_casino.xml | 24 +++++ 4 files changed, 51 insertions(+), 93 deletions(-) delete mode 100644 app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt create mode 100644 app/src/main/res/drawable/ic_casino.xml diff --git a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt index 9ab47e771..8c0426ae9 100644 --- a/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/ComposeConstants.kt @@ -21,7 +21,7 @@ val ListItemHeight = 64.dp val SuggestionItemHeight = 56.dp val SearchFilterHeight = 48.dp val ListThumbnailSize = 48.dp -val GridThumbnailHeight = 116.dp +val GridThumbnailHeight = 128.dp val AlbumThumbnailSize = 144.dp val ThumbnailCornerRadius = 6.dp diff --git a/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt b/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt deleted file mode 100644 index 087309e59..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/MediaSessionConnection.kt +++ /dev/null @@ -1,92 +0,0 @@ -package com.zionhuang.music.playback - -import android.content.ComponentName -import android.content.Context -import android.content.Intent -import android.content.ServiceConnection -import android.os.IBinder -import android.os.RemoteException -import android.support.v4.media.MediaMetadataCompat -import android.support.v4.media.session.MediaControllerCompat -import android.support.v4.media.session.MediaSessionCompat.QueueItem -import android.support.v4.media.session.PlaybackStateCompat -import com.zionhuang.music.playback.MusicService.MusicBinder -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow - -object MediaSessionConnection { - var mediaController: MediaControllerCompat? = null - private set - val transportControls: MediaControllerCompat.TransportControls? get() = mediaController?.transportControls - private val mediaControllerCallback = MediaControllerCallback() - - private val _isConnected = MutableStateFlow(false) - private val _playbackState = MutableStateFlow(null) - private val _mediaMetadata = MutableStateFlow(null) - private val _queueTitle = MutableStateFlow(null) - private val _queueItems = MutableStateFlow>(emptyList()) - - val isConnected: StateFlow = _isConnected - val playbackState: StateFlow = _playbackState - val mediaMetadata: StateFlow = _mediaMetadata - val queueTitle: StateFlow = _queueTitle - val queueItems: StateFlow> = _queueItems - - private var _binder: MusicBinder? = null - val binder: MusicBinder? get() = _binder - - private var serviceConnection: ServiceConnection? = null - - fun connect(context: Context) { - serviceConnection = object : ServiceConnection { - override fun onServiceConnected(name: ComponentName, iBinder: IBinder) { - if (iBinder !is MusicBinder) return - _binder = iBinder - try { - mediaController = MediaControllerCompat(context, iBinder.sessionToken).apply { - registerCallback(mediaControllerCallback) - } - _isConnected.value = true - } catch (_: RemoteException) { - } - } - - override fun onServiceDisconnected(name: ComponentName) { - _binder = null - mediaController?.unregisterCallback(mediaControllerCallback) - _isConnected.value = false - } - }.also { - val intent = Intent(context, MusicService::class.java) - context.bindService(intent, it, Context.BIND_AUTO_CREATE) - } - } - - fun disconnect(context: Context) { - if (serviceConnection != null) { - context.unbindService(serviceConnection!!) - } - } - - private class MediaControllerCallback : MediaControllerCompat.Callback() { - override fun onPlaybackStateChanged(state: PlaybackStateCompat) { - _playbackState.value = state - } - - override fun onMetadataChanged(metadata: MediaMetadataCompat?) { - _mediaMetadata.value = metadata - // force update playback state - mediaController?.let { - _playbackState.value = it.playbackState - } - } - - override fun onQueueChanged(queue: List) { - _queueItems.value = queue - } - - override fun onQueueTitleChanged(title: CharSequence?) { - _queueTitle.value = title?.toString() - } - } -} \ No newline at end of file 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 47c651ce3..6351c7a40 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 @@ -36,6 +36,7 @@ import com.zionhuang.music.ui.menu.YouTubeAlbumMenu import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider import com.zionhuang.music.viewmodels.HomeViewModel import com.zionhuang.music.viewmodels.MainViewModel +import kotlin.random.Random @OptIn(ExperimentalFoundationApi::class) @Composable @@ -249,5 +250,30 @@ fun HomeScreen( } } } + + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues()) + .padding(16.dp), + onClick = { + if (newReleaseAlbums.isEmpty() || Random.nextBoolean()) { + val song = mostPlayedSongs.random() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } else { + val album = newReleaseAlbums.random() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint( + playlistId = album.playlistId, + params = "wAEB" + ))) + } + }) { + Icon( + painter = painterResource(R.drawable.ic_casino), + contentDescription = null + ) + } } } diff --git a/app/src/main/res/drawable/ic_casino.xml b/app/src/main/res/drawable/ic_casino.xml new file mode 100644 index 000000000..4eec4118c --- /dev/null +++ b/app/src/main/res/drawable/ic_casino.xml @@ -0,0 +1,24 @@ + + + + + + + + From f059412353c1aca7d05180a3a7d0f4da6f992dea Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 22 Jan 2023 18:49:32 +0800 Subject: [PATCH 125/323] Add sleep timer (#97) --- .../{playback => models}/PersistQueue.kt | 3 +- .../zionhuang/music/playback/MusicService.kt | 4 - .../music/playback/PlayerConnection.kt | 5 +- .../zionhuang/music/playback/SongPlayer.kt | 45 +++++- .../com/zionhuang/music/ui/player/Queue.kt | 137 ++++++++++++++++-- app/src/main/res/values/strings.xml | 7 + 6 files changed, 174 insertions(+), 27 deletions(-) rename app/src/main/java/com/zionhuang/music/{playback => models}/PersistQueue.kt (68%) diff --git a/app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt b/app/src/main/java/com/zionhuang/music/models/PersistQueue.kt similarity index 68% rename from app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt rename to app/src/main/java/com/zionhuang/music/models/PersistQueue.kt index 0f422a256..5f412bfee 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PersistQueue.kt +++ b/app/src/main/java/com/zionhuang/music/models/PersistQueue.kt @@ -1,6 +1,5 @@ -package com.zionhuang.music.playback +package com.zionhuang.music.models -import com.zionhuang.music.models.MediaMetadata import java.io.Serializable data class PersistQueue( 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 a42a01ce3..2db18d1df 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -11,7 +11,6 @@ import android.support.v4.media.MediaBrowserCompat import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.session.MediaSessionCompat import androidx.annotation.DrawableRes import androidx.core.content.ContextCompat.startForegroundService import androidx.core.net.toUri @@ -87,9 +86,6 @@ class MusicService : MediaBrowserServiceCompat() { } inner class MusicBinder : Binder() { - val sessionToken: MediaSessionCompat.Token - get() = songPlayer.mediaSession.sessionToken - val player: ExoPlayer get() = this@MusicService.songPlayer.player 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 3bf23369f..e32f6baad 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -9,7 +9,6 @@ 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.models.MediaMetadata import com.zionhuang.music.playback.MusicService.MusicBinder import com.zionhuang.music.playback.queues.Queue import kotlinx.coroutines.ExperimentalCoroutinesApi @@ -25,8 +24,8 @@ class PlayerConnection( val player = binder.player val playbackState = MutableStateFlow(STATE_IDLE) - val playWhenReady = MutableStateFlow(false) - val mediaMetadata = MutableStateFlow(null) + val playWhenReady = MutableStateFlow(player.playWhenReady) + val mediaMetadata = MutableStateFlow(player.currentMetadata) val currentSong = mediaMetadata.flatMapLatest { database.song(it?.id) } diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 291b0cde3..c4024f3c3 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -14,6 +14,9 @@ import android.os.ResultReceiver import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat import android.support.v4.media.session.PlaybackStateCompat.* +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.core.net.toUri @@ -67,6 +70,7 @@ import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.* import com.zionhuang.music.lyrics.LyricsHelper import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.models.PersistQueue import com.zionhuang.music.playback.MusicService.Companion.ALBUM import com.zionhuang.music.playback.MusicService.Companion.ARTIST import com.zionhuang.music.playback.MusicService.Companion.PLAYLIST @@ -91,10 +95,8 @@ import java.net.SocketTimeoutException import java.net.UnknownHostException import kotlin.math.min import kotlin.math.pow +import kotlin.time.Duration.Companion.minutes -/** - * A wrapper around [ExoPlayer] - */ @OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) class SongPlayer( private val context: Context, @@ -144,6 +146,9 @@ class SongPlayer( private val normalizeFactor = MutableStateFlow(1f) val playerVolume = MutableStateFlow(context.dataStore[PlayerVolumeKey]?.coerceIn(0f, 1f) ?: 1f) + var sleepTimerTriggerTime by mutableStateOf(-1L) + var pauseWhenSongEnd by mutableStateOf(false) + val mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { isActive = true } @@ -592,6 +597,24 @@ class SongPlayer( } } + fun setSleepTimer(minute: Int) { + if (minute == -1) { + pauseWhenSongEnd = true + } else { + sleepTimerTriggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds + scope.launch { + delay(minute.minutes) + player.pause() + sleepTimerTriggerTime = -1L + } + } + } + + fun clearSleepTimer() { + pauseWhenSongEnd = false + sleepTimerTriggerTime = -1L + } + private fun openAudioEffectSession() { context.sendBroadcast( Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { @@ -630,6 +653,10 @@ class SongPlayer( bitmapProvider.currentBitmap = null bitmapProvider.onBitmapChanged(null) } + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } } override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, @DiscontinuityReason reason: Int) { @@ -646,9 +673,15 @@ class SongPlayer( player.shuffleModeEnabled = false mediaSession.setQueueTitle("") } - if (playbackState == STATE_ENDED && autoAddSong) { - player.currentMetadata?.let { - addToLibrary(it) + if (playbackState == STATE_ENDED) { + if (autoAddSong) { + player.currentMetadata?.let { + addToLibrary(it) + } + } + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 29443f054..abc74914f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -6,6 +6,8 @@ import android.text.format.Formatter import android.widget.Toast import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* @@ -13,6 +15,7 @@ import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -20,6 +23,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalClipboardManager import androidx.compose.ui.platform.LocalContext @@ -42,11 +46,13 @@ import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.ui.component.* -import com.zionhuang.music.utils.joinByBullet import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive +import kotlin.math.roundToInt -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) @Composable fun Queue( state: BottomSheetState, @@ -73,6 +79,84 @@ fun Queue( var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) + val sleepTimerEnabled = remember(playerConnection.songPlayer.sleepTimerTriggerTime, playerConnection.songPlayer.pauseWhenSongEnd) { + playerConnection.songPlayer.sleepTimerTriggerTime != -1L || playerConnection.songPlayer.pauseWhenSongEnd + } + + var sleepTimerTimeLeft by remember { + mutableStateOf(0L) + } + + LaunchedEffect(sleepTimerEnabled) { + if (sleepTimerEnabled) { + while (isActive) { + sleepTimerTimeLeft = if (playerConnection.songPlayer.pauseWhenSongEnd) { + playerConnection.player.duration - playerConnection.player.currentPosition + } else { + playerConnection.songPlayer.sleepTimerTriggerTime - System.currentTimeMillis() + } + delay(1000L) + } + } + } + + var showSleepTimerDialog by remember { + mutableStateOf(false) + } + + var sleepTimerValue by remember { + mutableStateOf(30f) + } + if (showSleepTimerDialog) { + AlertDialog( + properties = DialogProperties(usePlatformDefaultWidth = false), + onDismissRequest = { showSleepTimerDialog = false }, + icon = { Icon(painter = painterResource(R.drawable.ic_bedtime), contentDescription = null) }, + title = { Text(stringResource(R.string.sleep_timer)) }, + confirmButton = { + TextButton( + onClick = { + showSleepTimerDialog = false + playerConnection.songPlayer.setSleepTimer(sleepTimerValue.roundToInt()) + } + ) { + Text(stringResource(android.R.string.ok)) + } + }, + dismissButton = { + TextButton( + onClick = { showSleepTimerDialog = false } + ) { + Text(stringResource(android.R.string.cancel)) + } + }, + text = { + Column(horizontalAlignment = Alignment.CenterHorizontally) { + Text( + text = pluralStringResource(R.plurals.minute, sleepTimerValue.roundToInt(), sleepTimerValue.roundToInt()), + style = MaterialTheme.typography.bodyLarge + ) + + Slider( + value = sleepTimerValue, + onValueChange = { sleepTimerValue = it }, + valueRange = 5f..120f, + steps = (120 - 5) / 5 - 1, + ) + + OutlinedButton( + onClick = { + showSleepTimerDialog = false + playerConnection.songPlayer.setSleepTimer(-1) + } + ) { + Text(stringResource(R.string.end_of_song)) + } + } + } + ) + } + var showDetailsDialog by rememberSaveable { mutableStateOf(false) } @@ -164,6 +248,27 @@ fun Queue( modifier = Modifier.alpha(if (showLyrics) 1f else 0.5f) ) } + AnimatedContent( + targetState = sleepTimerEnabled + ) { sleepTimerEnabled -> + if (sleepTimerEnabled) { + Text( + text = makeTimeString(sleepTimerTimeLeft), + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clip(RoundedCornerShape(50)) + .clickable(onClick = playerConnection.songPlayer::clearSleepTimer) + .padding(8.dp) + ) + } else { + IconButton(onClick = { showSleepTimerDialog = true }) { + Icon( + painter = painterResource(R.drawable.ic_bedtime), + contentDescription = null + ) + } + } + } IconButton(onClick = playerConnection::toggleLibrary) { Icon( painter = painterResource(if (currentSong != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add), @@ -237,23 +342,31 @@ fun Queue( .asPaddingValues()) ) { Row( + horizontalArrangement = Arrangement.spacedBy(6.dp), verticalAlignment = Alignment.CenterVertically, modifier = Modifier - .height(ListItemHeight) - .padding(12.dp) + .padding(horizontal = 12.dp, vertical = 12.dp) ) { Text( text = queueTitle.orEmpty(), - style = MaterialTheme.typography.titleLarge, + style = MaterialTheme.typography.titleMedium, modifier = Modifier.weight(1f) ) - Text( - text = joinByBullet( - makeTimeString(queueLength * 1000L), - pluralStringResource(R.plurals.song_count, queueItems.size, queueItems.size) - ), - style = MaterialTheme.typography.bodyMedium - ) + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp), + horizontalAlignment = Alignment.End + ) { + Text( + text = pluralStringResource(R.plurals.song_count, queueItems.size, queueItems.size), + style = MaterialTheme.typography.bodyMedium + ) + + Text( + text = makeTimeString(queueLength * 1000L), + style = MaterialTheme.typography.bodyMedium + ) + } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index bcd1f5d74..4c2db879d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -314,4 +314,11 @@ The playlist is empty New release albums Most played songs + Sleep timer + End of song + + + 1 minute + %d minutes + From 7e0d6b783a34418c428b593ea12241b827e01e72 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 22 Jan 2023 18:55:10 +0800 Subject: [PATCH 126/323] [Feature] Delete song cache --- .../ui/screens/settings/StorageSettings.kt | 19 ++++++++++++++++++- app/src/main/res/values/strings.xml | 1 + 2 files changed, 19 insertions(+), 1 deletion(-) 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 f912c198a..f7703f5d4 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 @@ -26,8 +26,10 @@ 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.rememberPreference +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch @OptIn(ExperimentalCoilApi::class) @Composable @@ -36,6 +38,8 @@ fun StorageSettings() { val imageDiskCache = context.imageLoader.diskCache ?: return val playerCache = LocalPlayerConnection.current?.songPlayer?.cache ?: return + val coroutineScope = rememberCoroutineScope() + var imageCacheSize by remember { mutableStateOf(imageDiskCache.size) } @@ -92,7 +96,9 @@ fun StorageSettings() { PreferenceEntry( title = stringResource(R.string.pref_clear_image_cache_title), onClick = { - imageDiskCache.clear() + coroutineScope.launch(Dispatchers.IO) { + imageDiskCache.clear() + } }, ) @@ -130,5 +136,16 @@ fun StorageSettings() { }, onValueSelected = onMaxSongCacheSizeChange ) + + PreferenceEntry( + title = stringResource(R.string.clear_song_cache), + onClick = { + coroutineScope.launch(Dispatchers.IO) { + playerCache.keys.forEach { key -> + playerCache.removeResource(key) + } + } + }, + ) } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4c2db879d..8828c5397 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -316,6 +316,7 @@ Most played songs Sleep timer End of song + Clear song cache 1 minute From 129b395839a7477125cb9a3b225cc4e6e388039c Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Mon, 23 Jan 2023 14:15:42 +0800 Subject: [PATCH 127/323] Fix trivial bugs --- .../java/com/zionhuang/music/MainActivity.kt | 6 +- .../music/constants/PreferenceKeys.kt | 2 +- .../zionhuang/music/playback/SongPlayer.kt | 7 +- .../zionhuang/music/ui/screens/HomeScreen.kt | 295 +++++++++--------- .../music/ui/screens/settings/AboutScreen.kt | 3 +- .../ui/screens/settings/PrivacySettings.kt | 4 +- 6 files changed, 155 insertions(+), 162 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index b8c1d5b78..73e2d1884 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -175,8 +175,10 @@ class MainActivity : ComponentActivity() { selection = TextRange(query.length) )) navController.navigate("search/$query") - database.query { - insert(SearchHistory(query = query)) + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = query)) + } } } } 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 ad331adbf..079cdb4eb 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -28,7 +28,7 @@ val AutoDownloadKey = booleanPreferencesKey("autoDownload") val ExpandOnPlayKey = booleanPreferencesKey("expandOnPlay") val NotificationMoreActionKey = booleanPreferencesKey("notificationMoreAction") -val PauseSearchHistory = booleanPreferencesKey("pauseSearchHistory") +val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory") val EnableKugouKey = booleanPreferencesKey("enableKugou") val SongSortTypeKey = stringPreferencesKey("songSortType") diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index c4024f3c3..439e92454 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -146,6 +146,7 @@ class SongPlayer( private val normalizeFactor = MutableStateFlow(1f) val playerVolume = MutableStateFlow(context.dataStore[PlayerVolumeKey]?.coerceIn(0f, 1f) ?: 1f) + var sleepTimerJob: Job? = null var sleepTimerTriggerTime by mutableStateOf(-1L) var pauseWhenSongEnd by mutableStateOf(false) @@ -598,11 +599,13 @@ class SongPlayer( } fun setSleepTimer(minute: Int) { + sleepTimerJob?.cancel() + sleepTimerJob = null if (minute == -1) { pauseWhenSongEnd = true } else { sleepTimerTriggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds - scope.launch { + sleepTimerJob = scope.launch { delay(minute.minutes) player.pause() sleepTimerTriggerTime = -1L @@ -611,6 +614,8 @@ class SongPlayer( } fun clearSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null pauseWhenSongEnd = false sleepTimerTriggerTime = -1L } 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 6351c7a40..ecd8da4ac 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 @@ -3,7 +3,6 @@ package com.zionhuang.music.ui.screens import androidx.compose.foundation.* import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid @@ -63,6 +62,7 @@ fun HomeScreen( modifier = Modifier.fillMaxSize() ) { val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor val snapLayoutInfoProvider = remember(mostPlayedLazyGridState) { SnapLayoutInfoProvider( lazyGridState = mostPlayedLazyGridState, @@ -72,183 +72,168 @@ fun HomeScreen( ) } - LazyColumn( - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) ) { - item { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .padding(horizontal = 12.dp, vertical = 6.dp) - .widthIn(min = 84.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) - .clickable { - navController.navigate("settings") - } - .padding(12.dp) - ) { - Icon( - painter = painterResource(R.drawable.ic_settings), - contentDescription = null - ) + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) - Spacer(Modifier.height(6.dp)) + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(horizontal = 12.dp, vertical = 6.dp) + .widthIn(min = 84.dp) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable { + navController.navigate("settings") + } + .padding(12.dp) + ) { + Icon( + painter = painterResource(R.drawable.ic_settings), + contentDescription = null + ) - Text( - text = stringResource(R.string.title_settings), - style = MaterialTheme.typography.labelLarge, - ) - } + Spacer(Modifier.height(6.dp)) + + Text( + text = stringResource(R.string.title_settings), + style = MaterialTheme.typography.labelLarge, + ) } if (mostPlayedSongs.isNotEmpty()) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.most_played_songs), - style = MaterialTheme.typography.headlineSmall - ) - } - } - } + Text( + text = stringResource(R.string.most_played_songs), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(12.dp) + ) - item { - LazyHorizontalGrid( - state = mostPlayedLazyGridState, - rows = GridCells.Fixed(4), - flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), - modifier = Modifier - .fillMaxWidth() - .height(ListItemHeight * 4) - ) { - items( - items = mostPlayedSongs, - key = { it.id } - ) { song -> - SongListItem( - song = song, - isPlaying = song.id == mediaMetadata?.id, - playWhenReady = playWhenReady, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - } + LazyHorizontalGrid( + state = mostPlayedLazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + ) { + items( + items = mostPlayedSongs, + key = { it.id } + ) { song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .width(maxWidth * horizontalLazyGridItemWidthFactor) - .combinedClickable { - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) } - ) - } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .clickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) } } } if (newReleaseAlbums.isNotEmpty()) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate("new_release") - } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.new_release_albums), - style = MaterialTheme.typography.headlineSmall - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("new_release") } - - Icon( - painter = painterResource(R.drawable.ic_navigate_next), - contentDescription = null + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.new_release_albums), + style = MaterialTheme.typography.headlineSmall ) } + + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null + ) } - item { - LazyRow { - items( - items = newReleaseAlbums, - key = { it.id } - ) { album -> - YouTubeGridItem( - item = album, - badges = { - if (album.id in libraryAlbumIds) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (album.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = mediaMetadata?.id == album.id, - playWhenReady = playWhenReady, - modifier = Modifier - .combinedClickable( - onClick = { - navController.navigate("album/${album.id}") - }, - onLongClick = { - menuState.show { - YouTubeAlbumMenu( - album = album, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - } - } + LazyRow { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + badges = { + if (album.id in libraryAlbumIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) ) - .animateItemPlacement() - ) - } + } + if (album.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == album.id, + playWhenReady = playWhenReady, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + YouTubeAlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) } } } + + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) } FloatingActionButton( 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 6830f430c..f897058d7 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 @@ -9,6 +9,7 @@ import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.stringResource +import com.zionhuang.music.BuildConfig import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.ui.component.PreferenceEntry @@ -24,7 +25,7 @@ fun AboutScreen() { ) { PreferenceEntry( title = stringResource(R.string.pref_app_version_title), - description = stringResource(R.string.app_name), + description = BuildConfig.VERSION_NAME, icon = R.drawable.ic_info, onClick = { } ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index 70e55c014..d57cf9f7c 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -16,7 +16,7 @@ import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.EnableKugouKey -import com.zionhuang.music.constants.PauseSearchHistory +import com.zionhuang.music.constants.PauseSearchHistoryKey import com.zionhuang.music.ui.component.DefaultDialog import com.zionhuang.music.ui.component.PreferenceEntry import com.zionhuang.music.ui.component.SwitchPreference @@ -25,7 +25,7 @@ import com.zionhuang.music.utils.rememberPreference @Composable fun PrivacySettings() { val database = LocalDatabase.current - val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistory, defaultValue = false) + val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistoryKey, defaultValue = false) val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) var showClearHistoryDialog by remember { From 9459835804c26b8bec7d2bd563d4fc6535cf87bb Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 12:00:00 +0200 Subject: [PATCH 128/323] Create strings.xml Added Ukrainian translation --- app/src/main/res/values-uk-rUA/strings.xml | 335 +++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 app/src/main/res/values-uk-rUA/strings.xml diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml new file mode 100644 index 000000000..8148eb57b --- /dev/null +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -0,0 +1,335 @@ + + + Головна + Музика + Виконавці + Альбоми + Плейлисти + Огляд + Параметри + Зараз відтворюється + Звіт про помилки + + + Зовнішній вигляд + Кольоровий акцент системи + Кольоровий акцент теми + Темна тема + Увімк. + Вимк. + Використовувати конфігурацію системи + Вкладка навігації за замовчуванням + Налаштування вкладок навігації + Розташування тексту пісні + Ліворуч + По центру + Праворуч + + Контент + Логін + Мова контенту + Країна контенту + Увімкнути проксі + Тип проксі + URL проксі + Перезапуск програми + + Плеєр та аудіо + Якість аудіо + Авто + Висока + Низька + Постійна черга + Пропуск пауз між треками + Нормалізація аудіо + Еквалайзер + + Сховище + Перегляд завантажених файлів у SAF + На деяких пристроях ця функція може не працювати + Кеш + Макс. розмір кешу зображень + Очистити кеш зображень + Макс. розмір кешу аудіо + %s використано + + Загальні + Автоматичне завантаження + Завантажувати композицію після додавання до бібліотеки + Автоматичне додавання композиції до бібліотеки + Додати композицію до бібліотеки після завершення відтворення + Розгорнути нижню панель під час відтворення + Дії в панелі сповіщень + Показувати кнопки «Додати до бібліотеки» та «Подобається» + + Конфіденційність + Призупинити запис історії пошуку + Очистити історію пошуку + Ви впевнені, що хочете очистити всю історію пошуку? + Увімкнути провайдера текстів KuGou + + Резервне копіювання + Створити резервну копію + Відновити з резервної копії + + Про програму + Версія програми + + + Sakura + Red + Pink + Purple + Deep purple + Indigo + Blue + Light blue + Cyan + Teal + Green + Light green + Lime + Yellow + Amber + Orange + Deep orange + Brown + Blue grey + + + Пошук + Пошук в YouTube Music… + Пошук в бібліотеці… + + + Улюблені треки + Завантажена музика + + + Детальніше + Редагувати + Увімкнути радіо + Відтворити + Відтворити наступним + Додати в чергу + Додати до бібліотеки + Завантажити + Видалити із завантажених + Імпортувати плейлист + Додати в плейлист + Перейти на сторінку виконавця + Перейти до альбому + Оновити + Поділитися + Видалити + Пошук в Інтернеті + Вибрати інший текст пісні + + + Детальніше + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + + Редагувати текст пісні + Пошук тексту пісні + Вибрати текст пісні + + Редагувати композицію + Song title + Song artists + Вкажіть назву композиції + Вкажіть виконавця композиції + Зберегти + + Створити плейлист + Playlist name + Вкажіть назву плейлиста + + Редагувати виконавця + Artist name + Вкажіть ім\'я виконавця + + Дублікати виконавців + Виконавець %1$s вже існує. + + Вибрати плейлист + + Редагувати плейлист + + Вибір вмісту резервної копії + Вибір вмісту для відновлення + Параметри + Дані + Завантажені композиції + Резервну копію створено успішно + Не вдалося створити резервну копію + Не вдалося відновити з резервної копії + + + Music Player + Завантаження + + + + %d композиція + %d композиції + %d композицій + %d композицій + + + %d виконавець + %d виконавця + %d виконавців + %d виконавців + + + %d альбом + %d альбоми + %d альбомів + %d альбомів + + + %d плейлист + %d плейлисти + %d плейлистів + %d плейлистів + + + + Повторювати + Відтворити + Відтворити все + Радіо + Перемішати + Копіювати stack trace + Звіт про помилки + Звіт про помилки на GitHub + + + Нещодавно додані + Назва + Виконавець + Рік + Кількість + Тривалість + Нещодавно відтворені + + + + %d композицію буде видалено. + %d композиції будуть видалені. + %d композицій буде видалено. + %d композицій буде видалено. + + + %d вибрано + %d вибрано + + Скасувати + Неможливо визначити URL. + + %d композиція лунатиме наступною + %d композиції лунатимуть наступними + %d композицій лунатимуть наступними + %d композицій лунатимуть наступними + + + %d виконавець лунатиме наступним + %d виконавці лунатимуть наступними + %d виконавців лунатимуть наступними + %d виконавців лунатимуть наступними + + + %d альбом лунатиме наступним + %d альбоми лунатимуть наступними + %d альбомів лунатимуть наступними + %d альбомів лунатимуть наступними + + + %d плейлист лунатиме наступним + %d плейлисти лунатимуть наступними + %d плейлистів лунатимуть наступними + %d плейлистів лунатимуть наступними + + Обрана композиція лунатиме наступною + + %d композицію додано в чергу + %d композиції додано в чергу + %d композицій додано в чергу + %d композицій додано в чергу + + + %d виконавця додано в чергу + %d виконавців додано в чергу + %d виконавців додано в чергу + %d виконавців додано в чергу + + + %d альбом додано в чергу + %d альбоми додано в чергу + %d альбомів додано в чергу + %d альбомів додано в чергу + + + %d плейлист додано в чергу + %d плейлисти додано в чергу + %d плейлистів додано в чергу + %d плейлистів додано в чергу + + Додано в чергу + Додано до бібліотеки + Видалено з бібліотеки + Плейлист імпортовано + Додано в %1$s + + Початок завантаження %d композиції + Початок завантаження %d композицій + Початок завантаження %d композицій + Початок завантаження %d композицій + + Видалено із завантажених + Огляд + + + Поставити «Подобається» + Прибрати «Подобається» + Додати до бібліотеки + Видалити з бібліотеки + + + Всі + Композиції + Відео + Альбоми + Виконавці + Плейлисти + Плейлисти спільноти + Обрані плейлисти + + Використовувати конфігурацію системи + З вашої бібліотеки + + + Вибачте, цього не мало статися. + Скопійовано + + + Немає доступних потоків + Відсутнє підключення до мережі + Тайм-аут + Невідома помилка + + + Всі композиції + Шукані пісні + + + Текст пісні не знайдено + From ed10b48773423fc835bb2951ad015279c23a0d81 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 12:01:23 +0200 Subject: [PATCH 129/323] Create strings.xml Added russian translation --- app/src/main/res/values-ru-rRU/strings.xml | 335 +++++++++++++++++++++ 1 file changed, 335 insertions(+) create mode 100644 app/src/main/res/values-ru-rRU/strings.xml diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml new file mode 100644 index 000000000..5f48d3c90 --- /dev/null +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -0,0 +1,335 @@ + + + Главная + Музыка + Исполнители + Альбомы + Плейлисты + Исследовать + Настройки + Сейчас воспроизводится + Отчет об ошибках + + + Внешний вид + Цветовой акцент системы + Цветовой акцент темы + Темная тема + Вкл. + Выкл. + Использовать настройки системы + Вкладка навигации по умолчанию + Настройка вкладок навигации + Расположение текста песни + Слева + По центру + Справа + + Контент + Логин + Язык контента + Страна контента + Включить прокси + Тип прокси + URL прокси + Перезапуск приложения + + Плеер и аудио + Качество аудио + Авто + Высокое + Низкое + Постоянная очередь + Пропуск пауз между треками + Нормализация аудио + Эквалайзер + + Хранилище + Просмотр загруженных файлов в SAF + На некоторых устройствах эта функция может не работать + Кэш + Макс. размер кэша изображений + Очистить кэш изображений + Макс. размер кэша аудио + %s использовано + + Общие + Автоматическая загрузка + Загружать композицию после добавления в библиотеку + Автоматическое добавление композиции в библиотеку + Добавить композицию в библиотеку после завершения воспроизведения + Развернуть нижнюю панель во время воспроизведения + Действия в шторке уведомлений + Показывать кнопки «Добавить в библиотеку» и «Нравится» + + Конфиденциальность + Приостановить сохранение истории поиска + Очистить историю поиска + Вы уверены, что хотите очистить всю историю поиска? + Включить провайдера текстов KuGou + + Резервное копирование + Создать резервную копию + Восстановить из резервной копии + + О приложении + Версия приложения + + + Sakura + Red + Pink + Purple + Deep purple + Indigo + Blue + Light blue + Cyan + Teal + Green + Light green + Lime + Yellow + Amber + Orange + Deep orange + Brown + Blue grey + + + Поиск + Поиск в YouTube Music… + Поиск в библиотеке… + + + Любимые треки + Загруженная музыка + + + Подробнее + Редактировать + Запустить радио + Воспоизвести + Воспроизвести следующим + Добавить в очередь + Добавить в библиотеку + Загрузить + Удалить из загруженных + Импортировать плейлист + Добавить в плейлист + Перейти на страницу исполнителя + Перейти к альбому + Обновить + Поделиться + Удалить + Поиск в Интернете + Выбрать другой текст песни + + + Подробнее + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + + Редактировать текст песни + Поиск текста песни + Выбрать текст песни + + Редактировать композицию + Song title + Song artists + Укажите название композиции + Укажите исполнителя композиции + Сохранить + + Создать плейлист + Playlist name + Укажите название плейлиста + + Редактировать исполнителя + Artist name + Укажите имя исполнителя + + Дубликаты исполнителей + Исполнитель %1$s уже существует. + + Выбрать плейлист + + Редактировать плейлист + + Выбор содержимого резервной копии + Выбор содержимого для восстановления + Настройки + Данные + Загруженные композиции + Резервная копия создана успешно + Не удалось создать резервную копию + Не удалось восстановить резервную копию + + + Music Player + Загрузка + + + + %d композиция + %d композиции + %d композиций + %d композиций + + + %d исполнитель + %d исполнителя + %d исполнителей + %d исполнителей + + + %d альбом + %d альбома + %d альбомов + %d альбомов + + + %d плейлист + %d плейлиста + %d плейлистов + %d плейлистов + + + + Повторить + Воспроизвести + Воспроизвести все + Радио + Перемешать + Копировать stack trace + Отчет об ошибках + Отчет об ошибках на GitHub + + + Недавно добавленные + Название + Исполнитель + Год + Количество + Длительность + Недавно прослушанные + + + + %d композиция будет удалена. + %d композиции будут удалены. + %d композиций будет удалено. + %d композиций будет удалено. + + + %d выбрано + %d выбрано + + Отменить + Невозможно определить URL. + + %d композиция прозвучит следующей + %d композиции прозвучат следующими + %d композиций прозвучат следующими + %d композиций прозвучат следующими + + + %d исполнитель прозвучит следующим + %d исполнителя прозвучат следующими + %d исполнителей прозвучат следующими + %d исполнителей прозвучат следующими + + + %d альбом прозвучит следующим + %d альбома прозвучат следующими + %d альбомов прозвучат следующими + %d альбомов прозвучат следующими + + + %d плейлист прозвучит следующим + %d плейлиста прозвучат следующими + %d плейлистов прозвучат следующими + %d плейлистов прозвучат следующими + + Выбранная композиция прозвучит следующей + + %d композиция добавлена в очередь + %d композиции добавлено в очередь + %d композиций добавлено в очередь + %d композиций добавлено в очередь + + + %d исполнитель добавлен в очередь + %d исполнителя добавлено в очередь + %d исполнителей добавлено в очередь + %d исполнителей добавлено в очередь + + + %d альбом добавлен в очередь + %d альбома добавлено в очередь + %d альбомов добавлено в очередь + %d альбомов добавлено в очередь + + + %d плейлист добавлен в очередь + %d плейлиста добавлено в очередь + %d плейлистов добавлено в очередь + %d плейлистов добавлено в очередь + + Добавлено в очередь + Добавлено в библиотеку + Удалено из библиотеки + Плейлист импортирован + Добавлено в %1$s + + Начало загрузки %d композиции + Начало загрузки %d композиций + Начало загрузки %d композиций + Начало загрузки %d композиций + + Удалено из загруженных + Просмотр + + + Поставить «Нравится» + Убрать «Нравится» + Добавить в библиотеку + Удалить из библиотеки + + + Все + Композиции + Видео + Альбомы + Исполнители + Плейлисты + Плейлисты сообщества + Избранные плейлисты + + По умолчанию системы + Из вашей библиотеки + + + Извините, этого не должно было случиться. + Скопировано + + + Нет доступных потоков + Нет подключения к сети + Тайм-аут + Неизвестная ошибка + + + Все композиции + Искомые композиции + + + Текст песни не найден + From 09576690007a8c351a5e7abbc1ffe643bb1ffad3 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 12:16:32 +0200 Subject: [PATCH 130/323] Update strings.xml --- app/src/main/res/values-ru-rRU/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 5f48d3c90..71e408c21 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -40,7 +40,7 @@ Высокое Низкое Постоянная очередь - Пропуск пауз между треками + Пропуск тишины в треках Нормализация аудио Эквалайзер From 18e095ab7f3e26b8b4e0bd755b86d6deb0042197 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 12:19:19 +0200 Subject: [PATCH 131/323] Update strings.xml --- app/src/main/res/values-uk-rUA/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 8148eb57b..610d137dc 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -40,7 +40,7 @@ Висока Низька Постійна черга - Пропуск пауз між треками + Пропуск тиші в треках Нормалізація аудіо Еквалайзер From 6cad0a3f4b4b945956fe11b47e7c31f51aa94b3f Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 18:11:11 +0200 Subject: [PATCH 132/323] Update strings.xml --- app/src/main/res/values-ru-rRU/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 71e408c21..bcce88241 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -230,6 +230,8 @@ %d выбрано + %d выбрано + %d выбрано %d выбрано Отменить From 8984fccdf84d09c0d3d4fb1e7ce4220ab9c6f0de Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 18:21:14 +0200 Subject: [PATCH 133/323] Update strings.xml --- app/src/main/res/values-uk-rUA/strings.xml | 2 ++ 1 file changed, 2 insertions(+) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 610d137dc..fd99efcd0 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -230,6 +230,8 @@ %d вибрано + %d вибрано + %d вибрано %d вибрано Скасувати From acdaaaae5874abde36663087146ce86e1e74a84b Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 18:25:03 +0200 Subject: [PATCH 134/323] Update strings.xml --- app/src/main/res/values-uk-rUA/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index fd99efcd0..6cb2d79de 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -40,7 +40,7 @@ Висока Низька Постійна черга - Пропуск тиші в треках + Пропуск тиші в композиціях Нормалізація аудіо Еквалайзер From a7da5b04477075c9e076f8a9197774c31fcdde8d Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 18:25:57 +0200 Subject: [PATCH 135/323] Update strings.xml --- app/src/main/res/values-ru-rRU/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index bcce88241..3eea963d6 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -40,7 +40,7 @@ Высокое Низкое Постоянная очередь - Пропуск тишины в треках + Пропуск тишины в композициях Нормализация аудио Эквалайзер From 1c1a0d60520669fcb9638643f11d72ef10497fda Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 23:35:20 +0200 Subject: [PATCH 136/323] Update strings.xml The translation has been corrected to be more accurate and correct. --- app/src/main/res/values-ru-rRU/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 3eea963d6..54b021226 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -217,9 +217,9 @@ Название Исполнитель Год - Количество + Количество треков Длительность - Недавно прослушанные + Количество воспр. From 763c726d562b0bb915dd1500869064b63f3b2d25 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko <81887288+netrunner-exe@users.noreply.github.com> Date: Mon, 23 Jan 2023 23:35:28 +0200 Subject: [PATCH 137/323] Update strings.xml The translation has been corrected to be more accurate and correct. --- app/src/main/res/values-uk-rUA/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 6cb2d79de..9bc153dbb 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -217,9 +217,9 @@ Назва Виконавець Рік - Кількість + Кількість треків Тривалість - Нещодавно відтворені + Кількість відтворень From 39f4ab8b4df65c1265f464eeaa8abf04768a0329 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 24 Jan 2023 15:12:01 +0800 Subject: [PATCH 138/323] Add YouTubeAlbumRadio --- app/src/main/java/com/zionhuang/music/App.kt | 11 ++-- .../music/lyrics/YouTubeLyricsProvider.kt | 2 +- .../playback/queues/YouTubeAlbumRadio.kt | 41 +++++++++++++ .../zionhuang/music/ui/menu/YouTubeMenu.kt | 8 +-- .../zionhuang/music/ui/screens/HomeScreen.kt | 8 +-- .../music/viewmodels/AlbumViewModel.kt | 2 +- .../music/viewmodels/ArtistItemsViewModel.kt | 4 +- .../music/viewmodels/ArtistViewModel.kt | 2 +- .../music/viewmodels/LibraryViewModels.kt | 2 +- .../viewmodels/OnlinePlaylistViewModel.kt | 4 +- .../OnlineSearchSuggestionViewModel.kt | 2 +- .../java/com/zionhuang/innertube/YouTube.kt | 60 ++++++++++--------- .../com/zionhuang/innertube/YouTubeTest.kt | 20 +++---- 13 files changed, 104 insertions(+), 62 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index 431831f51..8ae00a711 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -64,11 +64,12 @@ class App : Application(), ImageLoaderFactory { .map { it[VisitorDataKey] } .distinctUntilChanged() .collect { visitorData -> - YouTube.visitorData = visitorData ?: YouTube.generateVisitorData().getOrNull()?.also { newVisitorData -> - dataStore.edit { settings -> - settings[VisitorDataKey] = newVisitorData - } - } ?: YouTube.DEFAULT_VISITOR_DATA + YouTube.visitorData = visitorData + ?: YouTube.visitorData().getOrNull()?.also { newVisitorData -> + dataStore.edit { settings -> + settings[VisitorDataKey] = newVisitorData + } + } ?: YouTube.DEFAULT_VISITOR_DATA } } GlobalScope.launch { diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt index 94f62d3c5..6a1911cde 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt @@ -9,7 +9,7 @@ object YouTubeLyricsProvider : LyricsProvider { override fun isEnabled(context: Context) = true override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = runCatching { val nextResult = YouTube.next(WatchEndpoint(videoId = id!!)).getOrThrow() - YouTube.getLyrics( + YouTube.lyrics( endpoint = nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found") ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable") } diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt new file mode 100644 index 000000000..9b9a50341 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.playback.queues + +import com.google.android.exoplayer2.MediaItem +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.MediaMetadata +import kotlinx.coroutines.Dispatchers.IO +import kotlinx.coroutines.withContext + +class YouTubeAlbumRadio( + private val playlistId: String, +) : Queue { + override val preloadItem: MediaMetadata? = null + private val endpoint = WatchEndpoint( + playlistId = playlistId, + params = "wAEB" + ) + private var continuation: String? = null + + override suspend fun getInitialStatus(): Queue.Status = withContext(IO) { + val albumSongs = YouTube.albumSongs(playlistId).getOrThrow() + val nextResult = YouTube.next(endpoint, continuation).getOrThrow() + continuation = nextResult.continuation + Queue.Status( + title = nextResult.title, + items = (albumSongs + nextResult.items.subList(albumSongs.size, nextResult.items.size)).map { it.toMediaItem() }, + mediaItemIndex = nextResult.currentIndex ?: 0 + ) + } + + override fun hasNextPage(): Boolean = continuation != null + + override suspend fun nextPage(): List { + val nextResult = withContext(IO) { + YouTube.next(endpoint, continuation).getOrThrow() + } + continuation = nextResult.continuation + return nextResult.items.map { it.toMediaItem() } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt index 90e47065a..fbc33657d 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt @@ -28,6 +28,7 @@ import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.playback.queues.YouTubeAlbumRadio import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.GridMenu import com.zionhuang.music.ui.component.GridMenuItem @@ -286,10 +287,7 @@ fun YouTubeAlbumMenu( icon = R.drawable.ic_radio, title = R.string.menu_start_radio ) { - playerConnection.playQueue(YouTubeQueue(WatchEndpoint( - playlistId = album.playlistId, - params = "wAEB" - ))) + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) onDismiss() } GridMenuItem( @@ -325,7 +323,7 @@ fun YouTubeAlbumMenu( title = R.string.action_add_to_library ) { coroutineScope.launch(Dispatchers.IO) { - YouTube.browseAlbum(album.browseId).onSuccess { albumPage -> + YouTube.album(album.browseId).onSuccess { albumPage -> database.query { insert(albumPage) } 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 ecd8da4ac..c2b595024 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 @@ -26,6 +26,7 @@ import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeAlbumRadio import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem @@ -208,7 +209,7 @@ fun HomeScreen( ) } }, - isPlaying = mediaMetadata?.id == album.id, + isPlaying = mediaMetadata?.album?.id == album.id, playWhenReady = playWhenReady, modifier = Modifier .combinedClickable( @@ -249,10 +250,7 @@ fun HomeScreen( playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) } else { val album = newReleaseAlbums.random() - playerConnection.playQueue(YouTubeQueue(WatchEndpoint( - playlistId = album.playlistId, - params = "wAEB" - ))) + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) } }) { Icon( 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 ecc03e684..7788a10d7 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AlbumViewModel.kt @@ -28,7 +28,7 @@ class AlbumViewModel @Inject constructor( viewModelScope.launch { _viewState.value = database.albumWithSongs(albumId).first()?.let { AlbumViewState.Local(it) - } ?: YouTube.browseAlbum(albumId).getOrNull()?.let { + } ?: YouTube.album(albumId).getOrNull()?.let { AlbumViewState.Remote(it) } } 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 43f3354c0..2434d4c06 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt @@ -24,7 +24,7 @@ class ArtistItemsViewModel @Inject constructor( init { viewModelScope.launch { - val artistItemsPage = YouTube.browseArtistItems(BrowseEndpoint( + val artistItemsPage = YouTube.artistItems(BrowseEndpoint( browseId = browseId, params = params )).getOrNull() ?: return@launch @@ -40,7 +40,7 @@ class ArtistItemsViewModel @Inject constructor( viewModelScope.launch { val oldItemsPage = itemsPage.value ?: return@launch val continuation = oldItemsPage.continuation ?: return@launch - val artistItemsContinuationPage = YouTube.browseArtistItemsContinuation(continuation).getOrNull() ?: return@launch + val artistItemsContinuationPage = YouTube.artistItemsContinuation(continuation).getOrNull() ?: return@launch itemsPage.update { ItemsPage( items = (oldItemsPage.items + artistItemsContinuationPage.items).distinctBy { it.id }, 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 377b40841..bc16429d1 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -21,7 +21,7 @@ class ArtistViewModel @Inject constructor( init { viewModelScope.launch { - artistPage = YouTube.browseArtist(artistId).getOrNull() + artistPage = YouTube.artist(artistId).getOrNull() } } } 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 96e073a43..743e5881d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -59,7 +59,7 @@ class LibraryArtistsViewModel @Inject constructor( it.thumbnailUrl == null || Duration.between(it.lastUpdateTime, LocalDateTime.now()) > Duration.ofDays(10) } .forEach { artist -> - YouTube.browseArtist(artist.id).onSuccess { artistPage -> + YouTube.artist(artist.id).onSuccess { artistPage -> database.query { update(artist, artistPage) } 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 98bc67864..dc1059c21 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -23,7 +23,7 @@ class OnlinePlaylistViewModel @Inject constructor( init { viewModelScope.launch { - val playlistPage = YouTube.browsePlaylist(playlistId).getOrNull() ?: return@launch + val playlistPage = YouTube.playlist(playlistId).getOrNull() ?: return@launch playlist.value = playlistPage.playlist itemsPage.value = ItemsPage(playlistPage.songs, playlistPage.songsContinuation) } @@ -33,7 +33,7 @@ class OnlinePlaylistViewModel @Inject constructor( viewModelScope.launch { val oldItemsPage = itemsPage.value ?: return@launch val continuation = oldItemsPage.continuation ?: return@launch - val playlistContinuationPage = YouTube.browsePlaylistContinuation(continuation).getOrNull() ?: return@launch + val playlistContinuationPage = YouTube.playlistContinuation(continuation).getOrNull() ?: return@launch itemsPage.update { ItemsPage( items = (oldItemsPage.items + playlistContinuationPage.songs).distinctBy { it.id }, diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt index 931487476..039d36be9 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchSuggestionViewModel.kt @@ -31,7 +31,7 @@ class OnlineSearchSuggestionViewModel @Inject constructor( ) } } else { - val result = YouTube.getSearchSuggestions(query).getOrNull() + val result = YouTube.searchSuggestions(query).getOrNull() database.searchHistory(query) .map { it.take(3) } .map { history -> diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 8b3e7032d..fcb5ee44f 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -40,7 +40,7 @@ object YouTube { innerTube.proxy = value } - suspend fun getSearchSuggestions(query: String): Result = runCatching { + suspend fun searchSuggestions(query: String): Result = runCatching { val response = innerTube.getSearchSuggestions(WEB_REMIX, query).body() SearchSuggestions( queries = response.contents?.getOrNull(0)?.searchSuggestionsSectionRenderer?.contents?.mapNotNull { content -> @@ -95,10 +95,9 @@ object YouTube { innerTube.player(ANDROID_MUSIC, videoId, playlistId).body() } - suspend fun browseAlbum(browseId: String): Result = runCatching { + suspend fun album(browseId: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId).body() val playlistId = response.microformat?.microformatDataRenderer?.urlCanonical?.substringAfterLast('=')!! - val audioPlaylistResponse = innerTube.browse(WEB_REMIX, "VL$playlistId").body() AlbumPage( album = AlbumItem( browseId = browseId, @@ -113,16 +112,21 @@ object YouTube { year = response.header.musicDetailHeaderRenderer.subtitle.runs.lastOrNull()?.text?.toIntOrNull(), thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.croppedSquareThumbnailRenderer?.getThumbnailUrl()!! ), - songs = audioPlaylistResponse.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() - ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() - ?.musicPlaylistShelfRenderer?.contents - ?.mapNotNull { - AlbumPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) - }!! + songs = albumSongs(playlistId).getOrThrow() ) } - suspend fun browseArtist(browseId: String): Result = runCatching { + suspend fun albumSongs(playlistId: String): Result> = runCatching { + val response = innerTube.browse(WEB_REMIX, "VL$playlistId").body() + response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.musicPlaylistShelfRenderer?.contents + ?.mapNotNull { + AlbumPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!! + } + + suspend fun artist(browseId: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId).body() ArtistPage( artist = ArtistItem( @@ -141,7 +145,7 @@ object YouTube { ) } - suspend fun browseArtistItems(endpoint: BrowseEndpoint): Result = runCatching { + suspend fun artistItems(endpoint: BrowseEndpoint): Result = runCatching { val response = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params).body() val gridRenderer = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull() ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() @@ -171,7 +175,7 @@ object YouTube { } } - suspend fun browseArtistItemsContinuation(continuation: String): Result = runCatching { + suspend fun artistItemsContinuation(continuation: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() ArtistItemsContinuationPage( items = response.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { @@ -181,7 +185,7 @@ object YouTube { ) } - suspend fun browsePlaylist(playlistId: String) = runCatching { + suspend fun playlist(playlistId: String) = runCatching { val response = innerTube.browse(WEB_REMIX, "VL$playlistId").body() PlaylistPage( playlist = PlaylistItem( @@ -214,6 +218,17 @@ object YouTube { ) } + suspend fun playlistContinuation(continuation: String) = runCatching { + val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() + PlaylistContinuationPage( + songs = response.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { + PlaylistPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) + }!!, + continuation = response.continuationContents.musicPlaylistShelfContinuation.continuations?.getContinuation() + ) + } + + suspend fun newReleaseAlbumsPreview(): Result> = runCatching { val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_explore").body() response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.getOrNull(1)?.musicCarouselShelfRenderer?.contents?.mapNotNull { @@ -231,17 +246,6 @@ object YouTube { } }.orEmpty() } - - suspend fun browsePlaylistContinuation(continuation: String) = runCatching { - val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() - PlaylistContinuationPage( - songs = response.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { - PlaylistPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) - }!!, - continuation = response.continuationContents.musicPlaylistShelfContinuation.continuations?.getContinuation() - ) - } - suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { val response = innerTube.next(WEB_REMIX, endpoint.videoId, endpoint.playlistId, endpoint.playlistSetVideoId, endpoint.index, endpoint.params, continuation).body() val playlistPanelRenderer = response.continuationContents?.playlistPanelContinuation @@ -276,12 +280,12 @@ object YouTube { ) } - suspend fun getLyrics(endpoint: BrowseEndpoint): Result = runCatching { + suspend fun lyrics(endpoint: BrowseEndpoint): Result = runCatching { val response = innerTube.browse(WEB_REMIX, endpoint.browseId, endpoint.params).body() response.contents?.sectionListRenderer?.contents?.firstOrNull()?.musicDescriptionShelfRenderer?.description?.runs?.firstOrNull()?.text } - suspend fun getQueue(videoIds: List? = null, playlistId: String? = null): Result> = runCatching { + suspend fun queue(videoIds: List? = null, playlistId: String? = null): Result> = runCatching { if (videoIds != null) { assert(videoIds.size <= MAX_GET_QUEUE_SIZE) // Max video limit } @@ -293,7 +297,7 @@ object YouTube { } } - suspend fun generateVisitorData(): Result = runCatching { + suspend fun visitorData(): Result = runCatching { Json.parseToJsonElement(innerTube.getSwJsData().bodyAsText().substring(5)) .jsonArray[0] .jsonArray[2] @@ -301,7 +305,7 @@ object YouTube { .jsonPrimitive.content } - suspend fun getAccountInfo(): Result = runCatching { + suspend fun accountInfo(): Result = runCatching { innerTube.accountMenu(WEB_REMIX).body().actions[0].openPopupAction.popup.multiPageMenuRenderer.header?.activeAccountHeaderRenderer?.toAccountInfo() } diff --git a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt index c09868398..6b6b1ef4d 100644 --- a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt +++ b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt @@ -81,19 +81,19 @@ class YouTubeTest { @Test fun `Check 'get_search_suggestion' endpoint`() = runBlocking { - val suggestions = youTube.getSearchSuggestions(SEARCH_QUERY).getOrThrow() + val suggestions = youTube.searchSuggestions(SEARCH_QUERY).getOrThrow() assertTrue(suggestions.queries.isNotEmpty()) } @Test fun `Check 'browse' endpoint`() = runBlocking { - var artist = youTube.browseArtist("UCI6B8NkZKqlFWoiC_xE-hzA").getOrThrow() + var artist = youTube.artist("UCI6B8NkZKqlFWoiC_xE-hzA").getOrThrow() assertTrue(artist.sections.isNotEmpty()) - artist = youTube.browseArtist("UCy2RKLxIOMOfGld_yBYEBLw").getOrThrow() // Artist that contains audiobook + artist = youTube.artist("UCy2RKLxIOMOfGld_yBYEBLw").getOrThrow() // Artist that contains audiobook assertTrue(artist.sections.isNotEmpty()) - val album = youTube.browseAlbum("MPREb_oNAdr9eUOfS").getOrThrow() + val album = youTube.album("MPREb_oNAdr9eUOfS").getOrThrow() assertTrue(album.songs.isNotEmpty()) - val playlist = youTube.browsePlaylist("RDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo").getOrThrow() + val playlist = youTube.playlist("RDCLAK5uy_mHAEb33pqvgdtuxsemicZNu-5w6rLRweo").getOrThrow() assertTrue(playlist.songs.isNotEmpty()) } @@ -125,9 +125,9 @@ class YouTubeTest { @Test fun `Check 'get_queue' endpoint`() = runBlocking { - var queue = youTube.getQueue(videoIds = VIDEO_IDS).getOrThrow() + var queue = youTube.queue(videoIds = VIDEO_IDS).getOrThrow() assertTrue(queue.isNotEmpty()) - queue = youTube.getQueue(playlistId = PLAYLIST_ID).getOrThrow() + queue = youTube.queue(playlistId = PLAYLIST_ID).getOrThrow() assertTrue(queue.isNotEmpty()) } @@ -136,7 +136,7 @@ class YouTubeTest { // This playlist has 2900 songs val playlistId = "PLtAw-mgfCzRwduBTjBHknz5U4_ZM4n6qm" var count = 5 - val playlistPage = YouTube.browsePlaylist(playlistId).getOrThrow() + val playlistPage = YouTube.playlist(playlistId).getOrThrow() var songs = playlistPage.songs var continuation = playlistPage.songsContinuation while (count > 0) { @@ -144,7 +144,7 @@ class YouTubeTest { println(it.id) } if (continuation == null) break - val continuationPage = YouTube.browsePlaylistContinuation(continuation).getOrThrow() + val continuationPage = YouTube.playlistContinuation(continuation).getOrThrow() songs = continuationPage.songs continuation = continuationPage.continuation count-- @@ -154,7 +154,7 @@ class YouTubeTest { @Test fun lyrics() = runBlocking { val nextResult = YouTube.next(WatchEndpoint(videoId = "NCC6lI0GGy0")).getOrThrow() - val lyrics = YouTube.getLyrics(nextResult.lyricsEndpoint!!).getOrThrow() + val lyrics = YouTube.lyrics(nextResult.lyricsEndpoint!!).getOrThrow() assertTrue(lyrics != null) } From a4d15f0832f62b6afb7a46417e2a153fc8ffc35e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 25 Jan 2023 14:13:11 +0800 Subject: [PATCH 139/323] Fix #439 --- innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt index 99e79f68d..72362e3ca 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt @@ -19,7 +19,7 @@ fun String.parseTime(): Int? { return parts[0] * 60 + parts[1] } if (parts.size == 3) { - return parts[0] * 1440 + parts[1] * 60 + parts[2] + return parts[0] * 3600 + parts[1] * 60 + parts[2] } } catch (e: Exception) { return null From e05ff3cdcc27a32bb9f99d2afb9dfc0816c142c2 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 25 Jan 2023 17:02:57 +0800 Subject: [PATCH 140/323] Support channel-like artists --- .../com/zionhuang/music/db/DatabaseDao.kt | 4 + .../com/zionhuang/music/ui/component/Items.kt | 3 +- .../ui/screens/artist/ArtistItemsScreen.kt | 145 ++--- .../music/ui/screens/artist/ArtistScreen.kt | 590 +++++++++++------- .../music/viewmodels/ArtistViewModel.kt | 6 + .../java/com/zionhuang/innertube/YouTube.kt | 2 +- .../models/response/BrowseResponse.kt | 4 +- .../innertube/pages/ArtistItemsPage.kt | 68 +- 8 files changed, 488 insertions(+), 334 deletions(-) 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 9f96849fb..954eca7cf 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -99,6 +99,10 @@ interface DatabaseDao { ArtistSongSortType.PLAY_TIME -> artistSongsByPlayTimeDesc(artistId) }.map { it.reversed(!descending) } + @Transaction + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId LIMIT :previewSize") + fun artistSongsPreview(artistId: String, previewSize: Int = 3): Flow> + @Transaction @Query("SELECT * FROM song WHERE id = :songId") fun song(songId: String?): Flow 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 6970dbe6b..7a894f522 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 @@ -458,6 +458,7 @@ inline fun YouTubeGridItem( AsyncImage( model = item.thumbnail, contentDescription = null, + contentScale = ContentScale.Crop, modifier = Modifier.fillMaxSize() ) @@ -518,7 +519,7 @@ inline fun YouTubeGridItem( text = subtitle, style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.secondary, - maxLines = 1, + maxLines = 2, overflow = TextOverflow.Ellipsis, ) } 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 868811bc4..21211ab8a 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 @@ -34,7 +34,6 @@ import com.zionhuang.music.ui.component.AppBarConfig import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.YouTubeGridItem import com.zionhuang.music.ui.component.YouTubeListItem -import com.zionhuang.music.ui.component.shimmer.GridItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ShimmerHost import com.zionhuang.music.ui.menu.YouTubeAlbumMenu @@ -88,18 +87,37 @@ fun ArtistItemsScreen( } } - if (itemsPage?.items?.firstOrNull() is AlbumItem) { - LazyVerticalGrid( - columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + if (itemsPage == null) { + ShimmerHost( + modifier = Modifier.padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + ) { + repeat(8) { + ListItemPlaceHolder() + } + } + } + + if (itemsPage?.items?.firstOrNull() is SongItem) { + LazyColumn( + state = lazyListState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { items( items = itemsPage?.items.orEmpty(), key = { it.id } ) { item -> - YouTubeGridItem( + YouTubeListItem( item = item, badges = { + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } if (item is SongItem && item.id in librarySongIds || item is AlbumItem && item.id in libraryAlbumIds || item is PlaylistItem && item.id in libraryPlaylistIds @@ -122,15 +140,6 @@ fun ArtistItemsScreen( .padding(end = 2.dp) ) } - if (item.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } }, isPlaying = when (item) { is SongItem -> mediaMetadata?.id == item.id @@ -138,18 +147,9 @@ fun ArtistItemsScreen( else -> false }, playWhenReady = playWhenReady, - fillMaxWidth = true, - modifier = Modifier - .combinedClickable( + trailingContent = { + IconButton( onClick = { - when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") - } - }, - onLongClick = { menuState.show { when (item) { is SongItem -> YouTubeSongMenu( @@ -175,41 +175,47 @@ fun ArtistItemsScreen( } } } - ) + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("playlist/${item.id}") + } + } ) } if (itemsPage?.continuation != null) { item(key = "loading") { ShimmerHost { - repeat(4) { - GridItemPlaceHolder() + repeat(3) { + ListItemPlaceHolder() } } } } } } else { - LazyColumn( - state = lazyListState, + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { items( items = itemsPage?.items.orEmpty(), key = { it.id } ) { item -> - YouTubeListItem( + YouTubeGridItem( item = item, badges = { - if (item.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } if (item is SongItem && item.id in librarySongIds || item is AlbumItem && item.id in libraryAlbumIds || item is PlaylistItem && item.id in libraryPlaylistIds @@ -232,6 +238,15 @@ fun ArtistItemsScreen( .padding(end = 2.dp) ) } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, isPlaying = when (item) { is SongItem -> mediaMetadata?.id == item.id @@ -239,9 +254,18 @@ fun ArtistItemsScreen( else -> false }, playWhenReady = playWhenReady, - trailingContent = { - IconButton( + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( onClick = { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("playlist/${item.id}") + } + }, + onLongClick = { menuState.show { when (item) { is SongItem -> YouTubeSongMenu( @@ -267,44 +291,9 @@ fun ArtistItemsScreen( } } } - ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .clickable { - when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") - } - } + ) ) } - - if (itemsPage == null) { - item { - ShimmerHost { - repeat(8) { - ListItemPlaceHolder() - } - } - } - } - - if (itemsPage?.continuation != null) { - item(key = "loading") { - ShimmerHost { - repeat(3) { - ListItemPlaceHolder() - } - } - } - } } } } 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 5939ce343..97634dcce 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 @@ -34,6 +34,7 @@ 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.SongMenu import com.zionhuang.music.ui.menu.YouTubeAlbumMenu import com.zionhuang.music.ui.menu.YouTubeArtistMenu import com.zionhuang.music.ui.menu.YouTubeSongMenu @@ -62,6 +63,7 @@ fun ArtistScreen( val libraryPlaylistIds by mainViewModel.libraryPlaylistIds.collectAsState() val artistPage = viewModel.artistPage + val librarySongs by viewModel.librarySongs.collectAsState() val lazyListState = rememberLazyListState() @@ -85,161 +87,175 @@ fun ArtistScreen( } } - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) - .asPaddingValues() - ) { - artistPage.let { - if (artistPage != null) { - item(key = "header") { - Column { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3) + if (viewModel.artistId.startsWith("LA")) { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = librarySongs, + key = { it.id } + ) { song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } ) { - AsyncImage( - model = artistPage.artist.thumbnail.resize(1200, 900), - contentDescription = null, - modifier = Modifier.fadingEdge( - top = WindowInsets.systemBars.asPaddingValues().calculateTopPadding() + AppBarHeight, - bottom = 64.dp - ) - ) - AutoResizeText( - text = artistPage.artist.title, - style = MaterialTheme.typography.displayLarge, - fontSizeRange = FontSizeRange(36.sp, 58.sp), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 48.dp) + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null ) } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) + } + } + } else { + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) + .asPaddingValues() + ) { + artistPage.let { + if (artistPage != null) { + item(key = "header") { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + AsyncImage( + model = artistPage.artist.thumbnail.resize(1200, 900), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 64.dp + ) + ) + AutoResizeText( + text = artistPage.artist.title, + style = MaterialTheme.typography.displayLarge, + fontSizeRange = FontSizeRange(32.sp, 58.sp), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(12.dp) - ) { - artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> - Button( - onClick = { - playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.btn_shuffle) - ) + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.btn_shuffle) + ) + } } - } - artistPage.artist.radioEndpoint?.let { radioEndpoint -> - OutlinedButton( - onClick = { - playerConnection.playQueue(YouTubeQueue(radioEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_radio), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_radio)) + artistPage.artist.radioEndpoint?.let { radioEndpoint -> + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_radio)) + } } } } } - } - artistPage.sections.fastForEach { section -> - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = section.moreEndpoint != null) { - navController.navigate("artistItems/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") - } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) + if (librarySongs.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + + } + .padding(12.dp) ) { - Text( - text = section.title, - style = MaterialTheme.typography.headlineMedium - ) - } - if (section.moreEndpoint != null) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.header_from_your_library), + style = MaterialTheme.typography.headlineMedium + ) + } Icon( painter = painterResource(R.drawable.ic_navigate_next), contentDescription = null ) } } - } - if ((section.items.firstOrNull() as? SongItem)?.album != null) { items( - items = section.items, - key = { it.id } + items = librarySongs, + key = { "local_${it.id}" } ) { song -> - YouTubeListItem( - item = song as SongItem, - badges = { - if (song.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (song.id in librarySongIds) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (song.id in likedSongIds) { - Icon( - painter = painterResource(R.drawable.ic_favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = mediaMetadata?.id == song.id, + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, playWhenReady = playWhenReady, trailingContent = { IconButton( onClick = { menuState.show { - YouTubeSongMenu( - song = song, + SongMenu( + originalSong = song, navController = navController, playerConnection = playerConnection, coroutineScope = coroutineScope, @@ -255,134 +271,232 @@ fun ArtistScreen( } }, modifier = Modifier - .clickable { + .fillMaxWidth() + .combinedClickable { playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) } .animateItemPlacement() ) } - } else { + } + + artistPage.sections.fastForEach { section -> item { - LazyRow { - items( - items = section.items, - key = { it.id } - ) { item -> - YouTubeGridItem( - item = item, - badges = { - if (item is SongItem && item.id in librarySongIds || - item is AlbumItem && item.id in libraryAlbumIds || - item is PlaylistItem && item.id in libraryPlaylistIds - ) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (item is SongItem && item.id in likedSongIds) { - Icon( - painter = painterResource(R.drawable.ic_favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = section.moreEndpoint != null) { + navController.navigate("artistItems/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = section.title, + style = MaterialTheme.typography.headlineMedium + ) + } + if (section.moreEndpoint != null) { + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null + ) + } + } + } + + if ((section.items.firstOrNull() as? SongItem)?.album != null) { + items( + items = section.items, + key = { it.id } + ) { song -> + YouTubeListItem( + item = song as SongItem, + badges = { + if (song.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in librarySongIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == song.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } } - }, - isPlaying = when (item) { - is SongItem -> mediaMetadata?.id == item.id - is AlbumItem -> mediaMetadata?.album?.id == item.id - else -> false - }, - playWhenReady = playWhenReady, - modifier = Modifier - .combinedClickable( - onClick = { - when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") - } - }, - onLongClick = { - menuState.show { + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) + } + } else { + item { + LazyRow { + items( + items = section.items, + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + badges = { + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, + modifier = Modifier + .combinedClickable( + onClick = { when (item) { - is SongItem -> YouTubeSongMenu( - song = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is AlbumItem -> YouTubeAlbumMenu( - album = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is ArtistItem -> YouTubeArtistMenu( - artist = item, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) - is PlaylistItem -> {} + is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("playlist/${item.id}") + } + }, + onLongClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> {} + } } } - } - ) - .animateItemPlacement() - ) + ) + .animateItemPlacement() + ) + } } } } } - } - } else { - item(key = "shimmer") { - ShimmerHost { - Box( - modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3) - ) { - Spacer( - modifier = Modifier - .shimmer() - .background(MaterialTheme.colorScheme.onSurface) - .fadingEdge( - top = WindowInsets.systemBars - .asPaddingValues() - .calculateTopPadding() + AppBarHeight, - bottom = 108.dp - ) - ) - TextPlaceholder( - height = 56.dp, + } else { + item(key = "shimmer") { + ShimmerHost { + Box( modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 48.dp) - ) - } + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + Spacer( + modifier = Modifier + .shimmer() + .background(MaterialTheme.colorScheme.onSurface) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 108.dp + ) + ) + TextPlaceholder( + height = 56.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } - Row( - modifier = Modifier.padding(12.dp) - ) { - ButtonPlaceholder(Modifier.weight(1f)) + Row( + modifier = Modifier.padding(12.dp) + ) { + ButtonPlaceholder(Modifier.weight(1f)) - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(12.dp)) - ButtonPlaceholder(Modifier.weight(1f)) - } + ButtonPlaceholder(Modifier.weight(1f)) + } - repeat(6) { - ListItemPlaceHolder() + repeat(6) { + ListItemPlaceHolder() + } } } } 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 bc16429d1..e6bc34046 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -8,16 +8,22 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.pages.ArtistPage +import com.zionhuang.music.db.MusicDatabase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class ArtistViewModel @Inject constructor( + database: MusicDatabase, savedStateHandle: SavedStateHandle, ) : ViewModel() { val artistId = savedStateHandle.get("artistId")!! var artistPage by mutableStateOf(null) + val librarySongs = database.artistSongsPreview(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { viewModelScope.launch { diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index fcb5ee44f..486d33a4a 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -134,7 +134,7 @@ object YouTube { title = response.header?.musicImmersiveHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: response.header?.musicVisualHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, thumbnail = response.header?.musicImmersiveHeaderRenderer?.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() - ?: response.header?.musicVisualHeaderRenderer?.foregroundThumbnail?.getThumbnailUrl()!!, + ?: response.header?.musicVisualHeaderRenderer?.foregroundThumbnail?.musicThumbnailRenderer?.getThumbnailUrl()!!, shuffleEndpoint = response.header?.musicImmersiveHeaderRenderer?.playButton?.buttonRenderer?.navigationEndpoint?.watchEndpoint, radioEndpoint = response.header?.musicImmersiveHeaderRenderer?.startRadioButton?.buttonRenderer?.navigationEndpoint?.watchEndpoint ), diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt index 2d28dcc54..bf9380546 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/BrowseResponse.kt @@ -65,8 +65,8 @@ data class BrowseResponse( @Serializable data class MusicVisualHeaderRenderer( val title: Runs, - val foregroundThumbnail: ThumbnailRenderer.MusicThumbnailRenderer, - val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer?, + val foregroundThumbnail: ThumbnailRenderer, + val thumbnail: ThumbnailRenderer?, ) @Serializable diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt index 04f4b983e..bb4866d66 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt @@ -39,20 +39,60 @@ data class ArtistItemsPage( ) } - fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): AlbumItem? { - return AlbumItem( - browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, - playlistId = renderer.thumbnailOverlay?.musicItemThumbnailOverlayRenderer - ?.content?.musicPlayButtonRenderer?.playNavigationEndpoint - ?.watchPlaylistEndpoint?.playlistId ?: return null, - title = renderer.title.runs?.firstOrNull()?.text ?: return null, - artists = null, - year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), - thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, - explicit = renderer.subtitleBadges?.find { - it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" - } != null - ) + fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): YTItem? { + return when { + renderer.isAlbum -> AlbumItem( + browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + playlistId = renderer.thumbnailOverlay?.musicItemThumbnailOverlayRenderer + ?.content?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = null, + year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.subtitleBadges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + // Video + renderer.isSong -> SongItem( + id = renderer.navigationEndpoint.watchEndpoint?.videoId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = renderer.subtitle.runs?.splitBySeparator()?.firstOrNull()?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = null, + duration = null, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + endpoint = renderer.navigationEndpoint.watchEndpoint + ) + renderer.isPlaylist -> PlaylistItem( + id = renderer.navigationEndpoint.browseEndpoint?.browseId?.removePrefix("VL") ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + author = renderer.subtitle.runs?.getOrNull(2)?.let { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + songCountText = renderer.subtitle.runs.getOrNull(4)?.text, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + playEndpoint = renderer.thumbnailOverlay + ?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint ?: return null, + shuffleEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MIX" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + else -> null + } } } } From a632f125b202bdec21549934abbf3d9ad47f9db5 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 25 Jan 2023 17:51:38 +0800 Subject: [PATCH 141/323] Add artist songs screen --- .../java/com/zionhuang/music/MainActivity.kt | 26 +- .../music/constants/PreferenceKeys.kt | 4 +- .../com/zionhuang/music/db/DatabaseDao.kt | 5 - .../music/ui/component/SortHeader.kt | 93 +++ .../music/ui/screens/artist/ArtistScreen.kt | 648 ++++++++---------- .../ui/screens/artist/ArtistSongsScreen.kt | 145 ++++ .../ui/screens/library/LibraryAlbumsScreen.kt | 133 +--- .../screens/library/LibraryArtistsScreen.kt | 123 +--- .../screens/library/LibraryPlaylistsScreen.kt | 113 +-- .../ui/screens/library/LibrarySongsScreen.kt | 122 +--- .../music/viewmodels/LibraryViewModels.kt | 22 + 11 files changed, 692 insertions(+), 742 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 73e2d1884..cce504c73 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -56,6 +56,7 @@ import com.zionhuang.music.ui.player.BottomSheetPlayer import com.zionhuang.music.ui.screens.* import com.zionhuang.music.ui.screens.artist.ArtistItemsScreen import com.zionhuang.music.ui.screens.artist.ArtistScreen +import com.zionhuang.music.ui.screens.artist.ArtistSongsScreen import com.zionhuang.music.ui.screens.library.LibraryAlbumsScreen import com.zionhuang.music.ui.screens.library.LibraryArtistsScreen import com.zionhuang.music.ui.screens.library.LibraryPlaylistsScreen @@ -326,11 +327,16 @@ class MainActivity : ComponentActivity() { type = NavType.StringType } ) - ) { - ArtistScreen( - navController = navController, - appBarConfig = appBarConfig - ) + ) { backStackEntry -> + val artistId = backStackEntry.arguments?.getString("artistId")!! + if (artistId.startsWith("LA")) { + ArtistSongsScreen(navController = navController) + } else { + ArtistScreen( + navController = navController, + appBarConfig = appBarConfig + ) + } } composable( route = "artistItems/{browseId}?params={params}", @@ -349,6 +355,16 @@ class MainActivity : ComponentActivity() { appBarConfig = appBarConfig ) } + composable( + route = "artistSongs/{artistId}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { + ArtistSongsScreen(navController = navController) + } composable( route = "playlist/{playlistId}", arguments = listOf( 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 079cdb4eb..3162cdd2e 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -39,6 +39,8 @@ val AlbumSortTypeKey = stringPreferencesKey("albumSortType") val AlbumSortDescendingKey = booleanPreferencesKey("albumSortDescending") val PlaylistSortTypeKey = stringPreferencesKey("playlistSortType") val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") +val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType") +val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending") enum class SongSortType { CREATE_DATE, NAME, ARTIST @@ -49,7 +51,7 @@ enum class ArtistSortType { } enum class ArtistSongSortType { - CREATE_DATE, NAME, PLAY_TIME + CREATE_DATE, NAME } enum class AlbumSortType { 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 954eca7cf..a02a39b26 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -88,15 +88,10 @@ interface DatabaseDao { @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY title DESC") fun artistSongsByNameDesc(artistId: String): Flow> - @Transaction - @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY totalPlayTime DESC") - fun artistSongsByPlayTimeDesc(artistId: String): Flow> - fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) = when (sortType) { ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateDesc(artistId) ArtistSongSortType.NAME -> artistSongsByNameDesc(artistId) - ArtistSongSortType.PLAY_TIME -> artistSongsByPlayTimeDesc(artistId) }.map { it.reversed(!descending) } @Transaction diff --git a/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt b/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt new file mode 100644 index 000000000..66f3efc59 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/SortHeader.kt @@ -0,0 +1,93 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import com.zionhuang.music.R + +@Composable +inline fun > SortHeader( + sortType: T, + sortDescending: Boolean, + crossinline onSortTypeChange: (T) -> Unit, + crossinline onSortDescendingChange: (Boolean) -> Unit, + crossinline sortTypeText: (T) -> Int, + trailingText: String, +) { + var menuExpanded by remember { mutableStateOf(false) } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Text( + text = stringResource(sortTypeText(sortType)), + color = MaterialTheme.colorScheme.primary, + style = MaterialTheme.typography.labelLarge, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ) { + menuExpanded = !menuExpanded + } + .padding(horizontal = 4.dp, vertical = 8.dp) + ) + + DropdownMenu( + expanded = menuExpanded, + onDismissRequest = { menuExpanded = false }, + modifier = Modifier.widthIn(min = 172.dp) + ) { + enumValues().forEach { type -> + DropdownMenuItem( + text = { + Text( + text = stringResource(sortTypeText(type)), + fontSize = 16.sp, + fontWeight = FontWeight.Normal + ) + }, + trailingIcon = { + Icon( + painter = painterResource(if (sortType == type) R.drawable.ic_radio_button_checked else R.drawable.ic_radio_button_unchecked), + contentDescription = null + ) + }, + onClick = { + onSortTypeChange(type) + menuExpanded = false + } + ) + } + } + + ResizableIconButton( + icon = if (sortDescending) R.drawable.ic_arrow_downward else R.drawable.ic_arrow_upward, + color = MaterialTheme.colorScheme.primary, + modifier = Modifier + .size(32.dp) + .padding(8.dp), + onClick = { onSortDescendingChange(!sortDescending) } + ) + + Spacer(Modifier.weight(1f)) + + Text( + text = trailingText, + style = MaterialTheme.typography.titleSmall, + color = MaterialTheme.colorScheme.secondary + ) + } +} 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 97634dcce..17e138118 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 @@ -87,175 +87,229 @@ fun ArtistScreen( } } - if (viewModel.artistId.startsWith("LA")) { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() - ) { - items( - items = librarySongs, - key = { it.id } - ) { song -> - SongListItem( - song = song, - isPlaying = song.id == mediaMetadata?.id, - playWhenReady = playWhenReady, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - } - } + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current + .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) + .asPaddingValues() + ) { + artistPage.let { + if (artistPage != null) { + item(key = "header") { + Column { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null + AsyncImage( + model = artistPage.artist.thumbnail.resize(1200, 900), + contentDescription = null, + modifier = Modifier + .align(Alignment.Center) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 64.dp + ) ) - } - }, - modifier = Modifier - .fillMaxWidth() - .combinedClickable { - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) - } - .animateItemPlacement() - ) - } - } - } else { - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current - .add(WindowInsets(top = -WindowInsets.systemBars.asPaddingValues().calculateTopPadding() - AppBarHeight)) - .asPaddingValues() - ) { - artistPage.let { - if (artistPage != null) { - item(key = "header") { - Column { - Box( + AutoResizeText( + text = artistPage.artist.title, + style = MaterialTheme.typography.displayLarge, + fontSizeRange = FontSizeRange(32.sp, 58.sp), + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + textAlign = TextAlign.Center, modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3) - ) { - AsyncImage( - model = artistPage.artist.thumbnail.resize(1200, 900), - contentDescription = null, - modifier = Modifier - .align(Alignment.Center) - .fadingEdge( - top = WindowInsets.systemBars - .asPaddingValues() - .calculateTopPadding() + AppBarHeight, - bottom = 64.dp - ) - ) - AutoResizeText( - text = artistPage.artist.title, - style = MaterialTheme.typography.displayLarge, - fontSizeRange = FontSizeRange(32.sp, 58.sp), - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - textAlign = TextAlign.Center, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 48.dp) - ) - } + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier.padding(12.dp) - ) { - artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> - Button( - onClick = { - playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text( - text = stringResource(R.string.btn_shuffle) - ) - } + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + modifier = Modifier.padding(12.dp) + ) { + artistPage.artist.shuffleEndpoint?.let { shuffleEndpoint -> + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text( + text = stringResource(R.string.btn_shuffle) + ) } + } - artistPage.artist.radioEndpoint?.let { radioEndpoint -> - OutlinedButton( - onClick = { - playerConnection.playQueue(YouTubeQueue(radioEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_radio), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_radio)) - } + artistPage.artist.radioEndpoint?.let { radioEndpoint -> + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_radio)) } } } } + } - if (librarySongs.isNotEmpty()) { - item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { + if (librarySongs.isNotEmpty()) { + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable { + navController.navigate("artistSongs/${artistPage.artist.id}") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.header_from_your_library), + style = MaterialTheme.typography.headlineMedium + ) + } + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null + ) + } + } + items( + items = librarySongs, + key = { "local_${it.id}" } + ) { song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) ) { - Text( - text = stringResource(R.string.header_from_your_library), - style = MaterialTheme.typography.headlineMedium + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null ) } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) + } + } + + artistPage.sections.fastForEach { section -> + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .clickable(enabled = section.moreEndpoint != null) { + navController.navigate("artistItems/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = section.title, + style = MaterialTheme.typography.headlineMedium + ) + } + if (section.moreEndpoint != null) { Icon( painter = painterResource(R.drawable.ic_navigate_next), contentDescription = null ) } } + } + if ((section.items.firstOrNull() as? SongItem)?.album != null) { items( - items = librarySongs, - key = { "local_${it.id}" } + items = section.items, + key = { it.id } ) { song -> - SongListItem( - song = song, - isPlaying = song.id == mediaMetadata?.id, + YouTubeListItem( + item = song as SongItem, + badges = { + if (song.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in librarySongIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == song.id, playWhenReady = playWhenReady, trailingContent = { IconButton( onClick = { menuState.show { - SongMenu( - originalSong = song, + YouTubeSongMenu( + song = song, navController = navController, playerConnection = playerConnection, coroutineScope = coroutineScope, @@ -271,232 +325,134 @@ fun ArtistScreen( } }, modifier = Modifier - .fillMaxWidth() - .combinedClickable { + .clickable { playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) } .animateItemPlacement() ) } - } - - artistPage.sections.fastForEach { section -> + } else { item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = section.moreEndpoint != null) { - navController.navigate("artistItems/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") - } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = section.title, - style = MaterialTheme.typography.headlineMedium - ) - } - if (section.moreEndpoint != null) { - Icon( - painter = painterResource(R.drawable.ic_navigate_next), - contentDescription = null - ) - } - } - } - - if ((section.items.firstOrNull() as? SongItem)?.album != null) { - items( - items = section.items, - key = { it.id } - ) { song -> - YouTubeListItem( - item = song as SongItem, - badges = { - if (song.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (song.id in librarySongIds) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (song.id in likedSongIds) { - Icon( - painter = painterResource(R.drawable.ic_favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = mediaMetadata?.id == song.id, - playWhenReady = playWhenReady, - trailingContent = { - IconButton( - onClick = { - menuState.show { - YouTubeSongMenu( - song = song, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - } + LazyRow { + items( + items = section.items, + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + badges = { + if (item is SongItem && item.id in librarySongIds || + item is AlbumItem && item.id in libraryAlbumIds || + item is PlaylistItem && item.id in libraryPlaylistIds + ) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .clickable { - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) - } - .animateItemPlacement() - ) - } - } else { - item { - LazyRow { - items( - items = section.items, - key = { it.id } - ) { item -> - YouTubeGridItem( - item = item, - badges = { - if (item is SongItem && item.id in librarySongIds || - item is AlbumItem && item.id in libraryAlbumIds || - item is PlaylistItem && item.id in libraryPlaylistIds - ) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (item is SongItem && item.id in likedSongIds) { - Icon( - painter = painterResource(R.drawable.ic_favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = when (item) { - is SongItem -> mediaMetadata?.id == item.id - is AlbumItem -> mediaMetadata?.album?.id == item.id - else -> false - }, - playWhenReady = playWhenReady, - modifier = Modifier - .combinedClickable( - onClick = { + if (item is SongItem && item.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + playWhenReady = playWhenReady, + modifier = Modifier + .combinedClickable( + onClick = { + when (item) { + is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) + is AlbumItem -> navController.navigate("album/${item.id}") + is ArtistItem -> navController.navigate("artist/${item.id}") + is PlaylistItem -> navController.navigate("playlist/${item.id}") + } + }, + onLongClick = { + menuState.show { when (item) { - is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) - is AlbumItem -> navController.navigate("album/${item.id}") - is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") - } - }, - onLongClick = { - menuState.show { - when (item) { - is SongItem -> YouTubeSongMenu( - song = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is AlbumItem -> YouTubeAlbumMenu( - album = item, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - is ArtistItem -> YouTubeArtistMenu( - artist = item, - playerConnection = playerConnection, - onDismiss = menuState::dismiss - ) - is PlaylistItem -> {} - } + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + is PlaylistItem -> {} } } - ) - .animateItemPlacement() - ) - } + } + ) + .animateItemPlacement() + ) } } } } - } else { - item(key = "shimmer") { - ShimmerHost { - Box( + } + } else { + item(key = "shimmer") { + ShimmerHost { + Box( + modifier = Modifier + .fillMaxWidth() + .aspectRatio(4f / 3) + ) { + Spacer( modifier = Modifier - .fillMaxWidth() - .aspectRatio(4f / 3) - ) { - Spacer( - modifier = Modifier - .shimmer() - .background(MaterialTheme.colorScheme.onSurface) - .fadingEdge( - top = WindowInsets.systemBars - .asPaddingValues() - .calculateTopPadding() + AppBarHeight, - bottom = 108.dp - ) - ) - TextPlaceholder( - height = 56.dp, - modifier = Modifier - .align(Alignment.BottomCenter) - .padding(horizontal = 48.dp) - ) - } + .shimmer() + .background(MaterialTheme.colorScheme.onSurface) + .fadingEdge( + top = WindowInsets.systemBars + .asPaddingValues() + .calculateTopPadding() + AppBarHeight, + bottom = 108.dp + ) + ) + TextPlaceholder( + height = 56.dp, + modifier = Modifier + .align(Alignment.BottomCenter) + .padding(horizontal = 48.dp) + ) + } - Row( - modifier = Modifier.padding(12.dp) - ) { - ButtonPlaceholder(Modifier.weight(1f)) + Row( + modifier = Modifier.padding(12.dp) + ) { + ButtonPlaceholder(Modifier.weight(1f)) - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(12.dp)) - ButtonPlaceholder(Modifier.weight(1f)) - } + ButtonPlaceholder(Modifier.weight(1f)) + } - repeat(6) { - ListItemPlaceHolder() - } + repeat(6) { + ListItemPlaceHolder() } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt new file mode 100644 index 000000000..7ed8575d7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt @@ -0,0 +1,145 @@ +package com.zionhuang.music.ui.screens.artist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.unit.dp +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.* +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.playback.queues.ListQueue +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.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference +import com.zionhuang.music.viewmodels.ArtistSongsViewModel + +@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun ArtistSongsScreen( + navController: NavController, + viewModel: ArtistSongsViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSongSortTypeKey, ArtistSongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSongSortDescendingKey, true) + + val artist by viewModel.artist.collectAsState() + val songs by viewModel.songs.collectAsState() + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item( + key = "header", + contentType = CONTENT_TYPE_HEADER + ) { + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSongSortType.NAME -> R.string.sort_by_name + } + }, + trailingText = pluralStringResource(R.plurals.song_count, songs.size, songs.size) + ) + } + + itemsIndexed( + items = songs, + key = { _, item -> item.id }, + contentType = { _, _ -> CONTENT_TYPE_SONG } + ) { index, song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + )) + } + .animateItemPlacement() + ) + } + } + + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues()) + .padding(16.dp), + onClick = { + playerConnection.playQueue(ListQueue( + title = artist?.name, + items = songs.shuffled().map { it.toMediaItem() }, + )) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null + ) + } + } +} \ No newline at end of file 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 f9a424ceb..8b3e0eee2 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 @@ -1,24 +1,19 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +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.items -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.ExperimentalComposeUiApi 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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -26,12 +21,12 @@ 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.ResizableIconButton +import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryAlbumsViewModel -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun LibraryAlbumsScreen( navController: NavController, @@ -41,6 +36,9 @@ fun LibraryAlbumsScreen( val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val (sortType, onSortTypeChange) = rememberEnumPreference(AlbumSortTypeKey, AlbumSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(AlbumSortDescendingKey, true) + val albums by viewModel.allAlbums.collectAsState() Box( @@ -53,7 +51,23 @@ fun LibraryAlbumsScreen( key = "header", contentType = CONTENT_TYPE_HEADER ) { - AlbumHeader(itemCount = albums.size) + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date + AlbumSortType.NAME -> R.string.sort_by_name + AlbumSortType.ARTIST -> R.string.sort_by_artist + AlbumSortType.YEAR -> R.string.sort_by_year + AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count + AlbumSortType.LENGTH -> R.string.sort_by_length + } + }, + trailingText = pluralStringResource(R.plurals.album_count, albums.size, albums.size) + ) } items( @@ -76,92 +90,3 @@ fun LibraryAlbumsScreen( } } } - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun AlbumHeader( - itemCount: Int, -) { - var sortType by rememberEnumPreference(AlbumSortTypeKey, AlbumSortType.CREATE_DATE) - var sortDescending by rememberPreference(AlbumSortDescendingKey, true) - var menuExpanded by remember { mutableStateOf(false) } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(when (sortType) { - AlbumSortType.CREATE_DATE -> R.string.sort_by_create_date - AlbumSortType.NAME -> R.string.sort_by_name - AlbumSortType.ARTIST -> R.string.sort_by_artist - AlbumSortType.YEAR -> R.string.sort_by_year - AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count - AlbumSortType.LENGTH -> R.string.sort_by_length - }), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ) { - menuExpanded = !menuExpanded - } - .padding(horizontal = 4.dp, vertical = 8.dp) - ) - - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - modifier = Modifier.widthIn(min = 172.dp) - ) { - listOf( - AlbumSortType.CREATE_DATE to R.string.sort_by_create_date, - AlbumSortType.NAME to R.string.sort_by_name, - AlbumSortType.ARTIST to R.string.sort_by_artist, - AlbumSortType.YEAR to R.string.sort_by_year, - AlbumSortType.SONG_COUNT to R.string.sort_by_song_count, - AlbumSortType.LENGTH to R.string.sort_by_length, - ).forEach { (type, text) -> - DropdownMenuItem( - text = { - Text( - text = stringResource(text), - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ) - }, - trailingIcon = { - Icon( - painter = painterResource(if (sortType == type) R.drawable.ic_radio_button_checked else R.drawable.ic_radio_button_unchecked), - contentDescription = null - ) - }, - onClick = { - sortType = type - menuExpanded = false - } - ) - } - } - - ResizableIconButton( - icon = if (sortDescending) R.drawable.ic_arrow_downward else R.drawable.ic_arrow_upward, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(32.dp) - .padding(8.dp), - onClick = { sortDescending = !sortDescending } - ) - - Spacer(Modifier.weight(1f)) - - Text( - text = pluralStringResource(R.plurals.album_count, itemCount, itemCount), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary - ) - } -} 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 daef38e80..878857caf 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,39 +2,38 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource -import androidx.compose.foundation.layout.* +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.items -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue import androidx.compose.ui.ExperimentalComposeUiApi 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.text.font.FontWeight -import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.* import com.zionhuang.music.ui.component.ArtistListItem -import com.zionhuang.music.ui.component.ResizableIconButton +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 -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun LibraryArtistsScreen( navController: NavController, viewModel: LibraryArtistsViewModel = hiltViewModel(), ) { + val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) + val artists by viewModel.allArtists.collectAsState() Box( @@ -47,7 +46,20 @@ fun LibraryArtistsScreen( key = "header", contentType = CONTENT_TYPE_HEADER ) { - ArtistHeader(artists.size) + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date + ArtistSortType.NAME -> R.string.sort_by_name + ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count + } + }, + trailingText = pluralStringResource(R.plurals.artist_count, artists.size, artists.size) + ) } items( @@ -68,86 +80,3 @@ fun LibraryArtistsScreen( } } } - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun ArtistHeader( - itemCount: Int, -) { - var sortType by rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) - var sortDescending by rememberPreference(ArtistSortDescendingKey, true) - val (menuExpanded, onMenuExpandedChange) = remember { mutableStateOf(false) } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(when (sortType) { - ArtistSortType.CREATE_DATE -> R.string.sort_by_create_date - ArtistSortType.NAME -> R.string.sort_by_name - ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count - }), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ) { - onMenuExpandedChange(!menuExpanded) - } - .padding(horizontal = 4.dp, vertical = 8.dp) - ) - - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { onMenuExpandedChange(false) }, - modifier = Modifier.widthIn(min = 172.dp) - ) { - listOf( - ArtistSortType.CREATE_DATE to R.string.sort_by_create_date, - ArtistSortType.NAME to R.string.sort_by_name, - ArtistSortType.SONG_COUNT to R.string.sort_by_song_count - ).forEach { (type, text) -> - DropdownMenuItem( - text = { - Text( - text = stringResource(text), - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ) - }, - trailingIcon = { - Icon( - painter = painterResource(if (sortType == type) R.drawable.ic_radio_button_checked else R.drawable.ic_radio_button_unchecked), - contentDescription = null - ) - }, - onClick = { - sortType = type - onMenuExpandedChange(false) - } - ) - } - } - - ResizableIconButton( - icon = if (sortDescending) R.drawable.ic_arrow_downward else R.drawable.ic_arrow_upward, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(32.dp) - .padding(8.dp), - onClick = { sortDescending = !sortDescending } - ) - - Spacer(Modifier.weight(1f)) - - Text( - text = pluralStringResource(R.plurals.artist_count, itemCount, itemCount), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary - ) - } -} 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 03869bc0a..2f6fd5aa1 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 @@ -2,12 +2,13 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.* +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -16,9 +17,7 @@ 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.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalDatabase @@ -30,7 +29,7 @@ import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYL import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID import com.zionhuang.music.ui.component.ListItem import com.zionhuang.music.ui.component.PlaylistListItem -import com.zionhuang.music.ui.component.ResizableIconButton +import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.component.TextFieldDialog import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference @@ -43,6 +42,10 @@ fun LibraryPlaylistsScreen( viewModel: LibraryPlaylistsViewModel = hiltViewModel(), ) { val database = LocalDatabase.current + + 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() val playlists by viewModel.allPlaylists.collectAsState() @@ -76,7 +79,20 @@ fun LibraryPlaylistsScreen( key = "header", contentType = CONTENT_TYPE_HEADER ) { - PlaylistHeader(itemCount = playlists.size) + SortHeader( + sortType = sortType, + sortDescending = sortDescending, + onSortTypeChange = onSortTypeChange, + onSortDescendingChange = onSortDescendingChange, + sortTypeText = { sortType -> + when (sortType) { + PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date + PlaylistSortType.NAME -> R.string.sort_by_name + PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count + } + }, + trailingText = pluralStringResource(R.plurals.playlist_count, playlists.size, playlists.size) + ) } item( @@ -157,86 +173,3 @@ fun LibraryPlaylistsScreen( } } } - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun PlaylistHeader( - itemCount: Int, -) { - var sortType by rememberEnumPreference(PlaylistSortTypeKey, PlaylistSortType.CREATE_DATE) - var sortDescending by rememberPreference(PlaylistSortDescendingKey, true) - var menuExpanded by remember { mutableStateOf(false) } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(when (sortType) { - PlaylistSortType.CREATE_DATE -> R.string.sort_by_create_date - PlaylistSortType.NAME -> R.string.sort_by_name - PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count - }), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ) { - menuExpanded = !menuExpanded - } - .padding(horizontal = 4.dp, vertical = 8.dp) - ) - - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - modifier = Modifier.widthIn(min = 172.dp) - ) { - listOf( - PlaylistSortType.CREATE_DATE to R.string.sort_by_create_date, - PlaylistSortType.NAME to R.string.sort_by_name, - PlaylistSortType.SONG_COUNT to R.string.sort_by_song_count - ).forEach { (type, text) -> - DropdownMenuItem( - text = { - Text( - text = stringResource(text), - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ) - }, - trailingIcon = { - Icon( - painter = painterResource(if (sortType == type) R.drawable.ic_radio_button_checked else R.drawable.ic_radio_button_unchecked), - contentDescription = null - ) - }, - onClick = { - sortType = type - menuExpanded = false - } - ) - } - } - - ResizableIconButton( - icon = if (sortDescending) R.drawable.ic_arrow_downward else R.drawable.ic_arrow_upward, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(32.dp) - .padding(8.dp), - onClick = { sortDescending = !sortDescending } - ) - - Spacer(Modifier.weight(1f)) - - Text( - text = pluralStringResource(R.plurals.playlist_count, itemCount, itemCount), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary - ) - } -} 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 860c6467c..d7bd44f26 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 @@ -1,25 +1,24 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi 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.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -28,13 +27,15 @@ import com.zionhuang.music.R import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.component.* +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.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibrarySongsViewModel -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun LibrarySongsScreen( navController: NavController, @@ -47,6 +48,9 @@ fun LibrarySongsScreen( val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + val items by viewModel.allSongs.collectAsState() Box( @@ -59,7 +63,20 @@ fun LibrarySongsScreen( key = "header", contentType = CONTENT_TYPE_HEADER ) { - SongHeader(itemCount = items.size) + 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 + } + }, + trailingText = pluralStringResource(R.plurals.song_count, items.size, items.size) + ) } itemsIndexed( @@ -126,86 +143,3 @@ fun LibrarySongsScreen( } } } - -@OptIn(ExperimentalComposeUiApi::class) -@Composable -fun SongHeader( - itemCount: Int, -) { - var sortType by rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) - var sortDescending by rememberPreference(SongSortDescendingKey, true) - var menuExpanded by remember { mutableStateOf(false) } - - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .padding(horizontal = 16.dp, vertical = 8.dp) - ) { - Text( - text = stringResource(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 - }), - color = MaterialTheme.colorScheme.primary, - style = MaterialTheme.typography.labelLarge, - modifier = Modifier - .clickable( - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ) { - menuExpanded = !menuExpanded - } - .padding(horizontal = 4.dp, vertical = 8.dp) - ) - - DropdownMenu( - expanded = menuExpanded, - onDismissRequest = { menuExpanded = false }, - modifier = Modifier.widthIn(min = 172.dp) - ) { - listOf( - SongSortType.CREATE_DATE to R.string.sort_by_create_date, - SongSortType.NAME to R.string.sort_by_name, - SongSortType.ARTIST to R.string.sort_by_artist - ).forEach { (type, text) -> - DropdownMenuItem( - text = { - Text( - text = stringResource(text), - fontSize = 16.sp, - fontWeight = FontWeight.Normal - ) - }, - trailingIcon = { - Icon( - painter = painterResource(if (sortType == type) R.drawable.ic_radio_button_checked else R.drawable.ic_radio_button_unchecked), - contentDescription = null - ) - }, - onClick = { - sortType = type - menuExpanded = false - } - ) - } - } - - ResizableIconButton( - icon = if (sortDescending) R.drawable.ic_arrow_downward else R.drawable.ic_arrow_upward, - color = MaterialTheme.colorScheme.primary, - modifier = Modifier - .size(32.dp) - .padding(8.dp), - onClick = { sortDescending = !sortDescending } - ) - - Spacer(Modifier.weight(1f)) - - Text( - text = pluralStringResource(R.plurals.song_count, itemCount, itemCount), - style = MaterialTheme.typography.titleSmall, - color = MaterialTheme.colorScheme.secondary - ) - } -} 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 743e5881d..5950b47e9 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -3,6 +3,7 @@ package com.zionhuang.music.viewmodels import android.content.Context +import androidx.lifecycle.SavedStateHandle import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube @@ -108,6 +109,27 @@ class LibraryPlaylistsViewModel @Inject constructor( .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } +@HiltViewModel +class ArtistSongsViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val artistId = savedStateHandle.get("artistId")!! + val artist = database.artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) + + val songs = context.dataStore.data + .map { + it[ArtistSongSortTypeKey].toEnum(ArtistSongSortType.CREATE_DATE) to (it[ArtistSongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + database.artistSongs(artistId, sortType, descending) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} + @HiltViewModel class LikedSongsViewModel @Inject constructor( @ApplicationContext context: Context, From fa982b05ac1a99a8e2d77bd53016675adb943dbc Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 25 Jan 2023 18:20:47 +0800 Subject: [PATCH 142/323] Enlarge clickable area in local search screen --- .../music/ui/screens/search/LocalSearchScreen.kt | 15 ++++++--------- 1 file changed, 6 insertions(+), 9 deletions(-) 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 c416b6e48..c1f731ace 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 @@ -91,7 +91,8 @@ fun LocalSearchScreen( modifier = Modifier .fillMaxWidth() .height(ListItemHeight) - .padding(start = 16.dp, end = 6.dp) + .clickable { viewModel.filter.value = filter } + .padding(start = 12.dp, end = 18.dp) ) { Text( text = stringResource(when (filter) { @@ -105,14 +106,10 @@ fun LocalSearchScreen( modifier = Modifier.weight(1f) ) - IconButton( - onClick = { viewModel.filter.value = filter } - ) { - Icon( - painter = painterResource(R.drawable.ic_navigate_next), - contentDescription = null - ) - } + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null + ) } } } From 62ad76dc841040146135524458e40d0d8541a8bb Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 25 Jan 2023 19:24:09 +0800 Subject: [PATCH 143/323] Fix trivial bugs --- app/src/main/java/com/zionhuang/music/MainActivity.kt | 2 +- .../main/java/com/zionhuang/music/playback/SongPlayer.kt | 7 ++----- 2 files changed, 3 insertions(+), 6 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index cce504c73..5e99c3521 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -125,7 +125,7 @@ class MainActivity : ComponentActivity() { mutableStateOf(DefaultThemeColor) } - DisposableEffect(playerConnection?.binder, isSystemInDarkTheme) { + DisposableEffect(playerConnection, isSystemInDarkTheme) { playerConnection?.onBitmapChanged = { bitmap -> coroutineScope.launch(Dispatchers.IO) { themeColor = bitmap?.extractThemeColor() ?: DefaultThemeColor diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 439e92454..a1d619b75 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -80,10 +80,7 @@ import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.utils.dataStore -import com.zionhuang.music.utils.enumPreference -import com.zionhuang.music.utils.get -import com.zionhuang.music.utils.preference +import com.zionhuang.music.utils.* import kotlinx.coroutines.* import kotlinx.coroutines.Dispatchers.IO import kotlinx.coroutines.flow.* @@ -445,7 +442,7 @@ class SongPlayer( val playedFormat = database.format(mediaId).firstOrNull() val song = database.song(mediaId).firstOrNull() if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { - // TODO + return@runBlocking dataSpec.withUri(getSongFile(context, mediaId).toUri()) } val playerResponse = withContext(IO) { From 057b3220f731937c8de7ddd89cefd742ac9c13ad Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 14:15:48 +0800 Subject: [PATCH 144/323] Add ime padding for search suggestion screen --- .../com/zionhuang/music/ui/component/AppBar.kt | 14 +++++++++++++- 1 file changed, 13 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt index d26ea1954..84bdd0e85 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt @@ -34,6 +34,7 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.max import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.zionhuang.music.R @@ -119,8 +120,19 @@ fun AppBar( .fillMaxSize() .background(MaterialTheme.colorScheme.background) .padding(WindowInsets.systemBars - .add(WindowInsets(top = AppBarHeight)) + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) .asPaddingValues()) + .padding( + top = AppBarHeight, + bottom = max( + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding(), + WindowInsets.ime + .asPaddingValues() + .calculateBottomPadding() + ) + ) ) { Crossfade( targetState = searchSource From c35ea069a2de3f6a7ef0c8ff5c9e9221f67ddf76 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 14:22:43 +0800 Subject: [PATCH 145/323] Fix icon background when selecting artist --- app/src/main/java/com/zionhuang/music/ui/menu/SongMenu.kt | 3 --- 1 file changed, 3 deletions(-) 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 81452a244..3b1354a3b 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 @@ -157,9 +157,6 @@ fun SongMenu( AsyncImage( model = artist.thumbnailUrl, contentDescription = null, - placeholder = painterResource(R.drawable.ic_artist), - error = painterResource(R.drawable.ic_artist), - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), modifier = Modifier .size(ListThumbnailSize) .clip(CircleShape) From aa991f4be8ef01b6dfbf47689c3f37aecada8b09 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 14:25:24 +0800 Subject: [PATCH 146/323] Improve reverse list performance --- app/src/main/java/com/zionhuang/music/extensions/ListExt.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt index 48cecaaf0..3e3cb9dbf 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt @@ -1,3 +1,3 @@ package com.zionhuang.music.extensions -fun List.reversed(reversed: Boolean) = if (reversed) reversed() else this \ No newline at end of file +fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this \ No newline at end of file From d59ccc1878b6a484d0bdce380ff0a609119b3827 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 14:26:17 +0800 Subject: [PATCH 147/323] Fix backup and restore feature --- .../com/zionhuang/music/db/DatabaseDao.kt | 9 +++ .../com/zionhuang/music/db/MusicDatabase.kt | 5 -- .../ui/screens/settings/BackupAndRestore.kt | 6 +- .../viewmodels/BackupRestoreViewModel.kt | 62 ++++++++++--------- 4 files changed, 44 insertions(+), 38 deletions(-) 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 a02a39b26..8900d52fe 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -1,6 +1,7 @@ package com.zionhuang.music.db import androidx.room.* +import androidx.sqlite.db.SupportSQLiteQuery import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.innertube.pages.ArtistPage @@ -8,6 +9,7 @@ import com.zionhuang.music.constants.* import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.reversed +import com.zionhuang.music.extensions.toSQLiteQuery import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.ui.utils.resize @@ -421,4 +423,11 @@ interface DatabaseDao { delete(albumWithSongs.album) albumWithSongs.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) } + + @RawQuery + fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int + + fun checkpoint() { + raw("PRAGMA wal_checkpoint(FULL)".toSQLiteQuery()) + } } 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 1873bf3ed..9b2de16bf 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -4,7 +4,6 @@ import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT import androidx.core.content.contentValuesOf import androidx.room.* import androidx.room.migration.Migration -import androidx.sqlite.db.SimpleSQLiteQuery import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import com.zionhuang.music.db.entities.* @@ -35,10 +34,6 @@ class MusicDatabase( } } - fun checkpoint() { - delegate.query(SimpleSQLiteQuery("PRAGMA wal_checkpoint(FULL)")) - } - fun close() = delegate.close() } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt index 446804e96..9f9eaf348 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt @@ -26,12 +26,12 @@ fun BackupAndRestore( val context = LocalContext.current val backupLauncher = rememberLauncherForActivityResult(ActivityResultContracts.CreateDocument("application/octet-stream")) { uri -> if (uri != null) { - viewModel.backup(uri) + viewModel.backup(context, uri) } } val restoreLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> if (uri != null) { - viewModel.restore(uri) + viewModel.restore(context, uri) } } Column( @@ -56,5 +56,3 @@ fun BackupAndRestore( ) } } - -const val PREF_NAME = "preferences.xml" \ No newline at end of file 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 2ad13657d..bbb5fac6a 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -1,21 +1,22 @@ package com.zionhuang.music.viewmodels -import android.app.Application +import android.content.Context import android.content.Intent import android.net.Uri import android.widget.Toast -import androidx.lifecycle.AndroidViewModel -import com.zionhuang.music.App +import androidx.lifecycle.ViewModel +import com.zionhuang.music.MainActivity import com.zionhuang.music.R import com.zionhuang.music.db.InternalDatabase import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.extensions.div import com.zionhuang.music.extensions.zipInputStream import com.zionhuang.music.extensions.zipOutputStream import com.zionhuang.music.playback.MusicService import com.zionhuang.music.playback.SongPlayer -import com.zionhuang.music.ui.screens.settings.PREF_NAME import dagger.hilt.android.lifecycle.HiltViewModel -import java.io.File +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.runBlocking import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipEntry @@ -24,22 +25,19 @@ import kotlin.system.exitProcess @HiltViewModel class BackupRestoreViewModel @Inject constructor( - application: Application, val database: MusicDatabase, -) : AndroidViewModel(application) { - val app = getApplication() - fun backup(uri: Uri) { +) : ViewModel() { + fun backup(context: Context, uri: Uri) { runCatching { - app.applicationContext.contentResolver.openOutputStream(uri)?.use { + context.applicationContext.contentResolver.openOutputStream(uri)?.use { it.buffered().zipOutputStream().use { outputStream -> - File( - File(app.filesDir.parentFile, "shared_prefs"), - "${app.packageName}_preferences.xml" - ).inputStream().buffered().use { inputStream -> - outputStream.putNextEntry(ZipEntry(PREF_NAME)) + (context.filesDir / "datastore" / SETTINGS_FILENAME).inputStream().buffered().use { inputStream -> + outputStream.putNextEntry(ZipEntry(SETTINGS_FILENAME)) inputStream.copyTo(outputStream) } - database.checkpoint() + runBlocking(Dispatchers.IO) { + database.checkpoint() + } FileInputStream(database.openHelper.writableDatabase.path).use { inputStream -> outputStream.putNextEntry(ZipEntry(InternalDatabase.DB_NAME)) inputStream.copyTo(outputStream) @@ -47,29 +45,29 @@ class BackupRestoreViewModel @Inject constructor( } } }.onSuccess { - Toast.makeText(app, R.string.message_backup_create_success, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.message_backup_create_success, Toast.LENGTH_SHORT).show() }.onFailure { - Toast.makeText(app, R.string.message_backup_create_failed, Toast.LENGTH_SHORT).show() + it.printStackTrace() + Toast.makeText(context, R.string.message_backup_create_failed, Toast.LENGTH_SHORT).show() } } - fun restore(uri: Uri) { + fun restore(context: Context, uri: Uri) { runCatching { - this.app.applicationContext.contentResolver.openInputStream(uri)?.use { + context.applicationContext.contentResolver.openInputStream(uri)?.use { it.zipInputStream().use { inputStream -> var entry = inputStream.nextEntry while (entry != null) { when (entry.name) { - PREF_NAME -> { - File( - File(app.filesDir.parentFile, "shared_prefs"), - "${app.packageName}_preferences.xml" - ).outputStream().use { outputStream -> + SETTINGS_FILENAME -> { + (context.filesDir / "datastore" / SETTINGS_FILENAME).outputStream().use { outputStream -> inputStream.copyTo(outputStream) } } InternalDatabase.DB_NAME -> { - database.checkpoint() + runBlocking(Dispatchers.IO) { + database.checkpoint() + } database.close() FileOutputStream(database.openHelper.writableDatabase.path).use { outputStream -> inputStream.copyTo(outputStream) @@ -80,11 +78,17 @@ class BackupRestoreViewModel @Inject constructor( } } } - app.stopService(Intent(app, MusicService::class.java)) - app.filesDir.resolve(SongPlayer.PERSISTENT_QUEUE_FILE).delete() + context.stopService(Intent(context, MusicService::class.java)) + context.filesDir.resolve(SongPlayer.PERSISTENT_QUEUE_FILE).delete() + context.startActivity(Intent(context, MainActivity::class.java)) exitProcess(0) }.onFailure { - Toast.makeText(app, R.string.message_restore_failed, Toast.LENGTH_SHORT).show() + it.printStackTrace() + Toast.makeText(context, R.string.message_restore_failed, Toast.LENGTH_SHORT).show() } } + + companion object { + const val SETTINGS_FILENAME = "settings.preferences_pb" + } } From 31165d69a0f233b06835a85084fed31e8e145ebd Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 17:17:41 +0800 Subject: [PATCH 148/323] Fix window insets --- .../music/ui/component/BottomSheetMenu.kt | 3 +++ .../zionhuang/music/ui/screens/HomeScreen.kt | 21 +++++++++++++++++-- 2 files changed, 22 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt index 67714c0b1..281ba42dd 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt @@ -73,6 +73,9 @@ fun BottomSheetMenu( Column( modifier = Modifier .fillMaxWidth() + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues()) .padding(top = 48.dp) .clip(ShapeDefaults.Large.top()) .background(background) 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 c2b595024..56fbebeb3 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 @@ -81,6 +81,9 @@ fun HomeScreen( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues()) .padding(horizontal = 12.dp, vertical = 6.dp) .widthIn(min = 84.dp) .clip(RoundedCornerShape(6.dp)) @@ -107,13 +110,20 @@ fun HomeScreen( Text( text = stringResource(R.string.most_played_songs), style = MaterialTheme.typography.headlineSmall, - modifier = Modifier.padding(12.dp) + modifier = Modifier + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues()) + .padding(12.dp) ) LazyHorizontalGrid( state = mostPlayedLazyGridState, rows = GridCells.Fixed(4), flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), modifier = Modifier .fillMaxWidth() .height(ListItemHeight * 4) @@ -162,6 +172,9 @@ fun HomeScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() + .padding(WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues()) .clickable { navController.navigate("new_release") } @@ -182,7 +195,11 @@ fun HomeScreen( ) } - LazyRow { + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues() + ) { items( items = newReleaseAlbums, key = { it.id } From 0e230d15ea4240726fdf6ba4f1b6e98e3dd842bb Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 22:03:16 +0800 Subject: [PATCH 149/323] Add built-in playlist screen --- .../java/com/zionhuang/music/MainActivity.kt | 35 ++-- .../{NoResultFound.kt => EmptyPlaceholder.kt} | 15 +- .../com/zionhuang/music/ui/player/Queue.kt | 2 + .../zionhuang/music/ui/screens/HomeScreen.kt | 72 +++++-- .../music/ui/screens/artist/ArtistScreen.kt | 4 +- .../screens/library/LibraryPlaylistsScreen.kt | 4 +- .../ui/screens/library/LibrarySongsScreen.kt | 44 ++--- .../screens/playlist/BuiltInPlaylistScreen.kt | 175 ++++++++++++++++++ .../screens/playlist/LocalPlaylistScreen.kt | 28 +-- .../ui/screens/search/LocalSearchScreen.kt | 5 +- .../ui/screens/search/OnlineSearchResult.kt | 12 +- .../viewmodels/BuiltInPlaylistViewModel.kt | 43 +++++ .../music/viewmodels/LibraryViewModels.kt | 32 ---- app/src/main/res/values/strings.xml | 2 + 14 files changed, 353 insertions(+), 120 deletions(-) rename app/src/main/java/com/zionhuang/music/ui/component/{NoResultFound.kt => EmptyPlaceholder.kt} (78%) create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 5e99c3521..1fbfe97b6 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -45,6 +45,8 @@ import com.google.android.exoplayer2.Player import com.valentinilk.shimmer.LocalShimmerTheme 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.MusicService @@ -61,6 +63,7 @@ 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 @@ -339,8 +342,21 @@ class MainActivity : ComponentActivity() { } } composable( - route = "artistItems/{browseId}?params={params}", + route = "artist/{artistId}/songs", arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + } + ) + ) { + ArtistSongsScreen(navController = navController) + } + composable( + route = "artist/{artistId}/{browseId}?params={params}", + arguments = listOf( + navArgument("artistId") { + type = NavType.StringType + }, navArgument("browseId") { type = NavType.StringType }, @@ -355,16 +371,6 @@ class MainActivity : ComponentActivity() { appBarConfig = appBarConfig ) } - composable( - route = "artistSongs/{artistId}", - arguments = listOf( - navArgument("artistId") { - type = NavType.StringType - } - ) - ) { - ArtistSongsScreen(navController = navController) - } composable( route = "playlist/{playlistId}", arguments = listOf( @@ -374,7 +380,12 @@ class MainActivity : ComponentActivity() { ) ) { backStackEntry -> val playlistId = backStackEntry.arguments?.getString("playlistId")!! - if (playlistId.startsWith("LP")) { + if (playlistId == LIKED_PLAYLIST_ID || playlistId == DOWNLOADED_PLAYLIST_ID) { + BuiltInPlaylistScreen( + appBarConfig = appBarConfig, + navController = navController + ) + } else if (playlistId.startsWith("LP")) { LocalPlaylistScreen( appBarConfig = appBarConfig, navController = navController diff --git a/app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt b/app/src/main/java/com/zionhuang/music/ui/component/EmptyPlaceholder.kt similarity index 78% rename from app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt rename to app/src/main/java/com/zionhuang/music/ui/component/EmptyPlaceholder.kt index 0914bcce2..39eb78f69 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/NoResultFound.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/EmptyPlaceholder.kt @@ -1,5 +1,6 @@ package com.zionhuang.music.ui.component +import androidx.annotation.DrawableRes import androidx.compose.foundation.Image import androidx.compose.foundation.layout.* import androidx.compose.material3.MaterialTheme @@ -9,20 +10,22 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.zionhuang.music.R @Composable -fun NoResultFound() { +fun EmptyPlaceholder( + @DrawableRes icon: Int, + text: String, + modifier: Modifier = Modifier, +) { Column( horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier + modifier = modifier .fillMaxSize() .padding(12.dp) ) { Image( - painter = painterResource(id = R.drawable.ic_search), + painter = painterResource(icon), contentDescription = null, colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), modifier = Modifier.size(64.dp) @@ -31,7 +34,7 @@ fun NoResultFound() { Spacer(Modifier.height(12.dp)) Text( - text = stringResource(R.string.no_results_found), + text = text, style = MaterialTheme.typography.bodyLarge ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index abc74914f..d1148d646 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -350,6 +350,8 @@ fun Queue( Text( text = queueTitle.orEmpty(), style = MaterialTheme.typography.titleMedium, + maxLines = 2, + overflow = TextOverflow.Ellipsis, modifier = Modifier.weight(1f) ) 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 56fbebeb3..36585a091 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 @@ -1,5 +1,6 @@ package com.zionhuang.music.ui.screens +import androidx.annotation.DrawableRes import androidx.compose.foundation.* import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.* @@ -14,6 +15,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource @@ -78,31 +80,33 @@ fun HomeScreen( ) { Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) - Column( - horizontalAlignment = Alignment.CenterHorizontally, + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .padding(WindowInsets.systemBars .only(WindowInsetsSides.Horizontal) .asPaddingValues()) .padding(horizontal = 12.dp, vertical = 6.dp) - .widthIn(min = 84.dp) - .clip(RoundedCornerShape(6.dp)) - .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) - .clickable { - navController.navigate("settings") - } - .padding(12.dp) ) { - Icon( - painter = painterResource(R.drawable.ic_settings), - contentDescription = null + NavigationTile( + title = stringResource(R.string.history), + icon = R.drawable.ic_history, + onClick = { navController.navigate("history") }, + enabled = false, + modifier = Modifier.weight(1f) ) - - Spacer(Modifier.height(6.dp)) - - Text( - text = stringResource(R.string.title_settings), - style = MaterialTheme.typography.labelLarge, + NavigationTile( + title = stringResource(R.string.stats), + icon = R.drawable.ic_trending_up, + onClick = { navController.navigate("stats") }, + enabled = false, + modifier = Modifier.weight(1f) + ) + NavigationTile( + title = stringResource(R.string.title_settings), + icon = R.drawable.ic_settings, + onClick = { navController.navigate("settings") }, + modifier = Modifier.weight(1f) ) } @@ -277,3 +281,35 @@ fun HomeScreen( } } } + +@Composable +fun NavigationTile( + title: String, + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable(enabled = enabled, onClick = onClick) + .padding(12.dp) + .alpha(if (enabled) 1f else 0.5f) + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + + Spacer(Modifier.height(6.dp)) + + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + ) + } +} 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 17e138118..b7b8fd5ff 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 @@ -180,7 +180,7 @@ fun ArtistScreen( modifier = Modifier .fillMaxWidth() .clickable { - navController.navigate("artistSongs/${artistPage.artist.id}") + navController.navigate("artist/${viewModel.artistId}/songs") } .padding(12.dp) ) { @@ -244,7 +244,7 @@ fun ArtistScreen( modifier = Modifier .fillMaxWidth() .clickable(enabled = section.moreEndpoint != null) { - navController.navigate("artistItems/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + navController.navigate("artist/${viewModel.artistId}/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") } .padding(12.dp) ) { 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 2f6fd5aa1..a7660980e 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 @@ -112,7 +112,7 @@ fun LibraryPlaylistsScreen( }, modifier = Modifier .clickable { - + navController.navigate("playlist/$LIKED_PLAYLIST_ID") } .animateItemPlacement() ) @@ -134,7 +134,7 @@ fun LibraryPlaylistsScreen( }, modifier = Modifier .clickable { - + navController.navigate("playlist/$DOWNLOADED_PLAYLIST_ID") } .animateItemPlacement() ) 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 d7bd44f26..5061c1835 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 @@ -51,7 +51,7 @@ fun LibrarySongsScreen( val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) - val items by viewModel.allSongs.collectAsState() + val songs by viewModel.allSongs.collectAsState() Box( modifier = Modifier.fillMaxSize() @@ -75,12 +75,12 @@ fun LibrarySongsScreen( SongSortType.ARTIST -> R.string.sort_by_artist } }, - trailingText = pluralStringResource(R.plurals.song_count, items.size, items.size) + trailingText = pluralStringResource(R.plurals.song_count, songs.size, songs.size) ) } itemsIndexed( - items = items, + items = songs, key = { _, item -> item.id }, contentType = { _, _ -> CONTENT_TYPE_SONG } ) { index, song -> @@ -113,7 +113,7 @@ fun LibrarySongsScreen( .combinedClickable { playerConnection.playQueue(ListQueue( title = context.getString(R.string.queue_all_songs), - items = items.map { it.toMediaItem() }, + items = songs.map { it.toMediaItem() }, startIndex = index )) } @@ -122,24 +122,26 @@ fun LibrarySongsScreen( } } - FloatingActionButton( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) - .padding(16.dp), - onClick = { - playerConnection.playQueue(ListQueue( - title = context.getString(R.string.queue_all_songs), - items = items.shuffled().map { it.toMediaItem() }, - )) + if (songs.isNotEmpty()) { + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues()) + .padding(16.dp), + onClick = { + playerConnection.playQueue(ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.shuffled().map { it.toMediaItem() }, + )) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_shuffle), - contentDescription = null - ) } } } 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 new file mode 100644 index 000000000..6f19f34f3 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/BuiltInPlaylistScreen.kt @@ -0,0 +1,175 @@ +package com.zionhuang.music.ui.screens.playlist + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +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.text.style.TextOverflow +import androidx.compose.ui.unit.dp +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.SongSortDescendingKey +import com.zionhuang.music.constants.SongSortType +import com.zionhuang.music.constants.SongSortTypeKey +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.AppBarConfig +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(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@Composable +fun BuiltInPlaylistScreen( + appBarConfig: AppBarConfig, + navController: NavController, + viewModel: BuiltInPlaylistViewModel = hiltViewModel(), +) { + val context = LocalContext.current + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val (sortType, onSortTypeChange) = rememberEnumPreference(SongSortTypeKey, SongSortType.CREATE_DATE) + val (sortDescending, onSortDescendingChange) = rememberPreference(SongSortDescendingKey, true) + + val songs by viewModel.songs.collectAsState() + val playlistLength = remember(songs) { + songs.fastSumBy { it.song.duration } + } + val playlistName = remember { + context.getString( + when (viewModel.playlistId) { + PlaylistEntity.LIKED_PLAYLIST_ID -> R.string.liked_songs + PlaylistEntity.DOWNLOADED_PLAYLIST_ID -> R.string.downloaded_songs + else -> error("Unknown playlist id") + } + ) + } + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + appBarConfig.title = { + Text( + text = playlistName, + style = MaterialTheme.typography.titleLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier.weight(1f) + ) + } + } + + Box( + modifier = Modifier.fillMaxSize() + ) { + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + item { + 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 + } + }, + trailingText = joinByBullet( + makeTimeString(playlistLength * 1000L), + pluralStringResource(R.plurals.song_count, songs.size, songs.size) + ) + ) + } + + itemsIndexed( + items = songs, + key = { _, song -> song.id } + ) { index, song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue(ListQueue( + title = playlistName, + items = songs.map { it.toMediaItem() }, + startIndex = index + )) + } + .animateItemPlacement() + ) + } + } + + if (songs.isNotEmpty()) { + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + .asPaddingValues()) + .padding(16.dp), + onClick = { + playerConnection.playQueue(ListQueue( + title = playlistName, + items = songs.shuffled().map { it.toMediaItem() }, + )) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null + ) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index 38145b284..e852d2a4f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -1,7 +1,6 @@ package com.zionhuang.music.ui.screens.playlist import androidx.compose.foundation.ExperimentalFoundationApi -import androidx.compose.foundation.Image import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn @@ -13,7 +12,6 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource @@ -78,26 +76,10 @@ fun LocalPlaylistScreen( playlist?.let { playlist -> item { if (playlist.songCount == 0) { - Column( - horizontalAlignment = Alignment.CenterHorizontally, - modifier = Modifier - .fillMaxSize() - .padding(12.dp) - ) { - Image( - painter = painterResource(id = R.drawable.ic_music_note), - contentDescription = null, - colorFilter = ColorFilter.tint(MaterialTheme.colorScheme.onBackground), - modifier = Modifier.size(64.dp) - ) - - Spacer(Modifier.height(12.dp)) - - Text( - text = stringResource(R.string.playlist_empty), - style = MaterialTheme.typography.bodyLarge - ) - } + EmptyPlaceholder( + icon = R.drawable.ic_music_note, + text = stringResource(R.string.playlist_empty) + ) } else { Column( verticalArrangement = Arrangement.spacedBy(12.dp), @@ -249,4 +231,4 @@ fun LocalPlaylistScreen( ) } } -} \ No newline at end of file +} 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 c1f731ace..f823d159b 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 @@ -191,7 +191,10 @@ fun LocalSearchScreen( item( key = "no_result" ) { - NoResultFound() + EmptyPlaceholder( + icon = R.drawable.ic_search, + text = stringResource(R.string.no_results_found) + ) } } } 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 8cd93f41b..2edb0b896 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 @@ -33,8 +33,8 @@ import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.SearchFilterHeight import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.EmptyPlaceholder import com.zionhuang.music.ui.component.LocalMenuState -import com.zionhuang.music.ui.component.NoResultFound import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ShimmerHost @@ -208,7 +208,10 @@ fun OnlineSearchResult( if (searchSummary?.summaries?.isEmpty() == true) { item { - NoResultFound() + EmptyPlaceholder( + icon = R.drawable.ic_search, + text = stringResource(R.string.no_results_found) + ) } } } else { @@ -230,7 +233,10 @@ fun OnlineSearchResult( if (itemsPage?.items?.isEmpty() == true) { item { - NoResultFound() + EmptyPlaceholder( + icon = R.drawable.ic_search, + text = stringResource(R.string.no_results_found) + ) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt new file mode 100644 index 000000000..9b9d7f018 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt @@ -0,0 +1,43 @@ +package com.zionhuang.music.viewmodels + +import android.content.Context +import androidx.lifecycle.SavedStateHandle +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +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.toEnum +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.* +import javax.inject.Inject + +@HiltViewModel +class BuiltInPlaylistViewModel @Inject constructor( + @ApplicationContext context: Context, + database: MusicDatabase, + savedStateHandle: SavedStateHandle, +) : ViewModel() { + val playlistId = savedStateHandle.get("playlistId")!! + + @OptIn(ExperimentalCoroutinesApi::class) + val songs = context.dataStore.data + .map { + it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) + } + .distinctUntilChanged() + .flatMapLatest { (sortType, descending) -> + when (playlistId) { + LIKED_PLAYLIST_ID -> database.likedSongs(sortType, descending) + DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(sortType, descending) + else -> error("Unknown playlist id") + } + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) +} 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 5950b47e9..b6b103219 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -129,35 +129,3 @@ class ArtistSongsViewModel @Inject constructor( } .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } - -@HiltViewModel -class LikedSongsViewModel @Inject constructor( - @ApplicationContext context: Context, - database: MusicDatabase, -) : ViewModel() { - val songs = context.dataStore.data - .map { - it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) - } - .distinctUntilChanged() - .flatMapLatest { (sortType, descending) -> - database.likedSongs(sortType, descending) - } - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) -} - -@HiltViewModel -class DownloadedSongsViewModel @Inject constructor( - @ApplicationContext context: Context, - database: MusicDatabase, -) : ViewModel() { - val songs = context.dataStore.data - .map { - it[SongSortTypeKey].toEnum(SongSortType.CREATE_DATE) to (it[SongSortDescendingKey] ?: true) - } - .distinctUntilChanged() - .flatMapLatest { (sortType, descending) -> - database.downloadedSongs(sortType, descending) - } - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) -} diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 8828c5397..af8235b64 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -317,6 +317,8 @@ Sleep timer End of song Clear song cache + History + Stats 1 minute From fef0bb9cb5a1bcccf8f63bf60791f40d4d59960d Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 27 Jan 2023 22:23:24 +0800 Subject: [PATCH 150/323] [Feature] Double tap to seek back/forward (#96) --- .../zionhuang/music/playback/SongPlayer.kt | 2 ++ .../zionhuang/music/ui/player/Thumbnail.kt | 19 +++++++++++++++++-- 2 files changed, 19 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index a1d619b75..68f599601 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -134,6 +134,8 @@ class SongPlayer( .setUsage(C.USAGE_MEDIA) .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) .build(), true) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(5000) .build() .apply { addListener(this@SongPlayer) 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 f255ccd46..97d983eb9 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,6 +3,7 @@ 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.gestures.detectTapGestures import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.Composable @@ -11,9 +12,11 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.LocalView import androidx.compose.ui.unit.dp 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.models.MediaMetadata @@ -27,10 +30,11 @@ fun Thumbnail( modifier: Modifier = Modifier, ) { mediaMetadata ?: return - val showLyrics by rememberPreference(ShowLyricsKey, false) - + val playerConnection = LocalPlayerConnection.current ?: return val currentView = LocalView.current + val showLyrics by rememberPreference(ShowLyricsKey, false) + DisposableEffect(showLyrics) { currentView.keepScreenOn = showLyrics onDispose { @@ -59,6 +63,17 @@ fun Thumbnail( .clip(RoundedCornerShape(ThumbnailCornerRadius)) .fillMaxWidth() .align(Alignment.Center) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + if (offset.x < size.width / 2) { + playerConnection.player.seekBack() + } else { + playerConnection.player.seekForward() + } + } + ) + } ) } } From caf20113dcb6a22128a28305dc780e8c10d89de8 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 28 Jan 2023 14:42:43 +0800 Subject: [PATCH 151/323] Improve AppBar composable --- .../java/com/zionhuang/music/ui/component/AppBar.kt | 11 ++++++++--- 1 file changed, 8 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt index 84bdd0e85..d01dbf2e7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt @@ -16,13 +16,13 @@ import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.drawBehind import androidx.compose.ui.focus.FocusRequester import androidx.compose.ui.focus.focusRequester import androidx.compose.ui.geometry.Offset import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.graphicsLayer import androidx.compose.ui.input.nestedscroll.NestedScrollConnection import androidx.compose.ui.input.nestedscroll.NestedScrollSource import androidx.compose.ui.platform.LocalDensity @@ -183,8 +183,13 @@ fun AppBar( .fillMaxWidth() .height(AppBarHeight) .padding(horizontal = horizontalPadding, vertical = verticalPadding) - .clip(RoundedCornerShape(cornerShapePercent)) - .background(barBackground) + .graphicsLayer { + clip = true + shape = RoundedCornerShape(cornerShapePercent) + } + .drawBehind { + drawRect(barBackground) + } ) { AnimatedVisibility( visible = appBarConfig.searchable, From 6e5459b0702ff76fe845e6b0941e80ee415b5fe6 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 28 Jan 2023 21:22:36 +0800 Subject: [PATCH 152/323] Fix artist items route --- app/src/main/java/com/zionhuang/music/MainActivity.kt | 3 ++- .../java/com/zionhuang/music/ui/screens/artist/ArtistScreen.kt | 2 +- 2 files changed, 3 insertions(+), 2 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 1fbfe97b6..ef828ada3 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -352,13 +352,14 @@ class MainActivity : ComponentActivity() { ArtistSongsScreen(navController = navController) } composable( - route = "artist/{artistId}/{browseId}?params={params}", + route = "artist/{artistId}/items?browseId={browseId}?params={params}", arguments = listOf( navArgument("artistId") { type = NavType.StringType }, navArgument("browseId") { type = NavType.StringType + nullable = true }, navArgument("params") { type = NavType.StringType 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 b7b8fd5ff..32f3b4261 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 @@ -244,7 +244,7 @@ fun ArtistScreen( modifier = Modifier .fillMaxWidth() .clickable(enabled = section.moreEndpoint != null) { - navController.navigate("artist/${viewModel.artistId}/${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + navController.navigate("artist/${viewModel.artistId}/items?browseId=${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") } .padding(12.dp) ) { From 87d09fd035263ed4d160d90d91acb30736ce6761 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 28 Jan 2023 22:01:14 +0800 Subject: [PATCH 153/323] Replace asPaddingValues with windowInsetsPadding --- .../zionhuang/music/ui/component/AppBar.kt | 27 +++++-------- .../music/ui/component/BottomSheetMenu.kt | 4 +- .../zionhuang/music/ui/player/MiniPlayer.kt | 6 +-- .../com/zionhuang/music/ui/player/Player.kt | 16 +++----- .../com/zionhuang/music/ui/player/Queue.kt | 39 +++++++++++-------- .../zionhuang/music/ui/player/Thumbnail.kt | 9 +++-- .../zionhuang/music/ui/screens/HomeScreen.kt | 19 ++++----- .../ui/screens/artist/ArtistItemsScreen.kt | 3 +- .../ui/screens/artist/ArtistSongsScreen.kt | 7 ++-- .../screens/library/LibraryPlaylistsScreen.kt | 7 ++-- .../ui/screens/library/LibrarySongsScreen.kt | 7 ++-- .../screens/playlist/BuiltInPlaylistScreen.kt | 7 ++-- .../ui/screens/search/OnlineSearchResult.kt | 5 +-- .../music/ui/screens/settings/AboutScreen.kt | 5 +-- .../ui/screens/settings/AppearanceSettings.kt | 5 +-- .../ui/screens/settings/BackupAndRestore.kt | 5 +-- .../ui/screens/settings/ContentSettings.kt | 5 +-- .../ui/screens/settings/GeneralSettings.kt | 5 +-- .../ui/screens/settings/PlayerSettings.kt | 5 +-- .../ui/screens/settings/PrivacySettings.kt | 4 +- .../ui/screens/settings/SettingsScreen.kt | 5 +-- .../ui/screens/settings/StorageSettings.kt | 7 +--- settings.gradle.kts | 2 + 23 files changed, 93 insertions(+), 111 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt index d01dbf2e7..4cc85b1d1 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt @@ -34,7 +34,6 @@ import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp -import androidx.compose.ui.unit.max import androidx.navigation.NavController import androidx.navigation.compose.currentBackStackEntryAsState import com.zionhuang.music.R @@ -119,20 +118,13 @@ fun AppBar( modifier = Modifier .fillMaxSize() .background(MaterialTheme.colorScheme.background) - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - .asPaddingValues()) - .padding( - top = AppBarHeight, - bottom = max( - WindowInsets.systemBars - .asPaddingValues() - .calculateBottomPadding(), - WindowInsets.ime - .asPaddingValues() - .calculateBottomPadding() - ) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) ) + .padding(top = AppBarHeight) + .navigationBarsPadding() + .imePadding() ) { Crossfade( targetState = searchSource @@ -177,9 +169,10 @@ fun AppBar( Box( modifier = Modifier - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) .fillMaxWidth() .height(AppBarHeight) .padding(horizontal = horizontalPadding, vertical = verticalPadding) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt index 281ba42dd..eb8e22a59 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt @@ -73,9 +73,7 @@ fun BottomSheetMenu( Column( modifier = Modifier .fillMaxWidth() - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(top = 48.dp) .clip(ShapeDefaults.Large.top()) .background(background) 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 0dd24f224..6d4f57964 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 @@ -21,11 +21,11 @@ import coil.compose.AsyncImage import com.google.android.exoplayer2.Player.STATE_BUFFERING import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R -import com.zionhuang.music.ui.component.LinearProgressIndicator import com.zionhuang.music.constants.MiniPlayerHeight import com.zionhuang.music.constants.ThumbnailCornerRadius import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.ui.component.LinearProgressIndicator @Composable fun MiniPlayer( @@ -44,9 +44,7 @@ fun MiniPlayer( modifier = modifier .fillMaxWidth() .height(MiniPlayerHeight) - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) ) { LinearProgressIndicator( indeterminate = playbackState == STATE_BUFFERING, 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 5a7c890e9..bb8118b3f 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 @@ -263,10 +263,8 @@ fun BottomSheetPlayer( Configuration.ORIENTATION_LANDSCAPE -> { Row( modifier = Modifier - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .add(WindowInsets(bottom = queueSheetState.collapsedBound)) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound) ) { Box( contentAlignment = Alignment.Center, @@ -283,9 +281,7 @@ fun BottomSheetPlayer( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier .weight(1f) - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Top) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) ) { Spacer(Modifier.weight(1f)) @@ -301,10 +297,8 @@ fun BottomSheetPlayer( Column( horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .add(WindowInsets(bottom = queueSheetState.collapsedBound)) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(bottom = queueSheetState.collapsedBound) ) { Box( contentAlignment = Alignment.Center, diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index d1148d646..8bd2b2402 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -231,9 +231,10 @@ fun Queue( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxSize() - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) ) { IconButton(onClick = { state.expandSoft() }) { Icon( @@ -303,10 +304,12 @@ fun Queue( LazyColumn( state = lazyListState, - contentPadding = WindowInsets.systemBars.add(WindowInsets( - top = ListItemHeight, - bottom = ListItemHeight) - ).asPaddingValues(), + contentPadding = WindowInsets.systemBars + .add(WindowInsets( + top = ListItemHeight, + bottom = ListItemHeight + )) + .asPaddingValues(), modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) ) { itemsIndexed( @@ -337,9 +340,10 @@ fun Queue( .background(MaterialTheme.colorScheme .surfaceColorAtElevation(NavigationBarDefaults.Elevation) .copy(alpha = 0.95f)) - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) + ) ) { Row( horizontalArrangement = Arrangement.spacedBy(6.dp), @@ -378,16 +382,19 @@ fun Queue( modifier = Modifier .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)) .fillMaxWidth() - .height(ListItemHeight + WindowInsets.systemBars - .asPaddingValues() - .calculateBottomPadding()) + .height(ListItemHeight + + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() + ) .align(Alignment.BottomCenter) .clickable { state.collapseSoft() } - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) .padding(12.dp) ) { IconButton( 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 97d983eb9..34cd7e8f6 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 @@ -51,10 +51,11 @@ fun Thumbnail( Box( modifier = Modifier .fillMaxSize() - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Top) - .add(WindowInsets(left = 16.dp, right = 16.dp)) - .asPaddingValues()) + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(left = 16.dp, right = 16.dp)) + ) ) { AsyncImage( model = mediaMetadata.thumbnailUrl, 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 36585a091..8c32e2f37 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 @@ -83,9 +83,7 @@ fun HomeScreen( Row( horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(horizontal = 12.dp, vertical = 6.dp) ) { NavigationTile( @@ -115,9 +113,7 @@ fun HomeScreen( text = stringResource(R.string.most_played_songs), style = MaterialTheme.typography.headlineSmall, modifier = Modifier - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(12.dp) ) @@ -176,9 +172,7 @@ fun HomeScreen( verticalAlignment = Alignment.CenterVertically, modifier = Modifier .fillMaxWidth() - .padding(WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .clickable { navController.navigate("new_release") } @@ -261,9 +255,10 @@ fun HomeScreen( FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) - .padding(LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) .padding(16.dp), onClick = { if (newReleaseAlbums.isEmpty() || Random.nextBoolean()) { 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 21211ab8a..54002a441 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 @@ -6,6 +6,7 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid @@ -89,7 +90,7 @@ fun ArtistItemsScreen( if (itemsPage == null) { ShimmerHost( - modifier = Modifier.padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current) ) { repeat(8) { ListItemPlaceHolder() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt index 7ed8575d7..6496fe702 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt @@ -125,9 +125,10 @@ fun ArtistSongsScreen( FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) - .padding(LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) .padding(16.dp), onClick = { playerConnection.playQueue(ListQueue( 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 a7660980e..15b258edf 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 @@ -160,9 +160,10 @@ fun LibraryPlaylistsScreen( FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) - .padding(LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) .padding(16.dp), onClick = { showAddPlaylistDialog = true } ) { 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 5061c1835..c936e2b78 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 @@ -126,9 +126,10 @@ fun LibrarySongsScreen( FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) - .padding(LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) .padding(16.dp), onClick = { playerConnection.playQueue(ListQueue( 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 index 6f19f34f3..1fc500c32 100644 --- 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 @@ -154,9 +154,10 @@ fun BuiltInPlaylistScreen( FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) - .padding(LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - .asPaddingValues()) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) .padding(16.dp), onClick = { playerConnection.playQueue(ListQueue( 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 2edb0b896..246028a7d 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 @@ -254,9 +254,8 @@ fun OnlineSearchResult( Row( modifier = Modifier - .padding(WindowInsets.systemBars - .add(WindowInsets(top = AppBarHeight)) - .asPaddingValues()) + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Top)) + .padding(top = AppBarHeight) .fillMaxWidth() .horizontalScroll(rememberScrollState()) ) { 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 f897058d7..08ca19e55 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 @@ -1,8 +1,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -20,7 +19,7 @@ fun AboutScreen() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { PreferenceEntry( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt index b2650396c..99a70c5fc 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -1,8 +1,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -24,7 +23,7 @@ fun AppearanceSettings() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { EnumListPreference( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt index 9f9eaf348..c2a84ae9a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt @@ -3,8 +3,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -36,7 +35,7 @@ fun BackupAndRestore( } Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { PreferenceEntry( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt index e20fe649f..c83bbc99f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt @@ -1,8 +1,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -30,7 +29,7 @@ fun ContentSettings() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { ListPreference( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt index cecc145e9..12f881da7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt @@ -1,8 +1,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -26,7 +25,7 @@ fun GeneralSettings() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { SwitchPreference( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt index e0d7555c7..795a8937c 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt @@ -1,8 +1,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -28,7 +27,7 @@ fun PlayerSettings() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { EnumListPreference( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index d57cf9f7c..e993fca67 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -1,8 +1,8 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.MaterialTheme @@ -65,7 +65,7 @@ fun PrivacySettings() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { SwitchPreference( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt index 594549d67..630976de0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt @@ -1,8 +1,7 @@ package com.zionhuang.music.ui.screens.settings import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.runtime.Composable @@ -19,7 +18,7 @@ fun SettingsScreen( ) { Column( modifier = Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { PreferenceEntry( 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 f7703f5d4..472f53eee 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 @@ -1,9 +1,6 @@ package com.zionhuang.music.ui.screens.settings -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.asPaddingValues -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.* import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.LinearProgressIndicator @@ -65,7 +62,7 @@ fun StorageSettings() { Column( Modifier - .padding(LocalPlayerAwareWindowInsets.current.asPaddingValues()) + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { PreferenceGroupTitle( diff --git a/settings.gradle.kts b/settings.gradle.kts index abd3662c2..2b71970e2 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -37,6 +37,8 @@ dependencyResolutionManagement { library("material3", "androidx.compose.material3", "material3").versionRef("material3") library("material3-windowsize", "androidx.compose.material3", "material3-window-size-class").versionRef("material3") + library("accompanist-insets", "com.google.accompanist", "accompanist-insets").version("0.28.0") + library("coil", "io.coil-kt", "coil-compose").version("2.2.2") library("shimmer", "com.valentinilk.shimmer", "compose-shimmer").version("1.0.3") From 60f05f08fd576fa02d662da8d8c165cd430ec5d6 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 28 Jan 2023 22:09:16 +0800 Subject: [PATCH 154/323] Fix search result empty when query typo --- .../src/main/java/com/zionhuang/innertube/YouTube.kt | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 486d33a4a..e94035dc3 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -72,12 +72,12 @@ object YouTube { val response = innerTube.search(WEB_REMIX, query, filter.value).body() SearchResult( items = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull() - ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.lastOrNull() ?.musicShelfRenderer?.contents?.mapNotNull { SearchPage.toYTItem(it.musicResponsiveListItemRenderer) }.orEmpty(), continuation = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull() - ?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull() + ?.tabRenderer?.content?.sectionListRenderer?.contents?.lastOrNull() ?.musicShelfRenderer?.continuations?.getContinuation() ) } @@ -86,7 +86,9 @@ object YouTube { val response = innerTube.search(WEB_REMIX, continuation = continuation).body() SearchResult( items = response.continuationContents?.musicShelfContinuation?.contents - ?.mapNotNull { SearchPage.toYTItem(it.musicResponsiveListItemRenderer) }!!, + ?.mapNotNull { + SearchPage.toYTItem(it.musicResponsiveListItemRenderer) + }!!, continuation = response.continuationContents.musicShelfContinuation.continuations?.getContinuation() ) } @@ -246,6 +248,7 @@ object YouTube { } }.orEmpty() } + suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { val response = innerTube.next(WEB_REMIX, endpoint.videoId, endpoint.playlistId, endpoint.playlistSetVideoId, endpoint.index, endpoint.params, continuation).body() val playlistPanelRenderer = response.continuationContents?.playlistPanelContinuation From 647fcfdab29cb25b0dc5fa118da1d3a222bf8f9a Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 29 Jan 2023 13:55:36 +0800 Subject: [PATCH 155/323] Add button to add playlist to library --- .../com/zionhuang/music/db/DatabaseDao.kt | 15 ++++++++-- .../com/zionhuang/music/ui/component/Items.kt | 13 +++++++-- .../screens/playlist/OnlinePlaylistScreen.kt | 28 +++++++++++++++++++ .../viewmodels/OnlinePlaylistViewModel.kt | 8 ++++-- 4 files changed, 58 insertions(+), 6 deletions(-) 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 8900d52fe..3d51663fb 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -2,6 +2,7 @@ package com.zionhuang.music.db import androidx.room.* import androidx.sqlite.db.SupportSQLiteQuery +import com.zionhuang.innertube.models.PlaylistItem import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.innertube.pages.ArtistPage @@ -245,7 +246,7 @@ interface DatabaseDao { fun artistByName(name: String): ArtistEntity? @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(song: SongEntity) + fun insert(song: SongEntity): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(artist: ArtistEntity) @@ -273,7 +274,7 @@ interface DatabaseDao { @Transaction fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) { - insert(mediaMetadata.toSongEntity().let(block)) + if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return mediaMetadata.artists.forEachIndexed { index, artist -> val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId() insert(ArtistEntity( @@ -342,6 +343,16 @@ interface DatabaseDao { }.forEach(::insert) } + fun insert(playlist: PlaylistItem) { + insert(PlaylistEntity( + id = playlist.id, + name = playlist.title, + author = playlist.author.name, + authorId = playlist.author.id, + thumbnailUrl = playlist.thumbnail + )) + } + @Update fun update(song: SongEntity) 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 7a894f522..f33ac0450 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 @@ -279,9 +279,18 @@ inline fun PlaylistListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = playlist.playlist.name, - subtitle = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + subtitle = if (playlist.playlist.isYouTubePlaylist) playlist.playlist.author else pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), thumbnailContent = { - if (playlist.thumbnails.isEmpty()) { + if (playlist.playlist.thumbnailUrl != null) { + AsyncImage( + model = playlist.playlist.thumbnailUrl, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(ListThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + } else if (playlist.thumbnails.isEmpty()) { Icon( painter = painterResource(R.drawable.ic_queue_music), contentDescription = null, diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 3917f29eb..96cf8607d 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -26,6 +26,7 @@ import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage import com.zionhuang.innertube.models.* +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R @@ -38,6 +39,8 @@ import com.zionhuang.music.ui.component.shimmer.* import com.zionhuang.music.ui.menu.YouTubeSongMenu import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlinePlaylistViewModel +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.runBlocking @OptIn(ExperimentalFoundationApi::class) @Composable @@ -48,6 +51,7 @@ fun OnlinePlaylistScreen( mainViewModel: MainViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current + val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() @@ -57,6 +61,7 @@ fun OnlinePlaylistScreen( val playlist by viewModel.playlist.collectAsState() val itemsPage by viewModel.itemsPage.collectAsState() + val inLibrary by viewModel.inLibrary.collectAsState() val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() @@ -145,6 +150,29 @@ fun OnlinePlaylistScreen( fontWeight = FontWeight.Normal ) } + + Row { + IconButton( + onClick = { + database.query { + if (inLibrary) { + runBlocking { + database.playlist(playlist.id).first()?.playlist + }?.let { playlist -> + delete(playlist) + } + } else { + insert(playlist) + } + } + } + ) { + Icon( + painter = painterResource(if (inLibrary) R.drawable.ic_library_add_check else R.drawable.ic_library_add), + contentDescription = null + ) + } + } } } 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 dc1059c21..5a7c84eb6 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -5,21 +5,25 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.models.ItemsPage import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.update +import kotlinx.coroutines.flow.* import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class OnlinePlaylistViewModel @Inject constructor( + database: MusicDatabase, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val playlistId = savedStateHandle.get("playlistId")!! val playlist = MutableStateFlow(null) val itemsPage = MutableStateFlow(null) + val inLibrary = database.playlist(playlistId) + .map { it != null } + .stateIn(viewModelScope, SharingStarted.Eagerly, false) init { viewModelScope.launch { From 849c37337c32782efb5394d27fae577bfe3f4f77 Mon Sep 17 00:00:00 2001 From: ShareASmile <60492161+ShareASmile@users.noreply.github.com> Date: Mon, 30 Jan 2023 08:03:59 +0530 Subject: [PATCH 156/323] add punjabi strings --- app/src/main/res/values-pa/strings.xml | 306 +++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 app/src/main/res/values-pa/strings.xml diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml new file mode 100644 index 000000000..6fbfacfe2 --- /dev/null +++ b/app/src/main/res/values-pa/strings.xml @@ -0,0 +1,306 @@ + + + ਘਰ + ਗੀਤ + ਕਲਾਕਾਰ + ਐਲਬਮ + ਪਲੇਲਿਸਟ + ਐਕਸਪਲੋਰ ਕਰੋ + ਸੈਟਿੰਗਾਂ + ਹੁਣ ਚੱਲ ਰਿਹਾ ਹੈ + ਤਰੁੱਟੀ ਰਿਪੋਰਟ + + + ਦਿੱਖ + ਸਿਸਟਮ ਥੀਮ ਦਾ ਪਾਲਣ ਕਰੋ + ਥੀਮ ਦਾ ਰੰਗ + ਗੂੜ੍ਹਾ ਥੀਮ + ਚਾਲੂ + ਬੰਦ + ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ + ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹੀ ਟੈਬ + ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਕੱਸਟੋਮਾਈਜ਼ ਕਰੋ + ਬੋਲ ਟੈਕਸਟ ਸਥਿਤੀ + ਖੱਬੇ + ਵਿਚਕਾਰ + ਸੱਜੇ + + ਸਮੱਗਰੀ + ਲਾਗ-ਇਨ + ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਭਾਸ਼ਾ + ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਦੇਸ਼ + ਪ੍ਰੌਕਸੀ ਚਾਲੂ ਕਰੋ + ਪ੍ਰੌਕਸੀ ਕਿਸਮ + ਪ੍ਰੌਕਸੀ URL + ਪ੍ਰਭਾਵੀ ਹੋਣ ਲਈ ਮੁੜ-ਚਾਲੂ ਕਰੋ + + ਪਲੇਅਰ ਅਤੇ ਆਡੀਓ + ਆਡੀਓ ਕੁਆਲਿਟੀ + ਆਟੋ + ਉੱਚ + ਘੱਟ + ਪਰਸਿਸਟੈਂਟ ਕਤਾਰ + ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ + ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ + ਈਕੋਲਾਈਜ਼ਰ + + ਸਟੋਰੇਜ + SAF ਵਿੱਚ ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫਾਈਲਾਂ ਦੇਖੋ + ਇਹ ਕੁਝ ਡਿਵਾਈਸਾਂ ਵਿੱਚ ਕੰਮ ਨਹੀਂ ਕਰ ਸਕਦਾ ਹੈ + ਕੈਸ਼ + ਅਧਿਕਤਮ ਚਿੱਤਰ ਕੈਸ਼ੇ ਆਕਾਰ + ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ + ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ + %s ਵਰਤਿਆ ਗਿਆ + + ਜਨਰਲ + ਆਟੋ ਡਾਊਨਲੋਡ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤੇ ਜਾਣ 'ਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕਰੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਗੀਤ ਆਟੋ ਸ਼ਾਮਲ ਕਰੋ + ਗੀਤ ਪੂਰਾ ਚੱਲ ਜਾਣ ਤੇ ਆਪਣੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ + ਚਲਾਉਣ 'ਤੇ ਹੇਠਲੇ ਪਲੇਅਰ ਦਾ ਵਿਸਤਾਰ ਕਰੋ + ਨੋਟੀਫਿਕੇਸ਼ਨ ਵਿੱਚ ਹੋਰ ਕਾਰਵਾਈਆਂ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਦਿਖਾਓ + + ਗੋਪਨੀਯਤਾ + ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ + ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ + ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? + KuGou ਬੋਲ ਪ੍ਰਦਾਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ + + ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ + ਬੈਕਅੱਪ + ਰੀਸਟੋਰ + + ਦੇ ਬਾਰੇ + ਐਪ ਸੰਸਕਰਣ + + + Sakura + Red + Pink + Purple + Deep purple + Indigo + Blue + Light blue + Cyan + Teal + Green + Light green + Lime + Yellow + Amber + Orange + Deep orange + Brown + Blue grey + + + Search + Search YouTube Music… + Search library… + + + Liked songs + Downloaded songs + + + Details + Edit + Start radio + Play + Play next + Add to queue + Add to library + Download + Remove download + Import playlist + Add to playlist + View artist + View album + Refetch + Share + Delete + Search online + Choose other lyrics + + + Details + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + + Edit lyrics + Search lyrics + Choose lyrics + + Edit song + Song title + Song artists + Song title cannot be empty. + Song artist cannot be empty. + Save + + Create playlist + Playlist name + Playlist name cannot be empty. + + Edit artist + Artist name + Artist name cannot be empty. + + Duplicate artists + Artist %1$s already exists. + + Choose playlist + + Edit playlist + + Choose backup content + Choose restore content + Preferences + Database + Downloaded songs + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + + Music Player + Download + + + + %d song + %d songs + + + %d artist + %d artists + + + %d album + %d albums + + + %d playlist + %d playlists + + + + Retry + Play + Play All + Radio + Shuffle + Copy stacktrace + Report + Report on GitHub + + + Date added + Name + Artist + Year + Song count + Length + Play time + + + + %d song has been deleted. + %d songs has been deleted. + + + %d selected + + Undo + Can\'t identify this url. + + Song will play next + %d songs will play next + + + Artist will play next + %d artists will play next + + + Album will play next + %d albums will play next + + + Playlist will play next + %d playlists will play next + + Selected will play next + + Song added to queue + %d songs added to queue + + + Artist added to queue + %d artists added to queue + + + Album added to queue + %d albums added to queue + + + Playlist added to queue + %d playlists added to queue + + Selected added to queue + Added to library + Removed from library + Playlist imported + Added to %1$s + + Start downloading song + Start downloading %d songs + + Removed download + View + + + Like + Remove like + Add to library + Remove from library + + + All + Songs + Videos + Albums + Artists + Playlists + Community playlists + Featured playlists + + System default + From your library + + + Sorry, that should not have happened. + Copied to clipboard + + + No stream available + No network connection + Timeout + Unknown error + + + All songs + Searched songs + + + Lyrics not found + \ No newline at end of file From ebf7d0b620a15b0c9a8d69f049a042e729c32037 Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Mon, 30 Jan 2023 19:12:15 +0530 Subject: [PATCH 157/323] add more punjabi language strings --- app/src/main/res/values-pa/strings.xml | 370 +++++++++++-------------- 1 file changed, 160 insertions(+), 210 deletions(-) diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 6fbfacfe2..5c11687d0 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -1,5 +1,4 @@ - ਘਰ ਗੀਤ ਕਲਾਕਾਰ @@ -9,8 +8,6 @@ ਸੈਟਿੰਗਾਂ ਹੁਣ ਚੱਲ ਰਿਹਾ ਹੈ ਤਰੁੱਟੀ ਰਿਪੋਰਟ - - ਦਿੱਖ ਸਿਸਟਮ ਥੀਮ ਦਾ ਪਾਲਣ ਕਰੋ ਥੀਮ ਦਾ ਰੰਗ @@ -24,7 +21,6 @@ ਖੱਬੇ ਵਿਚਕਾਰ ਸੱਜੇ - ਸਮੱਗਰੀ ਲਾਗ-ਇਨ ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਭਾਸ਼ਾ @@ -33,7 +29,6 @@ ਪ੍ਰੌਕਸੀ ਕਿਸਮ ਪ੍ਰੌਕਸੀ URL ਪ੍ਰਭਾਵੀ ਹੋਣ ਲਈ ਮੁੜ-ਚਾਲੂ ਕਰੋ - ਪਲੇਅਰ ਅਤੇ ਆਡੀਓ ਆਡੀਓ ਕੁਆਲਿਟੀ ਆਟੋ @@ -43,7 +38,6 @@ ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ ਈਕੋਲਾਈਜ਼ਰ - ਸਟੋਰੇਜ SAF ਵਿੱਚ ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫਾਈਲਾਂ ਦੇਖੋ ਇਹ ਕੁਝ ਡਿਵਾਈਸਾਂ ਵਿੱਚ ਕੰਮ ਨਹੀਂ ਕਰ ਸਕਦਾ ਹੈ @@ -52,255 +46,211 @@ ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ %s ਵਰਤਿਆ ਗਿਆ - ਜਨਰਲ ਆਟੋ ਡਾਊਨਲੋਡ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤੇ ਜਾਣ 'ਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕਰੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤੇ ਜਾਣ ਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕਰੋ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਗੀਤ ਆਟੋ ਸ਼ਾਮਲ ਕਰੋ ਗੀਤ ਪੂਰਾ ਚੱਲ ਜਾਣ ਤੇ ਆਪਣੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ ਚਲਾਉਣ 'ਤੇ ਹੇਠਲੇ ਪਲੇਅਰ ਦਾ ਵਿਸਤਾਰ ਕਰੋ ਨੋਟੀਫਿਕੇਸ਼ਨ ਵਿੱਚ ਹੋਰ ਕਾਰਵਾਈਆਂ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਦਿਖਾਓ - ਗੋਪਨੀਯਤਾ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? KuGou ਬੋਲ ਪ੍ਰਦਾਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ - ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ ਬੈਕਅੱਪ ਰੀਸਟੋਰ - ਦੇ ਬਾਰੇ ਐਪ ਸੰਸਕਰਣ - - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey - - - Search - Search YouTube Music… - Search library… - - - Liked songs - Downloaded songs - - - Details - Edit - Start radio - Play - Play next - Add to queue - Add to library - Download - Remove download - Import playlist - Add to playlist - View artist - View album - Refetch - Share - Delete - Search online - Choose other lyrics - - - Details - Media id - MIME type - Codecs - Bitrate - Sample rate - Loudness - Volume - File size - Unknown - - Edit lyrics - Search lyrics - Choose lyrics - - Edit song - Song title - Song artists - Song title cannot be empty. - Song artist cannot be empty. - Save - - Create playlist - Playlist name - Playlist name cannot be empty. - - Edit artist - Artist name - Artist name cannot be empty. - - Duplicate artists - Artist %1$s already exists. - - Choose playlist - - Edit playlist - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Music Player - Download - - + ਗੂੜ੍ਹਾ ਗੁਲਾਬੀ + ਲਾਲ + ਗੁਲਾਬੀ + ਜਾਮਣੀ + ਗੂੜ੍ਹਾ ਜਾਮਣੀ + ਵੈਂਗਣੀ + ਨੀਲਾ + ਫਿੱਕਾ ਨੀਲਾ + ਹਰਾ-ਨੀਲਾ + ਟੀਲ ਰੰਗ + ਹਰਾ + ਫਿੱਕਾ ਹਰਾ + ਖੱਟਾ + ਪੀਲਾ + ਅੰਬਰ + ਸੰਤਰੀ + ਗੂੜ੍ਹਾ ਸੰਤਰੀ + ਭੂਰਾ + ਨੀਲਾ ਸਲੇਟੀ + ਖੋਜੋ + ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… + ਲਾਇਬ੍ਰੇਰੀ ਖੋਜੋ… + ਪਸੰਦ ਕੀਤੇ ਗੀਤ + ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ + ਵੇਰਵੇ + ਸੰਪਾਦਿਤ ਕਰੋ + ਰੇਡੀਓ ਸ਼ੁਰੂ ਕਰੋ + ਚਲਾਓ + ਅਗਲਾ ਚਲਾਓ + ਕਤਾਰ ਵਿੱਚ ਜੋੜ੍ਹੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ + ਡਾਊਨਲੋਡ + ਡਾਊਨਲੋਡ ਹਟਾਓ + ਪਲੇਲਿਸਟ ਅਯਾਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ + ਕਲਾਕਾਰ ਵੇਖੋ + ਐਲਬਮ ਵੇਖੋ + ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ + ਸਾਂਝਾ ਕਰੋ + ਮਿਟਾਓ + ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ + ਹੋਰ ਬੋਲ ਚੁਣੋ + ਵੇਰਵੇ + ਮੀਡੀਆ ਆਈਡੀ/string> + ਮਾਈਮ ਪ੍ਰਕਾਰ + ਕੋਡੈਕਸ + ਬਿੱਟਰੇਟ + ਸੈਂਪਲ ਰੇਟ + ਆਵਾਜ਼ ਦੀ ਤੀਬਰਤਾ + ਆਵਾਜ਼ + ਫਾਈਲ ਸਾਈਜ + ਅਗਿਆਤ + ਬੋਲ ਸੰਪਾਦਿਤ ਕਰੋ + ਬੋਲ ਖੋਜੋ + ਬੋਲ ਚੁਣੋ + ਗੀਤ ਸੰਪਾਦਿਤ ਕਰੋ + ਗੀਤ ਦਾ ਸਿਰਲੇਖ + ਗੀਤ ਕਲਾਕਾਰ + ਗੀਤ ਦਾ ਸਿਰਲੇਖ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + ਗੀਤ ਕਲਾਕਾਰ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + ਸਾਂਭੋ + ਪਲੇਲਿਸਟ ਬਣਾਓ + ਪਲੇਲਿਸਟ ਦਾ ਨਾਮ + ਪਲੇਲਿਸਟ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + ਕਲਾਕਾਰ ਦਾ ਸੰਪਾਦਨ ਕਰੋ + ਕਲਾਕਾਰ ਦਾ ਨਾਮ + ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। + ਪਹਿਲਾਂ ਮੌਜੂਦ ਕਲਾਕਾਰ + ਕਲਾਕਾਰ %1$s ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ। + ਪਲੇਲਿਸਟ ਚੁਣੋ + ਪਲੇਲਿਸ ਸੰਪਾਦਿਤ ਕਰੋ + ਬੈਕਅੱਪ ਸਮੱਗਰੀ ਚੁਣੋ + ਰੀਸਟੋਰ ਸਮੱਗਰੀ ਚੁਣੋ + ਤਰਜੀਹਾਂ + ਡਾਟਾਬੇਸ + ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ + ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ + ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ + ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ + ਸੰਗੀਤ ਪਲੇਅਰ + ਡਾਊਨਲੋਡ + - %d song - %d songs + %d ਗੀਤ + %d ਗੀਤ - %d artist - %d artists + %d ਕਲਾਕਾਰ + %d ਕਲਾਕਾਰ - %d album - %d albums + %d ਐਲਬਮ + %d ਐਲਬਮ - %d playlist - %d playlists + %d ਪਲੇਲਿਸਟ + %d ਪਲੇਲਿਸਟਾਂ - - - Retry - Play - Play All - Radio - Shuffle - Copy stacktrace - Report - Report on GitHub - - - Date added - Name - Artist - Year - Song count - Length - Play time - - + ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਚਲਾਓ + ਸਾਰੇ ਚਲਾਓ + ਰੇਡੀਓ + ਸ਼ਫਲ ਕਰੋ + ਸਟੈਕਟਰੇਸ ਕਾਪੀ ਕਰੋ + ਰਿਪੋਰਟ ਕਰੋ + ਗਿਟਹੱਬ ਤੇ ਰਿਪੋਰਟ ਕਰੋ + ਤਰੀਕ + ਨਾਮ + ਕਲਾਕਾਰ + ਸਾਲ + ਗਿਣਤੀ + ਲੰਬਾਈ + ਚਲਾਉਣ ਦਾ ਸਮਾਂ - %d song has been deleted. - %d songs has been deleted. + %d ਗੀਤ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ। + %d ਗੀਤ ਮਿਟਾ ਦਿੱਤੇ ਗਿਆ ਹਨ। - %d selected + %d ਚੁਣਿਆ ਗਿਆ - Undo - Can\'t identify this url. + ਵਾਪਿਸ + ਇਸ url ਦੀ ਪਛਾਣ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ। - Song will play next - %d songs will play next + ਅੱਗੇ ਗੀਤ ਚੱਲੇਗਾ + ਅੱਗੇ %d ਗੀਤ ਚੱਲਣਗੇ - Artist will play next - %d artists will play next + ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲੇਗਾ + %d ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲਣਗੇ - Album will play next - %d albums will play next + ਅੱਗੇ ਐਲਬਮ ਚੱਲੇਗੀ + ਅੱਗੇ %d ਐਲਬਮਾਂ ਚੱਲਣਗੀਆਂ - Playlist will play next - %d playlists will play next + ਪਲੇਲਿਸਟ ਅੱਗੇ ਚੱਲੇਗੀ + %d ਪਲੇਲਿਸਟਾਂ ਅੱਗੇ ਚੱਲਣਗੀਆਂ - Selected will play next + ਚੁਣਿਆ ਗਿਆ ਅਗਲਾ ਚੱਲੇਗਾ - Song added to queue - %d songs added to queue + ਗੀਤ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + %d ਗੀਤਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - Artist added to queue - %d artists added to queue + ਕਲਾਕਾਰ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + %d ਕਲਾਕਾਰਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - Album added to queue - %d albums added to queue + ਐਲਬਮ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀ + %d ਐਲਬਮਾਂ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀਆਂ - Playlist added to queue - %d playlists added to queue + ਪਲੇਲਿਸਟ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + %d ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - Selected added to queue - Added to library - Removed from library - Playlist imported - Added to %1$s + ਚੁਣੇ ਹੋਏ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕੀਤਾ ਗਿਆ + ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਇਆ ਗਿਆ + ਪਲੇਲਿਸਟ ਆਯਾਤ ਕੀਤੀ ਗਈ + %1$s ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ - Start downloading song - Start downloading %d songs + ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰੋ + %d ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰੋ - Removed download - View - - - Like - Remove like - Add to library - Remove from library - - + ਡਾਊਨਲੋਡ ਹਟਾਇਆ ਗਿਆ + ਵਿਊਜ਼ + ਪਸੰਦ + ਪਸੰਦ ਹਟਾਓ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ + ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਓ All - Songs - Videos - Albums - Artists - Playlists - Community playlists - Featured playlists - - System default - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - - - All songs - Searched songs - - - Lyrics not found - \ No newline at end of file + ਗੀਤ + ਵੀਡੀਓ + ਐਲਬਮ + ਕਲਾਕਾਰ + ਪਲੇਲਿਸਟਾਂ + ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ + ਫੀਚਰਡ ਪਲੇਲਿਸਟਾਂ + ਸਿਸਟਮਡੀਫ਼ਾਲਟ + ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ + ਮਾਫ਼ ਕਰਨਾ, ਅਜਿਹਾ ਨਹੀਂ ਹੋਣਾ ਚਾਹੀਦਾ ਸੀ। + ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ + ਕੋਈ ਸਟ੍ਰੀਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ + ਕੋਈ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਨਹੀਂ + ਸਮਾਂ ਸਮਾਪਤ + ਅਗਿਆਤ ਤਰੁੱਟੀ + ਸਾਰੇ ਗੀਤ + ਖੋਜੇ ਗਏ ਗੀਤ + ਬੋਲ ਨਹੀਂ ਮਿਲੇ + From 01fa0a79155490e3ae4692b98071d1d679ca53ec Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Mon, 30 Jan 2023 19:33:12 +0530 Subject: [PATCH 158/323] try to fix error in build --- app/src/main/res/values-pa/strings.xml | 1 - 1 file changed, 1 deletion(-) diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 5c11687d0..540cff11e 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -145,7 +145,6 @@ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ ਸੰਗੀਤ ਪਲੇਅਰ ਡਾਊਨਲੋਡ - %d ਗੀਤ %d ਗੀਤ From 4cfafb1fac975641d0fe9d9b1a2a3cd58d8d2c17 Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Mon, 30 Jan 2023 20:37:09 +0530 Subject: [PATCH 159/323] remove hindi language strings.. ..as it is to be done in a separate PR --- app/src/main/res/values-hi/strings.xml | 306 ------------------------- 1 file changed, 306 deletions(-) delete mode 100644 app/src/main/res/values-hi/strings.xml diff --git a/app/src/main/res/values-hi/strings.xml b/app/src/main/res/values-hi/strings.xml deleted file mode 100644 index 8dd5cd9e0..000000000 --- a/app/src/main/res/values-hi/strings.xml +++ /dev/null @@ -1,306 +0,0 @@ - - - घर - गीत - कलाकार - एलबम - प्लेलिस्ट - Explore - Settings - Now playing - Error report - - - Appearance - Follow system theme - Theme color - Dark theme - On - Off - Follow system - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Content - Login - Default content language - Default content country - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Auto download - Download song when added to library - Auto add song to library - Add song to your library when it completes playing - Expand bottom player on play - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - About - App version - - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey - - - Search - Search YouTube Music… - Search library… - - - Liked songs - Downloaded songs - - - Details - Edit - Start radio - Play - Play next - Add to queue - Add to library - Download - Remove download - Import playlist - Add to playlist - View artist - View album - Refetch - Share - Delete - Search online - Choose other lyrics - - - Details - Media id - MIME type - Codecs - Bitrate - Sample rate - Loudness - Volume - File size - Unknown - - Edit lyrics - Search lyrics - Choose lyrics - - Edit song - Song title - Song artists - Song title cannot be empty. - Song artist cannot be empty. - Save - - Create playlist - Playlist name - Playlist name cannot be empty. - - Edit artist - Artist name - Artist name cannot be empty. - - Duplicate artists - Artist %1$s already exists. - - Choose playlist - - Edit playlist - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Music Player - Download - - - - %d song - %d songs - - - %d artist - %d artists - - - %d album - %d albums - - - %d playlist - %d playlists - - - - Retry - Play - Play All - Radio - Shuffle - Copy stacktrace - Report - Report on GitHub - - - Date added - Name - Artist - Year - Song count - Length - Play time - - - - %d song has been deleted. - %d songs has been deleted. - - - %d selected - - Undo - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library - Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs - - Removed download - View - - - Like - Remove like - Add to library - Remove from library - - - All - Songs - Videos - Albums - Artists - Playlists - Community playlists - Featured playlists - - System default - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - - - All songs - Searched songs - - - Lyrics not found - From 2cf4bd6976f6f6364ee5082a41366654a55f0899 Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Mon, 30 Jan 2023 21:18:53 +0530 Subject: [PATCH 160/323] Update strings.xml From a3fbdf41efdf477333f4667ff6f097e8d3ab4472 Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Tue, 31 Jan 2023 04:19:43 +0530 Subject: [PATCH 161/323] micro-tune pa translations.. ..also fix build --- app/src/main/res/values-pa/strings.xml | 103 +++++++++++++++++-------- 1 file changed, 69 insertions(+), 34 deletions(-) diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 540cff11e..fc5ceb99a 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -1,4 +1,5 @@ + ਘਰ ਗੀਤ ਕਲਾਕਾਰ @@ -8,6 +9,8 @@ ਸੈਟਿੰਗਾਂ ਹੁਣ ਚੱਲ ਰਿਹਾ ਹੈ ਤਰੁੱਟੀ ਰਿਪੋਰਟ + + ਦਿੱਖ ਸਿਸਟਮ ਥੀਮ ਦਾ ਪਾਲਣ ਕਰੋ ਥੀਮ ਦਾ ਰੰਗ @@ -15,9 +18,9 @@ ਚਾਲੂ ਬੰਦ ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ - ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹੀ ਟੈਬ - ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਕੱਸਟੋਮਾਈਜ਼ ਕਰੋ - ਬੋਲ ਟੈਕਸਟ ਸਥਿਤੀ + ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹੀ ਟੈਬ + ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਅਨੁਕੂਲ ਬਣਾਓ + ਬੋਲਾਂ ਦੀ ਟੈਕਸਟ ਸਥਿਤੀ ਖੱਬੇ ਵਿਚਕਾਰ ਸੱਜੇ @@ -34,8 +37,8 @@ ਆਟੋ ਉੱਚ ਘੱਟ - ਪਰਸਿਸਟੈਂਟ ਕਤਾਰ - ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ + ਨਿਰੰਤਰ ਕਤਾਰ + ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ ਈਕੋਲਾਈਜ਼ਰ ਸਟੋਰੇਜ @@ -51,9 +54,9 @@ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤੇ ਜਾਣ ਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕਰੋ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਗੀਤ ਆਟੋ ਸ਼ਾਮਲ ਕਰੋ ਗੀਤ ਪੂਰਾ ਚੱਲ ਜਾਣ ਤੇ ਆਪਣੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ - ਚਲਾਉਣ 'ਤੇ ਹੇਠਲੇ ਪਲੇਅਰ ਦਾ ਵਿਸਤਾਰ ਕਰੋ + ਚਲਾਉਣ ਤੇ ਹੇਠਲੇ ਪਲੇਅਰ ਦਾ ਵਿਸਤਾਰ ਕਰੋ ਨੋਟੀਫਿਕੇਸ਼ਨ ਵਿੱਚ ਹੋਰ ਕਾਰਵਾਈਆਂ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਦਿਖਾਓ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਵਿਖਾਓ ਗੋਪਨੀਯਤਾ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ @@ -64,6 +67,8 @@ ਰੀਸਟੋਰ ਦੇ ਬਾਰੇ ਐਪ ਸੰਸਕਰਣ + + ਗੂੜ੍ਹਾ ਗੁਲਾਬੀ ਲਾਲ ਗੁਲਾਬੀ @@ -83,11 +88,17 @@ ਗੂੜ੍ਹਾ ਸੰਤਰੀ ਭੂਰਾ ਨੀਲਾ ਸਲੇਟੀ + + ਖੋਜੋ ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… ਲਾਇਬ੍ਰੇਰੀ ਖੋਜੋ… + + ਪਸੰਦ ਕੀਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ + + ਵੇਰਵੇ ਸੰਪਾਦਿਤ ਕਰੋ ਰੇਡੀਓ ਸ਼ੁਰੂ ਕਰੋ @@ -106,17 +117,19 @@ ਮਿਟਾਓ ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ ਹੋਰ ਬੋਲ ਚੁਣੋ + + ਵੇਰਵੇ - ਮੀਡੀਆ ਆਈਡੀ/string> + ਮੀਡੀਆ ਆਈਡੀ ਮਾਈਮ ਪ੍ਰਕਾਰ ਕੋਡੈਕਸ ਬਿੱਟਰੇਟ ਸੈਂਪਲ ਰੇਟ - ਆਵਾਜ਼ ਦੀ ਤੀਬਰਤਾ + ਤੀਬਰਤਾ ਆਵਾਜ਼ - ਫਾਈਲ ਸਾਈਜ + ਫਾਈਲ ਆਕਾਰ ਅਗਿਆਤ - ਬੋਲ ਸੰਪਾਦਿਤ ਕਰੋ + ਬੋਲਾਂ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ ਬੋਲ ਖੋਜੋ ਬੋਲ ਚੁਣੋ ਗੀਤ ਸੰਪਾਦਿਤ ਕਰੋ @@ -128,13 +141,13 @@ ਪਲੇਲਿਸਟ ਬਣਾਓ ਪਲੇਲਿਸਟ ਦਾ ਨਾਮ ਪਲੇਲਿਸਟ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। - ਕਲਾਕਾਰ ਦਾ ਸੰਪਾਦਨ ਕਰੋ + ਕਲਾਕਾਰ ਸੰਪਾਦਿਤ ਕਰੋ ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। ਪਹਿਲਾਂ ਮੌਜੂਦ ਕਲਾਕਾਰ - ਕਲਾਕਾਰ %1$s ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ। + ਕਲਾਕਾਰ %1$s ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ ਪਲੇਲਿਸਟ ਚੁਣੋ - ਪਲੇਲਿਸ ਸੰਪਾਦਿਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਸੰਪਾਦਿਤ ਕਰੋ ਬੈਕਅੱਪ ਸਮੱਗਰੀ ਚੁਣੋ ਰੀਸਟੋਰ ਸਮੱਗਰੀ ਚੁਣੋ ਤਰਜੀਹਾਂ @@ -143,8 +156,12 @@ ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ + + ਸੰਗੀਤ ਪਲੇਅਰ ਡਾਊਨਲੋਡ + + %d ਗੀਤ %d ਗੀਤ @@ -161,6 +178,8 @@ %d ਪਲੇਲਿਸਟ %d ਪਲੇਲਿਸਟਾਂ + + ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ ਚਲਾਓ ਸਾਰੇ ਚਲਾਓ @@ -169,87 +188,103 @@ ਸਟੈਕਟਰੇਸ ਕਾਪੀ ਕਰੋ ਰਿਪੋਰਟ ਕਰੋ ਗਿਟਹੱਬ ਤੇ ਰਿਪੋਰਟ ਕਰੋ - ਤਰੀਕ + + + ਮਿਤੀ ਨਾਮ ਕਲਾਕਾਰ ਸਾਲ ਗਿਣਤੀ ਲੰਬਾਈ ਚਲਾਉਣ ਦਾ ਸਮਾਂ + + %d ਗੀਤ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ। - %d ਗੀਤ ਮਿਟਾ ਦਿੱਤੇ ਗਿਆ ਹਨ। + %d ਗੀਤ ਮਿਟਾ ਦਿੱਤੇ ਗਏ ਹਨ। - %d ਚੁਣਿਆ ਗਿਆ + %d ਚੁਣਿਆ ਗਿਆ + %d ਚੁਣੇ ਗਏ ਵਾਪਿਸ ਇਸ url ਦੀ ਪਛਾਣ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ। - ਅੱਗੇ ਗੀਤ ਚੱਲੇਗਾ + ਅੱਗੇ %d ਗੀਤ ਚੱਲੇਗਾ ਅੱਗੇ %d ਗੀਤ ਚੱਲਣਗੇ - ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲੇਗਾ + %d ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲੇਗਾ %d ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲਣਗੇ - ਅੱਗੇ ਐਲਬਮ ਚੱਲੇਗੀ + ਅੱਗੇ %d ਐਲਬਮ ਚੱਲੇਗੀ ਅੱਗੇ %d ਐਲਬਮਾਂ ਚੱਲਣਗੀਆਂ - ਪਲੇਲਿਸਟ ਅੱਗੇ ਚੱਲੇਗੀ + %d ਪਲੇਲਿਸਟ ਅੱਗੇ ਚੱਲੇਗੀ %d ਪਲੇਲਿਸਟਾਂ ਅੱਗੇ ਚੱਲਣਗੀਆਂ - ਚੁਣਿਆ ਗਿਆ ਅਗਲਾ ਚੱਲੇਗਾ + ਇਸ ਤੋਂ ਬਾਅਦ ਚੁਣਿਆ ਗਿਆ ਗੀਤ ਚੱਲੇਗਾ - ਗੀਤ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + %d ਗੀਤ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ %d ਗੀਤਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - ਕਲਾਕਾਰ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + %d ਕਲਾਕਾਰ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ %d ਕਲਾਕਾਰਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - ਐਲਬਮ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀ + %d ਐਲਬਮ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀ %d ਐਲਬਮਾਂ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀਆਂ - ਪਲੇਲਿਸਟ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ + %d ਪਲੇਲਿਸਟ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ %d ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ ਚੁਣੇ ਹੋਏ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕੀਤਾ ਗਿਆ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਇਆ ਗਿਆ - ਪਲੇਲਿਸਟ ਆਯਾਤ ਕੀਤੀ ਗਈ + ਪਲੇਲਿਸਟ ਅਯਾਤ ਕੀਤੀ ਗਈ %1$s ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ - ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰੋ - %d ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰੋ + %d ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰੋ + %d ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨੇ ਸ਼ੁਰੂ ਕਰੋ ਡਾਊਨਲੋਡ ਹਟਾਇਆ ਗਿਆ ਵਿਊਜ਼ + + ਪਸੰਦ ਪਸੰਦ ਹਟਾਓ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਓ - All + + + ਸਾਰੇ ਗੀਤ ਵੀਡੀਓ ਐਲਬਮ ਕਲਾਕਾਰ ਪਲੇਲਿਸਟਾਂ ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ - ਫੀਚਰਡ ਪਲੇਲਿਸਟਾਂ - ਸਿਸਟਮਡੀਫ਼ਾਲਟ + ਪ੍ਰਦਰਸ਼ਿਤ ਪਲੇਲਿਸਟਾਂ + + ਸਿਸਟਮ ਡੀਫ਼ਾਲਟ ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ + + ਮਾਫ਼ ਕਰਨਾ, ਅਜਿਹਾ ਨਹੀਂ ਹੋਣਾ ਚਾਹੀਦਾ ਸੀ। - ਕਲਿੱਪਬੋਰਡ 'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ + ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ ਕੋਈ ਸਟ੍ਰੀਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ ਕੋਈ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਨਹੀਂ ਸਮਾਂ ਸਮਾਪਤ ਅਗਿਆਤ ਤਰੁੱਟੀ + + ਸਾਰੇ ਗੀਤ ਖੋਜੇ ਗਏ ਗੀਤ + + ਬੋਲ ਨਹੀਂ ਮਿਲੇ From d4125a93e8b5b81ac6ba85d0f80e7b829ef4e39c Mon Sep 17 00:00:00 2001 From: K Gill <60492161+ShareASmile@users.noreply.github.com> Date: Tue, 31 Jan 2023 04:39:46 +0530 Subject: [PATCH 162/323] fine tune corrections in punjabi strings --- app/src/main/res/values-pa/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index fc5ceb99a..fe5ffc872 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -18,8 +18,8 @@ ਚਾਲੂ ਬੰਦ ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ - ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹੀ ਟੈਬ - ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਅਨੁਕੂਲ ਬਣਾਓ + ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹਣ ਵਾਲੀ ਟੈਬ + ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਆਪਣੀ ਪਸੰਦ ਦਾ ਢਾਲੋ ਬੋਲਾਂ ਦੀ ਟੈਕਸਟ ਸਥਿਤੀ ਖੱਬੇ ਵਿਚਕਾਰ @@ -44,7 +44,7 @@ ਸਟੋਰੇਜ SAF ਵਿੱਚ ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫਾਈਲਾਂ ਦੇਖੋ ਇਹ ਕੁਝ ਡਿਵਾਈਸਾਂ ਵਿੱਚ ਕੰਮ ਨਹੀਂ ਕਰ ਸਕਦਾ ਹੈ - ਕੈਸ਼ + ਕੈਸ਼ੇ ਅਧਿਕਤਮ ਚਿੱਤਰ ਕੈਸ਼ੇ ਆਕਾਰ ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ From c20c39aca2b5dc84f4609805e428a0a566c52c2d Mon Sep 17 00:00:00 2001 From: Fjuro Date: Tue, 31 Jan 2023 15:30:57 +0100 Subject: [PATCH 163/323] Create values-cs/strings.xml --- app/src/main/res/values-cs/strings.xml | 306 +++++++++++++++++++++++++ 1 file changed, 306 insertions(+) create mode 100644 app/src/main/res/values-cs/strings.xml diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml new file mode 100644 index 000000000..088d7aeb3 --- /dev/null +++ b/app/src/main/res/values-cs/strings.xml @@ -0,0 +1,306 @@ + + + Domů + Skladby + Umělci + Alba + Playlisty + Procházet + Nastavení + Právě hraje + Hlášení chyb + + + Vzhled + Podle systému + Barva motivu + Tmavý motiv + Zap + Vyp + Podle systému + Výchozí karta + Přizpůsobit navigační karty + Pozice textů + Vlevo + Uprostřed + Vpravo + + Obsah + Přihlásit se + Výchozí jazyk obsahu + Výchozí země obsahu + Povolit proxy + Typ proxy + Adresa URL proxy + Restartujte pro uplatnění změn + + Přehrávač a zvuk + Kvalita zvuku + Automaticky + Vysoká + Nízká + Ukládat frontu + Přeskakovat ticho + Normalizace zvuku + Ekvalizér + + Úložiště + Zobrazit stažené soubory v SAF + Nemusí fungovat na všech zařízeních + Mezipaměť + Maximální velikost mezipaměti obrázků + Vymazat mezipaměť obrázků + Maximální velikost mezipaměti skladeb + Využito %s + + Obecné + Automatické stahování + Stáhnout skladbu při přidání do knihovny + Automaticky přidat skladbu do knihovny + Přidat skladbu do vaší knihovny po skončení jejího přehrávání + Při přehrání rozšířit spodní přehrávač + Více akcí v oznámení + Zobrazit tlačítka přidání do knihovny a oblíbení + + Soukromí + Pozastavit historii vyhledávání + Vymazat historii vyhledávání + Opravdu chcete vymazat celou historii vyhledávání? + Povolit poskytovatele textů KuGou + + Záloha a obnovení + Zálohovat + Obnovit + + O aplikaci + Verze aplikace + + + Sakura + Červená + Růžová + Fialová + Tmavě fialová + Indigová + Modrá + Světle modrá + Azurová + Tyrkysová + Zelená + Světle zelená + Limetková + Žlutá + Jantarová + Oranžová + Tmavě oranžová + Hnědá + Modře šedá + + + Vyhledávání + Hledat v YouTube Music… + Hledat v knihovně… + + + Oblíbené skladby + Stažené skladby + + + Podrobnosti + Upravit + Spustit rádio + Přehrát + Přehrát jako další + Přidat do fronty + Přidat do knihovny + Stáhnout + Odebrat stahování + Importovat playlist + Přidat do playlistu + Zobrazit umělce + Zobrazit album + Obnovit + Sdílet + Odstranit + Hledat online + Vybrat jiné texty + + + Podrobnosti + ID média + Typ MIME + Kodeky + Přenosová rychlost + Vzorkovací frekvence + Hlučnost + Hlasitost + Velikost souboru + Neznámé + + Upravit texty + Hledat texty + Vybrat texty + + Upravit skladbu + Název skladby + Umělci skladby + Název skladby nemůže být prázdný. + Umělec skladby nemůže být prázdný. + Uložit + + Vytvořit playlist + Název playlistu + Název playlistu nemůže být prázdný. + + Upravit umělce + Jméno umělce + Jméno umělce nemůže být prázdné. + + Duplicitní umělci + Umělec %1$s již existuje. + + Vybrat playlist + + Upravit playlist + + Vybrat obsah zálohy + Vybrat obsah obnovení + Předvolby + Databáze + Stažené skladby + Záloha úspěšně vytvořena + Nepodařilo se vytvořit zálohu + Nepodařilo se obnovit zálohu + + + Hudební přehrávač + Stáhnout + + + + %d skladba + %d skladeb + + + %d umělec + %d umělců + + + %d album + %d alb + + + %d playlis + %d playlistů + + + + Zkusit znovu + Přehrát + Přehrát vše + Rádio + Náhodně + Kopírovat stacktrace + Nahlásit + Nahlásit na GitHub + + + Datum přidání + Název + Umělec + Rok + Počet skladeb + Délka + Doba přehrávání + + + + Byla odstraněna %d skladba. + Bylo odstraněno %d skladeb. + + + Vybráno %d + + Zrušit + Tuto adresu nelze identifikovat. + + Skladba bude hrát jako další + %d skladeb bude hrát jako další + + + Umělec bude hrát jako další + %d umělců bude hrát jako další + + + Album bude hrát jako další + %d alb bude hrát jako další + + + Playlist bude hrát jako další + %d playlistů bude hrát jako další + + Vybrané budou přehrány jako další + + Skladba přidána do fronty + %d skladeb přidáno do fronty + + + Umělec přidán do fronty + %d umělců přidáno do fronty + + + Album přidáno do fronty + %d alb přidáno do fronty + + + Playlist přidán do fronty + %d playlistů přidáno do fronty + + Vybrané přidány do fronty + Přidáno do knihovny + Odebráno z knihovny + Playlist importován + Přidáno do playlistu %1$s + + Stáhnout skladbu + Stáhnout %d skladen + + Odebrané stahování + Zobrazit + + + Oblíbené + Odebrat z oblíbených + Přidat do knihovny + Odebrat z knihovny + + + Vše + Skladby + Videa + Alba + Umělci + Playlisty + Komunitní playlisty + Doporučené playlisty + + Podle systému + Z vaší knihovny + + + Omlouváme se, tohle se nemělo stát. + Zkopírováno do schránky + + + Není dostupný žádný stream + Není dostupné připojení k internetu + Vypršel čas + Neznámá chyba + + + Všechny skladby + Hledané skladby + + + Texty nenalezeny + From 9a6dc003be210bfca4542d13105513a9b73534f9 Mon Sep 17 00:00:00 2001 From: Fjuro Date: Fri, 3 Feb 2023 11:12:34 +0100 Subject: [PATCH 164/323] Update strings.xml --- app/src/main/res/values-cs/strings.xml | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 088d7aeb3..ccb46e13d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -179,18 +179,22 @@ %d skladba + %d skladby %d skladeb %d umělec + %d umělci %d umělců %d album + %d alba %d alb - %d playlis + %d playlist + %d playlisty %d playlistů From a7116ac7e510667b06d51c7c4ff61b8b2ecec02b Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 3 Feb 2023 21:56:30 +0800 Subject: [PATCH 165/323] Add import playlist button --- .../6.json | 712 ++++++++++++++++++ .../java/com/zionhuang/music/MainActivity.kt | 22 +- .../com/zionhuang/music/db/DatabaseDao.kt | 21 +- .../com/zionhuang/music/db/MusicDatabase.kt | 57 +- .../music/db/entities/PlaylistEntity.kt | 14 +- .../zionhuang/music/db/entities/SongEntity.kt | 5 - .../zionhuang/music/playback/MusicService.kt | 4 +- .../music/ui/component/BigSeekBar.kt | 2 +- .../com/zionhuang/music/ui/component/Items.kt | 21 +- .../ui/screens/artist/ArtistItemsScreen.kt | 22 +- .../music/ui/screens/artist/ArtistScreen.kt | 2 +- .../screens/library/LibraryPlaylistsScreen.kt | 6 +- .../screens/playlist/OnlinePlaylistScreen.kt | 432 +++++------ .../ui/screens/search/LocalSearchScreen.kt | 2 +- .../ui/screens/search/OnlineSearchResult.kt | 20 +- .../ui/screens/search/OnlineSearchScreen.kt | 18 +- .../com/zionhuang/music/utils/StringUtils.kt | 24 +- .../viewmodels/OnlinePlaylistViewModel.kt | 37 +- 18 files changed, 1059 insertions(+), 362 deletions(-) create mode 100644 app/schemas/com.zionhuang.music.db.InternalDatabase/6.json diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/6.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/6.json new file mode 100644 index 000000000..5ec90c00b --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/6.json @@ -0,0 +1,712 @@ +{ + "formatVersion": 1, + "database": { + "version": 6, + "identityHash": "e099eec2e21e2def3fd2dc8b29798a02", + "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, `downloadState` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `modifyDate` INTEGER NOT NULL, 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": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modifyDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": [] + } + ], + "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, 'e099eec2e21e2def3fd2dc8b29798a02')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index ef828ada3..4fccf10a4 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -373,7 +373,20 @@ class MainActivity : ComponentActivity() { ) } composable( - route = "playlist/{playlistId}", + route = "online_playlist/{playlistId}", + arguments = listOf( + navArgument("playlistId") { + type = NavType.StringType + } + ) + ) { + OnlinePlaylistScreen( + appBarConfig = appBarConfig, + navController = navController + ) + } + composable( + route = "local_playlist/{playlistId}", arguments = listOf( navArgument("playlistId") { type = NavType.StringType @@ -386,13 +399,8 @@ class MainActivity : ComponentActivity() { appBarConfig = appBarConfig, navController = navController ) - } else if (playlistId.startsWith("LP")) { - LocalPlaylistScreen( - appBarConfig = appBarConfig, - navController = navController - ) } else { - OnlinePlaylistScreen( + LocalPlaylistScreen( appBarConfig = appBarConfig, navController = navController ) 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 3d51663fb..ea2a6971e 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -2,7 +2,6 @@ package com.zionhuang.music.db import androidx.room.* import androidx.sqlite.db.SupportSQLiteQuery -import com.zionhuang.innertube.models.PlaylistItem import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.pages.AlbumPage import com.zionhuang.innertube.pages.ArtistPage @@ -37,7 +36,7 @@ interface DatabaseDao { fun songsByRowIdDesc(): Flow> @Transaction - @Query("SELECT * FROM song ORDER BY create_date DESC") + @Query("SELECT * FROM song ORDER BY createDate DESC") fun songsByCreateDateDesc(): Flow> @Transaction @@ -72,7 +71,7 @@ interface DatabaseDao { songs.filter { it.song.downloadState == STATE_DOWNLOADED } } - @Query("SELECT COUNT(*) FROM song WHERE download_state = $STATE_DOWNLOADED") + @Query("SELECT COUNT(*) FROM song WHERE downloadState = $STATE_DOWNLOADED") fun downloadedSongsCount(): Flow @Transaction @@ -84,7 +83,7 @@ interface DatabaseDao { fun playlistSongs(playlistId: String): Flow> @Transaction - @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY create_date DESC") + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY createDate DESC") fun artistSongsByCreateDateDesc(artistId: String): Flow> @Transaction @@ -180,7 +179,7 @@ interface DatabaseDao { fun albumWithSongs(albumId: String): Flow @Transaction - @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY createDate DESC") + @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist ORDER BY rowId DESC") fun playlistsByCreateDateDesc(): Flow> @Transaction @@ -207,7 +206,7 @@ interface DatabaseDao { fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND download_state = $STATE_DOWNLOADED LIMIT :previewSize") + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND downloadState = $STATE_DOWNLOADED LIMIT :previewSize") fun searchDownloadedSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction @@ -343,16 +342,6 @@ interface DatabaseDao { }.forEach(::insert) } - fun insert(playlist: PlaylistItem) { - insert(PlaylistEntity( - id = playlist.id, - name = playlist.title, - author = playlist.author.name, - authorId = playlist.author.id, - thumbnailUrl = playlist.thumbnail - )) - } - @Update fun update(song: SongEntity) 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 9b2de16bf..a0afc31cf 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -1,16 +1,17 @@ package com.zionhuang.music.db -import android.database.sqlite.SQLiteDatabase.CONFLICT_ABORT +import android.database.sqlite.SQLiteDatabase import androidx.core.content.contentValuesOf import androidx.room.* +import androidx.room.migration.AutoMigrationSpec import androidx.room.migration.Migration import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import com.zionhuang.music.db.entities.* -import com.zionhuang.music.db.entities.ArtistEntity.Companion.generateArtistId -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId import com.zionhuang.music.extensions.toSQLiteQuery +import timber.log.Timber import java.time.Instant +import java.time.LocalDateTime import java.time.ZoneOffset import java.util.* @@ -57,12 +58,13 @@ class MusicDatabase( SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 5, + version = 6, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4), - AutoMigration(from = 4, to = 5) + AutoMigration(from = 4, to = 5), + AutoMigration(from = 5, to = 6, spec = Migration5To6::class) ] ) @TypeConverters(Converters::class) @@ -82,7 +84,7 @@ val MIGRATION_1_2 = object : Migration(1, 2) { database.query("SELECT * FROM artist".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { val oldId = cursor.getInt(0) - val newId = generateArtistId() + val newId = ArtistEntity.generateArtistId() artistMap[oldId] = newId artists.add(ArtistEntity( id = newId, @@ -96,7 +98,7 @@ val MIGRATION_1_2 = object : Migration(1, 2) { database.query("SELECT * FROM playlist".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { val oldId = cursor.getInt(0) - val newId = generatePlaylistId() + val newId = PlaylistEntity.generatePlaylistId() playlistMap[oldId] = newId playlists.add(PlaylistEntity( id = newId, @@ -169,7 +171,7 @@ val MIGRATION_1_2 = object : Migration(1, 2) { database.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position") database.execSQL("CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position") artists.forEach { artist -> - database.insert("artist", CONFLICT_ABORT, contentValuesOf( + database.insert("artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "id" to artist.id, "name" to artist.name, "createDate" to converters.dateToTimestamp(artist.createDate), @@ -177,35 +179,35 @@ val MIGRATION_1_2 = object : Migration(1, 2) { )) } songs.forEach { song -> - database.insert("song", CONFLICT_ABORT, contentValuesOf( + database.insert("song", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "id" to song.id, "title" to song.title, "duration" to song.duration, "liked" to song.liked, "totalPlayTime" to song.totalPlayTime, - "isTrash" to song.isTrash, + "isTrash" to false, "download_state" to song.downloadState, "create_date" to converters.dateToTimestamp(song.createDate), "modify_date" to converters.dateToTimestamp(song.modifyDate) )) } songArtistMaps.forEach { songArtistMap -> - database.insert("song_artist_map", CONFLICT_ABORT, contentValuesOf( + database.insert("song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "songId" to songArtistMap.songId, "artistId" to songArtistMap.artistId, "position" to songArtistMap.position )) } playlists.forEach { playlist -> - database.insert("playlist", CONFLICT_ABORT, contentValuesOf( + database.insert("playlist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "id" to playlist.id, "name" to playlist.name, - "createDate" to converters.dateToTimestamp(playlist.createDate), - "lastUpdateTime" to converters.dateToTimestamp(playlist.lastUpdateTime) + "createDate" to converters.dateToTimestamp(LocalDateTime.now()), + "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now()) )) } playlistSongMaps.forEach { playlistSongMap -> - database.insert("playlist_song_map", CONFLICT_ABORT, contentValuesOf( + database.insert("playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "playlistId" to playlistSongMap.playlistId, "songId" to playlistSongMap.songId, "position" to playlistSongMap.position @@ -213,3 +215,28 @@ val MIGRATION_1_2 = object : Migration(1, 2) { } } } + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "isTrash"), + DeleteColumn(tableName = "playlist", columnName = "author"), + DeleteColumn(tableName = "playlist", columnName = "authorId"), + DeleteColumn(tableName = "playlist", columnName = "year"), + DeleteColumn(tableName = "playlist", columnName = "thumbnailUrl"), + DeleteColumn(tableName = "playlist", columnName = "createDate"), + DeleteColumn(tableName = "playlist", columnName = "lastUpdateTime") +) +@RenameColumn.Entries( + RenameColumn(tableName = "song", fromColumnName = "download_state", toColumnName = "downloadState"), + RenameColumn(tableName = "song", fromColumnName = "create_date", toColumnName = "createDate"), + RenameColumn(tableName = "song", fromColumnName = "modify_date", toColumnName = "modifyDate") +) +class Migration5To6 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id FROM playlist WHERE id NOT LIKE 'LP%'").use { cursor -> + while (cursor.moveToNext()) { + Timber.d("id = ${cursor.getString(0)}") + db.execSQL("UPDATE playlist SET browseID = '${cursor.getString(0)}' WHERE id = '${cursor.getString(0)}'") + } + } + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt index 1b2ee150b..b2bf56092 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/PlaylistEntity.kt @@ -4,26 +4,14 @@ import androidx.compose.runtime.Immutable import androidx.room.Entity import androidx.room.PrimaryKey import org.apache.commons.lang3.RandomStringUtils -import java.time.LocalDateTime @Immutable @Entity(tableName = "playlist") data class PlaylistEntity( @PrimaryKey val id: String = generatePlaylistId(), val name: String, - val author: String? = null, - val authorId: String? = null, - val year: Int? = null, - val thumbnailUrl: String? = null, - val createDate: LocalDateTime = LocalDateTime.now(), - val lastUpdateTime: LocalDateTime = LocalDateTime.now(), + val browseId: String? = null, ) { - val isLocalPlaylist: Boolean - get() = id.startsWith("LP") - - val isYouTubePlaylist: Boolean - get() = !isLocalPlaylist - companion object { const val LIKED_PLAYLIST_ID = "LP_LIKED" const val DOWNLOADED_PLAYLIST_ID = "LP_DOWNLOADED" 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 cf549a794..406678660 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 @@ -1,7 +1,6 @@ package com.zionhuang.music.db.entities import androidx.compose.runtime.Immutable -import androidx.room.ColumnInfo import androidx.room.Entity import androidx.room.PrimaryKey import java.time.LocalDateTime @@ -17,12 +16,8 @@ data class SongEntity( val albumName: String? = null, val liked: Boolean = false, val totalPlayTime: Long = 0, // in milliseconds - val isTrash: Boolean = false, - @ColumnInfo(name = "download_state") val downloadState: Int = STATE_NOT_DOWNLOADED, - @ColumnInfo(name = "create_date") val createDate: LocalDateTime = LocalDateTime.now(), - @ColumnInfo(name = "modify_date") val modifyDate: LocalDateTime = LocalDateTime.now(), ) { fun toggleLike() = copy(liked = !liked) 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 2db18d1df..87745fbd5 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -132,8 +132,8 @@ class MusicService : MediaBrowserServiceCompat() { result.sendResult((listOf( mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.song_count, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.song_count, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) - ) + database.playlistsByCreateDateDesc().first().filter { it.playlist.isLocalPlaylist }.map { playlist -> - mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount), playlist.playlist.thumbnailUrl?.toUri() ?: playlist.thumbnails.firstOrNull()?.toUri()) + ) + database.playlistsByCreateDateDesc().first().map { playlist -> + mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri()) }).toMutableList()) } else -> when { diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt index 3e3283ae4..09d2b8ecf 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/BigSeekBar.kt @@ -35,7 +35,7 @@ fun BigSeekBar( width = it.size.width.toFloat() } .pointerInput(progressProvider) { - detectHorizontalDragGestures { change, dragAmount -> + detectHorizontalDragGestures { _, dragAmount -> onProgressChange((progressProvider() + dragAmount * 1.2f / width).coerceIn(0f, 1f)) } } 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 f33ac0450..5c4928f46 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 @@ -279,25 +279,15 @@ inline fun PlaylistListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = playlist.playlist.name, - subtitle = if (playlist.playlist.isYouTubePlaylist) playlist.playlist.author else pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + subtitle = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), thumbnailContent = { - if (playlist.playlist.thumbnailUrl != null) { - AsyncImage( - model = playlist.playlist.thumbnailUrl, - contentDescription = null, - contentScale = ContentScale.Crop, - modifier = Modifier - .size(ListThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - } else if (playlist.thumbnails.isEmpty()) { - Icon( + when (playlist.thumbnails.size) { + 0 -> Icon( painter = painterResource(R.drawable.ic_queue_music), contentDescription = null, modifier = Modifier.size(ListThumbnailSize) ) - } else if (playlist.thumbnails.size == 1) { - AsyncImage( + 1 -> AsyncImage( model = playlist.thumbnails[0], contentDescription = null, contentScale = ContentScale.Crop, @@ -305,8 +295,7 @@ inline fun PlaylistListItem( .size(ListThumbnailSize) .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) - } else { - Box( + else -> Box( modifier = Modifier .size(ListThumbnailSize) .clip(RoundedCornerShape(ThumbnailCornerRadius)) 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 54002a441..c0b37e490 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 @@ -110,15 +110,6 @@ fun ArtistItemsScreen( YouTubeListItem( item = item, badges = { - if (item.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } if (item is SongItem && item.id in librarySongIds || item is AlbumItem && item.id in libraryAlbumIds || item is PlaylistItem && item.id in libraryPlaylistIds @@ -141,6 +132,15 @@ fun ArtistItemsScreen( .padding(end = 2.dp) ) } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, isPlaying = when (item) { is SongItem -> mediaMetadata?.id == item.id @@ -189,7 +189,7 @@ fun ArtistItemsScreen( is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") } } ) @@ -263,7 +263,7 @@ fun ArtistItemsScreen( is SongItem -> playerConnection.playQueue(YouTubeQueue(item.endpoint ?: WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") } }, onLongClick = { 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 32f3b4261..4d268ae92 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 @@ -377,7 +377,7 @@ fun ArtistScreen( is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") } }, onLongClick = { 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 15b258edf..de895680e 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 @@ -112,7 +112,7 @@ fun LibraryPlaylistsScreen( }, modifier = Modifier .clickable { - navController.navigate("playlist/$LIKED_PLAYLIST_ID") + navController.navigate("local_playlist/$LIKED_PLAYLIST_ID") } .animateItemPlacement() ) @@ -134,7 +134,7 @@ fun LibraryPlaylistsScreen( }, modifier = Modifier .clickable { - navController.navigate("playlist/$DOWNLOADED_PLAYLIST_ID") + navController.navigate("local_playlist/$DOWNLOADED_PLAYLIST_ID") } .animateItemPlacement() ) @@ -150,7 +150,7 @@ fun LibraryPlaylistsScreen( modifier = Modifier .fillMaxWidth() .clickable { - navController.navigate("playlist/${playlist.id}") + navController.navigate("local_playlist/${playlist.id}") } .animateItemPlacement() ) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 96cf8607d..4b0752aa2 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.* 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.res.stringResource import androidx.compose.ui.text.buildAnnotatedString @@ -32,6 +33,8 @@ import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.AlbumThumbnailSize import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.PlaylistEntity +import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.* @@ -39,8 +42,7 @@ import com.zionhuang.music.ui.component.shimmer.* import com.zionhuang.music.ui.menu.YouTubeSongMenu import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlinePlaylistViewModel -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.launch @OptIn(ExperimentalFoundationApi::class) @Composable @@ -50,6 +52,7 @@ fun OnlinePlaylistScreen( viewModel: OnlinePlaylistViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(), ) { + val context = LocalContext.current val menuState = LocalMenuState.current val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return @@ -60,11 +63,11 @@ fun OnlinePlaylistScreen( val likedSongIds by mainViewModel.likedSongIds.collectAsState() val playlist by viewModel.playlist.collectAsState() - val itemsPage by viewModel.itemsPage.collectAsState() - val inLibrary by viewModel.inLibrary.collectAsState() + val songs by viewModel.playlistSongs.collectAsState() val lazyListState = rememberLazyListState() val coroutineScope = rememberCoroutineScope() + val snackbarHostState = remember { SnackbarHostState() } LaunchedEffect(playlist) { appBarConfig.title = { @@ -78,258 +81,255 @@ fun OnlinePlaylistScreen( } } - LaunchedEffect(lazyListState) { - snapshotFlow { - lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } - }.collect { shouldLoadMore -> - if (!shouldLoadMore) return@collect - viewModel.loadMore() - } - } - - LazyColumn( - state = lazyListState, - contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + Box( + modifier = Modifier.fillMaxSize() ) { - playlist.let { playlist -> - if (playlist != null) { - item { - Column( - modifier = Modifier.padding(12.dp) - ) { - Row( - verticalAlignment = Alignment.CenterVertically + LazyColumn( + state = lazyListState, + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + playlist.let { playlist -> + if (playlist != null) { + item { + Column( + modifier = Modifier.padding(12.dp) ) { - AsyncImage( - model = playlist.thumbnail, - contentDescription = null, - modifier = Modifier - .size(AlbumThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - ) - - Spacer(Modifier.width(16.dp)) - - Column( - verticalArrangement = Arrangement.Center, + Row( + verticalAlignment = Alignment.CenterVertically ) { - AutoResizeText( - text = playlist.title, - fontWeight = FontWeight.Bold, - maxLines = 2, - overflow = TextOverflow.Ellipsis, - fontSizeRange = FontSizeRange(16.sp, 22.sp) + AsyncImage( + model = playlist.thumbnail, + contentDescription = null, + modifier = Modifier + .size(AlbumThumbnailSize) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) - val annotatedString = buildAnnotatedString { - withStyle( - style = MaterialTheme.typography.titleMedium.copy( - fontWeight = FontWeight.Normal, - color = MaterialTheme.colorScheme.onBackground - ).toSpanStyle() - ) { - if (playlist.author.id != null) { - pushStringAnnotation(playlist.author.id!!, playlist.author.name) - append(playlist.author.name) - pop() - } else { - append(playlist.author.name) + Spacer(Modifier.width(16.dp)) + + Column( + verticalArrangement = Arrangement.Center, + ) { + AutoResizeText( + text = playlist.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() + ) { + if (playlist.author.id != null) { + pushStringAnnotation(playlist.author.id!!, playlist.author.name) + append(playlist.author.name) + pop() + } else { + append(playlist.author.name) + } } } - } - ClickableText(annotatedString) { offset -> - annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> - navController.navigate("artist/${range.tag}") + ClickableText(annotatedString) { offset -> + annotatedString.getStringAnnotations(offset, offset).firstOrNull()?.let { range -> + navController.navigate("artist/${range.tag}") + } } - } - playlist.songCountText?.let { songCountText -> - Text( - text = songCountText, - style = MaterialTheme.typography.titleMedium, - fontWeight = FontWeight.Normal - ) - } + playlist.songCountText?.let { songCountText -> + Text( + text = songCountText, + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.Normal + ) + } - Row { - IconButton( - onClick = { - database.query { - if (inLibrary) { - runBlocking { - database.playlist(playlist.id).first()?.playlist - }?.let { playlist -> - delete(playlist) + Row { + IconButton( + onClick = { + database.transaction { + val playlistEntity = PlaylistEntity(name = playlist.title) + insert(playlistEntity) + songs.map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { index, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlistEntity.id, + position = index + ) + } + .forEach(::insert) + coroutineScope.launch { + snackbarHostState.showSnackbar(context.getString(R.string.snackbar_playlist_imported)) } - } else { - insert(playlist) } } + ) { + Icon( + painter = painterResource(R.drawable.ic_input), + contentDescription = null + ) } - ) { - Icon( - painter = painterResource(if (inLibrary) R.drawable.ic_library_add_check else R.drawable.ic_library_add), - contentDescription = null - ) } } } - } - Spacer(Modifier.height(12.dp)) + Spacer(Modifier.height(12.dp)) - Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { - Button( - onClick = { - playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_shuffle), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_shuffle)) - } + Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { + Button( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.shuffleEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_shuffle), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_shuffle)) + } - OutlinedButton( - onClick = { - playerConnection.playQueue(YouTubeQueue(playlist.radioEndpoint)) - }, - contentPadding = ButtonDefaults.ButtonWithIconContentPadding, - modifier = Modifier.weight(1f) - ) { - Icon( - painter = painterResource(R.drawable.ic_radio), - contentDescription = null, - modifier = Modifier.size(ButtonDefaults.IconSize) - ) - Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_radio)) + OutlinedButton( + onClick = { + playerConnection.playQueue(YouTubeQueue(playlist.radioEndpoint)) + }, + contentPadding = ButtonDefaults.ButtonWithIconContentPadding, + modifier = Modifier.weight(1f) + ) { + Icon( + painter = painterResource(R.drawable.ic_radio), + contentDescription = null, + modifier = Modifier.size(ButtonDefaults.IconSize) + ) + Spacer(Modifier.size(ButtonDefaults.IconSpacing)) + Text(stringResource(R.string.btn_radio)) + } } } } - } - items( - items = itemsPage?.items.orEmpty(), - key = { it.id } - ) { song -> - if (song !is SongItem) return@items - YouTubeListItem( - item = song, - badges = { - if (song.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (song.id in librarySongIds) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (song.id in likedSongIds) { - Icon( - painter = painterResource(R.drawable.ic_favorite), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = mediaMetadata?.id == song.id, - playWhenReady = playWhenReady, - trailingContent = { - IconButton( - onClick = { - menuState.show { - YouTubeSongMenu( - song = song, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) + items( + items = songs, + key = { it.id } + ) { song -> + YouTubeListItem( + item = song, + badges = { + if (song.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in librarySongIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (song.id in likedSongIds) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.id == song.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeSongMenu( + song = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .clickable { - playerConnection.playQueue(YouTubeQueue(song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata())) - } - .animateItemPlacement() - ) - } - - if (itemsPage?.continuation != null) { - item(key = "loading") { - ShimmerHost { - repeat(3) { - ListItemPlaceHolder() - } - } + }, + modifier = Modifier + .clickable { + playerConnection.playQueue(YouTubeQueue(song.endpoint ?: WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + .animateItemPlacement() + ) } - } - } else { - item { - ShimmerHost { - Column(Modifier.padding(12.dp)) { - Row(verticalAlignment = Alignment.CenterVertically) { - Spacer( - modifier = Modifier - .size(AlbumThumbnailSize) - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - .background(MaterialTheme.colorScheme.onSurface) - ) + } 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)) + Spacer(Modifier.width(16.dp)) - Column( - verticalArrangement = Arrangement.Center, - ) { - TextPlaceholder() - TextPlaceholder() - TextPlaceholder() + Column( + verticalArrangement = Arrangement.Center, + ) { + TextPlaceholder() + TextPlaceholder() + TextPlaceholder() + } } - } - Spacer(Modifier.padding(8.dp)) + Spacer(Modifier.padding(8.dp)) - Row { - ButtonPlaceholder(Modifier.weight(1f)) + Row { + ButtonPlaceholder(Modifier.weight(1f)) - Spacer(Modifier.width(12.dp)) + Spacer(Modifier.width(12.dp)) - ButtonPlaceholder(Modifier.weight(1f)) + ButtonPlaceholder(Modifier.weight(1f)) + } } - } - repeat(6) { - ListItemPlaceHolder() + repeat(6) { + ListItemPlaceHolder() + } } } } } } + + SnackbarHost( + hostState = snackbarHostState, + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .align(Alignment.BottomCenter) + ) } } 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 f823d159b..39d3d7c35 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 @@ -179,7 +179,7 @@ fun LocalSearchScreen( playlist = item, modifier = Modifier .clickable { - navController.navigate("playlist/${item.id}") + navController.navigate("local_playlist/${item.id}") } .animateItemPlacement() ) 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 246028a7d..e69354122 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 @@ -88,15 +88,6 @@ fun OnlineSearchResult( YouTubeListItem( item = item, badges = { - if (item.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } if (item is SongItem && item.id in librarySongIds || item is AlbumItem && item.id in libraryAlbumIds || item is PlaylistItem && item.id in libraryPlaylistIds @@ -119,6 +110,15 @@ fun OnlineSearchResult( .padding(end = 2.dp) ) } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, isPlaying = when (item) { is SongItem -> mediaMetadata?.id == item.id @@ -167,7 +167,7 @@ fun OnlineSearchResult( is SongItem -> playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = item.id), item.toMediaMetadata())) is AlbumItem -> navController.navigate("album/${item.id}") is ArtistItem -> navController.navigate("artist/${item.id}") - is PlaylistItem -> navController.navigate("playlist/${item.id}") + is PlaylistItem -> navController.navigate("online_playlist/${item.id}") } } .animateItemPlacement() diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt index 82c623744..c3ca0a641 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt @@ -116,15 +116,6 @@ fun OnlineSearchScreen( YouTubeListItem( item = item, badges = { - if (item.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } if (item is SongItem && item.id in librarySongIds || item is AlbumItem && item.id in libraryAlbumIds || item is PlaylistItem && item.id in libraryPlaylistIds @@ -147,6 +138,15 @@ fun OnlineSearchScreen( .padding(end = 2.dp) ) } + if (item.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } }, isPlaying = when (item) { is SongItem -> mediaMetadata?.id == item.id diff --git a/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt b/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt index 43ed8d083..0955967bc 100644 --- a/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt +++ b/app/src/main/java/com/zionhuang/music/utils/StringUtils.kt @@ -3,23 +3,20 @@ package com.zionhuang.music.utils import java.math.BigInteger import java.security.MessageDigest -private const val MS_FORMAT = "%1\$d:%2$02d" -private const val HMS_FORMAT = "%1\$d:%2$02d:%3$02d" - -/** - * Convert duration in seconds to formatted time string - * - * @param duration in milliseconds - * @return formatted string - */ fun makeTimeString(duration: Long?): String { if (duration == null || duration < 0) return "" var sec = duration / 1000 + val day = sec / 86400 + sec %= 86400 val hour = sec / 3600 sec %= 3600 - val minute = (sec / 60).toInt() + val minute = sec / 60 sec %= 60 - return if (hour == 0L) MS_FORMAT.format(minute, sec) else HMS_FORMAT.format(hour, minute, sec) + return when { + day > 0 -> "%d:%02d:%02d:%02d".format(day, hour, minute, sec) + hour > 0 -> "%d:%02d:%02d".format(hour, minute, sec) + else -> "%d:%02d".format(minute, sec) + } } fun md5(str: String): String { @@ -27,4 +24,7 @@ fun md5(str: String): String { return BigInteger(1, md.digest(str.toByteArray())).toString(16).padStart(32, '0') } -fun joinByBullet(vararg str: String?) = str.filterNot { it.isNullOrEmpty() }.joinToString(separator = " • ") +fun joinByBullet(vararg str: String?) = + str.filterNot { + it.isNullOrEmpty() + }.joinToString(separator = " • ") 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 5a7c84eb6..3def17745 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -5,45 +5,34 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.PlaylistItem -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.models.ItemsPage +import com.zionhuang.innertube.models.SongItem import dagger.hilt.android.lifecycle.HiltViewModel -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class OnlinePlaylistViewModel @Inject constructor( - database: MusicDatabase, savedStateHandle: SavedStateHandle, ) : ViewModel() { private val playlistId = savedStateHandle.get("playlistId")!! val playlist = MutableStateFlow(null) - val itemsPage = MutableStateFlow(null) - val inLibrary = database.playlist(playlistId) - .map { it != null } - .stateIn(viewModelScope, SharingStarted.Eagerly, false) + val playlistSongs = MutableStateFlow>(emptyList()) init { - viewModelScope.launch { + viewModelScope.launch(Dispatchers.IO) { val playlistPage = YouTube.playlist(playlistId).getOrNull() ?: return@launch - playlist.value = playlistPage.playlist - itemsPage.value = ItemsPage(playlistPage.songs, playlistPage.songsContinuation) - } - } - - fun loadMore() { - viewModelScope.launch { - val oldItemsPage = itemsPage.value ?: return@launch - val continuation = oldItemsPage.continuation ?: return@launch - val playlistContinuationPage = YouTube.playlistContinuation(continuation).getOrNull() ?: return@launch - itemsPage.update { - ItemsPage( - items = (oldItemsPage.items + playlistContinuationPage.songs).distinctBy { it.id }, - continuation = playlistContinuationPage.continuation - ) + val songs = playlistPage.songs.toMutableList() + var continuation = playlistPage.songsContinuation + while (continuation != null) { + val continuationPage = YouTube.playlistContinuation(continuation).getOrNull() ?: break + songs += continuationPage.songs + continuation = continuationPage.continuation } + playlist.value = playlistPage.playlist + playlistSongs.value = songs } } } From 4e47788f79d3ac95c7a04da27b61d79f0c26f14b Mon Sep 17 00:00:00 2001 From: Fjuro Date: Fri, 3 Feb 2023 16:36:29 +0100 Subject: [PATCH 166/323] Update strings.xml --- app/src/main/res/values-cs/strings.xml | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ccb46e13d..4fb11404d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -180,21 +180,25 @@ %d skladba %d skladby + %d skladeb %d skladeb %d umělec %d umělci + %d umělců %d umělců %d album %d alba + %d alb %d alb %d playlist %d playlisty + %d playlistů %d playlistů From 443eff1ec49f7bd45dec4857b9c55779eda53c8a Mon Sep 17 00:00:00 2001 From: Fjuro Date: Fri, 3 Feb 2023 18:59:42 +0100 Subject: [PATCH 167/323] Update strings.xml --- app/src/main/res/values-cs/strings.xml | 25 ++++++++++++++++++++++++- 1 file changed, 24 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 4fb11404d..6bbdbab11 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -224,44 +224,65 @@ Byla odstraněna %d skladba. + Byly odstraněny %d skladby. + Bylo odstraněna %d skladeb. Bylo odstraněno %d skladeb. + Vybrána %d + Vybrány %d + Vybráno %d Vybráno %d Zrušit Tuto adresu nelze identifikovat. Skladba bude hrát jako další + %d skladby budou hrát jako další + %d skladeb bude hrát jako další %d skladeb bude hrát jako další Umělec bude hrát jako další + %d umělci budou hrát jako další + %d umělců bude hrát jako další %d umělců bude hrát jako další Album bude hrát jako další + %d alba budou hrát jako další + %d alb bude hrát jako další %d alb bude hrát jako další Playlist bude hrát jako další + %d playlisty budou hrát jako další + %d playlistů bude hrát jako další %d playlistů bude hrát jako další Vybrané budou přehrány jako další Skladba přidána do fronty + %d skladby přidány do fronty + %d skladeb přidáno do fronty %d skladeb přidáno do fronty Umělec přidán do fronty + %d umělci přidáni do fronty + %d umělců přidáno do fronty %d umělců přidáno do fronty Album přidáno do fronty + %d alba přidány do fronty + %d alb přidáno do fronty %d alb přidáno do fronty Playlist přidán do fronty + %d playlisty přidány do fronty + %d playlistů přidáno do fronty %d playlistů přidáno do fronty Vybrané přidány do fronty @@ -271,7 +292,9 @@ Přidáno do playlistu %1$s Stáhnout skladbu - Stáhnout %d skladen + Stáhnout %d skladby + Stáhnout %d skladeb + Stáhnout %d skladeb Odebrané stahování Zobrazit From 1c5fabf54a9713486a1dbbdf6ceb7a0ab54041a2 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 4 Feb 2023 13:25:26 +0800 Subject: [PATCH 168/323] Add playlist menu --- .../com/zionhuang/music/db/DatabaseDao.kt | 6 + .../zionhuang/music/ui/menu/PlaylistMenu.kt | 108 ++++++++++++++++++ .../screens/library/LibraryPlaylistsScreen.kt | 32 ++++-- .../viewmodels/OnlinePlaylistViewModel.kt | 12 +- app/src/main/res/values/strings.xml | 1 + .../java/com/zionhuang/innertube/YouTube.kt | 2 +- .../com/zionhuang/innertube/utils/Utils.kt | 19 +++ 7 files changed, 162 insertions(+), 18 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt 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 ea2a6971e..0c85b7ed7 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -241,6 +241,9 @@ interface DatabaseDao { """) fun move(playlistId: Long, fromPosition: Int, toPosition: Int) + @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId") + fun clearPlaylist(playlistId: String) + @Query("SELECT * FROM artist WHERE name = :name") fun artistByName(name: String): ArtistEntity? @@ -348,6 +351,9 @@ interface DatabaseDao { @Update fun update(artist: ArtistEntity) + @Update + fun update(playlist: PlaylistEntity) + @Update fun update(map: PlaylistSongMap) diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt new file mode 100644 index 000000000..7db49d623 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt @@ -0,0 +1,108 @@ +package com.zionhuang.music.ui.menu + +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.systemBars +import androidx.compose.material3.Icon +import androidx.compose.material3.Text +import androidx.compose.runtime.* +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.dp +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.R +import com.zionhuang.music.db.entities.Playlist +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.TextFieldDialog +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@Composable +fun PlaylistMenu( + playlist: Playlist, + coroutineScope: CoroutineScope, + onDismiss: () -> Unit, +) { + val database = LocalDatabase.current + + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.ic_edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.dialog_title_edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue(playlist.playlist.name, TextRange(playlist.playlist.name.length)), + onDone = { name -> + onDismiss() + database.query { + update(playlist.playlist.copy(name = name)) + } + } + ) + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.ic_edit, + title = R.string.menu_edit + ) { + showEditDialog = true + } + + if (playlist.playlist.browseId != null) { + GridMenuItem( + icon = R.drawable.ic_sync, + title = R.string.menu_sync + ) { + onDismiss() + coroutineScope.launch(Dispatchers.IO) { + val playlistPage = YouTube.playlist(playlist.playlist.browseId).completed().getOrNull() ?: return@launch + database.transaction { + clearPlaylist(playlist.id) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position + ) + } + .forEach(::insert) + } + } + } + } + + GridMenuItem( + icon = R.drawable.ic_delete, + title = R.string.menu_delete + ) { + onDismiss() + database.query { + delete(playlist.playlist) + } + } + } +} 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 de895680e..9fc898712 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 @@ -5,10 +5,7 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment @@ -27,10 +24,8 @@ import com.zionhuang.music.constants.* 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.ListItem -import com.zionhuang.music.ui.component.PlaylistListItem -import com.zionhuang.music.ui.component.SortHeader -import com.zionhuang.music.ui.component.TextFieldDialog +import com.zionhuang.music.ui.component.* +import com.zionhuang.music.ui.menu.PlaylistMenu import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryPlaylistsViewModel @@ -41,8 +36,11 @@ fun LibraryPlaylistsScreen( navController: NavController, viewModel: LibraryPlaylistsViewModel = hiltViewModel(), ) { + val menuState = LocalMenuState.current val database = LocalDatabase.current + val coroutineScope = rememberCoroutineScope() + val (sortType, onSortTypeChange) = rememberEnumPreference(PlaylistSortTypeKey, PlaylistSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(PlaylistSortDescendingKey, true) @@ -147,6 +145,24 @@ fun LibraryPlaylistsScreen( ) { playlist -> PlaylistListItem( playlist = playlist, + trailingContent = { + IconButton( + onClick = { + menuState.show { + PlaylistMenu( + playlist = playlist, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, modifier = Modifier .fillMaxWidth() .clickable { 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 3def17745..e750a9578 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -6,6 +6,7 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.PlaylistItem import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow @@ -23,16 +24,9 @@ class OnlinePlaylistViewModel @Inject constructor( init { viewModelScope.launch(Dispatchers.IO) { - val playlistPage = YouTube.playlist(playlistId).getOrNull() ?: return@launch - val songs = playlistPage.songs.toMutableList() - var continuation = playlistPage.songsContinuation - while (continuation != null) { - val continuationPage = YouTube.playlistContinuation(continuation).getOrNull() ?: break - songs += continuationPage.songs - continuation = continuationPage.continuation - } + val playlistPage = YouTube.playlist(playlistId).completed().getOrNull() ?: return@launch playlist.value = playlistPage.playlist - playlistSongs.value = songs + playlistSongs.value = playlistPage.songs } } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index af8235b64..2c5da4d37 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -319,6 +319,7 @@ Clear song cache History Stats + Sync 1 minute diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index e94035dc3..5857054fd 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -187,7 +187,7 @@ object YouTube { ) } - suspend fun playlist(playlistId: String) = runCatching { + suspend fun playlist(playlistId: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, "VL$playlistId").body() PlaylistPage( playlist = PlaylistItem( diff --git a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt index 72362e3ca..8f63cc50b 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt @@ -1,7 +1,26 @@ package com.zionhuang.innertube.utils +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.pages.PlaylistPage import java.security.MessageDigest +suspend fun Result.completed() = runCatching { + val page = getOrThrow() + val songs = page.songs.toMutableList() + var continuation = page.songsContinuation + while (continuation != null) { + val continuationPage = YouTube.playlistContinuation(continuation).getOrNull() ?: break + songs += continuationPage.songs + continuation = continuationPage.continuation + } + PlaylistPage( + playlist = page.playlist, + songs = songs, + songsContinuation = null, + continuation = page.continuation + ) +} + fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x".format(eachByte) } fun sha1(str: String): String = MessageDigest.getInstance("SHA-1").digest(str.toByteArray()).toHex() From d87ebaea2c0f1ad55fd6de3395ba33156c8074fd Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 5 Feb 2023 11:16:17 +0800 Subject: [PATCH 169/323] Delete lyrics first when refetching lyrics --- .../java/com/zionhuang/music/ui/component/Lyrics.kt | 8 ++++---- .../music/viewmodels/LyricsMenuViewModel.kt | 12 +++++++----- 2 files changed, 11 insertions(+), 9 deletions(-) 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 fe93162d9..6ed27a2d9 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 @@ -233,7 +233,7 @@ fun Lyrics( onClick = { menuState.show { LyricsMenu( - lyricsProvider = { lyricsEntity?.lyrics }, + lyricsProvider = { lyricsEntity }, mediaMetadataProvider = mediaMetadataProvider, onDismiss = menuState::dismiss ) @@ -251,7 +251,7 @@ fun Lyrics( @OptIn(ExperimentalMaterial3Api::class) @Composable fun LyricsMenu( - lyricsProvider: () -> String?, + lyricsProvider: () -> LyricsEntity?, mediaMetadataProvider: () -> MediaMetadata, onDismiss: () -> Unit, viewModel: LyricsMenuViewModel = hiltViewModel(), @@ -268,7 +268,7 @@ fun LyricsMenu( onDismiss = { showEditDialog = false }, icon = { Icon(painter = painterResource(R.drawable.ic_edit), contentDescription = null) }, title = { Text(text = mediaMetadataProvider().title) }, - initialTextFieldValue = TextFieldValue(lyricsProvider().orEmpty()), + initialTextFieldValue = TextFieldValue(lyricsProvider()?.lyrics.orEmpty()), singleLine = false, onDone = { database.query { @@ -473,8 +473,8 @@ fun LyricsMenu( icon = R.drawable.ic_cached, title = R.string.menu_refetch ) { - viewModel.refetchLyrics(mediaMetadataProvider()) onDismiss() + viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) } GridMenuItem( icon = R.drawable.ic_search, diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt index 33cd7c286..2e00e058b 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LyricsMenuViewModel.kt @@ -13,6 +13,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking import javax.inject.Inject @HiltViewModel @@ -43,12 +44,13 @@ class LyricsMenuViewModel @Inject constructor( job = null } - fun refetchLyrics(mediaMetadata: MediaMetadata) { - viewModelScope.launch { - val lyrics = lyricsHelper.getLyrics(mediaMetadata) - database.query { - upsert(LyricsEntity(mediaMetadata.id, lyrics)) + fun refetchLyrics(mediaMetadata: MediaMetadata, lyricsEntity: LyricsEntity?) { + database.query { + lyricsEntity?.let(::delete) + val lyrics = runBlocking { + lyricsHelper.getLyrics(mediaMetadata) } + upsert(LyricsEntity(mediaMetadata.id, lyrics)) } } } From d0c8a899d2443fed48bec490935de0b44b2ba6bc Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 5 Feb 2023 22:23:51 +0800 Subject: [PATCH 170/323] Drag queue and playlist songs --- app/build.gradle.kts | 1 + .../com/zionhuang/music/db/DatabaseDao.kt | 4 +- .../com/zionhuang/music/extensions/ListExt.kt | 8 +- .../music/playback/PlayerConnection.kt | 8 +- .../zionhuang/music/playback/SongPlayer.kt | 5 +- .../com/zionhuang/music/ui/component/Items.kt | 26 +- .../com/zionhuang/music/ui/player/Queue.kt | 95 ++++-- .../screens/playlist/LocalPlaylistScreen.kt | 34 ++- .../ui/utils/reordering/AnimatablesPool.kt | 38 +++ .../utils/reordering/AnimateItemPlacement.kt | 10 + .../music/ui/utils/reordering/DraggedItem.kt | 32 ++ .../music/ui/utils/reordering/Reorder.kt | 41 +++ .../utils/reordering/ReorderingLazyColumn.kt | 40 +++ .../ui/utils/reordering/ReorderingLazyList.kt | 274 ++++++++++++++++++ .../ui/utils/reordering/ReorderingState.kt | 221 ++++++++++++++ app/src/main/res/drawable/ic_drag_handle.xml | 6 +- settings.gradle.kts | 2 +- 17 files changed, 791 insertions(+), 54 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 7a32bd11f..28724083a 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -59,6 +59,7 @@ android { targetCompatibility = JavaVersion.VERSION_1_8 } kotlinOptions { + freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" jvmTarget = "1.8" } testOptions { 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 0c85b7ed7..87f21fa22 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -237,9 +237,9 @@ interface DatabaseDao { WHEN position > :fromPosition THEN position - 1 ELSE :toPosition END - WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition,:toPosition) AND MAX(:fromPosition,:toPosition) + WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition, :toPosition) AND MAX(:fromPosition, :toPosition) """) - fun move(playlistId: Long, fromPosition: Int, toPosition: Int) + fun move(playlistId: String, fromPosition: Int, toPosition: Int) @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId") fun clearPlaylist(playlistId: String) diff --git a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt index 3e3cb9dbf..c46f1513d 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/ListExt.kt @@ -1,3 +1,9 @@ package com.zionhuang.music.extensions -fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this \ No newline at end of file +fun List.reversed(reversed: Boolean) = if (reversed) asReversed() else this + +fun MutableList.move(fromIndex: Int, toIndex: Int): MutableList { + val item = removeAt(fromIndex) + add(toIndex, item) + return this +} 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 e32f6baad..12405c5c8 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -37,7 +37,7 @@ class PlayerConnection( } val queueTitle = MutableStateFlow(null) - val queueItems = MutableStateFlow>(emptyList()) + val queueWindows = MutableStateFlow>(emptyList()) val currentMediaItemIndex = MutableStateFlow(-1) val currentWindowIndex = MutableStateFlow(-1) @@ -61,7 +61,7 @@ class PlayerConnection( playWhenReady.value = binder.player.playWhenReady mediaMetadata.value = binder.player.currentMetadata queueTitle.value = binder.songPlayer.queueTitle - queueItems.value = binder.player.getQueueWindows() + queueWindows.value = binder.player.getQueueWindows() currentWindowIndex.value = binder.player.getCurrentQueueIndex() currentMediaItemIndex.value = binder.player.currentMediaItemIndex shuffleModeEnabled.value = binder.player.shuffleModeEnabled @@ -117,7 +117,7 @@ class PlayerConnection( } override fun onTimelineChanged(timeline: Timeline, reason: Int) { - queueItems.value = binder.player.getQueueWindows() + queueWindows.value = binder.player.getQueueWindows() queueTitle.value = binder.songPlayer.queueTitle currentMediaItemIndex.value = player.currentMediaItemIndex currentWindowIndex.value = binder.player.getCurrentQueueIndex() @@ -126,7 +126,7 @@ class PlayerConnection( override fun onShuffleModeEnabledChanged(enabled: Boolean) { shuffleModeEnabled.value = enabled - queueItems.value = binder.player.getQueueWindows() + queueWindows.value = binder.player.getQueueWindows() currentWindowIndex.value = binder.player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 68f599601..fda4259b1 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -713,10 +713,7 @@ class SongPlayer( override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { if (shuffleModeEnabled) { // Always put current playing item at first - val shuffledIndices = IntArray(player.mediaItemCount) - for (i in 0 until player.mediaItemCount) { - shuffledIndices[i] = i - } + val shuffledIndices = IntArray(player.mediaItemCount) { it } shuffledIndices.shuffle() shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = shuffledIndices[0] shuffledIndices[0] = player.currentMediaItemIndex 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 5c4928f46..a1a31aa0f 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 @@ -81,11 +81,11 @@ inline fun ListItem( } @Composable -inline fun ListItem( +fun ListItem( modifier: Modifier = Modifier, title: String, subtitle: String?, - crossinline badges: @Composable RowScope.() -> Unit = {}, + badges: @Composable RowScope.() -> Unit = {}, thumbnailContent: @Composable () -> Unit, trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( @@ -109,10 +109,10 @@ inline fun ListItem( ) @Composable -inline fun GridItem( +fun GridItem( modifier: Modifier = Modifier, title: String, - noinline subtitle: (@Composable RowScope.() -> Unit)? = null, + subtitle: (@Composable RowScope.() -> Unit)? = null, thumbnailContent: @Composable () -> Unit, ) { Column( @@ -141,7 +141,7 @@ inline fun GridItem( } @Composable -inline fun SongListItem( +fun SongListItem( song: Song, modifier: Modifier = Modifier, albumIndex: Int? = null, @@ -212,7 +212,7 @@ inline fun SongListItem( @OptIn(ExperimentalComposeUiApi::class) @Composable -inline fun ArtistListItem( +fun ArtistListItem( artist: Artist, modifier: Modifier = Modifier, trailingContent: @Composable RowScope.() -> Unit = {}, @@ -234,7 +234,7 @@ inline fun ArtistListItem( @OptIn(ExperimentalComposeUiApi::class) @Composable -inline fun AlbumListItem( +fun AlbumListItem( album: Album, modifier: Modifier = Modifier, isPlaying: Boolean = false, @@ -273,7 +273,7 @@ inline fun AlbumListItem( @OptIn(ExperimentalComposeUiApi::class) @Composable -inline fun PlaylistListItem( +fun PlaylistListItem( playlist: Playlist, modifier: Modifier = Modifier, trailingContent: @Composable RowScope.() -> Unit = {}, @@ -323,7 +323,7 @@ inline fun PlaylistListItem( ) @Composable -inline fun MediaMetadataListItem( +fun MediaMetadataListItem( mediaMetadata: MediaMetadata, modifier: Modifier, isPlaying: Boolean = false, @@ -360,11 +360,11 @@ inline fun MediaMetadataListItem( ) @Composable -inline fun YouTubeListItem( +fun YouTubeListItem( item: YTItem, modifier: Modifier = Modifier, albumIndex: Int? = null, - crossinline badges: @Composable RowScope.() -> Unit = {}, + badges: @Composable RowScope.() -> Unit = {}, isPlaying: Boolean = false, playWhenReady: Boolean = false, trailingContent: @Composable RowScope.() -> Unit = {}, @@ -422,10 +422,10 @@ inline fun YouTubeListItem( ) @Composable -inline fun YouTubeGridItem( +fun YouTubeGridItem( item: YTItem, modifier: Modifier = Modifier, - crossinline badges: @Composable RowScope.() -> Unit = {}, + badges: @Composable RowScope.() -> Unit = {}, isPlaying: Boolean = false, playWhenReady: Boolean = false, fillMaxWidth: Boolean = false, diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 8bd2b2402..8e722d431 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -11,7 +11,6 @@ import androidx.compose.animation.ExperimentalAnimationApi import androidx.compose.foundation.* import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState @@ -37,19 +36,27 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import androidx.compose.ui.window.DialogProperties import androidx.navigation.NavController +import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ShowLyricsKey import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.extensions.move import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.playback.PlayerConnection import com.zionhuang.music.ui.component.* +import com.zionhuang.music.ui.utils.reordering.ReorderingLazyColumn +import com.zionhuang.music.ui.utils.reordering.draggedItem +import com.zionhuang.music.ui.utils.reordering.rememberReorderingState +import com.zionhuang.music.ui.utils.reordering.reorder import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch import kotlin.math.roundToInt @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) @@ -66,14 +73,10 @@ fun Queue( val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() - val queueTitle by playerConnection.queueTitle.collectAsState() - val queueItems by playerConnection.queueItems.collectAsState() - val queueLength = remember(queueItems) { - queueItems.sumOf { it.mediaItem.metadata!!.duration } - } + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() - if (mediaMetadata == null) return + val currentSong by playerConnection.currentSong.collectAsState(initial = null) val currentFormat by playerConnection.currentFormat.collectAsState(initial = null) @@ -298,12 +301,33 @@ fun Queue( } } ) { - val lazyListState = rememberLazyListState( - initialFirstVisibleItemIndex = currentWindowIndex + val queueTitle by playerConnection.queueTitle.collectAsState() + val queueWindows by playerConnection.queueWindows.collectAsState() + val queueLength = remember(queueWindows) { + queueWindows.sumOf { it.mediaItem.metadata!!.duration } + } + + val coroutineScope = rememberCoroutineScope() + val reorderingState = rememberReorderingState( + lazyListState = rememberLazyListState(initialFirstVisibleItemIndex = currentWindowIndex), + key = queueWindows, + onDragEnd = { currentIndex, newIndex -> + if (!playerConnection.player.shuffleModeEnabled) { + playerConnection.player.moveMediaItem(currentIndex, newIndex) + } else { + playerConnection.player.setShuffleOrder( + DefaultShuffleOrder( + queueWindows.map { it.firstPeriodIndex }.toMutableList().move(currentIndex, newIndex).toIntArray(), + System.currentTimeMillis() + ) + ) + } + }, + extraItemCount = 0 ) - LazyColumn( - state = lazyListState, + ReorderingLazyColumn( + reorderingState = reorderingState, contentPadding = WindowInsets.systemBars .add(WindowInsets( top = ListItemHeight, @@ -313,24 +337,46 @@ fun Queue( modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) ) { itemsIndexed( - items = queueItems, + items = queueWindows, key = { _, item -> item.uid.hashCode() } ) { index, window -> MediaMetadataListItem( mediaMetadata = window.mediaItem.metadata!!, isPlaying = index == currentWindowIndex, playWhenReady = playWhenReady, + trailingContent = { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null, + modifier = Modifier + .reorder( + reorderingState = reorderingState, + index = index + ) + .clickable( + enabled = false, + onClick = {} + ) + .padding(8.dp) + ) + }, modifier = Modifier .fillMaxWidth() - .animateItemPlacement() .clickable { - if (index == currentWindowIndex) { - playerConnection.player.togglePlayPause() - } else { - playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) - playerConnection.player.playWhenReady = true + coroutineScope.launch(Dispatchers.Main) { + if (index == currentWindowIndex) { + playerConnection.player.togglePlayPause() + } else { + playerConnection.player.seekToDefaultPosition(window.firstPeriodIndex) + playerConnection.player.playWhenReady = true + } } } +// .animateItemPlacement(reorderingState = reorderingState) + .draggedItem( + reorderingState = reorderingState, + index = index + ) ) } } @@ -364,7 +410,7 @@ fun Queue( horizontalAlignment = Alignment.End ) { Text( - text = pluralStringResource(R.plurals.song_count, queueItems.size, queueItems.size), + text = pluralStringResource(R.plurals.song_count, queueWindows.size, queueWindows.size), style = MaterialTheme.typography.bodyMedium ) @@ -400,14 +446,19 @@ fun Queue( IconButton( modifier = Modifier.align(Alignment.CenterStart), onClick = { - playerConnection.player.shuffleModeEnabled = !playerConnection.player.shuffleModeEnabled + reorderingState.coroutineScope.launch { + reorderingState.lazyListState.animateScrollToItem( + if (playerConnection.player.shuffleModeEnabled) playerConnection.player.currentMediaItemIndex else 0 + ) + }.invokeOnCompletion { + playerConnection.player.shuffleModeEnabled = !playerConnection.player.shuffleModeEnabled + } } ) { Icon( painter = painterResource(R.drawable.ic_shuffle), contentDescription = null, - modifier = Modifier - .alpha(if (shuffleModeEnabled) 1f else 0.5f) + modifier = Modifier.alpha(if (shuffleModeEnabled) 1f else 0.5f) ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index e852d2a4f..852389201 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -3,8 +3,8 @@ package com.zionhuang.music.ui.screens.playlist import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* -import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* @@ -25,6 +25,7 @@ import androidx.compose.ui.util.fastSumBy import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R @@ -35,6 +36,7 @@ import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.utils.reordering.* import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.viewmodels.LocalPlaylistViewModel @@ -46,6 +48,7 @@ fun LocalPlaylistScreen( viewModel: LocalPlaylistViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current + val database = LocalDatabase.current val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() @@ -70,7 +73,19 @@ fun LocalPlaylistScreen( } } - LazyColumn( + val reorderingState = rememberReorderingState( + lazyListState = rememberLazyListState(), + key = songs, + onDragEnd = { fromIndex, toIndex -> + database.query { + move(viewModel.playlistId, fromIndex, toIndex) + } + }, + extraItemCount = 1 + ) + + ReorderingLazyColumn( + reorderingState = reorderingState, contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { playlist?.let { playlist -> @@ -217,17 +232,28 @@ fun LocalPlaylistScreen( contentDescription = null ) } + + IconButton( + onClick = { }, + modifier = Modifier.reorder(reorderingState = reorderingState, index = index) + ) { + Icon( + painter = painterResource(R.drawable.ic_drag_handle), + contentDescription = null + ) + } }, modifier = Modifier .fillMaxWidth() .combinedClickable { playerConnection.playQueue(ListQueue( title = playlist!!.playlist.name, - items = songs.map { it.toMediaItem() }, + items = songs.map(Song::toMediaItem), startIndex = index )) } - .animateItemPlacement() + .animateItemPlacement(reorderingState = reorderingState) + .draggedItem(reorderingState = reorderingState, index = index) ) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt new file mode 100644 index 000000000..553d40e6e --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimatablesPool.kt @@ -0,0 +1,38 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector +import androidx.compose.animation.core.TwoWayConverter +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock + +class AnimatablesPool( + private val size: Int, + private val initialValue: T, + typeConverter: TwoWayConverter, +) { + private val values = MutableList(size) { + Animatable(initialValue = initialValue, typeConverter = typeConverter) + } + + private val mutex = Mutex() + + init { + require(size > 0) + } + + suspend fun acquire(): Animatable? { + return mutex.withLock { + if (values.isNotEmpty()) values.removeFirst() else null + } + } + + suspend fun release(animatable: Animatable) { + mutex.withLock { + if (values.size < size) { + animatable.snapTo(initialValue) + values.add(animatable) + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt new file mode 100644 index 000000000..8738d099a --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/AnimateItemPlacement.kt @@ -0,0 +1,10 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.lazy.LazyItemScope +import androidx.compose.ui.Modifier + +context(LazyItemScope) + @ExperimentalFoundationApi +fun Modifier.animateItemPlacement(reorderingState: ReorderingState) = + if (!reorderingState.isDragging) animateItemPlacement() else this diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt new file mode 100644 index 000000000..2a11c6ef8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/DraggedItem.kt @@ -0,0 +1,32 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.layout.offset +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex + +fun Modifier.draggedItem( + reorderingState: ReorderingState, + index: Int, +): Modifier = when (reorderingState.draggingIndex) { + -1 -> this + index -> offset { + when (reorderingState.lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> IntOffset(0, reorderingState.offset.value) + Orientation.Horizontal -> IntOffset(reorderingState.offset.value, 0) + } + }.zIndex(1f) + else -> offset { + val offset = when (index) { + in reorderingState.indexesToAnimate -> reorderingState.indexesToAnimate.getValue(index).value + in (reorderingState.draggingIndex + 1)..reorderingState.reachedIndex -> -reorderingState.draggingItemSize + in reorderingState.reachedIndex until reorderingState.draggingIndex -> reorderingState.draggingItemSize + else -> 0 + } + when (reorderingState.lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> IntOffset(0, offset) + Orientation.Horizontal -> IntOffset(offset, 0) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt new file mode 100644 index 000000000..1d0411912 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/Reorder.kt @@ -0,0 +1,41 @@ +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.ui.Modifier +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput + +private fun Modifier.reorder( + reorderingState: ReorderingState, + index: Int, + detectDragGestures: DetectDragGestures, +): Modifier = pointerInput(reorderingState) { + with(detectDragGestures) { + detectDragGestures( + onDragStart = { reorderingState.onDragStart(index) }, + onDrag = reorderingState::onDrag, + onDragEnd = reorderingState::onDragEnd, + onDragCancel = reorderingState::onDragEnd, + ) + } +} + +fun Modifier.reorder( + reorderingState: ReorderingState, + index: Int, +): Modifier = reorder( + reorderingState = reorderingState, + index = index, + detectDragGestures = PointerInputScope::detectDragGestures, +) + +private fun interface DetectDragGestures { + suspend fun PointerInputScope.detectDragGestures( + onDragStart: (Offset) -> Unit, + onDragEnd: () -> Unit, + onDragCancel: () -> Unit, + onDrag: (change: PointerInputChange, dragAmount: Offset) -> Unit, + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt new file mode 100644 index 000000000..73b2e1b1d --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyColumn.kt @@ -0,0 +1,40 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.lazy.LazyListScope +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp + +@Composable +fun ReorderingLazyColumn( + reorderingState: ReorderingState, + modifier: Modifier = Modifier, + contentPadding: PaddingValues = PaddingValues(0.dp), + reverseLayout: Boolean = false, + verticalArrangement: Arrangement.Vertical = + if (!reverseLayout) Arrangement.Top else Arrangement.Bottom, + horizontalAlignment: Alignment.Horizontal = Alignment.Start, + flingBehavior: FlingBehavior = ScrollableDefaults.flingBehavior(), + userScrollEnabled: Boolean = true, + content: LazyListScope.() -> Unit, +) { + ReorderingLazyList( + modifier = modifier, + reorderingState = reorderingState, + contentPadding = contentPadding, + flingBehavior = flingBehavior, + horizontalAlignment = horizontalAlignment, + verticalArrangement = verticalArrangement, + isVertical = true, + reverseLayout = reverseLayout, + userScrollEnabled = userScrollEnabled, + content = content + ) +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt new file mode 100644 index 000000000..f315f9690 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt @@ -0,0 +1,274 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.checkScrollableContainerConstraints +import androidx.compose.foundation.clipScrollableContainer +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollableDefaults +import androidx.compose.foundation.gestures.scrollable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.calculateEndPadding +import androidx.compose.foundation.layout.calculateStartPadding +import androidx.compose.foundation.lazy.* +import androidx.compose.foundation.lazy.layout.LazyLayout +import androidx.compose.foundation.lazy.layout.LazyLayoutMeasureScope +import androidx.compose.foundation.lazy.layout.lazyLayoutSemantics +import androidx.compose.foundation.overscroll +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshots.Snapshot +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.layout.MeasureResult +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.unit.* + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun ReorderingLazyList( + modifier: Modifier, + reorderingState: ReorderingState, + contentPadding: PaddingValues, + reverseLayout: Boolean, + isVertical: Boolean, + flingBehavior: FlingBehavior, + userScrollEnabled: Boolean, + beyondBoundsItemCount: Int = 0, + horizontalAlignment: Alignment.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, + verticalAlignment: Alignment.Vertical? = null, + horizontalArrangement: Arrangement.Horizontal? = null, + content: LazyListScope.() -> Unit, +) { + val overscrollEffect = ScrollableDefaults.overscrollEffect() + val itemProvider = rememberLazyListItemProvider(reorderingState.lazyListState, content) + val semanticState = + rememberLazyListSemanticState(reorderingState.lazyListState, itemProvider, reverseLayout, isVertical) + val beyondBoundsInfo = reorderingState.lazyListBeyondBoundsInfo + val scope = rememberCoroutineScope() + val placementAnimator = remember(reorderingState.lazyListState, isVertical) { + LazyListItemPlacementAnimator(scope, isVertical) + } + reorderingState.lazyListState.placementAnimator = placementAnimator + + val measurePolicy = rememberLazyListMeasurePolicy( + itemProvider, + reorderingState.lazyListState, + beyondBoundsInfo, + contentPadding, + reverseLayout, + isVertical, + beyondBoundsItemCount, + horizontalAlignment, + verticalAlignment, + horizontalArrangement, + verticalArrangement, + placementAnimator, + ) + + val orientation = if (isVertical) Orientation.Vertical else Orientation.Horizontal + LazyLayout( + modifier = modifier + .then(reorderingState.lazyListState.remeasurementModifier) + .then(reorderingState.lazyListState.awaitLayoutModifier) + .lazyLayoutSemantics( + itemProvider = itemProvider, + state = semanticState, + orientation = orientation, + userScrollEnabled = userScrollEnabled + ) + .clipScrollableContainer(orientation) + .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout, orientation) + .lazyListPinningModifier(reorderingState.lazyListState, beyondBoundsInfo) + .overscroll(overscrollEffect) + .scrollable( + orientation = orientation, + reverseDirection = ScrollableDefaults.reverseDirection( + LocalLayoutDirection.current, + orientation, + reverseLayout + ), + interactionSource = reorderingState.lazyListState.internalInteractionSource, + flingBehavior = flingBehavior, + state = reorderingState.lazyListState, + overscrollEffect = overscrollEffect, + enabled = userScrollEnabled + ), + prefetchState = reorderingState.lazyListState.prefetchState, + measurePolicy = measurePolicy, + itemProvider = itemProvider + ) +} + +@ExperimentalFoundationApi +@Composable +private fun rememberLazyListMeasurePolicy( + itemProvider: LazyListItemProvider, + state: LazyListState, + beyondBoundsInfo: LazyListBeyondBoundsInfo, + contentPadding: PaddingValues, + reverseLayout: Boolean, + isVertical: Boolean, + beyondBoundsItemCount: Int, + horizontalAlignment: Alignment.Horizontal? = null, + verticalAlignment: Alignment.Vertical? = null, + horizontalArrangement: Arrangement.Horizontal? = null, + verticalArrangement: Arrangement.Vertical? = null, + placementAnimator: LazyListItemPlacementAnimator, +) = remember MeasureResult>( + state, + beyondBoundsInfo, + contentPadding, + reverseLayout, + isVertical, + horizontalAlignment, + verticalAlignment, + horizontalArrangement, + verticalArrangement, + placementAnimator +) { + { containerConstraints -> + checkScrollableContainerConstraints( + containerConstraints, + if (isVertical) Orientation.Vertical else Orientation.Horizontal + ) + + // resolve content paddings + val startPadding = + if (isVertical) { + contentPadding.calculateLeftPadding(layoutDirection).roundToPx() + } else { + // in horizontal configuration, padding is reversed by placeRelative + contentPadding.calculateStartPadding(layoutDirection).roundToPx() + } + + val endPadding = + if (isVertical) { + contentPadding.calculateRightPadding(layoutDirection).roundToPx() + } else { + // in horizontal configuration, padding is reversed by placeRelative + contentPadding.calculateEndPadding(layoutDirection).roundToPx() + } + val topPadding = contentPadding.calculateTopPadding().roundToPx() + val bottomPadding = contentPadding.calculateBottomPadding().roundToPx() + val totalVerticalPadding = topPadding + bottomPadding + val totalHorizontalPadding = startPadding + endPadding + val totalMainAxisPadding = if (isVertical) totalVerticalPadding else totalHorizontalPadding + val beforeContentPadding = when { + isVertical && !reverseLayout -> topPadding + isVertical && reverseLayout -> bottomPadding + !isVertical && !reverseLayout -> startPadding + else -> endPadding // !isVertical && reverseLayout + } + val afterContentPadding = totalMainAxisPadding - beforeContentPadding + val contentConstraints = + containerConstraints.offset(-totalHorizontalPadding, -totalVerticalPadding) + + // Update the state's cached Density + state.density = this + + // this will update the scope used by the item composables + itemProvider.itemScope.setMaxSize( + width = contentConstraints.maxWidth, + height = contentConstraints.maxHeight + ) + + val spaceBetweenItemsDp = if (isVertical) { + requireNotNull(verticalArrangement).spacing + } else { + requireNotNull(horizontalArrangement).spacing + } + val spaceBetweenItems = spaceBetweenItemsDp.roundToPx() + + val itemsCount = itemProvider.itemCount + + // can be negative if the content padding is larger than the max size from constraints + val mainAxisAvailableSize = if (isVertical) { + containerConstraints.maxHeight - totalVerticalPadding + } else { + containerConstraints.maxWidth - totalHorizontalPadding + } + val visualItemOffset = if (!reverseLayout || mainAxisAvailableSize > 0) { + IntOffset(startPadding, topPadding) + } else { + // When layout is reversed and paddings together take >100% of the available space, + // layout size is coerced to 0 when positioning. To take that space into account, + // we offset start padding by negative space between paddings. + IntOffset( + if (isVertical) startPadding else startPadding + mainAxisAvailableSize, + if (isVertical) topPadding + mainAxisAvailableSize else topPadding + ) + } + + val measuredItemProvider = LazyMeasuredItemProvider( + contentConstraints, + isVertical, + itemProvider, + this + ) { index, key, placeables -> + // we add spaceBetweenItems as an extra spacing for all items apart from the last one so + // the lazy list measuring logic will take it into account. + val spacing = if (index.value == itemsCount - 1) 0 else spaceBetweenItems + LazyMeasuredItem( + index = index.value, + placeables = placeables, + isVertical = isVertical, + horizontalAlignment = horizontalAlignment, + verticalAlignment = verticalAlignment, + layoutDirection = layoutDirection, + reverseLayout = reverseLayout, + beforeContentPadding = beforeContentPadding, + afterContentPadding = afterContentPadding, + spacing = spacing, + visualOffset = visualItemOffset, + key = key, + placementAnimator = placementAnimator + ) + } + state.premeasureConstraints = measuredItemProvider.childConstraints + + val firstVisibleItemIndex: DataIndex + val firstVisibleScrollOffset: Int + Snapshot.withoutReadObservation { + firstVisibleItemIndex = DataIndex(state.firstVisibleItemIndex) + firstVisibleScrollOffset = state.firstVisibleItemScrollOffset + } + + measureLazyList( + itemsCount = itemsCount, + itemProvider = measuredItemProvider, + mainAxisAvailableSize = mainAxisAvailableSize, + beforeContentPadding = beforeContentPadding, + afterContentPadding = afterContentPadding, + spaceBetweenItems = spaceBetweenItems, + firstVisibleItemIndex = firstVisibleItemIndex, + firstVisibleItemScrollOffset = firstVisibleScrollOffset, + scrollToBeConsumed = state.scrollToBeConsumed, + constraints = contentConstraints, + isVertical = isVertical, + headerIndexes = itemProvider.headerIndexes, + verticalArrangement = verticalArrangement, + horizontalArrangement = horizontalArrangement, + reverseLayout = reverseLayout, + density = this, + placementAnimator = placementAnimator, + beyondBoundsInfo = beyondBoundsInfo, + beyondBoundsItemCount = beyondBoundsItemCount, + layout = { width, height, placement -> + layout( + containerConstraints.constrainWidth(width + totalHorizontalPadding), + containerConstraints.constrainHeight(height + totalVerticalPadding), + emptyMap(), + placement + ) + } + ).also { + state.applyMeasureResult(it) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt new file mode 100644 index 000000000..e480721ae --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingState.kt @@ -0,0 +1,221 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.lazy.LazyListBeyondBoundsInfo +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt + +/** + * From [ViMusic](https://github.com/vfsfitvnm/ViMusic) + */ +@Stable +class ReorderingState( + val lazyListState: LazyListState, + val coroutineScope: CoroutineScope, + private val lastIndex: Int, + internal val onDragStart: () -> Unit, + internal val onDragEnd: (Int, Int) -> Unit, + private val extraItemCount: Int, +) { + private lateinit var lazyListBeyondBoundsInfoInterval: LazyListBeyondBoundsInfo.Interval + internal val lazyListBeyondBoundsInfo = LazyListBeyondBoundsInfo() + internal val offset = Animatable(0, Int.VectorConverter) + + internal var draggingIndex by mutableStateOf(-1) + internal var reachedIndex by mutableStateOf(-1) + internal var draggingItemSize by mutableStateOf(0) + + lateinit var itemInfo: LazyListItemInfo + + private var previousItemSize = 0 + private var nextItemSize = 0 + + private var overscrolled = 0 + + internal var indexesToAnimate = mutableStateMapOf>() + private var animatablesPool: AnimatablesPool? = null + + val isDragging: Boolean + get() = draggingIndex != -1 + + fun onDragStart(index: Int) { + overscrolled = 0 + itemInfo = lazyListState.layoutInfo.visibleItemsInfo.find { + it.index == index + extraItemCount + } ?: return + onDragStart() + draggingIndex = index + reachedIndex = index + draggingItemSize = itemInfo.size + + nextItemSize = draggingItemSize + previousItemSize = -draggingItemSize + + offset.updateBounds( + lowerBound = -index * draggingItemSize, + upperBound = (lastIndex - index) * draggingItemSize + ) + + lazyListBeyondBoundsInfoInterval = + lazyListBeyondBoundsInfo.addInterval(index + extraItemCount, index + extraItemCount) + + val size = + lazyListState.layoutInfo.viewportEndOffset - lazyListState.layoutInfo.viewportStartOffset + + animatablesPool = AnimatablesPool(size / draggingItemSize + 2, 0, Int.VectorConverter) + } + + fun onDrag(change: PointerInputChange, dragAmount: Offset) { + if (!isDragging) return + change.consume() + + val delta = when (lazyListState.layoutInfo.orientation) { + Orientation.Vertical -> dragAmount.y + Orientation.Horizontal -> dragAmount.x + }.roundToInt() + + val targetOffset = offset.value + delta + + coroutineScope.launch { + offset.snapTo(targetOffset) + } + + if (targetOffset > nextItemSize) { + if (reachedIndex < lastIndex) { + reachedIndex += 1 + nextItemSize += draggingItemSize + previousItemSize += draggingItemSize + + val indexToAnimate = reachedIndex - if (draggingIndex < reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex < reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(-draggingItemSize) + } else { + animatable.snapTo(draggingItemSize) + animatable.animateTo(0) + } + + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } else if (targetOffset < previousItemSize) { + if (reachedIndex > 0) { + reachedIndex -= 1 + previousItemSize -= draggingItemSize + nextItemSize -= draggingItemSize + + val indexToAnimate = reachedIndex + if (draggingIndex > reachedIndex) 0 else 1 + + coroutineScope.launch { + val animatable = indexesToAnimate.getOrPut(indexToAnimate) { + animatablesPool?.acquire() ?: return@launch + } + + if (draggingIndex > reachedIndex) { + animatable.snapTo(0) + animatable.animateTo(draggingItemSize) + } else { + animatable.snapTo(-draggingItemSize) + animatable.animateTo(0) + } + indexesToAnimate.remove(indexToAnimate) + animatablesPool?.release(animatable) + } + } + } else { + val offsetInViewPort = targetOffset + itemInfo.offset - overscrolled + + val topOverscroll = lazyListState.layoutInfo.viewportStartOffset + + lazyListState.layoutInfo.beforeContentPadding - offsetInViewPort + + val bottomOverscroll = lazyListState.layoutInfo.viewportEndOffset - + lazyListState.layoutInfo.afterContentPadding - offsetInViewPort - itemInfo.size + + if (topOverscroll > 0) { + overscroll(topOverscroll) + } else if (bottomOverscroll < 0) { + overscroll(bottomOverscroll) + } + } + } + + fun onDragEnd() { + if (!isDragging) return + + coroutineScope.launch { + offset.animateTo((previousItemSize + nextItemSize) / 2) + + withContext(Dispatchers.Main) { + onDragEnd(draggingIndex, reachedIndex) + } + + if (areEquals()) { + draggingIndex = -1 + reachedIndex = -1 + draggingItemSize = 0 + offset.snapTo(0) + } + + lazyListBeyondBoundsInfo.removeInterval(lazyListBeyondBoundsInfoInterval) + animatablesPool = null + } + } + + private fun overscroll(overscroll: Int) { + lazyListState.dispatchRawDelta(-overscroll.toFloat()) + coroutineScope.launch { + offset.snapTo(offset.value - overscroll) + } + overscrolled -= overscroll + } + + private fun areEquals(): Boolean { + return lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == draggingIndex + }?.key == lazyListState.layoutInfo.visibleItemsInfo.find { + it.index + extraItemCount == reachedIndex + }?.key + } +} + +@Composable +fun rememberReorderingState( + lazyListState: LazyListState, + key: Any, + onDragEnd: (Int, Int) -> Unit, + onDragStart: () -> Unit = {}, + extraItemCount: Int = 0, +): ReorderingState { + val coroutineScope = rememberCoroutineScope() + + return remember(key) { + ReorderingState( + lazyListState = lazyListState, + coroutineScope = coroutineScope, + lastIndex = if (key is List<*>) key.lastIndex else lazyListState.layoutInfo.totalItemsCount, + onDragStart = onDragStart, + onDragEnd = onDragEnd, + extraItemCount = extraItemCount, + ) + } +} diff --git a/app/src/main/res/drawable/ic_drag_handle.xml b/app/src/main/res/drawable/ic_drag_handle.xml index fa9e55b25..64d052f5e 100644 --- a/app/src/main/res/drawable/ic_drag_handle.xml +++ b/app/src/main/res/drawable/ic_drag_handle.xml @@ -3,7 +3,7 @@ android:height="24dp" android:viewportWidth="24" android:viewportHeight="24"> - + diff --git a/settings.gradle.kts b/settings.gradle.kts index 2b71970e2..d2a47eb7e 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -20,7 +20,7 @@ dependencyResolutionManagement { library("datastore", "androidx.datastore", "datastore-preferences").version("1.0.0") version("compose-compiler", "1.3.2") - version("compose", "1.3.0") + version("compose", "1.3.1") library("compose-runtime", "androidx.compose.runtime", "runtime").versionRef("compose") library("compose-foundation", "androidx.compose.foundation", "foundation").versionRef("compose") library("compose-ui", "androidx.compose.ui", "ui").versionRef("compose") From 7d41aa74b9e0fbb02a86b25fea092835f91927ed Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 5 Feb 2023 22:48:18 +0800 Subject: [PATCH 171/323] Add buttons in local playlist screen --- .../screens/playlist/LocalPlaylistScreen.kt | 81 +++++++++++++++++-- 1 file changed, 76 insertions(+), 5 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index 852389201..4df9ebd76 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -16,7 +16,9 @@ import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -25,20 +27,27 @@ import androidx.compose.ui.util.fastSumBy import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.utils.completed import com.zionhuang.music.LocalDatabase 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.ThumbnailCornerRadius +import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.db.entities.Song import com.zionhuang.music.extensions.toMediaItem +import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.ui.utils.reordering.* import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.viewmodels.LocalPlaylistViewModel +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch @OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) @Composable @@ -73,6 +82,26 @@ fun LocalPlaylistScreen( } } + var showEditDialog by remember { + mutableStateOf(false) + } + + if (showEditDialog) { + playlist?.playlist?.let { playlistEntity -> + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.ic_edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.dialog_title_edit_playlist)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue(playlistEntity.name, TextRange(playlistEntity.name.length)), + onDone = { name -> + database.query { + update(playlistEntity.copy(name = name)) + } + } + ) + } + } + val reorderingState = rememberReorderingState( lazyListState = rememberLazyListState(), key = songs, @@ -159,6 +188,46 @@ fun LocalPlaylistScreen( style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Normal ) + + Row { + IconButton( + onClick = { showEditDialog = true } + ) { + Icon( + painter = painterResource(R.drawable.ic_edit), + contentDescription = null + ) + } + + if (playlist.playlist.browseId != null) { + IconButton( + onClick = { + coroutineScope.launch(Dispatchers.IO) { + val playlistPage = YouTube.playlist(playlist.playlist.browseId).completed().getOrNull() ?: return@launch + database.transaction { + clearPlaylist(playlist.id) + playlistPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .mapIndexed { position, song -> + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = position + ) + } + .forEach(::insert) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_sync), + contentDescription = null + ) + } + } + } } } @@ -246,11 +315,13 @@ fun LocalPlaylistScreen( modifier = Modifier .fillMaxWidth() .combinedClickable { - playerConnection.playQueue(ListQueue( - title = playlist!!.playlist.name, - items = songs.map(Song::toMediaItem), - startIndex = index - )) + playerConnection.playQueue( + ListQueue( + title = playlist!!.playlist.name, + items = songs.map(Song::toMediaItem), + startIndex = index + ) + ) } .animateItemPlacement(reorderingState = reorderingState) .draggedItem(reorderingState = reorderingState, index = index) From 2dbd4f0f791ddb330047b00ea4a8e55bf9e24a1e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 15:44:22 +0800 Subject: [PATCH 172/323] Replace AppBar with SearchBar and TopAppBar --- app/build.gradle.kts | 12 +- .../java/com/zionhuang/music/MainActivity.kt | 324 +++++++++------ .../com/zionhuang/music/db/MusicDatabase.kt | 2 - .../zionhuang/music/ui/component/AppBar.kt | 390 ------------------ .../zionhuang/music/ui/component/SearchBar.kt | 301 ++++++++++++++ .../zionhuang/music/ui/screens/AlbumScreen.kt | 80 ++-- .../music/ui/screens/NewReleaseScreen.kt | 20 +- .../com/zionhuang/music/ui/screens/Screen.kt | 94 ----- .../com/zionhuang/music/ui/screens/Screens.kt | 19 + .../ui/screens/artist/ArtistItemsScreen.kt | 36 +- .../music/ui/screens/artist/ArtistScreen.kt | 42 +- .../ui/screens/artist/ArtistSongsScreen.kt | 43 +- .../screens/playlist/BuiltInPlaylistScreen.kt | 54 +-- .../screens/playlist/LocalPlaylistScreen.kt | 53 ++- .../screens/playlist/OnlinePlaylistScreen.kt | 42 +- .../ui/screens/search/OnlineSearchScreen.kt | 28 +- .../music/ui/screens/settings/AboutScreen.kt | 22 +- .../ui/screens/settings/AppearanceSettings.kt | 22 +- .../ui/screens/settings/BackupAndRestore.kt | 20 + .../ui/screens/settings/ContentSettings.kt | 22 +- .../ui/screens/settings/GeneralSettings.kt | 22 +- .../ui/screens/settings/PlayerSettings.kt | 22 +- .../ui/screens/settings/PrivacySettings.kt | 25 +- .../ui/screens/settings/SettingsScreen.kt | 17 + .../ui/screens/settings/StorageSettings.kt | 31 +- .../com/zionhuang/music/ui/utils/AppBar.kt | 63 +++ .../ui/utils/reordering/ReorderingLazyList.kt | 2 +- build.gradle.kts | 2 +- gradle.properties | 1 + gradle/wrapper/gradle-wrapper.properties | 2 +- innertube/build.gradle.kts | 4 + kugou/build.gradle.kts | 4 + settings.gradle.kts | 12 +- 33 files changed, 1044 insertions(+), 789 deletions(-) delete mode 100644 app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt delete mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/Screen.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 28724083a..cb10da169 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + plugins { id("com.android.application") kotlin("android") @@ -55,13 +57,17 @@ android { } compileOptions { isCoreLibraryDesugaringEnabled = true - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 + sourceCompatibility = JavaVersion.VERSION_11 + targetCompatibility = JavaVersion.VERSION_11 + } + kotlin { + jvmToolchain(11) } kotlinOptions { freeCompilerArgs = freeCompilerArgs + "-Xcontext-receivers" - jvmTarget = "1.8" + jvmTarget = "11" } + testOptions { unitTests.isIncludeAndroidResources = true unitTests.isReturnDefaultValues = true diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 4fccf10a4..f91aa10a2 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -10,7 +10,11 @@ import android.os.Bundle import android.os.IBinder import androidx.activity.ComponentActivity import androidx.activity.compose.setContent +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.Crossfade import androidx.compose.animation.core.* +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut import androidx.compose.foundation.background import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.layout.* @@ -24,6 +28,7 @@ import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.toArgb import androidx.compose.ui.input.nestedscroll.nestedScroll import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange @@ -71,6 +76,9 @@ import com.zionhuang.music.ui.screens.search.OnlineSearchResult import com.zionhuang.music.ui.screens.search.OnlineSearchScreen import com.zionhuang.music.ui.screens.settings.* import com.zionhuang.music.ui.theme.* +import com.zionhuang.music.ui.utils.appBarScrollBehavior +import com.zionhuang.music.ui.utils.canNavigateUp +import com.zionhuang.music.ui.utils.resetHeightOffset import com.zionhuang.music.utils.dataStore import com.zionhuang.music.utils.get import com.zionhuang.music.utils.rememberEnumPreference @@ -150,45 +158,49 @@ class MainActivity : ComponentActivity() { ) { val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() - val route = remember(navBackStackEntry) { navBackStackEntry?.destination?.route } val navigationItems = remember { - listOf(Screen.Home, Screen.Songs, Screen.Artists, Screen.Albums, Screen.Playlists) + listOf(Screens.Home, Screens.Songs, Screens.Artists, Screens.Albums, Screens.Playlists) } val defaultOpenTab = remember { dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) } - val (isSearchExpanded, onSearchExpandedChange) = rememberSaveable { mutableStateOf(false) } - val (textFieldValue, onTextFieldValueChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } - val appBarConfig = remember(navBackStackEntry) { - when { - route == null || navigationItems.any { it.route == route } -> searchAppBarConfig() - route.startsWith("search/") -> onlineSearchResultAppBarConfig(navBackStackEntry?.arguments?.getString("query").orEmpty()) - route.startsWith("album/") -> albumAppBarConfig() - route.startsWith("artist/") -> artistAppBarConfig() - route.startsWith("playlist/") -> playlistAppBarConfig() - route.startsWith("settings") -> settingsAppBarConfig(route) - else -> defaultAppBarConfig() + val focusManager = LocalFocusManager.current + val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { + mutableStateOf(TextFieldValue()) + } + var active by rememberSaveable { + mutableStateOf(false) + } + val onActiveChange: (Boolean) -> Unit = { newActive -> + active = newActive + if (!newActive) { + focusManager.clearFocus() + if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) + } } } - val onSearch: (String) -> Unit = remember { - { query -> - onTextFieldValueChange(TextFieldValue( - text = query, - selection = TextRange(query.length) - )) - navController.navigate("search/$query") - if (dataStore[PauseSearchHistoryKey] != true) { - database.query { - insert(SearchHistory(query = query)) - } + var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) + + val onSearch: (String) -> Unit = { + onActiveChange(false) + navController.navigate("search/$it") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) } } } - val shouldShowNavigationBar = remember(navBackStackEntry, isSearchExpanded) { - route == null || navigationItems.fastAny { it.route == route } && !isSearchExpanded + val shouldShowSearchBar = remember(active, navBackStackEntry) { + active || navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || + navBackStackEntry?.destination?.route?.startsWith("search/") == true + } + val shouldShowNavigationBar = remember(navBackStackEntry, active) { + navBackStackEntry?.destination?.route == null || + navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } && !active } val navigationBarHeight by animateDpAsState( targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp, @@ -211,31 +223,33 @@ class MainActivity : ComponentActivity() { if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight windowsInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .add(WindowInsets( - top = AppBarHeight, - bottom = bottom - )) + .add( + WindowInsets( + top = AppBarHeight, + bottom = bottom + ) + ) } val scrollBehavior = appBarScrollBehavior( canScroll = { - route?.startsWith("search/") == false && + navBackStackEntry?.destination?.route?.startsWith("search/") == false && (playerBottomSheetState.isCollapsed || playerBottomSheetState.isDismissed) } ) - LaunchedEffect(route) { - onSearchExpandedChange(false) - if (navigationItems.any { it.route == route }) { - onTextFieldValueChange(TextFieldValue()) + LaunchedEffect(navBackStackEntry) { + if (navBackStackEntry?.destination?.route?.startsWith("search/") == true) { + val searchQuery = navBackStackEntry?.arguments?.getString("query")!! + onQueryChange(TextFieldValue(searchQuery, TextRange(searchQuery.length))) + } else if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { + onQueryChange(TextFieldValue()) } - - val heightOffset = scrollBehavior.state.heightOffset - animate( - initialValue = heightOffset, - targetValue = 0f - ) { value, _ -> - scrollBehavior.state.heightOffset = value + scrollBehavior.state.resetHeightOffset() + } + LaunchedEffect(active) { + if (active) { + scrollBehavior.state.resetHeightOffset() } } @@ -283,31 +297,41 @@ class MainActivity : ComponentActivity() { NavHost( navController = navController, startDestination = when (defaultOpenTab) { - NavigationTab.HOME -> Screen.Home - NavigationTab.SONG -> Screen.Songs - NavigationTab.ARTIST -> Screen.Artists - NavigationTab.ALBUM -> Screen.Albums - NavigationTab.PLAYLIST -> Screen.Playlists + NavigationTab.HOME -> Screens.Home + NavigationTab.SONG -> Screens.Songs + NavigationTab.ARTIST -> Screens.Artists + NavigationTab.ALBUM -> Screens.Albums + NavigationTab.PLAYLIST -> Screens.Playlists }.route, modifier = Modifier.nestedScroll(scrollBehavior.nestedScrollConnection) ) { - composable(Screen.Home.route) { + composable(Screens.Home.route) { HomeScreen(navController) } - composable(Screen.Songs.route) { + composable(Screens.Songs.route) { LibrarySongsScreen(navController) } - composable(Screen.Artists.route) { + composable(Screens.Artists.route) { LibraryArtistsScreen(navController) } - composable(Screen.Albums.route) { + composable(Screens.Albums.route) { LibraryAlbumsScreen(navController) } - composable(Screen.Playlists.route) { + composable(Screens.Playlists.route) { LibraryPlaylistsScreen(navController) } composable("new_release") { - NewReleaseScreen(navController) + NewReleaseScreen(navController, scrollBehavior) + } + composable( + route = "search/{query}", + arguments = listOf( + navArgument("query") { + type = NavType.StringType + } + ) + ) { + OnlineSearchResult(navController) } composable( route = "album/{albumId}?playlistId={playlistId}", @@ -321,7 +345,7 @@ class MainActivity : ComponentActivity() { } ) ) { - AlbumScreen(navController) + AlbumScreen(navController, scrollBehavior) } composable( route = "artist/{artistId}", @@ -333,12 +357,9 @@ class MainActivity : ComponentActivity() { ) { backStackEntry -> val artistId = backStackEntry.arguments?.getString("artistId")!! if (artistId.startsWith("LA")) { - ArtistSongsScreen(navController = navController) + ArtistSongsScreen(navController, scrollBehavior) } else { - ArtistScreen( - navController = navController, - appBarConfig = appBarConfig - ) + ArtistScreen(navController, scrollBehavior) } } composable( @@ -349,7 +370,7 @@ class MainActivity : ComponentActivity() { } ) ) { - ArtistSongsScreen(navController = navController) + ArtistSongsScreen(navController, scrollBehavior) } composable( route = "artist/{artistId}/items?browseId={browseId}?params={params}", @@ -367,10 +388,7 @@ class MainActivity : ComponentActivity() { } ) ) { - ArtistItemsScreen( - navController = navController, - appBarConfig = appBarConfig - ) + ArtistItemsScreen(navController, scrollBehavior) } composable( route = "online_playlist/{playlistId}", @@ -380,10 +398,7 @@ class MainActivity : ComponentActivity() { } ) ) { - OnlinePlaylistScreen( - appBarConfig = appBarConfig, - navController = navController - ) + OnlinePlaylistScreen(navController, scrollBehavior) } composable( route = "local_playlist/{playlistId}", @@ -395,84 +410,147 @@ class MainActivity : ComponentActivity() { ) { backStackEntry -> val playlistId = backStackEntry.arguments?.getString("playlistId")!! if (playlistId == LIKED_PLAYLIST_ID || playlistId == DOWNLOADED_PLAYLIST_ID) { - BuiltInPlaylistScreen( - appBarConfig = appBarConfig, - navController = navController - ) + BuiltInPlaylistScreen(navController, scrollBehavior) } else { - LocalPlaylistScreen( - appBarConfig = appBarConfig, - navController = navController - ) + LocalPlaylistScreen(navController, scrollBehavior) } } - composable( - route = "search/{query}", - arguments = listOf( - navArgument("query") { - type = NavType.StringType - } - ) - ) { - OnlineSearchResult( - navController = navController - ) - } - composable("settings") { - SettingsScreen(navController) + SettingsScreen(navController, scrollBehavior) } composable("settings/appearance") { - AppearanceSettings() + AppearanceSettings(navController, scrollBehavior) } composable("settings/content") { - ContentSettings() + ContentSettings(navController, scrollBehavior) } composable("settings/player") { - PlayerSettings() + PlayerSettings(navController, scrollBehavior) } composable("settings/storage") { - StorageSettings() + StorageSettings(navController, scrollBehavior) } composable("settings/general") { - GeneralSettings() + GeneralSettings(navController, scrollBehavior) } composable("settings/privacy") { - PrivacySettings() + PrivacySettings(navController, scrollBehavior) } composable("settings/backup_restore") { - BackupAndRestore() + BackupAndRestore(navController, scrollBehavior) } composable("settings/about") { - AboutScreen() + AboutScreen(navController, scrollBehavior) } } - AppBar( - appBarConfig = appBarConfig, - textFieldValue = textFieldValue, - onTextFieldValueChange = onTextFieldValueChange, - isSearchExpanded = isSearchExpanded, - onSearchExpandedChange = onSearchExpandedChange, - scrollBehavior = scrollBehavior, - navController = navController, - localSearchScreen = { query, _ -> - LocalSearchScreen( - query = query, - navController = navController - ) - }, - onlineSearchScreen = { query, onDismiss -> - OnlineSearchScreen( - query = query, - onTextFieldValueChange = onTextFieldValueChange, - navController = navController, - onSearch = onSearch, - onDismiss = onDismiss - ) - }, - onSearchOnline = onSearch - ) + AnimatedVisibility( + visible = shouldShowSearchBar, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier.align(Alignment.TopCenter) + ) { + SearchBar( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + scrollBehavior = scrollBehavior, + placeholder = { + Text( + text = stringResource( + if (!active) R.string.menu_search + else when (searchSource) { + SearchSource.LOCAL -> R.string.search_library + SearchSource.ONLINE -> R.string.search_yt_music + } + ) + ) + }, + leadingIcon = { + IconButton(onClick = { + when { + active -> onActiveChange(false) + navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } -> { + navController.navigateUp() + } + else -> onActiveChange(true) + } + }) { + Icon( + painterResource( + if (active || (navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route })) { + R.drawable.ic_arrow_back + } else { + R.drawable.ic_search + } + ), + contentDescription = null + ) + } + }, + trailingIcon = { + if (active) { + if (query.text.isNotEmpty()) { + IconButton( + onClick = { onQueryChange(TextFieldValue("")) } + ) { + Icon( + painter = painterResource(R.drawable.ic_close), + contentDescription = null + ) + } + } + IconButton( + onClick = { + searchSource = if (searchSource == SearchSource.ONLINE) SearchSource.LOCAL else SearchSource.ONLINE + } + ) { + Icon( + painter = painterResource( + when (searchSource) { + SearchSource.LOCAL -> R.drawable.ic_library_music + SearchSource.ONLINE -> R.drawable.ic_language + } + ), + contentDescription = null + ) + } + } + }, + modifier = Modifier.align(Alignment.TopCenter) + ) { + Crossfade( + targetState = searchSource, + modifier = Modifier + .fillMaxSize() + .navigationBarsPadding() + .imePadding() + ) { searchSource -> + when (searchSource) { + SearchSource.LOCAL -> LocalSearchScreen( + query = query.text, + navController = navController + ) + SearchSource.ONLINE -> OnlineSearchScreen( + query = query.text, + onQueryChange = onQueryChange, + navController = navController, + onSearch = { + navController.navigate("search/$it") + if (dataStore[PauseSearchHistoryKey] != true) { + database.query { + insert(SearchHistory(query = it)) + } + } + }, + onDismiss = { onActiveChange(false) } + ) + } + } + } + } BottomSheetPlayer( state = playerBottomSheetState, 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 a0afc31cf..5c9e0a7cb 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -9,7 +9,6 @@ import androidx.sqlite.db.SupportSQLiteDatabase import androidx.sqlite.db.SupportSQLiteOpenHelper import com.zionhuang.music.db.entities.* import com.zionhuang.music.extensions.toSQLiteQuery -import timber.log.Timber import java.time.Instant import java.time.LocalDateTime import java.time.ZoneOffset @@ -234,7 +233,6 @@ class Migration5To6 : AutoMigrationSpec { override fun onPostMigrate(db: SupportSQLiteDatabase) { db.query("SELECT id FROM playlist WHERE id NOT LIKE 'LP%'").use { cursor -> while (cursor.moveToNext()) { - Timber.d("id = ${cursor.getString(0)}") db.execSQL("UPDATE playlist SET browseID = '${cursor.getString(0)}' WHERE id = '${cursor.getString(0)}'") } } diff --git a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt deleted file mode 100644 index 4cc85b1d1..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/component/AppBar.kt +++ /dev/null @@ -1,390 +0,0 @@ -package com.zionhuang.music.ui.component - -import androidx.activity.compose.BackHandler -import androidx.compose.animation.* -import androidx.compose.animation.core.* -import androidx.compose.foundation.background -import androidx.compose.foundation.clickable -import androidx.compose.foundation.focusable -import androidx.compose.foundation.layout.* -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.foundation.text.BasicTextField -import androidx.compose.foundation.text.KeyboardActions -import androidx.compose.foundation.text.KeyboardOptions -import androidx.compose.material3.* -import androidx.compose.runtime.* -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.draw.drawBehind -import androidx.compose.ui.focus.FocusRequester -import androidx.compose.ui.focus.focusRequester -import androidx.compose.ui.geometry.Offset -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.graphics.SolidColor -import androidx.compose.ui.graphics.graphicsLayer -import androidx.compose.ui.input.nestedscroll.NestedScrollConnection -import androidx.compose.ui.input.nestedscroll.NestedScrollSource -import androidx.compose.ui.platform.LocalDensity -import androidx.compose.ui.res.painterResource -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.input.ImeAction -import androidx.compose.ui.text.input.KeyboardType -import androidx.compose.ui.text.input.TextFieldValue -import androidx.compose.ui.text.style.TextOverflow -import androidx.compose.ui.unit.IntOffset -import androidx.compose.ui.unit.dp -import androidx.navigation.NavController -import androidx.navigation.compose.currentBackStackEntryAsState -import com.zionhuang.music.R -import com.zionhuang.music.constants.* -import com.zionhuang.music.ui.utils.canNavigateUp -import com.zionhuang.music.utils.rememberEnumPreference -import kotlin.math.roundToInt - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun AppBar( - appBarConfig: AppBarConfig, - textFieldValue: TextFieldValue, - onTextFieldValueChange: (TextFieldValue) -> Unit, - isSearchExpanded: Boolean = false, - onSearchExpandedChange: (Boolean) -> Unit, - scrollBehavior: TopAppBarScrollBehavior, - background: Color = MaterialTheme.colorScheme.background, - searchBarBackground: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp), - navController: NavController, - localSearchScreen: @Composable (query: String, onDismiss: () -> Unit) -> Unit, - onlineSearchScreen: @Composable (query: String, onDismiss: () -> Unit) -> Unit, - onSearchOnline: (String) -> Unit, -) { - val density = LocalDensity.current - val topInset = with(density) { WindowInsets.systemBars.getTop(density).toDp() } - val heightOffsetLimit = with(density) { -(AppBarHeight + topInset).toPx() } - SideEffect { - if (scrollBehavior.state.heightOffsetLimit != heightOffsetLimit) { - scrollBehavior.state.heightOffsetLimit = heightOffsetLimit - } - } - - var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) - - val expandTransition = updateTransition(targetState = isSearchExpanded || !appBarConfig.searchable, "searchExpanded") - val searchTransitionProgress by expandTransition.animateFloat(label = "") { if (it) 1f else 0f } - val horizontalPadding by expandTransition.animateDp(label = "") { if (it) 0.dp else 12.dp } - val verticalPadding by expandTransition.animateDp(label = "") { if (it) 0.dp else 8.dp } - val cornerShapePercent by expandTransition.animateInt(label = "") { if (it) 0 else 50 } - - val barBackground by animateColorAsState(when { - appBarConfig.searchable -> searchBarBackground - appBarConfig.transparentBackground -> Color.Transparent - else -> MaterialTheme.colorScheme.background - }) - - val focusRequester = remember { - FocusRequester() - } - - val backStateEntry by navController.currentBackStackEntryAsState() - val canNavigateUp = remember(backStateEntry) { - navController.canNavigateUp - } - - LaunchedEffect(isSearchExpanded) { - if (isSearchExpanded) { - focusRequester.requestFocus() - } - val heightOffset = scrollBehavior.state.heightOffset - animate( - initialValue = heightOffset, - targetValue = 0f - ) { value, _ -> - scrollBehavior.state.heightOffset = value - } - } - - AnimatedVisibility( - visible = isSearchExpanded, - enter = fadeIn(tween(easing = LinearOutSlowInEasing)) + slideInVertically(tween()) { - with(density) { -AppBarHeight.toPx().roundToInt() } - }, - exit = fadeOut() - ) { - BackHandler { - onSearchExpandedChange(false) - } - - Box( - modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background) - .windowInsetsPadding( - WindowInsets.systemBars - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - ) - .padding(top = AppBarHeight) - .navigationBarsPadding() - .imePadding() - ) { - Crossfade( - targetState = searchSource - ) { searchSource -> - when (searchSource) { - SearchSource.LOCAL -> localSearchScreen( - query = textFieldValue.text, - onDismiss = { onSearchExpandedChange(false) } - ) - SearchSource.ONLINE -> onlineSearchScreen( - query = textFieldValue.text, - onDismiss = { onSearchExpandedChange(false) } - ) - } - } - } - } - - Box( - modifier = Modifier.offset { - IntOffset(x = 0, y = scrollBehavior.state.heightOffset.roundToInt()) - } - ) { - AnimatedVisibility( - visible = !appBarConfig.transparentBackground, - enter = fadeIn(), - exit = fadeOut() - ) { - Box( - modifier = Modifier - .fillMaxWidth() - .height(topInset) - .drawBehind { - drawRect(if (appBarConfig.searchable && isSearchExpanded) { - searchBarBackground.copy(alpha = searchTransitionProgress) - } else { - background - }) - } - ) - } - - Box( - modifier = Modifier - .windowInsetsPadding( - WindowInsets.systemBars - .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) - ) - .fillMaxWidth() - .height(AppBarHeight) - .padding(horizontal = horizontalPadding, vertical = verticalPadding) - .graphicsLayer { - clip = true - shape = RoundedCornerShape(cornerShapePercent) - } - .drawBehind { - drawRect(barBackground) - } - ) { - AnimatedVisibility( - visible = appBarConfig.searchable, - enter = fadeIn(), - exit = fadeOut() - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxSize() - .clickable(enabled = appBarConfig.searchable && !isSearchExpanded) { - onSearchExpandedChange(true) - } - .focusable() - ) { - IconButton( - modifier = Modifier.padding(horizontal = 4.dp), - onClick = { - when { - isSearchExpanded -> onSearchExpandedChange(false) - !appBarConfig.isRootDestination && canNavigateUp -> navController.navigateUp() - else -> onSearchExpandedChange(true) - } - } - ) { - Icon( - painter = painterResource( - if (isSearchExpanded || (!appBarConfig.isRootDestination && canNavigateUp)) { - R.drawable.ic_arrow_back - } else { - R.drawable.ic_search - } - ), - contentDescription = null - ) - } - - if (isSearchExpanded) { - BasicTextField( - value = textFieldValue, - onValueChange = onTextFieldValueChange, - textStyle = MaterialTheme.typography.bodyLarge, - singleLine = true, - maxLines = 1, - cursorBrush = SolidColor(MaterialTheme.colorScheme.primary), - keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.Text, imeAction = ImeAction.Search), - keyboardActions = KeyboardActions( - onSearch = { - onSearchExpandedChange(false) - onSearchOnline(textFieldValue.text) - } - ), - decorationBox = { innerTextField -> - Box( - modifier = Modifier.fillMaxHeight(), - contentAlignment = Alignment.CenterStart - ) { - if (textFieldValue.text.isEmpty()) { - Text( - text = stringResource(when (searchSource) { - SearchSource.LOCAL -> R.string.search_library - SearchSource.ONLINE -> R.string.search_yt_music - }), - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .alpha(0.6f) - ) - } - innerTextField() - } - }, - modifier = Modifier - .weight(1f) - .focusRequester(focusRequester) - ) - } else { - appBarConfig.title(this) - } - - if (isSearchExpanded) { - if (textFieldValue.text.isNotEmpty()) { - IconButton( - onClick = { - onTextFieldValueChange(TextFieldValue("")) - } - ) { - Icon( - painter = painterResource(R.drawable.ic_close), - contentDescription = null - ) - } - } - - IconButton( - onClick = { - searchSource = if (searchSource == SearchSource.ONLINE) SearchSource.LOCAL else SearchSource.ONLINE - } - ) { - Icon( - painter = painterResource(when (searchSource) { - SearchSource.LOCAL -> R.drawable.ic_library_music - SearchSource.ONLINE -> R.drawable.ic_language - }), - contentDescription = null - ) - } - } - } - } - - AnimatedVisibility( - visible = !appBarConfig.searchable, - enter = fadeIn(), - exit = fadeOut() - ) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier.fillMaxSize() - ) { - IconButton( - modifier = Modifier.padding(horizontal = 4.dp), - onClick = { - when { - isSearchExpanded -> onSearchExpandedChange(false) - !appBarConfig.isRootDestination && canNavigateUp -> navController.navigateUp() - else -> onSearchExpandedChange(true) - } - } - ) { - Icon( - painter = painterResource( - if (isSearchExpanded || !appBarConfig.isRootDestination && canNavigateUp) { - R.drawable.ic_arrow_back - } else { - R.drawable.ic_search - } - ), - contentDescription = null - ) - } - - appBarConfig.title(this) - - appBarConfig.actions(this) - } - } - } - } -} - -@Stable -class AppBarConfig( - val isRootDestination: Boolean = false, - title: @Composable RowScope.() -> Unit = {}, - searchable: Boolean = true, - actions: @Composable RowScope.() -> Unit = {}, - transparentBackground: Boolean = false, -) { - var title by mutableStateOf(title) - var searchable by mutableStateOf(searchable) - var actions by mutableStateOf(actions) - var transparentBackground by mutableStateOf(transparentBackground) -} - -@OptIn(ExperimentalMaterial3Api::class) -@Composable -fun appBarScrollBehavior( - state: TopAppBarState = rememberTopAppBarState(), - canScroll: () -> Boolean = { true }, - snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), - flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), -): TopAppBarScrollBehavior = - AppBarScrollBehavior( - state = state, - snapAnimationSpec = snapAnimationSpec, - flingAnimationSpec = flingAnimationSpec, - canScroll = canScroll - ) - -@ExperimentalMaterial3Api -class AppBarScrollBehavior constructor( - override val state: TopAppBarState, - override val snapAnimationSpec: AnimationSpec?, - override val flingAnimationSpec: DecayAnimationSpec?, - val canScroll: () -> Boolean = { true }, -) : TopAppBarScrollBehavior { - override val isPinned: Boolean = false - override var nestedScrollConnection = object : NestedScrollConnection { - override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { - if (!canScroll()) return Offset.Zero - state.contentOffset += consumed.y - if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { - if (consumed.y == 0f && available.y > 0f) { - // Reset the total content offset to zero when scrolling all the way down. - // This will eliminate some float precision inaccuracies. - state.contentOffset = 0f - } - } - state.heightOffset += consumed.y - return Offset.Zero - } - } -} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt new file mode 100644 index 000000000..e6db353d1 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt @@ -0,0 +1,301 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.component + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.core.tween +import androidx.compose.foundation.gestures.awaitEachGesture +import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.waitForUpOrCancellation +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CornerSize +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.* +import androidx.compose.material3.tokens.MotionTokens +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.graphics.takeOrElse +import androidx.compose.ui.input.key.Key +import androidx.compose.ui.input.key.key +import androidx.compose.ui.input.key.onKeyEvent +import androidx.compose.ui.input.pointer.PointerEventPass +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.contentDescription +import androidx.compose.ui.semantics.semantics +import androidx.compose.ui.semantics.stateDescription +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.lerp +import androidx.compose.ui.util.lerp +import com.zionhuang.music.constants.AppBarHeight +import kotlin.math.max +import kotlin.math.roundToInt + +@ExperimentalMaterial3Api +@Composable +fun SearchBar( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + scrollBehavior: TopAppBarScrollBehavior, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + shape: Shape = SearchBarDefaults.inputFieldShape, + colors: SearchBarColors = SearchBarDefaults.colors(), + tonalElevation: Dp = SearchBarDefaults.Elevation, + windowInsets: WindowInsets = WindowInsets.systemBars, + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + content: @Composable ColumnScope.() -> Unit, +) { + val heightOffsetLimit = with(LocalDensity.current) { + -(AppBarHeight.toPx() + WindowInsets.systemBars.getTop(this)) + } + SideEffect { + if (scrollBehavior.state.heightOffsetLimit != heightOffsetLimit) { + scrollBehavior.state.heightOffsetLimit = heightOffsetLimit + } + } + + val animationProgress: Float by animateFloatAsState( + targetValue = if (active) 1f else 0f, + animationSpec = tween( + durationMillis = AnimationDurationMillis, + easing = MotionTokens.EasingLegacyCubicBezier, + ) + ) + + val defaultInputFieldShape = SearchBarDefaults.inputFieldShape + val defaultFullScreenShape = SearchBarDefaults.fullScreenShape + val animatedShape by remember { + derivedStateOf { + when { + shape == defaultInputFieldShape -> { + // The shape can only be animated if it's the default spec value + val animatedRadius = SearchBarCornerRadius * (1 - animationProgress) + RoundedCornerShape(CornerSize(animatedRadius)) + } + animationProgress == 1f -> defaultFullScreenShape + else -> shape + } + } + } + + val topInset = windowInsets.asPaddingValues().calculateTopPadding() + val startInset = windowInsets.asPaddingValues().calculateStartPadding(LocalLayoutDirection.current) + val endInset = windowInsets.asPaddingValues().calculateEndPadding(LocalLayoutDirection.current) + + val topPadding = SearchBarVerticalPadding + topInset + val animatedSurfaceTopPadding = lerp(topPadding, 0.dp, animationProgress) + val animatedInputFieldPadding by remember { + derivedStateOf { + PaddingValues( + start = startInset * animationProgress, + top = topPadding * animationProgress, + end = endInset * animationProgress, + bottom = SearchBarVerticalPadding * animationProgress, + ) + } + } + + BoxWithConstraints( + modifier = modifier + .offset { + IntOffset(x = 0, y = scrollBehavior.state.heightOffset.roundToInt()) + }, + propagateMinConstraints = true + ) { + val height: Dp + val width: Dp + val startPadding: Dp + val endPadding: Dp + with(LocalDensity.current) { + val startWidth = constraints.maxWidth.toFloat() + val startHeight = max(constraints.minHeight, InputFieldHeight.roundToPx()) + .coerceAtMost(constraints.maxHeight) + .toFloat() + val endWidth = constraints.maxWidth.toFloat() + val endHeight = constraints.maxHeight.toFloat() + + height = lerp(startHeight, endHeight, animationProgress).toDp() + width = lerp(startWidth, endWidth, animationProgress).toDp() + startPadding = lerp((SearchBarHorizontalPadding + startInset).roundToPx().toFloat(), 0f, animationProgress).toDp() + endPadding = lerp((SearchBarHorizontalPadding + endInset).roundToPx().toFloat(), 0f, animationProgress).toDp() + } + + Surface( + shape = animatedShape, + color = colors.containerColor, + contentColor = contentColorFor(colors.containerColor), + tonalElevation = tonalElevation, + modifier = Modifier + .padding( + top = animatedSurfaceTopPadding, + start = startPadding, + end = endPadding + ) + .size(width = width, height = height) + ) { + Column { + SearchBarInputField( + query = query, + onQueryChange = onQueryChange, + onSearch = onSearch, + active = active, + onActiveChange = onActiveChange, + modifier = Modifier.padding(animatedInputFieldPadding), + enabled = enabled, + placeholder = placeholder, + leadingIcon = leadingIcon, + trailingIcon = trailingIcon, + colors = colors.inputFieldColors, + interactionSource = interactionSource, + ) + + if (animationProgress > 0) { + Column(Modifier.alpha(animationProgress)) { + Divider(color = colors.dividerColor) + content() + } + } + } + } + } + + BackHandler(enabled = active) { + onActiveChange(false) + } +} + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalComposeUiApi::class) +@Composable +private fun SearchBarInputField( + query: TextFieldValue, + onQueryChange: (TextFieldValue) -> Unit, + onSearch: (String) -> Unit, + active: Boolean, + onActiveChange: (Boolean) -> Unit, + modifier: Modifier = Modifier, + enabled: Boolean = true, + placeholder: @Composable (() -> Unit)? = null, + leadingIcon: @Composable (() -> Unit)? = null, + trailingIcon: @Composable (() -> Unit)? = null, + colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), + interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, +) { + val focusRequester = remember { FocusRequester() } + val searchSemantics = getString(Strings.SearchBarSearch) + val suggestionsAvailableSemantics = getString(Strings.SuggestionsAvailable) + val textColor = LocalTextStyle.current.color.takeOrElse { + colors.textColor(enabled).value + } + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .height(InputFieldHeight) + ) { + if (leadingIcon != null) { + Spacer(Modifier.width(SearchBarIconOffsetX)) + leadingIcon() + } + + BasicTextField( + value = query, + onValueChange = onQueryChange, + modifier = Modifier + .weight(1f) + .focusRequester(focusRequester) + .pointerInput(Unit) { + awaitEachGesture { + // Must be PointerEventPass.Initial to observe events before the text field + // consumes them in the Main pass + awaitFirstDown(pass = PointerEventPass.Initial) + val upEvent = waitForUpOrCancellation(pass = PointerEventPass.Initial) + if (upEvent != null) { + onActiveChange(true) + } + } + } + .semantics { + contentDescription = searchSemantics + if (active) { + stateDescription = suggestionsAvailableSemantics + } + } + .onKeyEvent { + if (it.key == Key.Enter) { + onSearch(query.text) + return@onKeyEvent true + } + false + }, + enabled = enabled, + singleLine = true, + textStyle = LocalTextStyle.current.merge(TextStyle(color = textColor)), + cursorBrush = SolidColor(colors.cursorColor(isError = false).value), + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions(onSearch = { onSearch(query.text) }), + interactionSource = interactionSource, + decorationBox = { innerTextField -> + Box( + modifier = Modifier.fillMaxHeight(), + contentAlignment = Alignment.CenterStart + ) { + if (placeholder != null && query.text.isEmpty()) { + Box(Modifier.alpha(0.8f)) { + Decoration( + contentColor = colors.placeholderColor(enabled).value, + typography = MaterialTheme.typography.bodyLarge, + content = placeholder + ) + } + } + innerTextField() + } + } + ) + + if (trailingIcon != null) { + trailingIcon() + Spacer(Modifier.width(SearchBarIconOffsetX)) + } + } +} + +// Measurement specs +val InputFieldHeight = 48.dp +private val SearchBarCornerRadius: Dp = InputFieldHeight / 2 +internal val SearchBarMinWidth: Dp = 360.dp +private val SearchBarMaxWidth: Dp = 720.dp +internal val SearchBarVerticalPadding: Dp = 8.dp +internal val SearchBarHorizontalPadding: Dp = 12.dp + +// Search bar has 16dp padding between icons and start/end, while by default text field has 12dp. +val SearchBarIconOffsetX: Dp = 4.dp + +// Animation specs +private const val AnimationDurationMillis: Int = MotionTokens.DurationMedium2.toInt() 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 761c2781e..b20a63d8a 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 @@ -54,10 +54,11 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun AlbumScreen( navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: AlbumViewModel = hiltViewModel(), ) { val playerConnection = LocalPlayerConnection.current ?: return @@ -93,11 +94,13 @@ fun AlbumScreen( modifier = Modifier .fillMaxWidth() .combinedClickable { - playerConnection.playQueue(ListQueue( - title = viewState.albumWithSongs.album.title, - items = viewState.albumWithSongs.songs.map { it.toMediaItem() }, - startIndex = index - )) + playerConnection.playQueue( + ListQueue( + title = viewState.albumWithSongs.album.title, + items = viewState.albumWithSongs.songs.map { it.toMediaItem() }, + startIndex = index + ) + ) } ) } @@ -135,11 +138,13 @@ fun AlbumScreen( modifier = Modifier .fillMaxWidth() .combinedClickable { - playerConnection.playQueue(ListQueue( - title = viewState.albumPage.album.title, - items = viewState.albumPage.songs.map { it.toMediaItem() }, - startIndex = index - )) + playerConnection.playQueue( + ListQueue( + title = viewState.albumPage.album.title, + items = viewState.albumPage.songs.map { it.toMediaItem() }, + startIndex = index + ) + ) } ) } @@ -187,6 +192,19 @@ fun AlbumScreen( } } } + + TopAppBar( + title = { }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } @Composable @@ -282,10 +300,12 @@ fun LocalAlbumHeader( Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { - playerConnection.playQueue(ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.map(Song::toMediaItem) - )) + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.map(Song::toMediaItem) + ) + ) }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) @@ -303,10 +323,12 @@ fun LocalAlbumHeader( OutlinedButton( onClick = { - playerConnection.playQueue(ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.shuffled().map(Song::toMediaItem) - )) + playerConnection.playQueue( + ListQueue( + title = albumWithSongs.album.title, + items = albumWithSongs.songs.shuffled().map(Song::toMediaItem) + ) + ) }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) @@ -424,10 +446,12 @@ fun RemoteAlbumHeader( Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { - playerConnection.playQueue(ListQueue( - title = albumPage.album.title, - items = albumPage.songs.map(SongItem::toMediaItem) - )) + playerConnection.playQueue( + ListQueue( + title = albumPage.album.title, + items = albumPage.songs.map(SongItem::toMediaItem) + ) + ) }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) @@ -445,10 +469,12 @@ fun RemoteAlbumHeader( OutlinedButton( onClick = { - playerConnection.playQueue(ListQueue( - title = albumPage.album.title, - items = albumPage.songs.shuffled().map(SongItem::toMediaItem) - )) + playerConnection.playQueue( + ListQueue( + title = albumPage.album.title, + items = albumPage.songs.shuffled().map(SongItem::toMediaItem) + ) + ) }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) 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 6a2a981e2..66df2c448 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 @@ -8,17 +8,17 @@ import androidx.compose.foundation.layout.size import androidx.compose.foundation.lazy.grid.GridCells import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items -import androidx.compose.material3.Icon +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope 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.models.* import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R @@ -31,10 +31,11 @@ import com.zionhuang.music.ui.menu.YouTubeAlbumMenu import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.NewReleaseViewModel -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun NewReleaseScreen( navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: NewReleaseViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(), ) { @@ -110,4 +111,17 @@ fun NewReleaseScreen( } } } + + TopAppBar( + title = { Text(stringResource(R.string.new_release_albums)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/Screen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/Screen.kt deleted file mode 100644 index 78c0179a8..000000000 --- a/app/src/main/java/com/zionhuang/music/ui/screens/Screen.kt +++ /dev/null @@ -1,94 +0,0 @@ -package com.zionhuang.music.ui.screens - -import androidx.annotation.DrawableRes -import androidx.annotation.StringRes -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.runtime.Immutable -import androidx.compose.ui.Modifier -import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextOverflow -import com.zionhuang.music.R -import com.zionhuang.music.ui.component.AppBarConfig - -@Immutable -sealed class Screen( - @StringRes val titleId: Int, - @DrawableRes val iconId: Int, - val route: String, -) { - object Home : Screen(R.string.title_home, R.drawable.ic_home, "home") - object Songs : Screen(R.string.title_songs, R.drawable.ic_music_note, "songs") - object Artists : Screen(R.string.title_artists, R.drawable.ic_artist, "artists") - object Albums : Screen(R.string.title_albums, R.drawable.ic_album, "albums") - object Playlists : Screen(R.string.title_playlists, R.drawable.ic_queue_music, "playlists") -} - -fun defaultAppBarConfig() = AppBarConfig( - searchable = false -) - -fun searchAppBarConfig() = AppBarConfig( - isRootDestination = true, - title = { - Text( - text = stringResource(R.string.menu_search), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .alpha(0.6f) - .weight(1f) - ) - }, - searchable = true -) - -fun onlineSearchResultAppBarConfig(query: String) = AppBarConfig( - title = { - Text( - text = query, - style = MaterialTheme.typography.bodyLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - }, - searchable = true -) - -fun albumAppBarConfig() = AppBarConfig( - searchable = false -) - -fun artistAppBarConfig() = AppBarConfig( - searchable = false -) - -fun playlistAppBarConfig() = AppBarConfig( - searchable = false -) - -fun settingsAppBarConfig(route: String) = AppBarConfig( - title = { - Text( - text = stringResource(when (route) { - "settings" -> R.string.title_settings - "settings/appearance" -> R.string.pref_appearance_title - "settings/content" -> R.string.pref_content_title - "settings/player" -> R.string.pref_player_audio_title - "settings/storage" -> R.string.pref_storage_title - "settings/general" -> R.string.pref_general_title - "settings/privacy" -> R.string.pref_privacy_title - "settings/backup_restore" -> R.string.pref_backup_restore_title - "settings/about" -> R.string.pref_about_title - else -> error("Unknown route") - }), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - }, - searchable = false -) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt new file mode 100644 index 000000000..b31992422 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt @@ -0,0 +1,19 @@ +package com.zionhuang.music.ui.screens + +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.runtime.Immutable +import com.zionhuang.music.R + +@Immutable +sealed class Screens( + @StringRes val titleId: Int, + @DrawableRes val iconId: Int, + val route: String, +) { + object Home : Screens(R.string.title_home, R.drawable.ic_home, "home") + object Songs : Screens(R.string.title_songs, R.drawable.ic_music_note, "songs") + object Artists : Screens(R.string.title_artists, R.drawable.ic_artist, "artists") + object Albums : Screens(R.string.title_albums, R.drawable.ic_album, "albums") + object Playlists : Screens(R.string.title_playlists, R.drawable.ic_queue_music, "playlists") +} 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 c0b37e490..5b5b56e01 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 @@ -13,14 +13,10 @@ import androidx.compose.foundation.lazy.grid.LazyVerticalGrid import androidx.compose.foundation.lazy.grid.items 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.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource -import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @@ -31,7 +27,6 @@ import com.zionhuang.music.R import com.zionhuang.music.constants.GridThumbnailHeight import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.ui.component.AppBarConfig import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.YouTubeGridItem import com.zionhuang.music.ui.component.YouTubeListItem @@ -43,11 +38,11 @@ import com.zionhuang.music.ui.menu.YouTubeSongMenu import com.zionhuang.music.viewmodels.ArtistItemsViewModel import com.zionhuang.music.viewmodels.MainViewModel -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistItemsScreen( navController: NavController, - appBarConfig: AppBarConfig, + scrollBehavior: TopAppBarScrollBehavior, viewModel: ArtistItemsViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(), ) { @@ -67,18 +62,6 @@ fun ArtistItemsScreen( val title by viewModel.title.collectAsState() val itemsPage by viewModel.itemsPage.collectAsState() - LaunchedEffect(title) { - appBarConfig.title = { - Text( - text = title, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - } - LaunchedEffect(lazyListState) { snapshotFlow { lazyListState.layoutInfo.visibleItemsInfo.any { it.key == "loading" } @@ -297,4 +280,17 @@ fun ArtistItemsScreen( } } } + + TopAppBar( + title = { Text(title) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } 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 4d268ae92..cdabcd81d 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 @@ -1,6 +1,9 @@ package com.zionhuang.music.ui.screens.artist -import androidx.compose.foundation.* +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyRow @@ -10,6 +13,7 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight @@ -43,11 +47,11 @@ import com.zionhuang.music.ui.utils.resize import com.zionhuang.music.viewmodels.ArtistViewModel import com.zionhuang.music.viewmodels.MainViewModel -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistScreen( navController: NavController, - appBarConfig: AppBarConfig, + scrollBehavior: TopAppBarScrollBehavior, viewModel: ArtistViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(), ) { @@ -72,20 +76,6 @@ fun ArtistScreen( lazyListState.firstVisibleItemIndex == 0 } } - LaunchedEffect(transparentAppBar) { - appBarConfig.transparentBackground = transparentAppBar - } - LaunchedEffect(artistPage) { - appBarConfig.title = { - Text( - text = if (!transparentAppBar) artistPage?.artist?.title.orEmpty() else "", - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - } LazyColumn( state = lazyListState, @@ -459,4 +449,22 @@ fun ArtistScreen( } } } + + TopAppBar( + title = { if (!transparentAppBar) Text(artistPage?.artist?.title.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior, + colors = if (transparentAppBar) { + TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) + } else { + TopAppBarDefaults.topAppBarColors() + } + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt index 6496fe702..4c7d2b22d 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt @@ -5,15 +5,12 @@ import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed -import androidx.compose.material3.FloatingActionButton -import androidx.compose.material3.Icon -import androidx.compose.material3.IconButton +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -35,10 +32,11 @@ import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.ArtistSongsViewModel -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun ArtistSongsScreen( navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: ArtistSongsViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -111,17 +109,32 @@ fun ArtistSongsScreen( modifier = Modifier .fillMaxWidth() .combinedClickable { - playerConnection.playQueue(ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.map { it.toMediaItem() }, - startIndex = index - )) + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) } .animateItemPlacement() ) } } + TopAppBar( + title = { Text(artist?.name.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + FloatingActionButton( modifier = Modifier .align(Alignment.BottomEnd) @@ -131,10 +144,12 @@ fun ArtistSongsScreen( ) .padding(16.dp), onClick = { - playerConnection.playQueue(ListQueue( - title = artist?.name, - items = songs.shuffled().map { it.toMediaItem() }, - )) + playerConnection.playQueue( + ListQueue( + title = artist?.name, + items = songs.shuffled().map { it.toMediaItem() }, + ) + ) } ) { Icon( 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 index 1fc500c32..c9494f832 100644 --- 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 @@ -8,12 +8,10 @@ import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi 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.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastSumBy import androidx.hilt.navigation.compose.hiltViewModel @@ -27,7 +25,6 @@ import com.zionhuang.music.constants.SongSortTypeKey import com.zionhuang.music.db.entities.PlaylistEntity import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.ui.component.AppBarConfig import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.SortHeader @@ -38,11 +35,11 @@ import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.BuiltInPlaylistViewModel -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun BuiltInPlaylistScreen( - appBarConfig: AppBarConfig, navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: BuiltInPlaylistViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -70,18 +67,6 @@ fun BuiltInPlaylistScreen( val coroutineScope = rememberCoroutineScope() - LaunchedEffect(Unit) { - appBarConfig.title = { - Text( - text = playlistName, - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) - } - } - Box( modifier = Modifier.fillMaxSize() ) { @@ -139,17 +124,32 @@ fun BuiltInPlaylistScreen( modifier = Modifier .fillMaxWidth() .combinedClickable { - playerConnection.playQueue(ListQueue( - title = playlistName, - items = songs.map { it.toMediaItem() }, - startIndex = index - )) + 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.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + if (songs.isNotEmpty()) { FloatingActionButton( modifier = Modifier @@ -160,10 +160,12 @@ fun BuiltInPlaylistScreen( ) .padding(16.dp), onClick = { - playerConnection.playQueue(ListQueue( - title = playlistName, - items = songs.shuffled().map { it.toMediaItem() }, - )) + playerConnection.playQueue( + ListQueue( + title = playlistName, + items = songs.shuffled().map { it.toMediaItem() }, + ) + ) } ) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index 4df9ebd76..07c124fad 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -9,7 +9,6 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale @@ -49,11 +48,11 @@ import com.zionhuang.music.viewmodels.LocalPlaylistViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LocalPlaylistScreen( - appBarConfig: AppBarConfig, navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: LocalPlaylistViewModel = hiltViewModel(), ) { val menuState = LocalMenuState.current @@ -69,16 +68,11 @@ fun LocalPlaylistScreen( } val coroutineScope = rememberCoroutineScope() + val lazyListState = rememberLazyListState() - LaunchedEffect(playlist) { - appBarConfig.title = { - Text( - text = playlist?.playlist?.name.orEmpty(), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 } } @@ -103,7 +97,7 @@ fun LocalPlaylistScreen( } val reorderingState = rememberReorderingState( - lazyListState = rememberLazyListState(), + lazyListState = lazyListState, key = songs, onDragEnd = { fromIndex, toIndex -> database.query { @@ -234,10 +228,12 @@ fun LocalPlaylistScreen( Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) { Button( onClick = { - playerConnection.playQueue(ListQueue( - title = playlist.playlist.name, - items = songs.map(Song::toMediaItem) - )) + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.map(Song::toMediaItem) + ) + ) }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) @@ -253,10 +249,12 @@ fun LocalPlaylistScreen( OutlinedButton( onClick = { - playerConnection.playQueue(ListQueue( - title = playlist.playlist.name, - items = songs.shuffled().map(Song::toMediaItem) - )) + playerConnection.playQueue( + ListQueue( + title = playlist.playlist.name, + items = songs.shuffled().map(Song::toMediaItem) + ) + ) }, contentPadding = ButtonDefaults.ButtonWithIconContentPadding, modifier = Modifier.weight(1f) @@ -328,4 +326,17 @@ fun LocalPlaylistScreen( ) } } + + TopAppBar( + title = { if (showTopBarTitle) Text(playlist?.playlist?.name.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 4b0752aa2..1b43e84a0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -26,7 +26,8 @@ import androidx.compose.ui.unit.sp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import coil.compose.AsyncImage -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection @@ -37,18 +38,24 @@ import com.zionhuang.music.db.entities.PlaylistEntity import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.ui.component.* -import com.zionhuang.music.ui.component.shimmer.* +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.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.YouTubeSongMenu import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlinePlaylistViewModel import kotlinx.coroutines.launch -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun OnlinePlaylistScreen( - appBarConfig: AppBarConfig, navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: OnlinePlaylistViewModel = hiltViewModel(), mainViewModel: MainViewModel = hiltViewModel(), ) { @@ -69,15 +76,9 @@ fun OnlinePlaylistScreen( val coroutineScope = rememberCoroutineScope() val snackbarHostState = remember { SnackbarHostState() } - LaunchedEffect(playlist) { - appBarConfig.title = { - Text( - text = playlist?.title.orEmpty(), - style = MaterialTheme.typography.titleLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - modifier = Modifier.weight(1f) - ) + val showTopBarTitle by remember { + derivedStateOf { + lazyListState.firstVisibleItemIndex > 0 } } @@ -325,6 +326,19 @@ fun OnlinePlaylistScreen( } } + TopAppBar( + title = { if (showTopBarTitle) Text(playlist?.title.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) + SnackbarHost( hostState = snackbarHostState, modifier = Modifier diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt index c3ca0a641..cd00c5f7b 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt @@ -24,6 +24,7 @@ import com.zionhuang.music.R import com.zionhuang.music.constants.SuggestionItemHeight import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.SearchBarIconOffsetX import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlineSearchSuggestionViewModel @@ -32,7 +33,7 @@ import com.zionhuang.music.viewmodels.OnlineSearchSuggestionViewModel @Composable fun OnlineSearchScreen( query: String, - onTextFieldValueChange: (TextFieldValue) -> Unit, + onQueryChange: (TextFieldValue) -> Unit, navController: NavController, onSearch: (String) -> Unit, onDismiss: () -> Unit, @@ -73,10 +74,12 @@ fun OnlineSearchScreen( } }, onFillTextField = { - onTextFieldValueChange(TextFieldValue( - text = history.query, - selection = TextRange(history.query.length) - )) + onQueryChange( + TextFieldValue( + text = history.query, + selection = TextRange(history.query.length) + ) + ) }, modifier = Modifier.animateItemPlacement() ) @@ -94,10 +97,12 @@ fun OnlineSearchScreen( onDismiss() }, onFillTextField = { - onTextFieldValueChange(TextFieldValue( - text = query, - selection = TextRange(query.length) - )) + onQueryChange( + TextFieldValue( + text = query, + selection = TextRange(query.length) + ) + ) }, modifier = Modifier.animateItemPlacement() ) @@ -192,9 +197,8 @@ fun SuggestionItem( modifier = modifier .fillMaxWidth() .height(SuggestionItemHeight) - .clickable( - onClick = onClick - ) + .clickable(onClick = onClick) + .padding(end = SearchBarIconOffsetX) ) { Icon( painterResource(if (online) R.drawable.ic_search else R.drawable.ic_history), 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 08ca19e55..36e68917b 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 @@ -4,17 +4,24 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zionhuang.music.BuildConfig import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.ui.component.PreferenceEntry +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AboutScreen() { +fun AboutScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val uriHandler = LocalUriHandler.current Column( @@ -37,4 +44,17 @@ fun AboutScreen() { } ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_about_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt index 99a70c5fc..02a451a2f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.DarkModeKey @@ -15,8 +18,12 @@ import com.zionhuang.music.constants.LyricsTextPositionKey import com.zionhuang.music.ui.component.EnumListPreference import com.zionhuang.music.utils.rememberEnumPreference +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun AppearanceSettings() { +fun AppearanceSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val (darkMode, onDarkModeChange) = rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference(DefaultOpenTabKey, defaultValue = NavigationTab.HOME) val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) @@ -68,6 +75,19 @@ fun AppearanceSettings() { } ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_appearance_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } enum class DarkMode { diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt index c2a84ae9a..0c338221f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt @@ -6,11 +6,14 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable 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.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.ui.component.PreferenceEntry @@ -18,8 +21,11 @@ import com.zionhuang.music.viewmodels.BackupRestoreViewModel import java.time.LocalDateTime import java.time.format.DateTimeFormatter +@OptIn(ExperimentalMaterial3Api::class) @Composable fun BackupAndRestore( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, viewModel: BackupRestoreViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -33,6 +39,7 @@ fun BackupAndRestore( viewModel.restore(context, uri) } } + Column( Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) @@ -54,4 +61,17 @@ fun BackupAndRestore( } ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_backup_restore_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt index c83bbc99f..7ed41a55f 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.* @@ -18,8 +21,12 @@ import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import java.net.Proxy +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun ContentSettings() { +fun ContentSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val (contentLanguage, onContentLanguageChange) = rememberPreference(key = ContentLanguageKey, defaultValue = "system") val (contentCountry, onContentCountryChange) = rememberPreference(key = ContentCountryKey, defaultValue = "system") val (proxyEnabled, onProxyEnabledChange) = rememberPreference(key = ProxyEnabledKey, defaultValue = false) @@ -82,4 +89,17 @@ fun ContentSettings() { ) } } + + TopAppBar( + title = { Text(stringResource(R.string.pref_content_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt index 12f881da7..273e6d6f1 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.AutoAddToLibraryKey @@ -16,8 +19,12 @@ import com.zionhuang.music.constants.NotificationMoreActionKey import com.zionhuang.music.ui.component.SwitchPreference import com.zionhuang.music.utils.rememberPreference +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun GeneralSettings() { +fun GeneralSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val (autoAddToLibrary, onAutoAddToLibraryChange) = rememberPreference(key = AutoAddToLibraryKey, defaultValue = true) val (autoDownload, onAutoDownloadChange) = rememberPreference(key = AutoDownloadKey, defaultValue = false) val (expandOnPlay, onExpandOnPlayChange) = rememberPreference(key = ExpandOnPlayKey, defaultValue = false) @@ -56,4 +63,17 @@ fun GeneralSettings() { onCheckedChange = onNotificationMoreActionChange ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_general_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt index 795a8937c..758cb38de 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt @@ -4,9 +4,12 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.AudioNormalizationKey @@ -18,8 +21,12 @@ import com.zionhuang.music.ui.component.SwitchPreference import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun PlayerSettings() { +fun PlayerSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val (audioQuality, onAudioQualityChange) = rememberEnumPreference(key = AudioQualityKey, defaultValue = AudioQuality.AUTO) val (persistentQueue, onPersistentQueueChange) = rememberPreference(key = PersistentQueueKey, defaultValue = true) val (skipSilence, onSkipSilenceChange) = rememberPreference(key = SkipSilenceKey, defaultValue = true) @@ -62,4 +69,17 @@ fun PlayerSettings() { onCheckedChange = onAudioNormalizationChange ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_player_audio_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index e993fca67..45e134dc5 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -5,13 +5,13 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton +import androidx.compose.material3.* import androidx.compose.runtime.* 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.navigation.NavController import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R @@ -22,8 +22,12 @@ import com.zionhuang.music.ui.component.PreferenceEntry import com.zionhuang.music.ui.component.SwitchPreference import com.zionhuang.music.utils.rememberPreference +@OptIn(ExperimentalMaterial3Api::class) @Composable -fun PrivacySettings() { +fun PrivacySettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val database = LocalDatabase.current val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistoryKey, defaultValue = false) val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) @@ -86,6 +90,19 @@ fun PrivacySettings() { onCheckedChange = onEnableKugouChange ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_privacy_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } enum class AudioQuality { diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt index 630976de0..395b16b2d 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt @@ -4,17 +4,21 @@ import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll +import androidx.compose.material3.* import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.ui.component.PreferenceEntry +@OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, ) { Column( modifier = Modifier @@ -62,4 +66,17 @@ fun SettingsScreen( onClick = { navController.navigate("settings/about") } ) } + + TopAppBar( + title = { Text(stringResource(R.string.title_settings)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } 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 472f53eee..2f7653888 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 @@ -1,16 +1,19 @@ package com.zionhuang.music.ui.screens.settings -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.LinearProgressIndicator -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.Text +import androidx.compose.material3.* import androidx.compose.runtime.* 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.unit.dp +import androidx.navigation.NavController import coil.annotation.ExperimentalCoilApi import coil.imageLoader import com.zionhuang.music.LocalPlayerAwareWindowInsets @@ -28,9 +31,12 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -@OptIn(ExperimentalCoilApi::class) +@OptIn(ExperimentalCoilApi::class, ExperimentalMaterial3Api::class) @Composable -fun StorageSettings() { +fun StorageSettings( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, +) { val context = LocalContext.current val imageDiskCache = context.imageLoader.diskCache ?: return val playerCache = LocalPlayerConnection.current?.songPlayer?.cache ?: return @@ -145,4 +151,17 @@ fun StorageSettings() { }, ) } + + TopAppBar( + title = { Text(stringResource(R.string.pref_storage_title)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt new file mode 100644 index 000000000..9ac89552f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt @@ -0,0 +1,63 @@ +package com.zionhuang.music.ui.utils + +import androidx.compose.animation.* +import androidx.compose.animation.core.* +import androidx.compose.foundation.layout.* +import androidx.compose.material3.* +import androidx.compose.runtime.* +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import com.zionhuang.music.constants.* + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun appBarScrollBehavior( + state: TopAppBarState = rememberTopAppBarState(), + canScroll: () -> Boolean = { true }, + snapAnimationSpec: AnimationSpec? = spring(stiffness = Spring.StiffnessMediumLow), + flingAnimationSpec: DecayAnimationSpec? = rememberSplineBasedDecay(), +): TopAppBarScrollBehavior = + AppBarScrollBehavior( + state = state, + snapAnimationSpec = snapAnimationSpec, + flingAnimationSpec = flingAnimationSpec, + canScroll = canScroll + ) + +@ExperimentalMaterial3Api +class AppBarScrollBehavior constructor( + override val state: TopAppBarState, + override val snapAnimationSpec: AnimationSpec?, + override val flingAnimationSpec: DecayAnimationSpec?, + val canScroll: () -> Boolean = { true }, +) : TopAppBarScrollBehavior { + override val isPinned: Boolean = false + override var nestedScrollConnection = object : NestedScrollConnection { + override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { + if (!canScroll()) return Offset.Zero + state.contentOffset += consumed.y + if (state.heightOffset == 0f || state.heightOffset == state.heightOffsetLimit) { + if (consumed.y == 0f && available.y > 0f) { + // Reset the total content offset to zero when scrolling all the way down. + // This will eliminate some float precision inaccuracies. + state.contentOffset = 0f + } + } + state.heightOffset += consumed.y + return Offset.Zero + } + } +} + +@OptIn(ExperimentalMaterial3Api::class) +suspend fun TopAppBarState.resetHeightOffset() { + if (heightOffset != 0f) { + animate( + initialValue = heightOffset, + targetValue = 0f + ) { value, _ -> + heightOffset = value + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt index f315f9690..7b0facb92 100644 --- a/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt +++ b/app/src/main/java/com/zionhuang/music/ui/utils/reordering/ReorderingLazyList.kt @@ -84,7 +84,6 @@ internal fun ReorderingLazyList( ) .clipScrollableContainer(orientation) .lazyListBeyondBoundsModifier(reorderingState.lazyListState, beyondBoundsInfo, reverseLayout, orientation) - .lazyListPinningModifier(reorderingState.lazyListState, beyondBoundsInfo) .overscroll(overscrollEffect) .scrollable( orientation = orientation, @@ -259,6 +258,7 @@ private fun rememberLazyListMeasurePolicy( placementAnimator = placementAnimator, beyondBoundsInfo = beyondBoundsInfo, beyondBoundsItemCount = beyondBoundsItemCount, + pinnedItems = state.pinnedItems, layout = { width, height, placement -> layout( containerConstraints.constrainWidth(width + totalHorizontalPadding), diff --git a/build.gradle.kts b/build.gradle.kts index f987d8442..82a35b6c9 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -8,7 +8,7 @@ buildscript { mavenCentral() } dependencies { - classpath("com.android.tools.build:gradle:7.3.1") + classpath("com.android.tools.build:gradle:7.4.1") classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) } } diff --git a/gradle.properties b/gradle.properties index 4e50218ed..b1073c437 100755 --- a/gradle.properties +++ b/gradle.properties @@ -15,3 +15,4 @@ org.gradle.jvmargs=-Xmx2048M -Dkotlin.daemon.jvm.options\="-Xmx2048M" android.useAndroidX=true android.enableJetifier=true +org.gradle.unsafe.configuration-cache=true diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c3ef5ce66..b3c83cea0 100755 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ #Tue Oct 04 14:57:57 CST 2022 distributionBase=GRADLE_USER_HOME -distributionUrl=https\://services.gradle.org/distributions/gradle-7.4.2-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-7.5-bin.zip distributionPath=wrapper/dists zipStorePath=wrapper/dists zipStoreBase=GRADLE_USER_HOME diff --git a/innertube/build.gradle.kts b/innertube/build.gradle.kts index aa616431b..f51b08d89 100644 --- a/innertube/build.gradle.kts +++ b/innertube/build.gradle.kts @@ -4,6 +4,10 @@ plugins { alias(libs.plugins.kotlin.serialization) } +kotlin { + jvmToolchain(11) +} + dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/kugou/build.gradle.kts b/kugou/build.gradle.kts index f76b24832..174aface2 100644 --- a/kugou/build.gradle.kts +++ b/kugou/build.gradle.kts @@ -4,6 +4,10 @@ plugins { alias(libs.plugins.kotlin.serialization) } +kotlin { + jvmToolchain(11) +} + dependencies { implementation(libs.ktor.client.core) implementation(libs.ktor.client.okhttp) diff --git a/settings.gradle.kts b/settings.gradle.kts index d2a47eb7e..f78481ad9 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,3 +1,5 @@ +@file:Suppress("UnstableApiUsage") + enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") dependencyResolutionManagement { @@ -11,7 +13,7 @@ dependencyResolutionManagement { versionCatalogs { create("libs") { - version("kotlin", "1.7.20") + version("kotlin", "1.8.0") plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") library("activity", "androidx.activity", "activity-compose").version("1.5.1") @@ -19,10 +21,10 @@ dependencyResolutionManagement { library("hilt-navigation", "androidx.hilt", "hilt-navigation-compose").version("1.0.0") library("datastore", "androidx.datastore", "datastore-preferences").version("1.0.0") - version("compose-compiler", "1.3.2") - version("compose", "1.3.1") + version("compose-compiler", "1.4.0") + version("compose", "1.3.3") library("compose-runtime", "androidx.compose.runtime", "runtime").versionRef("compose") - library("compose-foundation", "androidx.compose.foundation", "foundation").versionRef("compose") + library("compose-foundation", "androidx.compose.foundation", "foundation").version("1.3.1") library("compose-ui", "androidx.compose.ui", "ui").versionRef("compose") library("compose-ui-util", "androidx.compose.ui", "ui-util").versionRef("compose") library("compose-ui-tooling", "androidx.compose.ui", "ui-tooling").versionRef("compose") @@ -33,7 +35,7 @@ dependencyResolutionManagement { library("viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("lifecycle") library("viewmodel-compose", "androidx.lifecycle", "lifecycle-viewmodel-compose").versionRef("lifecycle") - version("material3", "1.1.0-alpha03") + version("material3", "1.1.0-alpha05") library("material3", "androidx.compose.material3", "material3").versionRef("material3") library("material3-windowsize", "androidx.compose.material3", "material3-window-size-class").versionRef("material3") From bf97bdd4c7230e28a356272c620518324c669ea7 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 16:32:03 +0800 Subject: [PATCH 173/323] Close search bar when browse things --- .../java/com/zionhuang/music/MainActivity.kt | 6 ++--- .../ui/screens/search/LocalSearchScreen.kt | 25 +++++++++++++------ 2 files changed, 20 insertions(+), 11 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index f91aa10a2..b8191c109 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -447,8 +447,7 @@ class MainActivity : ComponentActivity() { AnimatedVisibility( visible = shouldShowSearchBar, enter = fadeIn(), - exit = fadeOut(), - modifier = Modifier.align(Alignment.TopCenter) + exit = fadeOut() ) { SearchBar( query = query, @@ -531,7 +530,8 @@ class MainActivity : ComponentActivity() { when (searchSource) { SearchSource.LOCAL -> LocalSearchScreen( query = query.text, - navController = navController + navController = navController, + onDismiss = { onActiveChange(false) } ) SearchSource.ONLINE -> OnlineSearchScreen( query = query.text, 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 39d3d7c35..ef166db08 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 @@ -37,6 +37,7 @@ import com.zionhuang.music.viewmodels.LocalSearchViewModel fun LocalSearchScreen( query: String, navController: NavController, + onDismiss: () -> Unit, viewModel: LocalSearchViewModel = hiltViewModel(), ) { val context = LocalContext.current @@ -95,13 +96,15 @@ fun LocalSearchScreen( .padding(start = 12.dp, end = 18.dp) ) { Text( - text = stringResource(when (filter) { - LocalFilter.SONG -> R.string.search_filter_songs - LocalFilter.ALBUM -> R.string.search_filter_albums - LocalFilter.ARTIST -> R.string.search_filter_artists - LocalFilter.PLAYLIST -> R.string.search_filter_playlists - LocalFilter.ALL -> error("") - }), + text = stringResource( + when (filter) { + LocalFilter.SONG -> R.string.search_filter_songs + LocalFilter.ALBUM -> R.string.search_filter_albums + LocalFilter.ARTIST -> R.string.search_filter_artists + LocalFilter.PLAYLIST -> R.string.search_filter_playlists + LocalFilter.ALL -> error("") + } + ), style = MaterialTheme.typography.titleLarge, modifier = Modifier.weight(1f) ) @@ -132,7 +135,10 @@ fun LocalSearchScreen( navController = navController, playerConnection = playerConnection, coroutineScope = coroutineScope, - onDismiss = menuState::dismiss + onDismiss = { + onDismiss() + menuState.dismiss() + } ) } } @@ -163,6 +169,7 @@ fun LocalSearchScreen( playWhenReady = playWhenReady, modifier = Modifier .clickable { + onDismiss() navController.navigate("album/${item.id}") } .animateItemPlacement() @@ -171,6 +178,7 @@ fun LocalSearchScreen( artist = item, modifier = Modifier .clickable { + onDismiss() navController.navigate("artist/${item.id}") } .animateItemPlacement() @@ -179,6 +187,7 @@ fun LocalSearchScreen( playlist = item, modifier = Modifier .clickable { + onDismiss() navController.navigate("local_playlist/${item.id}") } .animateItemPlacement() From b44d004791634dcc56be736e449ec79cf92bb85c Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 16:32:39 +0800 Subject: [PATCH 174/323] Hide keyboard when bottom sheet menu is open --- .../com/zionhuang/music/ui/component/BottomSheetMenu.kt | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt index eb8e22a59..7130c45d8 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/BottomSheetMenu.kt @@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.graphics.Color import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.unit.dp import com.zionhuang.music.ui.utils.top @@ -43,6 +44,8 @@ fun BottomSheetMenu( state: MenuState, background: Color = MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation), ) { + val focusManager = LocalFocusManager.current + AnimatedVisibility( visible = state.isVisible, enter = fadeIn(), @@ -81,4 +84,10 @@ fun BottomSheetMenu( state.content(this) } } + + LaunchedEffect(state.isVisible) { + if (state.isVisible) { + focusManager.clearFocus() + } + } } \ No newline at end of file From 5ae0a7076e81ed127a5f0650638c190896e910a1 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 21:31:26 +0800 Subject: [PATCH 175/323] Refactor strings.xml --- .../java/com/zionhuang/music/MainActivity.kt | 2 +- .../zionhuang/music/playback/MusicService.kt | 16 +- .../zionhuang/music/playback/SongPlayer.kt | 4 +- .../com/zionhuang/music/ui/component/Items.kt | 6 +- .../zionhuang/music/ui/component/Lyrics.kt | 20 +- .../zionhuang/music/ui/menu/PlaylistMenu.kt | 8 +- .../com/zionhuang/music/ui/menu/SongMenu.kt | 32 +- .../zionhuang/music/ui/menu/YouTubeMenu.kt | 36 +- .../com/zionhuang/music/ui/player/Queue.kt | 35 +- .../zionhuang/music/ui/screens/AlbumScreen.kt | 8 +- .../zionhuang/music/ui/screens/HomeScreen.kt | 2 +- .../com/zionhuang/music/ui/screens/Screens.kt | 10 +- .../music/ui/screens/artist/ArtistScreen.kt | 6 +- .../ui/screens/artist/ArtistSongsScreen.kt | 2 +- .../ui/screens/library/LibraryAlbumsScreen.kt | 2 +- .../screens/library/LibraryArtistsScreen.kt | 2 +- .../screens/library/LibraryPlaylistsScreen.kt | 16 +- .../ui/screens/library/LibrarySongsScreen.kt | 14 +- .../screens/playlist/BuiltInPlaylistScreen.kt | 2 +- .../screens/playlist/LocalPlaylistScreen.kt | 10 +- .../screens/playlist/OnlinePlaylistScreen.kt | 4 +- .../ui/screens/search/LocalSearchScreen.kt | 18 +- .../ui/screens/search/OnlineSearchResult.kt | 14 +- .../music/ui/screens/settings/AboutScreen.kt | 4 +- .../ui/screens/settings/AppearanceSettings.kt | 24 +- .../ui/screens/settings/BackupAndRestore.kt | 6 +- .../ui/screens/settings/ContentSettings.kt | 12 +- .../ui/screens/settings/GeneralSettings.kt | 16 +- .../ui/screens/settings/PlayerSettings.kt | 10 +- .../ui/screens/settings/PrivacySettings.kt | 10 +- .../ui/screens/settings/SettingsScreen.kt | 18 +- .../ui/screens/settings/StorageSettings.kt | 4 +- .../viewmodels/BackupRestoreViewModel.kt | 6 +- app/src/main/res/values-DE/strings.xml | 417 +++++++--------- app/src/main/res/values-cs/strings.xml | 442 +++++++---------- app/src/main/res/values-es-rUS/strings.xml | 416 +++++++--------- app/src/main/res/values-es/strings.xml | 418 +++++++---------- app/src/main/res/values-fa-rIR/strings.xml | 418 +++++++---------- app/src/main/res/values-fi-rFI/strings.xml | 416 +++++++--------- app/src/main/res/values-fr-rFR/strings.xml | 422 +++++++---------- app/src/main/res/values-hu/strings.xml | 416 +++++++--------- app/src/main/res/values-id/strings.xml | 404 +++++++--------- app/src/main/res/values-it/strings.xml | 422 +++++++---------- app/src/main/res/values-ja-rJP/strings.xml | 414 +++++++--------- app/src/main/res/values-ko-rKR/strings.xml | 412 +++++++--------- app/src/main/res/values-ml-rIN/strings.xml | 418 +++++++---------- app/src/main/res/values-or-rIN/strings.xml | 418 +++++++++-------- app/src/main/res/values-pa/strings.xml | 410 +++++++--------- app/src/main/res/values-pt-rBR/strings.xml | 418 +++++++---------- app/src/main/res/values-ru-rRU/strings.xml | 444 +++++++----------- app/src/main/res/values-sv-rSE/strings.xml | 416 +++++++--------- app/src/main/res/values-uk-rUA/strings.xml | 444 +++++++----------- app/src/main/res/values-zh-rCN/strings.xml | 414 +++++++--------- app/src/main/res/values-zh-rTW/strings.xml | 409 +++++++--------- app/src/main/res/values/strings.xml | 427 +++++++---------- 55 files changed, 3927 insertions(+), 5687 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index b8191c109..c54c0f690 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -459,7 +459,7 @@ class MainActivity : ComponentActivity() { placeholder = { Text( text = stringResource( - if (!active) R.string.menu_search + if (!active) R.string.search else when (searchSource) { SearchSource.LOCAL -> R.string.search_library SearchSource.ONLINE -> R.string.search_yt_music 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 87745fbd5..784137d32 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -102,10 +102,10 @@ class MusicService : MediaBrowserServiceCompat() { override fun onLoadChildren(parentId: String, result: Result>) = runBlocking { when (parentId) { ROOT -> result.sendResult(mutableListOf( - mediaBrowserItem(SONG, getString(R.string.title_songs), null, drawableUri(R.drawable.ic_music_note)), - mediaBrowserItem(ARTIST, getString(R.string.title_artists), null, drawableUri(R.drawable.ic_artist)), - mediaBrowserItem(ALBUM, getString(R.string.title_albums), null, drawableUri(R.drawable.ic_album)), - mediaBrowserItem(PLAYLIST, getString(R.string.title_playlists), null, drawableUri(R.drawable.ic_queue_music)) + mediaBrowserItem(SONG, getString(R.string.songs), null, drawableUri(R.drawable.ic_music_note)), + mediaBrowserItem(ARTIST, getString(R.string.artists), null, drawableUri(R.drawable.ic_artist)), + mediaBrowserItem(ALBUM, getString(R.string.albums), null, drawableUri(R.drawable.ic_album)), + mediaBrowserItem(PLAYLIST, getString(R.string.playlists), null, drawableUri(R.drawable.ic_queue_music)) )) SONG -> { result.detach() @@ -116,7 +116,7 @@ class MusicService : MediaBrowserServiceCompat() { ARTIST -> { result.detach() result.sendResult(database.artistsByCreateDateDesc().first().map { artist -> - mediaBrowserItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.song_count, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri()) + mediaBrowserItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri()) }.toMutableList()) } ALBUM -> { @@ -130,10 +130,10 @@ class MusicService : MediaBrowserServiceCompat() { val likedSongCount = database.likedSongsCount().first() val downloadedSongCount = database.downloadedSongsCount().first() result.sendResult((listOf( - mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.song_count, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), - mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.song_count, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) + mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), + mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) ) + database.playlistsByCreateDateDesc().first().map { playlist -> - mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.song_count, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri()) + mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri()) }).toMutableList()) } else -> when { diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index fda4259b1..326a74dde 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -249,7 +249,7 @@ class SongPlayer( override fun getCustomAction(player: Player) = CustomAction.Builder( ACTION_TOGGLE_SHUFFLE, - context.getString(R.string.btn_shuffle), + context.getString(R.string.shuffle), if (player.shuffleModeEnabled) R.drawable.ic_shuffle_on else R.drawable.ic_shuffle ).build() } @@ -287,7 +287,7 @@ class SongPlayer( action = ACTION_SHOW_BOTTOM_SHEET }, FLAG_IMMUTABLE) }) - .setChannelNameResourceId(R.string.channel_name_playback) + .setChannelNameResourceId(R.string.music_player) .setNotificationListener(notificationListener) .setCustomActionReceiver(object : CustomActionReceiver { override fun createCustomActions(context: Context, instanceId: Int): Map = mapOf( 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 a1a31aa0f..61431a319 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 @@ -218,7 +218,7 @@ fun ArtistListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = artist.artist.name, - subtitle = pluralStringResource(R.plurals.song_count, artist.songCount, artist.songCount), + subtitle = pluralStringResource(R.plurals.n_song, artist.songCount, artist.songCount), thumbnailContent = { AsyncImage( model = artist.artist.thumbnailUrl, @@ -244,7 +244,7 @@ fun AlbumListItem( title = album.album.title, subtitle = joinByBullet( album.artists.joinToString(), - pluralStringResource(R.plurals.song_count, album.album.songCount, album.album.songCount), + pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), album.album.year?.toString() ), thumbnailContent = { @@ -279,7 +279,7 @@ fun PlaylistListItem( trailingContent: @Composable RowScope.() -> Unit = {}, ) = ListItem( title = playlist.playlist.name, - subtitle = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + subtitle = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), thumbnailContent = { when (playlist.thumbnails.size) { 0 -> Icon( 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 6ed27a2d9..c9c1f448f 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 @@ -311,7 +311,7 @@ fun LyricsMenu( modifier = Modifier.verticalScroll(rememberScrollState()), onDismiss = { showSearchDialog = false }, icon = { Icon(painter = painterResource(R.drawable.ic_search), contentDescription = null) }, - title = { Text(stringResource(R.string.dialog_title_search_lyrics)) }, + title = { Text(stringResource(R.string.search_lyrics)) }, buttons = { TextButton( onClick = { showSearchDialog = false } @@ -335,7 +335,7 @@ fun LyricsMenu( } } ) { - Text(stringResource(R.string.menu_search_online)) + Text(stringResource(R.string.search_online)) } Spacer(Modifier.width(8.dp)) @@ -387,10 +387,12 @@ fun LyricsMenu( onDismiss() viewModel.cancelSearch() database.query { - upsert(LyricsEntity( - id = searchMediaMetadata.id, - lyrics = result.lyrics - )) + upsert( + LyricsEntity( + id = searchMediaMetadata.id, + lyrics = result.lyrics + ) + ) } } .padding(12.dp) @@ -465,20 +467,20 @@ fun LyricsMenu( ) { GridMenuItem( icon = R.drawable.ic_edit, - title = R.string.menu_edit + title = R.string.edit ) { showEditDialog = true } GridMenuItem( icon = R.drawable.ic_cached, - title = R.string.menu_refetch + title = R.string.refetch ) { onDismiss() viewModel.refetchLyrics(mediaMetadataProvider(), lyricsProvider()) } GridMenuItem( icon = R.drawable.ic_search, - title = R.string.menu_search, + title = R.string.search, ) { showSearchDialog = true } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt index 7db49d623..d582c49fa 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/PlaylistMenu.kt @@ -42,7 +42,7 @@ fun PlaylistMenu( if (showEditDialog) { TextFieldDialog( icon = { Icon(painter = painterResource(R.drawable.ic_edit), contentDescription = null) }, - title = { Text(text = stringResource(R.string.dialog_title_edit_playlist)) }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, onDismiss = { showEditDialog = false }, initialTextFieldValue = TextFieldValue(playlist.playlist.name, TextRange(playlist.playlist.name.length)), onDone = { name -> @@ -64,7 +64,7 @@ fun PlaylistMenu( ) { GridMenuItem( icon = R.drawable.ic_edit, - title = R.string.menu_edit + title = R.string.edit ) { showEditDialog = true } @@ -72,7 +72,7 @@ fun PlaylistMenu( if (playlist.playlist.browseId != null) { GridMenuItem( icon = R.drawable.ic_sync, - title = R.string.menu_sync + title = R.string.sync ) { onDismiss() coroutineScope.launch(Dispatchers.IO) { @@ -97,7 +97,7 @@ fun PlaylistMenu( GridMenuItem( icon = R.drawable.ic_delete, - title = R.string.menu_delete + title = R.string.delete ) { onDismiss() database.query { 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 3b1354a3b..821d2f0e0 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 @@ -80,7 +80,7 @@ fun SongMenu( ) { item { ListItem( - title = stringResource(R.string.dialog_title_create_playlist), + title = stringResource(R.string.create_playlist), thumbnailContent = { Image( painter = painterResource(R.drawable.ic_add), @@ -119,13 +119,15 @@ fun SongMenu( if (showCreatePlaylistDialog) { TextFieldDialog( icon = { Icon(painter = painterResource(R.drawable.ic_add), contentDescription = null) }, - title = { Text(text = stringResource(R.string.dialog_title_create_playlist)) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, onDismiss = { showCreatePlaylistDialog = false }, onDone = { playlistName -> database.query { - insert(PlaylistEntity( - name = playlistName - )) + insert( + PlaylistEntity( + name = playlistName + ) + ) } } ) @@ -209,48 +211,48 @@ fun SongMenu( ) { GridMenuItem( icon = R.drawable.ic_radio, - title = R.string.menu_start_radio + title = R.string.start_radio ) { onDismiss() playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) } GridMenuItem( icon = R.drawable.ic_playlist_play, - title = R.string.menu_play_next + title = R.string.play_next ) { onDismiss() playerConnection.playNext(song.toMediaItem()) } GridMenuItem( icon = R.drawable.ic_queue_music, - title = R.string.menu_add_to_queue + title = R.string.add_to_queue ) { onDismiss() playerConnection.addToQueue((song.toMediaItem())) } GridMenuItem( icon = R.drawable.ic_edit, - title = R.string.menu_edit, + title = R.string.edit, enabled = false ) { } GridMenuItem( icon = R.drawable.ic_playlist_add, - title = R.string.menu_add_to_playlist + title = R.string.add_to_playlist ) { showChoosePlaylistDialog = true } GridMenuItem( icon = R.drawable.ic_file_download, - title = R.string.menu_download, + title = R.string.download, enabled = false ) { } GridMenuItem( icon = R.drawable.ic_artist, - title = R.string.menu_view_artist + title = R.string.view_artist ) { if (song.artists.size == 1) { navController.navigate("artist/${song.artists[0].id}") @@ -262,7 +264,7 @@ fun SongMenu( if (song.song.albumId != null) { GridMenuItem( icon = R.drawable.ic_album, - title = R.string.menu_view_album + title = R.string.view_album ) { onDismiss() navController.navigate("album/${song.song.albumId}") @@ -270,7 +272,7 @@ fun SongMenu( } GridMenuItem( icon = R.drawable.ic_share, - title = R.string.menu_share + title = R.string.share ) { onDismiss() val intent = Intent().apply { @@ -282,7 +284,7 @@ fun SongMenu( } GridMenuItem( icon = R.drawable.ic_delete, - title = R.string.menu_delete + title = R.string.delete ) { onDismiss() database.query { diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt index fbc33657d..457b5c86d 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt @@ -118,21 +118,21 @@ fun YouTubeSongMenu( ) { GridMenuItem( icon = R.drawable.ic_radio, - title = R.string.menu_start_radio + title = R.string.start_radio ) { playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) onDismiss() } GridMenuItem( icon = R.drawable.ic_playlist_play, - title = R.string.menu_play_next + title = R.string.play_next ) { playerConnection.playNext(song.toMediaItem()) onDismiss() } GridMenuItem( icon = R.drawable.ic_queue_music, - title = R.string.menu_add_to_queue + title = R.string.add_to_queue ) { playerConnection.addToQueue((song.toMediaItem())) onDismiss() @@ -162,14 +162,14 @@ fun YouTubeSongMenu( } GridMenuItem( icon = R.drawable.ic_playlist_add, - title = R.string.menu_add_to_playlist, + title = R.string.add_to_playlist, enabled = false ) { } GridMenuItem( icon = R.drawable.ic_file_download, - title = R.string.menu_download, + title = R.string.download, enabled = false ) { @@ -177,7 +177,7 @@ fun YouTubeSongMenu( if (artists.isNotEmpty()) { GridMenuItem( icon = R.drawable.ic_artist, - title = R.string.menu_view_artist + title = R.string.view_artist ) { if (artists.size == 1) { navController.navigate("artist/${artists[0].id}") @@ -190,7 +190,7 @@ fun YouTubeSongMenu( song.album?.let { album -> GridMenuItem( icon = R.drawable.ic_album, - title = R.string.menu_view_album + title = R.string.view_album ) { navController.navigate("album/${album.id}") onDismiss() @@ -198,7 +198,7 @@ fun YouTubeSongMenu( } GridMenuItem( icon = R.drawable.ic_share, - title = R.string.menu_share + title = R.string.share ) { val intent = Intent().apply { action = Intent.ACTION_SEND @@ -285,21 +285,21 @@ fun YouTubeAlbumMenu( ) { GridMenuItem( icon = R.drawable.ic_radio, - title = R.string.menu_start_radio + title = R.string.start_radio ) { playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) onDismiss() } GridMenuItem( icon = R.drawable.ic_playlist_play, - title = R.string.menu_play_next, + title = R.string.play_next, enabled = false ) { onDismiss() } GridMenuItem( icon = R.drawable.ic_queue_music, - title = R.string.menu_add_to_queue, + title = R.string.add_to_queue, enabled = false ) { onDismiss() @@ -334,14 +334,14 @@ fun YouTubeAlbumMenu( GridMenuItem( icon = R.drawable.ic_playlist_add, - title = R.string.menu_add_to_playlist, + title = R.string.add_to_playlist, enabled = false ) { } GridMenuItem( icon = R.drawable.ic_file_download, - title = R.string.menu_download, + title = R.string.download, enabled = false ) { @@ -349,7 +349,7 @@ fun YouTubeAlbumMenu( album.artists?.let { artists -> GridMenuItem( icon = R.drawable.ic_artist, - title = R.string.menu_view_artist + title = R.string.view_artist ) { if (artists.size == 1) { navController.navigate("artist/${artists[0].id}") @@ -361,7 +361,7 @@ fun YouTubeAlbumMenu( } GridMenuItem( icon = R.drawable.ic_share, - title = R.string.menu_share + title = R.string.share ) { val intent = Intent().apply { action = Intent.ACTION_SEND @@ -393,7 +393,7 @@ fun YouTubeArtistMenu( artist.radioEndpoint?.let { watchEndpoint -> GridMenuItem( icon = R.drawable.ic_radio, - title = R.string.menu_start_radio + title = R.string.start_radio ) { playerConnection.playQueue(YouTubeQueue(watchEndpoint)) onDismiss() @@ -402,7 +402,7 @@ fun YouTubeArtistMenu( artist.shuffleEndpoint?.let { watchEndpoint -> GridMenuItem( icon = R.drawable.ic_shuffle, - title = R.string.btn_shuffle + title = R.string.shuffle ) { playerConnection.playQueue(YouTubeQueue(watchEndpoint)) onDismiss() @@ -410,7 +410,7 @@ fun YouTubeArtistMenu( } GridMenuItem( icon = R.drawable.ic_share, - title = R.string.menu_share + title = R.string.share ) { val intent = Intent().apply { action = Intent.ACTION_SEND diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 8e722d431..64bf7796e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -383,9 +383,11 @@ fun Queue( Box( modifier = Modifier - .background(MaterialTheme.colorScheme - .surfaceColorAtElevation(NavigationBarDefaults.Elevation) - .copy(alpha = 0.95f)) + .background( + MaterialTheme.colorScheme + .surfaceColorAtElevation(NavigationBarDefaults.Elevation) + .copy(alpha = 0.95f) + ) .windowInsetsPadding( WindowInsets.systemBars .only(WindowInsetsSides.Top + WindowInsetsSides.Horizontal) @@ -410,7 +412,7 @@ fun Queue( horizontalAlignment = Alignment.End ) { Text( - text = pluralStringResource(R.plurals.song_count, queueWindows.size, queueWindows.size), + text = pluralStringResource(R.plurals.n_song, queueWindows.size, queueWindows.size), style = MaterialTheme.typography.bodyMedium ) @@ -428,10 +430,11 @@ fun Queue( modifier = Modifier .background(MaterialTheme.colorScheme.secondaryContainer.copy(alpha = 0.8f)) .fillMaxWidth() - .height(ListItemHeight + - WindowInsets.systemBars - .asPaddingValues() - .calculateBottomPadding() + .height( + ListItemHeight + + WindowInsets.systemBars + .asPaddingValues() + .calculateBottomPadding() ) .align(Alignment.BottomCenter) .clickable { @@ -550,28 +553,28 @@ fun PlayerMenu( ) { GridMenuItem( icon = R.drawable.ic_radio, - title = R.string.menu_start_radio + title = R.string.start_radio ) { playerConnection.songPlayer.startRadioSeamlessly() onDismiss() } GridMenuItem( icon = R.drawable.ic_playlist_add, - title = R.string.menu_add_to_playlist, + title = R.string.add_to_playlist, enabled = false ) { } GridMenuItem( icon = R.drawable.ic_file_download, - title = R.string.menu_download, + title = R.string.download, enabled = false ) { } GridMenuItem( icon = R.drawable.ic_artist, - title = R.string.menu_view_artist + title = R.string.view_artist ) { if (mediaMetadata.artists.size == 1) { navController.navigate("artist/${mediaMetadata.artists[0].id}") @@ -584,7 +587,7 @@ fun PlayerMenu( if (mediaMetadata.album != null) { GridMenuItem( icon = R.drawable.ic_album, - title = R.string.menu_view_album + title = R.string.view_album ) { navController.navigate("album/${mediaMetadata.album.id}") playerBottomSheetState.collapseSoft() @@ -593,7 +596,7 @@ fun PlayerMenu( } GridMenuItem( icon = R.drawable.ic_share, - title = R.string.menu_share + title = R.string.share ) { val intent = Intent().apply { action = Intent.ACTION_SEND @@ -605,14 +608,14 @@ fun PlayerMenu( } GridMenuItem( icon = R.drawable.ic_info, - title = R.string.menu_details + title = R.string.details ) { onShowDetailsDialog() onDismiss() } GridMenuItem( icon = R.drawable.ic_equalizer, - title = R.string.pref_equalizer_title + title = R.string.equalizer ) { val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playerConnection.player.audioSessionId) 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 b20a63d8a..527a627ec 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 @@ -317,7 +317,7 @@ fun LocalAlbumHeader( ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text( - text = stringResource(R.string.btn_play) + text = stringResource(R.string.play) ) } @@ -339,7 +339,7 @@ fun LocalAlbumHeader( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_shuffle)) + Text(stringResource(R.string.shuffle)) } } } @@ -463,7 +463,7 @@ fun RemoteAlbumHeader( ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text( - text = stringResource(R.string.btn_play) + text = stringResource(R.string.play) ) } @@ -485,7 +485,7 @@ fun RemoteAlbumHeader( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_shuffle)) + Text(stringResource(R.string.shuffle)) } } } 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 8c32e2f37..e7e65096d 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 @@ -101,7 +101,7 @@ fun HomeScreen( modifier = Modifier.weight(1f) ) NavigationTile( - title = stringResource(R.string.title_settings), + title = stringResource(R.string.settings), icon = R.drawable.ic_settings, onClick = { navController.navigate("settings") }, modifier = Modifier.weight(1f) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt index b31992422..5d3918d43 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/Screens.kt @@ -11,9 +11,9 @@ sealed class Screens( @DrawableRes val iconId: Int, val route: String, ) { - object Home : Screens(R.string.title_home, R.drawable.ic_home, "home") - object Songs : Screens(R.string.title_songs, R.drawable.ic_music_note, "songs") - object Artists : Screens(R.string.title_artists, R.drawable.ic_artist, "artists") - object Albums : Screens(R.string.title_albums, R.drawable.ic_album, "albums") - object Playlists : Screens(R.string.title_playlists, R.drawable.ic_queue_music, "playlists") + object Home : Screens(R.string.home, R.drawable.ic_home, "home") + object Songs : Screens(R.string.songs, R.drawable.ic_music_note, "songs") + object Artists : Screens(R.string.artists, R.drawable.ic_artist, "artists") + object Albums : Screens(R.string.albums, R.drawable.ic_album, "albums") + object Playlists : Screens(R.string.playlists, R.drawable.ic_queue_music, "playlists") } 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 cdabcd81d..d49cf95a6 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 @@ -137,7 +137,7 @@ fun ArtistScreen( ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) Text( - text = stringResource(R.string.btn_shuffle) + text = stringResource(R.string.shuffle) ) } } @@ -156,7 +156,7 @@ fun ArtistScreen( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_radio)) + Text(stringResource(R.string.radio)) } } } @@ -178,7 +178,7 @@ fun ArtistScreen( modifier = Modifier.weight(1f) ) { Text( - text = stringResource(R.string.header_from_your_library), + text = stringResource(R.string.from_your_library), style = MaterialTheme.typography.headlineMedium ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt index 4c7d2b22d..882066e76 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/artist/ArtistSongsScreen.kt @@ -73,7 +73,7 @@ fun ArtistSongsScreen( ArtistSongSortType.NAME -> R.string.sort_by_name } }, - trailingText = pluralStringResource(R.plurals.song_count, songs.size, songs.size) + trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) ) } 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 8b3e0eee2..634e1ae93 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 @@ -66,7 +66,7 @@ fun LibraryAlbumsScreen( AlbumSortType.LENGTH -> R.string.sort_by_length } }, - trailingText = pluralStringResource(R.plurals.album_count, albums.size, albums.size) + trailingText = pluralStringResource(R.plurals.n_album, albums.size, albums.size) ) } 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 878857caf..241e16c13 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 @@ -58,7 +58,7 @@ fun LibraryArtistsScreen( ArtistSortType.SONG_COUNT -> R.string.sort_by_song_count } }, - trailingText = pluralStringResource(R.plurals.artist_count, artists.size, artists.size) + trailingText = pluralStringResource(R.plurals.n_artist, artists.size, artists.size) ) } 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 9fc898712..40a60bd50 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 @@ -55,13 +55,15 @@ fun LibraryPlaylistsScreen( if (showAddPlaylistDialog) { TextFieldDialog( icon = { Icon(painter = painterResource(R.drawable.ic_add), contentDescription = null) }, - title = { Text(text = stringResource(R.string.dialog_title_create_playlist)) }, + title = { Text(text = stringResource(R.string.create_playlist)) }, onDismiss = { showAddPlaylistDialog = false }, onDone = { playlistName -> database.query { - insert(PlaylistEntity( - name = playlistName - )) + insert( + PlaylistEntity( + name = playlistName + ) + ) } } ) @@ -89,7 +91,7 @@ fun LibraryPlaylistsScreen( PlaylistSortType.SONG_COUNT -> R.string.sort_by_song_count } }, - trailingText = pluralStringResource(R.plurals.playlist_count, playlists.size, playlists.size) + trailingText = pluralStringResource(R.plurals.n_playlist, playlists.size, playlists.size) ) } @@ -99,7 +101,7 @@ fun LibraryPlaylistsScreen( ) { ListItem( title = stringResource(R.string.liked_songs), - subtitle = pluralStringResource(R.plurals.song_count, likedSongCount, likedSongCount), + subtitle = pluralStringResource(R.plurals.n_song, likedSongCount, likedSongCount), thumbnailContent = { Icon( painter = painterResource(R.drawable.ic_favorite), @@ -122,7 +124,7 @@ fun LibraryPlaylistsScreen( ) { ListItem( title = stringResource(R.string.downloaded_songs), - subtitle = pluralStringResource(R.plurals.song_count, downloadedSongCount, downloadedSongCount), + subtitle = pluralStringResource(R.plurals.n_song, downloadedSongCount, downloadedSongCount), thumbnailContent = { Icon( painter = painterResource(R.drawable.ic_save_alt), 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 c936e2b78..69efad26b 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 @@ -75,7 +75,7 @@ fun LibrarySongsScreen( SongSortType.ARTIST -> R.string.sort_by_artist } }, - trailingText = pluralStringResource(R.plurals.song_count, songs.size, songs.size) + trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) ) } @@ -111,11 +111,13 @@ fun LibrarySongsScreen( modifier = Modifier .fillMaxWidth() .combinedClickable { - playerConnection.playQueue(ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.map { it.toMediaItem() }, - startIndex = index - )) + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.map { it.toMediaItem() }, + startIndex = index + ) + ) } .animateItemPlacement() ) 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 index c9494f832..556eca8a7 100644 --- 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 @@ -88,7 +88,7 @@ fun BuiltInPlaylistScreen( }, trailingText = joinByBullet( makeTimeString(playlistLength * 1000L), - pluralStringResource(R.plurals.song_count, songs.size, songs.size) + pluralStringResource(R.plurals.n_song, songs.size, songs.size) ) ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt index 07c124fad..287b65e23 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/LocalPlaylistScreen.kt @@ -84,7 +84,7 @@ fun LocalPlaylistScreen( playlist?.playlist?.let { playlistEntity -> TextFieldDialog( icon = { Icon(painter = painterResource(R.drawable.ic_edit), contentDescription = null) }, - title = { Text(text = stringResource(R.string.dialog_title_edit_playlist)) }, + title = { Text(text = stringResource(R.string.edit_playlist)) }, onDismiss = { showEditDialog = false }, initialTextFieldValue = TextFieldValue(playlistEntity.name, TextRange(playlistEntity.name.length)), onDone = { name -> @@ -116,7 +116,7 @@ fun LocalPlaylistScreen( if (playlist.songCount == 0) { EmptyPlaceholder( icon = R.drawable.ic_music_note, - text = stringResource(R.string.playlist_empty) + text = stringResource(R.string.playlist_is_empty) ) } else { Column( @@ -172,7 +172,7 @@ fun LocalPlaylistScreen( ) Text( - text = pluralStringResource(R.plurals.song_count, playlist.songCount, playlist.songCount), + text = pluralStringResource(R.plurals.n_song, playlist.songCount, playlist.songCount), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Normal ) @@ -244,7 +244,7 @@ fun LocalPlaylistScreen( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_play)) + Text(stringResource(R.string.play)) } OutlinedButton( @@ -265,7 +265,7 @@ fun LocalPlaylistScreen( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_shuffle)) + Text(stringResource(R.string.shuffle)) } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt index 1b43e84a0..4e1e693fb 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/playlist/OnlinePlaylistScreen.kt @@ -196,7 +196,7 @@ fun OnlinePlaylistScreen( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_shuffle)) + Text(stringResource(R.string.shuffle)) } OutlinedButton( @@ -212,7 +212,7 @@ fun OnlinePlaylistScreen( modifier = Modifier.size(ButtonDefaults.IconSize) ) Spacer(Modifier.size(ButtonDefaults.IconSpacing)) - Text(stringResource(R.string.btn_radio)) + Text(stringResource(R.string.radio)) } } } 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 ef166db08..a396e9803 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 @@ -63,11 +63,11 @@ fun LocalSearchScreen( .horizontalScroll(rememberScrollState()) ) { listOf( - LocalFilter.ALL to R.string.search_filter_all, - LocalFilter.SONG to R.string.search_filter_songs, - LocalFilter.ALBUM to R.string.search_filter_albums, - LocalFilter.ARTIST to R.string.search_filter_artists, - LocalFilter.PLAYLIST to R.string.search_filter_playlists + 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)) }, @@ -98,10 +98,10 @@ fun LocalSearchScreen( Text( text = stringResource( when (filter) { - LocalFilter.SONG -> R.string.search_filter_songs - LocalFilter.ALBUM -> R.string.search_filter_albums - LocalFilter.ARTIST -> R.string.search_filter_artists - LocalFilter.PLAYLIST -> R.string.search_filter_playlists + LocalFilter.SONG -> R.string.filter_songs + LocalFilter.ALBUM -> R.string.filter_albums + LocalFilter.ARTIST -> R.string.filter_artists + LocalFilter.PLAYLIST -> R.string.filter_playlists LocalFilter.ALL -> error("") } ), 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 e69354122..7d9ada7ff 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 @@ -262,13 +262,13 @@ fun OnlineSearchResult( Spacer(Modifier.width(8.dp)) listOf( - null to R.string.search_filter_all, - FILTER_SONG to R.string.search_filter_songs, - FILTER_VIDEO to R.string.search_filter_videos, - FILTER_ALBUM to R.string.search_filter_albums, - FILTER_ARTIST to R.string.search_filter_artists, - FILTER_COMMUNITY_PLAYLIST to R.string.search_filter_community_playlists, - FILTER_FEATURED_PLAYLIST to R.string.search_filter_featured_playlists + 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)) }, 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 36e68917b..68f9d5a6a 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 @@ -30,7 +30,7 @@ fun AboutScreen( .verticalScroll(rememberScrollState()) ) { PreferenceEntry( - title = stringResource(R.string.pref_app_version_title), + title = stringResource(R.string.app_version), description = BuildConfig.VERSION_NAME, icon = R.drawable.ic_info, onClick = { } @@ -46,7 +46,7 @@ fun AboutScreen( } TopAppBar( - title = { Text(stringResource(R.string.pref_about_title)) }, + title = { Text(stringResource(R.string.about)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt index 02a451a2f..e4af5febe 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -34,7 +34,7 @@ fun AppearanceSettings( .verticalScroll(rememberScrollState()) ) { EnumListPreference( - title = stringResource(R.string.pref_dark_theme_title), + title = stringResource(R.string.dark_theme), icon = R.drawable.ic_dark_mode, selectedValue = darkMode, onValueSelected = onDarkModeChange, @@ -47,37 +47,37 @@ fun AppearanceSettings( } ) EnumListPreference( - title = stringResource(R.string.pref_default_open_tab_title), + title = stringResource(R.string.default_open_tab), icon = R.drawable.ic_tab, selectedValue = defaultOpenTab, onValueSelected = onDefaultOpenTabChange, valueText = { when (it) { - NavigationTab.HOME -> stringResource(R.string.title_home) - NavigationTab.SONG -> stringResource(R.string.title_songs) - NavigationTab.ARTIST -> stringResource(R.string.title_artists) - NavigationTab.ALBUM -> stringResource(R.string.title_albums) - NavigationTab.PLAYLIST -> stringResource(R.string.title_playlists) + NavigationTab.HOME -> stringResource(R.string.home) + NavigationTab.SONG -> stringResource(R.string.songs) + NavigationTab.ARTIST -> stringResource(R.string.artists) + NavigationTab.ALBUM -> stringResource(R.string.albums) + NavigationTab.PLAYLIST -> stringResource(R.string.playlists) } } ) EnumListPreference( - title = stringResource(R.string.pref_lyrics_text_position_title), + title = stringResource(R.string.lyrics_text_position), icon = R.drawable.ic_lyrics, selectedValue = lyricsPosition, onValueSelected = onLyricsPositionChange, valueText = { when (it) { - LyricsPosition.LEFT -> stringResource(R.string.align_left) - LyricsPosition.CENTER -> stringResource(R.string.align_center) - LyricsPosition.RIGHT -> stringResource(R.string.align_right) + LyricsPosition.LEFT -> stringResource(R.string.left) + LyricsPosition.CENTER -> stringResource(R.string.center) + LyricsPosition.RIGHT -> stringResource(R.string.right) } } ) } TopAppBar( - title = { Text(stringResource(R.string.pref_appearance_title)) }, + title = { Text(stringResource(R.string.appearance)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt index 0c338221f..3eeaf46a0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/BackupAndRestore.kt @@ -46,7 +46,7 @@ fun BackupAndRestore( .verticalScroll(rememberScrollState()) ) { PreferenceEntry( - title = stringResource(R.string.pref_backup_title), + title = stringResource(R.string.backup), icon = R.drawable.ic_backup, onClick = { val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") @@ -54,7 +54,7 @@ fun BackupAndRestore( } ) PreferenceEntry( - title = stringResource(R.string.pref_restore_title), + title = stringResource(R.string.restore), icon = R.drawable.ic_restore, onClick = { restoreLauncher.launch(arrayOf("application/octet-stream")) @@ -63,7 +63,7 @@ fun BackupAndRestore( } TopAppBar( - title = { Text(stringResource(R.string.pref_backup_restore_title)) }, + title = { Text(stringResource(R.string.backup_restore)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt index 7ed41a55f..015c1e329 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/ContentSettings.kt @@ -40,7 +40,7 @@ fun ContentSettings( .verticalScroll(rememberScrollState()) ) { ListPreference( - title = stringResource(R.string.pref_content_language_title), + title = stringResource(R.string.content_language), icon = R.drawable.ic_language, selectedValue = contentLanguage, values = listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList(), @@ -52,7 +52,7 @@ fun ContentSettings( onValueSelected = onContentLanguageChange ) ListPreference( - title = stringResource(R.string.pref_default_content_country_title), + title = stringResource(R.string.content_country), icon = R.drawable.ic_place, selectedValue = contentCountry, values = listOf(SYSTEM_DEFAULT) + CountryCodeToName.keys.toList(), @@ -69,21 +69,21 @@ fun ContentSettings( ) SwitchPreference( - title = stringResource(R.string.pref_enable_proxy_title), + title = stringResource(R.string.enable_proxy), checked = proxyEnabled, onCheckedChange = onProxyEnabledChange ) if (proxyEnabled) { ListPreference( - title = stringResource(R.string.pref_proxy_type_title), + title = stringResource(R.string.proxy_type), selectedValue = proxyType, values = listOf(Proxy.Type.HTTP, Proxy.Type.SOCKS), valueText = { it.name }, onValueSelected = onProxyTypeChange ) EditTextPreference( - title = stringResource(R.string.pref_proxy_url_title), + title = stringResource(R.string.proxy_url), value = proxyUrl, onValueChange = onProxyUrlChange ) @@ -91,7 +91,7 @@ fun ContentSettings( } TopAppBar( - title = { Text(stringResource(R.string.pref_content_title)) }, + title = { Text(stringResource(R.string.content)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt index 273e6d6f1..cfe7dc0d0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/GeneralSettings.kt @@ -36,28 +36,28 @@ fun GeneralSettings( .verticalScroll(rememberScrollState()) ) { SwitchPreference( - title = stringResource(R.string.pref_auto_add_song_title), - description = stringResource(R.string.pref_auto_add_song_summary), + title = stringResource(R.string.auto_add_song), + description = stringResource(R.string.auto_add_song_desc), icon = R.drawable.ic_library_add, checked = autoAddToLibrary, onCheckedChange = onAutoAddToLibraryChange ) SwitchPreference( - title = stringResource(R.string.pref_auto_download_title), - description = stringResource(R.string.pref_auto_download_summary), + title = stringResource(R.string.auto_download), + description = stringResource(R.string.auto_download_desc), icon = R.drawable.ic_save_alt, checked = autoDownload, onCheckedChange = onAutoDownloadChange ) SwitchPreference( - title = stringResource(R.string.pref_expand_on_play_title), + title = stringResource(R.string.expand_on_play), icon = R.drawable.ic_open_in_full, checked = expandOnPlay, onCheckedChange = onExpandOnPlayChange ) SwitchPreference( - title = stringResource(R.string.pref_notification_more_action_title), - description = stringResource(R.string.pref_notification_more_action_summary), + title = stringResource(R.string.notification_more_action), + description = stringResource(R.string.notification_more_action_desc), icon = R.drawable.ic_notifications, checked = notificationMoreAction, onCheckedChange = onNotificationMoreActionChange @@ -65,7 +65,7 @@ fun GeneralSettings( } TopAppBar( - title = { Text(stringResource(R.string.pref_general_title)) }, + title = { Text(stringResource(R.string.general)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt index 758cb38de..97a819c9d 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt @@ -38,7 +38,7 @@ fun PlayerSettings( .verticalScroll(rememberScrollState()) ) { EnumListPreference( - title = stringResource(R.string.pref_audio_quality_title), + title = stringResource(R.string.audio_quality), icon = R.drawable.ic_graphic_eq, selectedValue = audioQuality, onValueSelected = onAudioQualityChange, @@ -51,19 +51,19 @@ fun PlayerSettings( } ) SwitchPreference( - title = stringResource(R.string.pref_persistent_queue_title), + title = stringResource(R.string.persistent_queue), icon = R.drawable.ic_queue_music, checked = persistentQueue, onCheckedChange = onPersistentQueueChange ) SwitchPreference( - title = stringResource(R.string.pref_skip_silence_title), + title = stringResource(R.string.skip_silence), icon = R.drawable.ic_skip_next, checked = skipSilence, onCheckedChange = onSkipSilenceChange ) SwitchPreference( - title = stringResource(R.string.pref_audio_normalization_title), + title = stringResource(R.string.audio_normalization), icon = R.drawable.ic_volume_up, checked = audioNormalization, onCheckedChange = onAudioNormalizationChange @@ -71,7 +71,7 @@ fun PlayerSettings( } TopAppBar( - title = { Text(stringResource(R.string.pref_player_audio_title)) }, + title = { Text(stringResource(R.string.player_and_audio)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index 45e134dc5..60a3d5626 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -41,7 +41,7 @@ fun PrivacySettings( onDismiss = { showClearHistoryDialog = false }, content = { Text( - text = stringResource(R.string.clear_search_history_question), + text = stringResource(R.string.clear_search_history_confirm), style = MaterialTheme.typography.bodyLarge, modifier = Modifier.padding(horizontal = 18.dp) ) @@ -73,18 +73,18 @@ fun PrivacySettings( .verticalScroll(rememberScrollState()) ) { SwitchPreference( - title = stringResource(R.string.pref_pause_search_history_title), + title = stringResource(R.string.pause_search_history), icon = R.drawable.ic_manage_search, checked = pauseSearchHistory, onCheckedChange = onPauseSearchHistoryChange ) PreferenceEntry( - title = stringResource(R.string.pref_clear_search_history_title), + title = stringResource(R.string.clear_search_history), icon = R.drawable.ic_clear_all, onClick = { showClearHistoryDialog = true } ) SwitchPreference( - title = stringResource(R.string.pref_enable_kugou_title), + title = stringResource(R.string.enable_kugou), icon = R.drawable.ic_lyrics, checked = enableKugou, onCheckedChange = onEnableKugouChange @@ -92,7 +92,7 @@ fun PrivacySettings( } TopAppBar( - title = { Text(stringResource(R.string.pref_privacy_title)) }, + title = { Text(stringResource(R.string.privacy)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt index 395b16b2d..b3fadfc10 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/SettingsScreen.kt @@ -26,49 +26,49 @@ fun SettingsScreen( .verticalScroll(rememberScrollState()) ) { PreferenceEntry( - title = stringResource(R.string.pref_appearance_title), + title = stringResource(R.string.appearance), icon = R.drawable.ic_palette, onClick = { navController.navigate("settings/appearance") } ) PreferenceEntry( - title = stringResource(R.string.pref_content_title), + title = stringResource(R.string.content), icon = R.drawable.ic_language, onClick = { navController.navigate("settings/content") } ) PreferenceEntry( - title = stringResource(R.string.pref_player_audio_title), + title = stringResource(R.string.player_and_audio), icon = R.drawable.ic_play, onClick = { navController.navigate("settings/player") } ) PreferenceEntry( - title = stringResource(R.string.pref_storage_title), + title = stringResource(R.string.storage), icon = R.drawable.ic_storage, onClick = { navController.navigate("settings/storage") } ) PreferenceEntry( - title = stringResource(R.string.pref_general_title), + title = stringResource(R.string.general), icon = R.drawable.ic_testing, onClick = { navController.navigate("settings/general") } ) PreferenceEntry( - title = stringResource(R.string.pref_privacy_title), + title = stringResource(R.string.privacy), icon = R.drawable.ic_security, onClick = { navController.navigate("settings/privacy") } ) PreferenceEntry( - title = stringResource(R.string.pref_backup_restore_title), + title = stringResource(R.string.backup_restore), icon = R.drawable.ic_settings_backup_restore, onClick = { navController.navigate("settings/backup_restore") } ) PreferenceEntry( - title = stringResource(R.string.pref_about_title), + title = stringResource(R.string.about), icon = R.drawable.ic_info, onClick = { navController.navigate("settings/about") } ) } TopAppBar( - title = { Text(stringResource(R.string.title_settings)) }, + title = { Text(stringResource(R.string.settings)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( 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 2f7653888..d122ce959 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 @@ -97,7 +97,7 @@ fun StorageSettings( ) PreferenceEntry( - title = stringResource(R.string.pref_clear_image_cache_title), + title = stringResource(R.string.clear_image_cache), onClick = { coroutineScope.launch(Dispatchers.IO) { imageDiskCache.clear() @@ -153,7 +153,7 @@ fun StorageSettings( } TopAppBar( - title = { Text(stringResource(R.string.pref_storage_title)) }, + title = { Text(stringResource(R.string.storage)) }, navigationIcon = { IconButton(onClick = navController::navigateUp) { Icon( 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 bbb5fac6a..7c45c1e36 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -45,10 +45,10 @@ class BackupRestoreViewModel @Inject constructor( } } }.onSuccess { - Toast.makeText(context, R.string.message_backup_create_success, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.backup_create_success, Toast.LENGTH_SHORT).show() }.onFailure { it.printStackTrace() - Toast.makeText(context, R.string.message_backup_create_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.backup_create_failed, Toast.LENGTH_SHORT).show() } } @@ -84,7 +84,7 @@ class BackupRestoreViewModel @Inject constructor( exitProcess(0) }.onFailure { it.printStackTrace() - Toast.makeText(context, R.string.message_restore_failed, Toast.LENGTH_SHORT).show() + Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() } } diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index dcf49ade2..76158e702 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -1,132 +1,80 @@ - - Startseite - Titel - Künstler - Alben - Wiedergabelisten - Erkunden - Einstellungen - Jetzt spielen - Fehlerbericht - - - Erscheinungsbild - Systemthema folgen - Themenfarbe - Dunkles Thema - An - Aus - System folgen - Standardmäßig geöffnete Registerkarte - Anpassen der Navigationsregisterkarten - Position des Liedtextes - Links - Mitte - Rechts - - Inhalt - Anmeldung - Standard-Inhaltssprache - Standard-Inhaltsland - Proxy einschalten - Proxy-Typ - Proxy-URL - Neustart, damit er wirksam wird - - Player und Audio - Tonqualität - Automatisch - Hoch - Niedrig - Dauerhafte Warteschlange - Stille überspringen - Audio-Normalisierung - Equalizer - - Speicher - Heruntergeladene Dateien in SAF anzeigen - Dies kann bei einigen Geräten nicht funktionieren - Zwischenspeicher - Maximale Größe des Bild-Caches - Bild-Cache löschen - Maximale Größe des Song-Cache - %s verwendet - - Allgemein - Automatisches Herunterladen - Lied herunterladen, wenn es zur Bibliothek hinzugefügt wird - Lied automatisch zur Bibliothek hinzufügen - Lied zu Ihrer Bibliothek hinzufügen, wenn es fertig abgespielt ist - Erweitern des unteren Spielers im Spiel - Weitere Aktionen in der Benachrichtigung - Schaltflächen "Zur Bibliothek hinzufügen" und "Gefällt mir" anzeigen - - Privatsphäre - Suchverlauf anhalten - Suchverlauf löschen - Sind Sie sicher, dass Sie den gesamten Suchverlauf löschen? - KuGou-Liedtextanbieter aktivieren - - Sichern und Wiederherstellen - Datensicherung - Wiederherstellen - - Über - App-Version + + Startseite + Titel + Künstler + Alben + Wiedergabelisten + + + + %d ausgewählt + %d ausgewählt + - - Sakura - Rot - Pink - Lila - Dunkelviolett - Indigo - Blau - Hellblau - Türkis - Türkisblau - Grün - Hellgrün - Limette - Gelb - Bernstein - Orange - Dunkelorange - Braun - Blau-grau + + History + Stats + New release albums + Most played songs - - Suche + + Suche YouTube Musik durchsuchen… Bibliothek durchsuchen… - - + Alles + Titel + Videos + Alben + Künstler + Wiedergabelisten + Community-Wiedergabelisten + Ausgewählte Wiedergabelisten + No results found + + + Aus der Bibliothek + + Beliebte Titel Heruntergeladene Titel + The playlist is empty - - Details - Bearbeiten - Radio starten - Wiedergegeben - Als nächstes wiedergeben - Zu Warteschlange hinzufügen - Zur Bibliothek hinzufügen - Herunterladen - Download entfernen - Wiedergabeliste importieren - Zur Wiedergabeliste hinzufügen - Künstler ansehen - Album ansehen - Neu laden - Teilen - Löschen - Online-Suche - Andere Texte auswählen + + Wiederholen + Radio + Shuffle + + + Details + Bearbeiten + Radio starten + Wiedergegeben + Als nächstes wiedergeben + Zu Warteschlange hinzufügen + Zur Bibliothek hinzufügen + Herunterladen + Download entfernen + Wiedergabeliste importieren + Zur Wiedergabeliste hinzufügen + Künstler ansehen + Album ansehen + Neu laden + Teilen + Löschen + Online-Suche + Sync + + + Datum hinzugefügt + Name + Künstler + Jahr + Anzahl der Lieder + Länge + Spielzeit - Details Medien-ID MIME type Codecs @@ -136,172 +84,143 @@ Volume Dateigröße Unbekannt + In die Zwischenablage kopiert - Text bearbeiten - Songtext suchen - Songtext auswählen + Text bearbeiten + Songtext suchen - Titel bearbeiten + Titel bearbeiten Songtitel Song-Künstler Der Songtitel darf nicht leer sein. Songinterpret darf nicht leer sein. - Speichern + Speichern - Wiedergabeliste erstellen - Name der Wiedergabeliste + Wiedergabeliste auswählen + Wiedergabeliste bearbeiten + Wiedergabeliste erstellen + Name der Wiedergabeliste Der Name der Wiedergabeliste darf nicht leer sein. - Künstler bearbeiten - Name des Künstlers + Künstler bearbeiten + Name des Künstlers Der Name des Künstlers darf nicht leer sein. - Doppelter Künstler - Künstler %1$s existiert bereits. - - Wiedergabeliste auswählen - - Wiedergabeliste bearbeiten - - Wählen Sie den Sicherungsinhalt - Wählen Sie Inhalt wiederherstellen - Einstellungen - Datenbank - Heruntergeladene Titel - Sicherung erfolgreich erstellt - Konnte keine Sicherung erstellen - Wiederherstellung der Sicherung fehlgeschlagen - - - Musik-Player - Herunterladen - - + %d Lied %d Lied - + %d Künstler %d Künstler - + %d Album %d Alben - + %d Wiedergabeliste %d Wiedergabelisten - - Wiederholen - Spielen - Alles abspielen - Radio - Shuffle - Stapelspur kopieren - Bericht - Bericht auf GitHub - - - Datum hinzugefügt - Name - Künstler - Jahr - Anzahl der Lieder - Länge - Spielzeit - - - %d Song wurde gelöscht. - %d Songs wurden gelöscht. - - - %d ausgewählt - %d ausgewählt - - Rückgängig machen - Kann diese Url nicht identifizieren. - - Song wird als nächstes gespielt - %d Songs werden als nächstes gespielt - - - Der Künstler spielt als nächstes - %d Künstler werden als nächstes spielen - - - Album wird als nächstes gespielt - %d Alben werden als nächstes gespielt - - - Playlist wird als nächstes abgespielt - %d Wiedergabelisten werden als nächstes abgespielt - - Ausgewählte spielen als nächstes - - Lied zur Warteschlange hinzugefügt - %d Lieder zur Warteschlange hinzugefügt - - - Künstler zur Warteschlange hinzugefügt - %d Künstler zur Warteschlange hinzugefügt - - - Album zur Warteschlange hinzugefügt - %d Alben zur Warteschlange hinzugefügt - - - Wiedergabeliste zur Warteschlange hinzugefügt - %d Wiedergabelisten zur Warteschlange hinzugefügt - - Ausgewählte zur Warteschlange hinzugefügt - Zur Bibliothek hinzugefügt - Aus der Bibliothek entfernt Wiedergabeliste importiert - Hinzugefügt zu %1$s - - Herunterladen des Songs starten - Herunterladen von %d Titeln beginnen + + + Liedtext nicht gefunden + Sleep timer + End of song + + 1 minute + %d minutes - Download entfernt - Ansicht + Kein Stream verfügbar + Keine Netzverbindung + Zeitüberschreitung + Unbekannter Fehler - + Favoriten Entfernen aus Favoriten Zur Bibliothek hinzufügen Aus der Bibliothek entfernen - - Alles - Titel - Videos - Alben - Künstler - Wiedergabelisten - Community-Wiedergabelisten - Ausgewählte Wiedergabelisten - - Systemvorgabe - Aus der Bibliothek - - - Tut mir leid, das hätte nicht passieren dürfen. - In die Zwischenablage kopiert - - - Kein Stream verfügbar - Keine Netzverbindung - Zeitüberschreitung - Unbekannter Fehler - Alle Titel Gesuchte Titel - - Liedtext nicht gefunden + + Musik-Player + + + Einstellungen + Erscheinungsbild + Dunkles Thema + An + Aus + System folgen + Standardmäßig geöffnete Registerkarte + Anpassen der Navigationsregisterkarten + Position des Liedtextes + Links + Mitte + Rechts + + Inhalt + Anmeldung + Standard-Inhaltssprache + Standard-Inhaltsland + Systemvorgabe + Proxy einschalten + Proxy-Typ + Proxy-URL + Neustart, damit er wirksam wird + + Player und Audio + Tonqualität + Automatisch + Hoch + Niedrig + Dauerhafte Warteschlange + Stille überspringen + Audio-Normalisierung + Equalizer + + Speicher + Zwischenspeicher + Image Cache + Song Cache + Max cache size + Unlimited + Maximale Größe des Bild-Caches + Bild-Cache löschen + Maximale Größe des Song-Cache + Clear song cache + %s verwendet + + Allgemein + Automatisches Herunterladen + Lied herunterladen, wenn es zur Bibliothek hinzugefügt wird + Lied automatisch zur Bibliothek hinzufügen + Lied zu Ihrer Bibliothek hinzufügen, wenn es fertig abgespielt ist + Erweitern des unteren Spielers im Spiel + Weitere Aktionen in der Benachrichtigung + Schaltflächen "Zur Bibliothek hinzufügen" und "Gefällt mir" anzeigen + + Privatsphäre + Suchverlauf anhalten + Suchverlauf löschen + Sind Sie sicher, dass Sie den gesamten Suchverlauf löschen? + KuGou-Liedtextanbieter aktivieren + + Sichern und Wiederherstellen + Datensicherung + Wiederherstellen + Sicherung erfolgreich erstellt + Konnte keine Sicherung erstellen + Wiederherstellung der Sicherung fehlgeschlagen + + Über + App-Version diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6bbdbab11..6b225f41d 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -1,132 +1,82 @@ - - Domů - Skladby - Umělci - Alba - Playlisty - Procházet - Nastavení - Právě hraje - Hlášení chyb - - - Vzhled - Podle systému - Barva motivu - Tmavý motiv - Zap - Vyp - Podle systému - Výchozí karta - Přizpůsobit navigační karty - Pozice textů - Vlevo - Uprostřed - Vpravo - - Obsah - Přihlásit se - Výchozí jazyk obsahu - Výchozí země obsahu - Povolit proxy - Typ proxy - Adresa URL proxy - Restartujte pro uplatnění změn - - Přehrávač a zvuk - Kvalita zvuku - Automaticky - Vysoká - Nízká - Ukládat frontu - Přeskakovat ticho - Normalizace zvuku - Ekvalizér - - Úložiště - Zobrazit stažené soubory v SAF - Nemusí fungovat na všech zařízeních - Mezipaměť - Maximální velikost mezipaměti obrázků - Vymazat mezipaměť obrázků - Maximální velikost mezipaměti skladeb - Využito %s - - Obecné - Automatické stahování - Stáhnout skladbu při přidání do knihovny - Automaticky přidat skladbu do knihovny - Přidat skladbu do vaší knihovny po skončení jejího přehrávání - Při přehrání rozšířit spodní přehrávač - Více akcí v oznámení - Zobrazit tlačítka přidání do knihovny a oblíbení - - Soukromí - Pozastavit historii vyhledávání - Vymazat historii vyhledávání - Opravdu chcete vymazat celou historii vyhledávání? - Povolit poskytovatele textů KuGou - - Záloha a obnovení - Zálohovat - Obnovit - - O aplikaci - Verze aplikace + + Domů + Skladby + Umělci + Alba + Playlisty + + + + Vybrána %d + Vybrány %d + Vybráno %d + Vybráno %d + - - Sakura - Červená - Růžová - Fialová - Tmavě fialová - Indigová - Modrá - Světle modrá - Azurová - Tyrkysová - Zelená - Světle zelená - Limetková - Žlutá - Jantarová - Oranžová - Tmavě oranžová - Hnědá - Modře šedá + + History + Stats + New release albums + Most played songs - - Vyhledávání + + Vyhledávání Hledat v YouTube Music… Hledat v knihovně… - - + Vše + Skladby + Videa + Alba + Umělci + Playlisty + Komunitní playlisty + Doporučené playlisty + No results found + + + Z vaší knihovny + + Oblíbené skladby Stažené skladby + The playlist is empty - - Podrobnosti - Upravit - Spustit rádio - Přehrát - Přehrát jako další - Přidat do fronty - Přidat do knihovny - Stáhnout - Odebrat stahování - Importovat playlist - Přidat do playlistu - Zobrazit umělce - Zobrazit album - Obnovit - Sdílet - Odstranit - Hledat online - Vybrat jiné texty + + Zkusit znovu + Rádio + Náhodně + + + Podrobnosti + Upravit + Spustit rádio + Přehrát + Přehrát jako další + Přidat do fronty + Přidat do knihovny + Stáhnout + Odebrat stahování + Importovat playlist + Přidat do playlistu + Zobrazit umělce + Zobrazit album + Obnovit + Sdílet + Odstranit + Hledat online + Sync + + + Datum přidání + Název + Umělec + Rok + Počet skladeb + Délka + Doba přehrávání - Podrobnosti ID média Typ MIME Kodeky @@ -136,202 +86,152 @@ Hlasitost Velikost souboru Neznámé + Zkopírováno do schránky - Upravit texty - Hledat texty - Vybrat texty + Upravit texty + Hledat texty - Upravit skladbu + Upravit skladbu Název skladby Umělci skladby Název skladby nemůže být prázdný. Umělec skladby nemůže být prázdný. - Uložit + Uložit - Vytvořit playlist - Název playlistu + Vybrat playlist + Upravit playlist + Vytvořit playlist + Název playlistu Název playlistu nemůže být prázdný. - Upravit umělce - Jméno umělce + Upravit umělce + Jméno umělce Jméno umělce nemůže být prázdné. - Duplicitní umělci - Umělec %1$s již existuje. - - Vybrat playlist - - Upravit playlist - - Vybrat obsah zálohy - Vybrat obsah obnovení - Předvolby - Databáze - Stažené skladby - Záloha úspěšně vytvořena - Nepodařilo se vytvořit zálohu - Nepodařilo se obnovit zálohu - - - Hudební přehrávač - Stáhnout - - + %d skladba %d skladby %d skladeb %d skladeb - + %d umělec %d umělci %d umělců %d umělců - + %d album %d alba %d alb %d alb - + %d playlist %d playlisty %d playlistů %d playlistů - - Zkusit znovu - Přehrát - Přehrát vše - Rádio - Náhodně - Kopírovat stacktrace - Nahlásit - Nahlásit na GitHub - - - Datum přidání - Název - Umělec - Rok - Počet skladeb - Délka - Doba přehrávání - - - Byla odstraněna %d skladba. - Byly odstraněny %d skladby. - Bylo odstraněna %d skladeb. - Bylo odstraněno %d skladeb. - - - Vybrána %d - Vybrány %d - Vybráno %d - Vybráno %d - - Zrušit - Tuto adresu nelze identifikovat. - - Skladba bude hrát jako další - %d skladby budou hrát jako další - %d skladeb bude hrát jako další - %d skladeb bude hrát jako další - - - Umělec bude hrát jako další - %d umělci budou hrát jako další - %d umělců bude hrát jako další - %d umělců bude hrát jako další - - - Album bude hrát jako další - %d alba budou hrát jako další - %d alb bude hrát jako další - %d alb bude hrát jako další - - - Playlist bude hrát jako další - %d playlisty budou hrát jako další - %d playlistů bude hrát jako další - %d playlistů bude hrát jako další - - Vybrané budou přehrány jako další - - Skladba přidána do fronty - %d skladby přidány do fronty - %d skladeb přidáno do fronty - %d skladeb přidáno do fronty - - - Umělec přidán do fronty - %d umělci přidáni do fronty - %d umělců přidáno do fronty - %d umělců přidáno do fronty - - - Album přidáno do fronty - %d alba přidány do fronty - %d alb přidáno do fronty - %d alb přidáno do fronty - - - Playlist přidán do fronty - %d playlisty přidány do fronty - %d playlistů přidáno do fronty - %d playlistů přidáno do fronty - - Vybrané přidány do fronty - Přidáno do knihovny - Odebráno z knihovny Playlist importován - Přidáno do playlistu %1$s - - Stáhnout skladbu - Stáhnout %d skladby - Stáhnout %d skladeb - Stáhnout %d skladeb + + + Texty nenalezeny + Sleep timer + End of song + + 1 minute + %d minutes + %d minutes - Odebrané stahování - Zobrazit + Není dostupný žádný stream + Není dostupné připojení k internetu + Vypršel čas + Neznámá chyba - + Oblíbené Odebrat z oblíbených Přidat do knihovny Odebrat z knihovny - - Vše - Skladby - Videa - Alba - Umělci - Playlisty - Komunitní playlisty - Doporučené playlisty - - Podle systému - Z vaší knihovny - - - Omlouváme se, tohle se nemělo stát. - Zkopírováno do schránky - - - Není dostupný žádný stream - Není dostupné připojení k internetu - Vypršel čas - Neznámá chyba - Všechny skladby Hledané skladby - - Texty nenalezeny + + Hudební přehrávač + + + Nastavení + Vzhled + Tmavý motiv + Zap + Vyp + Podle systému + Výchozí karta + Přizpůsobit navigační karty + Pozice textů + Vlevo + Uprostřed + Vpravo + + Obsah + Přihlásit se + Výchozí jazyk obsahu + Výchozí země obsahu + Podle systému + Povolit proxy + Typ proxy + Adresa URL proxy + Restartujte pro uplatnění změn + + Přehrávač a zvuk + Kvalita zvuku + Automaticky + Vysoká + Nízká + Ukládat frontu + Přeskakovat ticho + Normalizace zvuku + Ekvalizér + + Úložiště + Mezipaměť + Image Cache + Song Cache + Max cache size + Unlimited + Maximální velikost mezipaměti obrázků + Vymazat mezipaměť obrázků + Maximální velikost mezipaměti skladeb + Clear song cache + Využito %s + + Obecné + Automatické stahování + Stáhnout skladbu při přidání do knihovny + Automaticky přidat skladbu do knihovny + Přidat skladbu do vaší knihovny po skončení jejího přehrávání + Při přehrání rozšířit spodní přehrávač + Více akcí v oznámení + Zobrazit tlačítka přidání do knihovny a oblíbení + + Soukromí + Pozastavit historii vyhledávání + Vymazat historii vyhledávání + Opravdu chcete vymazat celou historii vyhledávání? + Povolit poskytovatele textů KuGou + + Záloha a obnovení + Zálohovat + Obnovit + Záloha úspěšně vytvořena + Nepodařilo se vytvořit zálohu + Nepodařilo se obnovit zálohu + + O aplikaci + Verze aplikace diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 412e31f9e..dcc6bac3b 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -1,132 +1,80 @@ - - Home - Canciones - Artistas - Albums - Playlists - Buscar - Configuración - Sonando ahora - Error report - - - Apariencia - Seguir color de acento del sistema - Color de acento - Modo oscuro - Encendido - Apagado - Predeterminado del sistema - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Contenido - Login - Idioma por defecto del contenido - País por defecto del contenido - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Descarga automática - Descarga la canción cuando se añade a la biblioteca - Añadir automáticamente la canción a la biblioteca - Añadir canción a la biblioteca cuando termine de reproducirse - Expandir reproductor inferior cuando comience la reproducción - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - Acerca de - Versión de la aplicación + + Home + Canciones + Artistas + Albums + Playlists + + + + %d seleccionada + %d seleccionadas + - - Sakura - Rojo - Rosa - Morado - Morado intenso - Indigo - Azul - Azul claro - Verde azulado - Turquesa - Verde - Verde claro - Lima - Amarillo - Ámbar - Naranja - Naranja intenso - Marrón - Gris azulado + + History + Stats + New release albums + Most played songs - - Buscar + + Buscar Search YouTube Music… Search library… + Todo + Canciones + Vídeos + Álbumes + Artistas + Playlists + Community playlists + Featured playlists + No results found + + + From your library - + Liked songs Downloaded songs + The playlist is empty - - Details - Editar - Start radio - Play - Reproducir luego - Añadir a la cola - Añadir a la biblioteca - Descargar - Remover archivo descargado - Import playlist - Añadir a una playlist - View artist - View album - Refetch - Share - Eliminar - Search online - Choose other lyrics + + Reintentar + Radio + Aleatorio + + + Details + Editar + Start radio + Play + Reproducir luego + Añadir a la cola + Añadir a la biblioteca + Descargar + Remover archivo descargado + Import playlist + Añadir a una playlist + View artist + View album + Refetch + Share + Eliminar + Search online + Sync + + + Fecha de incorporación + Nombre + Artista + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Editar canción + Editar canción Título Artista El título no puede estar vacío. El artista no puede estar vacío. - Guardar + Guardar - Crear playlist - Nombre + Elige una playlist + Editar playlist + Crear playlist + Nombre El nombre no puede estar vacío. - Editar artista - Nombre + Editar artista + Nombre El nombre no puede estar vacío. - Artistas duplicados - El artista %1$s ya existe. - - Elige una playlist - - Editar playlist - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Reproductor - Descargar - - + %d canción %d canciones - + %d artist %d artists - + %d album %d albums - + %d playlist %d playlists - - Reintentar - Play - Reproducir todo - Radio - Aleatorio - Copy stacktrace - Report - Report on GitHub - - - Fecha de incorporación - Nombre - Artista - Year - Song count - Length - Play time - - - %d canción fue eliminada. - %d canciones fueron eliminadas. - - - %d seleccionada - %d seleccionadas - - Deshacer - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like Añadir a la biblioteca Remove from library - - Todo - Canciones - Vídeos - Álbumes - Artistas - Playlists - Community playlists - Featured playlists - - Predeterminado del sistema - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + Reproductor - - No results found + + Configuración + Apariencia + Modo oscuro + Encendido + Apagado + Predeterminado del sistema + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Contenido + Login + Idioma por defecto del contenido + País por defecto del contenido + Predeterminado del sistema + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + General + Descarga automática + Descarga la canción cuando se añade a la biblioteca + Añadir automáticamente la canción a la biblioteca + Añadir canción a la biblioteca cuando termine de reproducirse + Expandir reproductor inferior cuando comience la reproducción + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + Acerca de + Versión de la aplicación diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 4c5f52f28..f423a5969 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,132 +1,80 @@ - - Home - Canciones - Artistas - Albums - Listas de reproducción - Explorar - Ajustes - Reproduciendo - Error report - - - Apariencia - Seguir el tema del sistema - Color del tema - Tema oscuro - Encendido - Apagado - Seguir el sistema - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Contenido - Login - Idioma de contenido predeterminado - País de contenido predeterminado - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Descarga automatica - Descargar canción cuando se agrega a la biblioteca - Agregar canción automáticamente a la biblioteca - Agregue una canción a su biblioteca cuando termine de reproducirse - Expandir reproductor inferior al reproducir - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - Acerca de - Version de la app + + Home + Canciones + Artistas + Albums + Listas de reproducción + + + + %d seleccionada + %d seleccionadas + - - Sakura - Rojo - Rosa - Morado - Morado oscuro - Indigo - Azul - Azul claro - Cian - Verde azulado - Verde - Verde claro - Lima - Amarillo - Ambar - Naranjo - Naranjo oscuro - Café - Gris azulado + + History + Stats + New release albums + Most played songs - - Buscar + + Buscar Search YouTube Music… Search library… + Todo + Canciones + Videos + Albums + Artistas + Listas de reproducción + Community playlists + Featured playlists + No results found + + + From your library - + Liked songs Downloaded songs + The playlist is empty - - Details - Editar - Start radio - Play - Reproducir siguiente - Añadir a la cola - Agregar a la biblioteca - Descargar - Quitar descarga - Import playlist - Agregar a la lista de reproducción - View artist - View album - Refetch - Share - Borrar - Search online - Choose other lyrics + + Volver a intentar + Radio + Shuffle + + + Details + Editar + Start radio + Play + Reproducir siguiente + Añadir a la cola + Agregar a la biblioteca + Descargar + Quitar descarga + Import playlist + Agregar a la lista de reproducción + View artist + View album + Refetch + Share + Borrar + Search online + Sync + + + Fecha Agregada + Nombre + Artista + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Editar canción + Editar canción Título de la canción Artista de la canción El título de la canción no puede estar vacío. El artista de la canción no puede estar vacío. - Guardar + Guardar - Crear lista de reproducción - Nombre de la lista de reproducción + Elegir lista de reproducción + Editar lista de reproducción + Crear lista de reproducción + Nombre de la lista de reproducción El nombre de la lista de reproducción no puede estar vacío. - Editar artista - Nombre del artista + Editar artista + Nombre del artista El nombre del artista no puede estar vacío. - Artistas duplicados - El artista %1$s ya existe. - - Elegir lista de reproducción - - Editar lista de reproducción - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Reproductor de música - Descargar - - + %d canción %d canciones - + %d artist %d artists - + %d album %d albums - + %d playlist %d playlists - - Volver a intentar - Play - Reproducir todo - Radio - Shuffle - Copy stacktrace - Report - Report on GitHub - - - Fecha Agregada - Nombre - Artista - Year - Song count - Length - Play time - - - %d canción ha sido eliminada. - %d canciónes han sido eliminadas. - - - %d seleccionada - %d seleccionadas - - Deshacer - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like Agregar a la biblioteca Remove from library - - Todo - Canciones - Videos - Albums - Artistas - Listas de reproducción - Community playlists - Featured playlists - - Sistema por defecto - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + Reproductor de música - - No results found - \ No newline at end of file + + Ajustes + Apariencia + Tema oscuro + Encendido + Apagado + Seguir el sistema + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Contenido + Login + Idioma de contenido predeterminado + País de contenido predeterminado + Sistema por defecto + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + General + Descarga automatica + Descargar canción cuando se agrega a la biblioteca + Agregar canción automáticamente a la biblioteca + Agregue una canción a su biblioteca cuando termine de reproducirse + Expandir reproductor inferior al reproducir + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + Acerca de + Version de la app + diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 076197b35..df3ae8d89 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -1,132 +1,80 @@ - - - خانه - آهنگ‌ها - هنرمندها - آلبوم‌ها - لیست‌پخش‌ها - کاوش - تنظیمات - در حال پخش - گزارش خطا - - - ظاهر - پیروی از تم سیستم - رنگ تم - تم تاریک - روشن - خاموش - پیروی از سیستم - زبانه باز پیش‌فرض - Customize navigation tabs - Lyrics text position - Left - Center - Right - - محتوا - Login - زبان پیش‌فرض محتوا - کشور پیش‌فرض محتوا - فعال‌کردن پروکسی - نوع پروکسی - آدرس‌اینترنتی پروکسی - راه‌اندازی‌مجدد برای اعمال اثر - - پخش‌کننده و صدا - کیفیت صدا - خودکار - بالا - پایین - Persistent queue - Skip silence - Audio normalization - اکولایزر - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - عمومی - بارگیری خودکار - بارگیری آهنگ با اضافه‌شدن به کتاب‌خانه - افزودن خودکار آهنگ به کتاب‌خانه - افزودن آهنگ به کتاب‌خانه پس از اتمام پخش - گسترش پخش‌کننده‌ی پایین هنگام پخش - More actions in notification - Show add to library and like buttons - - حریم‌خصوصی - متوقف‌کردن تاریخچه جستجو - پاک‌کردن تاریخچه جستجو - آیا برای پاک‌کردن تمام سابقه جستجو مطمئن هستید؟ - Enable KuGou lyrics provider - - پشتیبان‌گیری و بازگردانی - پشتیبان‌گیری - بازگردانی - - درباره - نسخه‌ی برنامه + + + خانه + آهنگ‌ها + هنرمندها + آلبوم‌ها + لیست‌پخش‌ها + + + + %d انتخاب‌شده + %d انتخاب‌شده‌اند + - - ساکورا - قرمز - صورتی - بنفش - آبی تیره - نیلی - آبی - آبی روشن - فیروزه‌ای - سبزآبی - سبز - سبز روشن - لیمویی - زرد - کهربایی - نارنجی - نارنجی تیره - قهوه‌ای - خاکستری آبی + + History + Stats + New release albums + Most played songs - - جستجو + + جستجو جستجو در یوتیوب موزیک… جستجوی کتاب‌خانه… + همه + آهنگ‌ها + فیلم‌ها + آلبوم‌ها + هنرمندها + لیست‌پخش‌ها + لیست‌پخش‌های انجمن + لیست‌پخش‌های ویژه + No results found + + + از کتابخانه شما - + آهنگ‌های موردپسند آهنگ‌های بارگیری‌شده + The playlist is empty - - Details - ویرایش - راه‌اندازی رادیو - پخش - پخش بعدی - اضافه‌کردن به صف - اضافه‌کردن به کتاب‌خانه - بارگیری - حذف بارگیری - واردکردن لیست‌پخش - اضافه‌کردن به لیست‌پخش - مشاهده‌ی هنرمند - مشاهده‌ی آلبوم - نوسازی - هم‌رسانی - حذف - Search online - Choose other lyrics + + تلاش‌مجدد + رادیو + بُرزدن + + + Details + ویرایش + راه‌اندازی رادیو + پخش + پخش بعدی + اضافه‌کردن به صف + اضافه‌کردن به کتاب‌خانه + بارگیری + حذف بارگیری + واردکردن لیست‌پخش + اضافه‌کردن به لیست‌پخش + مشاهده‌ی هنرمند + مشاهده‌ی آلبوم + نوسازی + هم‌رسانی + حذف + Search online + Sync + + + تاریخ اضافه‌شده + نام + هنرمند + سال + تعداد آهنگ + طول + زمان پخش - Details Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume File size Unknown + در بریده‌دان کپی‌شد - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - ویرایش آهنگ + ویرایش آهنگ عنوان آهنگ هنرمند آهنگ عنوان آهنگ نمی تواند خالی باشد. هنرمند آهنگ نمی تواند خالی باشد. - ذخیره + ذخیره - ایجاد لیست‌پخش - نام لیست‌پخش + انتخاب لیست‌پخش + ویرایش لیست‌پخش + ایجاد لیست‌پخش + نام لیست‌پخش نام لیست‌پخش نمی تواند خالی باشد. - ویرایش هنرمند - نام هنرمند + ویرایش هنرمند + نام هنرمند نام هنرمند نمی تواند خالی باشد. - هنرمندهای تکراری - هنرمند %1$s درحال‌حاضر وجوددارد. - - انتخاب لیست‌پخش - - ویرایش لیست‌پخش - - انتخاب محتوای پشتیبان - انتخاب محتوای بازگردان - ترجیح‌ها - پایگاه‌داده - آهنگ‌های دانلودشده - پشتیبان باموفقیت ایجادشد - پشتیبان ایجاد نشد - بازیابی پشتیبان انجام‌نشد - - - پخش‌کننده موسیقی - بارگیری - - + %d آهنگ %d آهنگ‌ها - + %d هنرمند %d هنرمندها - + %d آلبوم %d آلبوم‌ها - + %d لیست‌پخش %d لیست‌پخش‌ها - - تلاش‌مجدد - پخش - پخش همه - رادیو - بُرزدن - رونوشت ردیابی‌پشته - گزارش - گزارش در گیت‌هاب - - - تاریخ اضافه‌شده - نام - هنرمند - سال - تعداد آهنگ - طول - زمان پخش - - - %d آهنگ حذف‌شده‌است. - %d آهنگ‌ها حذف‌شده‌اند. - - - %d انتخاب‌شده - %d انتخاب‌شده‌اند - - واگرد - نمی توان این آدرس‌اینترنتی را شناسایی کرد. - - %d آهنگ بعدی پخش‌خواهدشد - %d آهنگ‌ بعدی پخش‌خواهندشد - - - %d هنرمند پخش‌خواهدشد - %d هنرمند پخش‌خواهندشد - - - %d آلبوم پخش‌خواهدشد - %d آلبوم پخش‌خواهندشد - - - %d لیست‌پخش پخش‌خواهدشد - %d لیست‌پخش پخش‌خواهندشد - - انتخاب‌ها پخش‌خواهند شد - - %d آهنگ به‌صف اضافه‌شد - %d آهنگ به‌صف اضافه‌شدند - - - %d هنرمند به‌صف اضافه‌شد - %d هنرمند به‌صف اضافه‌شدند - - - %d آلبوم به‌صف اضافه‌شد - %d آلبوم به‌صف اضافه شدند - - - %d لیست‌پخش به‌صف اضافه‌شد - %d لیست‌پخش‌ها به‌صف اضافه‌شد - - انتخاب‌شده به صف اضافه‌شد - به کتاب‌خانه اضافه‌شد - حذف از کتاب‌خانه لیست‌پخش واردشد - اضافه‌شده به %1$s - - شروع به‌بارگیری شد %d آهنگ - شروع به‌بارگیری شدند %d ‌آهنگ‌های + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - حذف بارگیری - نما + No stream available + No network connection + Timeout + Unknown error - + پسندیدن حذف پسندیدن اضافه‌کردن به کتاب‌خانه حذف از کتاب‌خانه - - همه - آهنگ‌ها - فیلم‌ها - آلبوم‌ها - هنرمندها - لیست‌پخش‌ها - لیست‌پخش‌های انجمن - لیست‌پخش‌های ویژه - - پیش فرض سیستم - از کتابخانه شما - - - متاسفم، نباید این اتفاق می افتاد. - در بریده‌دان کپی‌شد - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + پخش‌کننده موسیقی - - No results found + + تنظیمات + ظاهر + تم تاریک + روشن + خاموش + پیروی از سیستم + زبانه باز پیش‌فرض + Customize navigation tabs + Lyrics text position + Left + Center + Right + + محتوا + Login + زبان پیش‌فرض محتوا + کشور پیش‌فرض محتوا + پیش فرض سیستم + فعال‌کردن پروکسی + نوع پروکسی + آدرس‌اینترنتی پروکسی + راه‌اندازی‌مجدد برای اعمال اثر + + پخش‌کننده و صدا + کیفیت صدا + خودکار + بالا + پایین + Persistent queue + Skip silence + Audio normalization + اکولایزر + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + عمومی + بارگیری خودکار + بارگیری آهنگ با اضافه‌شدن به کتاب‌خانه + افزودن خودکار آهنگ به کتاب‌خانه + افزودن آهنگ به کتاب‌خانه پس از اتمام پخش + گسترش پخش‌کننده‌ی پایین هنگام پخش + More actions in notification + Show add to library and like buttons + + حریم‌خصوصی + متوقف‌کردن تاریخچه جستجو + پاک‌کردن تاریخچه جستجو + آیا برای پاک‌کردن تمام سابقه جستجو مطمئن هستید؟ + Enable KuGou lyrics provider + + پشتیبان‌گیری و بازگردانی + پشتیبان‌گیری + بازگردانی + پشتیبان باموفقیت ایجادشد + پشتیبان ایجاد نشد + بازیابی پشتیبان انجام‌نشد + + درباره + نسخه‌ی برنامه diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 0d4a5ba5b..253c3dcaa 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -1,132 +1,80 @@ - - Home - Kappaleet - Artistit - Albums - Soittolistat - Tutustu - Asetukset - Nyt soi - Error report - - - Ulkoasu - Järjestelmän teeman mukainen - Teeman väri - Tumma teema - Käytössä - Pois käytöstä - Järjestelmän teeman mukainen - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Sisältö - Login - Sisällön oletuskieli - Sisällön oletusmaa - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Automaattinen lataus - Lataa kirjastoon lisätyt kappaleet automaattisesti - Automaattinen vienti kirjastoon - Lisää kappale kirjastoon automaattisesti, kun se on soitettu - Laajenna alapalkki toistaessa - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - Tietoa - Sovelluksen versio + + Home + Kappaleet + Artistit + Albums + Soittolistat + + + + %d valittu + %d kappaletta valittu + - - Sakura - Punainen - Pinkki - Violetti - Syvä violetti - Indigo - Sininen - Vaaleansininen - Syaani - Sinappi - Vihreä - Vaalean vihreä - Lime - Keltainen - Meripihka - Oranssi - Syvä oranssi - Ruskea - Sinharmaa + + History + Stats + New release albums + Most played songs - - Etsi + + Etsi Search YouTube Music… Search library… + Kaikki + Kappaleet + Videot + Albumit + Artistit + Soittolistat + Community playlists + Featured playlists + No results found + + + From your library - + Liked songs Downloaded songs + The playlist is empty - - Details - Muokkaa - Start radio - Play - Toista seuraavaksi - Lisää jonoon - Lisää soittolistaan - Lataa - Poista lataus - Import playlist - Lisää kirjastoon - View artist - View album - Refetch - Share - Poista - Search online - Choose other lyrics + + Toisto + Radio + Sekoita + + + Details + Muokkaa + Start radio + Play + Toista seuraavaksi + Lisää jonoon + Lisää kirjastoon + Lataa + Poista lataus + Import playlist + Lisää soittolistaan + View artist + View album + Refetch + Share + Poista + Search online + Sync + + + Lisäyspäivä + Nimi + Artisti + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Muokkaa kappaletta + Muokkaa kappaletta Kappaleen nimi Artisti Kappaleen nimi ei voi olla tyhjä. Artistin nimi ei voi olla tyhjä. - Tallenna + Tallenna - Luo soittolista - Soittolistan nimi + Valitse soittolista + Muokkaa soittolistaa + Luo soittolista + Soittolistan nimi Soittolistan nimi ei voi olla tyhjä. - Muokkaa artistia - Artistin nimi + Muokkaa artistia + Artistin nimi Artistin nimi ei voi olla tyhjä. - Artistin kaksoiskappale - Artisti %1$s on jo olemassa. - - Valitse soittolista - - Muokkaa soittolistaa - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Soitin - Lataa - - + %d kappale %d kappaletta - + %d artist %d artists - + %d album %d albums - + %d playlist %d playlists - - Toisto - Play - Toista kaikki - Radio - Sekoita - Copy stacktrace - Report - Report on GitHub - - - Lisäyspäivä - Nimi - Artisti - Year - Song count - Length - Play time - - - %d kappale poistettu. - %d kappaletta poistettu. - - - %d valittu - %d kappaletta valittu - - Kumoa - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like Lisää kirjastoon Remove from library - - Kaikki - Kappaleet - Videot - Albumit - Artistit - Soittolistat - Community playlists - Featured playlists - - Järjestelmän oletus - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + Soitin - - No results found + + Asetukset + Ulkoasu + Tumma teema + Käytössä + Pois käytöstä + Järjestelmän teeman mukainen + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Sisältö + Login + Sisällön oletuskieli + Sisällön oletusmaa + Järjestelmän oletus + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + General + Automaattinen lataus + Lataa kirjastoon lisätyt kappaleet automaattisesti + Automaattinen vienti kirjastoon + Lisää kappale kirjastoon automaattisesti, kun se on soitettu + Laajenna alapalkki toistaessa + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + Tietoa + Sovelluksen versio diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 5dd3fc656..391ec3621 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -1,132 +1,81 @@ - - - Home - Chansons - Artistes - Albums - Listes de lecture - Explore - Paramètres - Lecture en cours - Error report - - - Appearance - Follow system theme - Theme color - Dark theme - On - Off - Lyrics text position - Default open tab - Customize navigation tabs - Follow system - Left - Center - Right - - Content - Login - Langue du contenu par défaut - Pays du contenu par défaut - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Téléchargement automatique - Télécharger la chanson lorsqu\'elle est ajoutée à la bibliothèque - Ajout automatique de chansons à la bibliothèque - Add song to your library when it completes playing - Ajouter la chanson à votre bibliothèque lorsqu\'elle est terminée. - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - À propos - Version de l\'application + + + Home + Chansons + Artistes + Albums + Listes de lecture + + + + %d sélectionné + %d sélectionné + %d sélectionné + - - Sakura - Rouge - Rose - Violet - Violet foncé - Indigo - Bleu - Bleu clair - Cyan - Turquoise - Vert - Vert clair - Vert citron - Jaune - Ambre - Orange - Orange foncé - Marron - Bleu grisâtre + + History + Stats + New release albums + Most played songs - - Recherche + + Recherche Search YouTube Music… Search library… + Tout + Chansons + Vidéos + Albums + Artistes + Listes de lecture + Community playlists + Featured playlists + No results found + + + From your library - + Liked songs Downloaded songs + The playlist is empty - - Details - Modifier - Start radio - Play - Jouer à la suite - Ajouter à la file d\'attente - Ajouter à la bibliothèque - Télécharger - Supprimer le téléchargement - Import playlist - Ajouter à la liste de lecture - View artist - View album - Refetch - Share - Effacer - Search online - Choose other lyrics + + Réessayer + Radio + Lecture aléatoire + + + Details + Modifier + Start radio + Play + Jouer à la suite + Ajouter à la file d\'attente + Ajouter à la bibliothèque + Télécharger + Supprimer le téléchargement + Import playlist + Ajouter à la liste de lecture + View artist + View album + Refetch + Share + Effacer + Search online + Sync + + + Date d\'ajout + Nom + Artiste + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -136,181 +85,148 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Editer la chanson + Editer la chanson Titre de la chanson Artiste de la chanson Le titre de la chanson ne peut pas être vide. L\'artiste de la chanson ne peut pas être vide. - Sauvegarder + Sauvegarder - Créer une liste de lecture - Nom de la liste de lecture + Choisir la liste de lecture + Editer la liste de lecture + Créer une liste de lecture + Nom de la liste de lecture Le nom de la liste de lecture ne peut pas être vide. - Editer l\'artiste - Nom de l\'artiste + Editer l\'artiste + Nom de l\'artiste Le nom de l\'artiste ne peut être vide. - Duplicate artists - Artist %1$s already exists. - - Choisir la liste de lecture - - Editer la liste de lecture - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Lecteur de musique - Télécharger - - + %d chanson %d chansons %d chansons - + %d artist %d artists %d artists - + %d album %d albums %d albums - + %d playlist %d playlists %d albums - - Réessayer - Play - Tout jouer - Radio - Lecture aléatoire - Copy stacktrace - Report - Report on GitHub - - - Date d\'ajout - Nom - Artiste - Year - Song count - Length - Play time - - - %d chanson a été supprimée. - %d chansons ont été supprimées. - - - %d sélectionné - %d sélectionné - %d sélectionné - - Annuler - Can\'t identify this url. - - %d song will play next - %d songs will play next - - - %d artist will play next - %d artists will play next - - - %d album will play next - %d albums will play next - - - %d playlist will play next - %d playlists will play next - - Selected will play next - - %d song added to queue - %d songs added to queue - - - %d artist added to queue - %d artists added to queue - - - %d album added to queue - %d albums added to queue - - - %d playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading %d song - Start downloading %d songs - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like Ajouter à la bibliothèque Remove from library - - Tout - Chansons - Vidéos - Albums - Artistes - Listes de lecture - Community playlists - Featured playlists - - Système par défaut - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + Lecteur de musique - - No results found + + Paramètres + Appearance + Dark theme + On + Off + Follow system + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Content + Login + 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 + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + General + Téléchargement automatique + Télécharger la chanson lorsqu\'elle est ajoutée à la bibliothèque + Ajout automatique de chansons à la bibliothèque + Add song to your library when it completes playing + Ajouter la chanson à votre bibliothèque lorsqu\'elle est terminée. + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + À propos + Version de l\'application diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 46f1d985a..ce1a0a27e 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -1,132 +1,80 @@ - - Kezdőlap - Dalok - Előadók - Albumok - Listák - Fedezd fel - Beállítások - Most játszva - Hibajelentés - - - Megjelenés - Rendszertéma szerint - Téma szín - Sötét téma - Be - Ki - Rendszer szerint - Alapért. nyitott lap - A navigációs lapok testreszabása - Dalszöveg szöveg pozíció - Balra - Középre - Jobbra - - Tartalom - Bejelentkezés - A tartalom alapért. nyelve - A tartalom alapért.t országa - Proxy bekapcsolása - Proxy típusa - Proxy URL - Indítsa újra, hogy életbe lépjen - - Lejátszó és hang - Hangminőség - Auto - Magas - Gyenge - Állandó várósor - A csend átugrása - Hang normalizálása - Equalizer - - Tárhely - A letöltött fájlok megtekintése SAF-ben - Előfordulhat, hogy ez egyes eszközökön nem működik - Gyorsítótár - Max. kép gyorsítótár - Kép gyors.tár törlés - Max. dal gyorsítótár - %s használva - - Általános - Auto. letöltés - Töltse le a dalt, amikor hozzáadja a könyvtárhoz - Dal auto. hozzáadása a könyvtárhoz - Adja hozzá a dalt a könyvtárához, amikor a lejátszás befejeződött - Bontsa ki az alsó lejátszót lejátszás közben - További műveletek az értesítésben - Könyvtárhoz adás és tetszés gombok megjelenítése - - Adatvédelem - Keresési előzmények szüneteltetése - Keresési előzmények törlése - Biztosan töröl minden keresési előzményt? - A KuGou dalszövegszolgáltató engedélyezése - - Bizt.mentés és visszaállítás - Bizt.mentés - Visszaállítás - - Rólunk - App verzió + + Kezdőlap + Dalok + Előadók + Albumok + Listák + + + + %d kijelölve + %d kijelölve + - - Szakura - Vörös - Pink - Bíbor - Mély bíbor - Indigókék - Kék - Világoskék - Cián - Zöldeskék - Zöld - Világoszöld - Lime - Sárga - Borostyán - Narancs - Sötét narancs - Barna - Kékesszürke + + History + Stats + New release albums + Most played songs - - Keresés + + Keresés YouTube Music keresés… Keresés könyvtárban… + Mind + Dalok + Videók + Albumok + Előadók + Listák + Közösségi listák + Kiemelt lejátszási listák + No results found + + + Saját könyvtárból - + Lájkolt dalok Letöltött dalok + The playlist is empty - - Részletek - Szerkeszt - Rádió indítás - Lejátszás - Következő - Várósorhoz ad - Könyvtárhoz ad - Letöltés - Letöltés eltávolítása - Lista importálása - Lejátszólistához ad - Előadó megtekintése - Album megtekintése - Újrahív - Megosztás - Törlés - Keresés online - Válasszon más dalszövegeket + + Újra + Rádió + Keverés + + + Részletek + Szerkeszt + Rádió indítás + Lejátszás + Következő + Várósorhoz ad + Könyvtárhoz ad + Letöltés + Letöltés eltávolítása + Lista importálása + Lejátszólistához ad + Előadó megtekintése + Album megtekintése + Újrahív + Megosztás + Törlés + Keresés online + Sync + + + Létrehozás dátuma + Név + Előadó + Év + Dal szám + Hossz + Lejátszási idő - Részletek Média id MIME típus Kodekek @@ -136,175 +84,143 @@ Hangerő Fájl méret Ismeretlen + Másolva a vágólapra - Dalszöveg szerkesztése - Dalszöveg keresése - Válassza ki a dalszövegeket + Dalszöveg szerkesztése + Dalszöveg keresése - Dal szerkesztése + Dal szerkesztése Dal címe Dal előadói A cím nem lehet üres. Az előadó nem lehet üres. - Ment + Ment - Lista létrehozás - Lista neve + Válasszon lejátszási listát + Lejátszólista szerkesztés + Lista létrehozás + Lista neve A lejátszólista neve nem lehet üres. - Előadó szerkesztése - Előadó neve + Előadó szerkesztése + Előadó neve Az előadó neve nem lehet üres. - Duplikált előadók - %1$s előadó már létezik. - - Válasszon lejátszási listát - - Lejátszólista szerkesztés - - Válasszon biztonsági másolatot - Válasszon visszaállítható tartalmat - Preferenciák - Adatbázis - Letöltött dalok - A bizt.mentés sikeresen létrehozva - Nem sikerült biztonsági mentést készíteni - Nem sikerült visszaállítani a biztonsági másolatot - - - Zenelejátszó - Letöltés - - + %d dal %d dal - + %d előadó %d előadó - + %d album %d album - + %d lejátszólista %d lejátszólista - - Újra - Lejátszás - Mind lejátszása - Rádió - Keverés - Veremkövetés másolása - Jelentés - Jelentés GitHub-on - - - Létrehozás dátuma - Név - Előadó - Év - Dal szám - Hossz - Lejátszási idő - - - %d dal törölve. - %d dal törölve. - - - %d kijelölve - %d kijelölve - - Visszavon - Az URL nem azonosítható. - - dal szól legközelebb - %d dal szól legközelebb - - - előadó szól legközelebb - %d előadó szól legközelebb - - - album szól legközelebb - %d album szól legközelebb - - - lista szól legközelebb - %d lista szól legközelebb - - A kiválasztottak szólnak legközelebb - - Dal hozzáadva a sorhoz - %d dal hozzáadva a sorhoz - - - Előadó hozzáadva a sorhoz - %d előadó hozzáadva a sorhoz - - - Album hozzáadva a sorhoz - %d album hozzáadva a sorhoz - - - Lista hozzáadva a sorhoz - %d lista sorhoz adva - - A kiválasztott hozzáadva a sorhoz - Könyvtárhoz adva - Eltávolítva könyvtárból Lista importálva - Hozzáadva ide, %1$s - - Dal letöltés indítása - Kezdje el %d dal letöltését + + + A dalszöveg nem található + Sleep timer + End of song + + 1 minute + %d minutes - Eltávolított letöltés - Nézet + Nincs elérhető adatfolyam + Nincs hálózati kapcsolat + Időn túl + Ismeretlen hiba - + Tetszik Lájk eltávolítása Könyvtárhoz ad Eltávolít a könyvtárból - - Mind - Dalok - Videók - Albumok - Előadók - Listák - Közösségi listák - Kiemelt lejátszási listák - - Rendszer alapérték - Saját könyvtárból - - - Sajnálom, ennek nem lett volna szabad megtörténnie. - Másolva a vágólapra - - - Nincs elérhető adatfolyam - Nincs hálózati kapcsolat - Időn túl - Ismeretlen hiba - Minden dal Keresett dalok - - A dalszöveg nem található + + Zenelejátszó - - No results found + + Beállítások + Megjelenés + Sötét téma + Be + Ki + Rendszer szerint + Alapért. nyitott lap + A navigációs lapok testreszabása + Dalszöveg szöveg pozíció + Balra + Középre + Jobbra + + Tartalom + Bejelentkezés + A tartalom alapért. nyelve + A tartalom alapért.t országa + Rendszer alapérték + Proxy bekapcsolása + Proxy típusa + Proxy URL + Indítsa újra, hogy életbe lépjen + + Lejátszó és hang + Hangminőség + Auto + Magas + Gyenge + Állandó várósor + A csend átugrása + Hang normalizálása + Equalizer + + Tárhely + Gyorsítótár + Image Cache + Song Cache + Max cache size + Unlimited + Max. kép gyorsítótár + Kép gyors.tár törlés + Max. dal gyorsítótár + Clear song cache + %s használva + + Általános + Auto. letöltés + Töltse le a dalt, amikor hozzáadja a könyvtárhoz + Dal auto. hozzáadása a könyvtárhoz + Adja hozzá a dalt a könyvtárához, amikor a lejátszás befejeződött + Bontsa ki az alsó lejátszót lejátszás közben + További műveletek az értesítésben + Könyvtárhoz adás és tetszés gombok megjelenítése + + Adatvédelem + Keresési előzmények szüneteltetése + Keresési előzmények törlése + Biztosan töröl minden keresési előzményt? + A KuGou dalszövegszolgáltató engedélyezése + + Bizt.mentés és visszaállítás + Bizt.mentés + Visszaállítás + A bizt.mentés sikeresen létrehozva + Nem sikerült biztonsági mentést készíteni + Nem sikerült visszaállítani a biztonsági másolatot + + Rólunk + App verzió diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index f18b0938e..38e2a26fa 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -1,132 +1,79 @@ - - Beranda - Lagu - Artis - Album - Daftar putar - Jelajah - Pengaturan - Sedang diputar - Laporkan kesalahan - - - Tampilan - Ikuti tema sistem - Warna tema - Tema gelap - Aktif - Tidak aktif - Ikuti sistem - Tab buka bawaan - Sesuaikan tab navigasi - Posisi teks lirik - Kiri - Tengah - Kanan - - Konten - Masuk - Bahasa konten bawaan - Negara konten bawaan - Aktifkan proxy - Tipe proxy - URL proxy - Mulai ulang agar berlaku - - Pemutar dan audio - Kualitas audio - Otomatis - Tinggi - Rendah - Dalam antrian - Lewati keheningan - Normalisasi audio - Ekualiser - - Penyimpanan - Lihat file yang diunduh di SAF - Ini mungkin tidak berfungsi di beberapa perangkat - Data sementara (cache) - Ukuran gambar data sementara (cache) maksimum - Bersihkan gambar data sementara (cache) - Ukuran lagu data sementara (cache) maksimum - %s digunakan - - Umum - Unduh secara otomatis - Unduh lagu saat ditambahkan ke perpustakaan - Tambahkan lagu secara otomatis ke perpustakaan - Tambahkan lagu ke perpustakaan Anda saat selesai diputar - Perluas pemutar bawah saat diputar - Lebih banyak tindakan dalam pemberitahuan - Tampilkan tombol tambahkan ke perpustakaan dan suka - - Privasi - Jeda riwayat pencarian - Bersihkan riwayat pencarian - Apakah anda yakin untuk menghapus semua riwayat penelusuran? - Aktifkan penyedia lirik KuGou - - Cadangkan dan pulihkan - Cadangkan - Pulihkan - - Tentang - Versi aplikasi + + Beranda + Lagu + Artis + Album + Daftar putar + + + + %d dipilih + - - Sakura - Merah - Merah muda - Ungu - Ungu gelap - Nila - Biru - Biru terang - Biru Kehijau-hijauan - Hijau laut - Hijau - Hijau terang - Hijau kekuningan - Kuning - Kuning lulur - Jingga - Jingga gelap - Cokelat - Biru keabu-abuan + + History + Stats + New release albums + Most played songs - - Caru + + Caru Cari di YouTube Music… Cari di perpustakaan… - - + Semua + Lagu + Video + Album + Artis + Daftar putar + Daftar putar komunitas + Daftar putar unggulan + No results found + + + Dari perpustakaan anda + + Lagu yang disukai Lagu yang diunduh + The playlist is empty - - Rincian - Sunting - Putar radio - Putar - Putar selanjutnya - Tambahkan ke antrian - Tambahkan ke perpustakaan - Unduh - Hapus unduhan - Pindahkan ke daftar putar - Tambahkan ke daftar putar - Lihat artis - Lihat album - Ambil kembali - Bagikan - Hapus - Cari secara online - Pilih lirik lain + + Mencoba kembali + Radio + Acak + + + Rincian + Sunting + Putar radio + Putar + Putar selanjutnya + Tambahkan ke antrian + Tambahkan ke perpustakaan + Unduh + Hapus unduhan + Pindahkan ke daftar putar + Tambahkan ke daftar putar + Lihat artis + Lihat album + Ambil kembali + Bagikan + Hapus + Cari secara online + Sync + + + Tanggal ditambahkan + Nama + Artis + Tahun + Hitungan lagu + Ukuran + Waktu dimainkan - Rincian ID media Tipe MIME Codecs @@ -136,157 +83,138 @@ Volume Ukuran berkas Tidak diketahui + Disalin ke papan klip - Sunting lirik - Cari lirik - Pilih lirik + Sunting lirik + Cari lirik - Sunting lagu + Sunting lagu Judul lagu Artis lagu Judul lagu wajib diisi. Artis lagu wajib diisi. - Simpan + Simpan - Buat daftar putar - Nama daftar putar + Pilih daftar putar + Sunting daftar putar + Buat daftar putar + Nama daftar putar Nama daftar putar wajib diisi. - Sunting artis - Nama artis + Sunting artis + Nama artis Nama artis wajib diisi. - Artis duplikat - Artis %1$s sudah ada. - - Pilih daftar putar - - Sunting daftar putar - - Pilih cadangan konten - Pilih pulihkan konten - Preferensi - Basis Data - Lagu yang telah diunduh - Cadangan berhasil dibuat - Tidak dapat membuat cadangan - Gagal untuk memulihkan cadangan - - - Pemutar Musik - Unduh - - + %d lagu - + %d artis - + %d album - + %d daftar putar - - Mencoba kembali - Putar - Putar semua - Radio - Acak - Salin stacktrace - Laporkan - Laporkan di GitHub - - - Tanggal ditambahkan - Nama - Artis - Tahun - Hitungan lagu - Ukuran - Waktu dimainkan - - - %d lagu telah dihapus. - - - %d dipilih - - Kembali - Tidak dapat mengidentifikasi url ini. - - %d lagu akan diputar selanjutnya - - - %d artis akan diputar selanjutnya - - - %d album akan diputar selanjutnya - - - %d daftar putar akan diputar selanjutnya - - Dipilih akan diputar selanjutnya - - %d lagu ditambahkan ke antrian - - - %d artis ditambahkan ke antrian - - - %d album ditambahkan ke antrian - - - %d daftar putar ditambahkan ke antrian - - Dipilih ditambahkan ke antrean - Ditambahkan ke perpustakaan - Dihapus dari perpustakaan Daftar putar dipindahkan - Ditambahkan ke %1$s - - Mulai mengunduh %d lagu + + + Lirik tidak ditemukan + Sleep timer + End of song + + %d minutes - Unduhan dihapus - Lihat + Tidak ada stream yang tersedia + Tidak ada koneksi jaringan + Waktu habis + Kesalahan yang tidak diketahui - + Suka Hapus suka Tambahkan ke perpustakaan Hapus dari perpustakaan - - Semua - Lagu - Video - Album - Artis - Daftar putar - Daftar putar komunitas - Daftar putar unggulan - - System default - Dari perpustakaan anda - - - Maaf, itu seharusnya tidak terjadi. - Disalin ke papan klip - - - Tidak ada stream yang tersedia - Tidak ada koneksi jaringan - Waktu habis - Kesalahan yang tidak diketahui - Semua lagu Lagu yang telah dicari - - Lirik tidak ditemukan + + Pemutar Musik + + + Pengaturan + Tampilan + Tema gelap + Aktif + Tidak aktif + Ikuti sistem + Tab buka bawaan + Sesuaikan tab navigasi + Posisi teks lirik + Kiri + Tengah + Kanan + + Konten + Masuk + Bahasa konten bawaan + Negara konten bawaan + System default + Aktifkan proxy + Tipe proxy + URL proxy + Mulai ulang agar berlaku + + Pemutar dan audio + Kualitas audio + Otomatis + Tinggi + Rendah + Dalam antrian + Lewati keheningan + Normalisasi audio + Ekualiser + + Penyimpanan + Data sementara (cache) + Image Cache + Song Cache + Max cache size + Unlimited + Ukuran gambar data sementara (cache) maksimum + Bersihkan gambar data sementara (cache) + Ukuran lagu data sementara (cache) maksimum + Clear song cache + %s digunakan + + Umum + Unduh secara otomatis + Unduh lagu saat ditambahkan ke perpustakaan + Tambahkan lagu secara otomatis ke perpustakaan + Tambahkan lagu ke perpustakaan Anda saat selesai diputar + Perluas pemutar bawah saat diputar + Lebih banyak tindakan dalam pemberitahuan + Tampilkan tombol tambahkan ke perpustakaan dan suka + + Privasi + Jeda riwayat pencarian + Bersihkan riwayat pencarian + Apakah anda yakin untuk menghapus semua riwayat penelusuran? + Aktifkan penyedia lirik KuGou + + Cadangkan dan pulihkan + Cadangkan + Pulihkan + Cadangan berhasil dibuat + Tidak dapat membuat cadangan + Gagal untuk memulihkan cadangan + + Tentang + Versi aplikasi diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index a48f70568..1b71e7961 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,132 +1,80 @@ - - Home - Brani - Artisti - Album - Playlist - Esplora - Impostazioni - In ascolto ora - Segnala errore - - - Aspetto - Segui tema di sistema - Colore del tema - Tema scuro - Attivato - Disattivato - Segui sistema - Scheda principale predefinita - Personalizza schede di navigazione - Posizione del testo dei brani - Sinistra - Centro - Destra - - Contenuti - Login - Lingua predefinita dei contenuti - Paese predefinito dei contenuti - Attiva proxy - Tipo di proxy - URL proxy - Riavvia l\'app per applicare le modifiche - - Riproduttore e audio - Qualità dell\'audio - Automatica - Alta - Bassa - Coda persistente - Salta silenzio - Normalizzazione dell\'audio - Equalizzatore - - Archiviazione - Guarda file scaricati in SAF - Potrebbe non funzionare su alcuni dispositivi - Cache - Grandezza massima della cache delle immagini - Pulisci cache delle immagini - Grandezza massima della cache dei brani - %s usato - - Generale - Scaricamento automatico - Scarica la canzone quando viene aggiunta alla libreria - Aggiunta automatica nella libreria - Aggiungi la canzone nella tua libreria quando viene terminato l\'ascolto - Espandi il riproduttore in basso quando inizia un ascolto - Ulteriori azioni in notifica - Mostra i bottoni Aggiungi alla libreria e Mi piace - - Privacy - Sospendi la cronologia delle ricerche - Pulisci la cronologia delle ricerche - Sei sicuro di voler cancellare la cronologia delle ricerche? - Attiva i testi forniti da KuGou - - Backup e ripristina - Backup - Ripristina - - Informazioni - Versione dell\'app + + Home + Brani + Artisti + Album + Playlist + + + + %d selezionato + %d selezionati + - - Sakura - Rosso - Rosa - Viola - Viola scuro - Indaco - Blu - Azzurro - Ciano - Verde acqua - Verde - Verde chiaro - Lime - Giallo - Ambra - Arancione - Arancione scuro - Marrone - Blu grigio + + History + Stats + New release albums + Most played songs - - Cerca + + Cerca Cerca su YouTube Music… Cerca nella libreria… + Tutto + Brani + Video + Album + Artisti + Playlist + Playlist della comunità + Playlist in rilievo + No results found + + + Dalla tua libreria - + Brani piaciuti Brani scaricati + The playlist is empty + + + Riprova + Radio + Mischia + + + Dettagli + Modifica + Apri la radio + Riproduci + Riproduci come successiva + Aggiungi alla coda + Aggiungi alla libreria + Scarica + Rimuovi scaricamento + Importa playlist + Aggiungi alla playlist + Mostra artista + Mostra album + Riottieni + Condividi + Elimina + Cerca online + Sync + + + Data di aggiunta + Nome + Artista + Anno + Numero brani + Durata + Numero di riproduzioni - - Dettagli - Modifica - Apri la radio - Riproduci - Riproduci come successiva - Aggiungi alla coda - Aggiungi alla libreria - Scarica - Rimuovi scaricamento - Importa playlist - Aggiungi alla playlist - Mostra artista - Mostra album - Riottieni - Condividi - Elimina - Cerca online - Scegli un altro testo - - - Dettagli + ID media Tipo MIME Codec @@ -136,175 +84,143 @@ Volume Dimensioni Sconosciuto + Copiato negli appunti - Modifica testo - Cerca testo - Scegli testo + Modifica testo + Cerca testo - Modifica brano + Modifica brano Titolo Artista Il titolo del brano non può essere vuoto. L\'artista del brano non può essere vuoto. - Salva + Salva - Crea playlist - Nome della playlist + Scegli una playlist + Modifica playlist + Crea playlist + Nome della playlist Il nome della playlist non può essere vuoto. - Modifica artista - Nome dell\'artista + Modifica artista + Nome dell\'artista Il nome dell\'artista non può essere vuoto. - Artisti duplicati - L\'artista %1$s esiste già. - - Scegli una playlist - - Modifica playlist - - Scegli cosa salvare - Scegli cosa ripristinare - Preferenze - Database - Brani scaricati - Backup creato con successo - Impossibile fare il backup - Impossibile eseguire il ripristino dal backup - - - Riproduttore - Scarica - - - + + %d brano %d brani - + %d artista %d artisti - + %d album %d album - + %d playlist %d playlist - - Riprova - Riproduci - Riproduci tutti - Radio - Mischia - Copia stacktrace - Segnala - Segnala su GitHub - - - Data di aggiunta - Nome - Artista - Anno - Numero brani - Durata - Numero di riproduzioni - - - %d brano è stato eliminato. - %d brani sono stati eliminati. - - - %d selezionato - %d selezionati - - Annulla - È impossibile identificare questo URL. - - Il brano verrà riprodotto successivamente - %d brani verranno riprodotti successivamente - - - L\'artista verrà riprodotto successivamente - %d artisti verranno riprodotti successivamente - - - L\'album verrà riprodotto successivamente - %d album verranno riprodotti successivamente - - - La playlist verrà riprodotta successivamente - %d playlist verranno riprodotte successivamente - - I brani selezionati verranno riprodotti successivamente - - Brano aggiunto in coda - %d brani aggiunti in coda - - - Artista aggiunto in coda - %d artisti aggiunti in coda - - - Album aggiunto in coda - %d album aggiunti in coda - - - Playlist aggiunta in coda - %d playlist aggiunte in coda - - Selezionati aggiunti in coda - Aggiunto in libreria - Rimosso dalla libreria Playlist importata - Aggiunto in %1$s - - Inizio scaricamento di un brano - Inizio scaricamento di %d brani + + + Testo non trovato + Sleep timer + End of song + + 1 minute + %d minutes - Download rimosso - Guarda + Stream non disponibile + Nessuna connessione a internet presente + Tempo scaduto + Errore sconosciuto - + Mi piace Non mi piace più Aggiungi alla libreria Rimuovi dalla libreria - - Tutto - Brani - Video - Album - Artisti - Playlist - Playlist della comunità - Playlist in rilievo - - Predefinito di sistema - Dalla tua libreria - - - Mi dispiace ma questo non doveva succedere. - Copiato negli appunti - - - Stream non disponibile - Nessuna connessione a internet presente - Tempo scaduto - Errore sconosciuto - Tutti i brani Brani cercati - - Testo non trovato + + Riproduttore - - No results found + + Impostazioni + Aspetto + Tema scuro + Attivato + Disattivato + Segui sistema + Scheda principale predefinita + Personalizza schede di navigazione + Posizione del testo dei brani + Sinistra + Centro + Destra + + Contenuti + Login + Lingua predefinita dei contenuti + Paese predefinito dei contenuti + Predefinito di sistema + Attiva proxy + Tipo di proxy + URL proxy + Riavvia l\'app per applicare le modifiche + + Riproduttore e audio + Qualità dell\'audio + Automatica + Alta + Bassa + Coda persistente + Salta silenzio + Normalizzazione dell\'audio + Equalizzatore + + Archiviazione + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Grandezza massima della cache delle immagini + Pulisci cache delle immagini + Grandezza massima della cache dei brani + Clear song cache + %s usato + + Generale + Scaricamento automatico + Scarica la canzone quando viene aggiunta alla libreria + Aggiunta automatica nella libreria + Aggiungi la canzone nella tua libreria quando viene terminato l\'ascolto + Espandi il riproduttore in basso quando inizia un ascolto + Ulteriori azioni in notifica + Mostra i bottoni Aggiungi alla libreria e Mi piace + + Privacy + Sospendi la cronologia delle ricerche + Pulisci la cronologia delle ricerche + Sei sicuro di voler cancellare la cronologia delle ricerche? + Attiva i testi forniti da KuGou + + Backup e ripristina + Backup + Ripristina + Backup creato con successo + Impossibile fare il backup + Impossibile eseguire il ripristino dal backup + + Informazioni + Versione dell\'app diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index f9a3a89ca..6e9ffec5b 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1,132 +1,79 @@ - - - ホーム - - アーティスト - アルバム - プレイリスト - 探索 - 設定 - 再生中 - エラーレポート - - - 外観 - システムのテーマに従う - テーマカラー - ダークテーマ - オン - オフ - システムに従う - 起動時に開くタブ - ナビゲーションタブのカスタマイズ - 歌詞テキストの位置 - - 中央 - - - コンテンツ - ログイン - デフォルトのコンテンツの言語 - デフォルトのコンテンツの国 - プロキシを有効化 - プロキシのタイプ - プロキシのURL - 再起動して反映 - - プレイヤーとオーディオ - オーディオ品質 - 自動 - - - 再生キューを保持 - 無音部分をスキップ - オーディオノーマライゼーション - イコライザー - - ストレージ - SAFにダウンロードされたファイルを表示 - 一部の端末では動作しない場合があります - キャッシュ - 画像の最大キャッシュサイズ - 画像のキャッシュを削除 - 曲の最大キャッシュサイズ - %s 使用中 - - 一般 - 自動ダウンロード - ライブラリに追加したら曲をダウンロードする - 曲を自動でライブラリに追加 - 再生が終了したら曲をライブラリに追加する - プレイヤーを再生時に展開 - 通知にもっとアクションを表示 - 「ライブラリに追加」と「いいね」ボタンを表示する - - プライバシー - 履歴の記録を一時停止 - 履歴を削除 - すべての検索履歴を削除しますか? - 酷狗からの歌詞取得を有効化 - - バックアップとリストア - バックアップ - リストア - - アプリについて - アプリのバージョン + + + ホーム + + アーティスト + アルバム + プレイリスト + + + + %dを選択済み + - - サクラ - レッド - ピンク - パープル - ディープパープル - インディゴ - ブルー - ライトブルー - シアン - ティール - グリーン - ライトグリーン - ライム - イエロー - アンバー - オレンジ - ディープオレンジ - ブラウン - ブルーグレー + + History + Stats + New release albums + Most played songs - - 検索 + + 検索 YouTube Musicを検索… ライブラリを検索… + すべて + + 動画 + アルバム + アーティスト + プレイリスト + コミュニティのプレイリスト + 注目のプレイリスト + No results found - + + ライブラリから + + いいねした曲 ダウンロードした曲 + The playlist is empty - - Details - 編集 - ラジオを再生 - 再生 - 次に再生 - キューに追加 - ライブラリに追加 - ダウンロード - ダウンロードを削除 - プレイリストをインポート - プレイリストに追加 - アーティストを見る - アルバムを見る - 再取得 - 共有 - 削除 - オンラインで検索 - ほかの歌詞を選択 + + 再試行 + ラジオ + シャッフル + + + Details + 編集 + ラジオを再生 + 再生 + 次に再生 + キューに追加 + ライブラリに追加 + ダウンロード + ダウンロードを削除 + プレイリストをインポート + プレイリストに追加 + アーティストを見る + アルバムを見る + 再取得 + 共有 + 削除 + オンラインで検索 + Sync + + + 追加日時 + 曲名 + アーティスト + リリース年 + 曲数 + 長さ + 再生時間 - 詳細 メディアID MIMEタイプ コーデック @@ -136,169 +83,138 @@ ボリューム ファイルサイズ 不明 + クリップボードにコピー - 歌詞を編集 - 歌詞を検索 - 歌詞を選択 + 歌詞を編集 + 歌詞を検索 - 曲を編集 + 曲を編集 曲名 曲のアーティスト 曲名は空白にできません。 曲のアーティストは空白にできません。 - 保存 + 保存 - プレイリストを作成 - プレイリスト名 + プレイリストを選択 + プレイリストを編集 + プレイリストを作成 + プレイリスト名 プレイリスト名は空白にできません。 - アーティストを編集 - アーティスト名 + アーティストを編集 + アーティスト名 アーティスト名は空白にできません。 - アーティスト名が重複しています - アーティスト %1$s は既に存在します。 - - プレイリストを選択 - - プレイリストを編集 - - バックアップするコンテンツを選択 - リストアするコンテンツを選択 - 設定 - データベース - ダウンロードした音楽 - バックアップの作成に成功しました - バックアップを作成できませんでした - バックアップのリストアに失敗しました - - - 音楽プレイヤー - ダウンロード - - + %d曲 - + %d アーティスト - + %d アルバム - + %d プレイリスト - - 再試行 - 再生 - すべて再生 - ラジオ - シャッフル - スタックトレースをコピー - 報告 - GitHubで報告 - - - 追加日時 - 曲名 - アーティスト - リリース年 - 曲数 - 長さ - 再生時間 - - - %d曲が削除されました。 - - - %dを選択済み - - 元に戻す - URLを認識できませんでした。 - - この曲が次に再生されます - %d 曲が次に再生されます - - - このアーティストが次に再生されます - %d アーティストが次に再生されます - - - このアルバムが次に再生されます - %d アルバムが次に再生されます - - - このプレイリストが次に再生されます - %d プレイリストが次に再生されます - - 選択内容が次に再生されます - - この曲がキューに追加されました - %d 曲がキューに追加されました - - - このアーティストがキューに追加されました - %d アーティストがキューに追加されました - - - このアルバムがキューに追加されました - %d アルバムがキューに追加されました - - - このプレイリストがキューに追加されました - %d プレイリストがキューに追加されました - - 選択内容がキューに追加されます - ライブラリに追加しました - ライブラリから削除しました プレイリストをインポートしました - %1$s に追加しました - - 曲のダウンロードを開始 - %d 曲のダウンロードを開始 + + + 歌詞が見つかりません + Sleep timer + End of song + + %d minutes - ダウンロードを削除しました - 見る + ストリームが利用できません + ネットワーク接続がありません + タイムアウトしました + 不明なエラーです - + いいね いいねを削除 ライブラリに追加 ライブラリから削除 - - すべて - - 動画 - アルバム - アーティスト - プレイリスト - コミュニティのプレイリスト - 注目のプレイリスト - - システムに従う - ライブラリから - - - 申し訳ありませんが、エラーが発生しました。 - クリップボードにコピー - - - ストリームが利用できません - ネットワーク接続がありません - タイムアウトしました - 不明なエラーです - すべての曲 検索した曲 - - 歌詞が見つかりません + + 音楽プレイヤー - - No results found + + 設定 + 外観 + ダークテーマ + オン + オフ + システムに従う + 起動時に開くタブ + ナビゲーションタブのカスタマイズ + 歌詞テキストの位置 + + 中央 + + + コンテンツ + ログイン + デフォルトのコンテンツの言語 + デフォルトのコンテンツの国 + システムに従う + プロキシを有効化 + プロキシのタイプ + プロキシのURL + 再起動して反映 + + プレイヤーとオーディオ + オーディオ品質 + 自動 + + + 再生キューを保持 + 無音部分をスキップ + オーディオノーマライゼーション + イコライザー + + ストレージ + キャッシュ + Image Cache + Song Cache + Max cache size + Unlimited + 画像の最大キャッシュサイズ + 画像のキャッシュを削除 + 曲の最大キャッシュサイズ + Clear song cache + %s 使用中 + + 一般 + 自動ダウンロード + ライブラリに追加したら曲をダウンロードする + 曲を自動でライブラリに追加 + 再生が終了したら曲をライブラリに追加する + プレイヤーを再生時に展開 + 通知にもっとアクションを表示 + 「ライブラリに追加」と「いいね」ボタンを表示する + + プライバシー + 履歴の記録を一時停止 + 履歴を削除 + すべての検索履歴を削除しますか? + 酷狗からの歌詞取得を有効化 + + バックアップとリストア + バックアップ + リストア + バックアップの作成に成功しました + バックアップを作成できませんでした + バックアップのリストアに失敗しました + + アプリについて + アプリのバージョン diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 2f88e132a..28dbe335f 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -1,132 +1,79 @@ - - Home - 노래 - 아티스트 - Albums - 플레이리스트 - 탐색 - 설정 - 재생 중 - Error report - - - 모양 - 시스템 테마 - 테마 색상 - 다크 테마 - - - 시스템 - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - 콘텐츠 - Login - 기본 콘텐츠 언어 - 기본 콘텐츠 국가 - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - 자동 다운로드 - 라이브러리에 추가할 때 노래 다운로드 - 라이브러리에 노래 자동 추가 - 재생이 완료되면 라이브러리에 노래 추가 - 플레이 시 하단 플레이어 확장 - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - 정보 - 앱 버전 + + Home + 노래 + 아티스트 + Albums + 플레이리스트 + + + + %d 선택됨 + - - 사쿠라 - 빨강 - 핑크 - 보라 - 짙은 보라 - 남색 - 파랑 - 라이트 블루 - 청록색 - 암청색 - 초록 - 연두 - 라임 - 노랑 - 호박색 - 주황색 - 딥 오렌지 - 갈색 - 블루 그레이 + + History + Stats + New release albums + Most played songs - - 검색 + + 검색 Search YouTube Music… Search library… + 모두 + 노래 + 비디오 + 앨범 + 아티스트 + 플레이리스트 + Community playlists + Featured playlists + No results found - + + From your library + + Liked songs Downloaded songs + The playlist is empty - - Details - 수정 - Start radio - Play - 다음 노래 재생 - 목록에 추가 - 라이브러리에 추가 - 다운로드 - 다운로드 제거 - Import playlist - 플레이리스트에 추가 - View artist - View album - Refetch - Share - 삭제 - Search online - Choose other lyrics + + 다시 + Radio + 셔플 + + + Details + 수정 + Start radio + Play + 다음 노래 재생 + 목록에 추가 + 라이브러리에 추가 + 다운로드 + 다운로드 제거 + Import playlist + 플레이리스트에 추가 + View artist + View album + Refetch + Share + 삭제 + Search online + Sync + + + 추가된 날짜 + 이름 + 아티스트 + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -136,169 +83,138 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - 노래 편집 + 노래 편집 노래 제목 노래 아티스트 노래 제목은 비워둘 수 없습니다. 노래 아티스트는 비워둘 수 없습니다. - 저장 + 저장 - 플레이리스트 만들기 - 플레이리스트 이름 + 플레이리스트 선택 + 플레이리스트 수정 + 플레이리스트 만들기 + 플레이리스트 이름 플레이리스트 이름은 비워둘 수 없습니다. - 아티스트 수정 - 아티스트 이름 + 아티스트 수정 + 아티스트 이름 아티스트 이름은 비워둘 수 없습니다. - 중복 아티스트 - 아티스트 %1$s이(가) 이미 존재합니다. - - 플레이리스트 선택 - - 플레이리스트 수정 - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - 음악 플레이어 - 다운로드 - - + %d 노래 - + %d artists - + %d albums - + %d playlists - - 다시 - Play - 모두 재생 - Radio - 셔플 - Copy stacktrace - Report - Report on GitHub - - - 추가된 날짜 - 이름 - 아티스트 - Year - Song count - Length - Play time - - - %d 노래가 삭제되었습니다. - - - %d 선택됨 - - 되돌리기 - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like 라이브러리에 추가 Remove from library - - 모두 - 노래 - 비디오 - 앨범 - 아티스트 - 플레이리스트 - Community playlists - Featured playlists - - 시스템 기본값 - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + 음악 플레이어 - - No results found + + 설정 + 모양 + 다크 테마 + + + 시스템 + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + 콘텐츠 + Login + 기본 콘텐츠 언어 + 기본 콘텐츠 국가 + 시스템 기본값 + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + General + 자동 다운로드 + 라이브러리에 추가할 때 노래 다운로드 + 라이브러리에 노래 자동 추가 + 재생이 완료되면 라이브러리에 노래 추가 + 플레이 시 하단 플레이어 확장 + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + 정보 + 앱 버전 diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index fb4cc6f56..77ab2b734 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -1,132 +1,80 @@ - - ഹോം - പാട്ടുകൾ - കലാകാരന്മാർ - ആൽബങ്ങൾ - പ്ലേലിസ്റ്റുകൾ - ആരായുക - ക്രമീകരണങ്ങൾ - ഇപ്പോൾ പ്ലേ ചെയ്യുന്നത് - ക്രാഷ് റിപ്പോർട്ട് - - - രൂപഭംഗി - സിസ്റ്റം തീം പിന്തുടരുക - തീം നിറം - ഡാർക്ക് തീം - ഓൺ - ഓഫ് - സിസ്റ്റം പിന്തുടരുക - സ്ഥിര ഓപ്പൺ ടാബ് - Customize navigation tabs - Lyrics text position - Left - Center - Right - - കന്റെന്റ് - Login - സ്ഥിര കന്റെന്റ് ഭാഷ - സ്ഥിര കന്റെന്റ് രാജ്യം - പ്രോക്സി പ്രവർത്തനക്ഷമമാക്കുക - പ്രോക്സി തരം - പ്രോക്സി URL - പ്രാബല്യത്തിൽ വരാൻ പുനരാരംഭിക്കുക - - പ്ലെയറും ഓഡിയോയും - ഓഡിയോ നിലവാരം - ഓട്ടോ - ഉയർന്ന - താഴ്ന്ന - Persistent queue - Skip silence - Audio normalization - ഇക്വലൈസർ - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - പൊതുവായ - യാന്ത്രിക ഡൗൺലോഡ് - ലൈബ്രറിയിൽ ചേർക്കുമ്പോൾ പാട്ട് ഡൗൺലോഡ് ചെയ്യുക - ലൈബ്രറിയിലേക്ക് പാട്ട് സ്വയമേവ ചേർക്കുക - നിങ്ങളുടെ ലൈബ്രറി പ്ലേ ചെയ്യുമ്പോൾ പാട്ട് ചേർക്കുക - താഴെയുള്ള പ്ലേയർ വികസിപ്പിക്കുക - More actions in notification - Show add to library and like buttons - - സ്വകാര്യത - തിരയൽ ചരിത്രം താൽക്കാലികമായി നിർത്തുക - തിരയൽ ചരിത്രം മായ്‌ക്കുക - എല്ലാ തിരയൽ ചരിത്രവും മായ്‌ക്കണമെന്ന് ഉറപ്പാണോ? - Enable KuGou lyrics provider - - ബാക്കപ്പും വീണ്ടെടുക്കലും - ബാക്കപ്പ് - വീണ്ടെടുക്കൽ - - കുറിച്ച് - അപ്ലിക്കേഷൻ പതിപ്പ് + + ഹോം + പാട്ടുകൾ + കലാകാരന്മാർ + ആൽബങ്ങൾ + പ്ലേലിസ്റ്റുകൾ + + + + %d തിരഞ്ഞെടുത്തു + %d തിരഞ്ഞെടുത്തു + - - സകുറ - ചുവപ്പ് - പിങ്ക് - പർപ്പിൾ - കടും പർപ്പിൾ - ഇൻഡിഗോ - നീല - ഇളം നീല - സിയാൻ - ടീൽ - പച്ച - ഇളം പച്ച - നാരങ്ങ - മഞ്ഞ - ആമ്പർ - ഓറഞ്ച് - കടും ഓറഞ്ച് - തവിട്ട് - നീല ചാരനിറം + + History + Stats + New release albums + Most played songs - - തിരയുക + + തിരയുക യൂട്യൂബ് സംഗീതം തിരയുക… തിരയൽ ലൈബ്രറി… + എല്ലാം + പാട്ടുകൾ + വീഡിയോകൾ + ആൽബങ്ങൾ + ആർട്ടിസ്റ്റുകൾ + പ്ലേലിസ്റ്റുകൾ + കമ്മ്യൂണിറ്റി പ്ലേലിസ്റ്റുകൾ + തിരഞ്ഞെടുത്ത പ്ലേലിസ്റ്റുകൾ + No results found + + + നിങ്ങളുടെ ലൈബ്രറിയിൽ നിന്ന് - + ഇഷ്ടപ്പെട്ട പാട്ടുകൾ ഡൗൺലോഡ് ചെയ്‌ത പാട്ടുകൾ + The playlist is empty - - Details - എഡിറ്റ് ചെയ്യുക - റേഡിയോ ആരംഭിക്കുക - പ്ലേ ചെയ്യുക - അടുത്തത് പ്ലേ ചെയ്യുക - ക്യൂവിൽ ചേർക്കുക - ലൈബ്രറിയിലേക്ക് ചേർക്കുക - ഡൗൺലോഡ് - ഡൗൺലോഡ് നീക്കം ചെയ്യുക - പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്യുക - പ്ലേലിസ്റ്റിൽ ആഡ് ചെയ്യുക - കലാകാരനെ കാണുക - ആൽബം കാണുക - വീണ്ടെടുക്കുക - പങ്കിടുക - ഡിലീറ്റ് - Search online - Choose other lyrics + + വീണ്ടും ശ്രമിക്കുക + റേഡിയോ + ഷഫിൾ + + + Details + എഡിറ്റ് ചെയ്യുക + റേഡിയോ ആരംഭിക്കുക + പ്ലേ ചെയ്യുക + അടുത്തത് പ്ലേ ചെയ്യുക + ക്യൂവിൽ ചേർക്കുക + ലൈബ്രറിയിലേക്ക് ചേർക്കുക + ഡൗൺലോഡ് + ഡൗൺലോഡ് നീക്കം ചെയ്യുക + പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്യുക + പ്ലേലിസ്റ്റിൽ ആഡ് ചെയ്യുക + കലാകാരനെ കാണുക + ആൽബം കാണുക + വീണ്ടെടുക്കുക + പങ്കിടുക + ഡിലീറ്റ് + Search online + Sync + + + ചേർത്ത തീയതി + പേര് + ആർട്ടിസ്റ്റ് + വർഷം + പാട്ടുകളുടെ എണ്ണം + ദൈർഘ്യം + Play time - Details Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume File size Unknown + ്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - ഗാനം എഡിറ്റ് ചെയ്യുക + ഗാനം എഡിറ്റ് ചെയ്യുക പാട്ടിന്റെ പേര് പാട്ടുകാരൻ ഗാനത്തിന്റെ പേര് ശൂന്യമാക്കാൻ കഴിയില്ല. പാട്ടുകാരൻ ശൂന്യമായിരിക്കാൻ കഴിയില്ല. - സേവ് + സേവ് - പ്ലേലിസ്റ്റ് സൃഷ്ടിക്കുക - പ്ലേലിസ്റ്റ് പേര് + പ്ലേലിസ്റ്റ് തിരഞ്ഞെടുക്കുക + പ്ലേലിസ്റ്റ് എഡിറ്റ് ചെയ്യുക + പ്ലേലിസ്റ്റ് സൃഷ്ടിക്കുക + പ്ലേലിസ്റ്റ് പേര് പ്ലേലിസ്റ്റിന്റെ പേര് ശൂന്യമാക്കാൻ കഴിയില്ല. - എഡിറ്റ് ആർട്ടിസ്റ്റ് - കലാകാരന്റെ പേര് + എഡിറ്റ് ആർട്ടിസ്റ്റ് + കലാകാരന്റെ പേര് കലാകാരന്റെ പേര് ശൂന്യമാക്കാൻ കഴിയില്ല. - ഡ്യൂപ്ലിക്കേറ്റ് ആർട്ടിസ്റ്റുകൾ - ആർട്ടിസ്റ്റ് %1$s ഇതിനകം നിലവിലുണ്ട്. - - പ്ലേലിസ്റ്റ് തിരഞ്ഞെടുക്കുക - - പ്ലേലിസ്റ്റ് എഡിറ്റ് ചെയ്യുക - - ബാക്കപ്പ് ഉള്ളടക്കം തിരഞ്ഞെടുക്കുക - പുനഃസ്ഥാപന ഉള്ളടക്കം തിരഞ്ഞെടുക്കുക - മുൻഗണനകൾ - ഡാറ്റാബേസ് - ഡൗൺലോഡ് ചെയ്‌ത പാട്ടുകൾ - ബാക്കപ്പ് സൃഷ്‌ടിച്ചു - ബാക്കപ്പ് സൃഷ്ടിക്കാൻ കഴിഞ്ഞില്ല - ബാക്കപ്പ് പുനഃസ്ഥാപിക്കാൻ കഴിഞ്ഞില്ല - - - മ്യൂസിക് പ്ലെയർ - ഡൗൺലോഡ് - - + %d പാട്ട് %d പാട്ടുകൾ - + %d ആർട്ടിസ്റ്റ് %d ആർട്ടിസ്റ്റുകൾ - + %d ആൽബം %d ആൽബങ്ങൾ - + %d പ്ലേലിസ്റ്റ് %d പ്ലേലിസ്റ്റുകൾ - - വീണ്ടും ശ്രമിക്കുക - പ്ലേ - എല്ലാം പ്ലേ ചെയ്യുക - റേഡിയോ - ഷഫിൾ - സ്റ്റാക്ക്ട്രെയിസ് പകർത്തുക - റിപ്പോർട്ട് ചെയ്യുക - ജിറ്റ്ഹബിൽ റിപ്പോർട്ട് ചെയ്യുക - - - ചേർത്ത തീയതി - പേര് - ആർട്ടിസ്റ്റ് - വർഷം - പാട്ടുകളുടെ എണ്ണം - ദൈർഘ്യം - Play time - - - %d പാട്ട് ഡിലീറ്റ് ചെയ്തു. - %d പാട്ടുകൾ ഡിലീറ്റ് ചെയ്തു. - - - %d തിരഞ്ഞെടുത്തു - %d തിരഞ്ഞെടുത്തു - - പഴയപടി ആക്കുക - ഈ url തിരിച്ചറിയാൻ കഴിയുന്നില്ല. - - പാട്ട് അടുത്തതായി പ്ലേ ചെയ്യും - %d പാട്ടുകൾ അടുത്തതായി പ്ലേ ചെയ്യും - - - ആർട്ടിസ്റ്റ് അടുത്തതായി പ്ലേ ചെയ്യും - %d ആർട്ടിസ്റ്റുകൾ അടുത്തതായി പ്ലേ ചെയ്യും - - - ആൽബം അടുത്തതായി പ്ലേ ചെയ്യും - %d ആൽബങ്ങൾ അടുത്തതായി പ്ലേ ചെയ്യും - - - പ്ലേലിസ്റ്റ് അടുത്തതായി പ്ലേ ചെയ്യും - %d പ്ലേലിസ്റ്റുകൾ അടുത്തതായി പ്ലേ ചെയ്യും - - തിരഞ്ഞെടുത്തത് അടുത്തതായി പ്ലേ ചെയ്യും - - പാട്ട് ക്യൂവിൽ ചേർത്തു - %d പാട്ടുകൾ ക്യൂവിൽ ചേർത്തു - - - ആർട്ടിസ്റ്റ് ക്യൂവിൽ ചേർത്തു - %d ആർട്ടിസ്റ്റുകൾ ക്യൂവിൽ ചേർത്തു - - - ആൽബം ക്യൂവിൽ ചേർത്തു - %d ആൽബങ്ങൾ ക്യൂവിൽ ചേർത്തു - - - പ്ലേലിസ്റ്റ് ക്യൂവിൽ ചേർത്തു - %d പ്ലേലിസ്റ്റുകൾ ക്യൂവിൽ ചേർത്തു - - തിരഞ്ഞെടുത്തത് ക്യൂവിൽ ചേർത്തു - ലൈബ്രറിയിൽ ചേർത്തു - ലൈബ്രറിയിൽ നിന്ന് നീക്കം ചെയ്തു പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്തു - %1$s-യിലേക്ക് ചേർത്തു - - പാട്ട് ഡൗൺലോഡ് ചെയ്യാൻ ആരംഭിക്കുക - %d പാട്ടുകൾ ഡൗൺലോഡ് ചെയ്യാൻ ആരംഭിക്കുക + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - ഡൗൺലോഡ് നീക്കം ചെയ്‌തു - കാണുക + No stream available + No network connection + Timeout + Unknown error - + Like Remove like ലൈബ്രറിയിലേക്ക് ചേർക്കുക Remove from library - - എല്ലാം - പാട്ടുകൾ - വീഡിയോകൾ - ആൽബങ്ങൾ - ആർട്ടിസ്റ്റുകൾ - പ്ലേലിസ്റ്റുകൾ - കമ്മ്യൂണിറ്റി പ്ലേലിസ്റ്റുകൾ - തിരഞ്ഞെടുത്ത പ്ലേലിസ്റ്റുകൾ - - സിസ്റ്റം സ്ഥിരസ്ഥിതി - നിങ്ങളുടെ ലൈബ്രറിയിൽ നിന്ന് - - - ക്ഷമിക്കണം, അങ്ങനെ സംഭവിക്കാൻ പാടില്ലായിരുന്നു. - ക്ലിപ്പ്ബോർഡിലേക്ക് പകർത്തി - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + മ്യൂസിക് പ്ലെയർ - - No results found - \ No newline at end of file + + ക്രമീകരണങ്ങൾ + രൂപഭംഗി + ഡാർക്ക് തീം + ഓൺ + ഓഫ് + സിസ്റ്റം പിന്തുടരുക + സ്ഥിര ഓപ്പൺ ടാബ് + Customize navigation tabs + Lyrics text position + Left + Center + Right + + കന്റെന്റ് + Login + സ്ഥിര കന്റെന്റ് ഭാഷ + സ്ഥിര കന്റെന്റ് രാജ്യം + സിസ്റ്റം സ്ഥിരസ്ഥിതി + പ്രോക്സി പ്രവർത്തനക്ഷമമാക്കുക + പ്രോക്സി തരം + പ്രോക്സി URL + പ്രാബല്യത്തിൽ വരാൻ പുനരാരംഭിക്കുക + + പ്ലെയറും ഓഡിയോയും + ഓഡിയോ നിലവാരം + ഓട്ടോ + ഉയർന്ന + താഴ്ന്ന + Persistent queue + Skip silence + Audio normalization + ഇക്വലൈസർ + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + പൊതുവായ + യാന്ത്രിക ഡൗൺലോഡ് + ലൈബ്രറിയിൽ ചേർക്കുമ്പോൾ പാട്ട് ഡൗൺലോഡ് ചെയ്യുക + ലൈബ്രറിയിലേക്ക് പാട്ട് സ്വയമേവ ചേർക്കുക + നിങ്ങളുടെ ലൈബ്രറി പ്ലേ ചെയ്യുമ്പോൾ പാട്ട് ചേർക്കുക + താഴെയുള്ള പ്ലേയർ വികസിപ്പിക്കുക + More actions in notification + Show add to library and like buttons + + സ്വകാര്യത + തിരയൽ ചരിത്രം താൽക്കാലികമായി നിർത്തുക + തിരയൽ ചരിത്രം മായ്‌ക്കുക + എല്ലാ തിരയൽ ചരിത്രവും മായ്‌ക്കണമെന്ന് ഉറപ്പാണോ? + Enable KuGou lyrics provider + + ബാക്കപ്പും വീണ്ടെടുക്കലും + ബാക്കപ്പ് + വീണ്ടെടുക്കൽ + ബാക്കപ്പ് സൃഷ്‌ടിച്ചു + ബാക്കപ്പ് സൃഷ്ടിക്കാൻ കഴിഞ്ഞില്ല + ബാക്കപ്പ് പുനഃസ്ഥാപിക്കാൻ കഴിഞ്ഞില്ല + + കുറിച്ച് + അപ്ലിക്കേഷൻ പതിപ്പ് + diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 6d9fb6013..00cb8e222 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -1,196 +1,226 @@ - "ଘର" - "ଗୀତ" - "କଳାକାର ମାନେ" - "ଆଲବମ୍" - "ପ୍ଲେଲିଷ୍ଟଗୁଡିକ" - "ଏକ୍ସପ୍ଲୋର୍ କରନ୍ତୁ" - "ସେଟିଂ ସମୂହ" - "ବର୍ତ୍ତମାନ ଚାଲିଛି" - "ତ୍ରୁଟି ରିପୋର୍ଟ" - "ଦୃଶ୍ୟତା" - "ସିଷ୍ଟମ୍ ଥିମ୍ ଅନୁସରଣ କରନ୍ତୁ" - "ଥିମ୍ ରଙ୍ଗ" - "ଗାଢ଼ ଥିମ୍" - "ଅନ୍" - "ଅଫ୍" - "ସିଷ୍ଟମ୍ ଅନୁସରଣ କରନ୍ତୁ" - "ଡିଫଲ୍ଟ ଖୋଲା ଟ୍ୟାବ୍" - "ନାଭିଗେସନ୍ ଟ୍ୟାବ୍ କଷ୍ଟୋମାଇଜ୍ କରନ୍ତୁ" - "ଗୀତ ପାଠ୍ୟ ଅବସ୍ଥାନ" - "ବାମ" - "କେନ୍ଦ୍ର" - "ଦକ୍ଷିଣ" - "ବିଷୟବସ୍ତୁ" - "ଭିତରକୁ ଯାଅ" - "ଡିଫଲ୍ଟ ବିଷୟବସ୍ତୁ ଭାଷା" - "ଡିଫଲ୍ଟ ବିଷୟବସ୍ତୁ ଦେଶ" - "ପ୍ରକ୍ସି ସକ୍ଷମ କରନ୍ତୁ" - "ପ୍ରକ୍ସି ପ୍ରକାର" - "ପ୍ରକ୍ସି URL" - "କାର୍ଯ୍ୟକାରୀ ହେବାକୁ ପୁନଃ ଆରମ୍ଭ କରନ୍ତୁ" - "ପ୍ଲେୟାର ଏବଂ ଅଡିଓ" - "ଅଡିଓ ଗୁଣବତ୍ତା" - "ସ୍ଵତଃ" - "ଅଧିକ" - "କମ୍" - "ପୂର୍ବ ଗୀତ ଗୁଡ଼ିକୁ ଧାଡିରେ ରଖନ୍ତୁ" - "ନୀରବତା ଏଡାନ୍ତୁ" - "ଅଡିଓ ସାଧାରଣକରଣ" - "ସମାନତା" - "ଭଣ୍ଡାର" - "SAF ରେ ଡାଉନଲୋଡ୍ ହୋଇଥିବା ଫାଇଲଗୁଡିକ ଦେଖନ୍ତୁ" - "ଏହା ହୁଏତ କିଛି ଉପକରଣରେ କାମ କରିନପାରେ" - "କ୍ୟାଚ୍" - "ସର୍ବାଧିକ ପ୍ରତିଛବି କ୍ୟାଚ୍ ଆକାର" - "ପ୍ରତିଛବି କ୍ୟାଚ୍ ସଫା କରନ୍ତୁ" - "ସର୍ବାଧିକ ଗୀତ କ୍ୟାଚ୍ ଆକାର" - "%s ବ୍ୟବହୃତ" - "ସାଧାରଣ" - "ସ୍ଵତଃ ଡାଉନଲଡ୍" - "ଲାଇବ୍ରେରୀରେ ଯୋଡି ହେଲେ ଗୀତ ଡାଉନଲୋଡ୍ କରନ୍ତୁ" - "ଲାଇବ୍ରେରୀରେ ସ୍ଵତଃ ଗୀତ ଯୋଡନ୍ତୁ" - "ଯେତେବେଳେ ଏହା ଚାଲିବା ସମାପ୍ତ ହେବ ସେତେବେଳେ ତୁମର ଲାଇବ୍ରେରୀରେ ଗୀତ ଯୋଡ" - "ପ୍ଲେ ଉପରେ ନିମ୍ନ ପ୍ଲେୟାର୍ ବିସ୍ତାର କରନ୍ତୁ" - "ବିଜ୍ଞପ୍ତିରେ ଅଧିକ କାର୍ଯ୍ୟ" - "ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ ଏବଂ ବଟନ୍ ପସନ୍ଦ କରନ୍ତୁ" - "ଗୋପନୀୟତା" - "ସନ୍ଧାନ ଇତିହାସକୁ ବିରତି ଦିଅ" - "ସନ୍ଧାନ ଇତିହାସ ସଫା କରନ୍ତୁ" - "ଆପଣ ସମସ୍ତ ସନ୍ଧାନ ଇତିହାସ ସଫା କରିବାକୁ ନିଶ୍ଚିତ କି?" - "KuGou ଗୀତ ପ୍ରଦାନକାରୀକୁ ସକ୍ଷମ କରନ୍ତୁ" - "ନକଲ ସଂରକ୍ଷଣ ଏବଂ ପୁନରୁଦ୍ଧାର କରନ୍ତୁ" - "ନକଲ ସଂରକ୍ଷଣ" - "ପୁନରୁଦ୍ଧାର କରନ୍ତୁ" - "ବିଷୟରେ" - "ଆପ୍ ସଂସ୍କରଣ" - "ସାକୁରା" - "ନାଲି" - "ଗୋଲାପୀ" - "ବାଇଗଣୀ" - "ଗାଢ଼ ବାଇଗଣୀ" - "ଗାଢ଼ ନୀଳ" - "ନୀଳ" - "ଫିକା ନୀଳ" - "ସବୁଜିଆ ନୀଳ" - "ଟିଲ୍" - "ସବୁଜ" - "ଫିକା ସବୁଜ" - "ଚୂନ" - "ହଳଦିଆ" - "ଅମ୍ବର" - "କମଳା" - "ଗାଢ଼ କମଳା" - "ମାଟିଆ" - "ନୀଳ ଧୂସର" - "ସନ୍ଧାନ କରନ୍ତୁ" - "ୟୁଟ୍ୟୁବ୍ ମ୍ୟୁଜିକ୍ ଖୋଜ…" - "ଲାଇବ୍ରେରୀ ଖୋଜ…" - "ପସନ୍ଦିତ ଗୀତ ଗୁଡ଼ିକ" - "ସଞ୍ଚିତ ଗୀତ ଗୁଡ଼ିକ" - "ବିବରଣୀ" - "ସଂପାଦନା କରନ୍ତୁ" - "ରେଡିଓ ଆରମ୍ଭ କରନ୍ତୁ" - "ଚଲାନ୍ତୁ" - "ପରବର୍ତ୍ତୀ ରେ ଚଲାନ୍ତୁ" - "ଧାଡିରେ ଯୋଡନ୍ତୁ" - "ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ" - "ଡାଉନଲୋଡ୍ କରନ୍ତୁ" - "ଡାଉନଲୋଡ୍ ଅପସାରଣ କରନ୍ତୁ" - "ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ କରନ୍ତୁ" - "ପ୍ଲେ ଲିଷ୍ଟରେ ଯୋଡନ୍ତୁ" - "କଳାକାର ଦେଖନ୍ତୁ" - "ଆଲବମ୍ ଦେଖନ୍ତୁ" - "ପୁନଃ ଆଣନ୍ତୁ" - "ଅଂଶୀଦାର କରନ୍ତୁ" - "ବିଲୋପ କରନ୍ତୁ" - "ଅନଲାଇନ୍ ସନ୍ଧାନ କରନ୍ତୁ" - "ଅନ୍ୟ ଗୀତ ପାଠ୍ୟ ବାଛନ୍ତୁ" - "ବିବରଣୀ" - "ମିଡିଆ id" - "MIME ପ୍ରକାର" - "କୋଡେକସ୍" - "ବିଟ୍ରେଟ୍" - "ନମୁନା ହାର" - "ସ୍ୱର ର ଉଚ୍ଚତା" - "ସ୍ୱର ର ତୀବ୍ରତା" - "ଫାଇଲ୍ ଆକାର" - "ଅଜ୍ଞାତ" - "ଗୀତ ପାଠ୍ୟ ଗୁଡିକ ସଂପାଦନ କରନ୍ତୁ" - "ଗୀତ ପାଠ୍ୟ ଖୋଜ" - "ଗୀତ ପାଠ୍ୟ ବାଛନ୍ତୁ" - "ଗୀତ ସଂପାଦନ କରନ୍ତୁ" - "ଗୀତର ଆଖ୍ୟା" - "ଗୀତ କଳାକାରମାନେ" - "ଗୀତର ଆଖ୍ୟା ଖାଲି ହୋଇପାରିବ ନାହିଁ ।" - "ଗୀତ କଳାକାର ଖାଲି ହୋଇପାରିବ ନାହିଁ ।" - "ସଞ୍ଚୟ କରନ୍ତୁ" - "ପ୍ଲେଲିଷ୍ଟ ସୃଷ୍ଟି କରନ୍ତୁ" - "ପ୍ଲେଲିଷ୍ଟ ନାମ" - "ପ୍ଲେଲିଷ୍ଟ ନାମ ଖାଲି ହୋଇପାରିବ ନାହିଁ ।" - "କଳାକାର ସଂପାଦନ କରନ୍ତୁ" - "କଳାକାର ନାମ" - "କଳାକାରଙ୍କ ନାମ ଖାଲି ହୋଇପାରିବ ନାହିଁ ।" - "ନକଲ କଳାକାର ଗୁଡ଼ିକ" - "କଳାକାର %1$s ପୂର୍ବରୁ ବିଦ୍ୟମାନ ଅଛି ।" - "ପ୍ଲେଲିଷ୍ଟ ବାଛନ୍ତୁ" - "ପ୍ଲେଲିଷ୍ଟ ସଂପାଦନ କରନ୍ତୁ" - "ନକଲ ସଂରକ୍ଷଣ ପାଇଁ ବିଷୟବସ୍ତୁ ବାଛନ୍ତୁ" - "ପୁନଃସ୍ଥାପନ ବିଷୟବସ୍ତୁ ବାଛନ୍ତୁ" - "ପସନ୍ଦ" - "ଡାଟାବେସ୍" - "ଡାଉନଲଡ୍ ହୋଇଥିବା ଗୀତ ଗୁଡ଼ିକ" - "ବ୍ୟାକଅପ୍ ସଫଳତାର ସହିତ ସୃଷ୍ଟି ହେଲା" - "ବ୍ୟାକଅପ୍ ସୃଷ୍ଟି କରିପାରିବ ନାହିଁ" - "ବ୍ୟାକଅପ୍ ପୁନରୁଦ୍ଧାର କରିବାରେ ବିଫଳ" - "ସଙ୍ଗୀତ ଚାଳକ" - "ଡାଉନଲୋଡ୍ କରନ୍ତୁ" - "ପୁନଃ ଚେଷ୍ଟା କରନ୍ତୁ" - "ଚଲାନ୍ତୁ" - "ସମସ୍ତ ଚଲାନ୍ତୁ" - "ରେଡିଓ" - "ଶଫଲ୍ କରନ୍ତୁ" - "ଷ୍ଟାକଟ୍ରେସ୍ କପି କରନ୍ତୁ" - "ରିପୋର୍ଟ୍ କରନ୍ତୁ" - "GitHub ରେ ରିପୋର୍ଟ କରନ୍ତୁ" - "ତାରିଖ ରେ ଯୋଡା ଯାଇଛି" - "ନାମ" - "କଳାକାର" - "ବର୍ଷ" - "ସଙ୍ଗୀତ ସଂଖ୍ୟା" - "ସମୟ" - "ଚାଲିଥିବା ସମୟ" - "ପୂର୍ବବତ୍ କରନ୍ତୁ" - "ଏହି url ଚିହ୍ନଟ କରିପାରିବ ନାହିଁ" - "ମନୋନୀତ ପରବର୍ତ୍ତୀ ସମୟରେ ଚାଲିବ" - "ମୋନାନିତ ଗୁଡ଼ିକ ଧାଡିରେ ଯୋଡା ଯାଇଛି" - "ଲାଇବ୍ରେରୀରେ ଯୋଡା ଯାଇଛି" - "ଲାଇବ୍ରେରୀରୁ ଅପସାରିତ" - "ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ ହୋଇଛି" - "%1$s ରେ ଯୋଗ କରାଯାଇଛି" - "ଡାଉନଲୋଡ୍ ଅପସାରଣ କରାଯାଇଛି" - "ଦୃଶ୍ୟ" - "ପସନ୍ଦ କରନ୍ତୁ" - "ପଶନ୍ଦ ଅପସାରଣ କରନ୍ତୁ" - "ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ" - "ଲାଇବ୍ରେରୀରୁ ଅପସାରଣ କରନ୍ତୁ" - "ସମସ୍ତ" - "ସଙ୍ଗୀତ ଗୁଡ଼ିକ" - "ଭିଡିଓ ଗୁଡ଼ିକ" - "ଆଲବମ୍ ଗୁଡ଼ିକ" - "କଳାକାର ମାନେ" - "ପ୍ଲେଲିଷ୍ଟ ଗୁଡ଼ିକ" - "ସମ୍ପ୍ରଦାୟ ପ୍ଲେଲିଷ୍ଟଗୁଡିକ" - "ବୈଶିଷ୍ଟ୍ୟଯୁକ୍ତ ତାଲିକାଗୁଡ଼ିକ" - "ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟ" - "ଆପଣଙ୍କ ଲାଇବ୍ରେରୀରୁ" - "ଦୁଃଖିତ, ତାହା ହୋଇ ନଥାନ୍ତା ।" - "କ୍ଲିପବୋର୍ଡରେ କପି କରାଯାଇଛି" - "କୌଣସି ଷ୍ଟ୍ରିମ୍ ଉପଲବ୍ଧ ନାହିଁ" - "କୌଣସି ନେଟୱର୍କ ସଂଯୋଗ ନାହିଁ" - "ସମୟ ଶେଷ" - "ଅଜ୍ଞାତ ତ୍ରୁଟି" - "ସମସ୍ତ ଗୀତ" - "ଖୋଜାଜାଇଥିବା ଗୀତ ଗୁଡ଼ିକ" - "ଗୀତ ର ପାଠ୍ୟ ମିଳିଲା ନାହିଁ" + + ଘର + ଗୀତ + କଳାକାର ମାନେ + ଆଲବମ୍ + ପ୍ଲେଲିଷ୍ଟଗୁଡିକ + + + + %d selected + %d selected + + + + History + Stats + New release albums + Most played songs + + + ସନ୍ଧାନ କରନ୍ତୁ + ୟୁଟ୍ୟୁବ୍ ମ୍ୟୁଜିକ୍ ଖୋଜ… + ଲାଇବ୍ରେରୀ ଖୋଜ… + ସମସ୍ତ + ସଙ୍ଗୀତ ଗୁଡ଼ିକ + ଭିଡିଓ ଗୁଡ଼ିକ + ଆଲବମ୍ ଗୁଡ଼ିକ + କଳାକାର ମାନେ + ପ୍ଲେଲିଷ୍ଟ ଗୁଡ଼ିକ + ସମ୍ପ୍ରଦାୟ ପ୍ଲେଲିଷ୍ଟଗୁଡିକ + ବୈଶିଷ୍ଟ୍ୟଯୁକ୍ତ ତାଲିକାଗୁଡ଼ିକ + No results found + + + ଆପଣଙ୍କ ଲାଇବ୍ରେରୀରୁ + + + ପସନ୍ଦିତ ଗୀତ ଗୁଡ଼ିକ + ସଞ୍ଚିତ ଗୀତ ଗୁଡ଼ିକ + The playlist is empty + + + ପୁନଃ ଚେଷ୍ଟା କରନ୍ତୁ + ରେଡିଓ + ଶଫଲ୍ କରନ୍ତୁ + + + ବିବରଣୀ + ସଂପାଦନା କରନ୍ତୁ + ରେଡିଓ ଆରମ୍ଭ କରନ୍ତୁ + ଚଲାନ୍ତୁ + ପରବର୍ତ୍ତୀ ରେ ଚଲାନ୍ତୁ + ଧାଡିରେ ଯୋଡନ୍ତୁ + ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ + ଡାଉନଲୋଡ୍ କରନ୍ତୁ + ଡାଉନଲୋଡ୍ ଅପସାରଣ କରନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ କରନ୍ତୁ + ପ୍ଲେ ଲିଷ୍ଟରେ ଯୋଡନ୍ତୁ + କଳାକାର ଦେଖନ୍ତୁ + ଆଲବମ୍ ଦେଖନ୍ତୁ + ପୁନଃ ଆଣନ୍ତୁ + ଅଂଶୀଦାର କରନ୍ତୁ + ବିଲୋପ କରନ୍ତୁ + ଅନଲାଇନ୍ ସନ୍ଧାନ କରନ୍ତୁ + Sync + + + ତାରିଖ ରେ ଯୋଡା ଯାଇଛି + ନାମ + କଳାକାର + ବର୍ଷ + ସଙ୍ଗୀତ ସଂଖ୍ୟା + ସମୟ + ଚାଲିଥିବା ସମୟ + + + ମିଡିଆ id + MIME ପ୍ରକାର + କୋଡେକସ୍ + ବିଟ୍ରେଟ୍ + ନମୁନା ହାର + ସ୍ୱର ର ଉଚ୍ଚତା + ସ୍ୱର ର ତୀବ୍ରତା + ଫାଇଲ୍ ଆକାର + ଅଜ୍ଞାତ + କ୍ଲିପବୋର୍ଡରେ କପି କରାଯାଇଛି + + ଗୀତ ପାଠ୍ୟ ଗୁଡିକ ସଂପାଦନ କରନ୍ତୁ + ଗୀତ ପାଠ୍ୟ ଖୋଜ + + ଗୀତ ସଂପାଦନ କରନ୍ତୁ + ଗୀତର ଆଖ୍ୟା + ଗୀତ କଳାକାରମାନେ + ଗୀତର ଆଖ୍ୟା ଖାଲି ହୋଇପାରିବ ନାହିଁ । + ଗୀତ କଳାକାର ଖାଲି ହୋଇପାରିବ ନାହିଁ । + ସଞ୍ଚୟ କରନ୍ତୁ + + ପ୍ଲେଲିଷ୍ଟ ବାଛନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ସଂପାଦନ କରନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ସୃଷ୍ଟି କରନ୍ତୁ + ପ୍ଲେଲିଷ୍ଟ ନାମ + ପ୍ଲେଲିଷ୍ଟ ନାମ ଖାଲି ହୋଇପାରିବ ନାହିଁ । + + କଳାକାର ସଂପାଦନ କରନ୍ତୁ + କଳାକାର ନାମ + କଳାକାରଙ୍କ ନାମ ଖାଲି ହୋଇପାରିବ ନାହିଁ । + + + + %d song + %d songs + + + %d artist + %d artists + + + %d album + %d albums + + + %d playlist + %d playlists + + + + ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ ହୋଇଛି + + + ଗୀତ ର ପାଠ୍ୟ ମିଳିଲା ନାହିଁ + Sleep timer + End of song + + 1 minute + %d minutes + + କୌଣସି ଷ୍ଟ୍ରିମ୍ ଉପଲବ୍ଧ ନାହିଁ + କୌଣସି ନେଟୱର୍କ ସଂଯୋଗ ନାହିଁ + ସମୟ ଶେଷ + ଅଜ୍ଞାତ ତ୍ରୁଟି + + + ପସନ୍ଦ କରନ୍ତୁ + ପଶନ୍ଦ ଅପସାରଣ କରନ୍ତୁ + ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ + ଲାଇବ୍ରେରୀରୁ ଅପସାରଣ କରନ୍ତୁ + + + ସମସ୍ତ ଗୀତ + ଖୋଜାଜାଇଥିବା ଗୀତ ଗୁଡ଼ିକ + + + ସଙ୍ଗୀତ ଚାଳକ + + + ସେଟିଂ ସମୂହ + ଦୃଶ୍ୟତା + ଗାଢ଼ ଥିମ୍ + ଅନ୍ + ଅଫ୍ + ସିଷ୍ଟମ୍ ଅନୁସରଣ କରନ୍ତୁ + ଡିଫଲ୍ଟ ଖୋଲା ଟ୍ୟାବ୍ + ନାଭିଗେସନ୍ ଟ୍ୟାବ୍ କଷ୍ଟୋମାଇଜ୍ କରନ୍ତୁ + ଗୀତ ପାଠ୍ୟ ଅବସ୍ଥାନ + ବାମ + କେନ୍ଦ୍ର + ଦକ୍ଷିଣ + + ବିଷୟବସ୍ତୁ + ଭିତରକୁ ଯାଅ + ଡିଫଲ୍ଟ ବିଷୟବସ୍ତୁ ଭାଷା + ଡିଫଲ୍ଟ ବିଷୟବସ୍ତୁ ଦେଶ + ସିଷ୍ଟମ୍ ଡିଫଲ୍ଟ + ପ୍ରକ୍ସି ସକ୍ଷମ କରନ୍ତୁ + ପ୍ରକ୍ସି ପ୍ରକାର + ପ୍ରକ୍ସି URL + କାର୍ଯ୍ୟକାରୀ ହେବାକୁ ପୁନଃ ଆରମ୍ଭ କରନ୍ତୁ + + ପ୍ଲେୟାର ଏବଂ ଅଡିଓ + ଅଡିଓ ଗୁଣବତ୍ତା + ସ୍ଵତଃ + ଅଧିକ + କମ୍ + ପୂର୍ବ ଗୀତ ଗୁଡ଼ିକୁ ଧାଡିରେ ରଖନ୍ତୁ + ନୀରବତା ଏଡାନ୍ତୁ + ଅଡିଓ ସାଧାରଣକରଣ + ସମାନତା + + ଭଣ୍ଡାର + କ୍ୟାଚ୍ + Image Cache + Song Cache + Max cache size + Unlimited + ସର୍ବାଧିକ ପ୍ରତିଛବି କ୍ୟାଚ୍ ଆକାର + ପ୍ରତିଛବି କ୍ୟାଚ୍ ସଫା କରନ୍ତୁ + ସର୍ବାଧିକ ଗୀତ କ୍ୟାଚ୍ ଆକାର + Clear song cache + %s ବ୍ୟବହୃତ + + ସାଧାରଣ + ସ୍ଵତଃ ଡାଉନଲଡ୍ + ଲାଇବ୍ରେରୀରେ ଯୋଡି ହେଲେ ଗୀତ ଡାଉନଲୋଡ୍ କରନ୍ତୁ + ଲାଇବ୍ରେରୀରେ ସ୍ଵତଃ ଗୀତ ଯୋଡନ୍ତୁ + ଯେତେବେଳେ ଏହା ଚାଲିବା ସମାପ୍ତ ହେବ ସେତେବେଳେ ତୁମର ଲାଇବ୍ରେରୀରେ ଗୀତ ଯୋଡ + ପ୍ଲେ ଉପରେ ନିମ୍ନ ପ୍ଲେୟାର୍ ବିସ୍ତାର କରନ୍ତୁ + ବିଜ୍ଞପ୍ତିରେ ଅଧିକ କାର୍ଯ୍ୟ + ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ ଏବଂ ବଟନ୍ ପସନ୍ଦ କରନ୍ତୁ + + ଗୋପନୀୟତା + ସନ୍ଧାନ ଇତିହାସକୁ ବିରତି ଦିଅ + ସନ୍ଧାନ ଇତିହାସ ସଫା କରନ୍ତୁ + ଆପଣ ସମସ୍ତ ସନ୍ଧାନ ଇତିହାସ ସଫା କରିବାକୁ ନିଶ୍ଚିତ କି? + KuGou ଗୀତ ପ୍ରଦାନକାରୀକୁ ସକ୍ଷମ କରନ୍ତୁ + + ନକଲ ସଂରକ୍ଷଣ ଏବଂ ପୁନରୁଦ୍ଧାର କରନ୍ତୁ + ନକଲ ସଂରକ୍ଷଣ + ପୁନରୁଦ୍ଧାର କରନ୍ତୁ + ବ୍ୟାକଅପ୍ ସଫଳତାର ସହିତ ସୃଷ୍ଟି ହେଲା + ବ୍ୟାକଅପ୍ ସୃଷ୍ଟି କରିପାରିବ ନାହିଁ + ବ୍ୟାକଅପ୍ ପୁନରୁଦ୍ଧାର କରିବାରେ ବିଫଳ + + ବିଷୟରେ + ଆପ୍ ସଂସ୍କରଣ diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 6442a8d1f..666ec41e7 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -1,125 +1,80 @@ - - - ਘਰ - ਗੀਤ - ਕਲਾਕਾਰ - ਐਲਬਮ - ਪਲੇਲਿਸਟ - ਐਕਸਪਲੋਰ ਕਰੋ - ਸੈਟਿੰਗਾਂ - ਹੁਣ ਚੱਲ ਰਿਹਾ ਹੈ - ਤਰੁੱਟੀ ਰਿਪੋਰਟ - - - ਦਿੱਖ - ਸਿਸਟਮ ਥੀਮ ਦਾ ਪਾਲਣ ਕਰੋ - ਥੀਮ ਦਾ ਰੰਗ - ਗੂੜ੍ਹਾ ਥੀਮ - ਚਾਲੂ - ਬੰਦ - ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ - ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹਣ ਵਾਲੀ ਟੈਬ - ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਆਪਣੀ ਪਸੰਦ ਦਾ ਢਾਲੋ - ਬੋਲਾਂ ਦੀ ਟੈਕਸਟ ਸਥਿਤੀ - ਖੱਬੇ - ਵਿਚਕਾਰ - ਸੱਜੇ - ਸਮੱਗਰੀ - ਲਾਗ-ਇਨ - ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਭਾਸ਼ਾ - ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਦੇਸ਼ - ਪ੍ਰੌਕਸੀ ਚਾਲੂ ਕਰੋ - ਪ੍ਰੌਕਸੀ ਕਿਸਮ - ਪ੍ਰੌਕਸੀ URL - ਪ੍ਰਭਾਵੀ ਹੋਣ ਲਈ ਮੁੜ-ਚਾਲੂ ਕਰੋ - ਪਲੇਅਰ ਅਤੇ ਆਡੀਓ - ਆਡੀਓ ਕੁਆਲਿਟੀ - ਆਟੋ - ਉੱਚ - ਘੱਟ - ਨਿਰੰਤਰ ਕਤਾਰ - ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ - ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ - ਈਕੋਲਾਈਜ਼ਰ - ਸਟੋਰੇਜ - SAF ਵਿੱਚ ਡਾਊਨਲੋਡ ਕੀਤੀਆਂ ਫਾਈਲਾਂ ਦੇਖੋ - ਇਹ ਕੁਝ ਡਿਵਾਈਸਾਂ ਵਿੱਚ ਕੰਮ ਨਹੀਂ ਕਰ ਸਕਦਾ ਹੈ - ਕੈਸ਼ੇ - ਅਧਿਕਤਮ ਚਿੱਤਰ ਕੈਸ਼ੇ ਆਕਾਰ - ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ - ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ - %s ਵਰਤਿਆ ਗਿਆ - ਜਨਰਲ - ਆਟੋ ਡਾਊਨਲੋਡ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤੇ ਜਾਣ ਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕਰੋ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਗੀਤ ਆਟੋ ਸ਼ਾਮਲ ਕਰੋ - ਗੀਤ ਪੂਰਾ ਚੱਲ ਜਾਣ ਤੇ ਆਪਣੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ - ਚਲਾਉਣ ਤੇ ਹੇਠਲੇ ਪਲੇਅਰ ਦਾ ਵਿਸਤਾਰ ਕਰੋ - ਨੋਟੀਫਿਕੇਸ਼ਨ ਵਿੱਚ ਹੋਰ ਕਾਰਵਾਈਆਂ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਵਿਖਾਓ - ਗੋਪਨੀਯਤਾ - ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ - ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ - ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? - KuGou ਬੋਲ ਪ੍ਰਦਾਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ - ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ - ਬੈਕਅੱਪ - ਰੀਸਟੋਰ - ਦੇ ਬਾਰੇ - ਐਪ ਸੰਸਕਰਣ + + + ਘਰ + ਗੀਤ + ਕਲਾਕਾਰ + ਐਲਬਮ + ਪਲੇਲਿਸਟ + + + + %d ਚੁਣਿਆ ਗਿਆ + %d ਚੁਣੇ ਗਏ + - - ਗੂੜ੍ਹਾ ਗੁਲਾਬੀ - ਲਾਲ - ਗੁਲਾਬੀ - ਜਾਮਣੀ - ਗੂੜ੍ਹਾ ਜਾਮਣੀ - ਵੈਂਗਣੀ - ਨੀਲਾ - ਫਿੱਕਾ ਨੀਲਾ - ਹਰਾ-ਨੀਲਾ - ਟੀਲ ਰੰਗ - ਹਰਾ - ਫਿੱਕਾ ਹਰਾ - ਖੱਟਾ - ਪੀਲਾ - ਅੰਬਰ - ਸੰਤਰੀ - ਗੂੜ੍ਹਾ ਸੰਤਰੀ - ਭੂਰਾ - ਨੀਲਾ ਸਲੇਟੀ + + History + Stats + New release albums + Most played songs - - ਖੋਜੋ + + ਖੋਜੋ ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… ਲਾਇਬ੍ਰੇਰੀ ਖੋਜੋ… - - + ਸਾਰੇ + ਗੀਤ + ਵੀਡੀਓ + ਐਲਬਮ + ਕਲਾਕਾਰ + ਪਲੇਲਿਸਟਾਂ + ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ + ਪ੍ਰਦਰਸ਼ਿਤ ਪਲੇਲਿਸਟਾਂ + No results found + + + ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ + + ਪਸੰਦ ਕੀਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ + The playlist is empty - - ਵੇਰਵੇ - ਸੰਪਾਦਿਤ ਕਰੋ - ਰੇਡੀਓ ਸ਼ੁਰੂ ਕਰੋ - ਚਲਾਓ - ਅਗਲਾ ਚਲਾਓ - ਕਤਾਰ ਵਿੱਚ ਜੋੜ੍ਹੋ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ - ਡਾਊਨਲੋਡ - ਡਾਊਨਲੋਡ ਹਟਾਓ - ਪਲੇਲਿਸਟ ਅਯਾਤ ਕਰੋ - ਪਲੇਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ - ਕਲਾਕਾਰ ਵੇਖੋ - ਐਲਬਮ ਵੇਖੋ - ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ - ਸਾਂਝਾ ਕਰੋ - ਮਿਟਾਓ - ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ - ਹੋਰ ਬੋਲ ਚੁਣੋ + + ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ + ਰੇਡੀਓ + ਸ਼ਫਲ ਕਰੋ + + + ਵੇਰਵੇ + ਸੰਪਾਦਿਤ ਕਰੋ + ਰੇਡੀਓ ਸ਼ੁਰੂ ਕਰੋ + ਚਲਾਓ + ਅਗਲਾ ਚਲਾਓ + ਕਤਾਰ ਵਿੱਚ ਜੋੜ੍ਹੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ + ਡਾਊਨਲੋਡ + ਡਾਊਨਲੋਡ ਹਟਾਓ + ਪਲੇਲਿਸਟ ਅਯਾਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ + ਕਲਾਕਾਰ ਵੇਖੋ + ਐਲਬਮ ਵੇਖੋ + ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ + ਸਾਂਝਾ ਕਰੋ + ਮਿਟਾਓ + ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ + Sync + + + ਮਿਤੀ + ਨਾਮ + ਕਲਾਕਾਰ + ਸਾਲ + ਗਿਣਤੀ + ਲੰਬਾਈ + ਚਲਾਉਣ ਦਾ ਸਮਾਂ - ਵੇਰਵੇ ਮੀਡੀਆ ਆਈਡੀ ਮਾਈਮ ਪ੍ਰਕਾਰ ਕੋਡੈਕਸ @@ -129,162 +84,143 @@ ਆਵਾਜ਼ ਫਾਈਲ ਆਕਾਰ ਅਗਿਆਤ - ਬੋਲਾਂ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ - ਬੋਲ ਖੋਜੋ - ਬੋਲ ਚੁਣੋ - ਗੀਤ ਸੰਪਾਦਿਤ ਕਰੋ + ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ + + ਬੋਲਾਂ ਨੂੰ ਸੰਪਾਦਿਤ ਕਰੋ + ਬੋਲ ਖੋਜੋ + + ਗੀਤ ਸੰਪਾਦਿਤ ਕਰੋ ਗੀਤ ਦਾ ਸਿਰਲੇਖ ਗੀਤ ਕਲਾਕਾਰ ਗੀਤ ਦਾ ਸਿਰਲੇਖ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। ਗੀਤ ਕਲਾਕਾਰ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। - ਸਾਂਭੋ - ਪਲੇਲਿਸਟ ਬਣਾਓ - ਪਲੇਲਿਸਟ ਦਾ ਨਾਮ + ਸਾਂਭੋ + + ਪਲੇਲਿਸਟ ਚੁਣੋ + ਪਲੇਲਿਸਟ ਸੰਪਾਦਿਤ ਕਰੋ + ਪਲੇਲਿਸਟ ਬਣਾਓ + ਪਲੇਲਿਸਟ ਦਾ ਨਾਮ ਪਲੇਲਿਸਟ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। - ਕਲਾਕਾਰ ਸੰਪਾਦਿਤ ਕਰੋ - ਕਲਾਕਾਰ ਦਾ ਨਾਮ - ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। - ਪਹਿਲਾਂ ਮੌਜੂਦ ਕਲਾਕਾਰ - ਕਲਾਕਾਰ %1$s ਪਹਿਲਾਂ ਹੀ ਮੌਜੂਦ ਹੈ - ਪਲੇਲਿਸਟ ਚੁਣੋ - ਪਲੇਲਿਸਟ ਸੰਪਾਦਿਤ ਕਰੋ - ਬੈਕਅੱਪ ਸਮੱਗਰੀ ਚੁਣੋ - ਰੀਸਟੋਰ ਸਮੱਗਰੀ ਚੁਣੋ - ਤਰਜੀਹਾਂ - ਡਾਟਾਬੇਸ - ਡਾਊਨਲੋਡ ਕੀਤੇ ਗੀਤ - ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ - ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ - ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ - - ਸੰਗੀਤ ਪਲੇਅਰ - ਡਾਊਨਲੋਡ + ਕਲਾਕਾਰ ਸੰਪਾਦਿਤ ਕਰੋ + ਕਲਾਕਾਰ ਦਾ ਨਾਮ + ਕਲਾਕਾਰ ਦਾ ਨਾਮ ਖਾਲੀ ਨਹੀਂ ਹੋ ਸਕਦਾ। - + %d ਗੀਤ %d ਗੀਤ - + %d ਕਲਾਕਾਰ %d ਕਲਾਕਾਰ - + %d ਐਲਬਮ %d ਐਲਬਮ - + %d ਪਲੇਲਿਸਟ %d ਪਲੇਲਿਸਟਾਂ - - ਦੁਬਾਰਾ ਕੋਸ਼ਿਸ਼ ਕਰੋ - ਚਲਾਓ - ਸਾਰੇ ਚਲਾਓ - ਰੇਡੀਓ - ਸ਼ਫਲ ਕਰੋ - ਸਟੈਕਟਰੇਸ ਕਾਪੀ ਕਰੋ - ਰਿਪੋਰਟ ਕਰੋ - ਗਿਟਹੱਬ ਤੇ ਰਿਪੋਰਟ ਕਰੋ - - - ਮਿਤੀ - ਨਾਮ - ਕਲਾਕਾਰ - ਸਾਲ - ਗਿਣਤੀ - ਲੰਬਾਈ - ਚਲਾਉਣ ਦਾ ਸਮਾਂ - - - %d ਗੀਤ ਮਿਟਾ ਦਿੱਤਾ ਗਿਆ ਹੈ। - %d ਗੀਤ ਮਿਟਾ ਦਿੱਤੇ ਗਏ ਹਨ। - - - %d ਚੁਣਿਆ ਗਿਆ - %d ਚੁਣੇ ਗਏ - - ਵਾਪਿਸ - ਇਸ url ਦੀ ਪਛਾਣ ਨਹੀਂ ਕੀਤੀ ਜਾ ਸਕਦੀ। - - ਅੱਗੇ %d ਗੀਤ ਚੱਲੇਗਾ - ਅੱਗੇ %d ਗੀਤ ਚੱਲਣਗੇ - - - %d ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲੇਗਾ - %d ਕਲਾਕਾਰ ਅੱਗੇ ਚੱਲਣਗੇ - - - ਅੱਗੇ %d ਐਲਬਮ ਚੱਲੇਗੀ - ਅੱਗੇ %d ਐਲਬਮਾਂ ਚੱਲਣਗੀਆਂ - - - %d ਪਲੇਲਿਸਟ ਅੱਗੇ ਚੱਲੇਗੀ - %d ਪਲੇਲਿਸਟਾਂ ਅੱਗੇ ਚੱਲਣਗੀਆਂ - - ਇਸ ਤੋਂ ਬਾਅਦ ਚੁਣਿਆ ਗਿਆ ਗੀਤ ਚੱਲੇਗਾ - - %d ਗੀਤ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - %d ਗੀਤਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - - - %d ਕਲਾਕਾਰ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - %d ਕਲਾਕਾਰਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - - - %d ਐਲਬਮ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀ - %d ਐਲਬਮਾਂ ਕਤਾਰ ਵਿੱਚ ਜੋੜੀਆਂ - - - %d ਪਲੇਲਿਸਟ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - %d ਪਲੇਲਿਸਟਾਂ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - - ਚੁਣੇ ਹੋਏ ਨੂੰ ਕਤਾਰ ਵਿੱਚ ਜੋੜਿਆ - ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕੀਤਾ ਗਿਆ - ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਇਆ ਗਿਆ ਪਲੇਲਿਸਟ ਅਯਾਤ ਕੀਤੀ ਗਈ - %1$s ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤਾ ਗਿਆ - - %d ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨਾ ਸ਼ੁਰੂ ਕਰੋ - %d ਗੀਤ ਡਾਊਨਲੋਡ ਕਰਨੇ ਸ਼ੁਰੂ ਕਰੋ + + + ਬੋਲ ਨਹੀਂ ਮਿਲੇ + Sleep timer + End of song + + 1 minute + %d minutes - ਡਾਊਨਲੋਡ ਹਟਾਇਆ ਗਿਆ - ਵਿਊਜ਼ + ਕੋਈ ਸਟ੍ਰੀਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ + ਕੋਈ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਨਹੀਂ + ਸਮਾਂ ਸਮਾਪਤ + ਅਗਿਆਤ ਤਰੁੱਟੀ - + ਪਸੰਦ ਪਸੰਦ ਹਟਾਓ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਿਲ ਕਰੋ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ ਹਟਾਓ - - ਸਾਰੇ - ਗੀਤ - ਵੀਡੀਓ - ਐਲਬਮ - ਕਲਾਕਾਰ - ਪਲੇਲਿਸਟਾਂ - ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ - ਪ੍ਰਦਰਸ਼ਿਤ ਪਲੇਲਿਸਟਾਂ - - ਸਿਸਟਮ ਡੀਫ਼ਾਲਟ - ਤੁਹਾਡੀ ਲਾਇਬ੍ਰੇਰੀ ਤੋਂ - - - ਮਾਫ਼ ਕਰਨਾ, ਅਜਿਹਾ ਨਹੀਂ ਹੋਣਾ ਚਾਹੀਦਾ ਸੀ। - ਕਲਿੱਪਬੋਰਡ \'ਤੇ ਕਾਪੀ ਕੀਤਾ ਗਿਆ - ਕੋਈ ਸਟ੍ਰੀਮ ਉਪਲਬਧ ਨਹੀਂ ਹੈ - ਕੋਈ ਨੈੱਟਵਰਕ ਕਨੈਕਸ਼ਨ ਨਹੀਂ - ਸਮਾਂ ਸਮਾਪਤ - ਅਗਿਆਤ ਤਰੁੱਟੀ - ਸਾਰੇ ਗੀਤ ਖੋਜੇ ਗਏ ਗੀਤ - - ਬੋਲ ਨਹੀਂ ਮਿਲੇ + + ਸੰਗੀਤ ਪਲੇਅਰ + + + ਸੈਟਿੰਗਾਂ + ਦਿੱਖ + ਗੂੜ੍ਹਾ ਥੀਮ + ਚਾਲੂ + ਬੰਦ + ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ + ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹਣ ਵਾਲੀ ਟੈਬ + ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਆਪਣੀ ਪਸੰਦ ਦਾ ਢਾਲੋ + ਬੋਲਾਂ ਦੀ ਟੈਕਸਟ ਸਥਿਤੀ + ਖੱਬੇ + ਵਿਚਕਾਰ + ਸੱਜੇ + + ਸਮੱਗਰੀ + ਲਾਗ-ਇਨ + ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਭਾਸ਼ਾ + ਡੀਫ਼ਾਲਟ ਸਮੱਗਰੀ ਦੇਸ਼ + ਸਿਸਟਮ ਡੀਫ਼ਾਲਟ + ਪ੍ਰੌਕਸੀ ਚਾਲੂ ਕਰੋ + ਪ੍ਰੌਕਸੀ ਕਿਸਮ + ਪ੍ਰੌਕਸੀ URL + ਪ੍ਰਭਾਵੀ ਹੋਣ ਲਈ ਮੁੜ-ਚਾਲੂ ਕਰੋ + + ਪਲੇਅਰ ਅਤੇ ਆਡੀਓ + ਆਡੀਓ ਕੁਆਲਿਟੀ + ਆਟੋ + ਉੱਚ + ਘੱਟ + ਨਿਰੰਤਰ ਕਤਾਰ + ਚੁੱਪ ਤੇ ਸਕਿੱਪ ਕਰੋ + ਆਡੀਓ ਨਾਰਮਲਾਈਜ਼ੇਸ਼ਨ + ਈਕੋਲਾਈਜ਼ਰ + + ਸਟੋਰੇਜ + ਕੈਸ਼ੇ + Image Cache + Song Cache + Max cache size + Unlimited + ਅਧਿਕਤਮ ਚਿੱਤਰ ਕੈਸ਼ੇ ਆਕਾਰ + ਚਿੱਤਰ ਕੈਸ਼ੇ ਸਾਫ਼ ਕਰੋ + ਅਧਿਕਤਮ ਗੀਤ ਕੈਸ਼ੇ ਆਕਾਰ + Clear song cache + %s ਵਰਤਿਆ ਗਿਆ + + ਜਨਰਲ + ਆਟੋ ਡਾਊਨਲੋਡ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕੀਤੇ ਜਾਣ ਤੇ ਗੀਤ ਡਾਊਨਲੋਡ ਕਰੋ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਗੀਤ ਆਟੋ ਸ਼ਾਮਲ ਕਰੋ + ਗੀਤ ਪੂਰਾ ਚੱਲ ਜਾਣ ਤੇ ਆਪਣੀ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਸ਼ਾਮਲ ਕਰੋ + ਚਲਾਉਣ ਤੇ ਹੇਠਲੇ ਪਲੇਅਰ ਦਾ ਵਿਸਤਾਰ ਕਰੋ + ਨੋਟੀਫਿਕੇਸ਼ਨ ਵਿੱਚ ਹੋਰ ਕਾਰਵਾਈਆਂ + ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਵਿਖਾਓ + + ਗੋਪਨੀਯਤਾ + ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ + ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ + ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? + KuGou ਬੋਲ ਪ੍ਰਦਾਤਾ ਨੂੰ ਸਮਰੱਥ ਬਣਾਓ + + ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ + ਬੈਕਅੱਪ + ਰੀਸਟੋਰ + ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ + ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ + ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ + + ਦੇ ਬਾਰੇ + ਐਪ ਸੰਸਕਰਣ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index b816996b2..28051724e 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,132 +1,80 @@ - - - Início - Músicas - Artistas - Álbuns - Playlists - Explorar - Configurações - Tocando agora - Reportar erro - - - Aparência - Seguir o tema do sistema - Cor do tema - Tema escuro - On - Off - Seguir o sistema - Aba padrão ao iniciar - Costumar barra de navegação - Lyrics text position - Left - Center - Right - - Conteúdo - Login - Idioma padrão do conteúdo - País padrão do conteúdo - Ativar proxy - Tipo de proxy - URL do proxy - Reiniciar para ativar proxy - - Player e áudio - Qualidade do áudio - Automática - Alta - Baixa - Fila persistente - Skip silence - Audio normalization - Equalizador - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Tamanho máximo do chace de imagens - Limpar cache de imagens - Tamanho máximo do cache de músicas - %s usados - - Geral - Fazer download automaticamente - Fazer download de música quando adicionada à biblioteca - Adicionar música à biblioteca automaticamente - Adicionar música à sua biblioteca quando terminar de ser reproduzida - Expandir player ao reproduzir - More actions in notification - Show add to library and like buttons - - Privacidade - Pausar histórico de pesquisa - Limpar histórico de pesquisa - Tem certeza que deseja deletar todo o seu histórico de pesquisa? - Enable KuGou lyrics provider - - Backup e restauração - Backup - Restaurar - - Sobre - Versão do aplicativo + + + Início + Músicas + Artistas + Álbuns + Playlists + + + + %d selecionada + %d selecionadas + - - Sakura - Vermelho - Rosa - Roxo - Roxo Escuro - Azul-anil - Azul - Azul Claro - Ciano - Verde-azulado - Verde - Verde Claro - Verde-limão - Amarelo - Âmbar - Laranja - Laranja Escuro - Marrom - Azul-acinzentado + + History + Stats + New release álbuns + Most played songs - - Pesquisar + + Pesquisar Pesquisar no YouTube Music… Pesquisar na biblioteca… + Tudo + Músicas + Vídeos + Álbuns + Artistas + Playlists + Playlists da comunidade + Playlists em destaque + No results found + + + Da sua biblioteca - + Músicas favoritas Músicas baixadas + The playlist is empty - - Detalhes - Editar - Iniciar rádio - Tocar - Tocar em seguida - Adicionar à fila - Adicionar à biblioteca - Download - Remover download - Importar playlist - Adicionar à playlist - Ver artista - Ver álbum - Atualizar - Compartilhar - Excluir - Search online - Choose other lyrics + + Tentar novamente + Rádio + Aleatório + + + Detalhes + Editar + Iniciar rádio + Tocar + Tocar em seguida + Adicionar à fila + Adicionar à biblioteca + Download + Remover download + Importar playlist + Adicionar à playlist + Ver artista + Ver álbum + Atualizar + Compartilhar + Excluir + Search online + Sync + + + Quando adicionada + Nome + Artista + Ano + Número da faixa + Tamanho + Tempo de reprodução - Detalhes Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume Tamanho do arquivo Desconhecido + Copiado para à área de transferência - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Editar música + Editar música Título da Música Artista da Música O título da música não pode ficar vazio. O artista da música não pode ficar vazio. - Salvar + Salvar - Criar playlist - Nome da playlist + Escolher playlist + Editar playlist + Criar playlist + Nome da playlist O nome da playlist não pode ficar vazio. - Editar artista - Nome do artista + Editar artista + Nome do artista O nome do artista não pode ficar vazio. - Artistas duplicados - O artista %1$s já existe. - - Escolher playlist - - Editar playlist - - Escolha o conteúdo do backup - Escolha o conteúdo a ser restaurado - Preferências - Banco de dados - Músicas baixadas - Backup criado com sucesso - Não foi possível criar o backup - Falha ao restaurar o backup - - - Reprodutor de Música - Download - - + %d música %d músicas - + %d artista %d artistas - + %d álbum %d álbuns - + %d playlist %d playlists - - Tentar novamente - Tocar - Tocar tudo - Rádio - Aleatório - Copiar stacktrace - Reportar - Reportar no GitHub - - - Quando adicionada - Nome - Artista - Ano - Número da faixa - Tamanho - Tempo de reprodução - - - %d música foi excluída. - %d músicas foram excluídas. - - - %d selecionada - %d selecionadas - - Desfazer - Não foi possível identificar essa url. - - %d música será reproduzida em seguida - %d músicas serão reproduzidas em seguida - - - %d artista será reproduzido em seguida - %d artistas serão reproduzidos em seguida - - - %d álbum será reproduzido em seguida - %d álguns serão reproduzidos em seguida - - - %d playlist será reproduzida em seguida - %d playlists serão reproduzidas em seguida - - Selecionados irão ser reproduzidos em seguida - - %d música adicionada à fila - %d músicas adicionadas à fila - - - %d artista adicionado à fila - %d artistas adicionados à fila - - - %d álbum adicionado à fila - %d álbuns adicionados à fila - - - %d playlist adicionada à fila - %d playlists adicionadas à fila - - Selecionadas adicionadas à fila - Adicionado à biblioteca - Removido da biblioteca Playlist importada - Adicionado em %1$s - - Iniciando o download de %d músicas - Iniciando o download de %d música + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - Download removido - Ver + Nenhum canal de reprodução disponível + Sem conexão com à internet + Tempo esgotado + Erro desconhecido - + Favoritar Remover dos favoritos Adicionar à biblioteca Remover da biblioteca - - Tudo - Músicas - Vídeos - Álbuns - Artistas - Playlists - Playlists da comunidade - Playlists em destaque - - Padrão do sistema - Da sua biblioteca - - - Desculpa, isso não deveria ter acontecido. - Copiado para à área de transferência - - - Nenhum canal de reprodução disponível - Sem conexão com à internet - Tempo esgotado - Erro desconhecido - Todas as músicas Músicas pesquisadas - - Lyrics not found + + Reprodutor de Música - - No results found + + Configurações + Aparência + Tema escuro + On + Off + Seguir o sistema + Aba padrão ao iniciar + Costumar barra de navegação + Lyrics text position + Left + Center + Right + + Conteúdo + Login + Idioma padrão do conteúdo + País padrão do conteúdo + Padrão do sistema + Ativar proxy + Tipo de proxy + URL do proxy + Reiniciar para ativar proxy + + Player e áudio + Qualidade do áudio + Automática + Alta + Baixa + Fila persistente + Skip silence + Audio normalization + Equalizador + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Tamanho máximo do chace de imagens + Limpar cache de imagens + Tamanho máximo do cache de músicas + Clear song cache + %s usados + + Geral + Fazer download automaticamente + Fazer download de música quando adicionada à biblioteca + Adicionar música à biblioteca automaticamente + Adicionar música à sua biblioteca quando terminar de ser reproduzida + Expandir player ao reproduzir + More actions in notification + Show add to library and like buttons + + Privacidade + Pausar histórico de pesquisa + Limpar histórico de pesquisa + Tem certeza que deseja deletar todo o seu histórico de pesquisa? + Enable KuGou lyrics provider + + Backup e restauração + Backup + Restaurar + Backup criado com sucesso + Não foi possível criar o backup + Falha ao restaurar o backup + + Sobre + Versão do aplicativo diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 54b021226..ec932006c 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -1,132 +1,82 @@ - - - Главная - Музыка - Исполнители - Альбомы - Плейлисты - Исследовать - Настройки - Сейчас воспроизводится - Отчет об ошибках - - - Внешний вид - Цветовой акцент системы - Цветовой акцент темы - Темная тема - Вкл. - Выкл. - Использовать настройки системы - Вкладка навигации по умолчанию - Настройка вкладок навигации - Расположение текста песни - Слева - По центру - Справа - - Контент - Логин - Язык контента - Страна контента - Включить прокси - Тип прокси - URL прокси - Перезапуск приложения - - Плеер и аудио - Качество аудио - Авто - Высокое - Низкое - Постоянная очередь - Пропуск тишины в композициях - Нормализация аудио - Эквалайзер - - Хранилище - Просмотр загруженных файлов в SAF - На некоторых устройствах эта функция может не работать - Кэш - Макс. размер кэша изображений - Очистить кэш изображений - Макс. размер кэша аудио - %s использовано - - Общие - Автоматическая загрузка - Загружать композицию после добавления в библиотеку - Автоматическое добавление композиции в библиотеку - Добавить композицию в библиотеку после завершения воспроизведения - Развернуть нижнюю панель во время воспроизведения - Действия в шторке уведомлений - Показывать кнопки «Добавить в библиотеку» и «Нравится» - - Конфиденциальность - Приостановить сохранение истории поиска - Очистить историю поиска - Вы уверены, что хотите очистить всю историю поиска? - Включить провайдера текстов KuGou - - Резервное копирование - Создать резервную копию - Восстановить из резервной копии - - О приложении - Версия приложения + + + Главная + Музыка + Исполнители + Альбомы + Плейлисты + + + + %d выбрано + %d выбрано + %d выбрано + %d выбрано + - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey + + History + Stats + New release albums + Most played songs - - Поиск + + Поиск Поиск в YouTube Music… Поиск в библиотеке… - - + Все + Композиции + Видео + Альбомы + Исполнители + Плейлисты + Плейлисты сообщества + Избранные плейлисты + No results found + + + Из вашей библиотеки + + Любимые треки Загруженная музыка + The playlist is empty - - Подробнее - Редактировать - Запустить радио - Воспоизвести - Воспроизвести следующим - Добавить в очередь - Добавить в библиотеку - Загрузить - Удалить из загруженных - Импортировать плейлист - Добавить в плейлист - Перейти на страницу исполнителя - Перейти к альбому - Обновить - Поделиться - Удалить - Поиск в Интернете - Выбрать другой текст песни + + Повторить + Радио + Перемешать + + + Подробнее + Редактировать + Запустить радио + Воспоизвести + Воспроизвести следующим + Добавить в очередь + Добавить в библиотеку + Загрузить + Удалить из загруженных + Импортировать плейлист + Добавить в плейлист + Перейти на страницу исполнителя + Перейти к альбому + Обновить + Поделиться + Удалить + Поиск в Интернете + Sync + + + Недавно добавленные + Название + Исполнитель + Год + Количество треков + Длительность + Количество воспр. - Подробнее Media id MIME type Codecs @@ -136,202 +86,152 @@ Volume File size Unknown + Скопировано - Редактировать текст песни - Поиск текста песни - Выбрать текст песни + Редактировать текст песни + Поиск текста песни - Редактировать композицию + Редактировать композицию Song title Song artists Укажите название композиции Укажите исполнителя композиции - Сохранить + Сохранить - Создать плейлист - Playlist name + Выбрать плейлист + Редактировать плейлист + Создать плейлист + Playlist name Укажите название плейлиста - Редактировать исполнителя - Artist name + Редактировать исполнителя + Artist name Укажите имя исполнителя - Дубликаты исполнителей - Исполнитель %1$s уже существует. - - Выбрать плейлист - - Редактировать плейлист - - Выбор содержимого резервной копии - Выбор содержимого для восстановления - Настройки - Данные - Загруженные композиции - Резервная копия создана успешно - Не удалось создать резервную копию - Не удалось восстановить резервную копию - - - Music Player - Загрузка - - + %d композиция %d композиции %d композиций %d композиций - + %d исполнитель %d исполнителя %d исполнителей %d исполнителей - + %d альбом %d альбома %d альбомов %d альбомов - + %d плейлист %d плейлиста %d плейлистов %d плейлистов - - Повторить - Воспроизвести - Воспроизвести все - Радио - Перемешать - Копировать stack trace - Отчет об ошибках - Отчет об ошибках на GitHub - - - Недавно добавленные - Название - Исполнитель - Год - Количество треков - Длительность - Количество воспр. - - - %d композиция будет удалена. - %d композиции будут удалены. - %d композиций будет удалено. - %d композиций будет удалено. - - - %d выбрано - %d выбрано - %d выбрано - %d выбрано - - Отменить - Невозможно определить URL. - - %d композиция прозвучит следующей - %d композиции прозвучат следующими - %d композиций прозвучат следующими - %d композиций прозвучат следующими - - - %d исполнитель прозвучит следующим - %d исполнителя прозвучат следующими - %d исполнителей прозвучат следующими - %d исполнителей прозвучат следующими - - - %d альбом прозвучит следующим - %d альбома прозвучат следующими - %d альбомов прозвучат следующими - %d альбомов прозвучат следующими - - - %d плейлист прозвучит следующим - %d плейлиста прозвучат следующими - %d плейлистов прозвучат следующими - %d плейлистов прозвучат следующими - - Выбранная композиция прозвучит следующей - - %d композиция добавлена в очередь - %d композиции добавлено в очередь - %d композиций добавлено в очередь - %d композиций добавлено в очередь - - - %d исполнитель добавлен в очередь - %d исполнителя добавлено в очередь - %d исполнителей добавлено в очередь - %d исполнителей добавлено в очередь - - - %d альбом добавлен в очередь - %d альбома добавлено в очередь - %d альбомов добавлено в очередь - %d альбомов добавлено в очередь - - - %d плейлист добавлен в очередь - %d плейлиста добавлено в очередь - %d плейлистов добавлено в очередь - %d плейлистов добавлено в очередь - - Добавлено в очередь - Добавлено в библиотеку - Удалено из библиотеки Плейлист импортирован - Добавлено в %1$s - - Начало загрузки %d композиции - Начало загрузки %d композиций - Начало загрузки %d композиций - Начало загрузки %d композиций + + + Текст песни не найден + Sleep timer + End of song + + 1 minute + %d minutes + %d minutes - Удалено из загруженных - Просмотр + Нет доступных потоков + Нет подключения к сети + Тайм-аут + Неизвестная ошибка - + Поставить «Нравится» Убрать «Нравится» Добавить в библиотеку Удалить из библиотеки - - Все - Композиции - Видео - Альбомы - Исполнители - Плейлисты - Плейлисты сообщества - Избранные плейлисты - - По умолчанию системы - Из вашей библиотеки - - - Извините, этого не должно было случиться. - Скопировано - - - Нет доступных потоков - Нет подключения к сети - Тайм-аут - Неизвестная ошибка - Все композиции Искомые композиции - - Текст песни не найден + + Music Player + + + Настройки + Внешний вид + Темная тема + Вкл. + Выкл. + Использовать настройки системы + Вкладка навигации по умолчанию + Настройка вкладок навигации + Расположение текста песни + Слева + По центру + Справа + + Контент + Логин + Язык контента + Страна контента + По умолчанию системы + Включить прокси + Тип прокси + URL прокси + Перезапуск приложения + + Плеер и аудио + Качество аудио + Авто + Высокое + Низкое + Постоянная очередь + Пропуск тишины в композициях + Нормализация аудио + Эквалайзер + + Хранилище + Кэш + Image Cache + Song Cache + Max cache size + Unlimited + Макс. размер кэша изображений + Очистить кэш изображений + Макс. размер кэша аудио + Clear song cache + %s использовано + + Общие + Автоматическая загрузка + Загружать композицию после добавления в библиотеку + Автоматическое добавление композиции в библиотеку + Добавить композицию в библиотеку после завершения воспроизведения + Развернуть нижнюю панель во время воспроизведения + Действия в шторке уведомлений + Показывать кнопки «Добавить в библиотеку» и «Нравится» + + Конфиденциальность + Приостановить сохранение истории поиска + Очистить историю поиска + Вы уверены, что хотите очистить всю историю поиска? + Включить провайдера текстов KuGou + + Резервное копирование + Создать резервную копию + Восстановить из резервной копии + Резервная копия создана успешно + Не удалось создать резервную копию + Не удалось восстановить резервную копию + + О приложении + Версия приложения diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index dc229657b..350bf6555 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -1,132 +1,80 @@ - - Home - Låtar - Artister - Albums - Spellistor - Utforska - Inställningar - Nu spelas - Error report - - - Utseende - Följ systemtema - Tema - Mörkt tema - - Av - Följ systemet - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Innehåll - Login - Standard innehållsspråk - Standard innehållsland - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Automatisk nedladdning - Ladda ner låt när det läggs till i biblioteket - Lägg till låt i biblioteket automatiskt - Lägg till låten i biblioteket efter det spelats klart - Utöka bottenspelaren vid låtspel - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - Om - App version + + Home + Låtar + Artister + Albums + Spellistor + + + + %d vald + %d valda + - - Sakura - Röd - Rosa - Lila - Djuplila - Indigo - Blå - Ljusblå - Turkos - Blågrön - Grön - Ljusgrön - Lime - Gul - Bärnsten - Orange - Djuporange - Brun - Blågrå + + History + Stats + New release albums + Most played songs - - Sök + + Sök Search YouTube Music… Search library… + Alla + Låtar + Videor + Album + Artister + Spellistor + Community playlists + Featured playlists + No results found + + + From your library - + Liked songs Downloaded songs + The playlist is empty - - Details - Redigera - Start radio - Play - Spela upp nästa - Lägg till i kön - Add to library - Ladda ned - Ta bort nedladdning - Import playlist - Lägg till i spellista - View artist - View album - Refetch - Share - Ta bort - Search online - Choose other lyrics + + Försök igen + Radio + Blanda + + + Details + Redigera + Start radio + Play + Spela upp nästa + Lägg till i kön + Add to library + Ladda ned + Ta bort nedladdning + Import playlist + Lägg till i spellista + View artist + View album + Refetch + Share + Ta bort + Search online + Sync + + + Datum tillagd + Namn + Artist + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -136,175 +84,143 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Redigera låten + Redigera låten Låt titel Låt artist Låt titel kan inte vara tom. Låt artist kan inte vara tom. - Spara + Spara - Skapa spellista - Namn på spellista + Välj spellista + Redigera spellista + Skapa spellista + Namn på spellista Namnet på spellistan kan inte vara tom. - Redigera artist - Artist namn + Redigera artist + Artist namn Artist namn kan inte vara tom. - Duplicerat artist - Artist %1$s finns redan. - - Välj spellista - - Redigera spellista - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Music Spelare - Nedladdning - - + %d låt %d låtar - + %d artist %d artists - + %d album %d albums - + %d playlist %d playlists - - Försök igen - Play - Spela upp alla - Radio - Blanda - Copy stacktrace - Report - Report on GitHub - - - Datum tillagd - Namn - Artist - Year - Song count - Length - Play time - - - %d låt har tagits bort. - %d låtar har tagits bort. - - - %d vald - %d valda - - Ångra - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like Lägg till i biblioteket Remove from library - - Alla - Låtar - Videor - Album - Artister - Spellistor - Community playlists - Featured playlists - - Systemets standardinställning - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + Music Spelare - - No results found + + Inställningar + Utseende + Mörkt tema + + Av + Följ systemet + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Innehåll + Login + Standard innehållsspråk + Standard innehållsland + Systemets standardinställning + Enable proxy + Proxy type + Proxy URL + Restart to take effect + + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s used + + General + Automatisk nedladdning + Ladda ner låt när det läggs till i biblioteket + Lägg till låt i biblioteket automatiskt + Lägg till låten i biblioteket efter det spelats klart + Utöka bottenspelaren vid låtspel + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + Om + App version diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 9bc153dbb..dd9870d15 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -1,132 +1,82 @@ - - - Головна - Музика - Виконавці - Альбоми - Плейлисти - Огляд - Параметри - Зараз відтворюється - Звіт про помилки - - - Зовнішній вигляд - Кольоровий акцент системи - Кольоровий акцент теми - Темна тема - Увімк. - Вимк. - Використовувати конфігурацію системи - Вкладка навігації за замовчуванням - Налаштування вкладок навігації - Розташування тексту пісні - Ліворуч - По центру - Праворуч - - Контент - Логін - Мова контенту - Країна контенту - Увімкнути проксі - Тип проксі - URL проксі - Перезапуск програми - - Плеєр та аудіо - Якість аудіо - Авто - Висока - Низька - Постійна черга - Пропуск тиші в композиціях - Нормалізація аудіо - Еквалайзер - - Сховище - Перегляд завантажених файлів у SAF - На деяких пристроях ця функція може не працювати - Кеш - Макс. розмір кешу зображень - Очистити кеш зображень - Макс. розмір кешу аудіо - %s використано - - Загальні - Автоматичне завантаження - Завантажувати композицію після додавання до бібліотеки - Автоматичне додавання композиції до бібліотеки - Додати композицію до бібліотеки після завершення відтворення - Розгорнути нижню панель під час відтворення - Дії в панелі сповіщень - Показувати кнопки «Додати до бібліотеки» та «Подобається» - - Конфіденційність - Призупинити запис історії пошуку - Очистити історію пошуку - Ви впевнені, що хочете очистити всю історію пошуку? - Увімкнути провайдера текстів KuGou - - Резервне копіювання - Створити резервну копію - Відновити з резервної копії - - Про програму - Версія програми + + + Головна + Музика + Виконавці + Альбоми + Плейлисти + + + + %d вибрано + %d вибрано + %d вибрано + %d вибрано + - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey + + History + Stats + New release albums + Most played songs - - Пошук + + Пошук Пошук в YouTube Music… Пошук в бібліотеці… - - + Всі + Композиції + Відео + Альбоми + Виконавці + Плейлисти + Плейлисти спільноти + Обрані плейлисти + No results found + + + З вашої бібліотеки + + Улюблені треки Завантажена музика + The playlist is empty - - Детальніше - Редагувати - Увімкнути радіо - Відтворити - Відтворити наступним - Додати в чергу - Додати до бібліотеки - Завантажити - Видалити із завантажених - Імпортувати плейлист - Додати в плейлист - Перейти на сторінку виконавця - Перейти до альбому - Оновити - Поділитися - Видалити - Пошук в Інтернеті - Вибрати інший текст пісні + + Повторювати + Радіо + Перемішати + + + Детальніше + Редагувати + Увімкнути радіо + Відтворити + Відтворити наступним + Додати в чергу + Додати до бібліотеки + Завантажити + Видалити із завантажених + Імпортувати плейлист + Додати в плейлист + Перейти на сторінку виконавця + Перейти до альбому + Оновити + Поділитися + Видалити + Пошук в Інтернеті + Sync + + + Нещодавно додані + Назва + Виконавець + Рік + Кількість треків + Тривалість + Кількість відтворень - Детальніше Media id MIME type Codecs @@ -136,202 +86,152 @@ Volume File size Unknown + Скопійовано - Редагувати текст пісні - Пошук тексту пісні - Вибрати текст пісні + Редагувати текст пісні + Пошук тексту пісні - Редагувати композицію + Редагувати композицію Song title Song artists Вкажіть назву композиції Вкажіть виконавця композиції - Зберегти + Зберегти - Створити плейлист - Playlist name + Вибрати плейлист + Редагувати плейлист + Створити плейлист + Playlist name Вкажіть назву плейлиста - Редагувати виконавця - Artist name + Редагувати виконавця + Artist name Вкажіть ім\'я виконавця - Дублікати виконавців - Виконавець %1$s вже існує. - - Вибрати плейлист - - Редагувати плейлист - - Вибір вмісту резервної копії - Вибір вмісту для відновлення - Параметри - Дані - Завантажені композиції - Резервну копію створено успішно - Не вдалося створити резервну копію - Не вдалося відновити з резервної копії - - - Music Player - Завантаження - - + %d композиція %d композиції %d композицій %d композицій - + %d виконавець %d виконавця %d виконавців %d виконавців - + %d альбом %d альбоми %d альбомів %d альбомів - + %d плейлист %d плейлисти %d плейлистів %d плейлистів - - Повторювати - Відтворити - Відтворити все - Радіо - Перемішати - Копіювати stack trace - Звіт про помилки - Звіт про помилки на GitHub - - - Нещодавно додані - Назва - Виконавець - Рік - Кількість треків - Тривалість - Кількість відтворень - - - %d композицію буде видалено. - %d композиції будуть видалені. - %d композицій буде видалено. - %d композицій буде видалено. - - - %d вибрано - %d вибрано - %d вибрано - %d вибрано - - Скасувати - Неможливо визначити URL. - - %d композиція лунатиме наступною - %d композиції лунатимуть наступними - %d композицій лунатимуть наступними - %d композицій лунатимуть наступними - - - %d виконавець лунатиме наступним - %d виконавці лунатимуть наступними - %d виконавців лунатимуть наступними - %d виконавців лунатимуть наступними - - - %d альбом лунатиме наступним - %d альбоми лунатимуть наступними - %d альбомів лунатимуть наступними - %d альбомів лунатимуть наступними - - - %d плейлист лунатиме наступним - %d плейлисти лунатимуть наступними - %d плейлистів лунатимуть наступними - %d плейлистів лунатимуть наступними - - Обрана композиція лунатиме наступною - - %d композицію додано в чергу - %d композиції додано в чергу - %d композицій додано в чергу - %d композицій додано в чергу - - - %d виконавця додано в чергу - %d виконавців додано в чергу - %d виконавців додано в чергу - %d виконавців додано в чергу - - - %d альбом додано в чергу - %d альбоми додано в чергу - %d альбомів додано в чергу - %d альбомів додано в чергу - - - %d плейлист додано в чергу - %d плейлисти додано в чергу - %d плейлистів додано в чергу - %d плейлистів додано в чергу - - Додано в чергу - Додано до бібліотеки - Видалено з бібліотеки Плейлист імпортовано - Додано в %1$s - - Початок завантаження %d композиції - Початок завантаження %d композицій - Початок завантаження %d композицій - Початок завантаження %d композицій + + + Текст пісні не знайдено + Sleep timer + End of song + + 1 minute + %d minutes + %d minutes - Видалено із завантажених - Огляд + Немає доступних потоків + Відсутнє підключення до мережі + Тайм-аут + Невідома помилка - + Поставити «Подобається» Прибрати «Подобається» Додати до бібліотеки Видалити з бібліотеки - - Всі - Композиції - Відео - Альбоми - Виконавці - Плейлисти - Плейлисти спільноти - Обрані плейлисти - - Використовувати конфігурацію системи - З вашої бібліотеки - - - Вибачте, цього не мало статися. - Скопійовано - - - Немає доступних потоків - Відсутнє підключення до мережі - Тайм-аут - Невідома помилка - Всі композиції Шукані пісні - - Текст пісні не знайдено + + Music Player + + + Параметри + Зовнішній вигляд + Темна тема + Увімк. + Вимк. + Використовувати конфігурацію системи + Вкладка навігації за замовчуванням + Налаштування вкладок навігації + Розташування тексту пісні + Ліворуч + По центру + Праворуч + + Контент + Логін + Мова контенту + Країна контенту + Використовувати конфігурацію системи + Увімкнути проксі + Тип проксі + URL проксі + Перезапуск програми + + Плеєр та аудіо + Якість аудіо + Авто + Висока + Низька + Постійна черга + Пропуск тиші в композиціях + Нормалізація аудіо + Еквалайзер + + Сховище + Кеш + Image Cache + Song Cache + Max cache size + Unlimited + Макс. розмір кешу зображень + Очистити кеш зображень + Макс. розмір кешу аудіо + Clear song cache + %s використано + + Загальні + Автоматичне завантаження + Завантажувати композицію після додавання до бібліотеки + Автоматичне додавання композиції до бібліотеки + Додати композицію до бібліотеки після завершення відтворення + Розгорнути нижню панель під час відтворення + Дії в панелі сповіщень + Показувати кнопки «Додати до бібліотеки» та «Подобається» + + Конфіденційність + Призупинити запис історії пошуку + Очистити історію пошуку + Ви впевнені, що хочете очистити всю історію пошуку? + Увімкнути провайдера текстів KuGou + + Резервне копіювання + Створити резервну копію + Відновити з резервної копії + Резервну копію створено успішно + Не вдалося створити резервну копію + Не вдалося відновити з резервної копії + + Про програму + Версія програми diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index d7b99f5d5..736b93d56 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -1,132 +1,79 @@ - - - 首页 - 歌曲 - 音乐人 - 专辑 - 播放列表 - 探索 - 设置 - 正在播放 - 错误报告 - - - 外观 - 跟随系统主题 - 主题颜色 - 深色主题 - - - 跟随系统 - 默认启动选项卡 - 自定义导航选项卡 - 歌词文字位置 - 靠左 - 置中 - 靠右 - - 内容 - 登录 - 默认内容语言 - 默认内容国家 - 启用代理 - 代理类型 - 代理链接 - 重启以应用变更 - - 播放器与音频 - 音质 - 自动 - - - 保留播放队列 - 跳过歌曲头尾无声片段 - 标准化音量 - 均衡器 - - 存储 - 查看已下载的歌曲 - 在某些设备可能无法打开 - 缓存 - 最大图像缓存大小 - 清除图像缓存 - 最大歌曲缓存大小 - 已使用 %s - - 通用 - 自动下载 - 自动下载新添加的歌曲 - 自动将音乐添加到媒体库 - 播放结束时添加到媒体库 - 播放音乐时展开播放器 - 在通知显示更多按钮 - 显示“添加到媒体库”和“喜欢”按钮 - - 隐私 - 暂停搜索记录 - 清除搜索记录 - 您确定要清除所有搜索记录吗? - 使用酷狗音乐提供歌词 - - 备份与还原 - 备份 - 还原 - - 关于 - 应用版本 + + + 首页 + 歌曲 + 音乐人 + 专辑 + 播放列表 + + + + 已选择 %d 个项目 + - - 樱花 - - - - 深紫 - 靛青 - - 浅蓝 - - 青绿 - 绿 - 浅绿 - 石灰绿 - - 橙黄 - - 深橙 - - 蓝灰 + + History + Stats + New release albums + Most played songs - - 搜索 + + 搜索 搜索 YouTube Music… 搜索媒体库… + 全部 + 歌曲 + 视频 + 专辑 + 音乐人 + 播放列表 + 社区播放列表 + 精选播放列表 + No results found - + + 来自您的媒体库 + + 喜欢的歌曲 已下载的歌曲 + The playlist is empty - - 详情 - 编辑 - 收听电台 - 播放 - 接下来播放 - 添加到队列 - 添加到媒体库 - 下载 - 移除下载 - 导入播放列表 - 添加到播放列表 - 浏览音乐人 - 浏览专辑 - 刷新 - 分享 - 移除 - 在线搜索 - 切换其他歌词 + + 重试 + 电台 + 随机播放 + + + 详情 + 编辑 + 收听电台 + 播放 + 接下来播放 + 添加到队列 + 添加到媒体库 + 下载 + 移除下载 + 导入播放列表 + 添加到播放列表 + 浏览音乐人 + 浏览专辑 + 刷新 + 分享 + 移除 + 在线搜索 + Sync + + + 新建时间 + 名称 + 音乐人 + 年份 + 歌曲总数 + 长度 + 播放时间 - 详情 媒体 ID 媒体类型 编码 @@ -136,169 +83,138 @@ 音量 文件大小 未知 + 已复制到剪贴板 - 编辑歌词 - 搜索歌词 - 选择歌词 + 编辑歌词 + 搜索歌词 - 编辑歌曲 + 编辑歌曲 歌名 音乐人 歌名不能为空 音乐人不能为空 - 保存 + 保存 - 新建播放列表 - 名称 + 选择播放列表 + 编辑播放列表 + 新建播放列表 + 名称 播放列表名称不能为空 - 编辑音乐人 - 音乐人名称 + 编辑音乐人 + 音乐人名称 音乐人名称不能为空 - 重复的音乐人 - 音乐人“%1$s”已存在。 - - 选择播放列表 - - 编辑播放列表 - - 选择备份内容 - 选择还原内容 - 设置 - 数据库 - 已下载的歌曲 - 成功新建备份 - 无法新建备份 - 无法还原备份 - - - 音乐播放器 - 下载 - - + %d 首歌曲 - + %d 位音乐人 - + %d 张专辑 - + %d 个播放列表 - - 重试 - 播放 - 全部播放 - 电台 - 随机播放 - 复制堆栈追踪 - 报告 - 在 GitHub 上报告 - - - 新建时间 - 名称 - 音乐人 - 年份 - 歌曲总数 - 长度 - 播放时间 - - - 已移除 %d 首歌曲 - - - 已选择 %d 个项目 - - 撤销 - 无法识别此链接 - - 即将播放此歌曲 - 即将播放此 %d 首歌曲 - - - 即将播放此音乐人的歌曲 - 即将播放此 %d 位音乐人的歌曲 - - - 即将播放此专辑 - 即将播放此 %d 张专辑 - - - 即将播放此播放列表 - 即将播放此 %d 个播放列表 - - 即将播放所选项目 - - 接下来播放此歌曲 - 接下来播放此 %d 首歌曲 - - - 接下来播放此音乐人的歌曲 - 接下来播放此 %d 位音乐人的歌曲 - - - 接下来播放此专辑 - 接下来播放此 %d 张专辑 - - - 接下来播放此播放列表 - 接下来播放此 %d 个播放列表 - - 接下来播放所选项目 - 已添加到媒体库 - 已从媒体库中移除 已导入此播放列表 - 已添加到“%1$s” - - 正在下载此歌曲 - 正在下载此 %d 首歌曲 + + + 无歌词 + Sleep timer + End of song + + %d 分钟 - 已移除下载 - 查看 + 无可用音源 + 无网络连接 + 连接超时 + 未知错误 - + 喜欢 取消喜欢 添加到媒体库 从媒体库中移除 - - 全部 - 歌曲 - 视频 - 专辑 - 音乐人 - 播放列表 - 社区播放列表 - 精选播放列表 - - 系统默认 - 来自您的媒体库 - - - 抱歉,这不该发生。 - 已复制到剪贴板 - - - 无可用音源 - 无网络连接 - 连接超时 - 未知错误 - 全部歌曲 搜索的歌曲 - - 无歌词 + + 音乐播放器 - - No results found + + 设置 + 外观 + 深色主题 + + + 跟随系统 + 默认启动选项卡 + 自定义导航选项卡 + 歌词文字位置 + 靠左 + 置中 + 靠右 + + 内容 + 登录 + 默认内容语言 + 默认内容国家 + 系统默认 + 启用代理 + 代理类型 + 代理链接 + 重启以应用变更 + + 播放器与音频 + 音质 + 自动 + + + 保留播放队列 + 跳过歌曲头尾无声片段 + 标准化音量 + 均衡器 + + 存储 + 缓存 + Image Cache + Song Cache + Max cache size + Unlimited + 最大图像缓存大小 + 清除图像缓存 + 最大歌曲缓存大小 + Clear song cache + 已使用 %s + + 通用 + 自动下载 + 自动下载新添加的歌曲 + 自动将音乐添加到媒体库 + 播放结束时添加到媒体库 + 播放音乐时展开播放器 + 在通知显示更多按钮 + 显示“添加到媒体库”和“喜欢”按钮 + + 隐私 + 暂停搜索记录 + 清除搜索记录 + 您确定要清除所有搜索记录吗? + 使用酷狗音乐提供歌词 + + 备份与还原 + 备份 + 还原 + 成功新建备份 + 无法新建备份 + 无法还原备份 + + 关于 + 应用版本 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index c117bbb5e..8cd144b7a 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -1,132 +1,79 @@ - - - 首頁 - 歌曲 - 藝人 - 專輯 - 播放清單 - 探索 - 設定 - 現正播放 - 錯誤報告 - - - 外觀 - 跟隨系統主題 - 主題顏色 - 深色主題 - - - 跟隨系統 - 預設啟動標籤 - 自訂導覽列 - 歌詞文字位置 - 靠左 - 置中 - 靠右 - - 內容 - 登入 - 預設內容語言 - 預設內容國家 - 啟用 Proxy - Proxy 種類 - Proxy URL - 重啟以套用變更 - - 播放與音訊 - 音質 - 自動 - - - 保留播放佇列 - 跳過無聲片段 - 標準化音量 - 等化器 - - 儲存 - 檢視已下載的歌曲 - 在某些裝置可能無法開啟 - 快取 - 圖片快取大小 - 清除圖片快取 - 歌曲快取大小 - 已使用 %s - - 一般 - 自動下載 - 自動下載新增的歌曲 - 自動將音樂加入媒體庫 - 在結束播放時加入你的音樂 - 在點擊音樂時展開播放器 - 在通知顯示更多按鈕 - 顯示「加入媒體庫」和「喜歡」按鈕 - - 隱私 - 暫停搜尋紀錄 - 清除搜尋紀錄 - 您確定要清除所有搜尋紀錄嗎? - 使用酷狗音樂提供歌詞 - - 備份與還原 - 備份 - 還原 - - 關於 - 應用程式版本 + + + 首頁 + 歌曲 + 藝人 + 專輯 + 播放清單 + + + + 已選取 %d 個項目 + - - 櫻花 - 紅色 - 粉色 - 紫色 - 深紫 - 靛青 - 藍色 - 淺藍 - 青色 - 青綠 - 綠色 - 淺綠 - 萊姆綠 - 黃色 - 琥珀 - 橙色 - 深橙 - 棕色 - 藍灰 + + 歷史記錄 + 統計 + 新專輯 + 最常播放 - - 搜尋 + + 搜尋 搜尋 YouTube Music… 搜尋媒體庫… + 全部 + 歌曲 + 影片 + 專輯 + 藝人 + 播放清單 + 社群播放清單 + 精選播放清單 + 找不到結果 - + + 來自你的媒體庫 + + 喜歡的歌曲 已下載的歌曲 + 播放清單為空 - - 詳細資訊 - 編輯 - 開啟電台 - 播放 - 接著播放 - 加入待播清單 - 加入媒體庫 - 下載 - 刪除下載 - 匯入播放清單 - 加入播放清單 - 瀏覽藝人 - 瀏覽專輯 - 更新資料 - 分享 - 移除 - 線上搜尋 - 切換其他歌詞 + + 重試 + 電台 + 隨機播放 + + + 詳細資訊 + 編輯 + 開啟電台 + 播放 + 接著播放 + 加入待播清單 + 加入媒體庫 + 下載 + 刪除下載 + 匯入播放清單 + 加入播放清單 + 瀏覽藝人 + 瀏覽專輯 + 更新資料 + 分享 + 移除 + 線上搜尋 + 同步 + + + 新增時間 + 名稱 + 藝人 + 年份 + 歌曲總數 + 長度 + 播放時間 - 詳細資訊 Id MIME 類型 編碼 @@ -136,169 +83,133 @@ 音量 檔案大小 未知 + 已複製至剪貼簿 - 編輯歌詞 - 搜尋歌詞 - 選擇歌詞 + 編輯歌詞 + 搜尋歌詞 - 編輯歌曲 + 編輯歌曲 歌名 藝人 歌名不能為空 藝人不能為空 - 儲存 + 儲存 - 新增播放清單 - 名稱 + 選擇播放清單 + 編輯播放清單 + 新增播放清單 + 名稱 播放清單名稱不能為空 - 編輯藝人 - 藝人名稱 + 編輯藝人 + 藝人名稱 藝人名稱不能為空 - 重複的作曲家 - 作曲家「%1$s」已存在。 - - 選擇播放清單 - - 編輯播放清單 - - 選擇備份項目 - 選擇還原項目 - 設定 - 資料庫 - 已下載的歌曲 - 成功建立備份 - 無法建立備份 - 無法還原備份 - - - 音樂播放器 - 下載 - - + %d 首歌曲 - + %d 位藝人 - + %d 張專輯 - + %d 個播放清單 - - 重試 - 播放 - 全部播放 - 電台 - 隨機播放 - 複製 stacktrace - 回報 - 在 GitHub 上回報 - - - 新增時間 - 名稱 - 藝人 - 年份 - 歌曲總數 - 長度 - 播放時間 - - - 已刪除 %d 首歌曲 - - - 已選取 %d 個項目 - - 復原 - 無法辨識此連結 - - 即將播放此歌曲 - 即將播放這%d首歌曲 - - - 即將播放此藝人的歌曲 - 即將播放這%d位藝人的歌曲 - - - 即將播放此專輯 - 即將播放這%d張專輯 - - - 即將播放此播放清單 - 即將播放這%d個播放清單 - - 即將播放所選項目 - - 已將此歌曲加入待播清單 - 已將這%d首歌曲加入待播清單 - - - 已將此藝人的歌曲加入待播清單 - 已將這%d位藝人的歌曲加入待播清單 - - - 已將此專輯加入待播清單 - 已將這%d張專輯加入待播清單 - - - 已將此播放清單加入待播清單 - 已將這%d個播放清單加入待播清單 - - 已將所選加入待播清單 - 已新增至媒體庫 - 已從媒體庫中移除 已匯入此播放清單 - 已新增至「%1$s」 - - 正在下載歌曲 - 正在下載%d首歌曲 + + + 沒有歌詞 + 睡眠定時器 + 這首歌曲播放完畢 + + %d 分鐘 - 已移除下載 - 查看 + 沒有可用的音源 + 沒有網路連線 + 連線逾時 + 未知的錯誤 - + 喜歡 取消喜歡 加入音樂庫 從音樂庫中移除 - - 全部 - 歌曲 - 影片 - 專輯 - 藝人 - 播放清單 - 社群播放清單 - 精選播放清單 - - 系統預設 - 來自你的媒體庫 - - - 抱歉,這不該發生的 - 已複製至剪貼簿 - - - 沒有可用的音源 - 沒有網路連線 - 連線逾時 - 未知的錯誤 - 全部歌曲 搜尋的歌曲 - - 沒有歌詞 + + 音樂播放器 - - 找不到結果 + + 設定 + 外觀 + 深色主題 + + + 跟隨系統 + 預設啟動標籤 + 自訂導覽列 + 歌詞文字位置 + 靠左 + 置中 + 靠右 + + 內容 + 登入 + 預設內容語言 + 預設內容國家 + 系統預設 + 啟用 Proxy + Proxy 種類 + Proxy URL + 重啟以套用變更 + + 播放與音訊 + 音質 + 自動 + + + 保留播放佇列 + 跳過無聲片段 + 標準化音量 + 等化器 + + 儲存 + 快取 + 圖片快取大小 + 清除圖片快取 + 歌曲快取大小 + 已使用 %s + + 一般 + 自動下載 + 自動下載新增的歌曲 + 自動將音樂加入媒體庫 + 在結束播放時加入你的音樂 + 在點擊音樂時展開播放器 + 在通知顯示更多按鈕 + 顯示「加入媒體庫」和「喜歡」按鈕 + + 隱私 + 暫停搜尋紀錄 + 清除搜尋紀錄 + 您確定要清除所有搜尋紀錄嗎? + 使用酷狗音樂提供歌詞 + + 備份與還原 + 備份 + 還原 + 成功建立備份 + 無法建立備份 + 無法還原備份 + + 關於 + 應用程式版本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 2c5da4d37..559cbd16b 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -1,136 +1,79 @@ - - Home - Songs - Artists - Albums - Playlists - Explore - Settings - Now playing - Error report - - - Appearance - Follow system theme - Theme color - Dark theme - On - Off - Follow system - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right - - Content - Login - Default content language - Default content country - Enable proxy - Proxy type - Proxy URL - Restart to take effect - - Player and audio - Audio quality - Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Image Cache - Song Cache - Max cache size - Unlimited - Max image cache size - Clear image cache - Max song cache size - %s used - - General - Auto download - Download song when added to library - Auto add song to library - Add song to your library when it completes playing - Expand bottom player on play - More actions in notification - Show add to library and like buttons - - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - - About - App version + + Home + Songs + Artists + Albums + Playlists + + + + %d selected + - - Sakura - Red - Pink - Purple - Deep purple - Indigo - Blue - Light blue - Cyan - Teal - Green - Light green - Lime - Yellow - Amber - Orange - Deep orange - Brown - Blue grey + + History + Stats + New release albums + Most played songs - - Search + + Search Search YouTube Music… Search library… + All + Songs + Videos + Albums + Artists + Playlists + Community playlists + Featured playlists + No results found - + + From your library + + Liked songs Downloaded songs + The playlist is empty - - Details - Edit - Start radio - Play - Play next - Add to queue - Add to library - Download - Remove download - Import playlist - Add to playlist - View artist - View album - Refetch - Share - Delete - Search online - Choose other lyrics + + Retry + Radio + Shuffle + + + Details + Edit + Start radio + Play + Play next + Add to queue + Add to library + Download + Remove download + Import playlist + Add to playlist + View artist + View album + Refetch + Share + Delete + Search online + Sync + + + Date added + Name + Artist + Year + Song count + Length + Play time - Details Media id MIME type Codecs @@ -140,189 +83,143 @@ Volume File size Unknown + Copied to clipboard - Edit lyrics - Search lyrics - Choose lyrics + Edit lyrics + Search lyrics - Edit song + Edit song Song title Song artists Song title cannot be empty. Song artist cannot be empty. - Save + Save - Create playlist - Playlist name + Choose playlist + Edit playlist + Create playlist + Playlist name Playlist name cannot be empty. - Edit artist - Artist name + Edit artist + Artist name Artist name cannot be empty. - Duplicate artists - Artist %1$s already exists. - - Choose playlist - - Edit playlist - - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup - - - Music Player - Download - - + %d song %d songs - + %d artist %d artists - + %d album %d albums - + %d playlist %d playlists - - Retry - Play - Play All - Radio - Shuffle - Copy stacktrace - Report - Report on GitHub - - - Date added - Name - Artist - Year - Song count - Length - Play time - - - %d song has been deleted. - %d songs has been deleted. - - - %d selected - - Undo - Can\'t identify this url. - - Song will play next - %d songs will play next - - - Artist will play next - %d artists will play next - - - Album will play next - %d albums will play next - - - Playlist will play next - %d playlists will play next - - Selected will play next - - Song added to queue - %d songs added to queue - - - Artist added to queue - %d artists added to queue - - - Album added to queue - %d albums added to queue - - - Playlist added to queue - %d playlists added to queue - - Selected added to queue - Added to library - Removed from library Playlist imported - Added to %1$s - - Start downloading song - Start downloading %d songs + + + Lyrics not found + Sleep timer + End of song + + 1 minute + %d minutes - Removed download - View + No stream available + No network connection + Timeout + Unknown error - + Like Remove like Add to library Remove from library - - All - Songs - Videos - Albums - Artists - Playlists - Community playlists - Featured playlists - - System default - From your library - - - Sorry, that should not have happened. - Copied to clipboard - - - No stream available - No network connection - Timeout - Unknown error - All songs Searched songs - - Lyrics not found + + Music Player - - No results found + + Settings + Appearance + Dark theme + On + Off + Follow system + Default open tab + Customize navigation tabs + Lyrics text position + Left + Center + Right + + Content + Login + Default content language + Default content country + System default + Enable proxy + Proxy type + Proxy URL + Restart to take effect - The playlist is empty - New release albums - Most played songs - Sleep timer - End of song + Player and audio + Audio quality + Auto + High + Low + Persistent queue + Skip silence + Audio normalization + Equalizer + + Storage + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size Clear song cache - History - Stats - Sync + %s used - - 1 minute - %d minutes - + General + Auto download + Download song when added to library + Auto add song to library + Add song to your library when it completes playing + Expand bottom player on play + More actions in notification + Show add to library and like buttons + + Privacy + Pause search history + Clear search history + Are you sure to clear all search history? + Enable KuGou lyrics provider + + Backup and restore + Backup + Restore + Backup created successfully + Couldn\'t create backup + Failed to restore backup + + About + App version From 355aa432d477e60efcd50e6e1ef8991a31367ec5 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 21:51:22 +0800 Subject: [PATCH 176/323] Fix search bar content insets --- .../java/com/zionhuang/music/MainActivity.kt | 19 +++++++------------ 1 file changed, 7 insertions(+), 12 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index c54c0f690..b14689722 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -156,6 +156,11 @@ class MainActivity : ComponentActivity() { .fillMaxSize() .background(MaterialTheme.colorScheme.background) ) { + val focusManager = LocalFocusManager.current + val density = LocalDensity.current + val windowsInsets = WindowInsets.systemBars + val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } + val navController = rememberNavController() val navBackStackEntry by navController.currentBackStackEntryAsState() @@ -166,7 +171,6 @@ class MainActivity : ComponentActivity() { dataStore[DefaultOpenTabKey].toEnum(defaultValue = NavigationTab.HOME) } - val focusManager = LocalFocusManager.current val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) } @@ -207,10 +211,6 @@ class MainActivity : ComponentActivity() { animationSpec = NavigationBarAnimationSpec ) - val density = LocalDensity.current - val windowsInsets = WindowInsets.systemBars - val bottomInset = with(density) { windowsInsets.getBottom(density).toDp() } - val playerBottomSheetState = rememberBottomSheetState( dismissedBound = 0.dp, collapsedBound = bottomInset + (if (shouldShowNavigationBar) NavigationBarHeight else 0.dp) + MiniPlayerHeight, @@ -223,12 +223,7 @@ class MainActivity : ComponentActivity() { if (!playerBottomSheetState.isDismissed) bottom += MiniPlayerHeight windowsInsets .only(WindowInsetsSides.Horizontal + WindowInsetsSides.Top) - .add( - WindowInsets( - top = AppBarHeight, - bottom = bottom - ) - ) + .add(WindowInsets(top = AppBarHeight, bottom = bottom)) } val scrollBehavior = appBarScrollBehavior( @@ -524,8 +519,8 @@ class MainActivity : ComponentActivity() { targetState = searchSource, modifier = Modifier .fillMaxSize() + .padding(bottom = if (!playerBottomSheetState.isDismissed) MiniPlayerHeight else 0.dp) .navigationBarsPadding() - .imePadding() ) { searchSource -> when (searchSource) { SearchSource.LOCAL -> LocalSearchScreen( From f01ff7f7ff33da584b9bce81e61c358966f1d185 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 22:19:35 +0800 Subject: [PATCH 177/323] Hide keyboard when scrolling in search bar content --- .../ui/screens/search/LocalSearchScreen.kt | 18 +++++++++++++++- .../ui/screens/search/OnlineSearchScreen.kt | 21 +++++++++++++++++-- 2 files changed, 36 insertions(+), 3 deletions(-) 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 a396e9803..bc43ce6ec 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 @@ -6,12 +6,15 @@ import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.* 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.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp @@ -31,8 +34,9 @@ import com.zionhuang.music.ui.component.* import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.viewmodels.LocalFilter import com.zionhuang.music.viewmodels.LocalSearchViewModel +import kotlinx.coroutines.flow.drop -@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun LocalSearchScreen( query: String, @@ -41,6 +45,7 @@ fun LocalSearchScreen( viewModel: LocalSearchViewModel = hiltViewModel(), ) { val context = LocalContext.current + val keyboardController = LocalSoftwareKeyboardController.current val menuState = LocalMenuState.current val coroutineScope = rememberCoroutineScope() val playerConnection = LocalPlayerConnection.current ?: return @@ -50,6 +55,16 @@ fun LocalSearchScreen( val searchFilter by viewModel.filter.collectAsState() val result by viewModel.result.collectAsState() + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + LaunchedEffect(query) { viewModel.query.value = query } @@ -79,6 +94,7 @@ fun LocalSearchScreen( } LazyColumn( + state = lazyListState, modifier = Modifier.weight(1f) ) { result.map.forEach { (filter, items) -> diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt index cd00c5f7b..f4c42aaf7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/search/OnlineSearchScreen.kt @@ -5,11 +5,14 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.ui.Alignment +import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha +import androidx.compose.ui.platform.LocalSoftwareKeyboardController import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue @@ -28,8 +31,9 @@ import com.zionhuang.music.ui.component.SearchBarIconOffsetX import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.viewmodels.MainViewModel import com.zionhuang.music.viewmodels.OnlineSearchSuggestionViewModel +import kotlinx.coroutines.flow.drop -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalComposeUiApi::class) @Composable fun OnlineSearchScreen( query: String, @@ -41,6 +45,7 @@ fun OnlineSearchScreen( mainViewModel: MainViewModel = hiltViewModel(), ) { val database = LocalDatabase.current + val keyboardController = LocalSoftwareKeyboardController.current val playerConnection = LocalPlayerConnection.current ?: return val playWhenReady by playerConnection.playWhenReady.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() @@ -52,11 +57,23 @@ fun OnlineSearchScreen( val viewState by viewModel.viewState.collectAsState() + val lazyListState = rememberLazyListState() + + LaunchedEffect(Unit) { + snapshotFlow { lazyListState.firstVisibleItemScrollOffset } + .drop(1) + .collect { + keyboardController?.hide() + } + } + LaunchedEffect(query) { viewModel.query.value = query } - LazyColumn { + LazyColumn( + state = lazyListState + ) { items( items = viewState.history, key = { it.query } From e12117546b0dbc94b6b902d0c7a07139f80f3630 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 22:40:30 +0800 Subject: [PATCH 178/323] Add credit in readme --- README.md | 37 +++++++++++-------------------------- 1 file changed, 11 insertions(+), 26 deletions(-) diff --git a/README.md b/README.md index e7fb83b6d..5f575ceed 100644 --- a/README.md +++ b/README.md @@ -77,20 +77,6 @@ With this app, you're like getting a free music streaming service. You can liste

-## Installation - -You can install _InnerTune_ using the following methods: - -1. Download the APK file from [GitHub Releases](https://github.com/z-huang/InnerTune/releases). -2. Add [IzzyOnDroid](https://apt.izzysoft.de/fdroid/index/apk/com.zionhuang.music) to your F-Droid repos following the [instruction](https://apt.izzysoft.de/fdroid/index/info), and you can search for this app and receive updates. -3. If you want to have bug fixes or enjoy new features quickly, install debug version from [GitHub Action](https://github.com/z-huang/InnerTune/actions) at your own risk and remember to backup your data more frequently. -4. Clone this repository and build a debug APK. - -How to get updates? - -1. F-Droid application. -2. [GitHub](https://github.com/z-huang/InnerTune) + [Obtainium (beta)](https://github.com/ImranR98/Obtainium) - ## FAQ ### Q: How to scrobble music to LastFM, LibreFM, ListenBrainz or GNU FM? @@ -118,18 +104,17 @@ recommended). #### App -1. Have a fork of this project. -2. If you have Android Studio, right click on the `app/src/main/res/values` folder, select "New"->" - Values Resource File". Input `strings.xml` as file name. Select "Locale", click ">>", choose your - language and region, and click "OK". -3. If not, create a folder named `values--r` under `app/src/main/res`. - Copy `app/src/main/res/values/strings.xml` to the created folder. -4. Replace each English string with the equivalent translation. Note that lines - with `translatable="false"` should be ignored. -5. (Recommended) Build the app to see if something is wrong. -6. Make a pull request with your changes. If you do step 5, the process of accepting your PR will be - faster. +Follow the [instruction](https://developer.android.com/guide/topics/resources/localization) and +create a pull request. If possible, please build the app beforehand and make sure there is no error +before you create a pull request. #### Fastlane (App Description and Changelogs) -Follow the [fastlane instruction](https://gitlab.com/-/snippets/1895688) to add your language and create a pull request. +Follow the [fastlane instruction](https://gitlab.com/-/snippets/1895688) to add your language and +create a pull request. + +## Credit + +I want to give credit to [vfsfitvnm/ViMusic](https://github.com/vfsfitvnm/ViMusic) for being an +example of Jetpack Compose and music player. It helped me a lot on my way to learn Compose and +Android development. \ No newline at end of file From 53199fbcf366e385a9a199bebf99299adaba3ac0 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 8 Feb 2023 22:52:46 +0800 Subject: [PATCH 179/323] Enable edit song button --- .../com/zionhuang/music/ui/menu/SongMenu.kt | 38 +++++++++++++++---- 1 file changed, 30 insertions(+), 8 deletions(-) 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 821d2f0e0..36786872c 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,7 +16,9 @@ import androidx.compose.ui.graphics.ColorFilter import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp @@ -52,6 +54,10 @@ fun SongMenu( val songState = database.song(originalSong.id).collectAsState(initial = originalSong) val song = songState.value ?: originalSong + var showEditDialog by rememberSaveable { + mutableStateOf(false) + } + var showChoosePlaylistDialog by rememberSaveable { mutableStateOf(false) } @@ -74,6 +80,21 @@ fun SongMenu( } } + if (showEditDialog) { + TextFieldDialog( + icon = { Icon(painter = painterResource(R.drawable.ic_edit), contentDescription = null) }, + title = { Text(text = stringResource(R.string.edit_song)) }, + onDismiss = { showEditDialog = false }, + initialTextFieldValue = TextFieldValue(song.song.title, TextRange(song.song.title.length)), + onDone = { title -> + onDismiss() + database.query { + update(song.song.copy(title = title)) + } + } + ) + } + if (showChoosePlaylistDialog) { ListDialog( onDismiss = { showChoosePlaylistDialog = false } @@ -103,11 +124,13 @@ fun SongMenu( onDismiss() coroutineScope.launch { database.query { - insert(PlaylistSongMap( - songId = song.id, - playlistId = playlist.id, - position = playlist.songCount - )) + insert( + PlaylistSongMap( + songId = song.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) } } } @@ -232,10 +255,9 @@ fun SongMenu( } GridMenuItem( icon = R.drawable.ic_edit, - title = R.string.edit, - enabled = false + title = R.string.edit ) { - + showEditDialog = true } GridMenuItem( icon = R.drawable.ic_playlist_add, From f63bfdf2179f611a9154c1d3fc3cac208898a446 Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Wed, 8 Feb 2023 18:45:19 +0300 Subject: [PATCH 180/323] tr --- app/src/main/res/values-tr/strings.xml | 306 ++++++++++++++++++ .../metadata/android/tr/full_description.txt | 18 ++ .../metadata/android/tr/short_description.txt | 1 + 3 files changed, 325 insertions(+) create mode 100644 app/src/main/res/values-tr/strings.xml create mode 100644 fastlane/metadata/android/tr/full_description.txt create mode 100644 fastlane/metadata/android/tr/short_description.txt diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml new file mode 100644 index 000000000..aa48b85ff --- /dev/null +++ b/app/src/main/res/values-tr/strings.xml @@ -0,0 +1,306 @@ + + + Ana Sayfa + Şarkılar + Sanatçılar + Albümler + Çalma Listeleri + Keşfet + Ayarlar + Şimdi çalıyor + Hata raporu + + + Görünüm + Sistem temasını izle + Tema rengi + Koyu tema + Kapalı + Açık + Sistemi izle + Varsayılan açılış sekmesi + Gezinti sekmelerini özelleştir + Şarkı sözleri metin konumu + Sol + Orta + Sağ + + İçerik + Giriş yap + Varsayılan içerik dili + Varsayılan içerik ülkesi + Proxy'yi etkinleştir + Proxy türü + Proxy URL\'si + Etkili olması için yeniden başlat + + Çalar ve ses + Ses kalitesi + Otomatik + Yüksek + Düşük + Kalıcı sıra + Sessizliği atla + Ses normalleştirme + Ekolayzer + + Depolama + İndirilen dosyaları SAF'ta görüntüle + Bazı cihazlarda çalışmayabilir + Önbellek + Maksimum görüntü önbellek boyutu + Görüntü önbelleğini temizle + Maksimum şarkı önbellek boyutu + %s kullanıldı + + Genel + Otomatik indir + Şarkı kütüphaneye eklendiğinde indirin + Kütüphaneye otomatik şarkı ekle + Çalmayı tamamladığında şarkıyı kütüphanenize ekleyin + Çalma esnasında alttaki oynatıcıyı genişlet + Bildirimde daha fazla eylem + Kitaplığa ekle ve beğen düğmelerini gösterir + + Gizlilik + Arama geçmişini duraklat + Arama geçmişini temizle + Tüm arama geçmişini temizlemek istediğinizden emin misiniz? + KuGou şarkı sözü sağlayıcısını etkinleştir + + Yedekleme ve geri yükleme + Yedekle + Geri yükle + + Hakkında + Uygulama sürümü + + + Sakura + Kırmızı + Pembe + Mor + Koyu mor + Çivit mavisi + Mavi + Açık mavi + Camgöbeği + Deniz mavisi + Yeşil + Açık yeşil + Kireç + Sarı + Kehribar + Turuncu + Koyu turuncu + Kahverengi + Mavi gri + + + Arama + YouTube Müzik\'te Ara... + Kütüphanede ara... + + + Beğenilen şarkılar + İndirilen şarkılar + + + Detaylar + Düzenle + Radyoyu başlat + Çal + Sonrakini çal + Sıraya ekle + Kütüphaneye ekle + İndir + İndirmeyi kaldır + Çalma listesini içe aktar + Çalma listesine ekle + Sanatçıyı görüntüle + Albümü görüntüle + Yeniden getir + Paylaş + Sil + Çevrimiçi ara + Başka sözler seç + + + Detaylar + Medya kimliği + MIME kimliği + Kodekler + Bit hızı + Örnek hızı + Ses yüksekliği + Ses seviyesi + Dosya boyutu + Bilinmiyor + + Şarkı sözlerini düzenle + Şarkı sözlerini ara + Şarkı sözlerini seç + + Şarkıyı düzenle + Şarkı adı + Şarkı sanatçıları + Şarkı adı boş olamaz. + Şarkının sanatçısı boş olamaz. + Kaydet + + Çalma listesi oluştur + Çalma listesi adı + Çalma listesi adı boş olamaz. + + Sanatçıyı düzenle + Sanatçı adı + Sanatçı adı boş olamaz. + + Sanatçıları kopyala + %1$s adlı sanatçı zaten mevcut. + + Çalma listesi seç + + Çalma listesini düzenle + + Yedekleme içeriğini seç + Geri yükleme içeriğini seç + Tercihler + Veritabanı + İndirilen şarkılar + Yedek başarıyla oluşturuldu + Yedek oluşturulamadı + Yedek geri yüklenemedi + + + Müzik Çalar + İndir + + + + %d şarkı + %d şarkı + + + %d sanatçı + %d sanatçı + + + %d albüm + %d albüm + + + %d çalma listesi + %d çalma listesi + + + + Yeniden dene + Çal + Hepsini çal + Radyo + Karıştır + Yığın izini kopyala + Bildir + GitHub üzerinden bildir + + + Eklendiği tarih + Ad + Sanatçı + Yıl + Şarkı sayısı + Uzunluk + Çalma süresi + + + + %d şarkı silindi. + %d şarkı silindi. + + + %d seçildi + + Geri al + Bu URL tanımlanamıyor. + + Sıradaki şarkı çalacak + Sırada %d şarkı çalacak + + + Sırada bir sanatçı var + Sırada %d sanatçı var + + + Bir sonraki albüm çalacak + Sırada %d albüm var + + + Bir çalma listesi daha sonra çalacak + Sırada %d çalma listesi var + + Seçilenler sırada çalacak + + Şarkı sıraya eklendi + Sıraya %d şarkı eklendi + + + Sanatçı kuyruğa eklendi + Sıraya %d sanatçı eklendi + + + Albüm sıraya eklendi + Sıraya %d albüm eklendi + + + Çalma listesi kuyruğa eklendi + Sıraya %d çalma listesi eklendi + + Seçilenler sıraya eklendi + Kütüphaneye eklendi + Kütüphaneden kaldırıldı + Çalma listesi içe aktarıldı + %1$s çalma listesine eklendi + + Şarkı indirmeye başla + %d şarkı indirmeye başla + + İndirme kaldırıldı + Görüntüle + + + Beğen + Beğeniyi kaldır + Kütüphaneye ekle + Kütüphaneden kaldır + + + Hepsi + Şarkılar + Videolar + Albümler + Sanatçılar + Çalma Listeleri + Topluluk çalma listeleri + Öne çıkan çalma listeleri + + Sistem varsayılanı + Kütüphanenizden + + + Üzgünüm, bu olmamalıydı. + Panoya kopyalandı + + + Yayın yok + Ağ bağlantısı yok + Zaman aşımı + Bilinmeyen hata + + + Tüm şarkılar + Aranan şarkılar + + + Şarkı sözleri bulunamadı + diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt new file mode 100644 index 000000000..3478bdaf6 --- /dev/null +++ b/fastlane/metadata/android/tr/full_description.txt @@ -0,0 +1,18 @@ +Bu uygulama ile ücretsiz bir müzik akışı hizmeti almış gibi oluyorsunuz. YouTube Müzik'ten müzik dinleyebilir ve kendi kitaplığınızı oluşturabilirsiniz. Ayrıca şarkılar çevrimdışı çalınmak üzere indirilebilir. Şarkılarınızı düzenlemek için çalma listeleri de oluşturabilirsiniz. InnerTune'un amacı, kullanımı kolay, pratik ve reklamsız bir uygulama ile herkesin hiçbir ücret ödemeden müzik dinlemesini sağlamaktır. +
Not: + +Proje şu anda dengesiz bir aşamada. Hatalarla karşılaşırsanız, lütfen GitHub üzerinden bildirin + +
Özellikler: + +* Reklamsız şarkı çalma +* Çevrimdışı oynatmak için müzik indirme +* Yerel kütüphane yönetimi +* İndirilen şarkıları SAF üzerinden dışa aktarma +* Önbellek şarkıları +* (Eşzamanlı) şarkı sözleri +* Ses normalleştirme +* Sessizliği atlama +* Yedekleme ve geri yükleme +* Proxy desteği +* Android Auto desteği \ No newline at end of file diff --git a/fastlane/metadata/android/tr/short_description.txt b/fastlane/metadata/android/tr/short_description.txt new file mode 100644 index 000000000..894979e46 --- /dev/null +++ b/fastlane/metadata/android/tr/short_description.txt @@ -0,0 +1 @@ +Materyal tasarımlı bir YouTube Müzik istemcisi \ No newline at end of file From 3d3b0238e7075dfc4380366d2da2fa56a6e090d4 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 10:41:26 +0800 Subject: [PATCH 181/323] Fix persistent queue --- .../zionhuang/music/models/MediaMetadata.kt | 19 ++-- .../zionhuang/music/playback/SongPlayer.kt | 86 +++++++++++-------- .../com/zionhuang/music/utils/DataStore.kt | 5 ++ 3 files changed, 66 insertions(+), 44 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt index 54aafb146..2aeae7f66 100644 --- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -8,6 +8,7 @@ import androidx.core.os.bundleOf import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.db.entities.* import com.zionhuang.music.ui.utils.resize +import java.io.Serializable @Immutable data class MediaMetadata( @@ -17,16 +18,16 @@ data class MediaMetadata( val duration: Int, val thumbnailUrl: String? = null, val album: Album? = null, -) { +) : Serializable { data class Artist( val id: String?, val name: String, - ) + ) : Serializable data class Album( val id: String, val title: String, - ) + ) : Serializable fun toMediaDescription(): MediaDescriptionCompat = builder .setMediaId(id) @@ -34,11 +35,13 @@ data class MediaMetadata( .setSubtitle(artists.joinToString { it.name }) .setDescription(artists.joinToString { it.name }) .setIconUri(thumbnailUrl?.resize(544, 544)?.toUri()) - .setExtras(bundleOf( - METADATA_KEY_DURATION to duration * 1000L, - METADATA_KEY_ARTIST to artists.joinToString { it.name }, - METADATA_KEY_ALBUM to album?.title - )) + .setExtras( + bundleOf( + METADATA_KEY_DURATION to duration * 1000L, + METADATA_KEY_ARTIST to artists.joinToString { it.name }, + METADATA_KEY_ALBUM to album?.title + ) + ) .build() fun toSongEntity() = SongEntity( diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 326a74dde..177ddf4cd 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -130,10 +130,12 @@ class SongPlayer( .setRenderersFactory(createRenderersFactory()) .setHandleAudioBecomingNoisy(true) .setWakeMode(WAKE_MODE_NETWORK) - .setAudioAttributes(AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), true) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), true + ) .setSeekBackIncrementMs(5000) .setSeekForwardIncrementMs(5000) .build() @@ -143,7 +145,7 @@ class SongPlayer( } private val normalizeFactor = MutableStateFlow(1f) - val playerVolume = MutableStateFlow(context.dataStore[PlayerVolumeKey]?.coerceIn(0f, 1f) ?: 1f) + val playerVolume = MutableStateFlow(context.dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) var sleepTimerJob: Job? = null var sleepTimerTriggerTime by mutableStateOf(-1L) @@ -307,7 +309,7 @@ class SongPlayer( override fun getCustomActions(player: Player): List { val actions = mutableListOf() - if (player.currentMetadata != null && context.dataStore[NotificationMoreActionKey] != false) { + if (player.currentMetadata != null && context.dataStore.get(NotificationMoreActionKey, true)) { actions.add(if (currentSong == null) ACTION_ADD_TO_LIBRARY else ACTION_REMOVE_FROM_LIBRARY) actions.add(if (currentSong?.song?.liked == true) ACTION_UNLIKE else ACTION_LIKE) } @@ -365,10 +367,12 @@ class SongPlayer( if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { val lyrics = lyricsHelper.getLyrics(mediaMetadata) database.query { - upsert(LyricsEntity( - id = mediaMetadata.id, - lyrics = lyrics - )) + upsert( + LyricsEntity( + id = mediaMetadata.id, + lyrics = lyrics + ) + ) } } } @@ -405,7 +409,7 @@ class SongPlayer( playerNotificationManager.invalidate() } } - if (context.dataStore[PersistentQueueKey] != false) { + if (context.dataStore.get(PersistentQueueKey, true)) { runCatching { context.filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> ObjectInputStream(fis).use { oos -> @@ -413,19 +417,25 @@ class SongPlayer( } } }.onSuccess { queue -> - playQueue(ListQueue( - title = queue.title, - items = queue.items.map { it.toMediaItem() }, - startIndex = queue.mediaItemIndex, - position = queue.position - ), playWhenReady = false) + playQueue( + queue = ListQueue( + title = queue.title, + items = queue.items.map { it.toMediaItem() }, + startIndex = queue.mediaItemIndex, + position = queue.position + ), + playWhenReady = false + ) } } } - private fun createOkHttpDataSourceFactory() = OkHttpDataSource.Factory(OkHttpClient.Builder() - .proxy(YouTube.proxy) - .build()) + private fun createOkHttpDataSourceFactory() = + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + ) private fun createCacheDataSource() = CacheDataSource.Factory() .setCache(cache) @@ -480,16 +490,18 @@ class SongPlayer( } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) database.query { - upsert(FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - )) + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + ) + ) } dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } @@ -502,11 +514,13 @@ class SongPlayer( .setEnableFloatOutput(enableFloatOutput) .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) .setOffloadMode(if (enableOffload) OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else OFFLOAD_MODE_DISABLED) - .setAudioProcessorChain(DefaultAudioProcessorChain( - emptyArray(), - SilenceSkippingAudioProcessor(2_000_000, 20_000, DEFAULT_SILENCE_THRESHOLD_LEVEL), - SonicAudioProcessor() - )) + .setAudioProcessorChain( + DefaultAudioProcessorChain( + emptyArray(), + SilenceSkippingAudioProcessor(2_000_000, 20_000, DEFAULT_SILENCE_THRESHOLD_LEVEL), + SonicAudioProcessor() + ) + ) .build() } @@ -744,7 +758,7 @@ class SongPlayer( } fun onDestroy() { - if (context.dataStore[PersistentQueueKey] != false) { + if (context.dataStore.get(PersistentQueueKey, true)) { saveQueueToDisk() } mediaSession.apply { diff --git a/app/src/main/java/com/zionhuang/music/utils/DataStore.kt b/app/src/main/java/com/zionhuang/music/utils/DataStore.kt index 0089cc734..dae632f41 100644 --- a/app/src/main/java/com/zionhuang/music/utils/DataStore.kt +++ b/app/src/main/java/com/zionhuang/music/utils/DataStore.kt @@ -23,6 +23,11 @@ operator fun DataStore.get(key: Preferences.Key): T? = data.first()[key] } +fun DataStore.get(key: Preferences.Key, defaultValue: T): T = + runBlocking(Dispatchers.IO) { + data.first()[key] ?: defaultValue + } + fun preference( context: Context, key: Preferences.Key, From a01f01fee353f111f1362a9181c9d53b52516b7f Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 11:12:14 +0800 Subject: [PATCH 182/323] Fix bitmap provider --- .../music/playback/BitmapProvider.kt | 33 ++++++++++++------- .../zionhuang/music/playback/SongPlayer.kt | 3 +- .../com/zionhuang/music/ui/theme/Theme.kt | 2 +- 3 files changed, 24 insertions(+), 14 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt index c03299c63..6043651de 100644 --- a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt +++ b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt @@ -25,17 +25,21 @@ class BitmapProvider(private val context: Context) { disposable?.dispose() val cache = map.get(url) if (cache == null) { - disposable = context.imageLoader.enqueue(ImageRequest.Builder(context) - .data(url) - .allowHardware(false) - .target(onSuccess = { drawable -> - val bitmap = (drawable as BitmapDrawable).bitmap - map.put(url, bitmap) - callback(bitmap) - currentBitmap = bitmap - onBitmapChanged(bitmap) - }) - .build()) + disposable = context.imageLoader.enqueue( + ImageRequest.Builder(context) + .data(url) + .allowHardware(false) + .target( + onSuccess = { drawable -> + val bitmap = (drawable as BitmapDrawable).bitmap + map.put(url, bitmap) + callback(bitmap) + currentBitmap = bitmap + onBitmapChanged(bitmap) + } + ) + .build() + ) } else { currentBitmap = cache onBitmapChanged(cache) @@ -43,6 +47,13 @@ class BitmapProvider(private val context: Context) { return cache } + fun clear() { + disposable?.dispose() + currentUrl = null + currentBitmap = null + onBitmapChanged(null) + } + companion object { const val MAX_CACHE_SIZE = 15 } diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 177ddf4cd..77135b01c 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -668,8 +668,7 @@ class SongPlayer( } } if (mediaItem == null) { - bitmapProvider.currentBitmap = null - bitmapProvider.onBitmapChanged(null) + bitmapProvider.clear() } if (pauseWhenSongEnd) { pauseWhenSongEnd = false diff --git a/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt b/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt index be5b5d268..062ebb90a 100644 --- a/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt +++ b/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt @@ -18,7 +18,7 @@ import androidx.palette.graphics.Palette import com.google.material.color.scheme.Scheme import com.google.material.color.score.Score -val DefaultThemeColor = Color(0xFF6650a4) +val DefaultThemeColor = Color(0xFF4285F4) @Composable fun InnerTuneTheme( From cf9427bb06904aa419d5a84d925e727bb2fe97ac Mon Sep 17 00:00:00 2001 From: Miguel Cano Santana <55782155+miguelcanosantana@users.noreply.github.com> Date: Thu, 9 Feb 2023 08:15:19 +0100 Subject: [PATCH 183/323] Updated Spanish translations. --- app/src/main/res/values-es/strings.xml | 220 ++++++++++++------------- 1 file changed, 110 insertions(+), 110 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 5bd70707c..b0c29299d 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,14 +1,14 @@ - Home + Inicio Canciones Artistas - Albums + Álbums Listas de reproducción Explorar Ajustes Reproduciendo - Error report + Reportar Error Apariencia @@ -18,40 +18,40 @@ Encendido Apagado Seguir el sistema - Default open tab - Customize navigation tabs - Lyrics text position - Left - Center - Right + Pestaña de inicio por defecto + Personalizar pestañas de navegación + Posición de las letras + Izquierda + Centro + Derecha Contenido - Login + Iniciar sesión Idioma de contenido predeterminado País de contenido predeterminado - Enable proxy - Proxy type - Proxy URL - Restart to take effect + Habilitar proxy + Tipo de proxy + URL del proxy + Reinicia para aplicar los cambios - Player and audio - Audio quality + Reproductor y sonido + Calidad del sonido Auto - High - Low - Persistent queue - Skip silence - Audio normalization - Equalizer - - Storage - View downloaded files in SAF - This may not work in some devices - Cache - Max image cache size - Clear image cache - Max song cache size - %s used + Alta + Baja + Cola persistente + Saltar silencio + Normalización del audio + Ecualizador + + Almacenamiento + Ver archivos descargados en SAF + Esto podría no funcionar en algunos dispositivos + Caché + Tamaño máximo de la caché de imagen + Borrar la caché de imagen + Tamaño máximo de la caché de música + %s usado General Descarga automatica @@ -59,18 +59,18 @@ Agregar canción automáticamente a la biblioteca Agregue una canción a su biblioteca cuando termine de reproducirse Expandir reproductor inferior al reproducir - More actions in notification - Show add to library and like buttons + Más acciones en la notificación + Mostrar añadir a la biblioteca y los botones de me gusta - Privacy - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider + Privacidad + Pausar historial de búsqueda + Borrar historial de búsqueda + ¿Estás seguro de borrar todo el historial de búsqueda? + Habilitar el proveedor de letras KuGou - Backup and restore - Backup - Restore + Respaldar y restaurar + Respaldar + Restaurar Acerca de Version de la app @@ -81,7 +81,7 @@ Rosa Morado Morado oscuro - Indigo + Índigo Azul Azul claro Cian @@ -90,36 +90,36 @@ Verde claro Lima Amarillo - Ambar - Naranjo - Naranjo oscuro - Café + Ámbar + Naranja + Naranja oscuro + Marrón Gris azulado Buscar - Search YouTube Music… - Search library… + Buscar en Youtube Music… + Buscar en biblioteca… - Liked songs - Downloaded songs + Canciones que te gustan + Canciones descargadas - Details + Detalles Editar - Start radio - Play + Comenzar radio + Reproducir Reproducir siguiente Añadir a la cola Agregar a la biblioteca Descargar Quitar descarga - Import playlist + Importar lista de reproducción Agregar a la lista de reproducción - View artist - View album - Refetch + Ver artista + Ver álbum + Cargar de nuevo Share Borrar Search online @@ -127,23 +127,23 @@ Details - Media id + Id del archivo MIME type Codecs Bitrate Sample rate - Loudness - Volume - File size - Unknown + Intensidad + Volumen + Tamaño del archivo + Desconocido - Edit lyrics - Search lyrics - Choose lyrics + Editar letras + Buscar letras + Elegir letras Editar canción Título de la canción - Artista de la canción + Artistas de la canción El título de la canción no puede estar vacío. El artista de la canción no puede estar vacío. Guardar @@ -163,14 +163,14 @@ Editar lista de reproducción - Choose backup content - Choose restore content - Preferences - Database - Downloaded songs - Backup created successfully - Couldn\'t create backup - Failed to restore backup + Elegir contenido para respaldar + Elegir contenido para restaurar + Preferencias + Base de datos + Canciones descargadas + Respaldo creado con éxito + No se pudo crear respaldo + Error al restaurar el respaldo Reproductor de música @@ -199,19 +199,19 @@ Play Reproducir todo Radio - Shuffle - Copy stacktrace - Report - Report on GitHub + Mezclar + Copiar stacktrace + Notificar + Notificar en GitHub - Fecha Agregada + Fecha Añadida Nombre Artista - Year - Song count - Length - Play time + Año + Número de canciones + Duración + Tiempo de reproducción @@ -223,7 +223,7 @@ %d seleccionadas Deshacer - Can\'t identify this url. + No se puede identificar esta url. Song will play next %d songs will play next @@ -240,7 +240,7 @@ Playlist will play next %d playlists will play next - Selected will play next + La selección se reproducirá a continuación Song added to queue %d songs added to queue @@ -257,51 +257,51 @@ Playlist added to queue %d playlists added to queue - Selected added to queue - Added to library - Removed from library - Playlist imported - Added to %1$s + La selección se ha añadido a la cola + Añadido a la biblioteca + Quitado de la biblioteca + Lista de reproducción importada + Añadido a %1$s Start downloading song Start downloading %d songs - Removed download - View + Descarga borrada + Ver - Like - Remove like + Dar me gusta + Quitar me gusta Agregar a la biblioteca - Remove from library + Quitar de la biblioteca Todo Canciones - Videos - Albums + Vídeos + Álbums Artistas Listas de reproducción - Community playlists - Featured playlists + Listas de la comunidad + Listas destacadas - Sistema por defecto - From your library + Por defecto del sistema + De tu biblioteca - Sorry, that should not have happened. - Copied to clipboard + Disculpas, eso no debería de haber pasado. + Copiado al portapapeles - No stream available - No network connection - Timeout - Unknown error + No hay un stream disponible + No hay conexión a internet + Se agotó el tiempo + Error desconocido - All songs - Searched songs + Todas las canciones + Canciones buscadas - Lyrics not found + Letras no encontradas \ No newline at end of file From 933fe1c32f6078790dab060f6c37fbc19a4fd0a2 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 19:17:23 +0800 Subject: [PATCH 184/323] Show player error --- .../music/playback/PlayerConnection.kt | 10 ++- .../zionhuang/music/ui/player/MiniPlayer.kt | 28 +++++++ .../music/ui/player/PlaybackError.kt | 43 ++++++++++ .../zionhuang/music/ui/player/Thumbnail.kt | 83 +++++++++++-------- 4 files changed, 127 insertions(+), 37 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt 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 12405c5c8..41262e30e 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -2,6 +2,7 @@ package com.zionhuang.music.playback import android.graphics.Bitmap import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.PlaybackException import com.google.android.exoplayer2.Player.* import com.google.android.exoplayer2.Timeline import com.zionhuang.music.db.MusicDatabase @@ -18,7 +19,7 @@ import kotlinx.coroutines.flow.flatMapLatest @OptIn(ExperimentalCoroutinesApi::class) class PlayerConnection( val database: MusicDatabase, - val binder: MusicBinder, + private val binder: MusicBinder, ) : Listener { val songPlayer = binder.songPlayer val player = binder.player @@ -53,6 +54,8 @@ class PlayerConnection( binder.songPlayer.bitmapProvider.onBitmapChanged = value } + val error = MutableStateFlow(null) + init { binder.player.addListener(this) binder.songPlayer.bitmapProvider.onBitmapChanged = onBitmapChanged @@ -103,6 +106,7 @@ class PlayerConnection( override fun onPlaybackStateChanged(state: Int) { playbackState.value = state + error.value = player.playerError } override fun onPlayWhenReadyChanged(newPlayWhenReady: Boolean, reason: Int) { @@ -136,6 +140,10 @@ class PlayerConnection( updateCanSkipPreviousAndNext() } + override fun onPlayerErrorChanged(playbackError: PlaybackException?) { + error.value = playbackError + } + private fun updateCanSkipPreviousAndNext() { if (!player.currentTimeline.isEmpty) { val window = player.currentTimeline.getWindow(player.currentMediaItemIndex, Timeline.Window()) 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 6d4f57964..2e8c65b41 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 @@ -1,5 +1,8 @@ package com.zionhuang.music.ui.player +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon @@ -12,6 +15,7 @@ import androidx.compose.runtime.getValue 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.res.painterResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow @@ -39,6 +43,7 @@ fun MiniPlayer( mediaMetadata ?: return val playerConnection = LocalPlayerConnection.current ?: return val canSkipNext by playerConnection.canSkipNext.collectAsState() + val error by playerConnection.error.collectAsState() Box( modifier = modifier @@ -68,7 +73,30 @@ fun MiniPlayer( .size(48.dp) .clip(RoundedCornerShape(ThumbnailCornerRadius)) ) + androidx.compose.animation.AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut() + ) { + Box( + Modifier + .size(48.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + painter = painterResource(R.drawable.ic_info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.Center) + ) + } + } } + Column( modifier = Modifier .weight(1f) diff --git a/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt new file mode 100644 index 000000000..62baf30e9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt @@ -0,0 +1,43 @@ +package com.zionhuang.music.ui.player + +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.material3.Icon +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.google.android.exoplayer2.PlaybackException +import com.zionhuang.music.R + +@Composable +fun PlaybackError( + error: PlaybackException, + retry: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier.pointerInput(Unit) { + detectTapGestures( + onTap = { retry() } + ) + } + ) { + Icon( + painter = painterResource(R.drawable.ic_info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error + ) + + Text( + text = error.message.orEmpty(), + style = MaterialTheme.typography.bodyMedium + ) + } +} 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 34cd7e8f6..b5afb9cbc 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 @@ -8,6 +8,7 @@ import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.RoundedCornerShape 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 @@ -34,6 +35,7 @@ fun Thumbnail( val currentView = LocalView.current val showLyrics by rememberPreference(ShowLyricsKey, false) + val error by playerConnection.error.collectAsState() DisposableEffect(showLyrics) { currentView.keepScreenOn = showLyrics @@ -44,52 +46,61 @@ fun Thumbnail( Box(modifier = modifier) { AnimatedVisibility( - visible = !showLyrics, + visible = !showLyrics && error == null, enter = fadeIn(), - exit = fadeOut() + exit = fadeOut(), + modifier = Modifier + .fillMaxSize() + .windowInsetsPadding( + WindowInsets.systemBars + .only(WindowInsetsSides.Top) + .add(WindowInsets(left = 16.dp, right = 16.dp)) + ) ) { - Box( + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, modifier = Modifier - .fillMaxSize() - .windowInsetsPadding( - WindowInsets.systemBars - .only(WindowInsetsSides.Top) - .add(WindowInsets(left = 16.dp, right = 16.dp)) - ) - ) { - AsyncImage( - model = mediaMetadata.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - .fillMaxWidth() - .align(Alignment.Center) - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { offset -> - if (offset.x < size.width / 2) { - playerConnection.player.seekBack() - } else { - playerConnection.player.seekForward() - } + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + .fillMaxWidth() + .align(Alignment.Center) + .pointerInput(Unit) { + detectTapGestures( + onDoubleTap = { offset -> + if (offset.x < size.width / 2) { + playerConnection.player.seekBack() + } else { + playerConnection.player.seekForward() } - ) - } - ) - } + } + ) + } + ) } AnimatedVisibility( - visible = showLyrics, + visible = showLyrics && error == null, enter = fadeIn(), exit = fadeOut() ) { - Box( - modifier = Modifier.fillMaxSize() - ) { - Lyrics( - sliderPositionProvider = sliderPositionProvider, - mediaMetadataProvider = { mediaMetadata } + Lyrics( + sliderPositionProvider = sliderPositionProvider, + mediaMetadataProvider = { mediaMetadata } + ) + } + + AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .padding(32.dp) + .align(Alignment.Center) + ) { + error?.let { error -> + PlaybackError( + error = error, + retry = playerConnection.player::prepare ) } } From 76cf230d7a2eda0694bf1812db0b9b36c0926548 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 19:35:02 +0800 Subject: [PATCH 185/323] Remove unused test --- .../music/ExampleInstrumentedTest.java | 27 ------------------- .../com/zionhuang/music/ExampleUnitTest.java | 17 ------------ 2 files changed, 44 deletions(-) delete mode 100755 app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java delete mode 100755 app/src/test/java/com/zionhuang/music/ExampleUnitTest.java diff --git a/app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java b/app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java deleted file mode 100755 index 7577f8939..000000000 --- a/app/src/androidTest/java/com/zionhuang/music/ExampleInstrumentedTest.java +++ /dev/null @@ -1,27 +0,0 @@ -package com.zionhuang.music; - -import android.content.Context; - -import androidx.test.platform.app.InstrumentationRegistry; -import androidx.test.ext.junit.runners.AndroidJUnit4; - -import org.junit.Test; -import org.junit.runner.RunWith; - -import static org.junit.Assert.*; - -/** - * Instrumented test, which will execute on an Android device. - * - * @see Testing documentation - */ -@RunWith(AndroidJUnit4.class) -public class ExampleInstrumentedTest { - @Test - public void useAppContext() { - // Context of the app under test. - Context appContext = InstrumentationRegistry.getInstrumentation().getTargetContext(); - - assertEquals("com.zionhuang.music", appContext.getPackageName()); - } -} diff --git a/app/src/test/java/com/zionhuang/music/ExampleUnitTest.java b/app/src/test/java/com/zionhuang/music/ExampleUnitTest.java deleted file mode 100755 index 821d16cb7..000000000 --- a/app/src/test/java/com/zionhuang/music/ExampleUnitTest.java +++ /dev/null @@ -1,17 +0,0 @@ -package com.zionhuang.music; - -import org.junit.Test; - -import static org.junit.Assert.*; - -/** - * Example local unit test, which will execute on the development machine (host). - * - * @see Testing documentation - */ -public class ExampleUnitTest { - @Test - public void addition_isCorrect() { - assertEquals(4, 2 + 2); - } -} \ No newline at end of file From 2e4e8ee6ec18a66d174a6112e9422f2d1f53c8db Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 22:25:39 +0800 Subject: [PATCH 186/323] Fix #564 --- app/src/main/java/com/zionhuang/music/MainActivity.kt | 9 ++++++++- .../main/java/com/zionhuang/music/ui/player/Player.kt | 3 +-- .../java/com/zionhuang/music/ui/screens/HomeScreen.kt | 3 +++ 3 files changed, 12 insertions(+), 3 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index b14689722..f4a3257ec 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -33,6 +33,7 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.IntOffset import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny @@ -577,7 +578,13 @@ class MainActivity : ComponentActivity() { contentDescription = null ) }, - label = { Text(stringResource(screen.titleId)) }, + label = { + Text( + text = stringResource(screen.titleId), + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + }, onClick = { navController.navigate(screen.route) { popUpTo(navController.graph.startDestinationId) { 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 bb8118b3f..af87785e6 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 @@ -6,7 +6,6 @@ import androidx.compose.foundation.background import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.* import androidx.compose.foundation.shape.CircleShape -import androidx.compose.foundation.text.BasicText import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable @@ -170,7 +169,7 @@ fun BottomSheetPlayer( overflow = TextOverflow.Ellipsis, ) - BasicText( + Text( text = if (duration != C.TIME_UNSET) makeTimeString(duration) else "", style = MaterialTheme.typography.labelMedium, maxLines = 1, 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 e7e65096d..2871da929 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 @@ -19,6 +19,7 @@ import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @@ -305,6 +306,8 @@ fun NavigationTile( Text( text = title, style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis ) } } From 391373868e3c77319e85f0828de6c869a94b6e6d Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 23:42:22 +0800 Subject: [PATCH 187/323] Implement #515 --- app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 77135b01c..8f97e339d 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -485,7 +485,7 @@ class SongPlayer( AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 AudioQuality.HIGH -> 1 AudioQuality.LOW -> -1 - } + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream } } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) From 5605a42d8a37a893f331251f1e457e9c73d1f8ae Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 9 Feb 2023 23:50:14 +0800 Subject: [PATCH 188/323] Fix #566 --- .../zionhuang/music/ui/screens/HomeScreen.kt | 42 ++++++++++--------- 1 file changed, 22 insertions(+), 20 deletions(-) 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 2871da929..c3171aabb 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 @@ -253,27 +253,29 @@ fun HomeScreen( Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) } - FloatingActionButton( - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + if (mostPlayedSongs.isNotEmpty() || newReleaseAlbums.isNotEmpty()) { + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + .padding(16.dp), + onClick = { + if (Random.nextBoolean() && mostPlayedSongs.isNotEmpty()) { + val song = mostPlayedSongs.random() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } else if (newReleaseAlbums.isNotEmpty()) { + val album = newReleaseAlbums.random() + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + } + }) { + Icon( + painter = painterResource(R.drawable.ic_casino), + contentDescription = null ) - .padding(16.dp), - onClick = { - if (newReleaseAlbums.isEmpty() || Random.nextBoolean()) { - val song = mostPlayedSongs.random() - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) - } else { - val album = newReleaseAlbums.random() - playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) - } - }) { - Icon( - painter = painterResource(R.drawable.ic_casino), - contentDescription = null - ) + } } } } From 8a0e3c5209b9d6e65c1d35b89420e188c06564d0 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 10 Feb 2023 00:01:43 +0800 Subject: [PATCH 189/323] Fix #503 --- .../main/java/com/zionhuang/music/constants/PreferenceKeys.kt | 1 + 1 file changed, 1 insertion(+) 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 3162cdd2e..950ca59ff 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -108,6 +108,7 @@ val LanguageCodeToName = mapOf( "hu" to "Magyar", "nl" to "Nederlands", "no" to "Norsk", + "or" to "Odia", "uz" to "O‘zbe", "pl" to "Polski", "pt-PT" to "Português", From d002a85ef9df67f35dec552a2de92563cc59be24 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 10 Feb 2023 12:57:59 +0800 Subject: [PATCH 190/323] Swipe artwork to change song (#457) --- .../zionhuang/music/ui/component/Lyrics.kt | 46 ++++---- .../com/zionhuang/music/ui/player/Player.kt | 2 - .../zionhuang/music/ui/player/Thumbnail.kt | 101 ++++++++++++------ .../utils/LazyGridSnapLayoutInfoProvider.kt | 101 +++++++++++++++--- 4 files changed, 179 insertions(+), 71 deletions(-) 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 c9c1f448f..077d9bf74 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 @@ -56,7 +56,6 @@ import kotlin.time.Duration.Companion.seconds @Composable fun Lyrics( sliderPositionProvider: () -> Long?, - mediaMetadataProvider: () -> MediaMetadata, modifier: Modifier = Modifier, ) { val playerConnection = LocalPlayerConnection.current ?: return @@ -65,6 +64,7 @@ fun Lyrics( val lyricsTextPosition by rememberEnumPreference(LyricsTextPositionKey, LyricsPosition.CENTER) + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val lyricsEntity by playerConnection.currentLyrics.collectAsState(initial = null) val lyrics = remember(lyricsEntity) { lyricsEntity?.lyrics @@ -226,24 +226,26 @@ fun Lyrics( ) } - IconButton( - modifier = Modifier - .align(Alignment.BottomEnd) - .padding(12.dp), - onClick = { - menuState.show { - LyricsMenu( - lyricsProvider = { lyricsEntity }, - mediaMetadataProvider = mediaMetadataProvider, - onDismiss = menuState::dismiss - ) + mediaMetadata?.let { mediaMetadata -> + IconButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .padding(12.dp), + onClick = { + menuState.show { + LyricsMenu( + lyricsProvider = { lyricsEntity }, + mediaMetadataProvider = { mediaMetadata }, + onDismiss = menuState::dismiss + ) + } } + ) { + Icon( + painter = painterResource(id = R.drawable.ic_more_horiz), + contentDescription = null + ) } - ) { - Icon( - painter = painterResource(id = R.drawable.ic_more_horiz), - contentDescription = null - ) } } } @@ -272,10 +274,12 @@ fun LyricsMenu( singleLine = false, onDone = { database.query { - upsert(LyricsEntity( - id = mediaMetadataProvider().id, - lyrics = it - )) + upsert( + LyricsEntity( + id = mediaMetadataProvider().id, + lyrics = it + ) + ) } } ) 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 af87785e6..5427b2426 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 @@ -270,7 +270,6 @@ fun BottomSheetPlayer( modifier = Modifier.weight(1f) ) { Thumbnail( - mediaMetadata = mediaMetadata, sliderPositionProvider = { sliderPosition }, modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) ) @@ -304,7 +303,6 @@ fun BottomSheetPlayer( modifier = Modifier.weight(1f) ) { Thumbnail( - mediaMetadata = mediaMetadata, sliderPositionProvider = { sliderPosition }, modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) ) 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 b5afb9cbc..ee4ab440e 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,14 @@ 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.HorizontalPager +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.runtime.Composable -import androidx.compose.runtime.DisposableEffect -import androidx.compose.runtime.collectAsState -import androidx.compose.runtime.getValue +import androidx.compose.runtime.* import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -20,23 +21,53 @@ 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.models.MediaMetadata +import com.zionhuang.music.extensions.metadata import com.zionhuang.music.ui.component.Lyrics +import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.flow.drop +@OptIn(ExperimentalFoundationApi::class) @Composable fun Thumbnail( - mediaMetadata: MediaMetadata?, sliderPositionProvider: () -> Long?, modifier: Modifier = Modifier, ) { - mediaMetadata ?: return val playerConnection = LocalPlayerConnection.current ?: return val currentView = LocalView.current - val showLyrics by rememberPreference(ShowLyricsKey, false) + val windows by playerConnection.queueWindows.collectAsState() + val currentWindowIndex by playerConnection.currentWindowIndex.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) { + try { + pagerState.scrollToPage(currentWindowIndex) + } catch (_: Exception) { + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect { + if (!pagerState.isScrollInProgress) { + playerConnection.player.seekToDefaultPosition(it) + } + } + } + DisposableEffect(showLyrics) { currentView.keepScreenOn = showLyrics onDispose { @@ -51,31 +82,40 @@ fun Thumbnail( exit = fadeOut(), modifier = Modifier .fillMaxSize() - .windowInsetsPadding( - WindowInsets.systemBars - .only(WindowInsetsSides.Top) - .add(WindowInsets(left = 16.dp, right = 16.dp)) - ) + .statusBarsPadding() ) { - AsyncImage( - model = mediaMetadata.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .clip(RoundedCornerShape(ThumbnailCornerRadius)) - .fillMaxWidth() - .align(Alignment.Center) - .pointerInput(Unit) { - detectTapGestures( - onDoubleTap = { offset -> - if (offset.x < size.width / 2) { - playerConnection.player.seekBack() - } else { - playerConnection.player.seekForward() + windows.takeIf { it.isNotEmpty() }?.let { windows -> + HorizontalPager( + state = pagerState, + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + pageCount = windows.size, + key = { windows[it].uid.hashCode() }, + beyondBoundsPageCount = 2 + ) { index -> + Box(Modifier.fillMaxSize()) { + AsyncImage( + model = windows[index].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() + } + } + ) } - } ) } - ) + } + } } AnimatedVisibility( @@ -84,8 +124,7 @@ fun Thumbnail( exit = fadeOut() ) { Lyrics( - sliderPositionProvider = sliderPositionProvider, - mediaMetadataProvider = { mediaMetadata } + sliderPositionProvider = sliderPositionProvider ) } diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt b/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt index 81b583aa0..1cf10ed82 100644 --- a/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt +++ b/app/src/main/java/com/zionhuang/music/ui/utils/LazyGridSnapLayoutInfoProvider.kt @@ -1,32 +1,20 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + package com.zionhuang.music.ui.utils import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.gestures.Orientation import androidx.compose.foundation.gestures.snapping.SnapLayoutInfoProvider +import androidx.compose.foundation.lazy.LazyListItemInfo +import androidx.compose.foundation.lazy.LazyListLayoutInfo import androidx.compose.foundation.lazy.grid.LazyGridItemInfo import androidx.compose.foundation.lazy.grid.LazyGridLayoutInfo import androidx.compose.foundation.lazy.grid.LazyGridState +import androidx.compose.foundation.pager.PagerState import androidx.compose.ui.unit.Density import androidx.compose.ui.util.fastForEach import androidx.compose.ui.util.fastSumBy -fun Density.calculateDistanceToDesiredSnapPosition( - layoutInfo: LazyGridLayoutInfo, - item: LazyGridItemInfo, - positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float, -): Float { - val containerSize = - with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } - - val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) - val itemCurrentPosition = item.offset.x.toFloat() - - return itemCurrentPosition - desiredDistance -} - -private val LazyGridLayoutInfo.singleAxisViewportSize: Int - get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width - @ExperimentalFoundationApi fun SnapLayoutInfoProvider( lazyGridState: LazyGridState, @@ -70,3 +58,82 @@ fun SnapLayoutInfoProvider( } } } + +@ExperimentalFoundationApi +fun SnapLayoutInfoProvider( + pagerState: PagerState, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float = + { layoutSize, itemSize -> (layoutSize / 2f - itemSize / 2f) }, +): SnapLayoutInfoProvider = object : SnapLayoutInfoProvider { + + private val layoutInfo: LazyListLayoutInfo + get() = pagerState.layoutInfo + + // Single page snapping is the default + override fun Density.calculateApproachOffset(initialVelocity: Float): Float = 0f + + override fun Density.calculateSnappingOffsetBounds(): ClosedFloatingPointRange { + var lowerBoundOffset = Float.NEGATIVE_INFINITY + var upperBoundOffset = Float.POSITIVE_INFINITY + + layoutInfo.visibleItemsInfo.fastForEach { item -> + val offset = + calculateDistanceToDesiredSnapPosition(layoutInfo, item, positionInLayout) + + // Find item that is closest to the center + if (offset <= 0 && offset > lowerBoundOffset) { + lowerBoundOffset = offset + } + + // Find item that is closest to center, but after it + if (offset >= 0 && offset < upperBoundOffset) { + upperBoundOffset = offset + } + } + + return lowerBoundOffset.rangeTo(upperBoundOffset) + } + + override fun Density.calculateSnapStepSize(): Float = with(layoutInfo) { + if (visibleItemsInfo.isNotEmpty()) { + visibleItemsInfo.fastSumBy { it.size } / visibleItemsInfo.size.toFloat() + } else { + 0f + } + } +} + +fun Density.calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyListLayoutInfo, + item: LazyListItemInfo, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } + + val desiredDistance = + positionInLayout(containerSize.toFloat(), item.size.toFloat()) + + val itemCurrentPosition = item.offset + return itemCurrentPosition - desiredDistance +} + +private val LazyListLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width + +fun Density.calculateDistanceToDesiredSnapPosition( + layoutInfo: LazyGridLayoutInfo, + item: LazyGridItemInfo, + positionInLayout: Density.(layoutSize: Float, itemSize: Float) -> Float, +): Float { + val containerSize = + with(layoutInfo) { singleAxisViewportSize - beforeContentPadding - afterContentPadding } + + val desiredDistance = positionInLayout(containerSize.toFloat(), item.size.width.toFloat()) + val itemCurrentPosition = item.offset.x.toFloat() + + return itemCurrentPosition - desiredDistance +} + +private val LazyGridLayoutInfo.singleAxisViewportSize: Int + get() = if (orientation == Orientation.Vertical) viewportSize.height else viewportSize.width From 5a790682cf08193a9d8040d84b0e2fda2197e53e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 10 Feb 2023 13:15:16 +0800 Subject: [PATCH 191/323] Pure black theme (#341) --- .../main/java/com/zionhuang/music/MainActivity.kt | 2 ++ .../com/zionhuang/music/constants/PreferenceKeys.kt | 1 + .../music/ui/screens/settings/AppearanceSettings.kt | 10 ++++++++++ .../main/java/com/zionhuang/music/ui/theme/Theme.kt | 13 ++++++++++--- app/src/main/res/drawable/contrast.xml | 9 +++++++++ app/src/main/res/values-DE/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-es-rUS/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa-rIR/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-ko-rKR/strings.xml | 1 + app/src/main/res/values-ml-rIN/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pa/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sv-rSE/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 27 files changed, 54 insertions(+), 3 deletions(-) create mode 100644 app/src/main/res/drawable/contrast.xml diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index f4a3257ec..64cda9d72 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -126,6 +126,7 @@ class MainActivity : ComponentActivity() { setContent { val coroutineScope = rememberCoroutineScope() val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) val isSystemInDarkTheme = isSystemInDarkTheme() val useDarkTheme = remember(darkTheme, isSystemInDarkTheme) { if (darkTheme == DarkMode.AUTO) isSystemInDarkTheme else darkTheme == DarkMode.ON @@ -150,6 +151,7 @@ class MainActivity : ComponentActivity() { InnerTuneTheme( darkTheme = useDarkTheme, + pureBlack = pureBlack, themeColor = themeColor ) { BoxWithConstraints( 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 950ca59ff..7c475f7fd 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -6,6 +6,7 @@ import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey val DarkModeKey = stringPreferencesKey("darkMode") +val PureBlackKey = booleanPreferencesKey("pureBlack") val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") const val SYSTEM_DEFAULT = "SYSTEM_DEFAULT" diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt index e4af5febe..d0d8e54aa 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -15,8 +15,11 @@ import com.zionhuang.music.R import com.zionhuang.music.constants.DarkModeKey import com.zionhuang.music.constants.DefaultOpenTabKey import com.zionhuang.music.constants.LyricsTextPositionKey +import com.zionhuang.music.constants.PureBlackKey import com.zionhuang.music.ui.component.EnumListPreference +import com.zionhuang.music.ui.component.SwitchPreference import com.zionhuang.music.utils.rememberEnumPreference +import com.zionhuang.music.utils.rememberPreference @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -25,6 +28,7 @@ fun AppearanceSettings( scrollBehavior: TopAppBarScrollBehavior, ) { val (darkMode, onDarkModeChange) = rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) + val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false) val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference(DefaultOpenTabKey, defaultValue = NavigationTab.HOME) val (lyricsPosition, onLyricsPositionChange) = rememberEnumPreference(LyricsTextPositionKey, defaultValue = LyricsPosition.CENTER) @@ -46,6 +50,12 @@ fun AppearanceSettings( } } ) + SwitchPreference( + title = stringResource(R.string.pure_black), + icon = R.drawable.contrast, + checked = pureBlack, + onCheckedChange = onPureBlackChange + ) EnumListPreference( title = stringResource(R.string.default_open_tab), icon = R.drawable.ic_tab, diff --git a/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt b/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt index 062ebb90a..71b94d7d0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt +++ b/app/src/main/java/com/zionhuang/music/ui/theme/Theme.kt @@ -23,16 +23,17 @@ val DefaultThemeColor = Color(0xFF4285F4) @Composable fun InnerTuneTheme( darkTheme: Boolean = isSystemInDarkTheme(), + pureBlack: Boolean = false, themeColor: Color = DefaultThemeColor, content: @Composable () -> Unit, ) { val context = LocalContext.current - val colorScheme = remember(darkTheme, themeColor) { + val colorScheme = remember(darkTheme, pureBlack, themeColor) { if (themeColor == DefaultThemeColor && Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) { - if (darkTheme) dynamicDarkColorScheme(context) + if (darkTheme) dynamicDarkColorScheme(context).pureBlack(pureBlack) else dynamicLightColorScheme(context) } else { - if (darkTheme) Scheme.dark(themeColor.toArgb()).toColorScheme() + if (darkTheme) Scheme.dark(themeColor.toArgb()).toColorScheme().pureBlack(pureBlack) else Scheme.light(themeColor.toArgb()).toColorScheme() } } @@ -86,6 +87,12 @@ fun Scheme.toColorScheme() = ColorScheme( scrim = Color(scrim), ) +fun ColorScheme.pureBlack(apply: Boolean) = + if (apply) copy( + surface = Color.Black, + background = Color.Black + ) else this + val ColorSaver = object : Saver { override fun restore(value: Int): Color = Color(value) override fun SaverScope.save(value: Color): Int = value.toArgb() diff --git a/app/src/main/res/drawable/contrast.xml b/app/src/main/res/drawable/contrast.xml new file mode 100644 index 000000000..3ec9d8981 --- /dev/null +++ b/app/src/main/res/drawable/contrast.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 76158e702..2d9df0dad 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -160,6 +160,7 @@ An Aus System folgen + Pure black Standardmäßig geöffnete Registerkarte Anpassen der Navigationsregisterkarten Position des Liedtextes diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 6b225f41d..92e4087c7 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -171,6 +171,7 @@ Zap Vyp Podle systému + Pure black Výchozí karta Přizpůsobit navigační karty Pozice textů diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index dcc6bac3b..062e871fd 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -160,6 +160,7 @@ Encendido Apagado Predeterminado del sistema + Pure black Default open tab Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index f423a5969..7b19f7517 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -160,6 +160,7 @@ Encendido Apagado Seguir el sistema + Pure black Default open tab Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index df3ae8d89..1f3a41984 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -160,6 +160,7 @@ روشن خاموش پیروی از سیستم + Pure black زبانه باز پیش‌فرض Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 253c3dcaa..58a3f4ac7 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -160,6 +160,7 @@ Käytössä Pois käytöstä Järjestelmän teeman mukainen + Pure black Default open tab Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 391ec3621..86abc8c93 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -166,6 +166,7 @@ On Off Follow system + Pure black Default open tab Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index ce1a0a27e..a63088625 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -160,6 +160,7 @@ Be Ki Rendszer szerint + Pure black Alapért. nyitott lap A navigációs lapok testreszabása Dalszöveg szöveg pozíció diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 38e2a26fa..44615145d 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -154,6 +154,7 @@ Aktif Tidak aktif Ikuti sistem + Pure black Tab buka bawaan Sesuaikan tab navigasi Posisi teks lirik diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 1b71e7961..146b1ab7b 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -160,6 +160,7 @@ Attivato Disattivato Segui sistema + Pure black Scheda principale predefinita Personalizza schede di navigazione Posizione del testo dei brani diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 6e9ffec5b..7e20afd99 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -154,6 +154,7 @@ オン オフ システムに従う + Pure black 起動時に開くタブ ナビゲーションタブのカスタマイズ 歌詞テキストの位置 diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 28dbe335f..4fc265a0a 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -154,6 +154,7 @@ 시스템 + Pure black Default open tab Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 77ab2b734..29a6b5768 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -160,6 +160,7 @@ ഓൺ ഓഫ് സിസ്റ്റം പിന്തുടരുക + Pure black സ്ഥിര ഓപ്പൺ ടാബ് Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 00cb8e222..57d2c9b87 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -160,6 +160,7 @@ ଅନ୍ ଅଫ୍ ସିଷ୍ଟମ୍ ଅନୁସରଣ କରନ୍ତୁ + Pure black ଡିଫଲ୍ଟ ଖୋଲା ଟ୍ୟାବ୍ ନାଭିଗେସନ୍ ଟ୍ୟାବ୍ କଷ୍ଟୋମାଇଜ୍ କରନ୍ତୁ ଗୀତ ପାଠ୍ୟ ଅବସ୍ଥାନ diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 666ec41e7..c2181d76f 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -160,6 +160,7 @@ ਚਾਲੂ ਬੰਦ ਸਿਸਟਮ ਦੀ ਪਾਲਣਾ ਕਰੋ + Pure black ਡੀਫ਼ਾਲਟ ਖੁੱਲ੍ਹਣ ਵਾਲੀ ਟੈਬ ਨੈਵੀਗੇਸ਼ਨ ਟੈਬਾਂ ਨੂੰ ਆਪਣੀ ਪਸੰਦ ਦਾ ਢਾਲੋ ਬੋਲਾਂ ਦੀ ਟੈਕਸਟ ਸਥਿਤੀ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 28051724e..32bc1f857 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -160,6 +160,7 @@ On Off Seguir o sistema + Pure black Aba padrão ao iniciar Costumar barra de navegação Lyrics text position diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index ec932006c..133d1bfe1 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -171,6 +171,7 @@ Вкл. Выкл. Использовать настройки системы + Pure black Вкладка навигации по умолчанию Настройка вкладок навигации Расположение текста песни diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index 350bf6555..99b664399 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -160,6 +160,7 @@ Av Följ systemet + Pure black Default open tab Customize navigation tabs Lyrics text position diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index dd9870d15..ceea91613 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -171,6 +171,7 @@ Увімк. Вимк. Використовувати конфігурацію системи + Pure black Вкладка навігації за замовчуванням Налаштування вкладок навігації Розташування тексту пісні diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 736b93d56..5a36ce9da 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -154,6 +154,7 @@ 跟随系统 + Pure black 默认启动选项卡 自定义导航选项卡 歌词文字位置 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8cd144b7a..fb5f42446 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -154,6 +154,7 @@ 跟隨系統 + 純黑 預設啟動標籤 自訂導覽列 歌詞文字位置 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 559cbd16b..a553d1c59 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -159,6 +159,7 @@ On Off Follow system + Pure black Default open tab Customize navigation tabs Lyrics text position From 7c369357306fb5c6dddc7b0af8cb9b186cba4351 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 10 Feb 2023 22:02:28 +0800 Subject: [PATCH 192/323] Fix #457 --- .../main/java/com/zionhuang/music/ui/player/Thumbnail.kt | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) 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 ee4ab440e..b1f97bfc1 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 @@ -53,7 +53,7 @@ fun Thumbnail( ) } - LaunchedEffect(pagerState, currentWindowIndex) { + LaunchedEffect(pagerState, currentWindowIndex, windows) { try { pagerState.scrollToPage(currentWindowIndex) } catch (_: Exception) { @@ -63,7 +63,7 @@ fun Thumbnail( LaunchedEffect(pagerState) { snapshotFlow { pagerState.settledPage }.drop(1).collect { if (!pagerState.isScrollInProgress) { - playerConnection.player.seekToDefaultPosition(it) + playerConnection.player.seekToDefaultPosition(windows[it].firstPeriodIndex) } } } @@ -89,7 +89,7 @@ fun Thumbnail( state = pagerState, flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), pageCount = windows.size, - key = { windows[it].uid.hashCode() }, + key = { it }, beyondBoundsPageCount = 2 ) { index -> Box(Modifier.fillMaxSize()) { From 33900fef14bb90de49496b345dc6e3d567554064 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 10 Feb 2023 22:06:07 +0800 Subject: [PATCH 193/323] Show more accurate playback error message --- .../main/java/com/zionhuang/music/ui/player/PlaybackError.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt index 62baf30e9..989f71b18 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt @@ -11,6 +11,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import com.google.android.exoplayer2.PlaybackException import com.zionhuang.music.R @@ -36,7 +37,7 @@ fun PlaybackError( ) Text( - text = error.message.orEmpty(), + text = error.cause?.cause?.message ?: stringResource(R.string.error_unknown), style = MaterialTheme.typography.bodyMedium ) } From 5522285fc465f5a4a3739e0f0bdce3b494226827 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 10 Feb 2023 22:22:23 +0800 Subject: [PATCH 194/323] Print stack trace for debugging --- .../music/viewmodels/ArtistItemsViewModel.kt | 41 +++++++++++-------- .../music/viewmodels/ArtistViewModel.kt | 7 +++- .../viewmodels/OnlinePlaylistViewModel.kt | 10 +++-- 3 files changed, 38 insertions(+), 20 deletions(-) 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 2434d4c06..e759ecc45 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistItemsViewModel.kt @@ -24,15 +24,20 @@ class ArtistItemsViewModel @Inject constructor( init { viewModelScope.launch { - val artistItemsPage = YouTube.artistItems(BrowseEndpoint( - browseId = browseId, - params = params - )).getOrNull() ?: return@launch - title.value = artistItemsPage.title - itemsPage.value = ItemsPage( - items = artistItemsPage.items, - continuation = artistItemsPage.continuation - ) + YouTube.artistItems( + BrowseEndpoint( + browseId = browseId, + params = params + ) + ).onSuccess { artistItemsPage -> + title.value = artistItemsPage.title + itemsPage.value = ItemsPage( + items = artistItemsPage.items, + continuation = artistItemsPage.continuation + ) + }.onFailure { e -> + e.printStackTrace() + } } } @@ -40,13 +45,17 @@ class ArtistItemsViewModel @Inject constructor( viewModelScope.launch { val oldItemsPage = itemsPage.value ?: return@launch val continuation = oldItemsPage.continuation ?: return@launch - val artistItemsContinuationPage = YouTube.artistItemsContinuation(continuation).getOrNull() ?: return@launch - itemsPage.update { - ItemsPage( - items = (oldItemsPage.items + artistItemsContinuationPage.items).distinctBy { it.id }, - continuation = artistItemsContinuationPage.continuation - ) - } + YouTube.artistItemsContinuation(continuation) + .onSuccess { artistItemsContinuationPage -> + itemsPage.update { + ItemsPage( + items = (oldItemsPage.items + artistItemsContinuationPage.items).distinctBy { it.id }, + continuation = artistItemsContinuationPage.continuation + ) + } + }.onFailure { e -> + e.printStackTrace() + } } } } 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 e6bc34046..d90d047ed 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -27,7 +27,12 @@ class ArtistViewModel @Inject constructor( init { viewModelScope.launch { - artistPage = YouTube.artist(artistId).getOrNull() + YouTube.artist(artistId) + .onSuccess { + artistPage = it + }.onFailure { e -> + e.printStackTrace() + } } } } 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 e750a9578..cd42a4676 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlinePlaylistViewModel.kt @@ -24,9 +24,13 @@ class OnlinePlaylistViewModel @Inject constructor( init { viewModelScope.launch(Dispatchers.IO) { - val playlistPage = YouTube.playlist(playlistId).completed().getOrNull() ?: return@launch - playlist.value = playlistPage.playlist - playlistSongs.value = playlistPage.songs + YouTube.playlist(playlistId).completed() + .onSuccess { playlistPage -> + playlist.value = playlistPage.playlist + playlistSongs.value = playlistPage.songs + }.onFailure { e -> + e.printStackTrace() + } } } } From 0fa28f58006aae58c0c735080a51016fa15d04e5 Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Fri, 10 Feb 2023 19:03:36 +0300 Subject: [PATCH 195/323] apostrophe fix --- app/src/main/res/values-tr/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index aa48b85ff..fbed32379 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -29,7 +29,7 @@ Giriş yap Varsayılan içerik dili Varsayılan içerik ülkesi - Proxy'yi etkinleştir + Proxy\'yi etkinleştir Proxy türü Proxy URL\'si Etkili olması için yeniden başlat @@ -45,7 +45,7 @@ Ekolayzer Depolama - İndirilen dosyaları SAF'ta görüntüle + İndirilen dosyaları SAF\'ta görüntüle Bazı cihazlarda çalışmayabilir Önbellek Maksimum görüntü önbellek boyutu From febfa141d4c3cc9ab80a281cdc11c57c83d09884 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 11 Feb 2023 23:53:13 +0800 Subject: [PATCH 196/323] Try fix #573 --- .../zionhuang/music/ui/player/Thumbnail.kt | 75 +++-- .../music/ui/utils/HorizontalPager.kt | 271 ++++++++++++++++++ 2 files changed, 306 insertions(+), 40 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt 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 b1f97bfc1..d904aa4ae 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 @@ -7,7 +7,6 @@ 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.HorizontalPager import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.runtime.* @@ -23,6 +22,7 @@ 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 @@ -53,17 +53,16 @@ fun Thumbnail( ) } - LaunchedEffect(pagerState, currentWindowIndex, windows) { - try { - pagerState.scrollToPage(currentWindowIndex) - } catch (_: Exception) { + LaunchedEffect(pagerState, currentWindowIndex) { + if (windows.isNotEmpty()) { + pagerState.animateScrollToPage(currentWindowIndex) } } LaunchedEffect(pagerState) { - snapshotFlow { pagerState.settledPage }.drop(1).collect { - if (!pagerState.isScrollInProgress) { - playerConnection.player.seekToDefaultPosition(windows[it].firstPeriodIndex) + snapshotFlow { pagerState.settledPage }.drop(1).collect { index -> + if (!pagerState.isScrollInProgress && index != currentWindowIndex && windows.isNotEmpty()) { + playerConnection.player.seekToDefaultPosition(windows[index].firstPeriodIndex) } } } @@ -84,36 +83,34 @@ fun Thumbnail( .fillMaxSize() .statusBarsPadding() ) { - windows.takeIf { it.isNotEmpty() }?.let { windows -> - HorizontalPager( - state = pagerState, - flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), - pageCount = windows.size, - key = { it }, - beyondBoundsPageCount = 2 - ) { index -> - Box(Modifier.fillMaxSize()) { - AsyncImage( - model = windows[index].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() - } + 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() } - ) - } - ) - } + } + ) + } + ) } } } @@ -123,9 +120,7 @@ fun Thumbnail( enter = fadeIn(), exit = fadeOut() ) { - Lyrics( - sliderPositionProvider = sliderPositionProvider - ) + Lyrics(sliderPositionProvider = sliderPositionProvider) } AnimatedVisibility( diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt b/app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt new file mode 100644 index 000000000..489a0d271 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/utils/HorizontalPager.kt @@ -0,0 +1,271 @@ +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") + +package com.zionhuang.music.ui.utils + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.gestures.FlingBehavior +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.ScrollScope +import androidx.compose.foundation.gestures.snapping.SnapFlingBehavior +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyList +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.pager.* +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalLayoutDirection +import androidx.compose.ui.semantics.* +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.LayoutDirection +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.launch +import kotlin.math.roundToInt + +@Composable +@ExperimentalFoundationApi +fun HorizontalPager( + items: List, + modifier: Modifier = Modifier, + state: PagerState = rememberPagerState(), + contentPadding: PaddingValues = PaddingValues(0.dp), + pageSize: PageSize = PageSize.Fill, + beyondBoundsPageCount: Int = 0, + pageSpacing: Dp = 0.dp, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + flingBehavior: SnapFlingBehavior = PagerDefaults.flingBehavior(state = state), + userScrollEnabled: Boolean = true, + reverseLayout: Boolean = false, + key: ((item: T) -> Any)? = null, + pageNestedScrollConnection: NestedScrollConnection = PagerDefaults.pageNestedScrollConnection( + Orientation.Horizontal + ), + pageContent: @Composable (item: T) -> Unit, +) { + Pager( + modifier = modifier, + state = state, + items = items, + pageSpacing = pageSpacing, + userScrollEnabled = userScrollEnabled, + orientation = Orientation.Horizontal, + verticalAlignment = verticalAlignment, + reverseLayout = reverseLayout, + contentPadding = contentPadding, + beyondBoundsPageCount = beyondBoundsPageCount, + pageSize = pageSize, + flingBehavior = flingBehavior, + key = key, + pageNestedScrollConnection = pageNestedScrollConnection, + pageContent = pageContent + ) +} + +@OptIn(ExperimentalFoundationApi::class) +@Composable +internal fun Pager( + modifier: Modifier, + state: PagerState, + items: List, + pageSize: PageSize, + pageSpacing: Dp, + orientation: Orientation, + beyondBoundsPageCount: Int, + verticalAlignment: Alignment.Vertical = Alignment.CenterVertically, + horizontalAlignment: Alignment.Horizontal = Alignment.CenterHorizontally, + contentPadding: PaddingValues, + flingBehavior: SnapFlingBehavior, + userScrollEnabled: Boolean, + reverseLayout: Boolean, + key: ((item: T) -> Any)?, + pageNestedScrollConnection: NestedScrollConnection, + pageContent: @Composable (item: T) -> Unit, +) { + require(beyondBoundsPageCount >= 0) { + "beyondBoundsPageCount should be greater than or equal to 0, " + + "you selected $beyondBoundsPageCount" + } + + val isVertical = orientation == Orientation.Vertical + val density = LocalDensity.current + val layoutDirection = LocalLayoutDirection.current + val calculatedContentPaddings = remember(contentPadding, orientation, layoutDirection) { + calculateContentPaddings( + contentPadding, + orientation, + layoutDirection + ) + } + + val pagerFlingBehavior = remember(flingBehavior, state) { + PagerWrapperFlingBehavior(flingBehavior, state) + } + + LaunchedEffect(density, state, pageSpacing) { + with(density) { state.pageSpacing = pageSpacing.roundToPx() } + } + + LaunchedEffect(state) { + snapshotFlow { state.isScrollInProgress } + .filter { !it } + .drop(1) // Initial scroll is false + .collect { state.updateOnScrollStopped() } + } + + val pagerSemantics = if (userScrollEnabled) { + Modifier.pagerSemantics(state, isVertical) + } else { + Modifier + } + + BoxWithConstraints(modifier = modifier.then(pagerSemantics)) { + val mainAxisSize = if (isVertical) constraints.maxHeight else constraints.maxWidth + // Calculates how pages are shown across the main axis + val pageAvailableSize = remember( + density, + mainAxisSize, + pageSpacing, + calculatedContentPaddings + ) { + with(density) { + val pageSpacingPx = pageSpacing.roundToPx() + val contentPaddingPx = calculatedContentPaddings.roundToPx() + with(pageSize) { + density.calculateMainAxisPageSize( + mainAxisSize - contentPaddingPx, + pageSpacingPx + ) + }.toDp() + } + } + + val horizontalAlignmentForSpacedArrangement = + if (!reverseLayout) Alignment.Start else Alignment.End + val verticalAlignmentForSpacedArrangement = + if (!reverseLayout) Alignment.Top else Alignment.Bottom + + val lazyListState = remember(state) { + val initialPageOffset = + with(density) { pageAvailableSize.roundToPx() } * state.initialPageOffsetFraction + LazyListState(state.initialPage, initialPageOffset.roundToInt()).also { + state.loadNewState(it) + } + } + + LazyList( + modifier = Modifier, + state = lazyListState, + contentPadding = contentPadding, + flingBehavior = pagerFlingBehavior, + horizontalAlignment = horizontalAlignment, + horizontalArrangement = Arrangement.spacedBy( + pageSpacing, + horizontalAlignmentForSpacedArrangement + ), + verticalArrangement = Arrangement.spacedBy( + pageSpacing, + verticalAlignmentForSpacedArrangement + ), + verticalAlignment = verticalAlignment, + isVertical = isVertical, + reverseLayout = reverseLayout, + userScrollEnabled = userScrollEnabled, + beyondBoundsItemCount = beyondBoundsPageCount + ) { + items(items = items, key = key) { item -> + val pageMainAxisSizeModifier = if (isVertical) { + Modifier.height(pageAvailableSize) + } else { + Modifier.width(pageAvailableSize) + } + Box( + modifier = Modifier + .then(pageMainAxisSizeModifier) + .nestedScroll(pageNestedScrollConnection), + contentAlignment = Alignment.Center + ) { + pageContent(item) + } + } + } + } +} + +private fun calculateContentPaddings( + contentPadding: PaddingValues, + orientation: Orientation, + layoutDirection: LayoutDirection, +): Dp { + + val startPadding = if (orientation == Orientation.Vertical) { + contentPadding.calculateTopPadding() + } else { + contentPadding.calculateLeftPadding(layoutDirection) + } + + val endPadding = if (orientation == Orientation.Vertical) { + contentPadding.calculateBottomPadding() + } else { + contentPadding.calculateRightPadding(layoutDirection) + } + + return startPadding + endPadding +} + +@OptIn(ExperimentalFoundationApi::class) +private class PagerWrapperFlingBehavior( + val originalFlingBehavior: SnapFlingBehavior, + val pagerState: PagerState, +) : FlingBehavior { + override suspend fun ScrollScope.performFling(initialVelocity: Float): Float { + return with(originalFlingBehavior) { + performFling(initialVelocity) { remainingScrollOffset -> + pagerState.snapRemainingScrollOffset = remainingScrollOffset + } + } + } +} + +@OptIn(ExperimentalFoundationApi::class) +@Suppress("ComposableModifierFactory") +@Composable +private fun Modifier.pagerSemantics(state: PagerState, isVertical: Boolean): Modifier { + val scope = rememberCoroutineScope() + fun performForwardPaging(): Boolean { + return if (state.canScrollForward) { + scope.launch { + state.animateToNextPage() + } + true + } else { + false + } + } + + fun performBackwardPaging(): Boolean { + return if (state.canScrollBackward) { + scope.launch { + state.animateToPreviousPage() + } + true + } else { + false + } + } + + return this.then(Modifier.semantics { + if (isVertical) { + pageUp { performBackwardPaging() } + pageDown { performForwardPaging() } + } else { + pageLeft { performBackwardPaging() } + pageRight { performForwardPaging() } + } + }) +} From 3e567af87337397035364cdebbbb7d7f3b3753a1 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 12 Feb 2023 22:35:17 +0800 Subject: [PATCH 197/323] Fix #577 --- app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt b/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt index 9ac89552f..c42145784 100644 --- a/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/utils/AppBar.kt @@ -32,7 +32,7 @@ class AppBarScrollBehavior constructor( override val flingAnimationSpec: DecayAnimationSpec?, val canScroll: () -> Boolean = { true }, ) : TopAppBarScrollBehavior { - override val isPinned: Boolean = false + override val isPinned: Boolean = true override var nestedScrollConnection = object : NestedScrollConnection { override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset { if (!canScroll()) return Offset.Zero From d22e22bd673c50e0cef48ecd2a073952100db85e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 14 Feb 2023 11:45:03 +0800 Subject: [PATCH 198/323] [Feature] History --- .../7.json | 718 ++++++++++++++++ .../8.json | 766 ++++++++++++++++++ .../java/com/zionhuang/music/MainActivity.kt | 3 + .../java/com/zionhuang/music/db/Converters.kt | 9 +- .../com/zionhuang/music/db/DatabaseDao.kt | 175 ++-- .../com/zionhuang/music/db/MusicDatabase.kt | 187 +++-- .../com/zionhuang/music/db/entities/Event.kt | 27 + .../music/db/entities/EventWithSong.kt | 17 + .../zionhuang/music/db/entities/SongEntity.kt | 14 +- .../java/com/zionhuang/music/di/AppModule.kt | 11 +- .../zionhuang/music/playback/SongPlayer.kt | 34 +- .../zionhuang/music/provider/SongsProvider.kt | 4 - .../com/zionhuang/music/ui/menu/SongMenu.kt | 43 +- .../zionhuang/music/ui/menu/YouTubeMenu.kt | 38 +- .../com/zionhuang/music/ui/player/Queue.kt | 15 +- .../zionhuang/music/ui/player/Thumbnail.kt | 5 +- .../zionhuang/music/ui/screens/AlbumScreen.kt | 4 +- .../music/ui/screens/HistoryScreen.kt | 128 +++ .../zionhuang/music/ui/screens/HomeScreen.kt | 1 - .../music/viewmodels/HistoryViewModel.kt | 61 ++ app/src/main/res/values-DE/strings.xml | 7 + app/src/main/res/values-cs/strings.xml | 7 + app/src/main/res/values-es-rUS/strings.xml | 7 + app/src/main/res/values-es/strings.xml | 7 + app/src/main/res/values-fa-rIR/strings.xml | 7 + app/src/main/res/values-fi-rFI/strings.xml | 7 + app/src/main/res/values-fr-rFR/strings.xml | 7 + app/src/main/res/values-hu/strings.xml | 7 + app/src/main/res/values-id/strings.xml | 7 + app/src/main/res/values-it/strings.xml | 7 + app/src/main/res/values-ja-rJP/strings.xml | 7 + app/src/main/res/values-ko-rKR/strings.xml | 7 + app/src/main/res/values-ml-rIN/strings.xml | 7 + app/src/main/res/values-or-rIN/strings.xml | 7 + app/src/main/res/values-pa/strings.xml | 7 + app/src/main/res/values-pt-rBR/strings.xml | 7 + app/src/main/res/values-ru-rRU/strings.xml | 7 + app/src/main/res/values-sv-rSE/strings.xml | 7 + app/src/main/res/values-uk-rUA/strings.xml | 7 + app/src/main/res/values-zh-rCN/strings.xml | 7 + app/src/main/res/values-zh-rTW/strings.xml | 7 + app/src/main/res/values/strings.xml | 7 + 42 files changed, 2178 insertions(+), 236 deletions(-) create mode 100644 app/schemas/com.zionhuang.music.db.InternalDatabase/7.json create mode 100644 app/schemas/com.zionhuang.music.db.InternalDatabase/8.json create mode 100644 app/src/main/java/com/zionhuang/music/db/entities/Event.kt create mode 100644 app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt create mode 100644 app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt create mode 100644 app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/7.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/7.json new file mode 100644 index 000000000..2924f6582 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/7.json @@ -0,0 +1,718 @@ +{ + "formatVersion": 1, + "database": { + "version": 7, + "identityHash": "8badff35bb8509366509650a5b15634a", + "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, `downloadState` INTEGER NOT NULL, `inLibrary` INTEGER, `createDate` INTEGER NOT NULL, `modifyDate` INTEGER NOT NULL, 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": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "modifyDate", + "columnName": "modifyDate", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": [] + } + ], + "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, '8badff35bb8509366509650a5b15634a')" + ] + } +} \ No newline at end of file diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/8.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/8.json new file mode 100644 index 000000000..04e7f3af3 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/8.json @@ -0,0 +1,766 @@ +{ + "formatVersion": 1, + "database": { + "version": 8, + "identityHash": "8de04c586d6be08319c8fab4240706ff", + "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, `downloadState` 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": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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": "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": "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": [] + } + ], + "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, '8de04c586d6be08319c8fab4240706ff')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 64cda9d72..a51c183ac 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -318,6 +318,9 @@ class MainActivity : ComponentActivity() { composable(Screens.Playlists.route) { LibraryPlaylistsScreen(navController) } + composable("history") { + HistoryScreen(navController, scrollBehavior) + } composable("new_release") { NewReleaseScreen(navController, scrollBehavior) } diff --git a/app/src/main/java/com/zionhuang/music/db/Converters.kt b/app/src/main/java/com/zionhuang/music/db/Converters.kt index b29ebbd7b..7b133bca6 100644 --- a/app/src/main/java/com/zionhuang/music/db/Converters.kt +++ b/app/src/main/java/com/zionhuang/music/db/Converters.kt @@ -7,10 +7,11 @@ import java.time.ZoneOffset class Converters { @TypeConverter - fun fromTimestamp(value: Long): LocalDateTime = - LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) + fun fromTimestamp(value: Long?): LocalDateTime? = + if (value != null) LocalDateTime.ofInstant(Instant.ofEpochMilli(value), ZoneOffset.UTC) + else null @TypeConverter - fun dateToTimestamp(date: LocalDateTime): Long = - date.atZone(ZoneOffset.UTC).toInstant().toEpochMilli() + fun dateToTimestamp(date: LocalDateTime?): Long? = + date?.atZone(ZoneOffset.UTC)?.toInstant()?.toEpochMilli() } 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 87f21fa22..57015dd20 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -19,7 +19,7 @@ import java.time.LocalDateTime @Dao interface DatabaseDao { - @Query("SELECT id FROM song") + @Query("SELECT id FROM song WHERE inLibrary IS NOT NULL") fun allSongId(): Flow> @Query("SELECT id FROM song WHERE liked") @@ -32,15 +32,15 @@ interface DatabaseDao { fun allPlaylistId(): Flow> @Transaction - @Query("SELECT * FROM song ORDER BY rowId DESC") + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY rowId DESC") fun songsByRowIdDesc(): Flow> @Transaction - @Query("SELECT * FROM song ORDER BY createDate DESC") + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY inLibrary DESC") fun songsByCreateDateDesc(): Flow> @Transaction - @Query("SELECT * FROM song ORDER BY title DESC") + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY title DESC") fun songsByNameDesc(): Flow> fun songs(sortType: SongSortType, descending: Boolean) = @@ -83,11 +83,11 @@ interface DatabaseDao { fun playlistSongs(playlistId: String): Flow> @Transaction - @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY createDate DESC") + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY inLibrary DESC") fun artistSongsByCreateDateDesc(artistId: String): Flow> @Transaction - @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId ORDER BY title DESC") + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL ORDER BY title DESC") fun artistSongsByNameDesc(artistId: String): Flow> fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) = @@ -97,7 +97,7 @@ interface DatabaseDao { }.map { it.reversed(!descending) } @Transaction - @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId LIMIT :previewSize") + @Query("SELECT song.* FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = :artistId AND inLibrary IS NOT NULL LIMIT :previewSize") fun artistSongsPreview(artistId: String, previewSize: Int = 3): Flow> @Transaction @@ -111,15 +111,15 @@ interface DatabaseDao { fun lyrics(id: String?): Flow @Transaction - @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist ORDER BY rowId DESC") + @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 DESC") fun artistsByCreateDateDesc(): Flow> @Transaction - @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist ORDER BY name DESC") + @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 name DESC") fun artistsByNameDesc(): Flow> @Transaction - @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map WHERE artistId = artist.id) AS songCount FROM artist ORDER BY songCount DESC") + @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 songCount DESC") fun artistsBySongCountDesc(): Flow> fun artists(sortType: ArtistSortType, descending: Boolean) = @@ -202,7 +202,7 @@ interface DatabaseDao { fun playlist(playlistId: String): Flow @Transaction - @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' LIMIT :previewSize") + @Query("SELECT * FROM song WHERE title LIKE '%' || :query || '%' AND inLibrary IS NOT NULL LIMIT :previewSize") fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction @@ -221,6 +221,10 @@ interface DatabaseDao { @Query("SELECT *, (SELECT COUNT(*) FROM playlist_song_map WHERE playlistId = playlist.id) AS songCount FROM playlist WHERE name LIKE '%' || :query || '%' LIMIT :previewSize") fun searchPlaylists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> + @Transaction + @Query("SELECT * FROM event ORDER BY rowId DESC") + fun events(): Flow> + @Query("SELECT * FROM search_history WHERE `query` LIKE :query || '%' ORDER BY id DESC") fun searchHistory(query: String = ""): Flow> @@ -230,7 +234,11 @@ interface DatabaseDao { @Query("UPDATE song SET totalPlayTime = totalPlayTime + :playTime WHERE id = :songId") fun incrementTotalPlayTime(songId: String, playTime: Long) - @Query(""" + @Query("UPDATE song SET inLibrary = :inLibrary WHERE id = :songId") + fun inLibrary(songId: String, inLibrary: LocalDateTime?) + + @Query( + """ UPDATE playlist_song_map SET position = CASE WHEN position < :fromPosition THEN position + 1 @@ -238,7 +246,8 @@ interface DatabaseDao { ELSE :toPosition END WHERE playlistId = :playlistId AND position BETWEEN MIN(:fromPosition, :toPosition) AND MAX(:fromPosition, :toPosition) - """) + """ + ) fun move(playlistId: String, fromPosition: Int, toPosition: Int) @Query("DELETE FROM playlist_song_map WHERE playlistId = :playlistId") @@ -254,7 +263,7 @@ interface DatabaseDao { fun insert(artist: ArtistEntity) @Insert(onConflict = OnConflictStrategy.IGNORE) - fun insert(album: AlbumEntity) + fun insert(album: AlbumEntity): Long @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(playlist: PlaylistEntity) @@ -274,59 +283,72 @@ interface DatabaseDao { @Insert(onConflict = OnConflictStrategy.REPLACE) fun insert(searchHistory: SearchHistory) + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(event: Event) + @Transaction fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) { if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return mediaMetadata.artists.forEachIndexed { index, artist -> val artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId() - insert(ArtistEntity( - id = artistId, - name = artist.name - )) - insert(SongArtistMap( - songId = mediaMetadata.id, - artistId = artistId, - position = index - )) + insert( + ArtistEntity( + id = artistId, + name = artist.name + ) + ) + insert( + SongArtistMap( + songId = mediaMetadata.id, + artistId = artistId, + position = index + ) + ) } } @Transaction fun insert(albumPage: AlbumPage) { - insert(AlbumEntity( - 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).forEach(::insert) - albumPage.songs.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 - ) - }?.forEach(::insert) - albumPage.album.artists?.mapIndexed { index, artist -> - AlbumArtistMap( - albumId = albumPage.album.browseId, - artistId = artist.id ?: artistByName(artist.name)?.id ?: ArtistEntity.generateArtistId(), - order = index - ) - }?.forEach(::insert) + if (insert(AlbumEntity( + 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 } + )) == -1L + ) return + 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) } @Transaction fun insert(albumWithSongs: AlbumWithSongs) { - insert(albumWithSongs.album) + if (insert(albumWithSongs.album) == -1L) return albumWithSongs.songs.map(Song::toMediaMetadata).forEach(::insert) albumWithSongs.songs.mapIndexed { index, song -> SongAlbumMap( @@ -358,11 +380,13 @@ interface DatabaseDao { fun update(map: PlaylistSongMap) fun update(artist: ArtistEntity, artistPage: ArtistPage) { - update(artist.copy( - name = artistPage.artist.title, - thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544), - lastUpdateTime = LocalDateTime.now() - )) + update( + artist.copy( + name = artistPage.artist.title, + thumbnailUrl = artistPage.artist.thumbnail.resize(544, 544), + lastUpdateTime = LocalDateTime.now() + ) + ) } @Upsert @@ -392,44 +416,15 @@ interface DatabaseDao { @Delete fun delete(searchHistory: SearchHistory) + @Delete + fun delete(event: Event) + @Query("SELECT * FROM playlist_song_map WHERE songId = :songId") fun playlistSongMaps(songId: String): List @Query("SELECT * FROM playlist_song_map WHERE playlistId = :playlistId AND position >= :from ORDER BY position") fun playlistSongMaps(playlistId: String, from: Int): List - @Query("SELECT COUNT(1) FROM song_artist_map WHERE artistId = :id") - fun artistSongCount(id: String): Int - - @Transaction - fun verifyPlaylistSongPosition(playlistId: String, from: Int) { - val maps = playlistSongMaps(playlistId, from) - var position = if (from <= 0) 0 else maps[0].position - maps.map { it.copy(position = position++) }.forEach(::update) - } - - @Transaction - fun delete(song: Song) { - if (song.album != null) return - delete(song.song) - song.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) - playlistSongMaps(song.id) - .groupBy { it.playlistId } - .mapValues { entry -> - entry.value.minOf { it.position } - 1 - } - .forEach { (playlistId, position) -> - verifyPlaylistSongPosition(playlistId, position) - } - } - - @Transaction - fun delete(albumWithSongs: AlbumWithSongs) { - albumWithSongs.songs.map { it.copy(album = null) }.forEach(::delete) - delete(albumWithSongs.album) - albumWithSongs.artists.filter { artistSongCount(it.id) == 0 }.forEach(::delete) - } - @RawQuery fun raw(supportSQLiteQuery: SupportSQLiteQuery): Int 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 5c9e0a7cb..a81139dc5 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -1,5 +1,6 @@ package com.zionhuang.music.db +import android.content.Context import android.database.sqlite.SQLiteDatabase import androidx.core.content.contentValuesOf import androidx.room.* @@ -49,6 +50,7 @@ class MusicDatabase( PlaylistSongMap::class, DownloadEntity::class, SearchHistory::class, + Event::class, FormatEntity::class, LyricsEntity::class ], @@ -57,13 +59,15 @@ class MusicDatabase( SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 6, + version = 8, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), AutoMigration(from = 3, to = 4), AutoMigration(from = 4, to = 5), - AutoMigration(from = 5, to = 6, spec = Migration5To6::class) + AutoMigration(from = 5, to = 6, spec = Migration5To6::class), + AutoMigration(from = 6, to = 7, spec = Migration6To7::class), + AutoMigration(from = 7, to = 8, spec = Migration7To8::class) ] ) @TypeConverters(Converters::class) @@ -72,11 +76,32 @@ abstract class InternalDatabase : RoomDatabase() { companion object { const val DB_NAME = "song.db" + + fun newInstance(context: Context): MusicDatabase = + MusicDatabase( + delegate = Room.databaseBuilder(context, InternalDatabase::class.java, DB_NAME) + .addMigrations(MIGRATION_1_2) + .build() + ) } } val MIGRATION_1_2 = object : Migration(1, 2) { override fun migrate(database: SupportSQLiteDatabase) { + data class OldSongEntity( + val id: String, + val title: String, + val duration: Int = -1, // in seconds + val thumbnailUrl: String? = null, + val albumId: String? = null, + val albumName: String? = null, + val liked: Boolean = false, + val totalPlayTime: Long = 0, // in milliseconds + val downloadState: Int = SongEntity.STATE_NOT_DOWNLOADED, + val createDate: LocalDateTime = LocalDateTime.now(), + val modifyDate: LocalDateTime = LocalDateTime.now(), + ) + val converters = Converters() val artistMap = mutableMapOf() val artists = mutableListOf() @@ -85,10 +110,12 @@ val MIGRATION_1_2 = object : Migration(1, 2) { val oldId = cursor.getInt(0) val newId = ArtistEntity.generateArtistId() artistMap[oldId] = newId - artists.add(ArtistEntity( - id = newId, - name = cursor.getString(1) - )) + artists.add( + ArtistEntity( + id = newId, + name = cursor.getString(1) + ) + ) } } @@ -99,20 +126,24 @@ val MIGRATION_1_2 = object : Migration(1, 2) { val oldId = cursor.getInt(0) val newId = PlaylistEntity.generatePlaylistId() playlistMap[oldId] = newId - playlists.add(PlaylistEntity( - id = newId, - name = cursor.getString(1) - )) + playlists.add( + PlaylistEntity( + id = newId, + name = cursor.getString(1) + ) + ) } } val playlistSongMaps = mutableListOf() database.query("SELECT * FROM playlist_song".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { - playlistSongMaps.add(PlaylistSongMap( - playlistId = playlistMap[cursor.getInt(1)]!!, - songId = cursor.getString(2), - position = cursor.getInt(3) - )) + playlistSongMaps.add( + PlaylistSongMap( + playlistId = playlistMap[cursor.getInt(1)]!!, + songId = cursor.getString(2), + position = cursor.getInt(3) + ) + ) } } // ensure we have continuous playlist song position @@ -124,24 +155,28 @@ val MIGRATION_1_2 = object : Migration(1, 2) { playlistSongCount[map.playlistId] = playlistSongCount[map.playlistId]!! + 1 } } - val songs = mutableListOf() + val songs = mutableListOf() val songArtistMaps = mutableListOf() database.query("SELECT * FROM song".toSQLiteQuery()).use { cursor -> while (cursor.moveToNext()) { val songId = cursor.getString(0) - songs.add(SongEntity( - id = songId, - title = cursor.getString(1), - duration = cursor.getInt(3), - liked = cursor.getInt(4) == 1, - createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(), - modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime() - )) - songArtistMaps.add(SongArtistMap( - songId = songId, - artistId = artistMap[cursor.getInt(2)]!!, - position = 0 - )) + songs.add( + OldSongEntity( + id = songId, + title = cursor.getString(1), + duration = cursor.getInt(3), + liked = cursor.getInt(4) == 1, + createDate = Instant.ofEpochMilli(Date(cursor.getLong(8)).time).atZone(ZoneOffset.UTC).toLocalDateTime(), + modifyDate = Instant.ofEpochMilli(Date(cursor.getLong(9)).time).atZone(ZoneOffset.UTC).toLocalDateTime() + ) + ) + songArtistMaps.add( + SongArtistMap( + songId = songId, + artistId = artistMap[cursor.getInt(2)]!!, + position = 0 + ) + ) } } database.execSQL("DROP TABLE IF EXISTS song") @@ -170,47 +205,57 @@ val MIGRATION_1_2 = object : Migration(1, 2) { database.execSQL("CREATE VIEW `sorted_song_artist_map` AS SELECT * FROM song_artist_map ORDER BY position") database.execSQL("CREATE VIEW `playlist_song_map_preview` AS SELECT * FROM playlist_song_map WHERE position <= 3 ORDER BY position") artists.forEach { artist -> - database.insert("artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( - "id" to artist.id, - "name" to artist.name, - "createDate" to converters.dateToTimestamp(artist.createDate), - "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime) - )) + database.insert( + "artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to artist.id, + "name" to artist.name, + "createDate" to converters.dateToTimestamp(artist.createDate), + "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime) + ) + ) } songs.forEach { song -> - database.insert("song", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( - "id" to song.id, - "title" to song.title, - "duration" to song.duration, - "liked" to song.liked, - "totalPlayTime" to song.totalPlayTime, - "isTrash" to false, - "download_state" to song.downloadState, - "create_date" to converters.dateToTimestamp(song.createDate), - "modify_date" to converters.dateToTimestamp(song.modifyDate) - )) + database.insert( + "song", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to song.id, + "title" to song.title, + "duration" to song.duration, + "liked" to song.liked, + "totalPlayTime" to song.totalPlayTime, + "isTrash" to false, + "download_state" to song.downloadState, + "create_date" to converters.dateToTimestamp(song.createDate), + "modify_date" to converters.dateToTimestamp(song.modifyDate) + ) + ) } songArtistMaps.forEach { songArtistMap -> - database.insert("song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( - "songId" to songArtistMap.songId, - "artistId" to songArtistMap.artistId, - "position" to songArtistMap.position - )) + database.insert( + "song_artist_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "songId" to songArtistMap.songId, + "artistId" to songArtistMap.artistId, + "position" to songArtistMap.position + ) + ) } playlists.forEach { playlist -> - database.insert("playlist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( - "id" to playlist.id, - "name" to playlist.name, - "createDate" to converters.dateToTimestamp(LocalDateTime.now()), - "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now()) - )) + database.insert( + "playlist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "id" to playlist.id, + "name" to playlist.name, + "createDate" to converters.dateToTimestamp(LocalDateTime.now()), + "lastUpdateTime" to converters.dateToTimestamp(LocalDateTime.now()) + ) + ) } playlistSongMaps.forEach { playlistSongMap -> - database.insert("playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( - "playlistId" to playlistSongMap.playlistId, - "songId" to playlistSongMap.songId, - "position" to playlistSongMap.position - )) + database.insert( + "playlist_song_map", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( + "playlistId" to playlistSongMap.playlistId, + "songId" to playlistSongMap.songId, + "position" to playlistSongMap.position + ) + ) } } } @@ -237,4 +282,20 @@ class Migration5To6 : AutoMigrationSpec { } } } -} \ No newline at end of file +} + +class Migration6To7 : AutoMigrationSpec { + override fun onPostMigrate(db: SupportSQLiteDatabase) { + db.query("SELECT id, createDate FROM song").use { cursor -> + while (cursor.moveToNext()) { + db.execSQL("UPDATE song SET inLibrary = ${cursor.getLong(1)} WHERE id = '${cursor.getString(0)}'") + } + } + } +} + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "createDate"), + DeleteColumn(tableName = "song", columnName = "modifyDate") +) +class Migration7To8 : AutoMigrationSpec diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Event.kt b/app/src/main/java/com/zionhuang/music/db/entities/Event.kt new file mode 100644 index 000000000..0edb3f351 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/Event.kt @@ -0,0 +1,27 @@ +package com.zionhuang.music.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey +import java.time.LocalDateTime + +@Immutable +@Entity( + tableName = "event", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class Event( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + val timestamp: LocalDateTime, + val playTime: Long, +) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt b/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt new file mode 100644 index 000000000..639d46aed --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/EventWithSong.kt @@ -0,0 +1,17 @@ +package com.zionhuang.music.db.entities + +import androidx.compose.runtime.Immutable +import androidx.room.Embedded +import androidx.room.Relation + +@Immutable +data class EventWithSong( + @Embedded + val event: Event, + @Relation( + entity = SongEntity::class, + parentColumn = "songId", + entityColumn = "id" + ) + val song: Song, +) 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 406678660..2f36ee6b2 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 @@ -17,10 +17,18 @@ data class SongEntity( val liked: Boolean = false, val totalPlayTime: Long = 0, // in milliseconds val downloadState: Int = STATE_NOT_DOWNLOADED, - val createDate: LocalDateTime = LocalDateTime.now(), - val modifyDate: LocalDateTime = LocalDateTime.now(), + val inLibrary: LocalDateTime? = null, ) { - fun toggleLike() = copy(liked = !liked) + fun toggleLike() = + if (!liked) { + copy( + liked = true, + inLibrary = inLibrary ?: LocalDateTime.now() + ) + } else copy(liked = false) + + fun toggleLibrary() = + copy(inLibrary = if (inLibrary == null) LocalDateTime.now() else null) companion object { const val STATE_NOT_DOWNLOADED = 0 diff --git a/app/src/main/java/com/zionhuang/music/di/AppModule.kt b/app/src/main/java/com/zionhuang/music/di/AppModule.kt index 018a08e06..6d766a9f2 100644 --- a/app/src/main/java/com/zionhuang/music/di/AppModule.kt +++ b/app/src/main/java/com/zionhuang/music/di/AppModule.kt @@ -1,9 +1,7 @@ package com.zionhuang.music.di import android.content.Context -import androidx.room.Room import com.zionhuang.music.db.InternalDatabase -import com.zionhuang.music.db.MIGRATION_1_2 import com.zionhuang.music.db.MusicDatabase import dagger.Module import dagger.Provides @@ -17,11 +15,6 @@ import javax.inject.Singleton object AppModule { @Singleton @Provides - fun provideDatabase(@ApplicationContext context: Context): MusicDatabase { - return MusicDatabase( - delegate = Room.databaseBuilder(context, InternalDatabase::class.java, InternalDatabase.DB_NAME) - .addMigrations(MIGRATION_1_2) - .build() - ) - } + fun provideDatabase(@ApplicationContext context: Context): MusicDatabase = + InternalDatabase.newInstance(context) } diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 8f97e339d..1a3915024 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -5,6 +5,7 @@ import android.app.PendingIntent.FLAG_IMMUTABLE import android.app.PendingIntent.FLAG_UPDATE_CURRENT import android.content.Context import android.content.Intent +import android.database.SQLException import android.graphics.Bitmap import android.media.audiofx.AudioEffect import android.net.ConnectivityManager @@ -60,12 +61,12 @@ import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.Event import com.zionhuang.music.db.entities.FormatEntity import com.zionhuang.music.db.entities.LyricsEntity 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.Song -import com.zionhuang.music.db.entities.SongEntity import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.* import com.zionhuang.music.lyrics.LyricsHelper @@ -90,6 +91,7 @@ import java.io.ObjectOutputStream import java.net.ConnectException import java.net.SocketTimeoutException import java.net.UnknownHostException +import java.time.LocalDateTime import kotlin.math.min import kotlin.math.pow import kotlin.time.Duration.Companion.minutes @@ -490,6 +492,7 @@ class SongPlayer( } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) database.query { + currentMediaMetadata.value?.let(::insert) upsert( FormatEntity( id = mediaId, @@ -583,24 +586,16 @@ class SongPlayer( fun toggleLibrary() { database.query { - val song = currentSong - val mediaMetadata = currentMediaMetadata.value ?: return@query - if (song == null) { - insert(mediaMetadata) - } else { - delete(song) + currentSong?.let { + update(it.song.toggleLibrary()) } } } fun toggleLike() { database.query { - val song = currentSong - val mediaMetadata = currentMediaMetadata.value ?: return@query - if (song == null) { - insert(mediaMetadata, SongEntity::toggleLike) - } else { - update(song.song.toggleLike()) + currentSong?.let { + update(it.song.toggleLike()) } } } @@ -720,6 +715,19 @@ class SongPlayer( val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem database.query { incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + + if (playbackStats.totalPlayTimeMs >= 10000) { + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs + ) + ) + } catch (_: SQLException) { + } + } } } diff --git a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt index e5b499a70..d2d5f0c0d 100644 --- a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt @@ -20,7 +20,6 @@ import dagger.hilt.components.SingletonComponent import kotlinx.coroutines.flow.first import kotlinx.coroutines.runBlocking import java.io.FileNotFoundException -import java.time.ZoneOffset class SongsProvider : DocumentsProvider() { lateinit var entryPoint: SongsProviderEntryPoint @@ -58,7 +57,6 @@ class SongsProvider : DocumentsProvider() { .add(Document.COLUMN_DISPLAY_NAME, song.song.title) .add(Document.COLUMN_MIME_TYPE, format.mimeType) .add(Document.COLUMN_SIZE, format.contentLength) - .add(Document.COLUMN_LAST_MODIFIED, song.song.modifyDate.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()) } } } @@ -75,7 +73,6 @@ class SongsProvider : DocumentsProvider() { .add(Document.COLUMN_DISPLAY_NAME, "${song.song.title}${mimeToExt(format.mimeType)}") .add(Document.COLUMN_MIME_TYPE, format.mimeType) .add(Document.COLUMN_SIZE, format.contentLength) - .add(Document.COLUMN_LAST_MODIFIED, song.song.modifyDate.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()) } } } @@ -94,7 +91,6 @@ class SongsProvider : DocumentsProvider() { .add(Document.COLUMN_DISPLAY_NAME, "${song.song.title}${mimeToExt(format.mimeType)}") .add(Document.COLUMN_MIME_TYPE, format.mimeType) .add(Document.COLUMN_SIZE, format.contentLength) - .add(Document.COLUMN_LAST_MODIFIED, song.song.modifyDate.atZone(ZoneOffset.UTC).toInstant().toEpochMilli()) } } } 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 36786872c..346eb1524 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 @@ -29,10 +29,7 @@ import com.zionhuang.music.LocalDatabase import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ListThumbnailSize -import com.zionhuang.music.db.entities.Playlist -import com.zionhuang.music.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.PlaylistSongMap -import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.db.entities.* import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.PlayerConnection @@ -44,6 +41,7 @@ import kotlinx.coroutines.launch @Composable fun SongMenu( originalSong: Song, + event: Event? = null, navController: NavController, playerConnection: PlayerConnection, coroutineScope: CoroutineScope, @@ -304,13 +302,36 @@ fun SongMenu( } context.startActivity(Intent.createChooser(intent, null)) } - GridMenuItem( - icon = R.drawable.ic_delete, - title = R.string.delete - ) { - onDismiss() - database.query { - delete(song) + if (song.song.inLibrary == null) { + GridMenuItem( + icon = R.drawable.ic_library_add, + title = R.string.add_to_library + ) { + onDismiss() + database.query { + update(song.song.toggleLibrary()) + } + } + } else { + GridMenuItem( + icon = R.drawable.ic_delete, + title = R.string.delete + ) { + onDismiss() + database.query { + update(song.song.toggleLibrary()) + } + } + } + if (event != null) { + GridMenuItem( + icon = R.drawable.ic_delete, + title = R.string.remove_from_history + ) { + onDismiss() + database.query { + delete(event) + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt index 457b5c86d..b1995b683 100644 --- a/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt +++ b/app/src/main/java/com/zionhuang/music/ui/menu/YouTubeMenu.kt @@ -14,7 +14,6 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp -import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem @@ -33,12 +32,10 @@ import com.zionhuang.music.playback.queues.YouTubeQueue 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.viewmodels.MainViewModel import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking +import java.time.LocalDateTime @Composable fun YouTubeSongMenu( @@ -46,15 +43,11 @@ fun YouTubeSongMenu( navController: NavController, playerConnection: PlayerConnection, coroutineScope: CoroutineScope, - mainViewModel: MainViewModel = hiltViewModel(), onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current - val librarySongIds by mainViewModel.librarySongIds.collectAsState() - val addedToLibrary = remember(librarySongIds) { - song.id in librarySongIds - } + val librarySong by database.song(song.id).collectAsState(initial = null) val artists = remember { song.artists.mapNotNull { it.id?.let { artistId -> @@ -137,17 +130,13 @@ fun YouTubeSongMenu( playerConnection.addToQueue((song.toMediaItem())) onDismiss() } - if (addedToLibrary) { + if (librarySong?.song?.inLibrary != null) { GridMenuItem( icon = R.drawable.ic_library_add_check, title = R.string.action_remove_from_library ) { - coroutineScope.launch(Dispatchers.IO) { - database.song(song.id).first()?.let { song -> - database.query { - delete(song) - } - } + database.query { + inLibrary(song.id, null) } } } else { @@ -155,8 +144,9 @@ fun YouTubeSongMenu( icon = R.drawable.ic_library_add, title = R.string.action_add_to_library ) { - database.query { + database.transaction { insert(song.toMediaMetadata()) + inLibrary(song.id, LocalDateTime.now()) } } } @@ -217,15 +207,11 @@ fun YouTubeAlbumMenu( navController: NavController, playerConnection: PlayerConnection, coroutineScope: CoroutineScope, - mainViewModel: MainViewModel = hiltViewModel(), onDismiss: () -> Unit, ) { val context = LocalContext.current val database = LocalDatabase.current - val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() - val addedToLibrary = remember(libraryAlbumIds) { - album.id in libraryAlbumIds - } + val libraryAlbum by database.album(album.id).collectAsState(initial = null) var showSelectArtistDialog by rememberSaveable { mutableStateOf(false) @@ -304,17 +290,13 @@ fun YouTubeAlbumMenu( ) { onDismiss() } - if (addedToLibrary) { + if (libraryAlbum != null) { GridMenuItem( icon = R.drawable.ic_library_add_check, title = R.string.action_remove_from_library ) { database.query { - runBlocking(Dispatchers.IO) { - album(album.id).first() - }?.let { - delete(it.album) - } + libraryAlbum?.album?.let(::delete) } } } else { diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 64bf7796e..5a41530db 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -19,7 +19,6 @@ import androidx.compose.material3.* import androidx.compose.runtime.* import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.ui.Alignment -import androidx.compose.ui.ExperimentalComposeUiApi import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.clip @@ -59,7 +58,7 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlin.math.roundToInt -@OptIn(ExperimentalComposeUiApi::class, ExperimentalFoundationApi::class, ExperimentalAnimationApi::class) +@OptIn(ExperimentalAnimationApi::class) @Composable fun Queue( state: BottomSheetState, @@ -275,7 +274,7 @@ fun Queue( } IconButton(onClick = playerConnection::toggleLibrary) { Icon( - painter = painterResource(if (currentSong != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add), + painter = painterResource(if (currentSong?.song?.inLibrary != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add), contentDescription = null ) } @@ -329,10 +328,12 @@ fun Queue( ReorderingLazyColumn( reorderingState = reorderingState, contentPadding = WindowInsets.systemBars - .add(WindowInsets( - top = ListItemHeight, - bottom = ListItemHeight - )) + .add( + WindowInsets( + top = ListItemHeight, + bottom = ListItemHeight + ) + ) .asPaddingValues(), modifier = Modifier.nestedScroll(state.preUpPostDownNestedScrollConnection) ) { 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 d904aa4ae..07d761da2 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 @@ -55,7 +55,10 @@ fun Thumbnail( LaunchedEffect(pagerState, currentWindowIndex) { if (windows.isNotEmpty()) { - pagerState.animateScrollToPage(currentWindowIndex) + try { + pagerState.animateScrollToPage(currentWindowIndex) + } catch (_: Exception) { + } } } 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 527a627ec..948a44fbb 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 @@ -279,7 +279,7 @@ fun LocalAlbumHeader( onClick = { database.query { if (inLibrary) { - delete(albumWithSongs) + delete(albumWithSongs.album) } else { insert(albumWithSongs) } @@ -424,7 +424,7 @@ fun RemoteAlbumHeader( runBlocking(Dispatchers.IO) { albumWithSongs(albumPage.album.browseId).first() }?.let { - delete(it) + delete(it.album) } } else { insert(albumPage) diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt new file mode 100644 index 000000000..a5a5fa732 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt @@ -0,0 +1,128 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.combinedClickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.hilt.navigation.compose.hiltViewModel +import androidx.navigation.NavController +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.viewmodels.DateAgo +import com.zionhuang.music.viewmodels.HistoryViewModel +import java.time.format.DateTimeFormatter + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun HistoryScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: HistoryViewModel = hiltViewModel(), +) { + val coroutineScope = rememberCoroutineScope() + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val playWhenReady by playerConnection.playWhenReady.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val events by viewModel.events.collectAsState() + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues(), + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top)) + ) { + events.forEach { (dateAgo, events) -> + stickyHeader { + Text( + text = when (dateAgo) { + DateAgo.Today -> stringResource(R.string.today) + DateAgo.Yesterday -> stringResource(R.string.yesterday) + DateAgo.ThisWeek -> stringResource(R.string.this_week) + DateAgo.LastWeek -> stringResource(R.string.last_week) + is DateAgo.Other -> dateAgo.date.format(DateTimeFormatter.ofPattern("yyyy/MM")) + }, + style = MaterialTheme.typography.headlineMedium, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background(MaterialTheme.colorScheme.background) + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + + items( + items = events, + key = { it.event.id } + ) { event -> + SongListItem( + song = event.song, + isPlaying = event.song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = event.song, + event = event.event, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .fillMaxWidth() + .combinedClickable { + playerConnection.playQueue( + YouTubeQueue( + endpoint = WatchEndpoint(videoId = event.song.id), + preloadItem = event.song.toMediaMetadata() + ) + ) + } + .animateItemPlacement() + ) + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.history)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.ic_arrow_back), + contentDescription = null + ) + } + } + ) +} 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 c3171aabb..4e27865bd 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 @@ -91,7 +91,6 @@ fun HomeScreen( title = stringResource(R.string.history), icon = R.drawable.ic_history, onClick = { navController.navigate("history") }, - enabled = false, modifier = Modifier.weight(1f) ) NavigationTile( diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt new file mode 100644 index 000000000..136b32986 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HistoryViewModel.kt @@ -0,0 +1,61 @@ +package com.zionhuang.music.viewmodels + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.viewModelScope +import com.zionhuang.music.db.MusicDatabase +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.flow.stateIn +import java.time.DayOfWeek +import java.time.LocalDate +import java.time.temporal.ChronoUnit +import javax.inject.Inject + +@HiltViewModel +class HistoryViewModel @Inject constructor( + val database: MusicDatabase, +) : ViewModel() { + private val today = LocalDate.now() + private val thisMonday = today.with(DayOfWeek.MONDAY) + private val lastMonday = thisMonday.minusDays(7) + + val events = database.events() + .map { events -> + events.groupBy { + val date = it.event.timestamp.toLocalDate() + val daysAgo = ChronoUnit.DAYS.between(date, today).toInt() + when { + daysAgo == 0 -> DateAgo.Today + daysAgo == 1 -> DateAgo.Yesterday + date >= thisMonday -> DateAgo.ThisWeek + date >= lastMonday -> DateAgo.LastWeek + else -> DateAgo.Other(date.withDayOfMonth(1)) + } + }.toSortedMap(compareBy { dateAgo -> + when (dateAgo) { + DateAgo.Today -> 0L + DateAgo.Yesterday -> 1L + DateAgo.ThisWeek -> 2L + DateAgo.LastWeek -> 3L + is DateAgo.Other -> ChronoUnit.DAYS.between(dateAgo.date, today) + } + }) + } + .stateIn(viewModelScope, SharingStarted.Lazily, emptyMap()) +} + +sealed class DateAgo { + object Today : DateAgo() + object Yesterday : DateAgo() + object ThisWeek : DateAgo() + object LastWeek : DateAgo() + class Other(val date: LocalDate) : DateAgo() { + override fun equals(other: Any?): Boolean { + if (other is Other) return date == other.date + return super.equals(other) + } + + override fun hashCode(): Int = date.hashCode() + } +} diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 2d9df0dad..0c6a7f457 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Suche YouTube Musik durchsuchen… @@ -62,6 +68,7 @@ Neu laden Teilen Löschen + Remove from history Online-Suche Sync diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 92e4087c7..cc431c5c2 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -20,6 +20,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Vyhledávání Hledat v YouTube Music… @@ -64,6 +70,7 @@ Obnovit Sdílet Odstranit + Remove from history Hledat online Sync diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 062e871fd..57c6c99ac 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Buscar Search YouTube Music… @@ -62,6 +68,7 @@ Refetch Share Eliminar + Remove from history Search online Sync diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 7b19f7517..79aa9eec9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Buscar Search YouTube Music… @@ -62,6 +68,7 @@ Refetch Share Borrar + Remove from history Search online Sync diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 1f3a41984..f0dcce64b 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + جستجو جستجو در یوتیوب موزیک… @@ -62,6 +68,7 @@ نوسازی هم‌رسانی حذف + Remove from history Search online Sync diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 58a3f4ac7..605942660 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Etsi Search YouTube Music… @@ -62,6 +68,7 @@ Refetch Share Poista + Remove from history Search online Sync diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 86abc8c93..b38b2afc4 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -19,6 +19,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Recherche Search YouTube Music… @@ -63,6 +69,7 @@ Refetch Share Effacer + Remove from history Search online Sync diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index a63088625..702ad1e4c 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Keresés YouTube Music keresés… @@ -62,6 +68,7 @@ Újrahív Megosztás Törlés + Remove from history Keresés online Sync diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 44615145d..5b5d1af8b 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -17,6 +17,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Caru Cari di YouTube Music… @@ -61,6 +67,7 @@ Ambil kembali Bagikan Hapus + Remove from history Cari secara online Sync diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 146b1ab7b..5494bddbf 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Cerca Cerca su YouTube Music… @@ -62,6 +68,7 @@ Riottieni Condividi Elimina + Remove from history Cerca online Sync diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 7e20afd99..ed6fff5b9 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -17,6 +17,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + 検索 YouTube Musicを検索… @@ -61,6 +67,7 @@ 再取得 共有 削除 + Remove from history オンラインで検索 Sync diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 4fc265a0a..2cbfcd7ed 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -17,6 +17,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + 검색 Search YouTube Music… @@ -61,6 +67,7 @@ Refetch Share 삭제 + Remove from history Search online Sync diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 29a6b5768..a6b7beb44 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + തിരയുക യൂട്യൂബ് സംഗീതം തിരയുക… @@ -62,6 +68,7 @@ വീണ്ടെടുക്കുക പങ്കിടുക ഡിലീറ്റ് + Remove from history Search online Sync diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 57d2c9b87..c2e1a1b4c 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + ସନ୍ଧାନ କରନ୍ତୁ ୟୁଟ୍ୟୁବ୍ ମ୍ୟୁଜିକ୍ ଖୋଜ… @@ -62,6 +68,7 @@ ପୁନଃ ଆଣନ୍ତୁ ଅଂଶୀଦାର କରନ୍ତୁ ବିଲୋପ କରନ୍ତୁ + Remove from history ଅନଲାଇନ୍ ସନ୍ଧାନ କରନ୍ତୁ Sync diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index c2181d76f..96360ec3b 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + ਖੋਜੋ ਯੂਟਿਊਬ ਮਿਊਜ਼ਿਕ ਖੋਜੋ… @@ -62,6 +68,7 @@ ਮੁੜ ਪ੍ਰਾਪਤ ਕਰੋ ਸਾਂਝਾ ਕਰੋ ਮਿਟਾਓ + Remove from history ਆਨਲਾਈਨ ਖੋਜ ਕਰੋ Sync diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 32bc1f857..73b5b70ab 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -18,6 +18,12 @@ New release álbuns Most played songs + + Today + Yesterday + This week + Last week + Pesquisar Pesquisar no YouTube Music… @@ -62,6 +68,7 @@ Atualizar Compartilhar Excluir + Remove from history Search online Sync diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 133d1bfe1..24d604783 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -20,6 +20,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Поиск Поиск в YouTube Music… @@ -64,6 +70,7 @@ Обновить Поделиться Удалить + Remove from history Поиск в Интернете Sync diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index 99b664399..c389b876d 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -18,6 +18,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Sök Search YouTube Music… @@ -62,6 +68,7 @@ Refetch Share Ta bort + Remove from history Search online Sync diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index ceea91613..deef19d97 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -20,6 +20,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Пошук Пошук в YouTube Music… @@ -64,6 +70,7 @@ Оновити Поділитися Видалити + Remove from history Пошук в Інтернеті Sync diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 5a36ce9da..59600c0b2 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -17,6 +17,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + 搜索 搜索 YouTube Music… @@ -61,6 +67,7 @@ 刷新 分享 移除 + Remove from history 在线搜索 Sync diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index fb5f42446..af297c63e 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -17,6 +17,12 @@ 新專輯 最常播放 + + 今天 + 昨天 + 這週 + 上週 + 搜尋 搜尋 YouTube Music… @@ -61,6 +67,7 @@ 更新資料 分享 移除 + 從記錄中移除 線上搜尋 同步 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index a553d1c59..5c03de00d 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -17,6 +17,12 @@ New release albums Most played songs + + Today + Yesterday + This week + Last week + Search Search YouTube Music… @@ -61,6 +67,7 @@ Refetch Share Delete + Remove from history Search online Sync From 8a068aa8effc545577efdcca51db0a17cf91f43e Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 14 Feb 2023 11:55:41 +0800 Subject: [PATCH 199/323] Add option to disable listen history --- .../java/com/zionhuang/music/constants/PreferenceKeys.kt | 1 + .../main/java/com/zionhuang/music/playback/SongPlayer.kt | 2 +- .../music/ui/screens/settings/PrivacySettings.kt | 8 ++++++++ app/src/main/res/values-DE/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-es-rUS/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa-rIR/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-ko-rKR/strings.xml | 1 + app/src/main/res/values-ml-rIN/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pa/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sv-rSE/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 7 ++++--- app/src/main/res/values/strings.xml | 1 + 25 files changed, 35 insertions(+), 4 deletions(-) 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 7c475f7fd..1460be3ac 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -29,6 +29,7 @@ val AutoDownloadKey = booleanPreferencesKey("autoDownload") val ExpandOnPlayKey = booleanPreferencesKey("expandOnPlay") val NotificationMoreActionKey = booleanPreferencesKey("notificationMoreAction") +val PauseListenHistoryKey = booleanPreferencesKey("pauseListenHistory") val PauseSearchHistoryKey = booleanPreferencesKey("pauseSearchHistory") val EnableKugouKey = booleanPreferencesKey("enableKugou") diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 1a3915024..7d4e67ee6 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -716,7 +716,7 @@ class SongPlayer( database.query { incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) - if (playbackStats.totalPlayTimeMs >= 10000) { + if (playbackStats.totalPlayTimeMs >= 10000 && !context.dataStore.get(PauseListenHistoryKey, false)) { try { insert( Event( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index 60a3d5626..926beed08 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -16,6 +16,7 @@ import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.EnableKugouKey +import com.zionhuang.music.constants.PauseListenHistoryKey import com.zionhuang.music.constants.PauseSearchHistoryKey import com.zionhuang.music.ui.component.DefaultDialog import com.zionhuang.music.ui.component.PreferenceEntry @@ -29,6 +30,7 @@ fun PrivacySettings( scrollBehavior: TopAppBarScrollBehavior, ) { val database = LocalDatabase.current + val (pauseListenHistory, onPauseListenHistoryChange) = rememberPreference(key = PauseListenHistoryKey, defaultValue = false) val (pauseSearchHistory, onPauseSearchHistoryChange) = rememberPreference(key = PauseSearchHistoryKey, defaultValue = false) val (enableKugou, onEnableKugouChange) = rememberPreference(key = EnableKugouKey, defaultValue = true) @@ -72,6 +74,12 @@ fun PrivacySettings( .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { + SwitchPreference( + title = stringResource(R.string.pause_listen_history), + icon = R.drawable.ic_history, + checked = pauseListenHistory, + onCheckedChange = onPauseListenHistoryChange + ) SwitchPreference( title = stringResource(R.string.pause_search_history), icon = R.drawable.ic_manage_search, diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 0c6a7f457..003fa4329 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -217,6 +217,7 @@ Schaltflächen "Zur Bibliothek hinzufügen" und "Gefällt mir" anzeigen Privatsphäre + Pause listen history Suchverlauf anhalten Suchverlauf löschen Sind Sie sicher, dass Sie den gesamten Suchverlauf löschen? diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index cc431c5c2..e3065562f 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -228,6 +228,7 @@ Zobrazit tlačítka přidání do knihovny a oblíbení Soukromí + Pause listen history Pozastavit historii vyhledávání Vymazat historii vyhledávání Opravdu chcete vymazat celou historii vyhledávání? diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 57c6c99ac..61b9cc1c1 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 79aa9eec9..8d22ebdc1 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index f0dcce64b..6eb5c3b2f 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons حریم‌خصوصی + Pause listen history متوقف‌کردن تاریخچه جستجو پاک‌کردن تاریخچه جستجو آیا برای پاک‌کردن تمام سابقه جستجو مطمئن هستید؟ diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 605942660..9e978be23 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index b38b2afc4..798304b43 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -223,6 +223,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 702ad1e4c..9c69b23f9 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -217,6 +217,7 @@ Könyvtárhoz adás és tetszés gombok megjelenítése Adatvédelem + Pause listen history Keresési előzmények szüneteltetése Keresési előzmények törlése Biztosan töröl minden keresési előzményt? diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 5b5d1af8b..0940c169f 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -211,6 +211,7 @@ Tampilkan tombol tambahkan ke perpustakaan dan suka Privasi + Pause listen history Jeda riwayat pencarian Bersihkan riwayat pencarian Apakah anda yakin untuk menghapus semua riwayat penelusuran? diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 5494bddbf..e4699089d 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -217,6 +217,7 @@ Mostra i bottoni Aggiungi alla libreria e Mi piace Privacy + Pause listen history Sospendi la cronologia delle ricerche Pulisci la cronologia delle ricerche Sei sicuro di voler cancellare la cronologia delle ricerche? diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index ed6fff5b9..1cabb099c 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -211,6 +211,7 @@ 「ライブラリに追加」と「いいね」ボタンを表示する プライバシー + Pause listen history 履歴の記録を一時停止 履歴を削除 すべての検索履歴を削除しますか? diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 2cbfcd7ed..dcd9fdd16 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -211,6 +211,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index a6b7beb44..9d7dad629 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons സ്വകാര്യത + Pause listen history തിരയൽ ചരിത്രം താൽക്കാലികമായി നിർത്തുക തിരയൽ ചരിത്രം മായ്‌ക്കുക എല്ലാ തിരയൽ ചരിത്രവും മായ്‌ക്കണമെന്ന് ഉറപ്പാണോ? diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index c2e1a1b4c..ed4fb54be 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -217,6 +217,7 @@ ଲାଇବ୍ରେରୀରେ ଯୋଡନ୍ତୁ ଏବଂ ବଟନ୍ ପସନ୍ଦ କରନ୍ତୁ ଗୋପନୀୟତା + Pause listen history ସନ୍ଧାନ ଇତିହାସକୁ ବିରତି ଦିଅ ସନ୍ଧାନ ଇତିହାସ ସଫା କରନ୍ତୁ ଆପଣ ସମସ୍ତ ସନ୍ଧାନ ଇତିହାସ ସଫା କରିବାକୁ ନିଶ୍ଚିତ କି? diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 96360ec3b..65f5dd513 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -217,6 +217,7 @@ ਲਾਇਬ੍ਰੇਰੀ ਵਿੱਚ ਜੋੜੋ ਅਤੇ ਪਸੰਦ ਬਟਨ ਵਿਖਾਓ ਗੋਪਨੀਯਤਾ + Pause listen history ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਰੋਕੋ ਖੋਜ ਇਤਿਹਾਸ ਸਾਫ਼ ਕਰੋ ਕੀ ਤੁਸੀਂ ਸਾਰੇ ਖੋਜ ਇਤਿਹਾਸ ਨੂੰ ਸਾਫ਼ ਕਰਨ ਲਈ ਯਕੀਨੀ ਹੋ? diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 73b5b70ab..e6103087f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons Privacidade + Pause listen history Pausar histórico de pesquisa Limpar histórico de pesquisa Tem certeza que deseja deletar todo o seu histórico de pesquisa? diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 24d604783..4310cf6c0 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -228,6 +228,7 @@ Показывать кнопки «Добавить в библиотеку» и «Нравится» Конфиденциальность + Pause listen history Приостановить сохранение истории поиска Очистить историю поиска Вы уверены, что хотите очистить всю историю поиска? diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index c389b876d..ff8d667fe 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -217,6 +217,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index deef19d97..11310b251 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -228,6 +228,7 @@ Показувати кнопки «Додати до бібліотеки» та «Подобається» Конфіденційність + Pause listen history Призупинити запис історії пошуку Очистити історію пошуку Ви впевнені, що хочете очистити всю історію пошуку? diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 59600c0b2..44226170b 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -211,6 +211,7 @@ 显示“添加到媒体库”和“喜欢”按钮 隐私 + Pause listen history 暂停搜索记录 清除搜索记录 您确定要清除所有搜索记录吗? diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index af297c63e..8be2e5d7b 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -206,9 +206,10 @@ 顯示「加入媒體庫」和「喜歡」按鈕 隱私 - 暫停搜尋紀錄 - 清除搜尋紀錄 - 您確定要清除所有搜尋紀錄嗎? + 暫停觀看記錄 + 暫停搜尋記錄 + 清除搜尋記錄 + 您確定要清除所有搜尋記錄嗎? 使用酷狗音樂提供歌詞 備份與還原 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 5c03de00d..916db172e 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -216,6 +216,7 @@ Show add to library and like buttons Privacy + Pause listen history Pause search history Clear search history Are you sure to clear all search history? From cbcaef2df122cedc64bfa6633aa6712edb31b76c Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 14 Feb 2023 12:18:48 +0800 Subject: [PATCH 200/323] Persistent Loop (#549) --- .../java/com/zionhuang/music/constants/PreferenceKeys.kt | 1 + .../main/java/com/zionhuang/music/playback/SongPlayer.kt | 9 +++++++++ 2 files changed, 10 insertions(+) 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 1460be3ac..8e78e0dc2 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -70,6 +70,7 @@ val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") val NavTabConfigKey = stringPreferencesKey("navTabConfig") val PlayerVolumeKey = floatPreferencesKey("playerVolume") +val RepeatModeKey = intPreferencesKey("repeatMode") val SearchSourceKey = stringPreferencesKey("searchSource") diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 7d4e67ee6..f271f20f0 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -144,6 +144,7 @@ class SongPlayer( .apply { addListener(this@SongPlayer) addAnalyticsListener(PlaybackStatsListener(false, this@SongPlayer)) + repeatMode = context.dataStore.get(RepeatModeKey, Player.REPEAT_MODE_OFF) } private val normalizeFactor = MutableStateFlow(1f) @@ -742,6 +743,14 @@ class SongPlayer( } } + override fun onRepeatModeChanged(repeatMode: Int) { + scope.launch { + context.dataStore.edit { settings -> + settings[RepeatModeKey] = repeatMode + } + } + } + private fun saveQueueToDisk() { if (player.playbackState == STATE_IDLE) { context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() From 3933162829ec9fc2403a37170f472673d185308c Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Tue, 14 Feb 2023 10:00:12 +0300 Subject: [PATCH 201/323] Update full_description.txt --- fastlane/metadata/android/tr/full_description.txt | 1 + 1 file changed, 1 insertion(+) diff --git a/fastlane/metadata/android/tr/full_description.txt b/fastlane/metadata/android/tr/full_description.txt index 3478bdaf6..afcb40cba 100644 --- a/fastlane/metadata/android/tr/full_description.txt +++ b/fastlane/metadata/android/tr/full_description.txt @@ -1,4 +1,5 @@ Bu uygulama ile ücretsiz bir müzik akışı hizmeti almış gibi oluyorsunuz. YouTube Müzik'ten müzik dinleyebilir ve kendi kitaplığınızı oluşturabilirsiniz. Ayrıca şarkılar çevrimdışı çalınmak üzere indirilebilir. Şarkılarınızı düzenlemek için çalma listeleri de oluşturabilirsiniz. InnerTune'un amacı, kullanımı kolay, pratik ve reklamsız bir uygulama ile herkesin hiçbir ücret ödemeden müzik dinlemesini sağlamaktır. +
Not: Proje şu anda dengesiz bir aşamada. Hatalarla karşılaşırsanız, lütfen GitHub üzerinden bildirin From 4a8f4cc763b10aa03b1da28b12cf4f9d926e1212 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 26 Feb 2023 16:42:02 +0800 Subject: [PATCH 202/323] [Feature] Quick picks --- app/build.gradle.kts | 2 + .../9.json | 840 ++++++++++++++++++ .../java/com/zionhuang/music/MainActivity.kt | 2 +- .../com/zionhuang/music/db/DatabaseDao.kt | 31 +- .../com/zionhuang/music/db/MusicDatabase.kt | 10 +- .../music/db/entities/RelatedSongMap.kt | 29 + .../zionhuang/music/extensions/PlayerExt.kt | 11 +- .../zionhuang/music/playback/SongPlayer.kt | 184 ++-- .../music/ui/screens/HistoryScreen.kt | 1 - .../zionhuang/music/ui/screens/HomeScreen.kt | 388 ++++---- .../music/viewmodels/HomeViewModel.kt | 37 +- app/src/main/res/values-DE/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-es-rUS/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa-rIR/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-ko-rKR/strings.xml | 1 + app/src/main/res/values-ml-rIN/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pa/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sv-rSE/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 6 + app/src/main/res/values/strings.xml | 1 + .../java/com/zionhuang/innertube/YouTube.kt | 37 +- .../zionhuang/innertube/pages/RelatedPage.kt | 92 ++ .../com/zionhuang/innertube/YouTubeTest.kt | 7 + settings.gradle.kts | 3 +- 37 files changed, 1414 insertions(+), 287 deletions(-) create mode 100644 app/schemas/com.zionhuang.music.db.InternalDatabase/9.json create mode 100644 app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 20195abe6..0daa5f6c7 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -103,6 +103,8 @@ dependencies { implementation(libs.palette) implementation(projects.materialColorUtilities) + implementation(libs.accompanist.swiperefresh) + implementation(libs.coil) implementation(libs.shimmer) diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/9.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/9.json new file mode 100644 index 000000000..4426115f5 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/9.json @@ -0,0 +1,840 @@ +{ + "formatVersion": 1, + "database": { + "version": 9, + "identityHash": "ccad10efd9b5c5ee1dc9b42c6e3715fd", + "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, `downloadState` 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": "downloadState", + "columnName": "downloadState", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `bannerUrl` TEXT, `description` TEXT, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "bannerUrl", + "columnName": "bannerUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "description", + "columnName": "description", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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, `songCount` INTEGER NOT NULL, `duration` INTEGER NOT NULL, `createDate` INTEGER NOT NULL, `lastUpdateTime` INTEGER NOT NULL, 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": "songCount", + "columnName": "songCount", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "createDate", + "columnName": "createDate", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + } + ], + "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": "download", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER NOT NULL, `songId` TEXT NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "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, 'ccad10efd9b5c5ee1dc9b42c6e3715fd')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index a51c183ac..48b3540a3 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -319,7 +319,7 @@ class MainActivity : ComponentActivity() { LibraryPlaylistsScreen(navController) } composable("history") { - HistoryScreen(navController, scrollBehavior) + HistoryScreen(navController) } composable("new_release") { NewReleaseScreen(navController, scrollBehavior) 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 57015dd20..5f55e0729 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -60,8 +60,32 @@ interface DatabaseDao { } @Transaction - @Query("SELECT * FROM song ORDER BY totalPlayTime DESC LIMIT 20") - fun mostPlayedSongs(): Flow> + @Query( + """ + SELECT song.* + FROM (SELECT *, COUNT(1) AS referredCount + FROM related_song_map + GROUP BY relatedSongId) map + JOIN song ON song.id = map.relatedSongId + WHERE songId IN (SELECT * + FROM (SELECT songId + FROM event + WHERE timestamp > :now - 86400000 * 7 + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT 5) + UNION + SELECT * + FROM (SELECT songId + FROM event + ORDER BY ROWID DESC + LIMIT 5)) + AND totalPlayTime < 30000 + ORDER BY referredCount DESC + LIMIT 100 + """ + ) + fun quickPicks(now: Long = System.currentTimeMillis()): Flow> @Query("SELECT COUNT(1) FROM song WHERE liked") fun likedSongsCount(): Flow @@ -286,6 +310,9 @@ interface DatabaseDao { @Insert(onConflict = OnConflictStrategy.IGNORE) fun insert(event: Event) + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(map: RelatedSongMap) + @Transaction fun insert(mediaMetadata: MediaMetadata, block: (SongEntity) -> SongEntity = { it }) { if (insert(mediaMetadata.toSongEntity().let(block)) == -1L) return 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 a81139dc5..4bc3b3188 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -50,16 +50,17 @@ class MusicDatabase( PlaylistSongMap::class, DownloadEntity::class, SearchHistory::class, - Event::class, FormatEntity::class, - LyricsEntity::class + LyricsEntity::class, + Event::class, + RelatedSongMap::class ], views = [ SortedSongArtistMap::class, SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 8, + version = 9, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), @@ -67,7 +68,8 @@ class MusicDatabase( AutoMigration(from = 4, to = 5), AutoMigration(from = 5, to = 6, spec = Migration5To6::class), AutoMigration(from = 6, to = 7, spec = Migration6To7::class), - AutoMigration(from = 7, to = 8, spec = Migration7To8::class) + AutoMigration(from = 7, to = 8, spec = Migration7To8::class), + AutoMigration(from = 8, to = 9) ] ) @TypeConverters(Converters::class) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt b/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt new file mode 100644 index 000000000..3c347269f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/db/entities/RelatedSongMap.kt @@ -0,0 +1,29 @@ +package com.zionhuang.music.db.entities + +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey +import androidx.room.PrimaryKey + +@Entity( + tableName = "related_song_map", + foreignKeys = [ + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = SongEntity::class, + parentColumns = ["id"], + childColumns = ["relatedSongId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class RelatedSongMap( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val relatedSongId: String, +) diff --git a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt index c110f3e31..f78a68535 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt @@ -79,4 +79,13 @@ val Player.mediaItems: List get() = mediaItemCount override fun get(index: Int): MediaItem = getMediaItemAt(index) - } \ No newline at end of file + } + +fun Player.findNextMediaItemById(mediaId: String): MediaItem? { + for (i in currentMediaItemIndex until mediaItemCount) { + if (getMediaItemAt(i).mediaId == mediaId) { + return getMediaItemAt(i) + } + } + return null +} diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index f271f20f0..4178d2626 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -36,6 +36,9 @@ import com.google.android.exoplayer2.audio.SilenceSkippingAudioProcessor.DEFAULT import com.google.android.exoplayer2.database.StandaloneDatabaseProvider import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource +import com.google.android.exoplayer2.extractor.ExtractorsFactory +import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor +import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor import com.google.android.exoplayer2.source.DefaultMediaSourceFactory import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder import com.google.android.exoplayer2.ui.PlayerNotificationManager @@ -48,6 +51,7 @@ import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvicto import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.ACTION_SHOW_BOTTOM_SHEET import com.zionhuang.music.MainActivity @@ -61,17 +65,16 @@ import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.Event -import com.zionhuang.music.db.entities.FormatEntity -import com.zionhuang.music.db.entities.LyricsEntity 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.Song import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED import com.zionhuang.music.extensions.* import com.zionhuang.music.lyrics.LyricsHelper import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.PersistQueue +import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.MusicService.Companion.ALBUM import com.zionhuang.music.playback.MusicService.Companion.ARTIST import com.zionhuang.music.playback.MusicService.Companion.PLAYLIST @@ -444,72 +447,113 @@ class SongPlayer( .setCache(cache) .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, createOkHttpDataSourceFactory())) - private fun createMediaSourceFactory() = DefaultMediaSourceFactory(ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> - runBlocking { - val mediaId = dataSpec.key ?: error("No media id") + private fun createExtractorsFactory() = ExtractorsFactory { + arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) + } - if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { - return@runBlocking dataSpec - } + private fun createDataSourceFactory(): ResolvingDataSource.Factory { + val songUrlCache = HashMap>() + return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> + runBlocking { + val mediaId = dataSpec.key ?: error("No media id") - // Check whether format exists so that users from older version can view format details - // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently - val playedFormat = database.format(mediaId).firstOrNull() - val song = database.song(mediaId).firstOrNull() - if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { - return@runBlocking dataSpec.withUri(getSongFile(context, mediaId).toUri()) - } + if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { + return@runBlocking dataSpec + } - val playerResponse = withContext(IO) { - YouTube.player(mediaId) - }.getOrElse { throwable -> - if (throwable is ConnectException || throwable is UnknownHostException) { - throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) + songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + return@runBlocking dataSpec.withUri(it.first.toUri()) } - if (throwable is SocketTimeoutException) { - throw PlaybackException(context.getString(R.string.error_timeout), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) + + // Check whether format exists so that users from older version can view format details + // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently + val playedFormat = database.format(mediaId).firstOrNull() + val song = database.song(mediaId).firstOrNull() + if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { + return@runBlocking dataSpec.withUri(getSongFile(context, mediaId).toUri()) } - throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) - } - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) - } - val format = if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { - // Use itag to identify previous played format - it.itag == playedFormat.itag + val playerResponse = withContext(IO) { + YouTube.player(mediaId) + }.getOrElse { throwable -> + when (throwable) { + is ConnectException, is UnknownHostException -> { + throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) + } + is SocketTimeoutException -> { + throw PlaybackException(context.getString(R.string.error_timeout), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) + } + else -> throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) + } } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - AudioQuality.HIGH -> 1 - AudioQuality.LOW -> -1 - } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) + } + + val format = if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { + // Use itag to identify previous played format + it.itag == playedFormat.itag + } + } else { + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream + } + } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) + + database.query { + val mediaMetadata = runBlocking(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } + if (song == null) { + mediaMetadata?.let { + insert(it.copy(duration = format.approxDurationMs?.toInt()?.div(1000) ?: it.duration)) + } + } else if (song.song.duration == -1) { + update(song.song.copy(duration = format.approxDurationMs?.toInt()?.div(1000) ?: mediaMetadata?.duration ?: -1)) } - } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) - - database.query { - currentMediaMetadata.value?.let(::insert) - upsert( - FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + ) ) - ) + } + + scope.launch(IO) { + val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return@launch + val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return@launch + database.query { + relatedPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .map { + RelatedSongMap( + songId = mediaId, + relatedSongId = it.id + ) + } + .forEach(::insert) + } + } + + songUrlCache[mediaId] = format.url to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } - dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } - }) + } + + private fun createMediaSourceFactory() = DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) private fun createRenderersFactory() = object : DefaultRenderersFactory(context) { override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = @@ -715,18 +759,20 @@ class SongPlayer( override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem database.query { - incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) - - if (playbackStats.totalPlayTimeMs >= 10000 && !context.dataStore.get(PauseListenHistoryKey, false)) { - try { - insert( - Event( - songId = mediaItem.mediaId, - timestamp = LocalDateTime.now(), - playTime = playbackStats.totalPlayTimeMs + if (playbackStats.totalPlayTimeMs >= 30000) { + incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + + if (!context.dataStore.get(PauseListenHistoryKey, false)) { + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs + ) ) - ) - } catch (_: SQLException) { + } catch (_: SQLException) { + } } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt index a5a5fa732..fc4197b06 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt @@ -35,7 +35,6 @@ import java.time.format.DateTimeFormatter @Composable fun HistoryScreen( navController: NavController, - scrollBehavior: TopAppBarScrollBehavior, viewModel: HistoryViewModel = hiltViewModel(), ) { val coroutineScope = rememberCoroutineScope() 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 4e27865bd..96fdb05ea 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 @@ -23,7 +23,9 @@ import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController -import com.zionhuang.innertube.models.* +import com.google.accompanist.swiperefresh.SwipeRefresh +import com.google.accompanist.swiperefresh.rememberSwipeRefreshState +import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R @@ -41,6 +43,7 @@ import com.zionhuang.music.viewmodels.HomeViewModel import com.zionhuang.music.viewmodels.MainViewModel import kotlin.random.Random +@Suppress("DEPRECATION") @OptIn(ExperimentalFoundationApi::class) @Composable fun HomeScreen( @@ -55,225 +58,226 @@ fun HomeScreen( val libraryAlbumIds by mainViewModel.libraryAlbumIds.collectAsState() - val mostPlayedSongs by viewModel.mostPlayedSongs.collectAsState() + val quickPicks by viewModel.quickPicks.collectAsState() val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + val isRefreshing by viewModel.isRefreshing.collectAsState() val coroutineScope = rememberCoroutineScope() - val mostPlayedLazyGridState = rememberLazyGridState() - BoxWithConstraints( - modifier = Modifier.fillMaxSize() + SwipeRefresh( + state = rememberSwipeRefreshState(isRefreshing), + onRefresh = viewModel::refresh, + modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current) ) { - val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f - val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor - val snapLayoutInfoProvider = remember(mostPlayedLazyGridState) { - SnapLayoutInfoProvider( - lazyGridState = mostPlayedLazyGridState, - positionInLayout = { layoutSize, itemSize -> - (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) - } - ) - } - - Column( - modifier = Modifier.verticalScroll(rememberScrollState()) + BoxWithConstraints( + modifier = Modifier.fillMaxSize() ) { - Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) - - Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), - modifier = Modifier - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) - .padding(horizontal = 12.dp, vertical = 6.dp) - ) { - NavigationTile( - title = stringResource(R.string.history), - icon = R.drawable.ic_history, - onClick = { navController.navigate("history") }, - modifier = Modifier.weight(1f) - ) - NavigationTile( - title = stringResource(R.string.stats), - icon = R.drawable.ic_trending_up, - onClick = { navController.navigate("stats") }, - enabled = false, - modifier = Modifier.weight(1f) - ) - NavigationTile( - title = stringResource(R.string.settings), - icon = R.drawable.ic_settings, - onClick = { navController.navigate("settings") }, - modifier = Modifier.weight(1f) + val horizontalLazyGridItemWidthFactor = if (maxWidth * 0.475f >= 320.dp) 0.475f else 0.9f + val horizontalLazyGridItemWidth = maxWidth * horizontalLazyGridItemWidthFactor + val snapLayoutInfoProvider = remember(mostPlayedLazyGridState) { + SnapLayoutInfoProvider( + lazyGridState = mostPlayedLazyGridState, + positionInLayout = { layoutSize, itemSize -> + (layoutSize * horizontalLazyGridItemWidthFactor / 2f - itemSize / 2f) + } ) } - if (mostPlayedSongs.isNotEmpty()) { - Text( - text = stringResource(R.string.most_played_songs), - style = MaterialTheme.typography.headlineSmall, + Column( + modifier = Modifier.verticalScroll(rememberScrollState()) + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) - .padding(12.dp) - ) - - LazyHorizontalGrid( - state = mostPlayedLazyGridState, - rows = GridCells.Fixed(4), - flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), - contentPadding = WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues(), - modifier = Modifier - .fillMaxWidth() - .height(ListItemHeight * 4) + .padding(horizontal = 12.dp, vertical = 6.dp) ) { - items( - items = mostPlayedSongs, - key = { it.id } - ) { song -> - SongListItem( - song = song, - isPlaying = song.id == mediaMetadata?.id, - playWhenReady = playWhenReady, - trailingContent = { - IconButton( - onClick = { - menuState.show { - SongMenu( - originalSong = song, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) + NavigationTile( + title = stringResource(R.string.history), + icon = R.drawable.ic_history, + onClick = { navController.navigate("history") }, + modifier = Modifier.weight(1f) + ) + NavigationTile( + title = stringResource(R.string.stats), + icon = R.drawable.ic_trending_up, + onClick = { navController.navigate("stats") }, + enabled = false, + modifier = Modifier.weight(1f) + ) + NavigationTile( + title = stringResource(R.string.settings), + icon = R.drawable.ic_settings, + onClick = { navController.navigate("settings") }, + modifier = Modifier.weight(1f) + ) + } + + if (quickPicks.isNotEmpty()) { + Text( + text = stringResource(R.string.quick_picks), + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .padding(12.dp) + ) + + LazyHorizontalGrid( + state = mostPlayedLazyGridState, + rows = GridCells.Fixed(4), + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues(), + modifier = Modifier + .fillMaxWidth() + .height(ListItemHeight * 4) + ) { + items( + items = quickPicks, + key = { it.id } + ) { song -> + SongListItem( + song = song, + isPlaying = song.id == mediaMetadata?.id, + playWhenReady = playWhenReady, + trailingContent = { + IconButton( + onClick = { + menuState.show { + SongMenu( + originalSong = song, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } } + ) { + Icon( + painter = painterResource(R.drawable.ic_more_vert), + contentDescription = null + ) } - ) { - Icon( - painter = painterResource(R.drawable.ic_more_vert), - contentDescription = null - ) - } - }, - modifier = Modifier - .width(horizontalLazyGridItemWidth) - .clickable { - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) - } - .animateItemPlacement() - ) + }, + modifier = Modifier + .width(horizontalLazyGridItemWidth) + .clickable { + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } + ) + } } } - } - if (newReleaseAlbums.isNotEmpty()) { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) - .clickable { - navController.navigate("new_release") - } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) + if (newReleaseAlbums.isNotEmpty()) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .clickable { + navController.navigate("new_release") + } + .padding(12.dp) ) { - Text( - text = stringResource(R.string.new_release_albums), - style = MaterialTheme.typography.headlineSmall + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = stringResource(R.string.new_release_albums), + style = MaterialTheme.typography.headlineSmall + ) + } + + Icon( + painter = painterResource(R.drawable.ic_navigate_next), + contentDescription = null ) } - Icon( - painter = painterResource(R.drawable.ic_navigate_next), - contentDescription = null - ) - } - - LazyRow( - contentPadding = WindowInsets.systemBars - .only(WindowInsetsSides.Horizontal) - .asPaddingValues() - ) { - items( - items = newReleaseAlbums, - key = { it.id } - ) { album -> - YouTubeGridItem( - item = album, - badges = { - if (album.id in libraryAlbumIds) { - Icon( - painter = painterResource(R.drawable.ic_library_add_check), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - if (album.explicit) { - Icon( - painter = painterResource(R.drawable.ic_explicit), - contentDescription = null, - modifier = Modifier - .size(18.dp) - .padding(end = 2.dp) - ) - } - }, - isPlaying = mediaMetadata?.album?.id == album.id, - playWhenReady = playWhenReady, - modifier = Modifier - .combinedClickable( - onClick = { - navController.navigate("album/${album.id}") - }, - onLongClick = { - menuState.show { - YouTubeAlbumMenu( - album = album, - navController = navController, - playerConnection = playerConnection, - coroutineScope = coroutineScope, - onDismiss = menuState::dismiss - ) - } + LazyRow( + contentPadding = WindowInsets.systemBars + .only(WindowInsetsSides.Horizontal) + .asPaddingValues() + ) { + items( + items = newReleaseAlbums, + key = { it.id } + ) { album -> + YouTubeGridItem( + item = album, + badges = { + if (album.id in libraryAlbumIds) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) } - ) - .animateItemPlacement() - ) + if (album.explicit) { + Icon( + painter = painterResource(R.drawable.ic_explicit), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, + isPlaying = mediaMetadata?.album?.id == album.id, + playWhenReady = playWhenReady, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("album/${album.id}") + }, + onLongClick = { + menuState.show { + YouTubeAlbumMenu( + album = album, + navController = navController, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + .animateItemPlacement() + ) + } } } } - Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) - } - - if (mostPlayedSongs.isNotEmpty() || newReleaseAlbums.isNotEmpty()) { - FloatingActionButton( - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + if (quickPicks.isNotEmpty() || newReleaseAlbums.isNotEmpty()) { + FloatingActionButton( + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + .padding(16.dp), + onClick = { + if (Random.nextBoolean() && quickPicks.isNotEmpty()) { + val song = quickPicks.random() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } else if (newReleaseAlbums.isNotEmpty()) { + val album = newReleaseAlbums.random() + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + } + }) { + Icon( + painter = painterResource(R.drawable.ic_casino), + contentDescription = null ) - .padding(16.dp), - onClick = { - if (Random.nextBoolean() && mostPlayedSongs.isNotEmpty()) { - val song = mostPlayedSongs.random() - playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) - } else if (newReleaseAlbums.isNotEmpty()) { - val album = newReleaseAlbums.random() - playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) - } - }) { - Icon( - painter = painterResource(R.drawable.ic_casino), - contentDescription = null - ) + } } } } 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 dcd3a8c8c..bdd360d89 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt @@ -5,29 +5,42 @@ import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.AlbumItem import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.Song import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.SharingStarted -import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.stateIn +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import javax.inject.Inject @HiltViewModel class HomeViewModel @Inject constructor( - database: MusicDatabase, + val database: MusicDatabase, ) : ViewModel() { - val mostPlayedSongs = database.mostPlayedSongs() - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val isRefreshing = MutableStateFlow(false) - private val _newReleaseAlbums = MutableStateFlow>(emptyList()) - val newReleaseAlbums = _newReleaseAlbums.asStateFlow() + val quickPicks = MutableStateFlow>(emptyList()) + val newReleaseAlbums = MutableStateFlow>(emptyList()) + + private suspend fun load() { + quickPicks.value = database.quickPicks().first().shuffled().take(20) + YouTube.newReleaseAlbumsPreview().onSuccess { + newReleaseAlbums.value = it + } + } + + fun refresh() { + if (isRefreshing.value) return + viewModelScope.launch(Dispatchers.IO) { + isRefreshing.value = true + load() + isRefreshing.value = false + } + } init { - viewModelScope.launch { - YouTube.newReleaseAlbumsPreview().onSuccess { - _newReleaseAlbums.value = it - } + viewModelScope.launch(Dispatchers.IO) { + load() } } } diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 003fa4329..615ee4b0a 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index e3065562f..ae3befc03 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -17,6 +17,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 61b9cc1c1..0944cd91a 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index 8d22ebdc1..0c382f113 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 6eb5c3b2f..a3362b6ab 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 9e978be23..61a53c027 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 798304b43..e9d6889c9 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -16,6 +16,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 9c69b23f9..24b7410bb 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 0940c169f..652d42ae8 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -14,6 +14,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index e4699089d..2a214f9dc 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 1cabb099c..d763197a4 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -14,6 +14,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index dcd9fdd16..98c4a9198 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -14,6 +14,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 9d7dad629..0a7840754 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index ed4fb54be..3d96c78eb 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 65f5dd513..b834eebfb 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index e6103087f..23a5f790f 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release álbuns Most played songs diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 4310cf6c0..fd9d67498 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -17,6 +17,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index ff8d667fe..dfee61fad 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -15,6 +15,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 11310b251..0c5a6f57b 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -17,6 +17,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 44226170b..99265a3a6 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,6 +14,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index 8be2e5d7b..a80830af7 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,6 +14,7 @@ 歷史記錄 統計 + 歌曲快選 新專輯 最常播放 @@ -191,9 +192,14 @@ 儲存 快取 + 圖片快取 + 歌曲快取 + 最大快取大小 + 無限制 圖片快取大小 清除圖片快取 歌曲快取大小 + 清除歌曲快取 已使用 %s 一般 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 916db172e..f61419d9a 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,6 +14,7 @@ History Stats + Quick picks New release albums Most played songs diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 5857054fd..9b18b3ee2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -1,6 +1,7 @@ package com.zionhuang.innertube import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV import com.zionhuang.innertube.models.YouTubeClient.Companion.ANDROID_MUSIC import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX import com.zionhuang.innertube.models.response.* @@ -93,10 +94,6 @@ object YouTube { ) } - suspend fun player(videoId: String, playlistId: String? = null): Result = runCatching { - innerTube.player(ANDROID_MUSIC, videoId, playlistId).body() - } - suspend fun album(browseId: String): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId).body() val playlistId = response.microformat?.microformatDataRenderer?.urlCanonical?.substringAfterLast('=')!! @@ -249,6 +246,10 @@ object YouTube { }.orEmpty() } + suspend fun player(videoId: String, playlistId: String? = null): Result = runCatching { + innerTube.player(ANDROID_MUSIC, videoId, playlistId).body() + } + suspend fun next(endpoint: WatchEndpoint, continuation: String? = null): Result = runCatching { val response = innerTube.next(WEB_REMIX, endpoint.videoId, endpoint.playlistId, endpoint.playlistSetVideoId, endpoint.index, endpoint.params, continuation).body() val playlistPanelRenderer = response.continuationContents?.playlistPanelContinuation @@ -288,6 +289,34 @@ object YouTube { response.contents?.sectionListRenderer?.contents?.firstOrNull()?.musicDescriptionShelfRenderer?.description?.runs?.firstOrNull()?.text } + suspend fun related(endpoint: BrowseEndpoint) = runCatching { + val response = innerTube.browse(WEB_REMIX, endpoint.browseId).body() + val songs = mutableListOf() + val albums = mutableListOf() + val artists = mutableListOf() + val playlists = mutableListOf() + response.contents?.sectionListRenderer?.contents?.forEach { sectionContent -> + sectionContent.musicCarouselShelfRenderer?.contents?.forEach { content -> + when (val item = content.musicResponsiveListItemRenderer?.let(RelatedPage.Companion::fromMusicResponsiveListItemRenderer) + ?: content.musicTwoRowItemRenderer?.let(RelatedPage.Companion::fromMusicTwoRowItemRenderer)) { + is SongItem -> if (content.musicResponsiveListItemRenderer?.overlay + ?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchEndpoint?.watchEndpointMusicSupportedConfigs + ?.watchEndpointMusicConfig?.musicVideoType == MUSIC_VIDEO_TYPE_ATV + ) { + songs.add(item) + } + is AlbumItem -> albums.add(item) + is ArtistItem -> artists.add(item) + is PlaylistItem -> playlists.add(item) + null -> {} + } + } + } + RelatedPage(songs, albums, artists, playlists) + } + suspend fun queue(videoIds: List? = null, playlistId: String? = null): Result> = runCatching { if (videoIds != null) { assert(videoIds.size <= MAX_GET_QUEUE_SIZE) // Max video limit diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt new file mode 100644 index 000000000..9460c9350 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt @@ -0,0 +1,92 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.* + +data class RelatedPage( + val songs: List, + val albums: List, + val artists: List, + val playlists: List, +) { + companion object { + fun fromMusicResponsiveListItemRenderer(renderer: MusicResponsiveListItemRenderer): SongItem? { + return SongItem( + id = renderer.playlistItemData?.videoId ?: return null, + title = renderer.flexColumns.firstOrNull() + ?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull() + ?.text ?: return null, + artists = renderer.flexColumns.getOrNull(1)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.oddElements()?.map { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + album = renderer.flexColumns.getOrNull(2)?.musicResponsiveListItemFlexColumnRenderer?.text?.runs?.firstOrNull()?.let { + Album( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId ?: return null + ) + }, + duration = null, + thumbnail = renderer.thumbnail?.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.badges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + } + + fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): YTItem? { + return when { + renderer.isAlbum -> AlbumItem( + browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + playlistId = renderer.thumbnailOverlay?.musicItemThumbnailOverlayRenderer + ?.content?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint?.playlistId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + artists = null, + year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + explicit = renderer.subtitleBadges?.find { + it.musicInlineBadgeRenderer.icon.iconType == "MUSIC_EXPLICIT_BADGE" + } != null + ) + renderer.isPlaylist -> PlaylistItem( + id = renderer.navigationEndpoint.browseEndpoint?.browseId?.removePrefix("VL") ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + author = renderer.subtitle.runs?.getOrNull(2)?.let { + Artist( + name = it.text, + id = it.navigationEndpoint?.browseEndpoint?.browseId + ) + } ?: return null, + songCountText = renderer.subtitle.runs.getOrNull(4)?.text, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + playEndpoint = renderer.thumbnailOverlay + ?.musicItemThumbnailOverlayRenderer?.content + ?.musicPlayButtonRenderer?.playNavigationEndpoint + ?.watchPlaylistEndpoint ?: return null, + shuffleEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MIX" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null + ) + renderer.isArtist -> { + ArtistItem( + id = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, + title = renderer.title.runs?.firstOrNull()?.text ?: return null, + thumbnail = renderer.thumbnailRenderer.musicThumbnailRenderer?.getThumbnailUrl() ?: return null, + shuffleEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + radioEndpoint = renderer.menu.menuRenderer.items.find { + it.menuNavigationItemRenderer?.icon?.iconType == "MIX" + }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, + ) + } + else -> null + } + } + } +} diff --git a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt index 6b6b1ef4d..55e429fe3 100644 --- a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt +++ b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt @@ -158,6 +158,13 @@ class YouTubeTest { assertTrue(lyrics != null) } + @Test + fun related() = runBlocking { + val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = "Z6ji6kls_OA")).getOrThrow().relatedEndpoint!! + val relatedPage = YouTube.related(relatedEndpoint).getOrThrow() + assertTrue(relatedPage.songs.isNotEmpty()) + } + companion object { private val VIDEO_IDS = listOf( "4H-N260cPCg", diff --git a/settings.gradle.kts b/settings.gradle.kts index f78481ad9..1c578d36f 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -35,11 +35,12 @@ dependencyResolutionManagement { library("viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("lifecycle") library("viewmodel-compose", "androidx.lifecycle", "lifecycle-viewmodel-compose").versionRef("lifecycle") + library("material", "androidx.compose.material", "material").version("1.4.0-beta02") version("material3", "1.1.0-alpha05") library("material3", "androidx.compose.material3", "material3").versionRef("material3") library("material3-windowsize", "androidx.compose.material3", "material3-window-size-class").versionRef("material3") - library("accompanist-insets", "com.google.accompanist", "accompanist-insets").version("0.28.0") + library("accompanist-swiperefresh", "com.google.accompanist", "accompanist-swiperefresh").version("0.28.0") library("coil", "io.coil-kt", "coil-compose").version("2.2.2") From bdf01fb885eb740d7aa566ac8c60b332289ae5ff Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 26 Feb 2023 22:58:09 +0800 Subject: [PATCH 203/323] Recover missing info when playing song --- .../com/zionhuang/music/db/DatabaseDao.kt | 3 + .../zionhuang/music/playback/SongPlayer.kt | 150 +++++++++--------- 2 files changed, 82 insertions(+), 71 deletions(-) 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 5f55e0729..205bda2ca 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -261,6 +261,9 @@ interface DatabaseDao { @Query("UPDATE song SET inLibrary = :inLibrary WHERE id = :songId") fun inLibrary(songId: String, inLibrary: LocalDateTime?) + @Query("SELECT COUNT(1) FROM related_song_map WHERE songId = :songId LIMIT 1") + fun hasRelatedSongs(songId: String): Boolean + @Query( """ UPDATE playlist_song_map SET position = diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 4178d2626..4d50b4f00 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -53,6 +53,7 @@ import com.google.android.exoplayer2.upstream.cache.SimpleCache import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.SongItem import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.models.response.PlayerResponse import com.zionhuang.music.ACTION_SHOW_BOTTOM_SHEET import com.zionhuang.music.MainActivity import com.zionhuang.music.R @@ -451,46 +452,78 @@ class SongPlayer( arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) } + private suspend fun recoverSong(mediaId: String, playerResponse: PlayerResponse? = null) { + val song = database.song(mediaId).first() + val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return + val duration = song?.song?.duration?.takeIf { it != -1 } + ?: mediaMetadata.duration.takeIf { it != -1 } + ?: (playerResponse ?: YouTube.player(mediaId).getOrNull())?.videoDetails?.lengthSeconds?.toInt() + ?: -1 + database.query { + if (song == null) insert(mediaMetadata.copy(duration = duration)) + else if (song.song.duration == -1) update(song.song.copy(duration = duration)) + } + if (!database.hasRelatedSongs(mediaId)) { + val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return + val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return + database.query { + relatedPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .map { + RelatedSongMap( + songId = mediaId, + relatedSongId = it.id + ) + } + .forEach(::insert) + } + } + } + private fun createDataSourceFactory(): ResolvingDataSource.Factory { val songUrlCache = HashMap>() return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> - runBlocking { - val mediaId = dataSpec.key ?: error("No media id") + val mediaId = dataSpec.key ?: error("No media id") - if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { - return@runBlocking dataSpec - } + if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { + scope.launch(IO) { recoverSong(mediaId) } + return@Factory dataSpec + } - songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { - return@runBlocking dataSpec.withUri(it.first.toUri()) - } + songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + scope.launch(IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(it.first.toUri()) + } - // Check whether format exists so that users from older version can view format details - // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently - val playedFormat = database.format(mediaId).firstOrNull() - val song = database.song(mediaId).firstOrNull() - if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { - return@runBlocking dataSpec.withUri(getSongFile(context, mediaId).toUri()) - } + // Check whether format exists so that users from older version can view format details + // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently + val playedFormat = runBlocking(IO) { database.format(mediaId).first() } + val song = runBlocking(IO) { database.song(mediaId).first() } + if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { + scope.launch(IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(getSongFile(context, mediaId).toUri()) + } - val playerResponse = withContext(IO) { - YouTube.player(mediaId) - }.getOrElse { throwable -> - when (throwable) { - is ConnectException, is UnknownHostException -> { - throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) - } - is SocketTimeoutException -> { - throw PlaybackException(context.getString(R.string.error_timeout), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) - } - else -> throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) + val playerResponse = runBlocking(IO) { + YouTube.player(mediaId) + }.getOrElse { throwable -> + when (throwable) { + is ConnectException, is UnknownHostException -> { + throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) } + is SocketTimeoutException -> { + throw PlaybackException(context.getString(R.string.error_timeout), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) + } + else -> throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) } - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) - } + } + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) + } - val format = if (playedFormat != null) { + val format = + if (playedFormat != null) { playerResponse.streamingData?.adaptiveFormats?.find { // Use itag to identify previous played format it.itag == playedFormat.itag @@ -507,49 +540,24 @@ class SongPlayer( } } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) - database.query { - val mediaMetadata = runBlocking(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } - if (song == null) { - mediaMetadata?.let { - insert(it.copy(duration = format.approxDurationMs?.toInt()?.div(1000) ?: it.duration)) - } - } else if (song.song.duration == -1) { - update(song.song.copy(duration = format.approxDurationMs?.toInt()?.div(1000) ?: mediaMetadata?.duration ?: -1)) - } - upsert( - FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - ) + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb ) - } - - scope.launch(IO) { - val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return@launch - val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return@launch - database.query { - relatedPage.songs - .map(SongItem::toMediaMetadata) - .onEach(::insert) - .map { - RelatedSongMap( - songId = mediaId, - relatedSongId = it.id - ) - } - .forEach(::insert) - } - } - - songUrlCache[mediaId] = format.url to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + ) } + scope.launch(IO) { recoverSong(mediaId, playerResponse) } + + songUrlCache[mediaId] = format.url to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) } } From d0ae34076cadb63167b728cb7c162fd895053270 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 28 Feb 2023 17:14:47 +0800 Subject: [PATCH 204/323] Show in-library icon in history screen --- .../com/zionhuang/music/ui/component/Items.kt | 24 +++++++++---------- .../com/zionhuang/music/ui/menu/SongMenu.kt | 2 +- .../music/ui/screens/HistoryScreen.kt | 21 ++++++++++++++++ .../zionhuang/music/ui/screens/HomeScreen.kt | 6 ++++- 4 files changed, 39 insertions(+), 14 deletions(-) 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 61431a319..d2f910f34 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 @@ -145,18 +145,8 @@ fun SongListItem( song: Song, modifier: Modifier = Modifier, albumIndex: Int? = null, - showBadges: Boolean = true, - isPlaying: Boolean = false, - playWhenReady: Boolean = false, - trailingContent: @Composable RowScope.() -> Unit = {}, -) = ListItem( - title = song.song.title, - subtitle = joinByBullet( - song.artists.joinToString(), - makeTimeString(song.song.duration * 1000L) - ), - badges = { - if (showBadges && song.song.liked) { + badges: @Composable RowScope.() -> Unit = { + if (song.song.liked) { Icon( painter = painterResource(R.drawable.ic_favorite), contentDescription = null, @@ -167,6 +157,16 @@ fun SongListItem( ) } }, + isPlaying: Boolean = false, + playWhenReady: Boolean = false, + trailingContent: @Composable RowScope.() -> Unit = {}, +) = ListItem( + title = song.song.title, + subtitle = joinByBullet( + song.artists.joinToString(), + makeTimeString(song.song.duration * 1000L) + ), + badges = badges, thumbnailContent = { Box( contentAlignment = Alignment.Center, 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 346eb1524..a894865ec 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 @@ -202,7 +202,7 @@ fun SongMenu( SongListItem( song = song, - showBadges = false, + badges = {}, trailingContent = { IconButton( onClick = { diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt index fc4197b06..d684451ad 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/HistoryScreen.kt @@ -76,6 +76,27 @@ fun HistoryScreen( song = event.song, isPlaying = event.song.id == mediaMetadata?.id, playWhenReady = playWhenReady, + badges = { + if (event.song.song.inLibrary != null) { + Icon( + painter = painterResource(R.drawable.ic_library_add_check), + contentDescription = null, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + if (event.song.song.liked) { + Icon( + painter = painterResource(R.drawable.ic_favorite), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .size(18.dp) + .padding(end = 2.dp) + ) + } + }, trailingContent = { IconButton( onClick = { 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 96fdb05ea..cd13a3037 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 @@ -68,7 +68,7 @@ fun HomeScreen( SwipeRefresh( state = rememberSwipeRefreshState(isRefreshing), onRefresh = viewModel::refresh, - modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + indicatorPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { BoxWithConstraints( modifier = Modifier.fillMaxSize() @@ -87,6 +87,8 @@ fun HomeScreen( Column( modifier = Modifier.verticalScroll(rememberScrollState()) ) { + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) + Row( horizontalArrangement = Arrangement.spacedBy(12.dp), modifier = Modifier @@ -253,6 +255,8 @@ fun HomeScreen( } } } + + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) } if (quickPicks.isNotEmpty() || newReleaseAlbums.isNotEmpty()) { From 38a486853fa2c1516804406f74b751fe1b7dc2f8 Mon Sep 17 00:00:00 2001 From: dortlanders <126497374+dortlanders@users.noreply.github.com> Date: Tue, 28 Feb 2023 14:10:44 +0100 Subject: [PATCH 205/323] Create strings.xml --- app/src/main/res/values-nl/strings.xml | 309 +++++++++++++++++++++++++ 1 file changed, 309 insertions(+) create mode 100644 app/src/main/res/values-nl/strings.xml diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml new file mode 100644 index 000000000..7c90f77e3 --- /dev/null +++ b/app/src/main/res/values-nl/strings.xml @@ -0,0 +1,309 @@ + + + Thuis + Nummers + Artiesten + Albums + Afspeellijsten + Ontdekken + Instellingen + Speelt nu + Fout melden + + + Uiterlijk + Volg systeem + Themakleur + Donker thema + Aan + Uit + Volg systeem + Standaard tabblad + Pas tabbladen aan + Songtekst positie + Links + Midden + Rechts + + Inhoud + Inloggen + Standaard inhoudstaal + Standaard inhoudsland + Proxy inschakelen + Proxy type + Proxy URL + Herstart om effect te hebben + + Speler en geluid + Geluidskwaliteit + Automatisch + Hoog + Laag + Blijvende wachtrij + Sla sliltes over + Geluidsnormalisatie + Equalizer + + Opslag + Gedownloade bestanden in SAF bekijken + Dit werkt mogelijk niet in sommige apparaten + Cache + Maximale beeldcachegrootte + Verwijder beeldcache + Maximale cachegrootte voor nummers + %s gebruikt + + Algemeen + Automatisch downloaden + Download nummer wanneer toegevoegd aan bibliotheek + Nummer automatisch toevoegen aan bibliotheek + Voeg een nummer toe aan uw bibliotheek wanneer het afspelen is voltooid + Vouw de onderste speler uit tijdens het spelen + Meer acties in melding + Toon toevoegen aan bibliotheek en like-knoppen + + Privacy + Pauzeer de zoekgeschiedenis + Verwijder zoekgeschiedenis + Weet je zeker dat je de zoekgeschiedenis wil wissen? + Schakel KuGou songtekst provider in + + Backup en herstel + Backup + Herstellen + + Over + App versie + + + Sakura + Rood + Roze + Paars + Donker paars + Indigo + Blauw + Licht blauw + Cyaan + Wintergroen + Groen + Licht groen + Limoen groen + Geel + Amber + Oranje + Donker oranje + Bruin + Blauw-grijs + + + Search + Zoeken via YouTube Music… + Zoeken in bibliotheek… + + + Favoriete nummers + Gedownloade nummers + + + Details + Bewerken + Start radio + Afspelen + Volgende afspelen + Voeg toe aan wachtrij + Voeg toe aan bibliotheek + Downloaden + Verwijder download + Importeer afspeellijst + Voeg toe aan afspeellijst + Toon artiest + Toon album + Ververs + Delen + Verwijderen + Zoek online + Kies andere songtekst + + + Details + Media id + MIME type + Codecs + Bitrate + Voorbeeldfrequentie + Luidheid + Volume + Bestandsgrootte + Onbekend + + Bewerk songtekst + Zoek naar songtekst + Kies songtekst + + Bewerk nummer + Titel nummer + Artiesten nummer + Titel van nummer mag niet leeg zijn. + Artiest kan niet leeg zijn. + Opslaan + + Afspeellijst maken + Naam afspeellijst + Afspeellijstnaam mag niet leeg zijn. + + Bewerk artiest + Artiest naam + Artiestennaam mag niet leeg zijn. + + Dubbele artiesten + Artiest %1$s bestaat al. + + Kies afspeellijst + + Bewerk afspeellijst + + Kies back-upinhoud + Kies herstelinhoud + Voorkeuren + Database + Gedownloade nummers + Back-up succesvol gemaakt. + Back-up maken mislukt. + Fout bij terugzetten back-up. + + + Muziekspeler + Downloaden + + + + %d nummer + %d nummers + + + %d artiest + %d artiesten + + + %d album + %d albums + + + %d afspeellijst + %d afspeellijsten + + + + Opnieuw + Afspelen + Speel alles af + Radio + Shuffle + Kopieer stacktrace + Melden + Melden op GitHub + + + Datum toegevoegd + Naam + Artiest + Jaar + Song count + Duur + Speeltijd + + + + %d nummer is verwijderd. + %d nummers zijn verwijderd. + + + %d geselecteerd + %d geselecteerd + %d geselecteerd + %d geselecteerd + + Ongedaan maken + Can\'t identify this url. + + Het nummer zal hierna spelen + %d nummers spelen als volgende + + + De artiest zal hierna spelen + %d artiesten zullen hierna spelen + + + Album zal hierna spelen + %d zullen hierna spelen + + + Afspeellijst zal hierna spelen + %d afspeellijsten zullen hierna spelen + + Geselecteerde wordt als volgende afgespeeld + + Nummer toegevoegd aan wachtrij + %d nummers toegevoegd aan wachtrij + + + Artiest toegevoegd aan wachtrij + %d artiesten toegevoegd aan wachtrij + + + Album toegevoegd aan wachtrij + %d albums toegevoegd aan wachtrij + + + Afspeellijst toegevoegd aan wachtrij + %d afspeellijsten toegevoegd aan wachtrij + + Geselecteerde toegevoegd aan wachtrij + Toegevoegd aan bibliotheek + Verwijderd van bibliotheek + Afspeellijst geïmporteerd + Toegevoegd aan %1$s + + Start downloaden van nummer + Start downloaden %d nummers + + Download verwijderd + Weergeven + + + Like + Verwijder like + Toevoegen aan bibliotheek + Verwijderen van bibliotheek + + + Alles + Nummers + Videos + Albums + Artiesten + Afspeellijsten + Afspeellijsten van de community + Voorgestelde afspeellijsten + + Systeemstandaard + Van je bibliotheek + + + Sorry, dat had niet mogen gebeuren. + Gekopieerd naar klembord + + + Geen stream beschikbaar + Geen netwerkverbinding + Timeout + Onbekende fout + + + Alle nummers + Opgezochte nummers + + + Songtekst niet gevonden + From 73e85c59c948673271e281eee90041c7d88d9cdd Mon Sep 17 00:00:00 2001 From: dortlanders <126497374+dortlanders@users.noreply.github.com> Date: Tue, 28 Feb 2023 14:11:23 +0100 Subject: [PATCH 206/323] Fix --- app/src/main/res/values-nl/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 7c90f77e3..da77d3002 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -209,7 +209,7 @@ Naam Artiest Jaar - Song count + Aantal nummers Duur Speeltijd From ed5386f8664da8bbe70152a7aac0797896a3f02c Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 1 Mar 2023 15:33:26 +0800 Subject: [PATCH 207/323] Don't increment song play time when listen history is paused --- .../zionhuang/music/playback/SongPlayer.kt | 23 ++++++++----------- 1 file changed, 10 insertions(+), 13 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 4d50b4f00..127dbcf1f 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -766,21 +766,18 @@ class SongPlayer( override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem - database.query { - if (playbackStats.totalPlayTimeMs >= 30000) { + if (playbackStats.totalPlayTimeMs >= 30000 && !context.dataStore.get(PauseListenHistoryKey, false)) { + database.query { incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) - - if (!context.dataStore.get(PauseListenHistoryKey, false)) { - try { - insert( - Event( - songId = mediaItem.mediaId, - timestamp = LocalDateTime.now(), - playTime = playbackStats.totalPlayTimeMs - ) + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs ) - } catch (_: SQLException) { - } + ) + } catch (_: SQLException) { } } } From e07301e71c4e24d62228fdc10d214e5005183c3c Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko Date: Fri, 10 Mar 2023 20:05:25 +0200 Subject: [PATCH 208/323] Update strings.xml --- app/src/main/res/values-uk-rUA/strings.xml | 61 +++++++++++----------- 1 file changed, 31 insertions(+), 30 deletions(-) diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index 0c5a6f57b..fc62f9871 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -15,17 +15,17 @@
- History - Stats - Quick picks - New release albums - Most played songs + Історія + Статистика + Швидкий вибір + Нові релізи альбомів + Найбільш програвані пісні - Today - Yesterday - This week - Last week + Сьогодні + Вчора + Цього тижня + Минулого тижня Пошук @@ -39,7 +39,7 @@ Плейлисти Плейлисти спільноти Обрані плейлисти - No results found + Результатів не знайдено З вашої бібліотеки @@ -47,7 +47,7 @@ Улюблені треки Завантажена музика - The playlist is empty + Плейлист порожній Повторювати @@ -66,14 +66,14 @@ Видалити із завантажених Імпортувати плейлист Додати в плейлист - Перейти на сторінку виконавця + Перейти до виконавця Перейти до альбому Оновити Поділитися Видалити - Remove from history + Видалити з історії Пошук в Інтернеті - Sync + Синхронізація Нещодавно додані @@ -147,12 +147,13 @@ Текст пісні не знайдено - Sleep timer - End of song + Таймер сну + Кінець пісні - 1 minute - %d minutes - %d minutes + 1 хвилина + %d хвилини + %d хвилин + %d хвилин Немає доступних потоків Відсутнє підключення до мережі @@ -178,8 +179,8 @@ Темна тема Увімк. Вимк. - Використовувати конфігурацію системи - Pure black + Використовувати налаштування системи + Режим чистого чорного кольору Вкладка навігації за замовчуванням Налаштування вкладок навігації Розташування тексту пісні @@ -191,7 +192,7 @@ Логін Мова контенту Країна контенту - Використовувати конфігурацію системи + Використовувати налаштування системи Увімкнути проксі Тип проксі URL проксі @@ -209,14 +210,14 @@ Сховище Кеш - Image Cache - Song Cache - Max cache size - Unlimited + Кеш зображень + Кеш аудіо + Максимальний розмір кешу + Необмежено Макс. розмір кешу зображень Очистити кеш зображень Макс. розмір кешу аудіо - Clear song cache + Очистити кеш аудіо %s використано Загальні @@ -229,8 +230,8 @@ Показувати кнопки «Додати до бібліотеки» та «Подобається» Конфіденційність - Pause listen history - Призупинити запис історії пошуку + Призупинити історію прослуховувань + Призупинити історію пошуку Очистити історію пошуку Ви впевнені, що хочете очистити всю історію пошуку? Увімкнути провайдера текстів KuGou @@ -243,5 +244,5 @@ Не вдалося відновити з резервної копії Про програму - Версія програми + Версія додатка From 2606dffeef23ac9321a6f6d69f21f17336d7de18 Mon Sep 17 00:00:00 2001 From: Oleksandr Tkachenko Date: Fri, 10 Mar 2023 20:05:31 +0200 Subject: [PATCH 209/323] Update strings.xml --- app/src/main/res/values-ru-rRU/strings.xml | 55 +++++++++++----------- 1 file changed, 28 insertions(+), 27 deletions(-) diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index fd9d67498..e5e189eba 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -15,17 +15,17 @@
- History - Stats - Quick picks - New release albums - Most played songs + История + Статистика + Быстрый выбор + Новые релизы альбомов + Самые популярные песни - Today - Yesterday - This week - Last week + Сегодня + Вчера + На этой неделе + На прошлой неделе Поиск @@ -39,7 +39,7 @@ Плейлисты Плейлисты сообщества Избранные плейлисты - No results found + Результаты не найдены Из вашей библиотеки @@ -47,7 +47,7 @@ Любимые треки Загруженная музыка - The playlist is empty + Плейлист пуст Повторить @@ -66,14 +66,14 @@ Удалить из загруженных Импортировать плейлист Добавить в плейлист - Перейти на страницу исполнителя + Перейти к исполнителю Перейти к альбому Обновить Поделиться Удалить - Remove from history + Удалить из истории Поиск в Интернете - Sync + Синхронизация Недавно добавленные @@ -147,12 +147,13 @@ Текст песни не найден - Sleep timer - End of song + Таймер сна + Конец песни - 1 minute - %d minutes - %d minutes + 1 минута + %d минуты + %d минут + %d минут Нет доступных потоков Нет подключения к сети @@ -179,7 +180,7 @@ Вкл. Выкл. Использовать настройки системы - Pure black + Режим чистого черного цвета Вкладка навигации по умолчанию Настройка вкладок навигации Расположение текста песни @@ -209,14 +210,14 @@ Хранилище Кэш - Image Cache - Song Cache - Max cache size - Unlimited + Кэш изображений + Кэш аудио + Максимальный размер кэша + Неограниченный Макс. размер кэша изображений Очистить кэш изображений Макс. размер кэша аудио - Clear song cache + Очистить кэш аудио %s использовано Общие @@ -229,8 +230,8 @@ Показывать кнопки «Добавить в библиотеку» и «Нравится» Конфиденциальность - Pause listen history - Приостановить сохранение истории поиска + Приостановить историю прослушивания + Приостановить историю поиска Очистить историю поиска Вы уверены, что хотите очистить всю историю поиска? Включить провайдера текстов KuGou From 59eaa4d0e01f69d4cde4f0c8e6a673ca2e52006d Mon Sep 17 00:00:00 2001 From: Javi <45560967+javdc@users.noreply.github.com> Date: Tue, 4 Apr 2023 21:09:54 +0200 Subject: [PATCH 210/323] Fix visitorData retrieval for some users Search for the correct visitorData string inside the inner array from sw.js_data call by checking if it has the correct prefix instead of returning the string at a hardcoded index. This fixes visitorData retrieval for some users. --- app/src/main/java/com/zionhuang/music/App.kt | 1 + innertube/src/main/java/com/zionhuang/innertube/YouTube.kt | 5 ++++- 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/app/src/main/java/com/zionhuang/music/App.kt b/app/src/main/java/com/zionhuang/music/App.kt index 8ae00a711..e8113327e 100644 --- a/app/src/main/java/com/zionhuang/music/App.kt +++ b/app/src/main/java/com/zionhuang/music/App.kt @@ -65,6 +65,7 @@ class App : Application(), ImageLoaderFactory { .distinctUntilChanged() .collect { visitorData -> YouTube.visitorData = visitorData + ?.takeIf { it != "null" } // Previously visitorData was sometimes saved as "null" due to a bug ?: YouTube.visitorData().getOrNull()?.also { newVisitorData -> dataStore.edit { settings -> settings[VisitorDataKey] = newVisitorData diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 9b18b3ee2..b57023f52 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -9,6 +9,7 @@ import com.zionhuang.innertube.pages.* import io.ktor.client.call.* import io.ktor.client.statement.* import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray import kotlinx.serialization.json.jsonPrimitive import java.net.Proxy @@ -333,7 +334,7 @@ object YouTube { Json.parseToJsonElement(innerTube.getSwJsData().bodyAsText().substring(5)) .jsonArray[0] .jsonArray[2] - .jsonArray[6] + .jsonArray.first { (it as? JsonPrimitive)?.content?.startsWith(VISITOR_DATA_PREFIX) == true } .jsonPrimitive.content } @@ -355,5 +356,7 @@ object YouTube { private const val MAX_GET_QUEUE_SIZE = 1000 + private const val VISITOR_DATA_PREFIX = "Cgt" + const val DEFAULT_VISITOR_DATA = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" } From 3d68a238efc393822ade503689bb2e7693d35859 Mon Sep 17 00:00:00 2001 From: Krintni <132835834+Krintni@users.noreply.github.com> Date: Mon, 8 May 2023 10:49:03 +0200 Subject: [PATCH 211/323] fix typo and improve translations in german --- app/src/main/res/values-DE/strings.xml | 12 ++++++------ 1 file changed, 6 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index d7d370e87..7c691bc2c 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -32,7 +32,7 @@ Proxy einschalten Proxy-Typ Proxy-URL - Neustart, damit er wirksam wird + Neustart, damit Änderungen wirksam werden Player und Audio Tonqualität @@ -111,7 +111,7 @@ Radio starten Wiedergegeben Als nächstes wiedergeben - Zu Warteschlange hinzufügen + Zur Warteschlange hinzufügen Zur Bibliothek hinzufügen Herunterladen Download entfernen @@ -163,8 +163,8 @@ Wiedergabeliste bearbeiten - Wählen Sie den Sicherungsinhalt - Wählen Sie Inhalt wiederherstellen + Inhalt zum Sichern auswählen + Inhalt zum Wiederherstellen auswählen Einstellungen Datenbank Heruntergeladene Titel @@ -223,7 +223,7 @@ %d ausgewählt
Rückgängig machen - Kann diese Url nicht identifizieren. + Kann diese URL nicht identifizieren. Song wird als nächstes gespielt %d Songs werden als nächstes gespielt @@ -294,7 +294,7 @@ Kein Stream verfügbar - Keine Netzverbindung + Keine Netzwerkverbindung Zeitüberschreitung Unbekannter Fehler From 5d5ecae7c8fac3be30728a361762fa0e7d5139d7 Mon Sep 17 00:00:00 2001 From: Krintni <132835834+Krintni@users.noreply.github.com> Date: Mon, 8 May 2023 12:46:11 +0200 Subject: [PATCH 212/323] fix typo and improve translations in german --- app/src/main/res/values-DE/strings.xml | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 7c691bc2c..ced17be6f 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -22,7 +22,7 @@ Anpassen der Navigationsregisterkarten Position des Liedtextes Links - Mitte + Zentriert Rechts Inhalt @@ -271,7 +271,7 @@ Favoriten - Entfernen aus Favoriten + Aus Favoriten entfernen Zur Bibliothek hinzufügen Aus der Bibliothek entfernen @@ -294,7 +294,7 @@ Kein Stream verfügbar - Keine Netzwerkverbindung + Keine Netzverbindung Zeitüberschreitung Unbekannter Fehler From 5cd498f1b827af384655cee387d2fddb88407d5a Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sun, 11 Jun 2023 12:19:40 +0800 Subject: [PATCH 213/323] Fix add to library icon in notification --- .../com/zionhuang/music/db/entities/Song.kt | 2 + .../zionhuang/music/playback/SongPlayer.kt | 97 ++++++++++++++++--- settings.gradle.kts | 2 +- 3 files changed, 86 insertions(+), 15 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt index 9e081aa46..a59c11987 100644 --- a/app/src/main/java/com/zionhuang/music/db/entities/Song.kt +++ b/app/src/main/java/com/zionhuang/music/db/entities/Song.kt @@ -2,6 +2,7 @@ package com.zionhuang.music.db.entities import androidx.compose.runtime.Immutable import androidx.room.Embedded +import androidx.room.Ignore import androidx.room.Junction import androidx.room.Relation @@ -29,6 +30,7 @@ data class Song @JvmOverloads constructor( entityColumn = "albumId" ) ) + @Ignore val album: AlbumEntity? = null, ) : LocalItem() { override val id: String diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt index 127dbcf1f..f0feb6649 100644 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt @@ -14,7 +14,10 @@ import android.os.Bundle import android.os.ResultReceiver import android.support.v4.media.MediaDescriptionCompat import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat.* +import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID +import android.support.v4.media.session.PlaybackStateCompat.ACTION_PREPARE +import android.support.v4.media.session.PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID +import android.support.v4.media.session.PlaybackStateCompat.CustomAction import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -22,17 +25,43 @@ import androidx.core.app.NotificationCompat import androidx.core.content.getSystemService import androidx.core.net.toUri import androidx.datastore.preferences.core.edit -import com.google.android.exoplayer2.* +import com.google.android.exoplayer2.C import com.google.android.exoplayer2.C.WAKE_MODE_NETWORK -import com.google.android.exoplayer2.PlaybackException.* -import com.google.android.exoplayer2.Player.* +import com.google.android.exoplayer2.DefaultRenderersFactory +import com.google.android.exoplayer2.ExoPlayer +import com.google.android.exoplayer2.MediaItem +import com.google.android.exoplayer2.PlaybackException +import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED +import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT +import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_REMOTE_ERROR +import com.google.android.exoplayer2.Player +import com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION +import com.google.android.exoplayer2.Player.DiscontinuityReason +import com.google.android.exoplayer2.Player.EVENT_IS_PLAYING_CHANGED +import com.google.android.exoplayer2.Player.EVENT_PLAYBACK_STATE_CHANGED +import com.google.android.exoplayer2.Player.EVENT_PLAY_WHEN_READY_CHANGED +import com.google.android.exoplayer2.Player.EVENT_POSITION_DISCONTINUITY +import com.google.android.exoplayer2.Player.EVENT_TIMELINE_CHANGED +import com.google.android.exoplayer2.Player.Events +import com.google.android.exoplayer2.Player.Listener +import com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT +import com.google.android.exoplayer2.Player.PositionInfo +import com.google.android.exoplayer2.Player.STATE_ENDED +import com.google.android.exoplayer2.Player.STATE_IDLE import com.google.android.exoplayer2.Player.State +import com.google.android.exoplayer2.Timeline import com.google.android.exoplayer2.analytics.AnalyticsListener import com.google.android.exoplayer2.analytics.PlaybackStats import com.google.android.exoplayer2.analytics.PlaybackStatsListener -import com.google.android.exoplayer2.audio.* -import com.google.android.exoplayer2.audio.DefaultAudioSink.* +import com.google.android.exoplayer2.audio.AudioAttributes +import com.google.android.exoplayer2.audio.AudioCapabilities +import com.google.android.exoplayer2.audio.DefaultAudioSink +import com.google.android.exoplayer2.audio.DefaultAudioSink.DefaultAudioProcessorChain +import com.google.android.exoplayer2.audio.DefaultAudioSink.OFFLOAD_MODE_DISABLED +import com.google.android.exoplayer2.audio.DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED +import com.google.android.exoplayer2.audio.SilenceSkippingAudioProcessor import com.google.android.exoplayer2.audio.SilenceSkippingAudioProcessor.DEFAULT_SILENCE_THRESHOLD_LEVEL +import com.google.android.exoplayer2.audio.SonicAudioProcessor import com.google.android.exoplayer2.database.StandaloneDatabaseProvider import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource @@ -57,7 +86,10 @@ import com.zionhuang.innertube.models.response.PlayerResponse import com.zionhuang.music.ACTION_SHOW_BOTTOM_SHEET import com.zionhuang.music.MainActivity import com.zionhuang.music.R -import com.zionhuang.music.constants.* +import com.zionhuang.music.constants.AudioNormalizationKey +import com.zionhuang.music.constants.AudioQualityKey +import com.zionhuang.music.constants.AutoAddToLibraryKey +import com.zionhuang.music.constants.MaxSongCacheSizeKey import com.zionhuang.music.constants.MediaSessionConstants.ACTION_ADD_TO_LIBRARY import com.zionhuang.music.constants.MediaSessionConstants.ACTION_LIKE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_REMOVE_FROM_LIBRARY @@ -65,13 +97,30 @@ import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE +import com.zionhuang.music.constants.NotificationMoreActionKey +import com.zionhuang.music.constants.PauseListenHistoryKey +import com.zionhuang.music.constants.PersistentQueueKey +import com.zionhuang.music.constants.PlayerVolumeKey +import com.zionhuang.music.constants.RepeatModeKey +import com.zionhuang.music.constants.ShowLyricsKey +import com.zionhuang.music.constants.SkipSilenceKey +import com.zionhuang.music.constants.SongSortType import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.* import com.zionhuang.music.db.entities.Event +import com.zionhuang.music.db.entities.FormatEntity +import com.zionhuang.music.db.entities.LyricsEntity 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.RelatedSongMap +import com.zionhuang.music.db.entities.Song import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED -import com.zionhuang.music.extensions.* +import com.zionhuang.music.extensions.currentMetadata +import com.zionhuang.music.extensions.findNextMediaItemById +import com.zionhuang.music.extensions.mediaItemIndexOf +import com.zionhuang.music.extensions.mediaItems +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.extensions.setQueueNavigator +import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.lyrics.LyricsHelper import com.zionhuang.music.models.MediaMetadata import com.zionhuang.music.models.PersistQueue @@ -85,10 +134,30 @@ import com.zionhuang.music.playback.queues.ListQueue import com.zionhuang.music.playback.queues.Queue import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.utils.* -import kotlinx.coroutines.* +import com.zionhuang.music.utils.dataStore +import com.zionhuang.music.utils.enumPreference +import com.zionhuang.music.utils.get +import com.zionhuang.music.utils.getSongFile +import com.zionhuang.music.utils.preference +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext import okhttp3.OkHttpClient import java.io.ObjectInputStream import java.io.ObjectOutputStream @@ -245,8 +314,8 @@ class SongPlayer( override fun getCustomAction(player: Player) = if (currentMediaMetadata.value != null) { CustomAction.Builder( ACTION_TOGGLE_LIBRARY, - context.getString(if (currentSong != null) R.string.action_remove_from_library else R.string.action_add_to_library), - if (currentSong != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add + context.getString(if (currentSong?.song?.inLibrary != null) R.string.action_remove_from_library else R.string.action_add_to_library), + if (currentSong?.song?.inLibrary != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add ).build() } else null }, diff --git a/settings.gradle.kts b/settings.gradle.kts index 1c578d36f..102c10aff 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -63,7 +63,7 @@ dependencyResolutionManagement { library("apache-lang3", "org.apache.commons", "commons-lang3").version("3.12.0") - version("hilt", "2.44") + version("hilt", "2.46.1") library("hilt", "com.google.dagger", "hilt-android").versionRef("hilt") library("hilt-compiler", "com.google.dagger", "hilt-android-compiler").versionRef("hilt") From b0c10016e3eab3ca6bf5d5dd32a2c35d5fbeae1a Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 15 Jun 2023 12:57:53 +0800 Subject: [PATCH 214/323] Fix nl translation --- app/src/main/res/values-nl/strings.xml | 445 +++++++++++-------------- 1 file changed, 186 insertions(+), 259 deletions(-) diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index da77d3002..290778f5e 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -1,132 +1,88 @@ - - Thuis - Nummers - Artiesten - Albums - Afspeellijsten - Ontdekken - Instellingen - Speelt nu - Fout melden - - - Uiterlijk - Volg systeem - Themakleur - Donker thema - Aan - Uit - Volg systeem - Standaard tabblad - Pas tabbladen aan - Songtekst positie - Links - Midden - Rechts - - Inhoud - Inloggen - Standaard inhoudstaal - Standaard inhoudsland - Proxy inschakelen - Proxy type - Proxy URL - Herstart om effect te hebben - - Speler en geluid - Geluidskwaliteit - Automatisch - Hoog - Laag - Blijvende wachtrij - Sla sliltes over - Geluidsnormalisatie - Equalizer - - Opslag - Gedownloade bestanden in SAF bekijken - Dit werkt mogelijk niet in sommige apparaten - Cache - Maximale beeldcachegrootte - Verwijder beeldcache - Maximale cachegrootte voor nummers - %s gebruikt - - Algemeen - Automatisch downloaden - Download nummer wanneer toegevoegd aan bibliotheek - Nummer automatisch toevoegen aan bibliotheek - Voeg een nummer toe aan uw bibliotheek wanneer het afspelen is voltooid - Vouw de onderste speler uit tijdens het spelen - Meer acties in melding - Toon toevoegen aan bibliotheek en like-knoppen - - Privacy - Pauzeer de zoekgeschiedenis - Verwijder zoekgeschiedenis - Weet je zeker dat je de zoekgeschiedenis wil wissen? - Schakel KuGou songtekst provider in - - Backup en herstel - Backup - Herstellen - - Over - App versie - - - Sakura - Rood - Roze - Paars - Donker paars - Indigo - Blauw - Licht blauw - Cyaan - Wintergroen - Groen - Licht groen - Limoen groen - Geel - Amber - Oranje - Donker oranje - Bruin - Blauw-grijs + + Thuis + Nummers + Artiesten + Albums + Afspeellijsten + + + + %d geselecteerd + %d geselecteerd + - - Search + + History + Stats + Quick picks + New release albums + Most played songs + + + Today + Yesterday + This week + Last week + + + Search Zoeken via YouTube Music… Zoeken in bibliotheek… - - + Alles + Nummers + Videos + Albums + Artiesten + Afspeellijsten + Afspeellijsten van de community + Voorgestelde afspeellijsten + No results found + + + Verwijderen van bibliotheek + + Favoriete nummers Gedownloade nummers + The playlist is empty - - Details - Bewerken - Start radio - Afspelen - Volgende afspelen - Voeg toe aan wachtrij - Voeg toe aan bibliotheek - Downloaden - Verwijder download - Importeer afspeellijst - Voeg toe aan afspeellijst - Toon artiest - Toon album - Ververs - Delen - Verwijderen - Zoek online - Kies andere songtekst + + Opnieuw + Radio + Shuffle + + + Details + Bewerken + Start radio + Afspelen + Volgende afspelen + Voeg toe aan wachtrij + Voeg toe aan bibliotheek + Downloaden + Verwijder download + Importeer afspeellijst + Voeg toe aan afspeellijst + Toon artiest + Toon album + Ververs + Delen + Verwijderen + Remove from history + Zoek online + Sync + + + Datum toegevoegd + Naam + Artiest + Jaar + Aantal nummers + Duur + Speeltijd - Details Media id MIME type Codecs @@ -136,174 +92,145 @@ Volume Bestandsgrootte Onbekend + Copied to clipboard - Bewerk songtekst - Zoek naar songtekst - Kies songtekst + Bewerk songtekst + Zoek naar songtekst - Bewerk nummer + Bewerk nummer Titel nummer Artiesten nummer Titel van nummer mag niet leeg zijn. Artiest kan niet leeg zijn. - Opslaan + Opslaan - Afspeellijst maken - Naam afspeellijst + Kies afspeellijst + Bewerk afspeellijst + Afspeellijst maken + Playlist name Afspeellijstnaam mag niet leeg zijn. - Bewerk artiest - Artiest naam + Bewerk artiest + Artiest naam Artiestennaam mag niet leeg zijn. - Dubbele artiesten - Artiest %1$s bestaat al. - - Kies afspeellijst - - Bewerk afspeellijst - - Kies back-upinhoud - Kies herstelinhoud - Voorkeuren - Database - Gedownloade nummers - Back-up succesvol gemaakt. - Back-up maken mislukt. - Fout bij terugzetten back-up. - - - Muziekspeler - Downloaden - - - %d nummer - %d nummers + + %d song + %d songs - - %d artiest - %d artiesten + + %d artist + %d artists - + %d album %d albums - - %d afspeellijst - %d afspeellijsten + + %d playlist + %d playlists - - Opnieuw - Afspelen - Speel alles af - Radio - Shuffle - Kopieer stacktrace - Melden - Melden op GitHub - - - Datum toegevoegd - Naam - Artiest - Jaar - Aantal nummers - Duur - Speeltijd - - - %d nummer is verwijderd. - %d nummers zijn verwijderd. - - - %d geselecteerd - %d geselecteerd - %d geselecteerd - %d geselecteerd - - Ongedaan maken - Can\'t identify this url. - - Het nummer zal hierna spelen - %d nummers spelen als volgende - - - De artiest zal hierna spelen - %d artiesten zullen hierna spelen - - - Album zal hierna spelen - %d zullen hierna spelen - - - Afspeellijst zal hierna spelen - %d afspeellijsten zullen hierna spelen - - Geselecteerde wordt als volgende afgespeeld - - Nummer toegevoegd aan wachtrij - %d nummers toegevoegd aan wachtrij - - - Artiest toegevoegd aan wachtrij - %d artiesten toegevoegd aan wachtrij - - - Album toegevoegd aan wachtrij - %d albums toegevoegd aan wachtrij - - - Afspeellijst toegevoegd aan wachtrij - %d afspeellijsten toegevoegd aan wachtrij - - Geselecteerde toegevoegd aan wachtrij - Toegevoegd aan bibliotheek - Verwijderd van bibliotheek - Afspeellijst geïmporteerd - Toegevoegd aan %1$s - - Start downloaden van nummer - Start downloaden %d nummers + Playlist imported + + + Songtekst niet gevonden + Sleep timer + End of song + + 1 minute + %d minutes - Download verwijderd - Weergeven + Geen stream beschikbaar + Geen netwerkverbinding + Timeout + Onbekende fout - + Like Verwijder like Toevoegen aan bibliotheek Verwijderen van bibliotheek - - Alles - Nummers - Videos - Albums - Artiesten - Afspeellijsten - Afspeellijsten van de community - Voorgestelde afspeellijsten - - Systeemstandaard - Van je bibliotheek - - - Sorry, dat had niet mogen gebeuren. - Gekopieerd naar klembord - - - Geen stream beschikbaar - Geen netwerkverbinding - Timeout - Onbekende fout - Alle nummers Opgezochte nummers - - Songtekst niet gevonden + + Music Player + + + Instellingen + Uiterlijk + Donker thema + Aan + Uit + Volg systeem + Pure black + Standaard tabblad + Pas tabbladen aan + Songtekst positie + Links + Midden + Rechts + + Inhoud + Inloggen + Standaard inhoudstaal + Standaard inhoudsland + Systeemstandaard + Proxy inschakelen + Proxy type + Proxy URL + Herstart om effect te hebben + + Speler en geluid + Geluidskwaliteit + Automatisch + Hoog + Laag + Blijvende wachtrij + Sla sliltes over + Geluidsnormalisatie + Equalizer + + Opslag + Cache + Image Cache + Song Cache + Max cache size + Unlimited + Max image cache size + Clear image cache + Max song cache size + Clear song cache + %s gebruikt + + Algemeen + Automatisch downloaden + Download nummer wanneer toegevoegd aan bibliotheek + Nummer automatisch toevoegen aan bibliotheek + Voeg een nummer toe aan uw bibliotheek wanneer het afspelen is voltooid + Vouw de onderste speler uit tijdens het spelen + Meer acties in melding + Toon toevoegen aan bibliotheek en like-knoppen + + Privacy + Pauzeer de zoekgeschiedenis + Pause search history + Verwijder zoekgeschiedenis + Weet je zeker dat je de zoekgeschiedenis wil wissen? + Schakel KuGou songtekst provider in + + Backup en herstel + Backup + Herstellen + Back-up succesvol gemaakt. + Back-up maken mislukt. + Fout bij terugzetten back-up. + + Over + App versie From 5f3c4b6f5abbb181cd6849f0ef4f90e32fd85e15 Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Thu, 15 Jun 2023 10:09:36 +0300 Subject: [PATCH 215/323] Update strings.xml --- app/src/main/res/values-tr/strings.xml | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index fbed32379..35c8d3138 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -219,6 +219,7 @@ %d şarkı silindi. + %d seçildi %d seçildi Geri al @@ -288,7 +289,7 @@ Kütüphanenizden - Üzgünüm, bu olmamalıydı. + Üzgünüz, bu olmamalıydı. Panoya kopyalandı From a44d77c359081d1cb9329c9bbcab6e3fa1a9991c Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Thu, 15 Jun 2023 23:21:19 +0800 Subject: [PATCH 216/323] Add option to disable dynamic theme (#601) --- .../main/java/com/zionhuang/music/MainActivity.kt | 15 +++++++++++---- .../zionhuang/music/constants/PreferenceKeys.kt | 1 + .../ui/screens/settings/AppearanceSettings.kt | 8 ++++++++ app/src/main/res/values-DE/strings.xml | 1 + app/src/main/res/values-cs/strings.xml | 1 + app/src/main/res/values-es-rUS/strings.xml | 1 + app/src/main/res/values-es/strings.xml | 1 + app/src/main/res/values-fa-rIR/strings.xml | 1 + app/src/main/res/values-fi-rFI/strings.xml | 1 + app/src/main/res/values-fr-rFR/strings.xml | 1 + app/src/main/res/values-hu/strings.xml | 1 + app/src/main/res/values-id/strings.xml | 1 + app/src/main/res/values-it/strings.xml | 1 + app/src/main/res/values-ja-rJP/strings.xml | 1 + app/src/main/res/values-ko-rKR/strings.xml | 1 + app/src/main/res/values-ml-rIN/strings.xml | 1 + app/src/main/res/values-nl/strings.xml | 1 + app/src/main/res/values-or-rIN/strings.xml | 1 + app/src/main/res/values-pa/strings.xml | 1 + app/src/main/res/values-pt-rBR/strings.xml | 1 + app/src/main/res/values-ru-rRU/strings.xml | 1 + app/src/main/res/values-sv-rSE/strings.xml | 1 + app/src/main/res/values-uk-rUA/strings.xml | 1 + app/src/main/res/values-zh-rCN/strings.xml | 1 + app/src/main/res/values-zh-rTW/strings.xml | 1 + app/src/main/res/values/strings.xml | 1 + 26 files changed, 43 insertions(+), 4 deletions(-) diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 48b3540a3..8f3f838fb 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -125,6 +125,7 @@ class MainActivity : ComponentActivity() { setContent { val coroutineScope = rememberCoroutineScope() + val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true) val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) val isSystemInDarkTheme = isSystemInDarkTheme() @@ -138,10 +139,14 @@ class MainActivity : ComponentActivity() { mutableStateOf(DefaultThemeColor) } - DisposableEffect(playerConnection, isSystemInDarkTheme) { - playerConnection?.onBitmapChanged = { bitmap -> - coroutineScope.launch(Dispatchers.IO) { - themeColor = bitmap?.extractThemeColor() ?: DefaultThemeColor + DisposableEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) { + if (!enableDynamicTheme) { + themeColor = DefaultThemeColor + } else { + playerConnection?.onBitmapChanged = { bitmap -> + coroutineScope.launch(Dispatchers.IO) { + themeColor = bitmap?.extractThemeColor() ?: DefaultThemeColor + } } } onDispose { @@ -475,6 +480,7 @@ class MainActivity : ComponentActivity() { navController.canNavigateUp && !navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } -> { navController.navigateUp() } + else -> onActiveChange(true) } }) { @@ -534,6 +540,7 @@ class MainActivity : ComponentActivity() { navController = navController, onDismiss = { onActiveChange(false) } ) + SearchSource.ONLINE -> OnlineSearchScreen( query = query.text, onQueryChange = onQueryChange, 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 8e78e0dc2..cc3e853c8 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -5,6 +5,7 @@ import androidx.datastore.preferences.core.floatPreferencesKey import androidx.datastore.preferences.core.intPreferencesKey import androidx.datastore.preferences.core.stringPreferencesKey +val DynamicThemeKey = booleanPreferencesKey("dynamicTheme") val DarkModeKey = stringPreferencesKey("darkMode") val PureBlackKey = booleanPreferencesKey("pureBlack") val DefaultOpenTabKey = stringPreferencesKey("defaultOpenTab") diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt index d0d8e54aa..ede0764c4 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/AppearanceSettings.kt @@ -14,6 +14,7 @@ import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.DarkModeKey import com.zionhuang.music.constants.DefaultOpenTabKey +import com.zionhuang.music.constants.DynamicThemeKey import com.zionhuang.music.constants.LyricsTextPositionKey import com.zionhuang.music.constants.PureBlackKey import com.zionhuang.music.ui.component.EnumListPreference @@ -27,6 +28,7 @@ fun AppearanceSettings( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, ) { + val (dynamicTheme, onDynamicThemeChange) = rememberPreference(DynamicThemeKey, defaultValue = true) val (darkMode, onDarkModeChange) = rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) val (pureBlack, onPureBlackChange) = rememberPreference(PureBlackKey, defaultValue = false) val (defaultOpenTab, onDefaultOpenTabChange) = rememberEnumPreference(DefaultOpenTabKey, defaultValue = NavigationTab.HOME) @@ -37,6 +39,12 @@ fun AppearanceSettings( .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { + SwitchPreference( + title = stringResource(R.string.enable_dynamic_theme), + icon = R.drawable.ic_palette, + checked = dynamicTheme, + onCheckedChange = onDynamicThemeChange + ) EnumListPreference( title = stringResource(R.string.dark_theme), icon = R.drawable.ic_dark_mode, diff --git a/app/src/main/res/values-DE/strings.xml b/app/src/main/res/values-DE/strings.xml index 91a72aa55..a56a0020d 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -164,6 +164,7 @@ Einstellungen Erscheinungsbild + Enable dynamic theme Dunkles Thema An Aus diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index ae3befc03..9767b75ec 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -175,6 +175,7 @@ Nastavení Vzhled + Enable dynamic theme Tmavý motiv Zap Vyp diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml index 0944cd91a..f7b343ae5 100644 --- a/app/src/main/res/values-es-rUS/strings.xml +++ b/app/src/main/res/values-es-rUS/strings.xml @@ -164,6 +164,7 @@ Configuración Apariencia + Enable dynamic theme Modo oscuro Encendido Apagado diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index fad656d93..cf3bee9d9 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -164,6 +164,7 @@ Ajustes Apariencia + Enable dynamic theme Tema oscuro Encendido Apagado diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index a3362b6ab..e4551a60c 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -164,6 +164,7 @@ تنظیمات ظاهر + Enable dynamic theme تم تاریک روشن خاموش diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 61a53c027..279618ddc 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -164,6 +164,7 @@ Asetukset Ulkoasu + Enable dynamic theme Tumma teema Käytössä Pois käytöstä diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index e9d6889c9..d08d25ca6 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -170,6 +170,7 @@ Paramètres Appearance + Enable dynamic theme Dark theme On Off diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index 24b7410bb..92648b2ca 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -164,6 +164,7 @@ Beállítások Megjelenés + Enable dynamic theme Sötét téma Be Ki diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index 652d42ae8..7ba89419d 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -158,6 +158,7 @@ Pengaturan Tampilan + Enable dynamic theme Tema gelap Aktif Tidak aktif diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index 2a214f9dc..42f0d2b27 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -164,6 +164,7 @@ Impostazioni Aspetto + Enable dynamic theme Tema scuro Attivato Disattivato diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index d763197a4..ba0ffa8ae 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -158,6 +158,7 @@ 設定 外観 + Enable dynamic theme ダークテーマ オン オフ diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 98c4a9198..44443094d 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -158,6 +158,7 @@ 설정 모양 + Enable dynamic theme 다크 테마 diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 0a7840754..97deb1d1f 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -164,6 +164,7 @@ ക്രമീകരണങ്ങൾ രൂപഭംഗി + Enable dynamic theme ഡാർക്ക് തീം ഓൺ ഓഫ് diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 290778f5e..56e25862c 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -164,6 +164,7 @@ Instellingen Uiterlijk + Enable dynamic theme Donker thema Aan Uit diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index 3d96c78eb..4284aa6f0 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -164,6 +164,7 @@ ସେଟିଂ ସମୂହ ଦୃଶ୍ୟତା + Enable dynamic theme ଗାଢ଼ ଥିମ୍ ଅନ୍ ଅଫ୍ diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index b834eebfb..186aebb40 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -164,6 +164,7 @@ ਸੈਟਿੰਗਾਂ ਦਿੱਖ + Enable dynamic theme ਗੂੜ੍ਹਾ ਥੀਮ ਚਾਲੂ ਬੰਦ diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 23a5f790f..0b6ee6da4 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -164,6 +164,7 @@ Configurações Aparência + Enable dynamic theme Tema escuro On Off diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index e5e189eba..2165f2b93 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -176,6 +176,7 @@ Настройки Внешний вид + Enable dynamic theme Темная тема Вкл. Выкл. diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index dfee61fad..34199cb7d 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -164,6 +164,7 @@ Inställningar Utseende + Enable dynamic theme Mörkt tema Av diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index fc62f9871..e524af981 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -176,6 +176,7 @@ Параметри Зовнішній вигляд + Enable dynamic theme Темна тема Увімк. Вимк. diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 99265a3a6..6ccb6723d 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -158,6 +158,7 @@ 设置 外观 + Enable dynamic theme 深色主题 diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a80830af7..ed65ec4f4 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -158,6 +158,7 @@ 設定 外觀 + 使用動態主題 深色主題 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f61419d9a..5ae1ff865 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -163,6 +163,7 @@ Settings Appearance + Enable dynamic theme Dark theme On Off From 1ff111d950e70de25066611d85fc70ba078c2a10 Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Thu, 15 Jun 2023 20:53:18 +0300 Subject: [PATCH 217/323] Update strings.xml --- app/src/main/res/values-tr/strings.xml | 437 +++++++++++-------------- 1 file changed, 183 insertions(+), 254 deletions(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 35c8d3138..e1f9dc197 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -1,307 +1,236 @@ - - Ana Sayfa - Şarkılar - Sanatçılar - Albümler - Çalma Listeleri - Keşfet - Ayarlar - Şimdi çalıyor - Hata raporu - - - Görünüm - Sistem temasını izle - Tema rengi - Koyu tema - Kapalı - Açık - Sistemi izle - Varsayılan açılış sekmesi - Gezinti sekmelerini özelleştir - Şarkı sözleri metin konumu - Sol - Orta - Sağ - - İçerik - Giriş yap - Varsayılan içerik dili - Varsayılan içerik ülkesi - Proxy\'yi etkinleştir - Proxy türü - Proxy URL\'si - Etkili olması için yeniden başlat - - Çalar ve ses - Ses kalitesi - Otomatik - Yüksek - Düşük - Kalıcı sıra - Sessizliği atla - Ses normalleştirme - Ekolayzer - - Depolama - İndirilen dosyaları SAF\'ta görüntüle - Bazı cihazlarda çalışmayabilir - Önbellek - Maksimum görüntü önbellek boyutu - Görüntü önbelleğini temizle - Maksimum şarkı önbellek boyutu - %s kullanıldı - - Genel - Otomatik indir - Şarkı kütüphaneye eklendiğinde indirin - Kütüphaneye otomatik şarkı ekle - Çalmayı tamamladığında şarkıyı kütüphanenize ekleyin - Çalma esnasında alttaki oynatıcıyı genişlet - Bildirimde daha fazla eylem - Kitaplığa ekle ve beğen düğmelerini gösterir - - Gizlilik - Arama geçmişini duraklat - Arama geçmişini temizle - Tüm arama geçmişini temizlemek istediğinizden emin misiniz? - KuGou şarkı sözü sağlayıcısını etkinleştir - - Yedekleme ve geri yükleme - Yedekle - Geri yükle - - Hakkında - Uygulama sürümü - - - Sakura - Kırmızı - Pembe - Mor - Koyu mor - Çivit mavisi - Mavi - Açık mavi - Camgöbeği - Deniz mavisi - Yeşil - Açık yeşil - Kireç - Sarı - Kehribar - Turuncu - Koyu turuncu - Kahverengi - Mavi gri + + Ana Sayfa + Şarkılar + Sanatçılar + Albümler + Çalma Listeleri + + + + %d seçildi + - - Arama - YouTube Müzik\'te Ara... + + Geçmiş + İstatistikler + Hızlı seçimler + Yeni çıkan albümler + En çok çalınan şarkılar + + + Bugün + Dün + Bu hafta + Geçen hafta + + + Arama + YouTube Müzik'te Ara... Kütüphanede ara... - - + Hepsi + Şarkılar + Videolar + Albümler + Sanatçılar + Çalma Listeleri + Topluluk çalma listeleri + Öne çıkan listeler + Sonuç bulunamadı + + + Kütüphanenizden + + Beğenilen şarkılar İndirilen şarkılar + Çalma listesi boş - - Detaylar - Düzenle - Radyoyu başlat - Çal - Sonrakini çal - Sıraya ekle - Kütüphaneye ekle - İndir - İndirmeyi kaldır - Çalma listesini içe aktar - Çalma listesine ekle - Sanatçıyı görüntüle - Albümü görüntüle - Yeniden getir - Paylaş - Sil - Çevrimiçi ara - Başka sözler seç + + Yeniden dene + Radyo + Karıştır + + + Ayrıntılar + Düzenle + Radyo başlat + Çal + Sonraki olarak çal + Sıraya ekle + Kütüphaneye ekle + İndir + İndirmeyi kaldır + Çalma listesini içe aktar + Çalma listesine ekle + Sanatçıyı görüntüle + Albümü görüntüle + Yenile + Paylaş + Sil + Geçmişten kaldır + Çevrimiçi arama + Eşitle + + + Eklendiği tarih + Ad + Sanatçı + Yıl + Şarkı sayısı + Uzunluk + Çalma süresi - Detaylar Medya kimliği - MIME kimliği + MIME türü Kodekler Bit hızı Örnek hızı Ses yüksekliği - Ses seviyesi + Ses şiddeti Dosya boyutu Bilinmiyor + Panoya kopyalandı - Şarkı sözlerini düzenle - Şarkı sözlerini ara - Şarkı sözlerini seç + Sözleri düzenle + Sözleri ara - Şarkıyı düzenle + Şarkıyı düzenle Şarkı adı Şarkı sanatçıları Şarkı adı boş olamaz. - Şarkının sanatçısı boş olamaz. - Kaydet + Şarkı sanatçısı boş olamaz. + Kaydet - Çalma listesi oluştur - Çalma listesi adı + Çalma listesi seç + Çalma listesini düzenle + Çalma listesi oluştur + Çalma listesi adı Çalma listesi adı boş olamaz. - Sanatçıyı düzenle - Sanatçı adı + Sanatçıyı düzenle + Sanatçı adı Sanatçı adı boş olamaz. - Sanatçıları kopyala - %1$s adlı sanatçı zaten mevcut. - - Çalma listesi seç - - Çalma listesini düzenle - - Yedekleme içeriğini seç - Geri yükleme içeriğini seç - Tercihler - Veritabanı - İndirilen şarkılar - Yedek başarıyla oluşturuldu - Yedek oluşturulamadı - Yedek geri yüklenemedi - - - Müzik Çalar - İndir - - + %d şarkı %d şarkı - + %d sanatçı %d sanatçı - + %d albüm %d albüm - + %d çalma listesi %d çalma listesi - - Yeniden dene - Çal - Hepsini çal - Radyo - Karıştır - Yığın izini kopyala - Bildir - GitHub üzerinden bildir - - - Eklendiği tarih - Ad - Sanatçı - Yıl - Şarkı sayısı - Uzunluk - Çalma süresi - - - %d şarkı silindi. - %d şarkı silindi. - - - %d seçildi - %d seçildi - - Geri al - Bu URL tanımlanamıyor. - - Sıradaki şarkı çalacak - Sırada %d şarkı çalacak - - - Sırada bir sanatçı var - Sırada %d sanatçı var - - - Bir sonraki albüm çalacak - Sırada %d albüm var - - - Bir çalma listesi daha sonra çalacak - Sırada %d çalma listesi var - - Seçilenler sırada çalacak - - Şarkı sıraya eklendi - Sıraya %d şarkı eklendi - - - Sanatçı kuyruğa eklendi - Sıraya %d sanatçı eklendi - - - Albüm sıraya eklendi - Sıraya %d albüm eklendi - - - Çalma listesi kuyruğa eklendi - Sıraya %d çalma listesi eklendi - - Seçilenler sıraya eklendi - Kütüphaneye eklendi - Kütüphaneden kaldırıldı Çalma listesi içe aktarıldı - %1$s çalma listesine eklendi - - Şarkı indirmeye başla - %d şarkı indirmeye başla + + + Şarkı sözleri bulunamadı + Uyku zamanlayıcısı + Şarkının sonu + + 1 dakika + %d dakika - İndirme kaldırıldı - Görüntüle + Akış yok + Ağ bağlantısı yok + Zaman aşımı + Bilinmeyen hata - + Beğen Beğeniyi kaldır Kütüphaneye ekle Kütüphaneden kaldır - - Hepsi - Şarkılar - Videolar - Albümler - Sanatçılar - Çalma Listeleri - Topluluk çalma listeleri - Öne çıkan çalma listeleri - - Sistem varsayılanı - Kütüphanenizden - - - Üzgünüz, bu olmamalıydı. - Panoya kopyalandı - - - Yayın yok - Ağ bağlantısı yok - Zaman aşımı - Bilinmeyen hata - Tüm şarkılar Aranan şarkılar - - Şarkı sözleri bulunamadı + + Müzik Çalar + + + Ayarlar + Görünüm + Dinamik temayı etkinleştir + Koyu tema + Açık + Kapalı + Sistemi takip et + Saf siyah + Başlangıç sayfası + Gezinti sekmelerini özelleştir + Şarkı sözleri konumu + Sol + Orta + Sağ + + İçerik + Giriş yap + Varsayılan içerik dili + İçerik için varsayılan ülke + Sistem varsayılanı + Proxy\'yi etkinleştir + Proxy türü + Proxy URL\'si + Etkinleşmek için yeniden başlat + + Müzik çalar ve ses + Ses kalitesi + Otomatik + Yüksek + Düşük + Sürekli sıra + Sessizliği atla + Ses normalleştirme + Ekolayzer + + Depolama + Önbellek + Görüntü Önbelleği + Şarkı Önbelleği + Maksimum önbellek boyutu + Sınırsız + Maksimum görüntü önbellek boyutu + Görüntü önbelleğini temizle + Maksimum şarkı önbellek boyutu + Şarkı önbelleğini temizle + %s kullanıldı + + Genel + Otomatik indirme + Kütüphaneye eklendiğinde şarkıyı indirir + Kütüphaneye otomatik şarkı ekle + Şarkı bittiğinde kütüphaneye ekler + Oyun sırasında alttaki oyuncuyu genişlet + Bildirimde daha fazla eylem + Kütüphaneye ekle ve beğen düğmelerini gösterir + + Gizlilik + Dinleme geçmişini duraklat + Arama geçmişini duraklat + Arama geçmişini temizle + Tüm arama geçmişini temizlediğinizden emin misiniz? + KuGou şarkı sözleri sağlayıcısını etkinleştir + + Yedekleme ve geri yükleme + Yedekle + Geri yükle + Yedekleme başarıyla oluşturuldu + Yedekleme oluşturulamadı + Yedekleme geri yüklenemedi + + Hakkında + Uygulama sürümü From 8ed2ad2de70f35ef699cb635347d2cba063f6293 Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Fri, 16 Jun 2023 12:59:36 +0300 Subject: [PATCH 218/323] Update strings.xml --- app/src/main/res/values-tr/strings.xml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index e1f9dc197..856545ae0 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -26,7 +26,7 @@ Arama - YouTube Müzik'te Ara... + YouTube Müzik\'te Ara... Kütüphanede ara... Hepsi Şarkılar From 87973bc1c6c2171f11040b4f0eec283cf12429ce Mon Sep 17 00:00:00 2001 From: metezd <37701679+metezd@users.noreply.github.com> Date: Fri, 16 Jun 2023 15:51:58 +0300 Subject: [PATCH 219/323] Update strings.xml --- app/src/main/res/values-tr/strings.xml | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 856545ae0..ec0320a5c 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -8,6 +8,7 @@ + %d seçildi %d seçildi From ffe985d818d31c0290e5255cce9f7911a9527a66 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 16 Jun 2023 21:48:19 +0800 Subject: [PATCH 220/323] Update issue form --- .github/ISSUE_TEMPLATE/bug_report.yml | 6 +++--- .github/ISSUE_TEMPLATE/config.yml | 1 + .github/ISSUE_TEMPLATE/feature_request.yml | 2 +- 3 files changed, 5 insertions(+), 4 deletions(-) create mode 100644 .github/ISSUE_TEMPLATE/config.yml diff --git a/.github/ISSUE_TEMPLATE/bug_report.yml b/.github/ISSUE_TEMPLATE/bug_report.yml index c63a41354..074e8271e 100644 --- a/.github/ISSUE_TEMPLATE/bug_report.yml +++ b/.github/ISSUE_TEMPLATE/bug_report.yml @@ -67,11 +67,11 @@ body: If your bug includes a crash, please use `adb logcat` or other ways to provide logs. - type: input - id: music-version + id: app-version attributes: - label: Music version + label: InnerTune version description: | - You can find your Music version in **Settings**. + You can find your InnerTune version in **Settings**. placeholder: | Example: "0.2.1-beta" validations: diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 000000000..3ba13e0ce --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1 @@ +blank_issues_enabled: false diff --git a/.github/ISSUE_TEMPLATE/feature_request.yml b/.github/ISSUE_TEMPLATE/feature_request.yml index 6582fa5e4..7e0fa1c25 100644 --- a/.github/ISSUE_TEMPLATE/feature_request.yml +++ b/.github/ISSUE_TEMPLATE/feature_request.yml @@ -1,5 +1,5 @@ name: Feature request -description: Suggest an idea for Music +description: Suggest an idea for InnerTune labels: [enhancement] body: - type: checkboxes From f6dddd50554aed269013475504298916c7bb4d98 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 16 Jun 2023 22:09:51 +0800 Subject: [PATCH 221/323] Update icon (#305) --- README.md | 7 +-- app/build.gradle.kts | 2 +- app/src/main/ic_launcher-playstore.png | Bin 6562 -> 13357 bytes .../res/drawable/ic_launcher_background.xml | 4 ++ .../res/drawable/ic_launcher_foreground.xml | 54 ++++++++++++++---- .../res/drawable/ic_launcher_monochrome.xml | 48 ++++++++++++++++ .../res/mipmap-anydpi-v26/ic_launcher.xml | 4 +- .../mipmap-anydpi-v26/ic_launcher_round.xml | 4 +- app/src/main/res/mipmap-hdpi/ic_launcher.png | Bin 1075 -> 0 bytes app/src/main/res/mipmap-hdpi/ic_launcher.webp | Bin 0 -> 1016 bytes .../res/mipmap-hdpi/ic_launcher_round.png | Bin 2896 -> 0 bytes .../res/mipmap-hdpi/ic_launcher_round.webp | Bin 0 -> 2410 bytes app/src/main/res/mipmap-mdpi/ic_launcher.png | Bin 792 -> 0 bytes app/src/main/res/mipmap-mdpi/ic_launcher.webp | Bin 0 -> 768 bytes .../res/mipmap-mdpi/ic_launcher_round.png | Bin 1801 -> 0 bytes .../res/mipmap-mdpi/ic_launcher_round.webp | Bin 0 -> 1580 bytes app/src/main/res/mipmap-xhdpi/ic_launcher.png | Bin 1538 -> 0 bytes .../main/res/mipmap-xhdpi/ic_launcher.webp | Bin 0 -> 1414 bytes .../res/mipmap-xhdpi/ic_launcher_round.png | Bin 4097 -> 0 bytes .../res/mipmap-xhdpi/ic_launcher_round.webp | Bin 0 -> 3328 bytes .../main/res/mipmap-xxhdpi/ic_launcher.png | Bin 2440 -> 0 bytes .../main/res/mipmap-xxhdpi/ic_launcher.webp | Bin 0 -> 1976 bytes .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin 6559 -> 0 bytes .../res/mipmap-xxhdpi/ic_launcher_round.webp | Bin 0 -> 5204 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.png | Bin 3376 -> 0 bytes .../main/res/mipmap-xxxhdpi/ic_launcher.webp | Bin 0 -> 2670 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin 9508 -> 0 bytes .../res/mipmap-xxxhdpi/ic_launcher_round.webp | Bin 0 -> 7362 bytes 28 files changed, 103 insertions(+), 20 deletions(-) create mode 100644 app/src/main/res/drawable/ic_launcher_background.xml create mode 100644 app/src/main/res/drawable/ic_launcher_monochrome.xml delete mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher.webp delete mode 100755 app/src/main/res/mipmap-hdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-hdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher.webp delete mode 100755 app/src/main/res/mipmap-mdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-mdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher.webp delete mode 100755 app/src/main/res/mipmap-xhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher.webp delete mode 100755 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp delete mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher.webp delete mode 100755 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png create mode 100644 app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp diff --git a/README.md b/README.md index 5f575ceed..a2eaf7f0f 100644 --- a/README.md +++ b/README.md @@ -1,6 +1,6 @@ # InnerTune - + Make your own music library with any song from YouTube Music. No ads, free, and simple. @@ -14,9 +14,8 @@ No ads, free, and simple. > **Note** > -> **1.** The project is currently in an unstable stage, so there should be many bugs. If you encounter one, please report by opening an issue. -> -> **2.** The icon of this app is temporary. It will be changed in the future. +> The project is currently in an unstable stage, so there should be many bugs. If you encounter one, +> please report by opening an issue. With this app, you're like getting a free music streaming service. You can listen to music from YouTube Music and build your own library. What's more, songs can be downloaded for offline playback. You can also create playlists to organize your songs. The aim of _InnerTune_ is to enable everyone to listen to music at no cost by an easy-to-use, practical and ad-free application. diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 0daa5f6c7..8d41b4e0f 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -14,7 +14,7 @@ android { defaultConfig { applicationId = "com.zionhuang.music" minSdk = 24 - targetSdk = 32 + targetSdk = 33 versionCode = 15 versionName = "0.4.4" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png index b8fb508b4f3aade7dd79440e9cef7bf89bf06f03..f4914bd10acc6812e82f87084f4667e876279776 100644 GIT binary patch literal 13357 zcmeHt_aoK+`}gx8M52K*&)Z04HH_>-**hYMQxVCEj2!DU6^>mhd+!k)`=DiJWM-ZU zaU3JZKF;{QRNwdK{tNCO?)&pgoq9dT^}NR8n$OHkbUE1g*Z=?=`g#{G0>A+MWB{zp z&|ho!2FL)MUDLmyX?f3napGCh;lYv3m09HKU_ShIr0e)p+>!W~)A~2$lJteRkNCv| zABr*dJ8(x+6E<=HHljH4B=~Y}T~U1@IfPj1bhw`7WsZsepc1~vcjEML`M$MN!SwEe zN=h_z)hxwykp%!Wl@d=LDVR?8 zzS2_=V7=Ksgvj;pn-09Vs(2gzbeb|~K&p)qWCGyMqo@U6%Mj8H@uNAiqXKQkzBgY@ z>Q9-}%b7_eR0Syu|Gm-QC$ShM8}Beh2%c-#t2`dtZ!L#~KY zmR^K-q;PMM^%7Kt5UbDzc$kl}U6EO#9x+F+QSCtc{KWM$9jVkyN=HX}u3EcqjV|z? z?pVG}NYkcYRc7>}1X=D_pMSW!aq6U3-teWZRHS}tf7v+qv8=gG#e{h606`87myHy; z@!}WE@k%Rc^!}9lOsYJ$&OtABjO2};-%KlvnoD$NkBcQyjPHMrNNQ9ta$m!C$@S!f zrit4=a13VP3xoJ;oZi2g*bpwD&M)3Wux`ovmg7~4tPoFloK!frsGPg(%AbpQE_E=H zJ}Dt{VS(os7j(gqzBlk??~FQ9$m|=v#;=DuT2-`c>afZcraJ4$+Q9`+EA}5;>CUWk zBd}8u&>gsJoPlYR){MQ>^k{#orAf3sug}VW6kTSkYsVR>E%P$m|+0i z2&o-is212iQ)xG7L0a3UG++kZ{kqf3TaS?=_llF$l^=;*Tn;7<7-A_F-cTPCdEs~Tqv|PzMiG3Q*h$eR`p3LRS0wb zW7)Aw&dfT&S8rIsXZqH;`8l7o!}Rqyq4PY%tcRb(MKW(ggA$~_gNEPS3T=9^P+Rf1{nP!3uw^J4l150oXueoO{x7Tg?J4? z`5ArP^$>WwU;R4EgJ~O_NoBO1P29WApzWQVM#^n(O`#({bCfepvzGW5K803gQk?J0 zT38J$o-K67`XSrA-klHupzYC6l>v#l>7L=bLcM>}z!Rj&YpN$+TCYiS9iz~hlQEs~ z-M*sy&RSkEr(Tv*sdQ(7f)lJy3xM8JD0+S4{pE8!?S&KfrN*zmsO2w|#D#st&~olw zpjXzoZ0K(*1)nX#f7=ji-gni2RWEA|A62@EScZTo+aPaAWqVX+qNC^USt4tLENcy6S>6g5m_iY+oF8?IiuYfebmSNM;EO^d3f& zf9hT`6n33+%{~jm8e`^Bi>gA$HczMfLxO0`lbE(UVU@jpQF|jbJkG((_xfaq-QeSZ zI{0$*GWS%h+q=TqHH=hnmypnR{mzS82vgVfCB&#I1Hu#H1q)#SCsde;p*0hZ*XD)v zYvLNKlCRpR%*V%3eErN*`H13yW4`3Ns#tm4*GGl5!<2{if@uw~0`91w@eb+WR-ZI!S*4xy1e40h1+y4W=i(c(ueCpFU2WS4lEmujG zUS?sAxv8+x=n*sJL+P`}1{9R$Th`aV=&|LDE;IN*;%=**7ovtsjqr)_P7NsV+I9^6 z#G98H$#z(p>|OA7S2-9yTgg_g$O;fq1i!{1LvwFK`=P4&9Nfd^sseiV?ePw>j;^o0 zZS;j7rx|-CnE*U*Er8zbkU^QE-XOD6E|yHPz{Ygxg6pFK$lMC)azpIYkv!N24|H|L zaSs}i^AV}z?G3v_Z{A$p3s5{CvM;T7MHR}b6H1N652ZxeRllFwts@;S#Sl#bT*H1vMwrlMyz=fvT3CX z4WV@km3qTN_B)|nJtXmU)6i6w+N-UA;15|L`6I|Z-akYpnyR5kn!MfNKDr$%8+>Ix zNoTT9#TcieG5lnH>__`n&wP^5`uX6uqI&xqv>@d|3-lJ4;DGjWT$%fkszg^9f^*Le z!R9X4u`j2Tw%B$R#~V?tO1MjR^V}rb`_7mnT4&Begp(>9y6sCs*3OW3h1_S{??4m zNGEP3&Tq_Es>%}3V}}@8o_|q4_uu)iE=IF$iE^mVV9 zwPY1b-%x7L@{E)SfG7Ge-B~gaLoRVRQk=yD95l^ov_{#oo)gFV^O_=b&6-|cjp04U zGp%UBPHI}_p22Rl(iEy+CZ}jX?&?LW=P}6Bx4WZkYZaGOfk}8l0^KK>CT0+4)1S137>@=AKUvD=DiOG{7w*vF%Wo7&rl3fbyp3MF#CbY1DH7(R%-JrBd2gM57?4;SPvU%h@z#&)qMkAp{wqlLiTrkm1NO<5jvURt z*c03K#nkbAU0Jsm-)2Ja(N-&o*$cy`vc?PU!-n!C5p^&$9pP$h960q1oN~2O zqNzX4Hdc8t{scr zgNDclcZ!57ORjQ)QHr9_zE64E{JH>PL4aK00_$|a>Br+z_X3X#y&}H&*q`{hi;1#f zSDj18dJIi_Za7pu?{o!gs!`HmAEp_kUhZxTek0*QbHI(u(W6keBPH$*CC)s^aM1Ua zysGJHbre&w_xZ=%bC-S9CExcz>&iq zWS``2eYYRJnrPFwppi1kKU&V+IG&jCA%@Rfh6O#^6wd)e=#o({dmhvfWa-K=vKx=Q z)1Tw0Hd+AjVXks9YgW=qo*vZ~&yL^DP8dxZE1am4j{|15R?KJ<+GGGbAgdyiaO$JB zHA*p@Rm>GP0^zQkkG+5Sh*?KGw;XPVLs^zwq(WCWvVhOZ|y%Wa${%71K1voGEesIod!v3vvQg@h=Ec)TF$TtCJR zLsZZDM$Y^4A(!9xnp5W|GTc-ScEnc3Uah&J?>@wifrNB^F6ra+rJOoN<*|9$%5ZO* zf%5sZ;h%?Rq9TPc$yOtMz5Rtc%plmRjD2nGaAN@fL&uENs0EFYqhh+G`MWz2_Br3J zZ+uYQC9kZytB)fzcOC+)Y0O!U^DlP#Jnv9v%inm&9b7V4|2gc0HDUtpYl2y*l=s82 zIFqT+8tIrzc;<>($CpMEL=T?S;rjakOszH08S}F06qWdkJR>Be4XLAv>AD-OWh0$Y z$*gVrnxl&pVTkC##Uq1IPAND-Y~1-|TZpkQd#xU51HbV;>GnV{4b6=!Oh!apJDq*(~M&b>}=kyfeEpR%o#)7nlvb zILpUy{LQMLC%vd7UgOjs4 zkWGn~lfvWZ14Y+&SmF!&p zFyECLx;{vs=N*Ma879i==QSRN`8LllKPyy~MoVo#e;1u> z;KcXii5vOuv8GU2zEE9`r;O;*6qu0+XwKKtcf3B{a2x;4O@;%YlG4(;y}&ccPX^wL z<=rv^A&m_$2u@d#gTO3U2)}cTI#st3n78IH0@4pa(kYza4cIi?WM~$4KAdyeWSQe? zXx@ax`2b(5FRxt_lI#Yb1h0>NM({Ff>Y{s*cNaJFca%;Xh5-x$pv}nwt4-{SK_S6e zeazG_!Y9WKQDciu{_bn= zKcpKtgURwOwr|~qQwPv#KSa^Ql-jGYvBKNlriRqnZtqLHKL$e^Vsns5fj@NUTXPR6 zE>7mTAvOj}#dz=I2`R1br`zTa@n(tDEU9p}TR0@09fN>J6bgtE8lQ1&HDesmDh5fP z(Fw|48Eann9=6gw0WQfRkSGc+g6KO;^&)>10fo+&-!sM|3l>Q$*B z)c)@5AkodYTF2S?oN#z3#f1S#=|Bv#{-OBhpU!bMpqxTMh0g*XozUee-jn(cf!AKv z|AY82l)^X~uPah!BzcVYG~tsXa$)by5BKMB>pQktjziOhNd4}dxswdQO?Q|3pHXw( z)eOyQ4@YlaGf-rhjI3f&W3-PBA3U)*suAX%lPI!|L5@l21h(HL?@8()ZCBqfj?!Hx zPB}G63JnrJKYh~Ot+O_v4bY3bnji{Zj>-)ir!HPA zsZ8NL!PTJsQk7)mUdNeIM58hnRG_f$sF%78X}<~si1a=U1&D9pw)^vZ_Fdy8@Mzk- zRq(i}pXze4e_nq=zB%5xm3!LrZt&3+bA6{WS&2NMkaBrFo>@Y`)+p=x1^xJFOXc%_ zi0QzB^oSn&6g#8b41Qz&=~_u{o7%H6G(*bgVvgS7Y<#+3K+s$DJlVG9np$HUyB9XZ zj`lWMCoUr>P$X_h0axFMDsj(B3U+1%<${oj1q%b^fcVNcr6artz+5T4LRypeR9?aHiBWrf(;p@cY%&OEPEBSFU# zmb-PmvBz8d^s^rWFt%&@uCQ;;iTy!rXk7$@#>6YGu^a7^9Gulk7QQV&t?7}5L)4; z5dvxxzq+&=_aVP`LWP?%o({f9+@yJWw9uMTFkjJf<%2(z53G*?cPJ*3(ha_7kb2$u z`IGy8O(GJqR*n!ds#BC>UJ*Oa1YFW@3AHkCVtczrH;vTD6-7;0Hw6X8Fg-oQTiCy8 zLsQB9%ZP#KZdkc0XpqZ+C++{~)5!1pm;Hl6OqRF^9F0SEIVXa?_VL48PjCS$RQ}PM zf56L-R3ElqpGECrW z6olJuoS+9I+t#Xbgggcnuy;=w=wd)jR_~b~ps&f_%%w^H)$(~K7`!0)37A+d+nHHE zrvCNW-{sPbi2X&ThV}cPoMI(1Wh>0*uU123tYMPq{HqZa#B;W^UDMk&myTSJ-W7UB zm89Vv72CxA_np3qVD5c$vGo1{cJDPF+#5+l??Q~?P3yleo%aB}9epnMQHzTf4s1{r z7wr%t_*dl%C1B}Uh5vlJJ<-a438l7_6cO&dC1pXAfiT_=@@j));Lcc*b^Pr$zjwS6 z+>T>k^cYZ0m47c@KL#lIa=^!OF*?W6|Dz}k-)M{LtDY#+`?yoq|94Ri4^Te27dGs~ zbn)`3{GP34sK)n;yHP-yUmT<1IKcT2XbypHdX55f*&cz!d2H*Rbqy=q(O)|p8`H16 zv~6mOq-IMkvrBLy_RkJzghc)omM8;4SKO9$y-DBhxvtJV()TKapJ23OPkXq4s-XDT zR`=CXF$|vb-osCrzf8UD2=YrLS`Rx8KbzN5PZ@H46ukuh`_9iGeP?qs`Nw^A)RR9K zKu!b7SAMfS9H1NiG6c$C?EfAIC81@1-w0($)_=7EO1LEd+7%O+=7xbPQP6yx0g%G~ zd+dLi@Lx^%zh(}Y^pc8;1vEwJe+_dE6V)Qrf^6|V{)0v2vA1@^MeR}QZ9>zVRclG_ zpWb4;W0KzMqpi3awv`^Jk7KY>#{L5-tUZV?ll<4td+ml5DV=*_9cD#$z4GIv4k$L$ zr=2M}-eY{c4eDnQ6l?54`sl65O%;~6oz(Mc0Te;g5cyg-{pD^6=S$cbQn~L zO?dP`jB7l*_23_e1jWWGZ%+uT^S4uKM(9fu57v%eru!Oak>9@U4CURfxa|*jUwm%z z5c~@%9@@GT-5L<4|Q`H=c)}Ee8|Y`Q0q_Y16Sy&XswdrJI$dexx@o z{~LKTfw@(xbuM>Y0nJGjzZ?KTs3H`+iGUh?+u>&sv61AjiBQbw?JT21!9u7z@%z+o zLe$rx6l1uou}mB8A+D~>Fa)})9@=~u)+{($8kJlWMTUZ5d3Tr{>OZ@f)v%sZ8D{oL z7DgKb!y(cn8%PB(kjG{P8~r-i1tP-!^+mm^p1}}75>&bU`^m50@ZKc;@(bq{ZmE}~ zGHC{|up2!yfxwhT%UFVI2Wk3cgZnaZH}L#J`&Yu(?QkPXgOo;So|5j2LN25wP=iY6 z09fefi>64FoNUh$b+=jY&w`wX2jt_lL3xBi4iq(#wr-6jH9^&9zu(RQt+u907q@-r z_zt<~>hAoX3o;IFW}5mlLy3*`>;Gf9V3pwB8AS`b+q5hHlE3Xw(GsKE%o*ZekcYo6 z{d;3uwN`=1a)^0cm~Kt^Tv^q>G?EO*dgdqYs}etFHjVygd6|Vf_x4oe14?!%)s+an zpzp+4X7V}{}0J?Z4(fs+5MOvA8f zZEvf{m{R>Az4-HVL?cs1Thq^-KIdyxB4O9HLcEQ0u*YWBysts(Tp3m$8mopgAQ~4u zIcB_c=)-@m5y;@MOIxhMT2GHZ4^M_Nu>Qo8-W4Sxt+JNWZMC}*KzrlRaSw)A0p&yO zU+%^x*Kp~(+OE&0VTik}@Rn(oLG&F-CN!nDY_^H_6q%m!n=j2;MSvJ#zJ0>{pXTUce>f3wi*ZON=(I-%|NbXuf*#BEM6OSl z-glZDSf0bnXb8@?3p*Wl{8?AApvE0OYaQq6;M<~k0FWFO^3)~ip_ab|9&=aOy$FNk zU(7v!-P@%T_pnQjV*5Px$mYkn2a)t*Dcx@E_UmWdgK&K%V=G!8&txBw8Al2CNpre z)4QTZrrP2y6WYkOPWsw%Xx^WDj^?mahL$;J$|@t;=cE+-sVUqrct33=O)is#2w3O;xJvK- z!=5vaU6Tm=loCmbbl~}8!12Xw#JKxhb{pj{b&q5|lGhI#=2Bdtb8U!Fn>521TPB$E zm%Q?OhBm2bX62ihm(XX%MDm8}Z375UA!JBz|LJkOvvO+cr|0TPHwJ_g?o=!z+T-eP zk@j9#ReT?V~*3i?7=Z-T&f)clWq_likEAn^Gd%Pt7OD~0$>K#iBYELf8O=`dDc zT)uMcxU6wi&?ucO0vv~e&(9+j3$tGH0$P1 zy)V4wiy!ZX6r?hhZ_4ZN9cI`546}~we-Gke(CAZHBLf`jBCp+wVfN}5tn$%kb3}J%#NTd2<>}iQX&m4 z5>ydx$CXZ6#J)kv38~&2hoPDO0onObpPW|zlt8b82$}4`pSS}zW4=AmD!!rwf6GaK-7=~Cjgp$VRgnel%0QM!?t%97A$LM`_u=RXY!^{P!9 zl8i6U1HE>FA@|X$WCGm;;$i@5p;$fCiE6NV`zu@8ROlBoCUnAr($V%F-O~@|-?D<* zU--xB^|05M^OR`396Hc;Mf_uL-X1d*$W~<*Fvy}X9j+Tm=Fl+0y^ZK6q97ZQw>Tus zN^C4BaD~)rk6O^rVGA|^wt;Vb!+~F3crPY0o{9o74{p?oq%G~6Z+!E68K%eYF{$^C z6O7MhlhY-Ufhuf_0D79PwqhTw%fYWF&`FpZ<4^5hwMZed421bjC=Hf7Hv!)R=%fh= ztpE?ztXzJ`9<6yiPgL@x!*Wz)e5z)&R1E380!-+`Zt5Qynv-_FugQ?{rp`RgKI`r- ziF?ydlXamY1WI3a@oO`*0@53Q^)c?YspV7b*9zDA(j;1Wra#}*q2Cf>K*+j)Yb?#u zkdmuF-G%9^=tk*B;Lv*WDO2^y1Le!Y#@b*`oB_cLEigpMP}vW$`<6)#*7vnOF6)*t zP^qS6w6j8!Nn!rQW34>DsYzTz5!;J8N$@cS3rV}J&5yTa_gW(=4**nwfYtP>=U$*# za%>M;VhWV+hQ|$y@qw3s85Pa=p?H{ zjLjxGS8!G9ddFo=rM>%a$`;|_rjzzRI%_3;LLC`l>4|b6=mKbSR8y;hvzQzLD8p!0 z`zo~<=ax4@>3KP}Cy}XF^h)=!R6KyrB_}87f{|k9sVBZrC1YIfs*I&$$9$aXnYeW@ zA)TTWfGU=NT4*)DSYByC44^)-uaC9^HpR$D**2S8TZ`3+ZM`Qln=^Um_rmN-<};ff zNPV^BY5Lrx?Z^(1@|F4J1WIN<;8k*xzZ7Wlr2X!~P8b(M-Nk4OX&Q%}nZCLQ4s{W5 zANA2AC9}s4#}&9{yAH3I(JFfrLmuvhDM|)k&5SngM$X{j70-6*Ex~<)J(BF*_CC^5pAIN?!||U@h)|O(eqSn>F{U;epF!M z#`e$+q|i0v=FJ>-8Z=X*w?SUr+H0c=^zN(k&a@4<4XQmPx5EKTKoDR6tZQ7o%?9R zXVa*bE(@;SWoNtTJebpD3qs~jp|I3%XdKd8x_fQK;)S8^?K%+^)_|;TKD7kG+Dv%l z_FFMd%&%*`uaAIX<1W3#RTA<-xMypz5=OoDsX2UexW&Z)v3~FEar3QIsCY z>z?%D>lKtk-Of@M>SZp@TGn|CquLMl`1?oL3oo`8mv!`wT774AId>n#oS9%)|D`9y zD3`T{p&(Zyu*iecZTE-Fj+LAg&zoO&FId8sQI+$|{Tp&bseap?#hFsju;_EG#WbdJ zVaV%1)09S6x;)TM1Mv=pJ+9ZoE}F$o@rt~vZIqh=LSl)jP@t-OOddqM4tcD?AQu@mx|LreDz>X1|p+B&1lHzS-8h3gw&lJ)=$7XRfF8-oNvOY0}45yr@dW<18ML;2OGd zd%=}s#>6mMv+D*Q5C~z%CNF=j5b5KG0Bn}0El(3uRSuzXS0}4p8jS2Ouv8u}9 zwhYDt6O3v{>lzZ=a7@656V_breOX^8AKMi%AB;PL+nvh5$|`|$sKEP#pddEEvo)+F z#cEUu&8f8q5<9MTvH>+HTmu|1l*PB6CvV`VFn#A=Fw3mZeB2Y|&4^AF0dh~DprOnW zN~?m;XVux)%_BD{#1nUnE? zlPv?}N23{)iyEaDNy|0c%SuPRN+dycBL6* zUTc7t2+#&5dT3C4iLxB{$#KATY@b~b%M;WYw^~`FG~wJdMLQX#DHM7 zbK`wg*(e27AoTD&JI;)20Zl?tZy`4qQoAt-ev^?8KWI~_Xk_(HL$$BF{=O zquXBkS7f!57dZh389q5; zgY1V`ZcPo(QlQbwHgryoW2|r`74%+ZLJP2I#qL&c?!J*VjU0OXVDOmj*u5mXI*;#- z@9e!FFW2-Kc<1Piv7n3LU^+Q9VRPdkGF8Yn1{$t3)-W!2cuoIeK6rq>RkKoA!wD+F zz_q;^T-!e{i|wlX1GGsk@iUrG$@KO+QHU1e)jxcQBKI_smMsQN3*DlCnCncSbMC=A zsJVj=;_aBeB)&Z}J--AKe7lkazu7|FCp81o9m}N^UBl z6yo67L`H-V7m&)k|Lyi05f&3`$9K6E6#NYhC9mc02Nw0=y-p#W;h~ebhefSxGy*O& za;q(|$M5;f*0vty42LivJq6#&q4I~jz4)tA4X@L2qRx^PP4aOM^FB56aJ=F+^xob9Wm54%dHw{(r&;YUmQ{Gdb;>@|=*|fxfoMg;K3+5B>+LOf9+q literal 6562 zcmbtYc|6qZ+P`PgrYuD%LWfE{$sXAwMcFFK5++Mg*6i!dR3wk591o#vmGIbOEDdG` zg-5cb>@$Y4jqJuS%*=a_^L{?>Ip=-P=Q+=LKfgbI_jaxKeP7r2x??Vz8S?Rp@&W+( zE*cqJ1po<~NU($Z3llw81b;lPdZu~+l*aAaaNG*}hnlw#GY>8T49YaPgdLh0QdH zf)F&~jG2SO0PNtZjD?#02hIP4=I=!PKgl7dKT_Uj+rD8!tBYStmcMMg#$)l>^G=#0 zzh9rc=_g-XDrT#@P=Obr@QeR=l(in;r?;VyVUqp%xrZD|mWkR4d=4!}>Hn0j{#d5+ zT>MnyS7Hmk=l(*NITL-ygLH))IOEmbxH#ZPy2fI}l$88LBjS6Pt6oH_rD_&vY?I*E zmQ2aWMD_RapMM?Ql4X)}`S>bhCz!A2Z5+LFOibOR&})valUd*~jkmPn45OSnF_YDS zRa1J+b)7#Nm}bW_Om3CoUnd|DfU@*g-H=2IulR10j^qmebom0g7(8bpVC*`J+#K<3 z$@rD^#_@zxze9&`7?Y^}0ncy^-?6}RG3_kfLO-5d5OVOzUlIqw;rAe;uao0B;mZWC~=`K)R(?jR39es5eHM2Tv)Zx3d2 zytmx-(xqvei1+VBg8K@DcZ+e4WU|+>oQ2|Dw@&Z#ByV3hR~5Atw7+Yy@@6}5zK7d9 z>Eg)qxy|r5^B+A6sGlU)Q`wvxL{R2(l=Hm_1QNJ*ZqS;%O~pk_IGnd~NxeP(d=ejo zDt`ub&&woZhC_zLKFR3lPWCpK%Q(D~kp(`1E`InVcIn9~+LKk1-7wi3jW3ID3$c%c z6bc$utE`7TerjzDhV8y0a_YLH+7=#gi4u6+7l-f8<_J_D>{QnfH$CDhfxzxfk{7mR zHp^L6uP!-GhhCd*ea1J(w+|e5TskS%TVUHkUQR6$KwDf6n4p@SaoV*LB)&0G4PxBO zl)>nDMMTs%hNDlUw+t8|L3Z#e2|eSLaE0Afknem`)BmP4T%BU4gL$Mo7oapvOK*ya zx>d3ZGFL1Z4C|d$5gpBZCmLqBBBeNZ5xh(+=l=!IB2A7ri6AX1N zD?(N$tR=S7o~M{fWPAREz7jW?pm3QI8>wETsGc!&xZw5)tH4^-$1_A_8BS8I<8n)C!&-`Z3 z7+5E769mT*JG<`$X@R<5wHIfS zojEQWou3{^KI9Hrd8r-fZzhHwyB#3`_q%*O|8;w<&dOIErDrCtKjaWti#F-Ana&k_ zPn*M!Tm{rblAG2*_lRU!ShsO49)^%u)s~JB%^+N zBtQzjqq_uuv_kLzp2Lp|ynTzQH8;7#MqeH;a%dl}n&1k< z&W^Lx>l2g9jLoekOKvu7|b4ZX>)kZ{VR@;X6#Y zvRBqidS*{oVYWhPZqrU7Gt^J|AA_))-GZ_rS7&(_O)i(WJr$ZygqB(jpBK&?fs7Eg z6W-U==uenDwSUvbW6G|7piP8#g=0hrD~4RHaB_)X6RQGeBBi4qOv!Wg#xGhp-01JY z;YC*Z;{GOqcnxMb-Ov=DF)j_VyqI#wL+lX455;w3k9PzNVp^)C@5b_6=OqTr-lmUz zWMWjxOXfnmQn@T9`8rMy>$)9G%jg(iT#4Zhjn#4BWoSOv@hv7%TnY-lAMz!)j7Xe# zUKPd*-GF_{EU~Hxd}8VyEfpBWr5NpEr?#cXMa!T_p*bHl>W znA6wS+DIo~mU`9>V?#J4OdRek6<0!;@bmNTnXpnd^Eg%LMeHByAJBdLK`7?Sm7mft zy5b(`Ah96LAHajb3F(aZ^(5-Ju-%Xzptf$i`E|Q6^y;7fpxm>YM2NG{VLSl(Nf7@2 zaFDzD&wPRagZx9L|5`j(uYdphGlu-L%s=bGf5TV*EaHD${qLK^UHPxg!kS+>E*z)S zAFQ-5Mg|5^ha;6jPd#QNJ$^)0tq24*#IuYx4x@F0d2W-O3D3u$d2z$$XfUL+pCq_;tqT$h}0ixeQMRQXf!D z3^B6tFgQk>QCT3flDgFyoLUGa*q{3-YU)*ZNgWf3ee6U8+;S@C0IM-rsiK+#e@nLBd2@a(%-vWVK{1GrfNE^ckJLaa5fNv58er)p8txyz?lB z@%pb}&Po4@(|ZK)Net@imzPFd?gIC>|IEfzL1 zF#8IktEO^z(0CfJ9MjZbFr)LYZy7MX$%6&`aUURzkW18@588$Wf(II%?m2p96=qL= zM^U9AMY!UWN#M=9?CAWSr5uv+*&U#MkGe_u?q5)Z?#aGkDCI1+&})Vg_Bkl8Y&I=M z78+Ks2$9_liwRejW$f5qL1f+3RoVT94z+CjY^e+@9f1Wr?o0tvFx`133o?a4I)$AC zWu@KAk{2<>Fkmc0x;i|4XaTGCahp8{^R5Fw4uaJ!YdY`C%@V+{JG{B;+-B9OJvb$;eKJ^tLhtUq-nZ zAOLf@bwgM1wPtJ{Z8x`5hTj-Ut)i?R{9ND53NFt|-)eve~ibjcQmP&6mV%B9TKMgao29#Im>!JL9bygudgZc7aP{y|~u! zp<%Jn7saBhUWO1RM&cNOxw$)n@aU6R^LYn1r7G9d!YV3Zvs3Sz*s@~U9>U7ys?d%; zGS1TFayBTq5%$bed)sfD!$YZbv9F&0-C5oEoc*)L7E5>p9vn??(}`d=EH9r^5X9EO zn$325lc_n^muW?;hqQLR?(U<9;Y$wi1>6>=;+Y!UUdnSe9;i|hRk)64Fo&OiEX67pK%O@4M z@?Jj-evSA3G^r(x=-Ur?u#EjbH%08Eek!DsS^BTstfWD0GRvrt=-M}_CFU-3=*0f5 z06}HJvq=lhIGFD0JY)E+QUzHTw*9aaaCNblE#cbT>Y(?(YChTKmRACc2)rEyeF3Zjc^C<=FLlDDu(Do}It;0C z<-`bB4}nut^lRtI_g&9I;xA0ENlJo_a`~Iy;pxl|2`5qyBB<|wgh~5PmB65Dso|_F z4Z>64MLVtP{3vNzH2nq)*Q(0My66+avC@7e^4GtJW@T@jNV!~JRVN5^8e+$)U={VG zYp*@5cbbH};Kqd~J}V-BtdvM7H|Q;j*4`X?b6JHln*Q==O?8ti8SeC|(Q^+kpHTKj z+uir&!o8_&AC0+M*AJ$%2IE58x_{+X4mKq~AFK+O z8stZ~eAGIHKaDFNX9^(eWLiXc6G|v{`Gv|?jos{v3u7gY#X2CO!F&wGV;1HKul%IW ze2P16`cK(F12x-3P-+ zssSlQxtR1C8*-nfQ?nE}lxi*2uc+i%o52pqEAt8n>v(z|E{)Q^hYtAmu_#n%B~g;i z8TjH@1J~jRMunAOzyDR6EQu`?&qS43?Ez11RFgAp?C2p_i||zxoyAI$F5wt})kA>| zU4wBMq7}I->vP)QaDgX~b*}O4F3l6k?a3kPlT(@-oFdx#_EXUrY`8p41`&@E(-sKj z9cEn~)>d`KNP+MRdH%QIHS)VXdjhV0l}}fgnwLaSmoj;Ve)CceH1DYPEg-gzK#O-8 zxt0kn88^*N*}#m%pP<8j3VfvaN24XSYaE>(AUC~wzBOzwf*J_}7~Im>vD4#o$@U^2 z?|Ny6zi44DYC;y54rNOUn2Xk4^HYS52C_W~AiIqfq()zR?4@B<(sP9Lp_FB)YdnvAmGB=oPz1I0ytF6Nk(7>yk z4utvtV;nZ{*M`5Sz0kTecpp+-l|!Z2J1iM=#uP2xI$b5HP2OqvLz@RHFQ48L z_@wPXn+%TKHSdBB$hth=7Q}ceZ@ovI2F+9m@YJ3y{+sdZW?0lw)xyvS1Tfv--q_WMPwEt-4?s>(+fKl4n| z{LE^H|F@tJ=jLn-lrgOk@a42>7NVJ%T0$#JDo#ErhF*>ZJ_C(=^9;{*$h+4m7Lj^& zpNG?%oEM4h><)w-vQyL_CY=I@dUCp4&OH8LB1ydZRwKy6u6fLm_T(Uiq;U@W< zxq`+GKPF5l>taeUN~Ldq*n95)u6nZZG|^PN!`D!UE$q<{4hI23jc631A>*BJaiLsB>GCF%e`wI^_-L=r{+KTC`x6Cr1bZq*Ov%n{3hb%7E=_y$uj*LA+8$6#F<~zoG|rq@??lL!dUe-Zs&C7r9by z-Ud-Du_c)wmc|cGrni~N7qbWY_M~aPhheexMU5&$pmy*e0vq(oZKDe}0YPZBPmcLk z2pR1N-F?bzsf5N+iVp!w*%`iTql_w2mn z_~6%9RvlfO@j!&vzGg6(M8Noa-P06?dkV{C>UIPbXD<@UfbSqOai6!Bet<{@ z!hRSTwOWSbVX|YDj$g4Dp8PaQb2yTub;;Ei|KJ>25SV?|H5Sx1nT?df8N0(Lqp$W4 zb$hjSC=%fjH6y)EPajc6pw>$TVuP=Yj$|0Ud0K`~GuDPlV5<9plVWP?(YVj021>$D z<|GWPDg$#xthVP)z*?w>t_2NQgGYHxmk>6~_2{zzm_g8-mt)N@qB(9JB>MavSWGql z{sq@>g==+p*Icg|aXS-uz&z*bl!(g6?+ObYKY7eb7S26F zlT=sqQ`bj|I_m?EmxT$EV?RL9wX6`5qH!r(*`_z}EP$XL@BmmN{$2C`82Y=}KWzTL z$uY=()aMT+{;vi9U=CGw6|?f|vB($womK#VqyFcu{aqaWZ)rID-hw}%q@bj9O5yY= z1yxH$bq%F68VZUh6%;fS6yDvxs{9WGp588ZZioD9f|em#93%i2&zl((>)(9vU*z}j A4*&oF diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml new file mode 100644 index 000000000..c5d5899fd --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #FFFFFF + \ No newline at end of file diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml index f4198ab18..a1ed771fb 100644 --- a/app/src/main/res/drawable/ic_launcher_foreground.xml +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -2,15 +2,47 @@ android:width="108dp" android:height="108dp" android:viewportWidth="108" - android:viewportHeight="108" - android:tint="#819CA9"> - - - + android:viewportHeight="108"> + + + + + + + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_monochrome.xml b/app/src/main/res/drawable/ic_launcher_monochrome.xml new file mode 100644 index 000000000..1ef5c4783 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_monochrome.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml index 5c84730ca..f30783b21 100755 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -2,5 +2,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml index 5c84730ca..f30783b21 100755 --- a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -2,5 +2,5 @@ - - + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png deleted file mode 100644 index 9087c790e90f8bdcdad6ce6a51f244676a595716..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1075 zcmV-31kC%1P)|DF|UvO0<#%NG$)F?hs2m~nQB~TRYv{0zk z0@7)K)u~-beUhfU`ssvC@C=|Mb$!G+Go=6~|8uY3JP;MX)xJxS_ ztRMs=isB>msuWN)1ArbY1~fA>bB}gFE|;rTwSdUqg;*@UUc?mhfTGbDjD7GyXa6f` z>Kj-m!wVY-P8ZDolGp^a7>z>Xvwo;}&4Rs~~hFsp&g?MHG;o7m$JOr0&TQ^_A zAiCc8giS#Hxp}B=e+8zO-u^7F8!jZ+* zyPrw{*+nn?9cIJcPjKO({4?3D789A9Ezl1L8=_hTyDT^tAGLt{(-(Gn6!B22@3mwE z2X8lzl7H?v^Jl7w6Pqt>WZH41u6;{5ciW!A*XdbyyIip;{A zL!HCnxThG9-EKddO7iuR$$te@gd7YVcQCa{1002ovPDHLkV1i~w;AH>+ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.webp b/app/src/main/res/mipmap-hdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..0cceb069de01c06547df99f87d8824215c68e3f1 GIT binary patch literal 1016 zcmV%=&{Prik&49JlrDUv*UmKk3w#_F;9t3r1Px3+E7$~^D8M@kEx z5xus4F@#>QlIBm6^V|KT?asw5x*ApuE9 zK(fe$gphztD3EJI5|Wh=L>8Gql7Nt4n!2u&LQs>CWCF=5kRj?y5;8%UW`q?80fJz6DtO_CG}rA{vjGf9$!LA?rt%a(7W5NR_=G}y;W&J zY-50gJgnxqjWG`;3@86LS+|>|-D0vTR>i7--))RT$W;N?kdW|ke7{dG^H=`=PcwCAoA`MzkWLmi`K{RH1JdjD zZCS~r{|?1F|Bjx`@Dzc#3htJ%nVvxL)~=g49>clD^AOI>U!Q+)Sco6Iv=*&QzjfaUtJiJXVI!)XsWL1QK*aPd#JFy}(#R;0;@Y&$^F;lb@Ocb7BUCVU+*%^q zP@aSW;ZTM-fog~iI`!<_935CFk;_#!HAMjn1+!KttZ#|X(69fD#dAl+`@lmC3@JiE z3r*nPpa*bn@H~Rz(r)qG_XOgde}U(skM~;$POs-DOqW=K{Z#3caUtT^UkLlR=)Tn#>)cMnA5%CNn5h5WW5+M>IA|VnX5)vX2A|ahCDXw%D m?bC7U_gAY`yN>V`rBx3X^GJDM@4-XDmzKMvLyG~*zo`H({pz{^ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png deleted file mode 100755 index 55ddf0acbbe335755caaf3e086cfd0ada78cc925..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2896 zcmV-W3$OHvP)O{t@wllLBm54M1e(6bQD2+5s>96EC>h-yDYmbr~kJ-mtGbYxpyCTi9Iv_+za=f zbN=(~|2&U7Y?y+DG3LR^(*{|d4|`q;AFvmZH?9)u_0;*+0mdZ(gY&fUd<4;0qRB+l zI8EmN8zHC7p#qHMSGW4#D@4IWyNFH^T_q|bYV7Xr))94b!hIuuc9p;T5wH7-RhyEe z(H(&C7OMf>k0*McD4D392F^fcvsn@e_0R@U5^r<7mHK+SyTo{k{+>^Cl&IMX6mu6Z z{MO7%w-uTBvo6v5-D1GdHt%D;mHK(Poz#f#gNQDHdl4vIA|#ldB%s=51s3`Y>W?R- z4X`J_4%F-tl19<9>SC@iA z*Ov&C^CF2cxe%0oN#KFoy%g~1QjkR8%p$4)Bb+I-(%@LxDFLX8&d$y^Tmq6{_)I2K z2*g+f4s%Bm@n&dmZ~v)Njkkb4b^0!pTx5iqJCnd8U06#?%NnOhlF%SY&O1X195G0c zcH*BkG&HPmC`f`xk{-k67+eibM`tH%Zf)yB+IF2RkZ2r+^78T@Iz-(>F^(^c9k)47 z2ggL}P*vB!V$-j%&%aD&`%WeFrRYTVuY`0~SfXYfhE7Q!>2$ivqN1YF;xXC<5Vd>W zl?M5n${Kbc_8bfQckDCiljCRDppTEnvEqsvDgBs8)mT+k)$e4fnvm>ubaYH5aP&fK;R=B6l$NtUAB~p;5GYA? zof(a$LB~mOHf?Qf`htRj?~2DP2_TZ1)2@znRQY!$g9AEtE`zn}J0!(OP)m(Q^EFpB zFG;FJ((^WV>8{MFSmv!FSpdbPshGaQAgM3p)q1_2-Me=$WYDT67%hq!(p}}X>;-7= zk<%>u_Ps$HYU?1fuC6X~PzGBNGeUK)5D*Rp35twMVXYG5B4KP8C#50m_U+qmKhqWo zB4#icjwm5LQUUrnDvq5>&0^K{jdn%Zki5#u%2NZXnIK*yZsVw8qbohvKmY***q4-? zo4x4D?fdM$x=M0HZOfxIH#av$M@LVx89WNeX|>w-xb!G8ePIpIr^nAqjd5gwL{5p3 z%m0l{DdM{4LF$#LlzJ%zkT6!t(Jl}MeJA~eWtwZ(u8pC0>y%P2B_#-IMwxznL_~z2 z7?WP!i|kLZFt4oC)PzC+Sz;o1v&_uQP+*ajmF3;X{*a;iNJ;fl5+KadB0noEEc_#n ziM*{P$de58DWxaFlmbX__r=AGeFRuii(On!eV$2E`l)%3JQ9G zM68s*Y<>;_vKXS;+S;<2GiQ$KG1Nj(bn@iMSLvmCB|VtJHc0Sf4ZC*j`d`2(0F;`V zI+Xxweck~gN2NV*;K0m20Hvg)%pidDN+hUm0#G*xNKXLG?gLO#Qqs)-1N2{j_V3^S zW*>lJVqyYKCX@DgSFy&%#22G#O%;1F3qWJXjvYmDkki~efT?}bWLcs)dhRj{KNjCJ8;B*IgseRFKwV{d zuB4ls(kN{ZrIwS(EXuPBrrL^UnZ_UQ-@CF&j>K$R0-~t5}AK zPE*MOMu}t(;IFClqTP;mO)`9W3IjWV^s|AwU)bT~$@3S+Qcp44%#RKJvg3dr70woO6U7 zy7{0~w%t);$`w*{>(;Gw!rdRTQq9z+O>-?1<9pIvPZjduSC{%I)Tn)TpjcdRym%F%!qd7%9X(wYgkyAw0(+H z0fl&N|jzzVyv$Hd3Y}5D{<(;JF zVmEr?#EEZ~mzS5xJ@SMZ!<>Q#>~FE>pSkB5YV}mx=Cs2tFc)|4-c|eg`OU%DwrtsA z-;o4rU?g3}Z``;sl=3t!bXT71H!)3PGs`V}z@m~bv(I7@dv?tK^GqsBy_(NTDjzew z6Ksy)(!sl0t!|@i;pZ3=ADjJU{rdI3beR+$9{%2wCr`|BK@t<@qH*TJSusw3;7~eY zrhsrOjb$<)lfynz;IV4es+Z|HH6kKndwqSqnQ!tmIU`1Kq|Jd^3E4WO(>Q+Abg zPEJnx)2C0FNaeZ}1O|OuR0=$0WMo_>9_NBr&a=yoh)hPjY{Z2{KR?E;f`q;dAGo~xBWhD?ixP#Q# zDqi#E&GY4I2cTZ35*!&B`3IFsb+NRxv<9}o59eY-Ro5s63oeo=5*rP{}YABCN zLz`%uOH3g8MBm_HD11i93rXOtS+gKflZmin>kayKf3kDu&JT_sKfW(LJ^gHcetsT> z3MFJzYac&;+(fF<3c@`Z{o3N<;u5?=w(abR6DRheF52MIf;Ok151w!iosTSuLxSnk zr;k8@$<+^g|E3Xr58ltm$LB{xZ(-027A$yY@#4kHLAb~N=-H3)4&LQ;PpE0$Y(mxX1tSjDw+=!Tk?)3WV&KT<<|BMM6+kP&iCh2><{uN5Byf6^DYf4Vc!y>>UjeF#+DB(yx=g zRF5gw+O|`A=GIFKaCwCet6dbZ#Dwr%_GKG>2Zo3>Tev2EKnuWj4D+m346cAA9* zEHJJzSQx+sBLs93LA{7c956~x_5@Jcw%w5JtaDB>-J{sHZQHhO+Z@^%*)!X=Z9B*3 zY*qCdw5#(2-gu_I4r4p1*qNUd+eXE91$AY_c5ZgBP%9;QKn~JJb%%iFzgjFg|DGNSYnx%$>qd9P= zELEVIRG)pm<1KJ^G)HNc27t;_Us_9t>2JDBk6hQT`&HL;9o_WZJGAE9%K>PXk~9^d z$~23UL59M61 z01dGgH!#5z9k^zL=Mc!}9`VZomAcswOqs3{Hfc2N>oD=ki>u z$3G`*t{IT-EOlaI?b2ZEBcS)LsOPq<>$;}(ysznaDml6Hx?$?a2{14T_7({I(O`%A zngO{E^d`UD`s@nYF1i}@mI^0#O6C`8w6-)IO(mT=21h;m|s@YMrmzqrG*zYq$<3-M!xDP%!)&GwW~RgR+ot z-eG7S94xFTAF&8m&us}QAHb+vSVrrQS}IME-u$!d?upD7c15dB)6XR4L&6`9?cNZ0q{nmaq1#yZWa-Zd1i=!r8fn~0bh7i z$7R!)dr-tOt26=7dfF#Mu({t5F$;i-iR7eR775hSmeV0{iiz;W_Ip!>3Rqa!*T~5N zlRtX&xBnpvdSL&g$FagOHU&@$DQgT7pl0I<@C%a3h10a!8er*ZhgqOnOh)t~GR-on zTToRZ9{n@qjCuYNMpac*$Hgu(Gqg;;&~mAgP#p}SB<(X9LQ4j|*g+>6a2$-RBb|p{ z%8TBO-9|K$ZdlIH%E?pS^ls%gtnpGoFV=2@>&0I%8A3}2zQkpBW^h#WtQxl;dLOi= z164&qlF^II3@ww-zY-+}RaJ>Z+Fwi}3OGLE5_Pyj?NF8ap7x85I-PZ5gbQc;Poz{8 z7U35iRYGiy=9ct^fQTrCrV*5B%9(pCKs#raW+QkJ4@gl^c*RlM&d~siP@{w!%wNBc zt8QmaDpesP6HkNvCH;{EsCpCH(K}2ApN3uI8V_dTYPUBu=q}Z0ZP7OUXP3qKMlwu3 z-(DR?x(XnPNH6k|2_I(utSUG5rPBo|H5CzE)z)U#b5)ASG{pxZlLP>iBErO_%=9+< zZ&tIti8N|xHl83UE=Z3<)e~U~+D#>Nqox@Tm<%c{CqpD8DW#@TVYy7cz){UMkEw1L zh9PpZ4>O^3Jg4_YOy6DJ4lUW852LPOJmEMTXL`?-;n5;O?-7ZHsWAAMl)?C?srh(J z&7UuPqGhcU9S-SsW-p?$c&}r3_Mw4jzr-lKCj7-)qwKGq^DcIyc ziR!ZOnnWb~N{qp4CUZBPA=1=kU8lib8vM^}|4_>1z3RCw@pBx;m%#2*5(c2Xp*fDI z&G>w^6F$t#tj-rLAMa{_6P=;wxH845t3;2hH@9sEul$%e&Ksh(0(=}sJfU%qN7r2N z1Atnc4O&WO7i_{v3u_f7>%lJppZB63--PZcU(g-*OciKn12=q`@lT(E(LJ?{)}NuL zILObGBQ*}#{!IHke738Uv_RAyj&C9XVE82_Z}3bhll5zpUtT8HW8%1xd zEBoB=d!dIHi)>)pm4;)FSye}%^;+eKc9dfaI86cZkj+s}ua2sauAQxpJK)k2j?rxd88DKpOaK4? literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png deleted file mode 100644 index b4d27a5c70d93d8a88ec42a1a9dad9fe4b767c5f..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 792 zcmV+z1LypSP)&{<)LD<^F${xw-eA)7I7sh64a}D80(k z9mM!i5VV##K+rx9SCI$Z@lu1lRTkWSV?d=hNK;Uh2=Hv?E@`< z7C;N21#q|kg`xn-R0h(yO>F^?=_&s=d3QDA;jM4P0Ryi+Fg!KGKEUtQI6NA6H$3w& z3qY5X0f(old1e8^(FM3WG^$;IiJ8xe%rgsMed|*U@Mkr_9>C;>FN(}RcrgKdCdVGY z+~Sgaw`~SsaeJXC2?ud|yMqFtBP!kuTk&C`Z9zxbDai|xc# zzEG4?B%|*G@Dz98A=fmxKSkgl$2Yw;GIbWBS86j~kTY*(6;q4aP3{JR!7HpLcdb_I z^+o`4(ZFuEpDmS29Cc!)z=7U?=lKn@*?f^a`g}fpqY~4JSS&V%5Y!I{M6IZeE#UqU zi9{UetzAitOxNAr-4zapCzHt}pGu`NNS~(mFjX6q<2XJP3b_mh1HC>vrIpHd)P=V W(O~!3O-7Xf0000LpQrT>`LzMYLU z;Wm;aMXH5+M)}qE-#gyhF1U>(Ig(D`c75GjTfI_7t+kadWgL3n zv6KUg*gd*AEF{WB|NsC7n=DP^QuTM_($QS#fKPxnyDNNSx7(ynbUBq?KOYGyLF zZQHidGi%${Fuk^I|H`Cl`_1a>`$qJC0@&X&EEGJ<8AoOyM+cl?a9|KH1vBI}oQ3?1 zhyW6e5osB@jnkVJXBJ$DLn#LIY(BDnRTAxUsf932V@0B3Rq8J&ler*Mm)DNe zc1t31OQMS|oExnaAcrK;c*$8wL@TG{nTCR#6ajNm(#CLf`}S?KEw%mfgs=rUy|qB- zJB&CX2UF0n4)LFs-q|-|RNa9~%je-Ts|!x)a*j$OH&n{l-sAh1brb6*jIT;n<0934 z3?zTI)OIQZYYOcGskx%L)}^HGQO>o26d%;AwJKP)WnCWb6(BgT*^#Y5Oie+jp2=BS zleDu;c-AwRT3%n3EldLag?K|A+C5J&A0=9`Q8B<68~))Qd!iusd7Bh&AuwU` yD__oZ-q&Bg`}7e(3E#iC;cbK5H{jOYJHT`^z?U<7e%sF<9B*^x_{nQ*LX-2Z&v z`Jd<9S+XR=msYoQ!7!HRANNjZPg5Vzi!*>|N$8LG-L}2=Pt6WT=u9J947=ctQ{F&>}L9#m5 z9BKmB$o|dmKP3?6d7vYqP$;ZKFm@gxGjz%^!L6ZD3;Z(S{%r!?E&vChVEF%wr6>!Y zrN7%TN36Nk0KbobhlI&PX@HI?TvwR87Vh_DOhSB+6rPk?19JR8peoIu0|J-L2 zPB#mNLh<55n$PR?{vwdjC?Sx;SN;omN?IqdsAPqHolduAffZy%Q&UsL!s@eZ z5;$_UhMm1~gIUJL0Z>Wr6sA~U7K>%i94m;a$>;N3SIhTf5f~gDmr|-M{y-p=`XFJl z*=#q2SkOcZ#KgqJW*X#`Q&=_#jEvhe(G@1Fs0-efmX?BOag);$`PUxqKB~3}DiVMj zMc_szlj*Z)N|h}zIXQV+jl7>t0=yk(LqkKAsLo=0p_!hZZe9!o@M*PLTM6@{RC?>y zt-RUU*@4AC0B&x#+Xs&wJGM4TpsTCvMJg2Ii-my4a3jvB( zS8Z+W)+hnJUcY&<5vZ)JEQk`QtE+p3ikK~30>}(*@03)i?wBpC&oa!cb|;&jnav6T zO8FBfPMp{jC2-)tfweO;Gie1es%b}GKl|yTZcf{bHu={-8nQxws_|fPaq)9vKevK5 z4K1k>n3?sl+M9o|!_?O(={nU)qr=0)?Sy%i*vREjZmmd#Krk3$EnOydD8XhW8wB94 zJv}|=33IN5owpJiqtUom?!T3^6AwL;?7ItB6oXfR06K{X#VV_>um6fy;zzB-xpU`s zP%ZE%ZRYNn2U7x{DFLiLnmKay$+EJtcX$EL5!>lJ>PQ=uw$u9Cy=f3|WI{l~WHy`i zg!P#@TLC!^nwpwE=bMY9ZgjJwJ7oe+k2hWL5yBXuc86WQd>QVOGiO^VZ_jq_-1#z@ zs9(vJ(l~IR(x!6o-&fZ$AGN~i9z7y`QuZ0xzJ2?Km>YY}=&6Q=hJ!eZk#^Dvtl)5Y zQVQa_rjB@rSV@CdSp9E+G3NP@z$pL0g9nCWu>dfw#(V5gdGa*+BNaIi&d~R76SkKC zu%@PFS)x<96~~VsFQg5=pC2p6b){LKpXpi*Qq_%r>Ts3ayFZ#1_#lc6T~4RdUs_sP z1Z>oJ<|;W_d{(E^9YxNC`4Sa3kmxiGu#>gd=Cl$x&^mvuk&W6M3>h%Zv>Bm|7Q1xm zQW-EMJzA7gQ&CY7O!PuqTU!uM&n=fhDfrCwalKf@D*3bMpo2 zRwWV$DrB`RycdvOa9|F&4P`^D{A<7!Aa>pi@zvSHl{7 zl9eRuNnYQ*d-n&_j(<(fpqAXA9X$(e`>a%e91z@(jEoGB`?X^X#^M=zb}QGh5;{vt zN}kAXCO~El2ZF%cmY<*h=B{14-YYCDd>?{){14ypG3)s=xsUzF9?>{jNO>wv00000NkvXXu0mjfHaBV* diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-mdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..60bbab8c22c25a7ce473af295e6c772ff15e890a GIT binary patch literal 1580 zcmV+{2GjXcNk&E_1^@t8MM6+kP&iB%1^@srFTe{B6^DYf4Vcxx?L7$*F#+-bA4|vn zKSgp=mk^zS@c_C3vvdtTg;J!ah;n9TW|^6pneAono-#G;oZx3>c82xbp`hMVXb9!XYsae+qP}no{62YUM(lfm?LJXJE)v08%UilnFXY_otpI% z)H%K`oxUBU%ybSUNiwau|Cyd`+s3#3cU?=8WZJUkoNL>*ZQHhO%{357M*koLFyeVT z0dUOW9LdodmfF)2#mo^Lg;R18=W}`V`#m_Nii;yw0Jw%1@Ug_6d1hWG9^bzptauL)oRyRUqA6(<6oJ5odPa?|M(nUL{sGa#ZHZ;(*Z1r2bYgvlrK z-}sHlb|fMq!*mg#9ch5qC;lih{>a6$h;BFQ0qsBo=7>)sAXfuHR5%7`TQM*LP%GL) zD1WG70-zZj+J*#-(Upf9R8Iq85>xEb9wKY}3rH&kv(DEvD4oKj^)J4`i$dQ?rF~~q z)I}8aQGF-1X-s4mAVnap5z4|tq4(|d5Ry|s+VtiazRC2TRjOvMn6SZJS@Td=Vxzje zCLy;;(sg}x^d|ApFZes$AKt0NK#_?AB-cF!;^QiBW6qL&FeH%y`A0;?)K^T@utaa% z+Cfmdg&`21LZPtCj{@HIKV8ScplxR|@u$KBqH;cGt{q7R=L~TPE~}5&0uQhnN@v>YpQLZA>B3_8;BOKTUd;!)x7| z9U!LImAfu6wDP{#rJ91^RJsm2DM^u^F%|B0dFo3%A7h5(f^YM|f~GG#jp1&E&lC}1 z8WRe;r~|Z=^OKX2WeGeTroHs@#Zr_6BOS9tf(V=6YzIipnEK*mm1zQk_TFbyNT`1{ zskt%;2seps$Di#)G>pW!jAcr{J>wY=hw{MGrhgO=qop{_MaCZ?A+BHl$YhnzK$AqJorZb@_bw1KL}v6$Qd06pKzs_|!V|vxq^#HujZ^0+Mw`=j^BllG#NDDkBBx5pYZDqacFR!v z@P7`NJhV~4Qg%Qx!IsMt0OkPl5SRwSZYX|uPYQ<6%?ANSdvisA%s6v!DD-jiR+}F6_7gyB4 zXqo*ySFr;VS zAKk+YPh2A5YH9`J)p8r$yz~CCPo?GLhhZ4{RGtTpd)j5c^2v`^q~NwpF+AfkZRe=r zZJVs^VY=w(%Miu+Xhx}i@s@fXtwLIdrmp|xmKUq< eU8`agv#i-2bIID*7?F{my6&~Lmv}W2jsXC~v<0L9 literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png deleted file mode 100644 index ee0af4db2a8f29e73555acccc84dcd8b8a8d6fd1..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 1538 zcmV+d2L1VoP)n1(5kXwB<>oHRuE-9%%CZ+u|Nn6|*#xb-%vosJ?@PYShJ-np?|0_R z%$YOiBqc!*1VIo4LD2t4LWpE?3amPLDT7xFMHO6zT&RH65x>`vUn6fJ8xqu8v<%if zE+grLwe1FS5!p(1k`=OB+a{93$L!Ry;7m3|W+lDm(>8-_Ba8X@c@YYQV9lHd+2-+h zc1S%F!ZZ~{d4USJsRch53Ple5+ z)-Nz;;`jM{Vt9D?pcDY%+bfgFw3c?>%Y+#>!hXEN*-T7K^zYrfcU@$Lx@y18W;4)^ z?`egE5cU%Rgt(QMoSdAhs;bJ+eHLi{yvky+?4SZX01zDjm&@fXD=T}Cvy%ZJxwWfE1(AmbHry zbDg5>j!2Ub_JR%LSmA?uJGjcL89z0Mxa1N_`#xfEZlS zd``Hg72Jj{C4kS5HK@-FEuB5m!v}x}pJ=m)p>f9o4^w;3_x*GX=nIM`fVQ4KbjASy z0000000000000000000000000000000000006+`}B<@WsV(gAn9Ztw2;CLWbAfi7X z0OH_#Zbex7Zir*X^WxjzPN_o)zo>KhfX2uMep&R{%hweRFf-&wiUa zV(n?K7kM1$;o8#xAj_Ty4@GlFm)KuXuY0Ty0L18rvp&((en}MR3?2X$d7*8;+#mJO zasZGFV4Uu3zW%jITKE7EqZ`g&kQzP!#9$sA#kZLNuuK33hsV_~RRCa_09?FglO8?* z#PWGj&$YM!_`^B?0MVGgPKgJAp21rH5RETWP_o14C+VRv2LME4?&0K4Hkkkp)fnS4 z^n67EcmnJpC1J=U5yS1bm8o~HKNw! zSi&zq)hb3OCgb+qS3!+UM3mn_g3;7s{{>Ys^IHb6*yvM zW=|EA|XC{#0@b-h(!siP^s{heTRwkoE{M}uz+>k;|;INihi(s z_Wg?s^oWa|(1ard85oFAlSfk)(sOu36D)bGu`lU|{R;5FXKiXy%cVzLEEV5L-|Sx% z;tKnoht?8@X)4FFrY8NWf;6eU-6#r8)7+yLB|ECppD2v)22NQfx<>`b1`B(LWaydc zU8Mt5ja4Xvk2K$HIzX4$g);a|3;=qbQr{2O`N4I6F#`aIRsDZR`0Ban zZ7L%uC<*-pB`I6L!D!=&S0Kfptdw9Kk19^hJl=QpT?k(MX&d_=A8;Q8N~1geVhN z4L2S@#i3v(O~@c`h;r!5q$Yh}t@cf1_BMsH&UHUO2N8|M8DPMK5Er{n3;=z9Sngo* zE3x$|TD0Dbfgo$t$diUU52)E91%KJ;U5|ljzJA_OF|f_w&jL<&=CP?@)(c4!T&%k2 zJfvh*bGIv)bt}3E(PZ{MA>x!g`oOB44tkp;{DVVEfPbv=^K&jEJ2o6WdQ?nRdB`0^ zW{d}hX{zKIqBQ*Rc9ORpykEz@d}ji8-Y&~*@ML>unEMqr&!Kl=I&IyjmpD{SB=g$4n$vM6udpiEiPpa zp`1t#^2jzxCz14=6WrT2t@Gf{^<*%K!lbo~-NiaP09cy&8Cakcn>9G|>zu7;7d; z)!M`b(HIp1s0a})3gQX~s3<1vg2=wS$G$w^^!wd@*Ia?edLQpTd}rpHx7~N|xxeq6 z-?yCQKJtjI*h)`D2w_K}W3lf+=C4zL`$1Dt+y zCK*dIiNu@4hsB$}!*9+#D8zwZZ9PQMLhU+y??^I(6qiM%_y8*W%!}v=~Lw_gUhz zL*c@2Z*Z?=>%n6pE+vilyk#ItXd|O6ii?Z=%-RcNYfs?6r10}C?(MWt3qF4>P51|f z_6dAhKHe81PF+qBP0h{y_Kw&dh}_)VRc7&wWCK%IS2vC>c2`S&eqK>&pId%M`RCV1 zMP`2CK)*n`HV<^jbBz^A^xUSwostX8EL$OfgVs%jK41$O;bfc!^;!Qe?Jt>V_-HdGa`>)2^2mQW!ckp>c|7wFHQ zKR*K)%E`O_aK9b-xZAYa`qfgvNBh2&0y8y7*SHt#;$ASUUxt?~`ebVP6?Kjx(5PlqF2x2F6&00a3!Va&$;rtMedhkg#>S0`h*u0X1$-PBA`V8yib_L`>h%I_ z2+nTbzWqs`Y=NB1Al@25C%Uag&u1VC*hF5_r{r=TJ{>2n-p*9D<#+Um@LE(`;@?n2#4fJCL3b;>0mo8m;6WCJYcI?Uf(bd=2|Hm3yzZFoBYJ^HmW1xP7s7~-2ZZyqL$9XiDWiV)e6JfLWs0@Po7 zfK)fx*Equ1xl>w#KiIwU$}1z9nwoAXa~U=TsFxQaPbh8CqD9CH**)M1$=T1nd-skb z7c|$V0QD6BLtIc$kiT~A+9x|@M`Z;ZKYrYcjxDhk7u1RrAbCQ?#l>ZtH*cP4N&&hv zXU@!^gY`BAm`4GnrKN_QJ9qxHlP4rA;MA#8v*-^sHU*e17^@`(EHtG6`^d=1*)|23 zR{=YA?0A+_)14I%78d4XqagEA(AKS6=bKc(o;`b}8;wSr5jK+oii(QL)~{bb+oS^C zd+)so6bIa~q#J4^kFBt*Qe4l-6%iK_rTP5(BF;*$kr%Ftgj*TnZb_MFZfUVPH}uY( zJNXoAO$F8u%oXB5Oy}d{<3^FNMFKI*@y{(R5n(Zx#lM0gx}9?P+2L?$GVQJGe5;9s z@tT#Dl{R_uWOrcQF%Xl+fk_@S7z}3yI~CE;)Fe(_PL`$zsx{M1i>Zj4H*X>pfk;^A z=jZo8D#DJwK$jRnhGPFbIiok170Z2!;*VWYtinB0b*SX698UAwk#fukTQ*54OHczOj zxkVg{y`p-{*Xn?bUauGX_wWA`Fed(YG&LgTxPANfdE|;#n%fnDYkq$IZgb9xxR#zHtxkLx6o6%{jg5^M55{PiD`VVsiHeHCk0Z{WJ^QA@4ybO3 zF&*5T;A?GSR%K#x85bVEbKU31aU7e^u<76nNBpP89i zxP19?q@kVI-8WrUtXP3wFg7nQFG#s3f|{@QFQg&3 zNTb8U!&fWTOsPggWmN`gMWVSCa3dpE6ZBJFUM{w6+x9jv?Uw&L(2(=Qi4(nuyKbwU z7aA-DWZo^*ME)l=H8tajC!WA!J?9?jM=o!@^%ka1jJK5(=s!h_Wt#33t1tGXA zuBftf>u)m{3?ei%bO*3xOnX8Ho`c6O#9*xG% zTi`|>(aeH^f^;?Gg0!4`)i&Dral?&JY3D3U54;dm`0e^3Jw6q4;lA~T|ppjI76=SB#u32^~SFVH)>po}BoCSG# zdAS@Ls6s{9CLlQaLjO0~pdN(n2FeGx*Uvz}2Nrm}cI{f8udnYSVD8cVNJj~zSqDJ;F(8+qI*DitT<5_;SLam#_D(%ztVOUkvf5L*NW z25tj3jFDOPleQyXb8>NU!Mv8IVq;^25o|yQ2))(1DNMLRz6e8>Cod+6Ly8So(MxV* z-jQsFJTc4C=?3Kb!@|Oj0ux}vOHs}Cqz&EFsZ$^0p*7$5`1ojy_QL?FsRS$7wip|> zwqoVu>C>lU35!|4gt0OE6E^J0&`9>c!NFlpQc}{Dhe-)C_~`vHF)@jR!8~BWm<-C3 zHKf&)bLY+-L$8yaot@_;BqYSkbTmYt9iZg=HeU04)yc`}8DPLz3^OP7bfpB@C{H1o z8y6RMMmEf-s2UsKX$W}bW_;r0$&;WROf#RNFuH^Oge6?iapugKc)}2#LN_}qDk`M3 zv{d$vj6*>QvY*duypZ3A!NryXo_z0)mH__&C6MtM!#%+3Il* ziHV7K)~#EIAj=2$hW@nq7c-Q3)s zJ$Ufo&a||&5_n4dbh>uVpIgL6$Q+M4pf1u=JPiiS;VcOZ4Ez#xM%{4_xEI{hU<6sJ zQW6#;czSw1$-TjM>C&akgM)+jlOl@I$}rZp9r&(n1G=&cIF-F6P?HaVFx%VP`-P1gH?E^V=g^fa zS5h#mproWk8sJ82#TqLfc@m}yk5Nco7j~!urcTwl!gbU=GK7n)2xs`yBooNDmV3oa07J51*|KFTDV|sx5D@VBi4!NjJx3)eDd{?K zb#{7sdSOmZPHBFAegy>YNnwTf4adaA#hr_YhzKTX`TTy3K*sa7H*Tl8i zr+0oBfH!3$q%{fPTo^sfNyWeh^6L!NEM#4gbp&0|@6XMjKmX@1zWCxwD!+i>J-(yg zmf{#bb`Ep;Og`6CoEz6*ejmZtwjXL#(;r3Xm~#P$3$;#HENF)B4SL`n;>DEZ&45B? zDC8;r4aa!lKlm@7!yV^jig}c;rF+ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..1604c864b8f1481afbd1f647781f7cc6ee7def7e GIT binary patch literal 3328 zcmV+b4gc~|Nk&Ha3;+OEMM6+kP&iEM3;+NxU%(d-^@f7BZ6t?3?Cl;15itSoL!r`9 zIMMm8Z}9KjJfQ?_{~Ag8UHXK(ySux)bK4Vo;^ywIw{>@S!`?b9Gkyy;-j9N%_abvN-z_%lbE;5d4 zAQC;fM7kK|jNE9j7|g6*T{Jpq6wxCZs@yw4BosMJx*3h>qLDBL^q5A!9{fL|A?s)- zL^7RJFsjEiSrpSXYLOX;o#Y!FW+P$9b4Gi)nrw1=Y0P{F?O}554Ur_>$uwP)Rk}uB zQV?yqYH)k}qg^za)uk7u8G8XkLzZT94;l3tv=6AOroY83v1Sefjt|lv1BSz-+l<}J6`}@#=&mU>p-h>l zQQ#P5nhqI;Q=wzflnSsK&w8QBe#xd2W+X5w?~aB#(9@9GOOEDDpfMH7H7yc#zc?)< zMhlCOap9i?Nup%b&w&ml*$)rx;OemwiyPR6;cMkP-VSf>sR>;br8<<0_{2HyTkJMH zF8ij?XUb=ADg!0L#D{2F-KiFm3aBbzzGqCP)>+ZAO{)YwAWBB2fDCzNhrNUpxq1{5cb!bbuA^WhTo!HF zaexD!$q62BzlTXYqWe3H->pEJ{!n(@j&%bqij?~*3$P+&-0oOCEK&E%wJaJEvDTcY zuf-8i2>bg*M}vk5j>m|(0;yG|75Nk8&>rp8Ct{<2ILqZ2oD9RE)YsbET~k=sJOY%| zZZ6)9#$mzYt;S0}=Gt=(ZlB7KuVP1skP=r*p1pt^Kczd~v09BRNVMf#1S&owYXzsx z3=$@oYPNqY`Ha+7a8a&*Re=H_;|aQ-`ru54eo^{Pp1&MX5)ZcPz68xL+q5chjP8p| zhQV}AVh!wrh8He>1%#@K`xmLXdLAAkbK19zPG%ym7_nN&@86LsrYchSKv+Rzv8IhB zG+3Juov@vh$^KIkDkc6<9mx@3_&|@IIM2C=E85y@d&O@NDT}+5KaVBcG>+{E7u_{& zQGWrBH!VD*$e(iW&izmm_D&ELEQDZZY1d{=E);eR=TdvFOABvhI9XXoh!9xw?rI*+ znH(WEPhgrm_PFd8IBJe#2qURGsM}@i=|b4w&p0dys49@H?$D$*_Gmon22k`ILwbzK-?i)i%OE(wfT$D&R>YOQ-<+YMk(xunoX@7J3s@yuUWIA z5sNix-P=Wgd4!{{D_UvRUDV4IaEKe%8?7|&E9x%*hy&cCm%;%+2&n5Ey`BAigp{_+ zXpUBHZr#>h$Tk)os-m^fH8lln5~C4^nRC!>vm?M#PeWMOU6YoOuN&GG`O~V+zYtXu zr0Kt?-1N7YOw%spk+MQ-cx1U63%X^*=|S4VJ=E*c`Tv17cfT{6%q9Wp%oHwr6OS^QzXb^~V%Hoc4gT!Yf#x`)s$v57|0Kh+C z2Gv}QiX9zA@j&r#SsGMWGNd+FaNT^jkatFhX5rISQ!JJf6cdZx*tL1+yMmPp98k4N zlR|6G^V#cCHZ>J3BdS9=2wA3$9DBn?yC4V5`@Mt#73KkT-3=O&3SY^7)Af$B0^tqC zg=gEtQBS)3i~SP7JklF+Y)^TcA8=_m{ zX>F$nWat-|H2He+gtq4#&~q+tSjFp`Ur?ykcwlnvGY$A$ecNO{M^VVrH{Slf_JRZQ zU&kBnIQl&>AEJoexFByhR{@Tu{M;xK-SG}Dt$HZW|#`Dof${6s%@BFR>&`V$5x^`Ih z#=KxS1;k)>*7Xc<#ovtfXpgsJJYVQ-31Hm~$@5h44#aWWCMsDVm%hCfG<@)69|wc! zHlWrGN^cRHo`%GKBDGYe#n3kdHxemXLtO z@n<@K*ti}VczGTx2w%oaJS~?)lN^0x$j~plO;5gMbc4Y5)1byxu*i2XxC(HAp33Eh zV)3z85h%&h4<3r*sWBBI5E%rbufcUyl->q6K@jd}XO&Ik_2P#Z0%vUrn3rAI8kj3! z!Sh_f^V12uW%ak1AprP5PrDiR-!yzRse_>efKPuunuj5A))InxUf*jg717J|=1c&v%suaONk_?sy>#X5>bU|3EGu>}v;bd{ z0;_%Jw=jqceoOgz7)_Vd`WN!-MTUGSuqU;_h0$y&$Fk_DNd*72s}0*Odt^xp2$Uk= z%xwZT^-rb&<8FLHRIy|lc2P1a+q8NyK?JC#Q2u<6xlQ_^B%CP)W2nL5=;y6q1n>p{ zp9q@$GbvE_P0k&Y3mudub4DqcgX<|jI{}6PcfX)D47eQthyDe(;s2T_Rf)N%M?4-$ zfMA-CB?1TmERXjib5VjMi9>nwsX((FP~rhY1TZrMctUwN^EL#qmjU`$zvMX>#?o&f zXuP-5herG)!?$40Sq zxS+ZG)dz2Tf6x#U<&)E|*Ys9d;JQ?N&3hL%4fu!ghGe$n0VO;6Ia5+#RE_&fBxYv4 z0Fq#7n3ftcscIT6*yk@2SN_6j1vh~~+3l|7W-CEARg`Ska&Kp>Ei{4G%UxV3AwM^n zGenRkuag&_n3-9Zo9Xe}X^p_UIJ#VL@wj_@S5DnwUD-2@UL5o9jI0aGFKxK0<+}Da zvoCA-`?X{LHGW?1%#LeUPJR4`&S3wH-uDd&9_HNv55zeyOr`Gu4xRHo9v<&+rwUsz zcKOt`#(miLMc?cX^cTylC0ePL2n^09=25td`-Jr*B_npQo7<+Pe-K%bQh4mr<1 z|9nnv)}PPlKfKc~{#c$V@}eO0?l^(; KVVaJ7F$4e+-)o-$ literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png deleted file mode 100644 index baff0e40b40bdf68c0f48580395651282c796a05..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 2440 zcmb_edpOhW8~<+6YK|o(hZ%BCD6c7}z1|UOScD;kh2Atd6b-X-%bHJ5|9eb`&AIc=^yAfdti30h574}_jSIGYL$C&2VMB;jm)de3I#Wv;9gBQ*m8`R ze;A25kl_{26Ww0jFwhbHKcq(*$kE4*$!LopfoDHmf~ZN@YbJOLxkBe5Q)@1HV!v5l4z>RlEQ|s z=ho(+7Db&bR&YHGzAp_sC>0lR#PdpI6PU!x+S-m~ zc8-Q#2g1k zMel-%Fz7c`=T5VcjwmhQ@X<~Cxux|o27_U{e4JWeZ{*D6x`Yym+q!S#MXiy~{gZgd znfWXNf#4CbI*n~JW#bSBU85$arY=g#>AjOvQjaot=ZZ1^V*JR?-p-CyIpm0TF081~ zqC|h2zPZ6{6a}a1tF%c-_w@9nrsw5t7|%|mshmFQ2BHlFyg+Yuh>-4ywT}gB4h{}Z z2+jf>h`WcF+^Yai9O+!SA~Dq0+1cr5u*=E8Vcs1$4uONlQ-D|*r~??t5f?ZmAQq4W zBLN|#065@$y+5MS3XIUwAIGIB&DQG8)x>}#tz&6j#JJDD%B5N#>TW+apH$O1b)p>H&G{&Gv1E_rfa2W`j_sq)nTtu`{$kK4{eyx{?bidCK zBpK9G8yF8|v3uS$wmfN*+Eaarjpo>3HUZpCWVZV&R4e(As6AXUU!&+v8U{Fi0!}$E zF8vUD-Q;%U1uU~m2>u*Aso2y259uyScU;y%4~6YY#e&#mF6WINj~BVj=8V<{iI6&> zWwxU99511_&(FW~k@-8PEx5UYqUJA`)ANMj0913cWVGs)l;G}#am^Q7p6CJ4TWjo+ z*3Df=H6T>4Ix!%dni}Z4n*{h%@*9OHA4SYRj%GmKw*daz3A$Qx)O0_B?CI|y>rVam zwiDzXN_4f_p;(S93aF*l$AHU3DwG zE(U*(t7=KwRbVOFuKl%k<>W;8tr9}W+`4BgD@ENXq6S1ds8Ueq?h`uck^C$C_7jGf z2%w{kG#Q^iyR@)Cdp3*}C7s=qxL|N&)ij6=zyX3kbyYNW`G5idh6fKfR*5&ceFMSo z1G$R?cAn9dWC-ajxD4BA>NZCQ+K-TiV7gN3o(Bz5BGWl;DVlDI^7c? z*u0L6Ecz;)_xjY;?RQB6aR-UR{Dk+mpgklD`2vMjuUj&+jld%YfNb0C zRIFVfHF6%5iUF3 z7esa7u(V|UPOG27q_D<@`DFuQ^nTL4O?2oV?l18qJL1J_1I$doIzB&qb3${b`Rpi< zQPmAXT0uq+@ha78cfa)m_$!T$0JK%BcuXmIt@&u-Dk!_Kx?Z^8WTnGQo%pE-PsHF& z$RJ;q^iu#gAM$v;-2`Dc6uy1oCS`W$Rp8I7+aSJD!`y0_OJTf-MD)0U!71OQJS(85 zsLA7Ds3UKkEJD2IiocsIkHF{rpsE1Nv0UuCv8;q)lvh&?G%@mZ=rXJR$~@51E&tRQ zlF;9$^V88$gY!1pIXr8UdLx}Peu5j70`UEOKOV*j2 z@6yL$+RIvU&YvGL5MAH`$rC5X3jYFSC$-+3NJ^^UC!Cq=7W~ulk_eI&egrcof^#nzp)I&!OM#`1x^7Y+aS0)x1Hj{@CCJ?P2ctP^=hbzBY+D)Zf$K*qBd5y zC=v9qInOf#pqYUhm8_9~sZUj!X8GNkpsvT7@yw%Z)RL0RM@&sklOyPtnOXH3Vp>*> zy!{KgCi?mqfq)up^h)|zE_cnX5X$Lv`tQq&w+8!U(7?dE*etRE|8i-P9*$e1L_n3t87`i8SB&g%y0 za;n_3Uo+T3sfm}Despz{Ilm=HC=xlm>t%9niJRoF9dAx>(h8%HuqtrL18H`&0 zy@?4`#r^&L>~AZUHKI);gM&7(l+zbvPnr}j6sPbNPwG`h|6TXm8`VuCMrobg;-9AhF9IoZ!?wBv#{@a(6nHU4Fu(mt)=B*>53w?P$UWio@! z(L3h#ezdR-TDCq$*16VW(RKF2=;-K3A_wgq^D!hi*i2hjcc=Wti<`A|b?h&Fec$IM zC(lXF^l`Z}fq{W*NfO3IhIupUHIt)>u!S(QJY2W)2bf!}N7Nk`C0J>`luc&qfED@O zVs87CoMAw?&&KLZG`H?5C(pa(bZ9&4k@3jTi)&v-K2a}h#Pd6RXT{d6SGCTs9`l@H ztA|S`Wik~5t!*PCBR?leYa6PmsWmARqb*LZ< z49``Y54)%t@y2vLbGr9L+8uQn43KU)>yq7`jLxvJFhGBu+kQH2**J|3aA;Phg5?gX tNRd$}@K&O$kv%w$FZZwcWwLxSI+hlYr%3RV6--!wjinumYH{h#e*mcqbv*z8 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher.webp new file mode 100644 index 0000000000000000000000000000000000000000..cf5d462bea084c675fd8e893d69410ce89435648 GIT binary patch literal 1976 zcmV;p2S@l)Nk&Gn2LJ$9MM6+kP&iDa2LJ#skH8}k6^DYhZJ2~V@OB44L`(qV7Un(5 zMjs~OtepM-49NL6v2Euaq`EGzz_!z;vQygLI_NKzZQHKx?C!KX`|QM+E_T{H_X1*` zuZnHkHqR*c(z>=a@kCXs+biSRMjZjiwzXr=Z`-zQTSJIlrLsogRLNYjQl@ulJH7LL z-+w1S&bDo)?fiJ#wyoHH*dMz1^_^|nqsTAZQHhO8~vXE+KF03M-0T2`Cms!(Fnx) zN{RS&(+DZqL@7l)WJY#mlkrdAE~Augl8TFT6BA|5Rd%{#jF0 z>(EVDM$jT`@j0`YO3;&2>t2QD(-Al}0lnD-lTRfoBI=>o6yJyAD=>w7Y#~|XEl_SB zSecj@MsR^xbjK8AQ5aQz3aREJB!?KP1>E8rnQIq7GH;QT5->Qli$TE59 z9V1^%LpcYTt^ov`x7&cli~sL|3yhS5;z0@&0!|6i2jrEC-2t~!WQYi?;L%tP4K zC~@iy_SxKD8EeX)aIuBX_Y-G%XrjhRZ@ulR4D|4;3iMM3{{dCNpEvBJyh8M${>^Tl^xStBXA3Qmlia$o^6Ro#ZJxyDom?5)FuR z;>Hu8+yP$~j3Q-cFMq+5%5XPmAx|FYl1z6U02EyqQ+h9rOGvTv@cnWJ0(2_55tiJV zBbqTgIl#5+0#L*$!u67Nt{iXWX&_05HXOMPz-%|AFO{?7r^T-VneV#2`Tycl8xExD z5X`EfMGDgZkajSu!5^#^2w(I5Bqdj(!VLnc(!s}Qp@sKyvjKuayCr4lkMm3w{!Y-t zuIlhd!hDc69mb$V4pIOBrFQ%-%A#e>qsECD+vNo(UZ1!y#M@}2r5P*%K%QnW&tJs3 zE_U-b>)&GL(Snl@)Uqo808{288Iy<35&$ZkP_t&uK6VB~R;fXsRJ|356G|I<#4r3H z8kE34=a)K;$@P|lc;H+Iy(ev4eP)BG&?hbhXC9>6Swlg`rp^`*otGwQ+=|0r-PN9- z*;ttQp?xe0W*45hFm6!;d2z|lo{56Ay;&c>aBRlKErROsn~RHU+USFei(MT#b9UX| zv?=FXag1ENBCv`vWe)VUr`ok-*6ldg!S2^X*cC@UE!GpIt`sb=4-hF!Gk{M4kA4> zr?-cIQgDh)+t~!5%z;Hu<67u*QDE&t*`<;j0n~~drpSy2vfJPMk+3A3m;VpKl#%Q& zWjhjv!I(NO zKAfy^_5qs8RKS*hZ0 z$dw5^f|UwriYZeRiwUThGNn0+BH>mamu3+C#XxmGc6I86V4T@JFV)Z(MvR2bP#sxb zqbxn$56X|aK*Pvyz5|^jAB3IelWb=`&G%8}r!rZQknxqsazFV$lur}xI4!7~-G2vc zj)Up~7i3^Gg^-n)m4v5;@1DQ|eg|ZHfB|2uE@vTI z5r|lc5%B+nSv^NvepKanC|CUm5>SH)?AE-t{SHDh|D`q0$}1*|KB{txo2vo`1dFs$AVaPB$Z{@uO*dw=Eh z>7yLVp&ZJg9Lk{_rg|{OEO@h|X4Op-f6qecIe9_?2&}1D^#lYur{?(-xXcU~!LVBV zt|M=5yp7;(9B)&2dz#u5`q>Ej9mnn*T;^#PYVCJ{#9ysla4>;0WPK<7WshFY~sEx9j9FIs>JhA8KlC)WoBs znXnsi)i|Y=Y zS-c(MtzN^^sOj6!jJ&lIOwv$SPxmxS0jYPvk%p>2@^(SP+lZcqnP?L|lk~Q5kN@0N zKPZQGI!CGvX7F~p6L30A7@W?+oF+)P=b0K>Ptix+z#|36i`=?Y-l)w8ny#8!LnxsS zygKV6<>Jtma8+D>}B=2WZ&BR$Y@MV(p&&tmFP<91DdOwo4D;i!$fu?JHBz6`0Y-DGp4znME z5Z|}8wf#H)ceTXpbSRK?AJ!z^sJf-4<FSYK&2cY7O0U#T*Alp;0byy`w95 z#J1)@5X&wo(s?LryB*LsVO^;392>x$2(0;!?Kp$O)YTgm&mmBx~ea zMVI#j8`am>Bb2QtJ!EZA(cIYB_^i3=9<<2ox7Z3jz$1jReSxf`qDtq3udr1`MFmSp zNO%D@ynOkxwV^_nC3lNg&3R7G?$Gmkfk$ky$r<{n4+){Hu(0q3x1o*RLzx)5_(d!+ z*U%-l-b&fEZKqTTdbuyythTn6rKP2Pt=CXSl}wAGqN3rPaBJ19atPs$OR^!iHsKW$j!@>t0Zki-Y$GmCIy!eZsSnhf9pJ>GS@? zVUEnq%(JKs=`4(i-lwXnYK}M|UJlbD^a;@6v%j*Y<`%u*BWzh(TFSWh`IFAPki`3h zQ|M|_0P4j`^ub`3o||v5)gACc85tQDb>f9Y?^9l0KHZ#)hK2%Y<=$h$a7kO6;Z}CQ zrp3j@?A*C?b9&tiiJ4bxYwK<~c^`cMw0d74yLdH`H8eFF^gb~F-q6s%l9Q8rHzO14 zK3rsZ$f53|FMv>A`r+^irbOy&WdDVON?D2uk0|NuCdaLSeSzllOxg7WE z(FTU^4+ODI{$bs=ACHE!T_NY0|M@E{>SmfSz15gz9q^-#CrK^m3dqJ+-?AU~1|cbUxzVlQ4$queWdCzQt|cm#WhiJ=Q*DWo5I) z-B9JW99bKH&cs|dn`P3lb4f`FJAL}}T-d%xVwzl-Rbyl02DxrECo_PAFovnG2?+^1 zVf!ZzV`BK)wryK~e&RH_iD$V2By6+K_0XL=cQQZt-~;C;4r5~YDlRUbBMDzj0m$qD zVq#4wEG+C_o;ZxrA6fJ4>w7thXE_4|se-vev9YmR!N6k!B+0&PTU%SB6@cUi5N0f< zrluxv2J9a@jEUo3iHV7$`N?YJn0?6^ATb-ed-rY~*T8;Y;jxN}V)n(;x&I*jBin80 zVGQYfOiawrU_omggjhF! z{(QJAyXNNR2n8T{0tC$q9mHi!40I5f&`Jl1`e(z24KAFUe9}Kk2$LT`!r*FFR#wr% zg$q4tgx*ry@MV>poQ%g^x5`;S%NZc2L z+uhwATLoJaBVFnrEBYmOwOU;u_ZpBA!u~e5BQrCz=$&`o8A)tdcL$Ko_U+rfIep~{ zKng$rC^t8^^0UuAn*=tr0m#0+0fb z!~?Zz1rWI>yMqT0`f_+06@V0g08n0DUei}!eT5Ka_wmOc+jI-i&Ye3a@DtQ3S(s7> zqAaZL^Upt@rZqsTR;?P%|Mu?LeI0r%pBnn zOmg=?_!1^0BqVYsT#1cGPHVN`E(*UisIs#1gsDyEz(!43dH2{p?rm1@JKn>c!;WkH zh$nNN&VjuXfBp4WOy_VWHXaQS7fJp2>%ijT;_apcP(;G*1ru5Uw0QAiWMPiGckiCbPh`{ss_;B` z60#xTG52LXIXOAaUwrY!%V6T&ci-*XEkKm#JItIpa|9PqzZq*LR@czT_Jm#3+r!{w z2#}D%CnY5n`uh5!2ITO}Gtby`2M`V`H#av2{_0j$Rdta7GAbdBXSbi%!*fjK43H!P z!_wxbh7KLtPkhd5cZcQt`SbhpSC`*@`)#u^W@4x$>-#})a@(B)85p+w4G9U^2NsA4 ztsa43`Pyr*fi~yJ$jHAKn~+A8_~?Zwqk+eaw|D6%EG%SOwru$fED#e~0c0_6-aH8D z0n3&xo2pi;D~xGO3_}g57ApYhehe%vEvmDzO)w555ky_j}LUvK;FD~7)v$=FLWa#+vwmi$Bo9ZGnq=Iy3K8m zr#TNK79MM}ap3_gvuDp91Ra!`n!3pt0E&oDRshmd_&R*}@Lt${#*7)*t={@^0g~#V zA)7aEp2r2O!B=U7M8+xr=^=b&W@fhX4E)cq{gddQ2RaBd&>S5d2P2G?mX;cPg4xlF zDx(DGTK|zsvmqDe5g8fz71C%!?ip>Y07WJwo6X+P zC?0O#zI`ohK7IOh0A%wd;p;&dn>cYI6!;LX#QmzPtBVa;RK$bbj1iz~saa+rp7ENH zkWlvAbI&~sn~xnk*6~Th*F#}!>eQ*&8)igOQqo>{p>_j3H{()#l2HPbm4DAH#=h>~ zzmM9_G1%JI*LO%y!q-FD7&e^ZuS385^2@n(b#*O<8Xrr{%r!cAFsyRFs>UqDb8>QW z>xUnH_$F-a?d?6_Nwcrtg)!`zICA7jH~#9Kk&zK*C@)l2S8i%X|_O6GK}5wvtn?UI8JC`%gY-K;f3-_%GkO? zAw~hvq0^CO;eBr2yxF*D(W19tTMrKpcpuxIis!Dpki(cUV?02rN~PM@*x0CdF9d`6 zpTA;oc)s5s#8PweOy2w85#I+79z^EFb0r@hUkdcZ&f&pKNK7X&gQ8)3R#q1K>8GFm54Iy4I+0B+`{>~iy^yev(0~C0 zum%2v*x1-h*vY{V6>97{hCO|B8MXjVQ*(=1jCTbG2diK+vYj*8QZL@;cY@l*#Rcwf z)a1#NXJlk#6kO(kpC#VOAp=`y974PxXzTFUd2o3ujV!(B4;F%tenSHnaI6HLavao@JiEEw* z>%5YZlG>LpUHV_J71urRJ_Gb2oVyEU7<3!#>FMbMw|L~pksq)n2t8}nAk$wFYLc>Y zg@PltxO*IM)*K9GL6I>mr?A-6tIX1o_Kw@PZ)26|Pp}c$YOrL$)S{1pjt8L(6Of#o zoN!^2&YU?DSXx?Yuz5pbYvkdy=zb-;nv%(mT~x83Ph1ezmHl`$T-X){`wHy{J}2y@ z7=GoNuXsTTL}|-MxF49XN2{By2-A8e+)M)m12S?ANc~P+AHynaA48nB9*X zFs3~etLy4nX=SwlRz-C!YiMdV3s2J(JctH?fq~K7CfMhBw7tDOhN2t|8M-?2LY7nl zAIOgph_Sr+#l*ziK*0)yD|0lGu@?)Gah*7E;ug0B9@R9KY%@?YTw&42NDiqUa&~ib z^G220$H!;J)vH&t@aSj-BK^WsSy_qAF>}1Vy=TJ~WD{p&&^_JhAyhP7NT`jUHEY%! z4pFY?i4-I22cC21&gD&;HtmnF0k;KYg$von+8E(^X!arrG*>Y*14Pl$(V51kiOf;+ zl$Dji^JEh|V#ei4HnA~gcr=GPgyld)|AbMaM$Ni(>Cz3%KOu-Z6vOvks*XOzny%pB z;N+1bNB)7}8BG~ie|-*B^d>}5`)(c{9%K0HL>n8Mr%#_g9hsk>FVu-pDpGV&&%6#5 z2|IFgb6G$@Kosxee?VVTpSzI_Y>Z7bYnz$b(haz~xVYekCh!~d<7wq_%tfJ(_5WpP}#Qi#Uw>w4XV`(^;_*)llqy zHq_D4am=7WgC@fbzwyQ!{~8t+7M+=y$q?U3B+3#b5ghaZJTtrwPd;K_ef8Bh(O2j* z^d0(;`qG~I*0PV8AJQ3(P9*re+}zyYh5^#lb?essZ&XxN0n&XjQ$t_M0VKM|4l%&Pxn@=Sh13_tjW|@UetF^hL}-kED(v}8t&JVf1}1aI5-I2h{yFm95`^`m-zVj z+T!A3aR)86xrR20$5A7OBeA%8^=j>|UAvBWdU`&OK0)8`J{pI z#Ot_IU)h_x-g#VaWJk3NWD=fsc6MWi3>o5!bRV6#aN)wY0|ElhaG~7*w+>wZVQGrt zO&X?SFfY3HyS|keK=e;??e>I zrl3MGl^Plv z7`df(NtiPTP#VCriO^tW1iVdRVqycXef8?q-?_TF&K5lm?h*G&;J8yCILKM+JgGOb zCRg2`CPHEPkQaF*F|_&d_RL#vy|rNL)~&nFo;{nKl$6wj>}zWL_cxOS@N2%(BRju+jlGu^wLBt2K)=>?Dkj|5%7 zh2>NaJ3s#T!YZ`CeBAinSH zXCjB<-{M^2NdXgpf#AP$=MKX)oH%hJ8rQ^i+<)?GoJQA*tZEAGfi#ZjaYS(XD&X`! zNPVeu2hlti2upA7@+aUMY-?*PdL=}D6&y|irEvd?zx*s(h(C#qu_V3fD(U;-M!#la&mI8?y3f#fzQHcE?v6xzb8(d_%eAKT$3NJ z5!Z@q#n8Urs-09P&zdC#N?AOrn z9zVnX5g^kME+v2e02UI$zVw-sa1FQ?x+WVM ziBne+svhJKMv$?gMMlB+@E8Dr+5~Fj368M@$SC@ok#vq>bWV5r3}^aGN4f?D8WV-d zii~DM;?$mMD*Z`6iC_(;(Bevh){S70HaGe|SNh$-5>TAzoDTFEcJ!In8sSNS#>9b= zLM4J_O9t;t#Nf2kG> RCrAJQ002ovPDHLkV1nmzyGH;3 diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..c3ddc64c7debba85a5cb66542fe0ee6ecef70ba1 GIT binary patch literal 5204 zcmV-a6szk}Nk&FY6aWBMMM6+kP&iCK6aWA(kH8}k6$gX1jU+YvVfWeHI|30g0s14p zfM(*tD3VMwA(s32L=JTI2bz(2I|YtzT4MlUaqH&mHkX+h$IQ&k%*@Qp%*@R2+NR9R z+{fj|%*^j4wm<3Y`_8^Q7yjLg6qU)jF_h7oJhrmzWqQTClp)C|ds?blDn-?U!70)y z<<@?K43jZflckdN^i_CsA!WD>ODS{fsbocIy&n!E$u_OdKdWbcD*)T3+uF8GThn~+ z+mRjFzXY4vk3|8JWYe}4_t>^=pQr2lY}>YN`+eKC&1c)TuI+=3mMGh9+%^~D0DO2v zwpLP?FOcK^|DidTw{6?DZQHhO@6Pj|-~4BCfOPf^Ql?r}VV8aVV;sPF>KK=>RVJO? z{V$MBX_vV`URyd40KjPW|2JjZn73@>8m=4(09dxV*43PC+qP}nM%sTzZW}og-92;L zyq#ab&GZgP4ulbaFi8;z*e!gX7s7ISDZg^N;d2StEwShHl0OBA7@~z(AwEbHlK9pl z=O(N_J9(bhX;Ce^*SH5!LpnhwK(;_WfgFHbfjoiKLsAd|LP3CIcRO(Le@4dr=d5q9 zdbV-PYquaJa4uvLRqNEJ4O9#91);`@+Okhc#xH((h;z)ARKhxCTL47qTi(OEr| z4kAGZY1KigI%ra;s@0m1pc)-La4zA9**T7L2H5U*Lb^fTggh|x^pFWw)vW_2hFi5J zSY?dyk=sMMI@xSDd^r#+WF6!>##j%nhp@Uuv%_uGLF_JL9J4XR%5iotTPsLD`sn@QAhtO$hR`mNu}Ih*<2kGx=VXm)*RcxHgs~tp8pLn&HVfO?A$mD( znYpOvFf|5pkvQaK90;4^AloqBO%+)Xste=wh%bccJ1+;ZLyi)v$dVw$(^ZbqyK;cz zw2Xo@;@#XLYr^fqe{R8Ojtek31F{F9vPg_zFCd4p5l#-m(epFTj}loH*4^di{89=o z=VWMp0~E?L5oGF2dk?~Hh88*mm49SYD3>{hAf;$y)a!wv<3xbex!(!{zg7Qj4 zI4AAh76c$QSWi|I*?Tpkwk%Pan@+A7R!Es)vH5C3`IIZ_8ywcjjK?eU)aCF68@GAo_kBErf8iJ`= zXO;wEC{?lOsmD4f254ACHs*k$BZ2TpV-FGd$O7zu{KJJ@orV!`D*-e5l9mq{(LS1} z3(75~>Kvf>q7M-juBKo*zLLCVoM}L(SenipoS2wG{eK=yVSkSF#4SMCs6h`?aZRXk zC^7v=K+^IHJ^4gp>i4@AJkOQmzPDn05_lw{{YPqYC(4nIuxA$&N7}z6LEoB#68z%` zn4Ig;6+>t`s+`KI%3K-W!%Y?XmYjQgcZ8uHh$(LGD@GMzNq!A54g*Y00ZGdr*lIoz zLc}FO1?q)pLV{k|6XQyI4Q}TPJHrI)OsCbf>~{oA%(dx+>FAf*wV^=1C?ayiC#$rL z%c|_0>4;2Do+*+df zAsjg!*8n`lPTj9b0R*y}28QgPK!uxEXEeu|rCpJFvXdv@N5g|ek=Y0nkC%95^n>3| z7j9e~j6@%a(%crnY|4xDyp66vh4;Bk7&&03&LlA)LK^?+@Ow9}?sYClMzQKy&S*)h zoJ)9h`~iK45V8>{$h(fVl>0Xz(s7fXC-_=atR>8>%xlc{cJIlPCsxMrLrJGS&vPeIM@@k4e!V3{s1H&TEW_Hc@HD4$=o1g)RdUliui5A4x1fwv^5e5g@0k9kLGS))UiQir2Qi4c` zpoqd}C*lJis$staOaQkz2Q3|II4H_V&&+mI=jEuhJd;(+MwLraem6`~0I_!zCjrUI zI%vyA3ymv7ypv+bTVNxc!3vDPdT7zI;UVVF5H^`N6VTCY`Ny(wSy~=PM-b_E z7(mXo;Q{1z`ISZQIl4bA)$|j`?<$=th_3(0qUkq|6S!HMgK^m(zZ#hbA^^y$@iu!X z9kk&=LRZ)^UfE4MtM7L!D3C9_<5JRH7bK zG-5I4aq{Vd^FlNVfMf#3Vs_kzi(0+5aG>a5o<~##uV-!|f=9s3d@Ff6QmpuMR$LO4 zBR-TrNw^H@C(%3e-tfpI1B6$_+MLI77W0%18fEHEd6Xp=qx6z^=erzi5*&cp2SR|h z9{c4Z7^m$^8OkflCu^&5ISJ7BjljW?knVs8pen~Jn0bO;8OkSK_{PwDoV}HV;1&qt z$7Ygw#umRQ>OFHPx3A+j1=@Fb*DAaiqt3m6CZ7b%VP03<@e4v%2CrpN{3ZyLo4^G~ zA4@!fIy0V}6ZwtF*=>m|{eL>CBkVay%=}_rsd#D{?@nR#wc#lUzSA=jfUzD&O*S5^ zTO_ttAN_ab!1G+8{Ax80=K+?FBb*%PxEf-BZW{KO%G3dE{y-@ns_d0lydJ7HD8T!n zD_@W+;%_%QLV<}3c%SN zN}Gg6@qOj=u~~a$6eYE@RGmBHl#m3#)@$~OfsViQp5u+|IsJ+#eZ(HUy%?gnYZI$a zg{)GMl>5XuUDOfwP{R&)i1>w|!7Ub~`pR^UyAWbxPqn=aYJjyEA026>A}Wefu(^s! zMF0u7=YSNcXW4xNbUZU_fdYJY+$GW2Br25P`H=t&Po_^JGMLKjf6K$k6YtVq3(@+e z^`tF~@3lyayExhVGwgE%BQ>0++5-3kp#?am~nF9Q6&>lO)?Eu|?T zAHn#oE{xKrn1CZ?0Q3M?QGRYmkrGos+}6GWCa|PePS&GY*2Zul!Evtx!ua>+!4!uH za1wyiFN#GUPT$(JQKX#OiW-Qs?ynQ*yrv$A?X2E^gijjmDKtp7|44%6UWE3$Gknp0 zP^J=YQxZUUL5x42fRCF3bn1qvfwTtW@MrDY;d{R^{@ywNI@x3fLZu~F2k+s#pPwJo zd?YvZ;B6PZ0v#(-#-kq&!Ot|5CV%RIDB+SeSyj(P6qye(<*JK%j~q4-Y5U{(gj8Od z{6+glMV?GOc)s&v0yqa{T|E$sqzQ1>Scq=!$0p$^B9!p4rNPVDc`!Mf)XwP&Gr(7z zWB*JLQIV2+r0_q8nL4{CoT`WT#_*g2_*cjw_e~lyAi9q|fB}_g=T#B)pVHjCx+Dew zw$(0DA{aU-adJ|3Vfe?4r1cOuvRWaa%14z1ujAGI z*G%*g+4OWWK+ywsKwx4HGy>>>Jv+KV7XPrY)W_=uRE1h#D2_*_$Dd5fCLie){lS+Y zcUnrD1N1df)@x>Dh%aKZ|7C0vx+J9942(nhMJPJBR}6?VCLf7jbN}ZswIUSZK^M7= zB`ip4az}ac{exM4@QUD%VFr6lx$2_+GYe5bQ6VS>^%&;uKLGY<<1^++9)Pl6T#+B( zg{nXgZNe+_N0$1W$ga>r=T8S-%NvnxXZ9Ft=1Ble_qYI|k@>y`u!?LCUMLUV&F^U& z#gQ~KrOd7|0NjA^I2_(lVEy`j7$*SH)w;r%X{4E(BwJ38Soa6SCgS2ZTs*6ns}>lR zYk=hbt#WLVaW>lnQ1pQV;5$FLlXCATE}qS+E*P1I15>bhARb{d+mixNwO#gVbe)t( z^?-}bYgC?Ifga2Di#t2TV4~;p%?D7ojl3FNC*&?`5AZ@wFftETiENubpHJcF3dsi$ z#n~eVA%9FR9w)fjNRlweP+T2|!bp2uu8){NsV%nnVhU@{z0fz4I^hDTy z8wL-@i4x$SLKM$*CA3>Tj8ivbn1A2BKiAO{G{4JfoSGN{*!mz8qapqhHjmHqKOHH! z87z3WAXfroQT80R#Q?%9|1p$k^d8FKm6|*AR^8$4hOoas3g68alha%ch%Tna@Tv=c zWKx>n!dLxL?BA?Ad|S<&n~Gk{jPT?DbcCoOb`yqtfZvOdK28)=o_2vJ=HQJAlYTFM z-k6~Vb%z`ec?P4REi-$oF7WhDBu_*9Zj2k0zYEd!-*)-LXqsoo9$5txr0xCua~CGW zdu+cX4nR1{qTuZ32luyen?-^!OaWq3@d zRayhA0Btm-%IwJbI__{>xA=P^oEOtO`7dI}aGWbb+kcw?93`kpyxBb=3O55)KvCMh z{CsxyMnO#8zWW@vL&y0LGg%=90o>g94u5Q&k(Z_+c{|3wp7%!KYB5rOY6wI_i?H&P z<9MD=(n1+}vU8~^Nd4`c+cK_GT3$x$KX!=EwFQk-mK8f3w@Dv_yCJF}8neM&nfMys z8itb-Q}y@nhmOQJRuBX=D~s3}`BbYQ#A3`ltAkQt<6$}R-McYaL&q3@%h|iY1F-H8 zpL;T?Vp(x=ZpXN-0FG`6WOPm>Ek;A);`Dkfi{8ZhLvnZ=9u%QziT`dCn)yf(lR`eS zIS8G;R2fi=ruK6uJ%+^>qj#s6G)G3a1OSe$;yNN6EGz(=l*H@~iypj5MoT}@($M94 z40$vQU(DWj@L2)=8lKZ*adUhgPeN&7s*6!K1r1ZuP@K9VR8L0f1Uwp>n~F#dW6 zpS^@VXMOB^=#lpbK=u? z02tRkE*aoBc%P-Z#lO!co9wgCQeEIa_+!b(t$q3q>G3(4DQLwK1V_*Z-~#ybRo-(o6TvX;FV*_HKu zk+L*KmKxj0p2m`48V2{#x%Zs+o_p_q_nhbXeSg2_`~7~N?|Q!H7kdF?CL$;!2mpYH zg}Dh9q|G}ooDW>F!|wb5Ak4NfIejUR&dhN2wwXdiTblpX_<`czx3@$o#@3@G<4#dd zRS7d1(@xG<<}v2P?&+=REO^*)y8c@BaQ(;j8>`89sO5;q!%&>1eB0}s8T=9P|kG#k+t2^v zw?&~)CY@ymUA5~!aSceld)A$UX%&YJv%EsPEPC8JR-CC!P1(;L+LwSv-jY}h`F@zE zr>FC^m%9h+959CC{Zi6{6YonW998u9dP*Wh-ZnUwI@WrRdoU9XRAPL~fwy$YK^jf7 zVkR*4`kya=AwM_H$^oBtoTmv?17rbUL(Yo0xAXhN(pBiKwZn z+7}=yoP38_W>%q+JolOvgIQ((JPlwnnOJLUYgaw(^L5GT>2iy;IbqkXjW*pbPebMzef`$Gq82qWyc4?)X+d#3j zL{K=A9$TT|Vs33sqdqJOt=a=o7>1@B67^nRZ~Fu1HkgdG|Ea|b-PX|0Qd;iROarO{V1gy(p;H=jozNJ}{^{M2uO&cBz``&G2}LRMUak*ZUaWMp zB?ZC&SWGpr)&?p#l1v}$-E^+g2F1oP;tEHVQ5eie4KaWZMH06q>8c1EgF*m^7$64x zlYmqV@Qg;?E~u;yF!y(Am`s*??7`a>Wn5$GX_1%yvf;1}ogQu^3K3J_BM?#?@8A=^ z^F-fTzU^?3GZDAxLMw1M=!Uacn)yaG17P0DhG8hdj+c|nshUeTHdmja|5)sHuVw)nT9ZG&=)q7VycIFz$Dh*(o-12ouTBNuSGRq{N-XRKnx0mzGYy&{=~f{)&M)c6Gwci8ruu`Zk>_HX5RWgI@J z0?H2zBYMCq;Z@uY;==B$U)~{I@pn#xZWRFX9wP@m5Y8|Z5FnUsms&0u0Z?``;`*hr z7wyQP2N+0i`HGy;CMX>P5Bjh=w<7gsFB~$gLK>Cw&i+vggJ$f2N-(Gt7++@`pcPCe)m7`=Esl zn0odzB-C&(FXXxdDXe&8x1>&~0s#H{vvUH^|4IH$>hBu=mwiOqu2L(CPSyKJb&)?+ zj_yB6wB2Qs@#JexK{)xq%LYFiing`|+95cLI(Y_+kt$dW=}jS)J`dDyG1pGnza3Qq z>w>tKot5SOShVK*qqsV@MXY$v_`J(9XA=xvSwN+cXZ*LU&@wx$?Je#Ybsnqu8(R(@ zze`UIbU=4AMFZzltL#?p)3Q}O+BM7r>U=a6swv6Vy*z+7pdr8tR&#u_RTWb2Z`bz# z%-J!WF5wQi?kYBDEy%WK!xFPM|37}sVC#re=&;}OUJAk?{`=x zAw?D0+`=23AcWZRo-AHh2e6&ML{sG-9l2{7e-uB^x?OdXoFU%N4?z-LgMV~}jE*oKSMSmQ?Fhub zrG5MCw(a%K%pt%c`sD9mlfs3V&RMyhLkKOX`*2k$Rdf(cNbM%>r6Muld%fr*2kac3 zOg16G$60AtgM7H|h#jDlzT?>QjzX+88FnK-38fGZl+Xdgi|!fA_B zG>5ghIRB0O$czMkA@8x3oO)e1VADuFaOluZSeDX!ZcXpiE9b74&9B!(YK%-^kOQGO z?;L5~^Z?uve_8;{s1y#m`2yA@kpF2xWWe>y2VZ}Rh7nY_&+K_kmA;Fqxm8Gos9Z8C zTM{C6^{UaHD_IN)56FN!H7R*OyMqkySlM*HVdS;b)V8A?2pv;VXa67rvTBI4!?mc%Ur+9wmqN3N-wYxz!cUDBE zxo}3$44+)Fo^-lIx0T4mIr8Z+m#L!C`<<_MBOr2dqw#@^_rKfkjcQkMU0&&YnLCnK zqk8})oD*r0L3yx&;K=nDh}Nzks0)@$XX3WByOtcIrP!fB0%j(j(nRT&9Fl@u2zMhZ!7nd)o%PIqZyBv3Qg1QHHe_Nw*- zwh(VqGmor>`cBj|C6KwKEyU)gU8(aP->U{LZ&!JkpCbe$&t8UnO+V1A(9$EV<#PTw zZqfdY&lD0-&T5VHh0Id>+85bqYwO3kxw%}Mx)a28mIeL9&`C3aV>el|v>S`{D<6Zq z*rGRY-!4dfjOya^dj)$4^RAm3oU^jGPtDEGcfl7_I?y$l&k|tp4F1`3J%!5??PIqZ zL&uI8bWZjaUF@aN#N|5t!u=*5K|F1PhJ-R!HdaqzJDUhhx)c2%v#<8|2|r$ZBJ)c9 zCzyF9`uRjRha|XU-12aBe$eZ%hDOTz`Z^=gynEO4vK*e;#0x>JGe>ca$PM3hl~GZXK(+--r8Cmuqvp;KN$p)my(^!0_hF?$Z1Jg!)A zkNi+tYg$vQCOD)cQPpZ+Cd}?f1fkh)+?p9dXdz4*4y7(PbjUCIL4wfP#>tIt1#UHx z%*gX>nT49D`FDnJ$~0ssa>GMsq1s8>txiifxns=X{%Jxtp)jK*uF7_v4^x&rCPJA$ zIusdOEqlYmE{eV&{72Of_6Zr~FVEd;PXs4A4aj^7FJlM>Ev(S0g_PWNqCQiUTYZ*; z(LKH<=C9HA7KA`qcO98wGyJy77mPAX?`wMqXG21d$zaQO5sQV07KO^>ulS$ZClH1mx4x; zny1OZ7lshqX9DQ#|7Y5E{@1qq+o`>+?|lWgH(J}3t=P70+qP|MuZgNT&zkb)AM7Xn zIjf3Rn1^Ao?0P)&OaN}OO%}|M4CM_#Q@-~ZE5umdXHXEI@me9l@**NK z9y59a(3QW_4v2_eOFD)~L}V#!fbFsu=I{n!djqf(KY=-K|K^{bh>5>Q_6#CRVCxJv z)(ZftR~x-x4;-^Xs%H>gok}U@(|j(U0J{94q2WrYwY$cQSs_s(;y+6D(E=_IMpr;{ ztB3&r%m@IaF~3M-CDL`u8-RZUp>p23wP3M+O0;;!RXlV|QG2ZMGKnO6B`4I&4$n>j|JzyRqilHtn{vUx6m z#{zL*U{_J*WIF=esJ;q~?)BpH>Q8E)(Ycj=5f| z#H#u$uHrqT!aeKm?@U&>Wi<*soF@#H{GBIU?^dQusnI62sPhXsw(=uo{dbycyki_F z4}Sc6R0>T)mwMlTGN*!4r;;j7J>@1X1d{M=>)V%!02z#!BiH)s*tRlH|pvnNui ztoYnn6!;aH`H2jFqNA7i{8<)juGFav>bnk^0Ztr7p84%FgZh8J9QH^CVhCz034@DWoG7k$E7iSHYVh+ZvaieO45iU%G0+nO#>7wgHyuFV_3uS6 z;XrvB2X6WNuJk%77I|R!*z8@IQxVWxcv#l(@0Cp2V!`Qj3Q)7q2@T82Uh<+aUo1K* z{3v@XfN2&vp`pSpgC)%E4Zw6Q)v$1V9QHgp==kDSjS2p%d~BCy#XD& z25MGjYc8EmGW7?cpyZ)U+uELuf{`=ITncX06qw0eII8Hbre$n%>eVh2rsg{WJBfw8 zGmOwh52scbADcKcrNYT-PZij-HT-*>wB(ju$PgQUXe+X-Bh!N@U+csokKdPGC#BO! z>2%VHny-j7Z)5nauzO6)hgT&xSdSC$~?~W+& z3wfOiooyH=wvZQlGzus6s1zJ1Pl#1q!F3Cx-W@nM~xFSXy7{p zBO#UVDSI*~P>omA#|YUjrNX&^B`UmYV0=7zA43DJ#Yf#=P%Qkwa@o<*xzPHLC5sGy zu;DUcwqV)pVoINi)Z<_M8N<@WBu`?>U$fM6MI!z{z7=A=P|uMp-%CPnaGMdz*(KZp zx|AB{iOBAZ*_5*`iL_$@lvbk%xZz$>PM=oFZV637x9IQ%F1fN{RnMtx3GOrOCb09G zhYo~=$jp~XDC*hBB^LL9ZaC8)4JJQ+4~m)uF5AE?aG*S-pnL5?uG$Zbj*Q1$0Nk)* zZ&J~^eT2N=IwRnyU1pR{BYmtT27Y?if%1@&mgQe}2uZ1ONaW__+k9-!$!RrO%gAOI zQWMqx`w6N24HH7-G~sHzqAvc}*vZ8YRc#V88(E@GJP<4(fpF5dlMqBiZVRGEt#DG= zb6&Y?PSeoIf%1^0>V$ulXOe&?N$+)nLMDPGlehX0B1obRxj_u{1hi+X*ov@3^S1|? z@}3n>ROb^Chr0pT^Svd6DEjO18?*$sjxBZ}RM9{x@azj8oAL2~@4iY()D@!5^K-Z( zCr5*C_8aVL-p*{!)qF)fO^VbRBHAUWeQtC?`^?}-E2&d|)Z1NEnpfdG4o`2t1U*0kM^7-47)u>%w$D6{0>ZjpqOm`7kE1S3eb!^+5Bl1{2+Z(9DaK z5&)_Y<;o>SeE}g^9EM-G@-v5v;?yfgDO(%VdI9lp1$;9>%jzo&(|8~!7ZF$>TkvEp zaCr>TFf`9HFv=|`{W(YuNdQQ|7vzAr@AAdr^M?e(aIXVXK(m@t@^hcCxrog~&ngD> z=pzflI)R4_kuco1Ob`9d9@0B9mIsu80ZMiOD8Mx8;@q$%d~%Cnh=F0;^2vF74YYLG zR@sD}GbI;*HvPF2pashiQ!~I4@VVRV4m_m$fq>iXTM8zHzURUEVWQuRl*l33u|Nf0 zb`2O(Sew+2dcW6#uo9dXp>*H(^eV6jObf$4)UAJKv+SDJ!eVXW0Ra(d0uw|Tbm`3R zqzG@&H0aW(&lCJl@1DswqgUta0dS}HO#DB}^T5mXT2e&3bNzwOZ)pNrB9~+X7RXng zl1h`)8lA$PA|!cqsnSa2RjTwpA^>C8?GygoGy0%wPr35J9yalT?v$}{waWaUUv80c+V+UdSV@dAYP;s>+yr&;X^=|(wy0rJtR+Po-m#NaqG-hdBY(O z@9g))I)LB?Njg0~1v+qk1}VtzIRrGif5<&Ye)IEww|?5gk%at`v;p(PgSYzk1OCoG z>;BFc?B~Dr!6(kNB_Y=&oik(2Pd#Bk3kGl1@nA3*=K0i&J=2kdyc0>=i9EAMk64}W c(c{cW$CgAG0*R!Qwg5^gi7@1n0Alwp0?{QhCIA2c literal 0 HcmV?d00001 diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png deleted file mode 100755 index 8380a34b3ac25285f63ad08381ab6c4a1382a256..0000000000000000000000000000000000000000 GIT binary patch literal 0 HcmV?d00001 literal 9508 zcmY*fWk6G3*x$zJ4#_{=(x7ySbazOHpmZZ6Hb5Grl$4O}l&%31(nw1q-6f3NJKy)y z`(bwU+I7N zeQb_xtgqBWfEC5Xv>D34E3dKql5ZedfK97GV_?+I=E5R1I>muMqvLkKKjk=!wSX~J zZ6^Aj{N~vg1?_yMQiks_p+~VXM|qW?v0t*CcS~N`PD@q|YfI*TgLVrP+U{4|kkA8} z5={fCi>P<`(QKLj|MJF(+IBQ1>t!>qo$%O;{U8J$*Hl41C+!xXstWwu7s0z9;QSU$ zyhT(sE!ozHeF`+DZEf|uTo?Ydl(KB^9`I=i*KT6!G^eopJnv?`vuTVWk|F%v@urVU zCL)V&Il?Qe|2W9i(&IjQNx+qOnWXit7uSYie^sifR5Ql)>6~nLMNv2Bw;PsU4*wNQ zJj8U_k_R~?uvL!~cPcVZjnZF1&M0qX0$dY30N`u7^W36e98G5fE3O+Pgc*YoSJ|GmAU* zKwrHhj$$V>rFqG=qyQ4;uy;3-wiID&KD*a1e#TEPyNZN*19X*Sm7ExCkDY(r%#aFr zd-DwNUJ9@D8F_21(EB3uO%(Z^mFVwcceXBFWrAyr*ruvHS9yqAhmj%^}* zZAMGQck(ZUzKCITzh-DnTry$KODl}i1-g+i76$OkMKpMy_Ql8H$kAm~j5502NH!mn zDGTpxXJnXkvZUdiw8HK-{lj#0hM2vEb|~KkfFl?cK}KHak9|W{r73cy32iimdi_y( zhGl)0H#^q?&6LcmU7AG~#XN6yvy*gyL4Pq_3FN5DxLdwFwqpWidaQa?PH(v>ui}$? zhDsA8%;gq+GXb)|kgh;ZBJ!9D220(jooY zfM?-@ZZhv#8ojSNDe=XP4@WH8y(Hcx&X!Tg9w(BOOFDgYSa&_5ik{Ouwd&N4X_7O|?# z%*+unF|h(LwtD*VX5S9k(_O^m{Z0~VzQa(9p4!C7NPw<-=F6m=nSF2MPs=r?vw57K zw!$8-c#_r)U%l#yWzvAiAgbPbIuSt48R2fLEbE@7p(*P=CdqxnzocY_)1a?uJW!iN4_dl(;kzY@!)2+ zwJy(-Wif{a?z|M266=09L{y>_Ve^G5kJ{L%b);5g13e)|J!^a5!e$lbVm=S%8|SrS z(NV(_iD6$lTx%n}X_ND8Ps0*`AOP3wS#3v$B=l1fFGji!v`r5r)4lk%{h|J0?N1Mrw4m{ zRZ%r7pkb!k4q8j6m7(u{UQzx)U>qJ1@tecqZc@#OH|QE`|I%H8B75@SA=or)Ekjx13cnVO z^K)h7eI{o;4x6gTz*V0g1KR-QR%r4w@wZngGVq!w0PB*HlyLN8ho2#Ff zptxA(kPI_;;YQ+9?ct)oYB@zY7Z1-{6%`el5P|m<&-zv9s|+O!nksedg4Fq0N!Hts zBcBmtjWozD*VTXh`gQp1>}(g-SX#-7g3RS%I~C17UToKxv$>>&6X9 zJD8vIAFEn#kX2ZLV_^W*7$6?nCMws+siako7hNeUP zQ+jIZ-zk<0&vf?R(}9hfmeN*6d9*JChsEPSs7`Xe21Rxyf2W1YGwf<;M4 zddi&~zXWqcN|R#|5?W&6`F>DTzIciy=c(WQEu;GmddUDM40v3q+SKj8p{kA0VPIt3 z82&2e1@S>}$O?JPp%RDb(v}Tu+%^)aP_nH3gW6h8NDkE*;%td$^XF)VCAdfKm-f8d zWH%mXwP4$3H%hGLdIsGdu-5rJQYv_?=~=+z-_>A0Z4TU4 zvWhj*Aw1vIioRgEpFe+!Wr?`aVLQA2{{FM66MHfovpc8W=Mxj+SQg}bA@(*PFetYIzupudKry*0qgb@@xZwk|7ymrTr2Nuay_b7U57+^M`)+gyE-rnEs4`#|b zLkxPImxgl>=WCFWi87%8)ofB_H8rj^)H5z!c*EXZj6`74AlVJ1qmB|7V9E8b;4C;> zp<>8{mfWn#}gk^jb$y%yfv?2j!ea@NioVo-T9Yx90Dip*Dk1$YH0 z8UQY+@S>{FZ+rO5pWo^jDi@K2d~QP`w`AG8Sb5(?0Vy_AR8*rA6K>LP!;aoa+=gdD z;{d*r!xl`xa#8=T6=_2uR{cg8&;WO>crIVmE8==RJ3HIM^vH>ox1}+_5LWAhlYqt~ zY99b<--4SKAzct!gx`?pz!@S;D??FfX=%To$*iQX6~lhumA&cL9SzK-=uHt+XeF=f z6!V_oA!tbSqYL7^eh=6)Vt93F>7L&I4rd61>F7t&$pk-`TBY$j&*ncMM>B*NLyr6b z4=IzVnL9?K#l*(y(8UX(ABAj?k6}(`Ip+l5?OR9!2uQ(1U~ODrt*yMBo!v~jfUWzG zMJzVp)U6$g3>JCnVFHkYwG9~lqo7E;zunDE07XbiNx7I(1C|t&l#rp&cdh9FQn2>) zM`KS<&;Gl=hnB^+Fni2i!=+}o=3SX~dwcsX)>IiBP$>`TN2(2GrBB>|2%~DV#j4hj zT6w_z!^4A2YYE2TxThDEm4g3tHI0aC;Ag0I1Oto# zMosb4vJ>*sVWT&kG-P`R+)DyHLZR2-IpK4nf9p*HJQETSs5|>N#}`&qk*(?Ku-OWu zL%qDNt}Z$+))(j z-vg2Wf#AuiH;o5ra6!L=6%AHcqdY7Z`a}gTCH@yCC(yAKoJb{UsxVAr$wHU=1_vFek+Q^KTu`YXmZplz6vW@Z6Tlq+f%v)!1Fuil zkYFU-=3jI#Nf=O*zdD5??=Q#L;2r*#S0wNL48)TnBDbUc|J|VZv43!I?P)sq=>@GZ zsyB?W0qCU_XJ=*#asS0#N5&*BE`I-bvzGhI8PxG`F_MmTcz(U))^22k+C0;a8fODb0M3aM1LJ*zIg>BU<3Fq zs~1caj%B_)6tME&8O^w6Bb9xF*EiN$Uw;u=L<(rf16*ac*!iC6Q<_3Wqj*-fr&+Wq z+@yw5Pp{w71ZmcG%H&RwqDAV?&&=U~yk*6{8&HPzq@~NK;M^EW&bX)++6KX8x#G( z<0dWEU;@~WIE&iWw!3ztcvNR<6B8c1JHr%y4GynoB|N|4sdH58nr`yY<#?P}0#o5fHX@I%uVvW8ZA$^$if&ArdZFdo3_Mw0$wdnUpD3W6U41NWerugCX zBJ;?|!XN!Ggtk~cgXznw^bV24#>hw|qu+XOwco#g&p`~Y6dsIe&*@1$J=eoWpaa#1 z0s9l!68;xkte;jG;b)WIUTfrl+m!(CEg7D?WL;fP480`i*L_vqN7_NaK=B+vv+Ift zs(>Ry%zJCi)v&BaA_+QLp=M^w4)yAZD7N$K0;lQlL(~DB59>@D!~k$@@xNsIQ+G)6 zuWc`xfrfTLceG-jcAvfrj{XPQm@E|yDhp+R-R?t04-g2%@BUbdZo(tYZ1F}vuN}Y% zGeBV?NahLg=UkGg%atI+eY}e4I&9b*1-1J^aE{Q}sHh>xpLmgA;E}K&$x)YnX+;H> z;Hw~QZGAJdhlby18*j2d-9{*u&!Omqq#05V8|(;M4lzfy`jau0kkvGt{!~xz$%VX` znHg5c1hkmplTx-dKCDT>$J{Sp`cW|6K$XsV) zutc@Ga*S>Ucpp0)rHl@`9w$%>?p>1b-cS3kVj@)q)(3dKIQEBwy9q>QX z?qI14ByS3vMz0*@eJU6p5mLMIBbqb`n>s4n_maEi zk8YHh%9lW1%i!DX(cxiLgKX-QfcCv_?V_j#7=ZhEXlN*Z?eWB~22@a>l;E5H08N`C zSzis5UF!-;BIu6}==(K2oiOaTuG?1hcDw#`i~-F%4N!uTCxIWaE9Ttk&s?y-QhHGc%KH$vL$b#%&ov4;U$y*x1<4w-?(;fN-lW zm9pvG!uRl-Za&(BgM;$4S3>rq=|93g*)=+6I%$FzpqrI|uAV0uMn=YcJygBnI1|uG zE|fLyPbj`6YPKj)jQ0$|YYV)+pr%GA(}Focu)Tk9W35w^6N3|7pq`I+K25fRpH|>3 z)(u9c!?~DtlTpOXM3^n-R*0hd`g+}$LkwOxp*j;RSlZBe>Ci3=jeDqg_@g`&=Dy~83a@*IX{2HG`RlGDhWrpy)p8^KTjAdWMC+Xx$gMt%)H$<=!X)pzeJvot*|@?u-dbs zk@M0@WmSfaEyCa5KL`U4^5H`ZHMsj&4t5`EJua#r)fN&GNERh4o4=*QP_f=rJ`d`v zHg0)J>yTmVLh-A7v1>X^jDYK-{bh?Dw}Kvi-(_k68!#5X&EZ@Xl!JG`1TxH%GG}Pi zurm1C05|xnmbOJ%BchEyaeep;7k(@S-xFwoMmkVbxZCsH#Cmg`RSTv7A;p=onVA`u z;nuQLPMPLVUeh=-cM9utq7$mANgybbwDlNCgc0!F;cRJdl;3cLkWP~FZp0QvNV@;L z){9Se`ndjTwifOCH)~9Z86lIlYo&TtLBaYRKIrX;68vjyY%I<38=EzqRbCl*`sOcT#=h~6%psOcyPc4AJ>CtCVoj5(Ot#nZ)%LnN^* zPQ-QrJPI9`yu%ySXF&W0WiRCx$UQn^cEJ2$+2-0Dj=?86w+4tgQ@2inN~#agtWV%> zYgK({>Ovyp>xj$T0uUOS(KB-L1By|r8gf?O=S}iAcRx+f*9Vn-bV&M+Vw;-7q2DXKz%1Wvhvik*moFN4o?W$4{8VZ0y&O zvTGi}rFo*zKWCE1xBKcPrPvPLI&JD*Ii#7iT$O35tJ|42IiF(`LvBuvfpI?Gt7|iH zME09^D2iocv93c`sQw96LrNJRlXo#}rd^*OEv zK3i!aW5k0z`{Hw9!vC?Q3X2x^jX^lc;7q}HHuLgZosT!rJl)T_dSNJaAwtsN^R>r` zKR^kreI)$+r$~du-#9CkYhy@V7E6%!QIqRRTP}>T_ZLI2Ve`0hfUpEZ@dgfsbPlG# zE(cwy-bHW5Lq1891jly;_Lk-W!}BT3^Qh$eV&)=3BYbD%(<6sZ4gk-C8{a^8-APq8 zjz=FuR6^pgX!2iPnxHiP7|HwcjuCiIq_%Co`O{^A* ze3vwA`jT=Nw+o%fEjJnFAJZko1FoHC(@+RNVHHvP$A%%Ol3vF>Ko-Zk@l#3)so`1*jUXf5v54iD8H( zRPC53t)apgWQY~6=IiU*^36jjfo>%$DJkT?=JHQ(p10~%=ceOy5%me*=S&)XVG6oK zc@hwtA>5-gYGzFGP3GEFzfJDgD2+M3;U4bqfTth@SIB?v(WAm{t4C;Y0IvUp`s_^T z`_*!bW45nI?OsHmQ-ZQm*tK#7Of^{ILi#(q-VuvvliVF;M)fyVHNI)Vk9YfjfP{nzB`tI9 zanS@XbHzC;XIHxkXiRA(Kk&OE&Nikww>kHNmBO(342a>z{iV`$>mm4slj$ZM*)mY+ zXNMB#*xaNK85tSr`<>@U4HI2>>Uh*I_5?CcPG?jnu46E-)HpB+{)^gnw*9AbEK&TC zyRb_X(;GyY+F1510n^J8`sj0XAe_oC@R2`Xb;V6plEJ0!if;QnI@o6%}Rn z3KV<(Ag+&rl1^J*m+|4NXX2soMPUzgYi@>_?p9=TG7JlAK zkgyzZpz?o8v@hZbzc8XqNxa6_s%syN($9N^KX=G0Dg6P&@}pR1$+`3W&8dt9##7fv zU@Wk^PT_?o$#N{Vvx^J+>0*O}rL!~l>t|&QA#I`N&bA~}R6m8C7u+c)l3yT2lcgfo zPi<7z-D1FZ>F}v39j2ZiD3^%0{lj^_p|CaECMX;d#JQj#%*Vn~=!h~}D$elEx%V?v zGuJkI)+n(H;gs#cQmzgTCG0(x$h&zh&0Oj1kQNx`AQP{4?do92EQ(;7;QIG9^a=fF zDAX?@JHt5>A*NL0)V--TZ~wHJ=>a+&!k&=49F0X;Io24-%5fMt=Xa&s2{AD?5`HIf zC~swe>x2FH_&8}9&VqUB!RLA@(1S*zY$o^gDgUdG=?|-ADRYHs~Aab(q&{UzLgptjk=~0-)PX(UQ(MJw1h@=aiO~D!*~# zj+z`;kDLJ_)YR1ewmza^68s26Y(&y;P$@%@N$u@0yf1GdkY7S%>u!wLTU7bdg{*&* zqXIJPe!YVqN7F+eJbc{U-K7mF7#Ut$u_=CRn!4RnJDQ5_lJY$=e)?6UHh(S+)!q3! zILtBufEZq=Cjg*a(}lWblcsFZiF$Ob^!OH#{UR|5-=i~ZLc$vOW`N`i2rLw94A6;( z_`8sMh0-HOC}H_5OW5TCx)Dwh%;O()C6xn|!d1DrO5G1i+J7I}lE=QCjHw&Z(>Ffu zibo&HKJ|{@zb6zO*%!H|9j~Yt+bhl#N=dAa8B7Z!%udM8Kq+E>-=~M$oITSkfjjCT zO$#WN6s{6PSnkvh#*(aCbTMUBSWxf>K_>-K{~UF;Uy{o!>Agp^8=@Hb$4A?Ug*~*$ zP4O)rLYa-BjEIV=Y^mMvG&~%a;wcv#c*S#<{=IAYP+|z@_s6aMJpHo37l}4u#LGfb z80M<-@KSzs`GsyMQj9mgTvu1OaOQoO8J!6U72aC{LU)?PU-Caajg5_GQEa&n3!%r- z8YZo zMYHg$r>SNzIZ$UNeG2%z8Fn9ac2H6CWRLP+vK}5EQ~1o=^b=qiis-HKj)@@yX0kOC z4!ujv!kaf1yN8Ea^^UXUJShBoiU*#tCyzrv-+A)?t@^wnpzelq=6CVViX--JWD`w_ zm_N=di-kDkaIvHtu^BtzceFt4eX;e^%=st&quTqKfL6CgqTckhL8)~`BBWI-=Z>0t zO&|;2=C3o{s}R)k;;YbNBa+8Y-Q-VDr=64PNwh^9ke{1qM(^d*?qB;xE$^9LTmjJO#Vk9mk?#aG7;-KS5h#dHW zJ__jH+RX`Y9cNl?a#`{{+M6nPPaR)OGIFPV6|SmZh!syH=XgWhH<^j+iq?LkFaStM z!fiihob3K^Zu3@FR+jo?qoStkE^}?#iEQQ|Bq5+UfL^OLP~{JyFc1NHc+M6ogzhk@UgFBTA80u;<*|M+C$5NiTckXnznIKPlC#OyDQe z-LQOCSADsNhqSo+?@m1k4G8oFpHmiTlTi{Ad$6Op5&nD&O8NO*?oJ>VR#qOx_N7@S zr=?T{9a#SM5FL;@00S@C<+O%PC8ofiSQ4%ZKrkRomVAh9o<&aG!^bE4b~WI1EX-&H zrS+s&$31lmQAaq%XT9OLZ(zi+azu8a|MmCxLqZwODyysKMmTK?fKT{VVak(-iF}Ec zq9&NkghLTJ9M7Mtq7>1h9KkhNphz`v{^F$l^oedhqMoR=qep7p0_!Saq@$C?3q#jZ zRC^6;Jxh$Z(8@$LcUTEsM2spgnl;0YNJu@|e~ujkpZW^VE(Hbo>}F#ARmOETkHs2U z1bsC32!z?X;B&gwjKecLf>mef(5+mQO!Y+>?Tl$g;U8$}=yE2Yx$$4zUcGwt{;R&n zhtnLX08N+Gj$0z>6zH`Gf?$a1DOS~mo&fubgh zl61cD?XSJ6PqGH1M-;ST(&B1+P<=}y0hrvJtgurthTG|36>6c#ytEMHYL6lpa!_XJ z0YLsch4Hcs`a9cf^&x(319>KbFnul@AXmJ^P7_MI7VngIgm3RS_=nn~%6p92E+?31 z#dJ-vBZvYRI5I;0Qz^V!acEtg#F~HKPK@!28O%aD*O}avGbD9MiV{IufOG-^f)sqL zU)+5G=Tr#pKsMHNnp00s|?pG+OR!|`{|d;bQYkF!u@0Lo`Mq(RUEM#HDg;!<(>lI=ygaD zDR4SNRvZT4B32Nj5WGDt?52@D^(mh4%%3=Uy*3-$)!shZM}yV@=zQlQhw)mDk~v%| zq%O<(#~vrMirXTV&1fO=lr_MTkZP!X%8wGQT>y?d89QfP$j=fXPhd)=a%5*GV1Ln_ z)IDEJoQGQ*TP!N~LXqH5amWU5W=;cU2I#_HLZW#>h_EOMSiYm)egdc{Xv$a1S%v-& DB9lux diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.webp new file mode 100644 index 0000000000000000000000000000000000000000..c753313977ff304994e0de7b999a77ba339f3afa GIT binary patch literal 7362 zcmV;z96jSwNk&Gx8~^}UMM6+kP&iDj8~^|>zrZgLHHYH1Z6kUAwR0!!J0cXgZPeUm zL-+-MP+2X^1#%NIJ!GOx>vEMDO3B#^^v+5`)x+0J(5y2ZhrRGU;DY{tnIkLzo6Neq z>p8f)ySux)yW1@5?(V*ut=;i;-?4SKnK@_N-6Ng;7jM6L`|Y>ix37?QrdGSC49<`W zPP!UrsA2HNJvapKi>ctywbd7m1z*h~UDd&kpIjlSbA<+n#xqDR&@SreO5@N|m7=D) zir_YNG_@UE(+gY$ox3iaMiVSJ^j01DUo{TBORy9b+@_A7)44bW)V#jb|WQ_=@;Sdkh>S)4k>Mrk7a*` z6FC$?ZVlXv%%Ow71YAJP%;+okBo{L9`&0EC`h_va;NA(b+E%Ld$Zqq{5x`q=}-fo!p&8 z)bB|lIuLV+5aI^$h6F>xAR`|CeRnv*cis2n{NIxjwG&NNcUB^c3kU~d3yFj*gdBj} zhkS=5Ly94dkS>TCG6*3cBn0RZs_jnxH@|UYi{$k`_zS*ipU-A-k(lKcElOe*uM>m` zae_>N9D=-p)*q2>p^#L%&Ex=#}>PdX`P3yUJlcqy`~= zedyE%p@%cjTFhcuh!x~DLVd&<+J;k&km9vjHjBPC4znO~ey8Yp=w*J7rY?CF`w5Vn z{7%vL5aiY;uvv0C+5+)|Kk1|Wk}^Lj8WOuvNusA-5dJ#L}$EH;n`NGsaTs9|1k8AN`xHfAIpt}A!5940^% zxXH-jkD@VCShrm0Ibaxv$tNdqNd{ff4|PLkIE0-hIyj8SPsWZ8sOvJ-ktpODNZ23a zCWA*i6g3&?NEGU9j}+33n@k>>*0gMfuMkHf`>BvhKHZqvj00Lyy%CPAutpZ!3LP#P zxXJj@l!0 z?;lnfvJuYe0jS!8+FW+9a$$7fuoxkgh9MMFpn$;1qa>zPMbtVU;I#4MW)5cq3MrI}u&kBHT*#|@#(tMWs)fUv?p+ioo z=Sdy@%%G|1kUWfLtW@eO3abqkPI}<0-RnMTxjWWjzqOb@ZEPam+8wqdel-(IfLvBy zGHH}DUZ)w@h((SkrBZV=$DB(&`Xw6H{GrXlO;0w3fv-*r`9!Lz&HUOQHWTz5N~j9%J~O>PC$JhV`K*TXw?J}p{qkIm#ueg0L8{S!M) zrR&Il1>i3uqxI=ojkgL`PF+WTW+IeJ6K_67YJv*0nad9PwE8~!ZT9ulUkhVW=h-t#;A7t4$N~v(aSv7kqN7E2gVWC0ryT9Av3yVWCO;!_|C$3Bs{2_-_ zjQUr2xu*VQ$gcWrLFQe1545yF~0N$i72kL#qdcyA~?7cA(0oml&cxK44F ziadfe5+8hzCJg^Uj*&{0({1sCf@dzx*a%c*qrn%t!0Re-gUF`*are@#+aETD|H5Xf z9`KMIelY~++sUFY@(jT5+^RUG77yOg4no0Nme75}3QJji316uyDMS zx?mL+84eQYp5qDR8`bL*O&tA#m>LWBkkY7NYI75ZzYx~uS=%t?3EjKJcXGx1;H}z# z4T%EK9dbeHjR|-q#D$=im>iCbRf`JYjefHD3)ExjrX0hta~wQ1q%x+@^y@hYyBWA1 zA+5<^c}5D-aRq5s_lMhe{R@-d&eVK@sWG9IVM> z#UBb#-x!M^>X4dR>YZzNPcrPiS@9|T5QW>{YWd6{N<(3&{lFIdp96ct?9fP~Xv+$O zOwVBCHe07F@Yc^gyZDDdS zR$WM-xLzTTI1tpJ&zBHIE=jki9lMZ9Lj%ssK{y&DqSj8U&%IRC;l7Q8M|1hSbi}{9 z^=@I6zN&Bop(1a~qfs>mt8vmp&Rh~L!%e{oBnE}kfv)Ens${h{aE{Xo&7@03!&Ha; zw%DC^GPl{a>kBB1%E+f~2i7O`YTz3VO>Tb(ys$kDsLOslfQK2O*H7bZj< za2jia(k%1vfA*4>gbJXy;}lY9s7HN5pdSD3bsIT^;X(qTE@)Iu!7AL;{kEL{BO`$6 z$3LvAFyB~t$=9MTM}kPDUllU109_9se%rPv798r4nz;zWxH0m(#=&;m(D*oY>*LR* z4>?6APU99RW?{6B*yxxSCV)|gnFyIgCnognOR_R@8dwfuT#eX_eX(A2BE7^1mEnks z4|fE^+LH4~EJ}e8AG-|qtlJQC70>{jw!B1YQA(d*UyX0Fw<6@Gpx6pK@)tQpC>n9E z$|M2!wl0LdbmJoMCmnGhL@FIY6$(beQ1{S;MBWRg0Thc?oSExVc~b}@iEzAp&VoSq2@wen#( ze7Go#B9>~XrX-S>p9Zj(!=H;<#Dc2RKLw+-cLs}ych#;)&?!YN)fk$VvD%Wb2{62? zc3FbB42W8)F*qe-wJ&HO;HPEf2;`y?wNyhr31eihNyKZ)mclOxg{ZY`iE2Vd$Zi`p zF^l3EM5m}_GrI4QaAIy1rffDmI5dgc`{pLJ-3fuR_akOP9Q>&fwa+c<(|9Y$h`Bx( zvYW@sk0KGv9#yHhPMl=d3X_PdfF=VNv?oo(KDT!OtCJ5flU)IHLbL$=*8CNzNX0!@ zA}K}tRs=XaR{%G!8PAbZq|y>p;A`HwJq{uc1nAY{91=5te2jPj*90v$QmJ6EOmo6I z^BEpo_+K|DT7m~52NI&V1^58nP1%NW>CR%uFx2t1B9-)Bx;aDkyiFZ2KgVNl3W0)< zn}Q3_smNSpG|`$*b&7%=4r|s%64~=+H6UkTUD}0+xo$WB?Rf~{(1V>hi2xcgCsI1uzCVUT@CS|b(jQJIFD#6R{xJ}5?q zl@Bm$!7r+eht%vDUh93J^JAAJu-RsW4{vxa<5MYOvLTT6s9S;xBmu1QM}@5*#t?Z0 z=<2I+ikiY9NuzY+ud;Uo;YEssEz-Ti3d0?=RFJunM+hKvO+G9dBz$~1Kx-tx03Vs-LSBf^k_(UCP6>Phu z!*NY=Jghy=%(dyfEhgBn2pt{fy7PUCK_p z3cmt?H9iJO71<-rHSre{lP6i~vR}A5%y+%;ZI9K#%$!Sl5Ua<*7e(ZuqVAT^_OuAF zozh%7OS3K9RsRd0vZ+GHEi*3aa?y)a@P#$#hJ_yuu&K!x4f(^Pl@dwQ`#ywwFJ%&m ze(T1d6fp(S!ywRMd;VJh`wB4vSe9pfCh5j0qv#%a9D*?1@Lz4U=tgr>FeUpVt^i{# ze$Th*=;%gWGm_?%8p&=yUM0`<_4zlLg_9CK(qL{m%rv$c_!RxGFuyfJ2u9aS()Dk4RVr#WGbeBd+J>U@=MVM6kJQ!fw%|OSjNJn z(1z{FZLLG13or-RS0gqvoKk8rKb1hn-G>bMG$BWz)}}AC=r+F*C`CHJG5Z&-9tTM< z^T9C#*e<}THUI@d2<6Qupm~p!Neq}cXed07N73~LU_!QcA0Knf&4Z+Q2=tSVS&ig9 z!pU$Y++#EPR58j8z`9m9uuDg{c2jHAEI0nJoB+ZiXuqc>|H7y{GCKA6}d^o`w z@^0o46hUcr&tabW9{%<#yqZ~sfxBRpc~mU&UBOqfjkg~d>tj~s zU0dMfzdXe>`759n7!jUj5w-5E7Tc4ddi>kO6#R2r0frI9s|gn3*D(*ZJ=PfqX*0GI z+zz8^(UM8x>FXn)c0xfhMz`umM=9k||FVh6hn4yqgdxgVYg>u-n_)@;wgz{TZVce& zjfpM5p*$Cxf!rgp2i^7fH_UU9jaCmg^32@?%*dS+kAYoYA-=OfWq@Ef%nF9aV-vF5 z0sc=fA3gBhhI#BP?USY!m7-`UWCg=wF2U6WTWqqBw>$9^EULp`Ci_j&7rL2zf#`5r zKc#~r?ox@tyJGE$-!4Q(R2Ssl127&6yO`y8)D(OP|0B*(5O5oq=OSvY9-b%O49v-! zm?06`iS9W9HPOZngC#&TD$)%oYy-mi58EK7ycGt|Q9u98rh_6?e{U(uN5(sDXslwj zxOs8a=!l{-bbMFt z?r%Q8WO(eiD#g_uzl6l&OF(YzcwBwL(O}MvZI#6o(8cpM05fxctC!N}6Fl%zaOA}S z_5k-v#DDDBNqh$Nj;YQ6%yN-q7|z2e>f(8a1dEam@CX|kYr->vABhJTjezi&H$dcu z)c-n}uL7=49wlZr+vPHEEZ$XaTGs74vk#)jpgu|yP@ezS#h7DWwm6>PjNnHw31B)Z z4qHvmsz*Hyxi5kg5-7MdImMyJhJG8}uhX9Q@$g^G+-9AlV0fk51@%$Hy;ehgw`|=i zIttTpXDE}hhrl^)O-oLLc7Q{&N5v7~a-Bq0qP!c!hx#i^ zQGG3p&KU)#>=2^IT}nLwgNTuFPQFDZRi)uD9EO8+_xiH`}~j^${+Mdm6;$E_$j-sLf{)MM~iB>*GKXDV-p*_Dtt0*o!HyOyQp zR++DSB#T{Q^$=(jH?Zd00X8Gys=Q5RzTb|4Xc&1>lltAuApbz+VG`{5R^kqnH`gaT zlJ};Wv*cA5ACk%tD(g*QOoLLK1x3im= zqMFo2E2Ex_i^1}&h`7%M zxceZPg&UPo)&+0xq`j)D2UbEkHFtcF0^C10E}~e1zbZY3Rg#iq9tJnB@^)ELp2GbG zq?%~0rnUzU=}YnLvRvu?J#$iu#7cfj>B6`&@&IPt2gfBec@VtkSLCs{+lFbKM8; z@L}~DDf3hZwUtEiZMahy&+`3H(LYs zf+T$H@s&BDpInesjH$NZ8C}Kr5Pgxk1kNKM&QW@An`QIzWkuRI22DVwSZtCCO)-hZ zn&#jiSD<4~zTOtgy~>>QUCc%LkoaIKeTT!~iInB10L(0Lop{L&GI>UBm!WiRi2eh( zK#d7fVJK<{`n~(wXi8BwEqAYr$yj#QPH~p=q~yyYv7Y!zXOVX(`aB77okE93qVx_! zz}>K8K7KBb{LjC#mk~mUc_m8ws?ce6e8}IQm!EgZPMKHPH?&h+!g->3r-G?;Pdos+ zH6yJ!+Y%QOIy_Pz#7ghAGHybSElAYw|9MUDdYMR!yNpyoFYeD;Sf>)W`BQz$?k{z9vVbytX83RVJB>Fr><`WNwd-`CAm0s_)F`*=f=HYu;DypJ< ztggR)PF~6BG)eJ6^x?wAk(^Fktye>^C0Gk(IVyhco;h-DOgz31NJbYOQ=+0ES$kYjD<>d353qs z>o7IJewFDhPsPQiyPVi;$+m|`3W+kNB{r8f9q|J(875ulLBK5cGn# z&DxS2othi7^X_6xkye(%cUytF)o5IU=1R0xp}i{YzgLmwHE8_tWvD1e!Pyt$?;O0H znj0lK_U0EJh2SH5y-HXN^uGOf-T#-i%pV<9D(?9qt z`ybG|Q_P96TEuzmDLf?-^BV{irrLP|fD@k|l46faDoQ9Yhn@JCF#zZNdw1?UG#;US zu?P+A+`0GuTg4pn#9`)wgrcMvYM>r~J^!BP~FbRNAuIxkidFVnho#ENJTaHUAAwh+LRCo?RvdFDhl)2 z?Zwe4c_C@}l8i#FH4;}NP(+7-E8(mzt5qv2EX;_Hm*65kKBKTurnOpKI4`e8q=+CZ o?x@FRv*~oYNL=W2I-AYr;hp8@6JA5dxFkpf2N6Q)u0Qv&)KmY&$ literal 0 HcmV?d00001 From 9b5f2e43a76bdecaa1f6e595899859656a1e8d25 Mon Sep 17 00:00:00 2001 From: Zion Huang <39427017+z-huang@users.noreply.github.com> Date: Fri, 16 Jun 2023 22:18:51 +0800 Subject: [PATCH 222/323] Create FUNDING.yml --- .github/FUNDING.yml | 13 +++++++++++++ 1 file changed, 13 insertions(+) create mode 100644 .github/FUNDING.yml diff --git a/.github/FUNDING.yml b/.github/FUNDING.yml new file mode 100644 index 000000000..fdfa6e75d --- /dev/null +++ b/.github/FUNDING.yml @@ -0,0 +1,13 @@ +# These are supported funding model platforms + +github: # Replace with up to 4 GitHub Sponsors-enabled usernames e.g., [user1, user2] +patreon: # Replace with a single Patreon username +open_collective: # Replace with a single Open Collective username +ko_fi: # Replace with a single Ko-fi username +tidelift: # Replace with a single Tidelift platform-name/package-name e.g., npm/babel +community_bridge: # Replace with a single Community Bridge project-name e.g., cloud-foundry +liberapay: zionhuang +issuehunt: # Replace with a single IssueHunt username +otechie: # Replace with a single Otechie username +lfx_crowdfunding: # Replace with a single LFX Crowdfunding project-name e.g., cloud-foundry +custom: ['https://www.buymeacoffee.com/zionhuang'] From cca486f898a4bd096d16115f111fdadfcff0df49 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Fri, 16 Jun 2023 22:42:00 +0800 Subject: [PATCH 223/323] Fix lint --- app/src/main/AndroidManifest.xml | 1 + app/src/main/res/drawable/ic_launcher_background.xml | 4 ---- 2 files changed, 1 insertion(+), 4 deletions(-) delete mode 100644 app/src/main/res/drawable/ic_launcher_background.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 67cf164fb..7a9288374 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -3,6 +3,7 @@ xmlns:tools="http://schemas.android.com/tools"> + diff --git a/app/src/main/res/drawable/ic_launcher_background.xml b/app/src/main/res/drawable/ic_launcher_background.xml deleted file mode 100644 index c5d5899fd..000000000 --- a/app/src/main/res/drawable/ic_launcher_background.xml +++ /dev/null @@ -1,4 +0,0 @@ - - - #FFFFFF - \ No newline at end of file From fcbc5beb4fe3d303d61491c5787cb0cb8cd8c1e4 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 17 Jun 2023 00:09:00 +0800 Subject: [PATCH 224/323] Bump gradle to 7.4.2 --- build.gradle.kts | 3 +- gradle/libs.versions.toml | 77 +++++++++++++++++++++++++++++++++++++++ settings.gradle.kts | 77 --------------------------------------- 3 files changed, 79 insertions(+), 78 deletions(-) create mode 100644 gradle/libs.versions.toml diff --git a/build.gradle.kts b/build.gradle.kts index 82a35b6c9..cf1f346f3 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,9 +6,10 @@ buildscript { repositories { google() mavenCentral() + maven { setUrl("https://jitpack.io") } } dependencies { - classpath("com.android.tools.build:gradle:7.4.1") + classpath(libs.gradle) classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) } } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml new file mode 100644 index 000000000..1038db4de --- /dev/null +++ b/gradle/libs.versions.toml @@ -0,0 +1,77 @@ +[versions] +androidGradlePlugin = "7.4.2" +kotlin = "1.8.0" +compose-compiler = "1.4.0" +compose = "1.3.3" +lifecycle = "2.5.1" +material3 = "1.1.0-alpha05" +exoplayer = "2.18.2" +room = "2.5.0" +hilt = "2.46.1" +ktor = "2.2.2" + +[libraries] +gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } +activity = { group = "androidx.activity", name = "activity-compose", version = "1.5.1" } +navigation = { group = "androidx.navigation", name = "navigation-compose", version = "2.5.3" } +hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.0.0" } +datastore = { group = "androidx.datastore", name = "datastore-preferences", version = "1.0.0" } + +compose-runtime = { group = "androidx.compose.runtime", name = "runtime", version.ref = "compose" } +compose-foundation = { group = "androidx.compose.foundation", name = "foundation", version = "1.3.1" } +compose-ui = { group = "androidx.compose.ui", name = "ui", version.ref = "compose" } +compose-ui-util = { group = "androidx.compose.ui", name = "ui-util", version.ref = "compose" } +compose-ui-tooling = { group = "androidx.compose.ui", name = "ui-tooling", version.ref = "compose" } +compose-animation = { group = "androidx.compose.animation", name = "animation-graphics", version.ref = "compose" } +compose-animation-graphics = { group = "androidx.compose.animation", name = "animation-graphics", version.ref = "compose" } + +viewmodel = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-ktx", version.ref = "lifecycle" } +viewmodel-compose = { group = "androidx.lifecycle", name = "lifecycle-viewmodel-compose", version.ref = "lifecycle" } + +material = { group = "androidx.compose.material", name = "material", version = "1.4.0-beta02" } + +material3 = { group = "androidx.compose.material3", name = "material3", version.ref = "material3" } +material3-windowsize = { group = "androidx.compose.material3", name = "material3-window-size-class", version.ref = "material3" } + +accompanist-swiperefresh = { group = "com.google.accompanist", name = "accompanist-swiperefresh", version = "0.28.0" } + +coil = { group = "io.coil-kt", name = "coil-compose", version = "2.2.2" } + +shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version = "1.0.3" } + +palette = { group = "androidx.palette", name = "palette", version = "1.0.0" } + +exoplayer = { group = "com.google.android.exoplayer", name = "exoplayer", version.ref = "exoplayer" } +exoplayer-mediasession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" } +exoplayer-okhttp = { group = "com.google.android.exoplayer", name = "extension-okhttp", version.ref = "exoplayer" } + +paging-runtime = { group = "androidx.paging", name = "paging-runtime", version = "3.1.1" } +paging-compose = { group = "androidx.paging", name = "paging-compose", version = "1.0.0-alpha17" } + +room-runtime = { group = "androidx.room", name = "room-runtime", version.ref = "room" } +room-compiler = { group = "androidx.room", name = "room-compiler", version.ref = "room" } +room-ktx = { group = "androidx.room", name = "room-ktx", version.ref = "room" } + +apache-lang3 = { group = "org.apache.commons", name = "commons-lang3", version = "3.12.0" } + +hilt = { group = "com.google.dagger", name = "hilt-android", version.ref = "hilt" } +hilt-compiler = { group = "com.google.dagger", name = "hilt-android-compiler", version.ref = "hilt" } + +ktor-client-core = { group = "io.ktor", name = "ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { group = "io.ktor", name = "ktor-client-okhttp", version.ref = "ktor" } +ktor-client-content-negotiation = { group = "io.ktor", name = "ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-encoding = { group = "io.ktor", name = "ktor-client-encoding", version.ref = "ktor" } +ktor-serialization-json = { group = "io.ktor", name = "ktor-serialization-kotlinx-json", version.ref = "ktor" } + +brotli = { group = "org.brotli", name = "dec", version = "0.1.2" } + +opencc4j = { group = "com.github.houbb", name = "opencc4j", version = "1.7.2" } + +desugaring = { group = "com.android.tools", name = "desugar_jdk_libs", version = "1.1.5" } + +junit = { group = "junit", name = "junit", version = "4.13.2" } + +timber = { group = "com.jakewharton.timber", name = "timber", version = "4.7.1" } + +[plugins] +kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts index 102c10aff..87f2e6060 100755 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -1,7 +1,5 @@ @file:Suppress("UnstableApiUsage") - enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") - dependencyResolutionManagement { repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) @@ -10,81 +8,6 @@ dependencyResolutionManagement { mavenCentral() maven { setUrl("https://jitpack.io") } } - - versionCatalogs { - create("libs") { - version("kotlin", "1.8.0") - plugin("kotlin-serialization", "org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") - - library("activity", "androidx.activity", "activity-compose").version("1.5.1") - library("navigation", "androidx.navigation", "navigation-compose").version("2.5.3") - library("hilt-navigation", "androidx.hilt", "hilt-navigation-compose").version("1.0.0") - library("datastore", "androidx.datastore", "datastore-preferences").version("1.0.0") - - version("compose-compiler", "1.4.0") - version("compose", "1.3.3") - library("compose-runtime", "androidx.compose.runtime", "runtime").versionRef("compose") - library("compose-foundation", "androidx.compose.foundation", "foundation").version("1.3.1") - library("compose-ui", "androidx.compose.ui", "ui").versionRef("compose") - library("compose-ui-util", "androidx.compose.ui", "ui-util").versionRef("compose") - library("compose-ui-tooling", "androidx.compose.ui", "ui-tooling").versionRef("compose") - library("compose-animation", "androidx.compose.animation", "animation-graphics").versionRef("compose") - library("compose-animation-graphics", "androidx.compose.animation", "animation-graphics").versionRef("compose") - - version("lifecycle", "2.5.1") - library("viewmodel", "androidx.lifecycle", "lifecycle-viewmodel-ktx").versionRef("lifecycle") - library("viewmodel-compose", "androidx.lifecycle", "lifecycle-viewmodel-compose").versionRef("lifecycle") - - library("material", "androidx.compose.material", "material").version("1.4.0-beta02") - version("material3", "1.1.0-alpha05") - library("material3", "androidx.compose.material3", "material3").versionRef("material3") - library("material3-windowsize", "androidx.compose.material3", "material3-window-size-class").versionRef("material3") - - library("accompanist-swiperefresh", "com.google.accompanist", "accompanist-swiperefresh").version("0.28.0") - - library("coil", "io.coil-kt", "coil-compose").version("2.2.2") - - library("shimmer", "com.valentinilk.shimmer", "compose-shimmer").version("1.0.3") - - library("palette", "androidx.palette", "palette").version("1.0.0") - - version("exoplayer", "2.18.2") - library("exoplayer", "com.google.android.exoplayer", "exoplayer").versionRef("exoplayer") - library("exoplayer-mediasession", "com.google.android.exoplayer", "extension-mediasession").versionRef("exoplayer") - library("exoplayer-okhttp", "com.google.android.exoplayer", "extension-okhttp").versionRef("exoplayer") - - library("paging-runtime", "androidx.paging", "paging-runtime").version("3.1.1") - library("paging-compose", "androidx.paging", "paging-compose").version("1.0.0-alpha17") - - version("room", "2.5.0") - library("room-runtime", "androidx.room", "room-runtime").versionRef("room") - library("room-compiler", "androidx.room", "room-compiler").versionRef("room") - library("room-ktx", "androidx.room", "room-ktx").versionRef("room") - - library("apache-lang3", "org.apache.commons", "commons-lang3").version("3.12.0") - - version("hilt", "2.46.1") - library("hilt", "com.google.dagger", "hilt-android").versionRef("hilt") - library("hilt-compiler", "com.google.dagger", "hilt-android-compiler").versionRef("hilt") - - version("ktor", "2.2.2") - library("ktor-client-core", "io.ktor", "ktor-client-core").versionRef("ktor") - library("ktor-client-okhttp", "io.ktor", "ktor-client-okhttp").versionRef("ktor") - library("ktor-client-content-negotiation", "io.ktor", "ktor-client-content-negotiation").versionRef("ktor") - library("ktor-client-encoding", "io.ktor", "ktor-client-encoding").versionRef("ktor") - library("ktor-serialization-json", "io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor") - - library("brotli", "org.brotli", "dec").version("0.1.2") - - library("opencc4j", "com.github.houbb", "opencc4j").version("1.7.2") - - library("desugaring", "com.android.tools", "desugar_jdk_libs").version("1.1.5") - - library("junit", "junit", "junit").version("4.13.2") - - library("timber", "com.jakewharton.timber", "timber").version("4.7.1") - } - } } rootProject.name = "InnerTune" From 50388b7820fd80efb07cb4d0b3d5ac5695ae8eb4 Mon Sep 17 00:00:00 2001 From: Sdarfeesh <50188628+Sdarfeesh@users.noreply.github.com> Date: Sat, 17 Jun 2023 10:36:13 +0800 Subject: [PATCH 225/323] Update Simplified Chinese --- app/src/main/res/values-zh-rCN/strings.xml | 86 +++++++++++----------- 1 file changed, 43 insertions(+), 43 deletions(-) diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index 6ccb6723d..c19430f57 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -12,17 +12,17 @@ - History - Stats - Quick picks - New release albums - Most played songs + 历史记录 + 统计 + 歌曲快选 + 新专辑 + 最常播放 - Today - Yesterday - This week - Last week + 今天 + 昨天 + 这周 + 上周 搜索 @@ -36,7 +36,7 @@ 播放列表 社区播放列表 精选播放列表 - No results found + 找不到结果 来自您的媒体库 @@ -44,7 +44,7 @@ 喜欢的歌曲 已下载的歌曲 - The playlist is empty + 播放列表为空 重试 @@ -57,20 +57,20 @@ 收听电台 播放 接下来播放 - 添加到队列 - 添加到媒体库 + 加入播放队列 + 加入媒体库 下载 - 移除下载 + 删除下载 导入播放列表 - 添加到播放列表 + 加入播放列表 浏览音乐人 浏览专辑 刷新 分享 移除 - Remove from history + 从记录中移除 在线搜索 - Sync + 同步 新建时间 @@ -83,7 +83,7 @@ 媒体 ID - 媒体类型 + MIME 类型 编码 比特率 采样率 @@ -131,21 +131,21 @@ 已导入此播放列表 - 无歌词 - Sleep timer - End of song + 没有歌词 + 睡眠定时器 + 这首歌曲播放完毕 %d 分钟 - 无可用音源 - 无网络连接 + 没有可用音源 + 没有网络连接 连接超时 未知错误 喜欢 取消喜欢 - 添加到媒体库 + 加入媒体库 从媒体库中移除 @@ -158,12 +158,12 @@ 设置 外观 - Enable dynamic theme + 启用动态主题 深色主题 跟随系统 - Pure black + 纯黑 默认启动选项卡 自定义导航选项卡 歌词文字位置 @@ -178,7 +178,7 @@ 系统默认 启用代理 代理类型 - 代理链接 + 代理 URL 重启以应用变更 播放器与音频 @@ -187,33 +187,33 @@ 保留播放队列 - 跳过歌曲头尾无声片段 + 跳过无声片段 标准化音量 均衡器 - 存储 + 储存 缓存 - Image Cache - Song Cache - Max cache size - Unlimited - 最大图像缓存大小 + 图像缓存 + 歌曲缓存 + 最大缓存大小 + 无限制 + 图像缓存大小 清除图像缓存 - 最大歌曲缓存大小 - Clear song cache + 歌曲缓存大小 + 清除歌曲缓存 已使用 %s - 通用 + 一般 自动下载 - 自动下载新添加的歌曲 - 自动将音乐添加到媒体库 - 播放结束时添加到媒体库 - 播放音乐时展开播放器 + 自动下载新增的歌曲 + 自动将音乐加入媒体库 + 在播放结束时加入媒体库 + 在播放音乐时展开播放器 在通知显示更多按钮 - 显示“添加到媒体库”和“喜欢”按钮 + 显示“加入媒体库”和“喜欢”按钮 隐私 - Pause listen history + 暂停观看记录 暂停搜索记录 清除搜索记录 您确定要清除所有搜索记录吗? From 040a7dd655db7bd2386b40ca4d3eab1947048a36 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Sat, 17 Jun 2023 20:49:06 +0800 Subject: [PATCH 226/323] Redesign about screen --- .../music/ui/screens/settings/AboutScreen.kt | 104 +++++++++++++++--- app/src/main/res/drawable/buymeacoffee.xml | 48 ++++++++ app/src/main/res/drawable/liberapay.xml | 17 +++ 3 files changed, 154 insertions(+), 15 deletions(-) create mode 100644 app/src/main/res/drawable/buymeacoffee.xml create mode 100644 app/src/main/res/drawable/liberapay.xml 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 68f9d5a6a..eb4b57c66 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 @@ -1,20 +1,33 @@ package com.zionhuang.music.ui.screens.settings +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.LocalUriHandler import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.navigation.NavController import com.zionhuang.music.BuildConfig import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.ui.component.PreferenceEntry @OptIn(ExperimentalMaterial3Api::class) @Composable @@ -25,24 +38,85 @@ fun AboutScreen( val uriHandler = LocalUriHandler.current Column( - Modifier + modifier = Modifier + .fillMaxWidth() .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) - .verticalScroll(rememberScrollState()) + .verticalScroll(rememberScrollState()), + horizontalAlignment = Alignment.CenterHorizontally ) { - PreferenceEntry( - title = stringResource(R.string.app_version), - description = BuildConfig.VERSION_NAME, - icon = R.drawable.ic_info, - onClick = { } + Image( + painter = painterResource(R.drawable.ic_launcher_monochrome), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(NavigationBarDefaults.Elevation)) + .clickable { } ) - PreferenceEntry( - title = "GitHub", - description = "z-huang/InnerTune", - icon = R.drawable.ic_github, - onClick = { - uriHandler.openUri("https://github.com/z-huang/InnerTune") - } + + Text( + text = "InnerTune", + style = MaterialTheme.typography.headlineSmall, + modifier = Modifier.padding(top = 8.dp, bottom = 4.dp) ) + + Row(verticalAlignment = Alignment.CenterVertically) { + Text( + text = BuildConfig.VERSION_NAME, + style = MaterialTheme.typography.titleMedium, + color = MaterialTheme.colorScheme.secondary + ) + if (BuildConfig.DEBUG) { + Spacer(Modifier.width(4.dp)) + + Text( + text = "DEBUG", + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .border( + width = 1.dp, + color = MaterialTheme.colorScheme.secondary, + shape = CircleShape + ) + .padding( + horizontal = 4.dp, + vertical = 2.dp + ) + ) + } + } + + Spacer(Modifier.height(8.dp)) + + Row { + IconButton( + onClick = { uriHandler.openUri("https://github.com/z-huang/InnerTune") } + ) { + Icon( + painter = painterResource(R.drawable.ic_github), + contentDescription = null + ) + } + + IconButton( + onClick = { uriHandler.openUri("https://liberapay.com/zionhuang") } + ) { + Icon( + painter = painterResource(R.drawable.liberapay), + contentDescription = null + ) + } + + IconButton( + onClick = { uriHandler.openUri("https://www.buymeacoffee.com/zionhuang") } + ) { + Icon( + painter = painterResource(R.drawable.buymeacoffee), + contentDescription = null + ) + } + } + } TopAppBar( diff --git a/app/src/main/res/drawable/buymeacoffee.xml b/app/src/main/res/drawable/buymeacoffee.xml new file mode 100644 index 000000000..b667cb25e --- /dev/null +++ b/app/src/main/res/drawable/buymeacoffee.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + + + + diff --git a/app/src/main/res/drawable/liberapay.xml b/app/src/main/res/drawable/liberapay.xml new file mode 100644 index 000000000..d296518d8 --- /dev/null +++ b/app/src/main/res/drawable/liberapay.xml @@ -0,0 +1,17 @@ + + + + + + + \ No newline at end of file From 27c95384b040d1bc165132e41beb224848003c50 Mon Sep 17 00:00:00 2001 From: Javi <45560967+javdc@users.noreply.github.com> Date: Sat, 17 Jun 2023 18:16:32 +0200 Subject: [PATCH 227/323] Update spanish translations Signed-off-by: Javi <45560967+javdc@users.noreply.github.com> --- app/src/main/res/values-es/strings.xml | 124 ++++++++++++------------- 1 file changed, 62 insertions(+), 62 deletions(-) diff --git a/app/src/main/res/values-es/strings.xml b/app/src/main/res/values-es/strings.xml index cf3bee9d9..ca13665ea 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -3,7 +3,7 @@ Inicio Canciones Artistas - Álbums + Álbumes Listas de reproducción @@ -13,17 +13,17 @@ - History - Stats - Quick picks - New release albums - Most played songs + Historial + Estadísticas + Selecciones rápidas + Nuevos lanzamientos + Canciones más reproducidas - Today - Yesterday - This week - Last week + Hoy + Ayer + Esta semana + La semana pasada Buscar @@ -32,12 +32,12 @@ Todo Canciones Vídeos - Álbums + Álbumes Artistas Listas de reproducción Listas de la comunidad Listas destacadas - No results found + No se han encontrado resultados De tu biblioteca @@ -45,10 +45,10 @@ Canciones que te gustan Canciones descargadas - The playlist is empty + La lista de reproducción está vacía - Volver a intentar + Reintentar Radio Mezclar @@ -61,20 +61,20 @@ Añadir a la cola Agregar a la biblioteca Descargar - Quitar descarga + Borrar descarga Importar lista de reproducción Agregar a la lista de reproducción Ver artista Ver álbum Cargar de nuevo - Share + Compartir Borrar - Remove from history - Search online - Sync + Eliminar del historial + Buscar online + Sincronizar - Fecha Añadida + Fecha añadida Nombre Artista Año @@ -83,11 +83,11 @@ Tiempo de reproducción - Id del archivo - MIME type - Codecs - Bitrate - Sample rate + ID multimedia + Tipo de MIME + Códecs + Tasa de bits + Frecuencia Intensidad Volumen Tamaño del archivo @@ -120,16 +120,16 @@ %d canciones - %d artist - %d artists + %d artista + %d artistas - %d album - %d albums + %d álbum + %d álbumes - %d playlist - %d playlists + %d lista de reproducción + %d listas de reproducción @@ -137,15 +137,15 @@ Letras no encontradas - Sleep timer - End of song + Temporizador de apagado + Al finalizar la canción - 1 minute - %d minutes + 1 minuto + %d minutos No hay un stream disponible No hay conexión a internet - Se agotó el tiempo + Tiempo de espera agotado Error desconocido @@ -164,15 +164,15 @@ Ajustes Apariencia - Enable dynamic theme + Habilitar tema dinámico Tema oscuro Encendido Apagado Seguir el sistema - Pure black + Negro puro Pestaña de inicio por defecto Personalizar pestañas de navegación - Lyrics text position + Posición del texto de las letras Izquierda Centro Derecha @@ -189,7 +189,7 @@ Reproductor y sonido Calidad del sonido - Auto + Automático Alta Baja Cola persistente @@ -199,39 +199,39 @@ Almacenamiento Caché - Image Cache - Song Cache - Max cache size - Unlimited - Tamaño máximo de la caché de imagen - Borrar la caché de imagen - Tamaño máximo de la caché de música - Clear song cache + Caché de imágenes + Caché de canciones + Tamaño máximo de la caché + Ilimitado + Tamaño máximo de la caché de imágenes + Borrar la caché de imágenes + Tamaño máximo de la caché de canciones + Borrar la caché de canciones %s usado General - Descarga automatica - Descargar canción cuando se agrega a la biblioteca + Descarga automática + Descargar canción cuando se agregue a la biblioteca Agregar canción automáticamente a la biblioteca - Agregue una canción a su biblioteca cuando termine de reproducirse + Agrega la canción a tu biblioteca cuando termine de reproducirse Expandir reproductor inferior al reproducir Más acciones en la notificación - Show add to library and like buttons + Mostrar botones de añadir a la biblioteca y dar me gusta Privacidad - Pause listen history - Pausar historial de búsqueda - Borrar historial de búsqueda - ¿Estás seguro de borrar todo el historial de búsqueda? + Pausar historial de escucha + Pausar historial de búsquedas + Borrar historial de búsquedas + ¿Estás seguro de que quieres borrar todo el historial de búsquedas? Habilitar el proveedor de letras KuGou - Respaldar y restaurar - Respaldar + Copias de seguridad y restauración + Hacer copia de seguridad Restaurar - Respaldo creado con éxito - No se pudo crear respaldo - Error al restaurar el respaldo + Copia de seguridad creada con éxito + No se ha podido crear la copia de seguridad + Error al restaurar la copia de seguridad Acerca de - Version de la app + Versión de la app From 6690ad0772b1449fd66b7a7547f07ea68affbd46 Mon Sep 17 00:00:00 2001 From: Javi <45560967+javdc@users.noreply.github.com> Date: Mon, 19 Jun 2023 19:07:16 +0200 Subject: [PATCH 228/323] Fix search continuation Signed-off-by: Javi <45560967+javdc@users.noreply.github.com> --- .../com/zionhuang/innertube/models/response/SearchResponse.kt | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt index 4aee02938..845a7ac7f 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/SearchResponse.kt @@ -12,7 +12,7 @@ data class SearchResponse( ) { @Serializable data class Contents( - val tabbedSearchResultsRenderer: Tabs, + val tabbedSearchResultsRenderer: Tabs?, ) @Serializable From fe1e98f618f23952556828524a1a59d860cd6e52 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 20 Jun 2023 18:09:23 +0800 Subject: [PATCH 229/323] Migrate to media3 --- app/build.gradle.kts | 10 +- app/src/main/AndroidManifest.xml | 12 +- .../java/com/zionhuang/music/MainActivity.kt | 52 +- .../music/constants/MediaSessionConstants.kt | 16 +- .../music/constants/PreferenceKeys.kt | 5 + .../music/extensions/CoroutineExt.kt | 18 + .../music/extensions/MediaItemExt.kt | 31 +- .../extensions/MediaSessionConnectorExt.kt | 30 - .../zionhuang/music/extensions/PlayerExt.kt | 24 +- .../zionhuang/music/models/MediaMetadata.kt | 23 - .../zionhuang/music/playback/MusicService.kt | 1035 +++++++++++++++-- .../music/playback/PlayerConnection.kt | 75 +- .../zionhuang/music/playback/SongPlayer.kt | 929 --------------- .../music/playback/queues/EmptyQueue.kt | 2 +- .../music/playback/queues/ListQueue.kt | 2 +- .../zionhuang/music/playback/queues/Queue.kt | 2 +- .../playback/queues/YouTubeAlbumRadio.kt | 2 +- .../music/playback/queues/YouTubeQueue.kt | 2 +- .../zionhuang/music/provider/SongsProvider.kt | 2 +- .../zionhuang/music/ui/player/MiniPlayer.kt | 16 +- .../music/ui/player/PlaybackError.kt | 2 +- .../com/zionhuang/music/ui/player/Player.kt | 39 +- .../com/zionhuang/music/ui/player/Queue.kt | 22 +- .../music/ui/screens/settings/AboutScreen.kt | 2 + .../ui/screens/settings/PlayerSettings.kt | 1 + .../ui/screens/settings/PrivacySettings.kt | 4 - .../ui/screens/settings/StorageSettings.kt | 19 +- .../zionhuang/music/utils/CoilBitmapLoader.kt | 34 + .../viewmodels/BackupRestoreViewModel.kt | 4 +- app/src/main/res/drawable/small_icon.xml | 48 + app/src/main/res/values/values.xml | 8 + gradle/libs.versions.toml | 18 +- lint.xml | 6 + 33 files changed, 1271 insertions(+), 1224 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt delete mode 100644 app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt create mode 100644 app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt create mode 100644 app/src/main/res/drawable/small_icon.xml create mode 100644 app/src/main/res/values/values.xml create mode 100644 lint.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 8d41b4e0f..4c625fd79 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -83,6 +83,10 @@ kapt { } dependencies { + implementation(libs.guava) + implementation(libs.coroutines.guava) + implementation(libs.concurrent.futures) + implementation(libs.activity) implementation(libs.navigation) implementation(libs.hilt.navigation) @@ -109,9 +113,9 @@ dependencies { implementation(libs.shimmer) - implementation(libs.exoplayer) - implementation(libs.exoplayer.mediasession) - implementation(libs.exoplayer.okhttp) + implementation(libs.media3) + implementation(libs.media3.session) + implementation(libs.media3.okhttp) implementation(libs.paging.runtime) implementation(libs.paging.compose) diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 7a9288374..6b44c6a63 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -123,8 +123,9 @@ android:stopWithTask="false" tools:ignore="ExportedService"> + + - @@ -139,15 +140,6 @@ - - - - - - - diff --git a/app/src/main/java/com/zionhuang/music/MainActivity.kt b/app/src/main/java/com/zionhuang/music/MainActivity.kt index 8f3f838fb..a6c98df01 100644 --- a/app/src/main/java/com/zionhuang/music/MainActivity.kt +++ b/app/src/main/java/com/zionhuang/music/MainActivity.kt @@ -5,6 +5,7 @@ import android.content.ComponentName import android.content.Context import android.content.Intent import android.content.ServiceConnection +import android.graphics.drawable.BitmapDrawable import android.os.Build import android.os.Bundle import android.os.IBinder @@ -39,6 +40,10 @@ import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastAny import androidx.compose.ui.util.fastForEach import androidx.core.view.WindowCompat +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken import androidx.navigation.NavDestination.Companion.hierarchy import androidx.navigation.NavType import androidx.navigation.compose.NavHost @@ -46,8 +51,9 @@ import androidx.navigation.compose.composable import androidx.navigation.compose.currentBackStackEntryAsState import androidx.navigation.compose.rememberNavController import androidx.navigation.navArgument -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.Player +import coil.imageLoader +import coil.request.ImageRequest +import com.google.common.util.concurrent.MoreExecutors import com.valentinilk.shimmer.LocalShimmerTheme import com.zionhuang.music.constants.* import com.zionhuang.music.db.MusicDatabase @@ -86,7 +92,8 @@ import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import dagger.hilt.android.AndroidEntryPoint import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.withContext import javax.inject.Inject @AndroidEntryPoint @@ -123,8 +130,15 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) WindowCompat.setDecorFitsSystemWindows(window, false) + // Connect to service so that notification and background playing will work + val sessionToken = SessionToken(this, ComponentName(this, MusicService::class.java)) + val controllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + controllerFuture.addListener( + { controllerFuture.get() }, + MoreExecutors.directExecutor() + ) + setContent { - val coroutineScope = rememberCoroutineScope() val enableDynamicTheme by rememberPreference(DynamicThemeKey, defaultValue = true) val darkTheme by rememberEnumPreference(DarkModeKey, defaultValue = DarkMode.AUTO) val pureBlack by rememberPreference(PureBlackKey, defaultValue = false) @@ -139,18 +153,24 @@ class MainActivity : ComponentActivity() { mutableStateOf(DefaultThemeColor) } - DisposableEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) { - if (!enableDynamicTheme) { + LaunchedEffect(playerConnection, enableDynamicTheme, isSystemInDarkTheme) { + val playerConnection = playerConnection + if (!enableDynamicTheme || playerConnection == null) { themeColor = DefaultThemeColor - } else { - playerConnection?.onBitmapChanged = { bitmap -> - coroutineScope.launch(Dispatchers.IO) { - themeColor = bitmap?.extractThemeColor() ?: DefaultThemeColor - } - } + return@LaunchedEffect } - onDispose { - playerConnection?.onBitmapChanged = {} + playerConnection.service.currentMediaMetadata.collectLatest { song -> + themeColor = if (song != null) { + withContext(Dispatchers.IO) { + val result = imageLoader.execute( + ImageRequest.Builder(this@MainActivity) + .data(song.thumbnailUrl) + .allowHardware(false) // pixel access is not supported on Config#HARDWARE bitmaps + .build() + ) + (result.drawable as BitmapDrawable).bitmap.extractThemeColor() + } + } else DefaultThemeColor } } @@ -216,7 +236,8 @@ class MainActivity : ComponentActivity() { } val navigationBarHeight by animateDpAsState( targetValue = if (shouldShowNavigationBar) NavigationBarHeight else 0.dp, - animationSpec = NavigationBarAnimationSpec + animationSpec = NavigationBarAnimationSpec, + label = "" ) val playerBottomSheetState = rememberBottomSheetState( @@ -529,6 +550,7 @@ class MainActivity : ComponentActivity() { ) { Crossfade( targetState = searchSource, + label = "", modifier = Modifier .fillMaxSize() .padding(bottom = if (!playerBottomSheetState.isDismissed) MiniPlayerHeight else 0.dp) diff --git a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt index 19c74975c..0f95dbf60 100644 --- a/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt +++ b/app/src/main/java/com/zionhuang/music/constants/MediaSessionConstants.kt @@ -1,13 +1,11 @@ package com.zionhuang.music.constants -object MediaSessionConstants { - const val ACTION_TOGGLE_LIBRARY = "action_toggle_library" - const val ACTION_ADD_TO_LIBRARY = "action_add_to_library" - const val ACTION_REMOVE_FROM_LIBRARY = "action_remove_from_library" - - const val ACTION_TOGGLE_LIKE = "action_toggle_like" - const val ACTION_LIKE = "action_like" - const val ACTION_UNLIKE = "action_unlike" +import android.os.Bundle +import androidx.media3.session.SessionCommand - const val ACTION_TOGGLE_SHUFFLE = "action_shuffle" +object MediaSessionConstants { + const val ACTION_TOGGLE_LIBRARY = "TOGGLE_LIBRARY" + const val ACTION_TOGGLE_LIKE = "TOGGLE_LIKE" + val CommandToggleLibrary = SessionCommand(ACTION_TOGGLE_LIBRARY, Bundle.EMPTY) + val CommandToggleLike = SessionCommand(ACTION_TOGGLE_LIKE, Bundle.EMPTY) } 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 cc3e853c8..132b3cbe3 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -18,6 +18,11 @@ val ProxyUrlKey = stringPreferencesKey("proxyUrl") val ProxyTypeKey = stringPreferencesKey("proxyType") val AudioQualityKey = stringPreferencesKey("audioQuality") + +enum class AudioQuality { + AUTO, HIGH, LOW +} + val PersistentQueueKey = booleanPreferencesKey("persistentQueue") val SkipSilenceKey = booleanPreferencesKey("skipSilence") val AudioNormalizationKey = booleanPreferencesKey("audioNormalization") diff --git a/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt b/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt new file mode 100644 index 000000000..e03b292d7 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/extensions/CoroutineExt.kt @@ -0,0 +1,18 @@ +package com.zionhuang.music.extensions + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.collectLatest +import kotlinx.coroutines.launch + +fun Flow.collect(scope: CoroutineScope, action: suspend (value: T) -> Unit) { + scope.launch { + collect(action) + } +} + +fun Flow.collectLatest(scope: CoroutineScope, action: suspend (value: T) -> Unit) { + scope.launch { + collectLatest(action) + } +} diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt index bdda724d8..50ae92c12 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt @@ -1,6 +1,8 @@ package com.zionhuang.music.extensions -import com.google.android.exoplayer2.MediaItem +import androidx.core.net.toUri +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.db.entities.Song import com.zionhuang.music.models.MediaMetadata @@ -14,6 +16,15 @@ fun Song.toMediaItem() = MediaItem.Builder() .setUri(song.id) .setCustomCacheKey(song.id) .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) .build() fun SongItem.toMediaItem() = MediaItem.Builder() @@ -21,6 +32,15 @@ fun SongItem.toMediaItem() = MediaItem.Builder() .setUri(id) .setCustomCacheKey(id) .setTag(toMediaMetadata()) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnail.toUri()) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) .build() fun MediaMetadata.toMediaItem() = MediaItem.Builder() @@ -28,4 +48,13 @@ fun MediaMetadata.toMediaItem() = MediaItem.Builder() .setUri(id) .setCustomCacheKey(id) .setTag(this) + .setMediaMetadata( + androidx.media3.common.MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(thumbnailUrl?.toUri()) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) .build() \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt deleted file mode 100644 index 48b111139..000000000 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaSessionConnectorExt.kt +++ /dev/null @@ -1,30 +0,0 @@ -package com.zionhuang.music.extensions - -import android.support.v4.media.MediaDescriptionCompat -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ext.mediasession.TimelineQueueNavigator - -fun MediaSessionConnector.setQueueNavigator(getMediaDescription: (player: Player, windowIndex: Int) -> MediaDescriptionCompat) = setQueueNavigator(object : TimelineQueueNavigator(mediaSession, Int.MAX_VALUE) { - override fun getMediaDescription(player: Player, windowIndex: Int) = getMediaDescription(player, windowIndex) - override fun onSkipToPrevious(player: Player) { - super.onSkipToPrevious(player) - if (player.playerError != null) { - player.prepare() - } - } - - override fun onSkipToNext(player: Player) { - super.onSkipToNext(player) - if (player.playerError != null) { - player.prepare() - } - } - - override fun onSkipToQueueItem(player: Player, id: Long) { - super.onSkipToQueueItem(player, id) - if (player.playerError != null) { - player.prepare() - } - } -}) diff --git a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt index f78a68535..f2785c09b 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/PlayerExt.kt @@ -1,13 +1,12 @@ package com.zionhuang.music.extensions -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.Player.REPEAT_MODE_OFF -import com.google.android.exoplayer2.Timeline +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Timeline import com.zionhuang.music.models.MediaMetadata -import java.util.* -import kotlin.collections.AbstractList +import java.util.ArrayDeque fun Player.togglePlayPause() { playWhenReady = !playWhenReady @@ -59,17 +58,6 @@ fun Player.getCurrentQueueIndex(): Int { return index } -fun Player.mediaItemIndexOf(mediaId: String?): Int? { - if (mediaId == null) return null - for (i in 0 until mediaItemCount) { - val item = getMediaItemAt(i) - if (item.mediaId == mediaId) { - return i - } - } - return null -} - val Player.currentMetadata: MediaMetadata? get() = currentMediaItem?.metadata diff --git a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt index 2aeae7f66..f25a8c641 100644 --- a/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt +++ b/app/src/main/java/com/zionhuang/music/models/MediaMetadata.kt @@ -1,10 +1,6 @@ package com.zionhuang.music.models -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.MediaMetadataCompat.* import androidx.compose.runtime.Immutable -import androidx.core.net.toUri -import androidx.core.os.bundleOf import com.zionhuang.innertube.models.SongItem import com.zionhuang.music.db.entities.* import com.zionhuang.music.ui.utils.resize @@ -29,21 +25,6 @@ data class MediaMetadata( val title: String, ) : Serializable - fun toMediaDescription(): MediaDescriptionCompat = builder - .setMediaId(id) - .setTitle(title) - .setSubtitle(artists.joinToString { it.name }) - .setDescription(artists.joinToString { it.name }) - .setIconUri(thumbnailUrl?.resize(544, 544)?.toUri()) - .setExtras( - bundleOf( - METADATA_KEY_DURATION to duration * 1000L, - METADATA_KEY_ARTIST to artists.joinToString { it.name }, - METADATA_KEY_ALBUM to album?.title - ) - ) - .build() - fun toSongEntity() = SongEntity( id = id, title = title, @@ -52,10 +33,6 @@ data class MediaMetadata( albumId = album?.id, albumName = album?.title ) - - companion object { - private val builder = MediaDescriptionCompat.Builder() - } } fun Song.toMediaMetadata() = MediaMetadata( 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 784137d32..44f544f0b 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -1,168 +1,949 @@ package com.zionhuang.music.playback -import android.app.Notification +import android.app.PendingIntent import android.content.ContentResolver +import android.content.Context import android.content.Intent +import android.database.SQLException +import android.media.audiofx.AudioEffect +import android.net.ConnectivityManager import android.net.Uri import android.os.Binder import android.os.Bundle -import android.os.IBinder -import android.support.v4.media.MediaBrowserCompat -import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_BROWSABLE -import android.support.v4.media.MediaBrowserCompat.MediaItem.FLAG_PLAYABLE -import android.support.v4.media.MediaDescriptionCompat import androidx.annotation.DrawableRes -import androidx.core.content.ContextCompat.startForegroundService +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.core.content.getSystemService import androidx.core.net.toUri -import androidx.media.MediaBrowserServiceCompat -import androidx.media.session.MediaButtonReceiver -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.upstream.cache.SimpleCache +import androidx.datastore.preferences.core.edit +import androidx.media3.common.AudioAttributes +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ALBUM +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_ARTIST +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ALBUMS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_ARTISTS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_FOLDER_PLAYLISTS +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_MUSIC +import androidx.media3.common.MediaMetadata.MEDIA_TYPE_PLAYLIST +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.EVENT_IS_PLAYING_CHANGED +import androidx.media3.common.Player.EVENT_PLAYBACK_STATE_CHANGED +import androidx.media3.common.Player.EVENT_PLAY_WHEN_READY_CHANGED +import androidx.media3.common.Player.EVENT_POSITION_DISCONTINUITY +import androidx.media3.common.Player.EVENT_TIMELINE_CHANGED +import androidx.media3.common.Player.STATE_ENDED +import androidx.media3.common.Player.STATE_IDLE +import androidx.media3.common.Timeline +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DefaultDataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.Cache +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.CacheEvictor +import androidx.media3.datasource.cache.LeastRecentlyUsedCacheEvictor +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.datasource.okhttp.OkHttpDataSource +import androidx.media3.exoplayer.DefaultRenderersFactory +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.PlaybackStats +import androidx.media3.exoplayer.analytics.PlaybackStatsListener +import androidx.media3.exoplayer.audio.AudioCapabilities +import androidx.media3.exoplayer.audio.DefaultAudioSink +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor +import androidx.media3.exoplayer.audio.SilenceSkippingAudioProcessor.DEFAULT_SILENCE_THRESHOLD_LEVEL +import androidx.media3.exoplayer.audio.SonicAudioProcessor +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder +import androidx.media3.extractor.ExtractorsFactory +import androidx.media3.extractor.mkv.MatroskaExtractor +import androidx.media3.extractor.mp4.FragmentedMp4Extractor +import androidx.media3.session.CommandButton +import androidx.media3.session.DefaultMediaNotificationProvider +import androidx.media3.session.LibraryResult +import androidx.media3.session.MediaLibraryService +import androidx.media3.session.MediaSession +import androidx.media3.session.SessionCommand +import androidx.media3.session.SessionResult +import com.google.common.collect.ImmutableList +import com.google.common.util.concurrent.Futures +import com.google.common.util.concurrent.ListenableFuture +import com.zionhuang.innertube.YouTube +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.models.response.PlayerResponse +import com.zionhuang.music.ACTION_SHOW_BOTTOM_SHEET +import com.zionhuang.music.MainActivity import com.zionhuang.music.R +import com.zionhuang.music.constants.AudioNormalizationKey +import com.zionhuang.music.constants.AudioQuality +import com.zionhuang.music.constants.AudioQualityKey +import com.zionhuang.music.constants.AutoAddToLibraryKey +import com.zionhuang.music.constants.MaxSongCacheSizeKey +import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY +import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE +import com.zionhuang.music.constants.MediaSessionConstants.CommandToggleLibrary +import com.zionhuang.music.constants.MediaSessionConstants.CommandToggleLike +import com.zionhuang.music.constants.NotificationMoreActionKey +import com.zionhuang.music.constants.PauseListenHistoryKey +import com.zionhuang.music.constants.PersistentQueueKey +import com.zionhuang.music.constants.PlayerVolumeKey +import com.zionhuang.music.constants.RepeatModeKey +import com.zionhuang.music.constants.ShowLyricsKey +import com.zionhuang.music.constants.SkipSilenceKey import com.zionhuang.music.constants.SongSortType import com.zionhuang.music.db.MusicDatabase +import com.zionhuang.music.db.entities.Event +import com.zionhuang.music.db.entities.FormatEntity +import com.zionhuang.music.db.entities.LyricsEntity 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.RelatedSongMap +import com.zionhuang.music.db.entities.Song +import com.zionhuang.music.db.entities.SongEntity +import com.zionhuang.music.extensions.collect +import com.zionhuang.music.extensions.collectLatest +import com.zionhuang.music.extensions.currentMetadata +import com.zionhuang.music.extensions.findNextMediaItemById +import com.zionhuang.music.extensions.mediaItems +import com.zionhuang.music.extensions.metadata +import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.lyrics.LyricsHelper +import com.zionhuang.music.models.PersistQueue import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.EmptyQueue +import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.playback.queues.Queue +import com.zionhuang.music.playback.queues.YouTubeQueue +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.getSongFile +import com.zionhuang.music.utils.preference import dagger.hilt.android.AndroidEntryPoint -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.distinctUntilChangedBy import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.guava.future +import kotlinx.coroutines.launch +import kotlinx.coroutines.plus +import kotlinx.coroutines.runBlocking +import kotlinx.coroutines.withContext +import okhttp3.OkHttpClient +import timber.log.Timber +import java.io.ObjectInputStream +import java.io.ObjectOutputStream +import java.net.ConnectException +import java.net.SocketTimeoutException +import java.net.UnknownHostException +import java.time.LocalDateTime import javax.inject.Inject +import kotlin.math.min +import kotlin.math.pow +import kotlin.time.Duration.Companion.minutes +@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) @AndroidEntryPoint -class MusicService : MediaBrowserServiceCompat() { +class MusicService : MediaLibraryService(), + Player.Listener, + PlaybackStatsListener.Callback, + MediaLibraryService.MediaLibrarySession.Callback { @Inject lateinit var database: MusicDatabase @Inject lateinit var lyricsHelper: LyricsHelper - private val coroutineScope = CoroutineScope(Dispatchers.Main) + Job() + private val scope = CoroutineScope(Dispatchers.Main) + Job() private val binder = MusicBinder() - private lateinit var songPlayer: SongPlayer + + private lateinit var connectivityManager: ConnectivityManager + val bitmapProvider = BitmapProvider(this) + + private val autoAddSong by preference(this, AutoAddToLibraryKey, true) + private val audioQuality by enumPreference(this, AudioQualityKey, AudioQuality.AUTO) + + private var currentQueue: Queue = EmptyQueue + var queueTitle: String? = null + + val currentMediaMetadata = MutableStateFlow(null) + private val currentSongFlow = currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.song(mediaMetadata?.id) + } + private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> + database.format(mediaMetadata?.id) + } + private var currentSong: Song? = null + + private val normalizeFactor = MutableStateFlow(1f) + val playerVolume = MutableStateFlow(dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) + + private var sleepTimerJob: Job? = null + var sleepTimerTriggerTime by mutableStateOf(-1L) + var pauseWhenSongEnd by mutableStateOf(false) + + private lateinit var cacheEvictor: CacheEvictor + lateinit var cache: Cache + lateinit var player: ExoPlayer + private lateinit var mediaSession: MediaLibrarySession override fun onCreate() { super.onCreate() - songPlayer = SongPlayer(this, database, lyricsHelper, coroutineScope, object : PlayerNotificationManager.NotificationListener { - override fun onNotificationCancelled(notificationId: Int, dismissedByUser: Boolean) { - stopForeground(STOP_FOREGROUND_REMOVE) + setMediaNotificationProvider( + DefaultMediaNotificationProvider(this, { NOTIFICATION_ID }, CHANNEL_ID, R.string.music_player) + .apply { + setSmallIcon(R.drawable.small_icon) + } + ) + cacheEvictor = when (val cacheSize = dataStore[MaxSongCacheSizeKey] ?: 1024) { + -1 -> NoOpCacheEvictor() + else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) + } + cache = SimpleCache(cacheDir.resolve("exoplayer"), cacheEvictor, StandaloneDatabaseProvider(this)) + player = ExoPlayer.Builder(this) + .setMediaSourceFactory(createMediaSourceFactory()) + .setRenderersFactory(createRenderersFactory()) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_NETWORK) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) + .build(), true + ) + .setSeekBackIncrementMs(5000) + .setSeekForwardIncrementMs(5000) + .build() + .apply { + addListener(this@MusicService) + addAnalyticsListener(PlaybackStatsListener(false, this@MusicService)) + repeatMode = dataStore.get(RepeatModeKey, Player.REPEAT_MODE_OFF) + } + mediaSession = MediaLibrarySession.Builder(this, player, this) + .setSessionActivity( + PendingIntent.getActivity( + this, + 0, + Intent(this, MainActivity::class.java).apply { + action = ACTION_SHOW_BOTTOM_SHEET + }, + PendingIntent.FLAG_IMMUTABLE + ) + ) + .setBitmapLoader(CoilBitmapLoader(this, scope)) + .build() + connectivityManager = getSystemService()!! + + combine(playerVolume, normalizeFactor) { playerVolume, normalizeFactor -> + playerVolume * normalizeFactor + }.collectLatest(scope) { + player.volume = it + } + + playerVolume.debounce(1000).collect(scope) { volume -> + dataStore.edit { settings -> + settings[PlayerVolumeKey] = volume + } + } + + currentSongFlow.collect(scope) { song -> + currentSong = song + updateNotification(song) + } + + combine( + currentMediaMetadata.distinctUntilChangedBy { it?.id }, + dataStore.data.map { it[ShowLyricsKey] ?: false }.distinctUntilChanged() + ) { mediaMetadata, showLyrics -> + mediaMetadata to showLyrics + }.collectLatest(scope) { (mediaMetadata, showLyrics) -> + if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { + val lyrics = lyricsHelper.getLyrics(mediaMetadata) + database.query { + upsert( + LyricsEntity( + id = mediaMetadata.id, + lyrics = lyrics + ) + ) + } + } + } + + dataStore.data + .map { it[SkipSilenceKey] ?: true } + .distinctUntilChanged() + .collectLatest(scope) { + player.skipSilenceEnabled = it + } + + combine( + currentFormat, + dataStore.data + .map { it[AudioNormalizationKey] ?: true } + .distinctUntilChanged() + ) { format, normalizeAudio -> + format to normalizeAudio + }.collectLatest(scope) { (format, normalizeAudio) -> + normalizeFactor.value = if (normalizeAudio && format?.loudnessDb != null) { + min(10f.pow(-format.loudnessDb.toFloat() / 20), 1f) + } else { + 1f + } + } + + dataStore.data + .map { it[NotificationMoreActionKey] ?: true } + .distinctUntilChanged() + .collectLatest(scope) { + updateNotification(currentSong) + } + + if (dataStore.get(PersistentQueueKey, true)) { + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> + ObjectInputStream(fis).use { oos -> + oos.readObject() as PersistQueue + } + } + }.onSuccess { queue -> + playQueue( + queue = ListQueue( + title = queue.title, + items = queue.items.map { it.toMediaItem() }, + startIndex = queue.mediaItemIndex, + position = queue.position + ), + playWhenReady = false + ) + } + } + } + + private fun updateNotification(song: Song?) { + mediaSession.setCustomLayout( + if (dataStore.get(NotificationMoreActionKey, true)) + listOf( + CommandButton.Builder() + .setDisplayName(getString(if (song?.song?.inLibrary != null) R.string.action_remove_from_library else R.string.action_add_to_library)) + .setIconResId(if (song?.song?.inLibrary != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add) + .setSessionCommand(CommandToggleLibrary) + .setEnabled(song != null) + .build(), + CommandButton.Builder() + .setDisplayName(getString(if (currentSong?.song?.liked == true) R.string.action_remove_like else R.string.action_like)) + .setIconResId(if (song?.song?.liked == true) R.drawable.ic_favorite else R.drawable.ic_favorite_border) + .setSessionCommand(CommandToggleLike) + .setEnabled(song != null) + .build() + ) + else emptyList() + ) + } + + private suspend fun recoverSong(mediaId: String, playerResponse: PlayerResponse? = null) { + val song = database.song(mediaId).first() + val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return + val duration = song?.song?.duration?.takeIf { it != -1 } + ?: mediaMetadata.duration.takeIf { it != -1 } + ?: (playerResponse ?: YouTube.player(mediaId).getOrNull())?.videoDetails?.lengthSeconds?.toInt() + ?: -1 + database.query { + if (song == null) insert(mediaMetadata.copy(duration = duration)) + else if (song.song.duration == -1) update(song.song.copy(duration = duration)) + } + if (!database.hasRelatedSongs(mediaId)) { + val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return + val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return + database.query { + relatedPage.songs + .map(SongItem::toMediaMetadata) + .onEach(::insert) + .map { + RelatedSongMap( + songId = mediaId, + relatedSongId = it.id + ) + } + .forEach(::insert) + } + } + } + + private fun updateQueueTitle(title: String?) { + queueTitle = title + } + + fun playQueue(queue: Queue, playWhenReady: Boolean = true) { + currentQueue = queue + updateQueueTitle(null) + player.shuffleModeEnabled = false + if (queue.preloadItem != null) { + player.setMediaItem(queue.preloadItem!!.toMediaItem()) + player.prepare() + player.playWhenReady = playWhenReady + } + + scope.launch { + val initialStatus = withContext(Dispatchers.IO) { queue.getInitialStatus() } + if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch + initialStatus.title?.let { queueTitle -> + updateQueueTitle(queueTitle) + } + if (queue.preloadItem != null) { + player.addMediaItems(0, initialStatus.items.subList(0, initialStatus.mediaItemIndex)) + player.addMediaItems(initialStatus.items.subList(initialStatus.mediaItemIndex + 1, initialStatus.items.size)) + } else { + player.setMediaItems(initialStatus.items, if (initialStatus.mediaItemIndex > 0) initialStatus.mediaItemIndex else 0, initialStatus.position) + player.prepare() + player.playWhenReady = playWhenReady + } + } + } + + fun startRadioSeamlessly() { + val currentMediaMetadata = player.currentMetadata ?: return + if (player.currentMediaItemIndex > 0) player.removeMediaItems(0, player.currentMediaItemIndex) + if (player.currentMediaItemIndex < player.mediaItemCount - 1) player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) + scope.launch { + val radioQueue = YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) + val initialStatus = radioQueue.getInitialStatus() + initialStatus.title?.let { queueTitle -> + updateQueueTitle(queueTitle) + } + player.addMediaItems(initialStatus.items.drop(1)) + currentQueue = radioQueue + } + } + + fun playNext(items: List) { + player.addMediaItems(if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, items) + player.prepare() + } + + fun addToQueue(items: List) { + player.addMediaItems(items) + player.prepare() + } + + fun toggleLibrary() { + database.query { + currentSong?.let { + update(it.song.toggleLibrary()) + } + } + } + + fun toggleLike() { + database.query { + currentSong?.let { + update(it.song.toggleLike()) } + } + } + + private fun addToLibrary(mediaMetadata: com.zionhuang.music.models.MediaMetadata) { + database.query { + insert(mediaMetadata) + } + } - override fun onNotificationPosted(notificationId: Int, notification: Notification, ongoing: Boolean) { - if (ongoing) { - startForegroundService(this@MusicService, Intent(this@MusicService, MusicService::class.java)) - startForeground(notificationId, notification) + fun setSleepTimer(minute: Int) { + sleepTimerJob?.cancel() + sleepTimerJob = null + if (minute == -1) { + pauseWhenSongEnd = true + } else { + sleepTimerTriggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds + sleepTimerJob = scope.launch { + delay(minute.minutes) + player.pause() + sleepTimerTriggerTime = -1L + } + } + } + + fun clearSleepTimer() { + sleepTimerJob?.cancel() + sleepTimerJob = null + pauseWhenSongEnd = false + sleepTimerTriggerTime = -1L + } + + private fun openAudioEffectSession() { + sendBroadcast( + Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + ) + } + + private fun closeAudioEffectSession() { + sendBroadcast( + Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) + } + ) + } + + /** + * Auto load more + */ + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (reason != Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT && + player.playbackState != STATE_IDLE && + player.mediaItemCount - player.currentMediaItemIndex <= 5 && + currentQueue.hasNextPage() + ) { + scope.launch { + val mediaItems = currentQueue.nextPage() + if (player.playbackState != STATE_IDLE) { + player.addMediaItems(mediaItems) + } + } + } + if (mediaItem == null) { + bitmapProvider.clear() + } + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } + + override fun onPositionDiscontinuity(oldPosition: Player.PositionInfo, newPosition: Player.PositionInfo, @Player.DiscontinuityReason reason: Int) { + if (reason == Player.DISCONTINUITY_REASON_AUTO_TRANSITION && autoAddSong) { + oldPosition.mediaItem?.metadata?.let { + addToLibrary(it) + } + } + } + + override fun onPlaybackStateChanged(@Player.State playbackState: Int) { + if (playbackState == STATE_IDLE) { + currentQueue = EmptyQueue + player.shuffleModeEnabled = false + updateQueueTitle("") + } + if (playbackState == STATE_ENDED) { + if (autoAddSong) { + player.currentMetadata?.let { + addToLibrary(it) + } + } + if (pauseWhenSongEnd) { + pauseWhenSongEnd = false + player.pause() + } + } + } + + override fun onEvents(player: Player, events: Player.Events) { + if (events.containsAny(EVENT_PLAYBACK_STATE_CHANGED, EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + if (player.playbackState != STATE_ENDED && player.playWhenReady) { + openAudioEffectSession() + } else { + closeAudioEffectSession() + } + } + if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { + currentMediaMetadata.value = player.currentMetadata + } + } + + + override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { + if (shuffleModeEnabled) { + // Always put current playing item at first + val shuffledIndices = IntArray(player.mediaItemCount) { it } + shuffledIndices.shuffle() + shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = shuffledIndices[0] + shuffledIndices[0] = player.currentMediaItemIndex + player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) + } + } + + override fun onRepeatModeChanged(repeatMode: Int) { + scope.launch { + dataStore.edit { settings -> + settings[RepeatModeKey] = repeatMode + } + } + } + + private fun createOkHttpDataSourceFactory() = + OkHttpDataSource.Factory( + OkHttpClient.Builder() + .proxy(YouTube.proxy) + .build() + ) + + private fun createCacheDataSource() = CacheDataSource.Factory() + .setCache(cache) + .setUpstreamDataSourceFactory(DefaultDataSource.Factory(this, createOkHttpDataSourceFactory())) + + private fun createDataSourceFactory(): ResolvingDataSource.Factory { + val songUrlCache = HashMap>() + return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> + val mediaId = dataSpec.key ?: error("No media id") + + if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec + } + + songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(it.first.toUri()) + } + + // Check whether format exists so that users from older version can view format details + // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently + val playedFormat = runBlocking(Dispatchers.IO) { database.format(mediaId).first() } + val song = runBlocking(Dispatchers.IO) { database.song(mediaId).first() } + if (playedFormat != null && song?.song?.downloadState == SongEntity.STATE_DOWNLOADED) { + scope.launch(Dispatchers.IO) { recoverSong(mediaId) } + return@Factory dataSpec.withUri(getSongFile(this, mediaId).toUri()) + } + + val playerResponse = runBlocking(Dispatchers.IO) { + YouTube.player(mediaId) + }.getOrElse { throwable -> + when (throwable) { + is ConnectException, is UnknownHostException -> { + throw PlaybackException(getString(R.string.error_no_internet), throwable, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) + } + + is SocketTimeoutException -> { + throw PlaybackException(getString(R.string.error_timeout), throwable, PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) + } + + else -> throw PlaybackException(getString(R.string.error_unknown), throwable, PlaybackException.ERROR_CODE_REMOTE_ERROR) + } + } + if (playerResponse.playabilityStatus.status != "OK") { + throw PlaybackException(playerResponse.playabilityStatus.reason, null, PlaybackException.ERROR_CODE_REMOTE_ERROR) + } + + val format = + if (playedFormat != null) { + playerResponse.streamingData?.adaptiveFormats?.find { + // Use itag to identify previous played format + it.itag == playedFormat.itag + } } else { - stopForeground(STOP_FOREGROUND_DETACH) + playerResponse.streamingData?.adaptiveFormats + ?.filter { it.isAudio } + ?.maxByOrNull { + it.bitrate * when (audioQuality) { + AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 + AudioQuality.HIGH -> 1 + AudioQuality.LOW -> -1 + } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream + } + } ?: throw PlaybackException(getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) + + database.query { + upsert( + FormatEntity( + id = mediaId, + itag = format.itag, + mimeType = format.mimeType.split(";")[0], + codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), + bitrate = format.bitrate, + sampleRate = format.audioSampleRate, + contentLength = format.contentLength!!, + loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb + ) + ) + } + scope.launch(Dispatchers.IO) { recoverSong(mediaId, playerResponse) } + + songUrlCache[mediaId] = format.url to playerResponse.streamingData!!.expiresInSeconds * 1000L + dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) + } + } + + private fun createExtractorsFactory() = ExtractorsFactory { + arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) + } + + private fun createMediaSourceFactory() = DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) + + private fun createRenderersFactory() = object : DefaultRenderersFactory(this) { + override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = + DefaultAudioSink.Builder() + .setAudioCapabilities(AudioCapabilities.getCapabilities(context)) + .setEnableFloatOutput(enableFloatOutput) + .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) + .setOffloadMode(if (enableOffload) DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else DefaultAudioSink.OFFLOAD_MODE_DISABLED) + .setAudioProcessorChain( + DefaultAudioSink.DefaultAudioProcessorChain( + emptyArray(), + SilenceSkippingAudioProcessor(2_000_000, 20_000, DEFAULT_SILENCE_THRESHOLD_LEVEL), + SonicAudioProcessor() + ) + ) + .build() + } + + override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { + val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem + if (playbackStats.totalPlayTimeMs >= 30000 && !dataStore.get(PauseListenHistoryKey, false)) { + database.query { + incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + try { + insert( + Event( + songId = mediaItem.mediaId, + timestamp = LocalDateTime.now(), + playTime = playbackStats.totalPlayTimeMs + ) + ) + } catch (_: SQLException) { } } - }) - sessionToken = songPlayer.mediaSession.sessionToken + } } - override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { - super.onStartCommand(intent, flags, startId) - MediaButtonReceiver.handleIntent(songPlayer.mediaSession, intent) - return START_STICKY + private fun saveQueueToDisk() { + if (player.playbackState == STATE_IDLE) { + filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() + return + } + val persistQueue = PersistQueue( + title = queueTitle, + items = player.mediaItems.mapNotNull { it.metadata }, + mediaItemIndex = player.currentMediaItemIndex, + position = player.currentPosition + ) + runCatching { + filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> + ObjectOutputStream(fos).use { oos -> + oos.writeObject(persistQueue) + } + } + }.onFailure { + it.printStackTrace() + } } override fun onDestroy() { - songPlayer.onDestroy() + if (dataStore.get(PersistentQueueKey, true)) { + saveQueueToDisk() + } + mediaSession.release() + player.removeListener(this) + player.release() + cache.release() super.onDestroy() } - override fun onBind(intent: Intent): IBinder? { - val superBinder = super.onBind(intent) - return when (intent.action) { - SERVICE_INTERFACE -> superBinder - else -> binder - } - } + override fun onBind(intent: Intent?) = super.onBind(intent) ?: binder override fun onTaskRemoved(rootIntent: Intent?) { super.onTaskRemoved(rootIntent) stopSelf() } - inner class MusicBinder : Binder() { - val player: ExoPlayer - get() = this@MusicService.songPlayer.player + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo) = mediaSession - val songPlayer: SongPlayer - get() = this@MusicService.songPlayer + override fun onConnect(session: MediaSession, controller: MediaSession.ControllerInfo): MediaSession.ConnectionResult { + val connectionResult = super.onConnect(session, controller) + return MediaSession.ConnectionResult.accept( + connectionResult.availableSessionCommands.buildUpon() + .add(CommandToggleLibrary) + .add(CommandToggleLike).build(), + connectionResult.availablePlayerCommands + ) + } - val cache: SimpleCache - get() = this@MusicService.songPlayer.cache + override fun onCustomCommand( + session: MediaSession, + controller: MediaSession.ControllerInfo, + customCommand: SessionCommand, + args: Bundle, + ): ListenableFuture { + when (customCommand.customAction) { + ACTION_TOGGLE_LIKE -> toggleLike() + ACTION_TOGGLE_LIBRARY -> toggleLibrary() + } + return Futures.immediateFuture(SessionResult(SessionResult.RESULT_SUCCESS)) } + override fun onGetLibraryRoot( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + params: LibraryParams?, + ): ListenableFuture> = Futures.immediateFuture( + LibraryResult.ofItem( + MediaItem.Builder() + .setMediaId(ROOT) + .setMediaMetadata( + MediaMetadata.Builder() + .setIsPlayable(false) + .setIsBrowsable(false) + .setMediaType(MediaMetadata.MEDIA_TYPE_FOLDER_MIXED) + .build() + ) + .build(), + params + ) + ) + + override fun onGetChildren( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + parentId: String, + page: Int, + pageSize: Int, + params: LibraryParams?, + ): ListenableFuture>> = scope.future(Dispatchers.IO) { + LibraryResult.ofItemList( + when (parentId) { + ROOT -> listOf( + mediaItem(SONG, getString(R.string.songs), null, drawableUri(R.drawable.ic_music_note), true, MEDIA_TYPE_PLAYLIST), + mediaItem(ARTIST, getString(R.string.artists), null, drawableUri(R.drawable.ic_artist), true, MEDIA_TYPE_FOLDER_ARTISTS), + mediaItem(ALBUM, getString(R.string.albums), null, drawableUri(R.drawable.ic_album), true, MEDIA_TYPE_FOLDER_ALBUMS), + mediaItem(PLAYLIST, getString(R.string.playlists), null, drawableUri(R.drawable.ic_queue_music), true, MEDIA_TYPE_FOLDER_PLAYLISTS) + ) + + SONG -> database.songsByCreateDateDesc().first().map { it.toMediaItem(parentId) } + ARTIST -> database.artistsByCreateDateDesc().first().map { artist -> + mediaItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri(), true, MEDIA_TYPE_ARTIST) + } + + ALBUM -> database.albumsByCreateDateDesc().first().map { album -> + mediaItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri(), true, MEDIA_TYPE_ALBUM) + } + + PLAYLIST -> { + val likedSongCount = database.likedSongsCount().first() + val downloadedSongCount = database.downloadedSongsCount().first() + listOf( + mediaItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite), true, MEDIA_TYPE_PLAYLIST), + mediaItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt), true, MEDIA_TYPE_PLAYLIST) + ) + database.playlistsByCreateDateDesc().first().map { playlist -> + mediaItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri(), true, MEDIA_TYPE_PLAYLIST) + } + } + + else -> when { + parentId.startsWith("$ARTIST/") -> + database.artistSongsByCreateDateDesc(parentId.removePrefix("$ARTIST/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("$ALBUM/") -> + database.albumSongs(parentId.removePrefix("$ALBUM/")).first().map { + it.toMediaItem(parentId) + } + + parentId.startsWith("$PLAYLIST/") -> + when (val playlistId = parentId.removePrefix("$PLAYLIST/")) { + LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) + DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, true) + else -> database.playlistSongs(playlistId) + }.first().map { + it.toMediaItem(parentId) + } + + else -> emptyList() + } + }, + params + ) + } - override fun onGetRoot(clientPackageName: String, clientUid: Int, rootHints: Bundle?): BrowserRoot = BrowserRoot(ROOT, null) + override fun onGetItem( + session: MediaLibrarySession, + browser: MediaSession.ControllerInfo, + mediaId: String, + ): ListenableFuture> = scope.future(Dispatchers.IO) { + Timber.d("onGetItem: $mediaId") + database.song(mediaId).first()?.toMediaItem()?.let { + LibraryResult.ofItem(it, null) + } ?: LibraryResult.ofError(LibraryResult.RESULT_ERROR_UNKNOWN) + } - override fun onLoadChildren(parentId: String, result: Result>) = runBlocking { - when (parentId) { - ROOT -> result.sendResult(mutableListOf( - mediaBrowserItem(SONG, getString(R.string.songs), null, drawableUri(R.drawable.ic_music_note)), - mediaBrowserItem(ARTIST, getString(R.string.artists), null, drawableUri(R.drawable.ic_artist)), - mediaBrowserItem(ALBUM, getString(R.string.albums), null, drawableUri(R.drawable.ic_album)), - mediaBrowserItem(PLAYLIST, getString(R.string.playlists), null, drawableUri(R.drawable.ic_queue_music)) - )) + override fun onSetMediaItems( + mediaSession: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItems: MutableList, + startIndex: Int, + startPositionMs: Long, + ): ListenableFuture = scope.future { + // Play from Android Auto + val defaultResult = MediaSession.MediaItemsWithStartPosition(emptyList(), startIndex, startPositionMs) + val path = mediaItems.firstOrNull()?.mediaId?.split("/") + ?: return@future defaultResult + when (path.firstOrNull()) { SONG -> { - result.detach() - result.sendResult(database.songsByCreateDateDesc().first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) - }.toMutableList()) + val songId = path.getOrNull(1) ?: return@future defaultResult + val allSongs = database.songsByCreateDateDesc().first() + MediaSession.MediaItemsWithStartPosition( + allSongs.map { it.toMediaItem() }, + allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + ARTIST -> { - result.detach() - result.sendResult(database.artistsByCreateDateDesc().first().map { artist -> - mediaBrowserItem("$ARTIST/${artist.id}", artist.artist.name, resources.getQuantityString(R.plurals.n_song, artist.songCount, artist.songCount), artist.artist.thumbnailUrl?.toUri()) - }.toMutableList()) + val songId = path.getOrNull(2) ?: return@future defaultResult + val artistId = path.getOrNull(1) ?: return@future defaultResult + val songs = database.artistSongsByCreateDateDesc(artistId).first() + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + ALBUM -> { - result.detach() - result.sendResult(database.albumsByCreateDateDesc().first().map { album -> - mediaBrowserItem("$ALBUM/${album.id}", album.album.title, album.artists.joinToString(), album.album.thumbnailUrl?.toUri()) - }.toMutableList()) + val songId = path.getOrNull(2) ?: return@future defaultResult + val albumId = path.getOrNull(1) ?: return@future defaultResult + val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@future defaultResult + MediaSession.MediaItemsWithStartPosition( + albumWithSongs.songs.map { it.toMediaItem() }, + albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + PLAYLIST -> { - result.detach() - val likedSongCount = database.likedSongsCount().first() - val downloadedSongCount = database.downloadedSongsCount().first() - result.sendResult((listOf( - mediaBrowserItem("$PLAYLIST/$LIKED_PLAYLIST_ID", getString(R.string.liked_songs), resources.getQuantityString(R.plurals.n_song, likedSongCount, likedSongCount), drawableUri(R.drawable.ic_favorite)), - mediaBrowserItem("$PLAYLIST/$DOWNLOADED_PLAYLIST_ID", getString(R.string.downloaded_songs), resources.getQuantityString(R.plurals.n_song, downloadedSongCount, downloadedSongCount), drawableUri(R.drawable.ic_save_alt)) - ) + database.playlistsByCreateDateDesc().first().map { playlist -> - mediaBrowserItem("$PLAYLIST/${playlist.id}", playlist.playlist.name, resources.getQuantityString(R.plurals.n_song, playlist.songCount, playlist.songCount), playlist.thumbnails.firstOrNull()?.toUri()) - }).toMutableList()) - } - else -> when { - parentId.startsWith("$ARTIST/") -> { - result.detach() - result.sendResult(database.artistSongsByCreateDateDesc(parentId.removePrefix("$ARTIST/")).first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) - }.toMutableList()) - } - parentId.startsWith("$ALBUM/") -> { - result.detach() - result.sendResult(database.albumSongs(parentId.removePrefix("$ALBUM/")).first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) - }.toMutableList()) - } - parentId.startsWith("$PLAYLIST/") -> { - result.detach() - result.sendResult(when (val playlistId = parentId.removePrefix("$PLAYLIST/")) { - LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, true) - DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, true) - else -> database.playlistSongs(playlistId) - }.first().map { - MediaBrowserCompat.MediaItem(it.toMediaMetadata().copy(id = "$parentId/${it.id}").toMediaDescription(), FLAG_PLAYABLE) - }.toMutableList()) - } - else -> { - result.sendResult(mutableListOf()) + val songId = path.getOrNull(2) ?: return@future defaultResult + val playlistId = path.getOrNull(1) ?: return@future defaultResult + val songs = when (playlistId) { + LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true).first() + DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, descending = true).first() + else -> database.playlistSongs(playlistId).first() } + MediaSession.MediaItemsWithStartPosition( + songs.map { it.toMediaItem() }, + songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0, + startPositionMs + ) } + + else -> defaultResult } } @@ -173,16 +954,42 @@ class MusicService : MediaBrowserServiceCompat() { .appendPath(resources.getResourceEntryName(id)) .build() - private fun mediaBrowserItem(id: String, title: String, subtitle: String?, iconUri: Uri?, flags: Int = FLAG_BROWSABLE) = - MediaBrowserCompat.MediaItem( - MediaDescriptionCompat.Builder() - .setMediaId(id) - .setTitle(title) - .setSubtitle(subtitle) - .setIconUri(iconUri) - .build(), - flags - ) + private fun mediaItem(id: String, title: String, subtitle: String?, iconUri: Uri?, browsable: Boolean = false, mediaType: Int = MEDIA_TYPE_MUSIC) = + MediaItem.Builder() + .setMediaId(id) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(title) + .setSubtitle(subtitle) + .setArtist(subtitle) + .setArtworkUri(iconUri) + .setIsPlayable(!browsable) + .setIsBrowsable(browsable) + .setMediaType(mediaType) + .build() + ) + .build() + + private fun Song.toMediaItem(path: String) = + MediaItem.Builder() + .setMediaId("$path/$id") + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(song.title) + .setSubtitle(artists.joinToString { it.name }) + .setArtist(artists.joinToString { it.name }) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setIsPlayable(true) + .setIsBrowsable(false) + .setMediaType(MEDIA_TYPE_MUSIC) + .build() + ) + .build() + + inner class MusicBinder : Binder() { + val service: MusicService + get() = this@MusicService + } companion object { const val ROOT = "root" @@ -190,5 +997,11 @@ class MusicService : MediaBrowserServiceCompat() { const val ARTIST = "artist" const val ALBUM = "album" const val PLAYLIST = "playlist" + + const val CHANNEL_ID = "music_channel_01" + const val NOTIFICATION_ID = 888 + const val ERROR_CODE_NO_STREAM = 1000001 + const val CHUNK_LENGTH = 512 * 1024L + const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" } } 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 41262e30e..5415002ea 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -1,10 +1,17 @@ package com.zionhuang.music.playback import android.graphics.Bitmap -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.Player.* -import com.google.android.exoplayer2.Timeline +import androidx.media3.common.MediaItem +import androidx.media3.common.PlaybackException +import androidx.media3.common.Player +import androidx.media3.common.Player.COMMAND_SEEK_IN_CURRENT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_NEXT_MEDIA_ITEM +import androidx.media3.common.Player.COMMAND_SEEK_TO_PREVIOUS_MEDIA_ITEM +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_IDLE +import androidx.media3.common.Timeline import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.extensions.currentMetadata import com.zionhuang.music.extensions.getCurrentQueueIndex @@ -19,10 +26,10 @@ import kotlinx.coroutines.flow.flatMapLatest @OptIn(ExperimentalCoroutinesApi::class) class PlayerConnection( val database: MusicDatabase, - private val binder: MusicBinder, -) : Listener { - val songPlayer = binder.songPlayer - val player = binder.player + binder: MusicBinder, +) : Player.Listener { + val service = binder.service + val player = service.player val playbackState = MutableStateFlow(STATE_IDLE) val playWhenReady = MutableStateFlow(player.playWhenReady) @@ -51,42 +58,42 @@ class PlayerConnection( var onBitmapChanged: (Bitmap?) -> Unit = {} set(value) { field = value - binder.songPlayer.bitmapProvider.onBitmapChanged = value + service.bitmapProvider.onBitmapChanged = value } val error = MutableStateFlow(null) init { - binder.player.addListener(this) - binder.songPlayer.bitmapProvider.onBitmapChanged = onBitmapChanged - - playbackState.value = binder.player.playbackState - playWhenReady.value = binder.player.playWhenReady - mediaMetadata.value = binder.player.currentMetadata - queueTitle.value = binder.songPlayer.queueTitle - queueWindows.value = binder.player.getQueueWindows() - currentWindowIndex.value = binder.player.getCurrentQueueIndex() - currentMediaItemIndex.value = binder.player.currentMediaItemIndex - shuffleModeEnabled.value = binder.player.shuffleModeEnabled - repeatMode.value = binder.player.repeatMode + player.addListener(this) + service.bitmapProvider.onBitmapChanged = onBitmapChanged + + playbackState.value = player.playbackState + playWhenReady.value = player.playWhenReady + mediaMetadata.value = player.currentMetadata + queueTitle.value = service.queueTitle + queueWindows.value = player.getQueueWindows() + currentWindowIndex.value = player.getCurrentQueueIndex() + currentMediaItemIndex.value = player.currentMediaItemIndex + shuffleModeEnabled.value = player.shuffleModeEnabled + repeatMode.value = player.repeatMode } fun playQueue(queue: Queue) { - binder.songPlayer.playQueue(queue) + service.playQueue(queue) } fun playNext(item: MediaItem) = playNext(listOf(item)) fun playNext(items: List) { - binder.songPlayer.playNext(items) + service.playNext(items) } fun addToQueue(item: MediaItem) = addToQueue(listOf(item)) fun addToQueue(items: List) { - binder.songPlayer.addToQueue(items) + service.addToQueue(items) } fun toggleRepeatMode() { - binder.player.let { + player.let { it.repeatMode = when (it.repeatMode) { REPEAT_MODE_OFF -> REPEAT_MODE_ALL REPEAT_MODE_ALL -> REPEAT_MODE_ONE @@ -97,11 +104,11 @@ class PlayerConnection( } fun toggleLike() { - binder.songPlayer.toggleLike() + service.toggleLike() } fun toggleLibrary() { - binder.songPlayer.toggleLibrary() + service.toggleLibrary() } override fun onPlaybackStateChanged(state: Int) { @@ -116,22 +123,22 @@ class PlayerConnection( override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { mediaMetadata.value = mediaItem?.metadata currentMediaItemIndex.value = player.currentMediaItemIndex - currentWindowIndex.value = binder.player.getCurrentQueueIndex() + currentWindowIndex.value = player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } override fun onTimelineChanged(timeline: Timeline, reason: Int) { - queueWindows.value = binder.player.getQueueWindows() - queueTitle.value = binder.songPlayer.queueTitle + queueWindows.value = player.getQueueWindows() + queueTitle.value = service.queueTitle currentMediaItemIndex.value = player.currentMediaItemIndex - currentWindowIndex.value = binder.player.getCurrentQueueIndex() + currentWindowIndex.value = player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } override fun onShuffleModeEnabledChanged(enabled: Boolean) { shuffleModeEnabled.value = enabled - queueWindows.value = binder.player.getQueueWindows() - currentWindowIndex.value = binder.player.getCurrentQueueIndex() + queueWindows.value = player.getQueueWindows() + currentWindowIndex.value = player.getCurrentQueueIndex() updateCanSkipPreviousAndNext() } @@ -159,7 +166,7 @@ class PlayerConnection( } fun dispose() { - songPlayer.bitmapProvider.onBitmapChanged = {} + service.bitmapProvider.onBitmapChanged = {} player.removeListener(this) } } diff --git a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt b/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt deleted file mode 100644 index f0feb6649..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/SongPlayer.kt +++ /dev/null @@ -1,929 +0,0 @@ -package com.zionhuang.music.playback - -import android.app.PendingIntent -import android.app.PendingIntent.FLAG_IMMUTABLE -import android.app.PendingIntent.FLAG_UPDATE_CURRENT -import android.content.Context -import android.content.Intent -import android.database.SQLException -import android.graphics.Bitmap -import android.media.audiofx.AudioEffect -import android.net.ConnectivityManager -import android.net.Uri -import android.os.Bundle -import android.os.ResultReceiver -import android.support.v4.media.MediaDescriptionCompat -import android.support.v4.media.session.MediaSessionCompat -import android.support.v4.media.session.PlaybackStateCompat.ACTION_PLAY_FROM_MEDIA_ID -import android.support.v4.media.session.PlaybackStateCompat.ACTION_PREPARE -import android.support.v4.media.session.PlaybackStateCompat.ACTION_PREPARE_FROM_MEDIA_ID -import android.support.v4.media.session.PlaybackStateCompat.CustomAction -import androidx.compose.runtime.getValue -import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.setValue -import androidx.core.app.NotificationCompat -import androidx.core.content.getSystemService -import androidx.core.net.toUri -import androidx.datastore.preferences.core.edit -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.C.WAKE_MODE_NETWORK -import com.google.android.exoplayer2.DefaultRenderersFactory -import com.google.android.exoplayer2.ExoPlayer -import com.google.android.exoplayer2.MediaItem -import com.google.android.exoplayer2.PlaybackException -import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_FAILED -import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT -import com.google.android.exoplayer2.PlaybackException.ERROR_CODE_REMOTE_ERROR -import com.google.android.exoplayer2.Player -import com.google.android.exoplayer2.Player.DISCONTINUITY_REASON_AUTO_TRANSITION -import com.google.android.exoplayer2.Player.DiscontinuityReason -import com.google.android.exoplayer2.Player.EVENT_IS_PLAYING_CHANGED -import com.google.android.exoplayer2.Player.EVENT_PLAYBACK_STATE_CHANGED -import com.google.android.exoplayer2.Player.EVENT_PLAY_WHEN_READY_CHANGED -import com.google.android.exoplayer2.Player.EVENT_POSITION_DISCONTINUITY -import com.google.android.exoplayer2.Player.EVENT_TIMELINE_CHANGED -import com.google.android.exoplayer2.Player.Events -import com.google.android.exoplayer2.Player.Listener -import com.google.android.exoplayer2.Player.MEDIA_ITEM_TRANSITION_REASON_REPEAT -import com.google.android.exoplayer2.Player.PositionInfo -import com.google.android.exoplayer2.Player.STATE_ENDED -import com.google.android.exoplayer2.Player.STATE_IDLE -import com.google.android.exoplayer2.Player.State -import com.google.android.exoplayer2.Timeline -import com.google.android.exoplayer2.analytics.AnalyticsListener -import com.google.android.exoplayer2.analytics.PlaybackStats -import com.google.android.exoplayer2.analytics.PlaybackStatsListener -import com.google.android.exoplayer2.audio.AudioAttributes -import com.google.android.exoplayer2.audio.AudioCapabilities -import com.google.android.exoplayer2.audio.DefaultAudioSink -import com.google.android.exoplayer2.audio.DefaultAudioSink.DefaultAudioProcessorChain -import com.google.android.exoplayer2.audio.DefaultAudioSink.OFFLOAD_MODE_DISABLED -import com.google.android.exoplayer2.audio.DefaultAudioSink.OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED -import com.google.android.exoplayer2.audio.SilenceSkippingAudioProcessor -import com.google.android.exoplayer2.audio.SilenceSkippingAudioProcessor.DEFAULT_SILENCE_THRESHOLD_LEVEL -import com.google.android.exoplayer2.audio.SonicAudioProcessor -import com.google.android.exoplayer2.database.StandaloneDatabaseProvider -import com.google.android.exoplayer2.ext.mediasession.MediaSessionConnector -import com.google.android.exoplayer2.ext.okhttp.OkHttpDataSource -import com.google.android.exoplayer2.extractor.ExtractorsFactory -import com.google.android.exoplayer2.extractor.mkv.MatroskaExtractor -import com.google.android.exoplayer2.extractor.mp4.FragmentedMp4Extractor -import com.google.android.exoplayer2.source.DefaultMediaSourceFactory -import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder -import com.google.android.exoplayer2.ui.PlayerNotificationManager -import com.google.android.exoplayer2.ui.PlayerNotificationManager.CustomActionReceiver -import com.google.android.exoplayer2.ui.PlayerNotificationManager.EXTRA_INSTANCE_ID -import com.google.android.exoplayer2.upstream.DefaultDataSource -import com.google.android.exoplayer2.upstream.ResolvingDataSource -import com.google.android.exoplayer2.upstream.cache.CacheDataSource -import com.google.android.exoplayer2.upstream.cache.LeastRecentlyUsedCacheEvictor -import com.google.android.exoplayer2.upstream.cache.NoOpCacheEvictor -import com.google.android.exoplayer2.upstream.cache.SimpleCache -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.models.SongItem -import com.zionhuang.innertube.models.WatchEndpoint -import com.zionhuang.innertube.models.response.PlayerResponse -import com.zionhuang.music.ACTION_SHOW_BOTTOM_SHEET -import com.zionhuang.music.MainActivity -import com.zionhuang.music.R -import com.zionhuang.music.constants.AudioNormalizationKey -import com.zionhuang.music.constants.AudioQualityKey -import com.zionhuang.music.constants.AutoAddToLibraryKey -import com.zionhuang.music.constants.MaxSongCacheSizeKey -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_ADD_TO_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_LIKE -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_REMOVE_FROM_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIBRARY -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_LIKE -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_TOGGLE_SHUFFLE -import com.zionhuang.music.constants.MediaSessionConstants.ACTION_UNLIKE -import com.zionhuang.music.constants.NotificationMoreActionKey -import com.zionhuang.music.constants.PauseListenHistoryKey -import com.zionhuang.music.constants.PersistentQueueKey -import com.zionhuang.music.constants.PlayerVolumeKey -import com.zionhuang.music.constants.RepeatModeKey -import com.zionhuang.music.constants.ShowLyricsKey -import com.zionhuang.music.constants.SkipSilenceKey -import com.zionhuang.music.constants.SongSortType -import com.zionhuang.music.db.MusicDatabase -import com.zionhuang.music.db.entities.Event -import com.zionhuang.music.db.entities.FormatEntity -import com.zionhuang.music.db.entities.LyricsEntity -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.RelatedSongMap -import com.zionhuang.music.db.entities.Song -import com.zionhuang.music.db.entities.SongEntity.Companion.STATE_DOWNLOADED -import com.zionhuang.music.extensions.currentMetadata -import com.zionhuang.music.extensions.findNextMediaItemById -import com.zionhuang.music.extensions.mediaItemIndexOf -import com.zionhuang.music.extensions.mediaItems -import com.zionhuang.music.extensions.metadata -import com.zionhuang.music.extensions.setQueueNavigator -import com.zionhuang.music.extensions.toMediaItem -import com.zionhuang.music.lyrics.LyricsHelper -import com.zionhuang.music.models.MediaMetadata -import com.zionhuang.music.models.PersistQueue -import com.zionhuang.music.models.toMediaMetadata -import com.zionhuang.music.playback.MusicService.Companion.ALBUM -import com.zionhuang.music.playback.MusicService.Companion.ARTIST -import com.zionhuang.music.playback.MusicService.Companion.PLAYLIST -import com.zionhuang.music.playback.MusicService.Companion.SONG -import com.zionhuang.music.playback.queues.EmptyQueue -import com.zionhuang.music.playback.queues.ListQueue -import com.zionhuang.music.playback.queues.Queue -import com.zionhuang.music.playback.queues.YouTubeQueue -import com.zionhuang.music.ui.utils.resize -import com.zionhuang.music.utils.dataStore -import com.zionhuang.music.utils.enumPreference -import com.zionhuang.music.utils.get -import com.zionhuang.music.utils.getSongFile -import com.zionhuang.music.utils.preference -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Dispatchers.IO -import kotlinx.coroutines.ExperimentalCoroutinesApi -import kotlinx.coroutines.FlowPreview -import kotlinx.coroutines.Job -import kotlinx.coroutines.delay -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.collectLatest -import kotlinx.coroutines.flow.combine -import kotlinx.coroutines.flow.debounce -import kotlinx.coroutines.flow.distinctUntilChanged -import kotlinx.coroutines.flow.distinctUntilChangedBy -import kotlinx.coroutines.flow.first -import kotlinx.coroutines.flow.flatMapLatest -import kotlinx.coroutines.flow.map -import kotlinx.coroutines.launch -import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext -import okhttp3.OkHttpClient -import java.io.ObjectInputStream -import java.io.ObjectOutputStream -import java.net.ConnectException -import java.net.SocketTimeoutException -import java.net.UnknownHostException -import java.time.LocalDateTime -import kotlin.math.min -import kotlin.math.pow -import kotlin.time.Duration.Companion.minutes - -@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class) -class SongPlayer( - private val context: Context, - private val database: MusicDatabase, - private val lyricsHelper: LyricsHelper, - private val scope: CoroutineScope, - notificationListener: PlayerNotificationManager.NotificationListener, -) : Listener, PlaybackStatsListener.Callback { - private val connectivityManager = context.getSystemService()!! - val bitmapProvider = BitmapProvider(context) - - private val autoAddSong by preference(context, AutoAddToLibraryKey, true) - private val audioQuality by enumPreference(context, AudioQualityKey, AudioQuality.AUTO) - - private var currentQueue: Queue = EmptyQueue - var queueTitle: String? = null - - val currentMediaMetadata = MutableStateFlow(null) - private val currentSongFlow = currentMediaMetadata.flatMapLatest { mediaMetadata -> - database.song(mediaMetadata?.id) - } - private val currentFormat = currentMediaMetadata.flatMapLatest { mediaMetadata -> - database.format(mediaMetadata?.id) - } - var currentSong: Song? = null - - private val cacheEvictor = when (val cacheSize = context.dataStore[MaxSongCacheSizeKey] ?: 1024) { - -1 -> NoOpCacheEvictor() - else -> LeastRecentlyUsedCacheEvictor(cacheSize * 1024 * 1024L) - } - val cache = SimpleCache(context.cacheDir.resolve("exoplayer"), cacheEvictor, StandaloneDatabaseProvider(context)) - val player: ExoPlayer = ExoPlayer.Builder(context) - .setMediaSourceFactory(createMediaSourceFactory()) - .setRenderersFactory(createRenderersFactory()) - .setHandleAudioBecomingNoisy(true) - .setWakeMode(WAKE_MODE_NETWORK) - .setAudioAttributes( - AudioAttributes.Builder() - .setUsage(C.USAGE_MEDIA) - .setContentType(C.AUDIO_CONTENT_TYPE_MUSIC) - .build(), true - ) - .setSeekBackIncrementMs(5000) - .setSeekForwardIncrementMs(5000) - .build() - .apply { - addListener(this@SongPlayer) - addAnalyticsListener(PlaybackStatsListener(false, this@SongPlayer)) - repeatMode = context.dataStore.get(RepeatModeKey, Player.REPEAT_MODE_OFF) - } - - private val normalizeFactor = MutableStateFlow(1f) - val playerVolume = MutableStateFlow(context.dataStore.get(PlayerVolumeKey, 1f).coerceIn(0f, 1f)) - - var sleepTimerJob: Job? = null - var sleepTimerTriggerTime by mutableStateOf(-1L) - var pauseWhenSongEnd by mutableStateOf(false) - - val mediaSession = MediaSessionCompat(context, context.getString(R.string.app_name)).apply { - isActive = true - } - private val mediaSessionConnector = MediaSessionConnector(mediaSession).apply { - setPlayer(player) - setPlaybackPreparer(object : MediaSessionConnector.PlaybackPreparer { - override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = false - override fun getSupportedPrepareActions(): Long = ACTION_PREPARE or ACTION_PREPARE_FROM_MEDIA_ID or ACTION_PLAY_FROM_MEDIA_ID - override fun onPrepareFromMediaId(mediaId: String, playWhenReady: Boolean, extras: Bundle?) { - scope.launch { - val path = mediaId.split("/") - when (path.firstOrNull()) { - SONG -> { - val songId = path.getOrNull(1) ?: return@launch - val allSongs = database.songsByCreateDateDesc().first() - playQueue(ListQueue( - title = context.getString(R.string.queue_all_songs), - items = allSongs.map { it.toMediaItem() }, - startIndex = allSongs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - ARTIST -> { - val songId = path.getOrNull(2) ?: return@launch - val artistId = path.getOrNull(1) ?: return@launch - val artist = database.artist(artistId).first() ?: return@launch - val songs = database.artistSongsByCreateDateDesc(artistId).first() - playQueue(ListQueue( - title = artist.name, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - ALBUM -> { - val songId = path.getOrNull(2) ?: return@launch - val albumId = path.getOrNull(1) ?: return@launch - val albumWithSongs = database.albumWithSongs(albumId).first() ?: return@launch - playQueue(ListQueue( - title = albumWithSongs.album.title, - items = albumWithSongs.songs.map { it.toMediaItem() }, - startIndex = albumWithSongs.songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - PLAYLIST -> { - val songId = path.getOrNull(2) ?: return@launch - val playlistId = path.getOrNull(1) ?: return@launch - val songs = when (playlistId) { - LIKED_PLAYLIST_ID -> database.likedSongs(SongSortType.CREATE_DATE, descending = true).first() - DOWNLOADED_PLAYLIST_ID -> database.downloadedSongs(SongSortType.CREATE_DATE, descending = true).first() - else -> database.playlistSongs(playlistId).first() - } - playQueue(ListQueue( - title = when (playlistId) { - LIKED_PLAYLIST_ID -> context.getString(R.string.liked_songs) - DOWNLOADED_PLAYLIST_ID -> context.getString(R.string.downloaded_songs) - else -> database.playlist(playlistId).first()?.playlist?.name ?: return@launch - }, - items = songs.map { it.toMediaItem() }, - startIndex = songs.indexOfFirst { it.id == songId }.takeIf { it != -1 } ?: 0 - ), playWhenReady) - } - } - } - } - - override fun onPrepareFromSearch(query: String, playWhenReady: Boolean, extras: Bundle?) {} - override fun onPrepareFromUri(uri: Uri, playWhenReady: Boolean, extras: Bundle?) {} - override fun onPrepare(playWhenReady: Boolean) { - player.playWhenReady = playWhenReady - player.prepare() - } - }) - setCustomActionProviders( - object : MediaSessionConnector.CustomActionProvider { - override fun onCustomAction(player: Player, action: String, extras: Bundle?) = toggleLike() - override fun getCustomAction(player: Player) = if (currentMediaMetadata.value != null) { - CustomAction.Builder( - ACTION_TOGGLE_LIKE, - context.getString(if (currentSong?.song?.liked == true) R.string.action_remove_like else R.string.action_like), - if (currentSong?.song?.liked == true) R.drawable.ic_favorite else R.drawable.ic_favorite_border - ).build() - } else null - }, - object : MediaSessionConnector.CustomActionProvider { - override fun onCustomAction(player: Player, action: String, extras: Bundle?) = toggleLibrary() - override fun getCustomAction(player: Player) = if (currentMediaMetadata.value != null) { - CustomAction.Builder( - ACTION_TOGGLE_LIBRARY, - context.getString(if (currentSong?.song?.inLibrary != null) R.string.action_remove_from_library else R.string.action_add_to_library), - if (currentSong?.song?.inLibrary != null) R.drawable.ic_library_add_check else R.drawable.ic_library_add - ).build() - } else null - }, - object : MediaSessionConnector.CustomActionProvider { - override fun onCustomAction(player: Player, action: String, extras: Bundle?) { - player.shuffleModeEnabled = !player.shuffleModeEnabled - } - - override fun getCustomAction(player: Player) = - CustomAction.Builder( - ACTION_TOGGLE_SHUFFLE, - context.getString(R.string.shuffle), - if (player.shuffleModeEnabled) R.drawable.ic_shuffle_on else R.drawable.ic_shuffle - ).build() - } - ) - setQueueNavigator { player, windowIndex -> player.getMediaItemAt(windowIndex).metadata!!.toMediaDescription() } - setQueueEditor(object : MediaSessionConnector.QueueEditor { - override fun onCommand(player: Player, command: String, extras: Bundle?, cb: ResultReceiver?) = false - override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat) = throw UnsupportedOperationException() - override fun onAddQueueItem(player: Player, description: MediaDescriptionCompat, index: Int) = throw UnsupportedOperationException() - override fun onRemoveQueueItem(player: Player, description: MediaDescriptionCompat) { - player.mediaItemIndexOf(description.mediaId)?.let { i -> - player.removeMediaItem(i) - } - } - }) - } - - private val playerNotificationManager = PlayerNotificationManager.Builder(context, NOTIFICATION_ID, CHANNEL_ID) - .setMediaDescriptionAdapter(object : PlayerNotificationManager.MediaDescriptionAdapter { - override fun getCurrentContentTitle(player: Player): CharSequence = - player.currentMetadata?.title.orEmpty() - - override fun getCurrentContentText(player: Player): CharSequence? = - player.currentMetadata?.artists?.joinToString { it.name } - - override fun getCurrentLargeIcon(player: Player, callback: PlayerNotificationManager.BitmapCallback): Bitmap? = - player.currentMetadata?.thumbnailUrl?.let { url -> - bitmapProvider.load(url.resize(544, 544)) { - callback.onBitmap(it) - } - } - - override fun createCurrentContentIntent(player: Player): PendingIntent? = - PendingIntent.getActivity(context, 0, Intent(context, MainActivity::class.java).apply { - action = ACTION_SHOW_BOTTOM_SHEET - }, FLAG_IMMUTABLE) - }) - .setChannelNameResourceId(R.string.music_player) - .setNotificationListener(notificationListener) - .setCustomActionReceiver(object : CustomActionReceiver { - override fun createCustomActions(context: Context, instanceId: Int): Map = mapOf( - ACTION_ADD_TO_LIBRARY to NotificationCompat.Action.Builder( - R.drawable.ic_library_add, context.getString(R.string.action_add_to_library), createPendingIntent(context, ACTION_ADD_TO_LIBRARY, instanceId) - ).build(), - ACTION_REMOVE_FROM_LIBRARY to NotificationCompat.Action.Builder( - R.drawable.ic_library_add_check, context.getString(R.string.action_remove_from_library), createPendingIntent(context, ACTION_REMOVE_FROM_LIBRARY, instanceId) - ).build(), - ACTION_LIKE to NotificationCompat.Action.Builder( - R.drawable.ic_favorite_border, context.getString(R.string.action_like), createPendingIntent(context, ACTION_LIKE, instanceId) - ).build(), - ACTION_UNLIKE to NotificationCompat.Action.Builder( - R.drawable.ic_favorite, context.getString(R.string.action_remove_like), createPendingIntent(context, ACTION_UNLIKE, instanceId) - ).build() - ) - - override fun getCustomActions(player: Player): List { - val actions = mutableListOf() - if (player.currentMetadata != null && context.dataStore.get(NotificationMoreActionKey, true)) { - actions.add(if (currentSong == null) ACTION_ADD_TO_LIBRARY else ACTION_REMOVE_FROM_LIBRARY) - actions.add(if (currentSong?.song?.liked == true) ACTION_UNLIKE else ACTION_LIKE) - } - return actions - } - - override fun onCustomAction(player: Player, action: String, intent: Intent) { - when (action) { - ACTION_ADD_TO_LIBRARY, ACTION_REMOVE_FROM_LIBRARY -> toggleLibrary() - ACTION_LIKE, ACTION_UNLIKE -> toggleLike() - } - } - }) - .build() - .apply { - setPlayer(player) - setMediaSessionToken(mediaSession.sessionToken) - setSmallIcon(R.drawable.ic_notification) - setUseRewindAction(false) - setUseFastForwardAction(false) - } - - init { - scope.launch { - combine(playerVolume, normalizeFactor) { playerVolume, normalizeFactor -> - playerVolume * normalizeFactor - }.collectLatest { - player.volume = it - } - } - scope.launch { - playerVolume.debounce(1000).collect { volume -> - context.dataStore.edit { settings -> - settings[PlayerVolumeKey] = volume - } - } - } - scope.launch { - currentSongFlow.collect { song -> - val shouldInvalidate = currentSong == null || song == null || currentSong?.song?.liked != song.song.liked - currentSong = song - if (shouldInvalidate) { - mediaSessionConnector.invalidateMediaSessionPlaybackState() - playerNotificationManager.invalidate() - } - } - } - scope.launch { - combine( - currentMediaMetadata.distinctUntilChangedBy { it?.id }, - context.dataStore.data.map { it[ShowLyricsKey] ?: false }.distinctUntilChanged() - ) { mediaMetadata, showLyrics -> - mediaMetadata to showLyrics - }.collectLatest { (mediaMetadata, showLyrics) -> - if (showLyrics && mediaMetadata != null && database.lyrics(mediaMetadata.id).first() == null) { - val lyrics = lyricsHelper.getLyrics(mediaMetadata) - database.query { - upsert( - LyricsEntity( - id = mediaMetadata.id, - lyrics = lyrics - ) - ) - } - } - } - } - scope.launch { - context.dataStore.data - .map { it[SkipSilenceKey] ?: true } - .distinctUntilChanged() - .collectLatest { - player.skipSilenceEnabled = it - } - } - scope.launch { - combine( - currentFormat, - context.dataStore.data - .map { it[AudioNormalizationKey] ?: true } - .distinctUntilChanged() - ) { format, normalizeAudio -> - format to normalizeAudio - }.collectLatest { (format, normalizeAudio) -> - normalizeFactor.value = if (normalizeAudio && format?.loudnessDb != null) { - min(10f.pow(-format.loudnessDb.toFloat() / 20), 1f) - } else { - 1f - } - } - } - scope.launch { - context.dataStore.data - .map { it[NotificationMoreActionKey] ?: true } - .distinctUntilChanged() - .collectLatest { - playerNotificationManager.invalidate() - } - } - if (context.dataStore.get(PersistentQueueKey, true)) { - runCatching { - context.filesDir.resolve(PERSISTENT_QUEUE_FILE).inputStream().use { fis -> - ObjectInputStream(fis).use { oos -> - oos.readObject() as PersistQueue - } - } - }.onSuccess { queue -> - playQueue( - queue = ListQueue( - title = queue.title, - items = queue.items.map { it.toMediaItem() }, - startIndex = queue.mediaItemIndex, - position = queue.position - ), - playWhenReady = false - ) - } - } - } - - private fun createOkHttpDataSourceFactory() = - OkHttpDataSource.Factory( - OkHttpClient.Builder() - .proxy(YouTube.proxy) - .build() - ) - - private fun createCacheDataSource() = CacheDataSource.Factory() - .setCache(cache) - .setUpstreamDataSourceFactory(DefaultDataSource.Factory(context, createOkHttpDataSourceFactory())) - - private fun createExtractorsFactory() = ExtractorsFactory { - arrayOf(MatroskaExtractor(), FragmentedMp4Extractor()) - } - - private suspend fun recoverSong(mediaId: String, playerResponse: PlayerResponse? = null) { - val song = database.song(mediaId).first() - val mediaMetadata = withContext(Dispatchers.Main) { player.findNextMediaItemById(mediaId)?.metadata } ?: return - val duration = song?.song?.duration?.takeIf { it != -1 } - ?: mediaMetadata.duration.takeIf { it != -1 } - ?: (playerResponse ?: YouTube.player(mediaId).getOrNull())?.videoDetails?.lengthSeconds?.toInt() - ?: -1 - database.query { - if (song == null) insert(mediaMetadata.copy(duration = duration)) - else if (song.song.duration == -1) update(song.song.copy(duration = duration)) - } - if (!database.hasRelatedSongs(mediaId)) { - val relatedEndpoint = YouTube.next(WatchEndpoint(videoId = mediaId)).getOrNull()?.relatedEndpoint ?: return - val relatedPage = YouTube.related(relatedEndpoint).getOrNull() ?: return - database.query { - relatedPage.songs - .map(SongItem::toMediaMetadata) - .onEach(::insert) - .map { - RelatedSongMap( - songId = mediaId, - relatedSongId = it.id - ) - } - .forEach(::insert) - } - } - } - - private fun createDataSourceFactory(): ResolvingDataSource.Factory { - val songUrlCache = HashMap>() - return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> - val mediaId = dataSpec.key ?: error("No media id") - - if (cache.isCached(mediaId, dataSpec.position, CHUNK_LENGTH)) { - scope.launch(IO) { recoverSong(mediaId) } - return@Factory dataSpec - } - - songUrlCache[mediaId]?.takeIf { it.second < System.currentTimeMillis() }?.let { - scope.launch(IO) { recoverSong(mediaId) } - return@Factory dataSpec.withUri(it.first.toUri()) - } - - // Check whether format exists so that users from older version can view format details - // There may be inconsistent between the downloaded file and the displayed info if user change audio quality frequently - val playedFormat = runBlocking(IO) { database.format(mediaId).first() } - val song = runBlocking(IO) { database.song(mediaId).first() } - if (playedFormat != null && song?.song?.downloadState == STATE_DOWNLOADED) { - scope.launch(IO) { recoverSong(mediaId) } - return@Factory dataSpec.withUri(getSongFile(context, mediaId).toUri()) - } - - val playerResponse = runBlocking(IO) { - YouTube.player(mediaId) - }.getOrElse { throwable -> - when (throwable) { - is ConnectException, is UnknownHostException -> { - throw PlaybackException(context.getString(R.string.error_no_internet), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_FAILED) - } - is SocketTimeoutException -> { - throw PlaybackException(context.getString(R.string.error_timeout), throwable, ERROR_CODE_IO_NETWORK_CONNECTION_TIMEOUT) - } - else -> throw PlaybackException(context.getString(R.string.error_unknown), throwable, ERROR_CODE_REMOTE_ERROR) - } - } - if (playerResponse.playabilityStatus.status != "OK") { - throw PlaybackException(playerResponse.playabilityStatus.reason, null, ERROR_CODE_REMOTE_ERROR) - } - - val format = - if (playedFormat != null) { - playerResponse.streamingData?.adaptiveFormats?.find { - // Use itag to identify previous played format - it.itag == playedFormat.itag - } - } else { - playerResponse.streamingData?.adaptiveFormats - ?.filter { it.isAudio } - ?.maxByOrNull { - it.bitrate * when (audioQuality) { - AudioQuality.AUTO -> if (connectivityManager.isActiveNetworkMetered) -1 else 1 - AudioQuality.HIGH -> 1 - AudioQuality.LOW -> -1 - } + (if (it.mimeType.startsWith("audio/webm")) 10240 else 0) // prefer opus stream - } - } ?: throw PlaybackException(context.getString(R.string.error_no_stream), null, ERROR_CODE_NO_STREAM) - - database.query { - upsert( - FormatEntity( - id = mediaId, - itag = format.itag, - mimeType = format.mimeType.split(";")[0], - codecs = format.mimeType.split("codecs=")[1].removeSurrounding("\""), - bitrate = format.bitrate, - sampleRate = format.audioSampleRate, - contentLength = format.contentLength!!, - loudnessDb = playerResponse.playerConfig?.audioConfig?.loudnessDb - ) - ) - } - scope.launch(IO) { recoverSong(mediaId, playerResponse) } - - songUrlCache[mediaId] = format.url to playerResponse.streamingData!!.expiresInSeconds * 1000L - dataSpec.withUri(format.url.toUri()).subrange(dataSpec.uriPositionOffset, CHUNK_LENGTH) - } - } - - private fun createMediaSourceFactory() = DefaultMediaSourceFactory(createDataSourceFactory(), createExtractorsFactory()) - - private fun createRenderersFactory() = object : DefaultRenderersFactory(context) { - override fun buildAudioSink(context: Context, enableFloatOutput: Boolean, enableAudioTrackPlaybackParams: Boolean, enableOffload: Boolean) = - DefaultAudioSink.Builder() - .setAudioCapabilities(AudioCapabilities.getCapabilities(context)) - .setEnableFloatOutput(enableFloatOutput) - .setEnableAudioTrackPlaybackParams(enableAudioTrackPlaybackParams) - .setOffloadMode(if (enableOffload) OFFLOAD_MODE_ENABLED_GAPLESS_REQUIRED else OFFLOAD_MODE_DISABLED) - .setAudioProcessorChain( - DefaultAudioProcessorChain( - emptyArray(), - SilenceSkippingAudioProcessor(2_000_000, 20_000, DEFAULT_SILENCE_THRESHOLD_LEVEL), - SonicAudioProcessor() - ) - ) - .build() - } - - private fun updateQueueTitle(title: String?) { - mediaSession.setQueueTitle(title) - queueTitle = title - } - - fun playQueue(queue: Queue, playWhenReady: Boolean = true) { - currentQueue = queue - updateQueueTitle(null) - player.shuffleModeEnabled = false - if (queue.preloadItem != null) { - player.setMediaItem(queue.preloadItem!!.toMediaItem()) - player.prepare() - player.playWhenReady = playWhenReady - } - - scope.launch { - val initialStatus = withContext(IO) { queue.getInitialStatus() } - if (queue.preloadItem != null && player.playbackState == STATE_IDLE) return@launch - initialStatus.title?.let { queueTitle -> - updateQueueTitle(queueTitle) - } - if (queue.preloadItem != null) { - player.addMediaItems(0, initialStatus.items.subList(0, initialStatus.mediaItemIndex)) - player.addMediaItems(initialStatus.items.subList(initialStatus.mediaItemIndex + 1, initialStatus.items.size)) - } else { - player.setMediaItems(initialStatus.items, if (initialStatus.mediaItemIndex > 0) initialStatus.mediaItemIndex else 0, initialStatus.position) - player.prepare() - player.playWhenReady = playWhenReady - } - } - } - - fun startRadioSeamlessly() { - val currentMediaMetadata = player.currentMetadata ?: return - if (player.currentMediaItemIndex > 0) player.removeMediaItems(0, player.currentMediaItemIndex) - if (player.currentMediaItemIndex < player.mediaItemCount - 1) player.removeMediaItems(player.currentMediaItemIndex + 1, player.mediaItemCount) - scope.launch { - val radioQueue = YouTubeQueue(endpoint = WatchEndpoint(videoId = currentMediaMetadata.id)) - val initialStatus = radioQueue.getInitialStatus() - initialStatus.title?.let { queueTitle -> - updateQueueTitle(queueTitle) - } - player.addMediaItems(initialStatus.items.drop(1)) - currentQueue = radioQueue - } - } - - fun playNext(items: List) { - player.addMediaItems(if (player.mediaItemCount == 0) 0 else player.currentMediaItemIndex + 1, items) - player.prepare() - } - - fun addToQueue(items: List) { - player.addMediaItems(items) - player.prepare() - } - - fun toggleLibrary() { - database.query { - currentSong?.let { - update(it.song.toggleLibrary()) - } - } - } - - fun toggleLike() { - database.query { - currentSong?.let { - update(it.song.toggleLike()) - } - } - } - - private fun addToLibrary(mediaMetadata: MediaMetadata) { - database.query { - insert(mediaMetadata) - } - } - - fun setSleepTimer(minute: Int) { - sleepTimerJob?.cancel() - sleepTimerJob = null - if (minute == -1) { - pauseWhenSongEnd = true - } else { - sleepTimerTriggerTime = System.currentTimeMillis() + minute.minutes.inWholeMilliseconds - sleepTimerJob = scope.launch { - delay(minute.minutes) - player.pause() - sleepTimerTriggerTime = -1L - } - } - } - - fun clearSleepTimer() { - sleepTimerJob?.cancel() - sleepTimerJob = null - pauseWhenSongEnd = false - sleepTimerTriggerTime = -1L - } - - private fun openAudioEffectSession() { - context.sendBroadcast( - Intent(AudioEffect.ACTION_OPEN_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - ) - } - - private fun closeAudioEffectSession() { - context.sendBroadcast( - Intent(AudioEffect.ACTION_CLOSE_AUDIO_EFFECT_CONTROL_SESSION).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, player.audioSessionId) - } - ) - } - - /** - * Auto load more - */ - override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { - if (reason != MEDIA_ITEM_TRANSITION_REASON_REPEAT && - player.playbackState != STATE_IDLE && - player.mediaItemCount - player.currentMediaItemIndex <= 5 && - currentQueue.hasNextPage() - ) { - scope.launch { - val mediaItems = currentQueue.nextPage() - if (player.playbackState != STATE_IDLE) { - player.addMediaItems(mediaItems) - } - } - } - if (mediaItem == null) { - bitmapProvider.clear() - } - if (pauseWhenSongEnd) { - pauseWhenSongEnd = false - player.pause() - } - } - - override fun onPositionDiscontinuity(oldPosition: PositionInfo, newPosition: PositionInfo, @DiscontinuityReason reason: Int) { - if (reason == DISCONTINUITY_REASON_AUTO_TRANSITION && autoAddSong) { - oldPosition.mediaItem?.metadata?.let { - addToLibrary(it) - } - } - } - - override fun onPlaybackStateChanged(@State playbackState: Int) { - if (playbackState == STATE_IDLE) { - currentQueue = EmptyQueue - player.shuffleModeEnabled = false - mediaSession.setQueueTitle("") - } - if (playbackState == STATE_ENDED) { - if (autoAddSong) { - player.currentMetadata?.let { - addToLibrary(it) - } - } - if (pauseWhenSongEnd) { - pauseWhenSongEnd = false - player.pause() - } - } - } - - override fun onEvents(player: Player, events: Events) { - if (events.containsAny(EVENT_PLAYBACK_STATE_CHANGED, EVENT_PLAY_WHEN_READY_CHANGED, EVENT_IS_PLAYING_CHANGED, EVENT_POSITION_DISCONTINUITY)) { - if (player.playbackState != STATE_ENDED && player.playWhenReady) { - openAudioEffectSession() - } else { - closeAudioEffectSession() - } - } - if (events.containsAny(EVENT_TIMELINE_CHANGED, EVENT_POSITION_DISCONTINUITY)) { - currentMediaMetadata.value = player.currentMetadata - } - } - - override fun onPlaybackStatsReady(eventTime: AnalyticsListener.EventTime, playbackStats: PlaybackStats) { - val mediaItem = eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem - if (playbackStats.totalPlayTimeMs >= 30000 && !context.dataStore.get(PauseListenHistoryKey, false)) { - database.query { - incrementTotalPlayTime(mediaItem.mediaId, playbackStats.totalPlayTimeMs) - try { - insert( - Event( - songId = mediaItem.mediaId, - timestamp = LocalDateTime.now(), - playTime = playbackStats.totalPlayTimeMs - ) - ) - } catch (_: SQLException) { - } - } - } - } - - override fun onShuffleModeEnabledChanged(shuffleModeEnabled: Boolean) { - if (shuffleModeEnabled) { - // Always put current playing item at first - val shuffledIndices = IntArray(player.mediaItemCount) { it } - shuffledIndices.shuffle() - shuffledIndices[shuffledIndices.indexOf(player.currentMediaItemIndex)] = shuffledIndices[0] - shuffledIndices[0] = player.currentMediaItemIndex - player.setShuffleOrder(DefaultShuffleOrder(shuffledIndices, System.currentTimeMillis())) - } - } - - override fun onRepeatModeChanged(repeatMode: Int) { - scope.launch { - context.dataStore.edit { settings -> - settings[RepeatModeKey] = repeatMode - } - } - } - - private fun saveQueueToDisk() { - if (player.playbackState == STATE_IDLE) { - context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() - return - } - val persistQueue = PersistQueue( - title = mediaSession.controller.queueTitle?.toString(), - items = player.mediaItems.mapNotNull { it.metadata }, - mediaItemIndex = player.currentMediaItemIndex, - position = player.currentPosition - ) - runCatching { - context.filesDir.resolve(PERSISTENT_QUEUE_FILE).outputStream().use { fos -> - ObjectOutputStream(fos).use { oos -> - oos.writeObject(persistQueue) - } - } - }.onFailure { - it.printStackTrace() - } - } - - fun onDestroy() { - if (context.dataStore.get(PersistentQueueKey, true)) { - saveQueueToDisk() - } - mediaSession.apply { - isActive = false - release() - } - mediaSessionConnector.setPlayer(null) - playerNotificationManager.setPlayer(null) - player.removeListener(this) - player.release() - cache.release() - } - - enum class AudioQuality { - AUTO, HIGH, LOW - } - - companion object { - const val CHANNEL_ID = "music_channel_01" - const val NOTIFICATION_ID = 888 - const val ERROR_CODE_NO_STREAM = 1000001 - const val CHUNK_LENGTH = 512 * 1024L - const val PERSISTENT_QUEUE_FILE = "persistent_queue.data" - - fun createPendingIntent(context: Context, action: String, instanceId: Int): PendingIntent = PendingIntent.getBroadcast( - context, - instanceId, - Intent(action).setPackage(context.packageName).putExtra(EXTRA_INSTANCE_ID, instanceId), - FLAG_UPDATE_CURRENT or FLAG_IMMUTABLE - ) - } -} diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt index 9e6cae186..828106528 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/EmptyQueue.kt @@ -1,6 +1,6 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem import com.zionhuang.music.models.MediaMetadata object EmptyQueue : Queue { diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt index 231dca0d3..7d50c8463 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/ListQueue.kt @@ -1,6 +1,6 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem import com.zionhuang.music.models.MediaMetadata class ListQueue( diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt index 68a24d557..8c6d52682 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/Queue.kt @@ -1,6 +1,6 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem import com.zionhuang.music.models.MediaMetadata interface Queue { diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt index 9b9a50341..4c137cf06 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeAlbumRadio.kt @@ -1,6 +1,6 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.extensions.toMediaItem diff --git a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt index d8a472e4f..6dcf3680a 100644 --- a/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt +++ b/app/src/main/java/com/zionhuang/music/playback/queues/YouTubeQueue.kt @@ -1,6 +1,6 @@ package com.zionhuang.music.playback.queues -import com.google.android.exoplayer2.MediaItem +import androidx.media3.common.MediaItem import com.zionhuang.innertube.YouTube import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.music.extensions.toMediaItem diff --git a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt index d2d5f0c0d..602fd3cff 100644 --- a/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/provider/SongsProvider.kt @@ -8,7 +8,7 @@ import android.provider.DocumentsContract.Document import android.provider.DocumentsContract.Document.MIME_TYPE_DIR import android.provider.DocumentsContract.Root import android.provider.DocumentsProvider -import com.google.android.exoplayer2.util.FileTypes +import androidx.media3.common.FileTypes import com.zionhuang.music.R import com.zionhuang.music.constants.SongSortType import com.zionhuang.music.db.MusicDatabase 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 2e8c65b41..30183533c 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 @@ -3,7 +3,19 @@ package com.zionhuang.music.ui.player import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut import androidx.compose.foundation.background -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +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.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -21,8 +33,8 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import androidx.media3.common.Player.STATE_BUFFERING import coil.compose.AsyncImage -import com.google.android.exoplayer2.Player.STATE_BUFFERING import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.MiniPlayerHeight diff --git a/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt index 989f71b18..d96886432 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/PlaybackError.kt @@ -13,7 +13,7 @@ import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp -import com.google.android.exoplayer2.PlaybackException +import androidx.media3.common.PlaybackException import com.zionhuang.music.R @Composable 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 5427b2426..5adb5d195 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 @@ -4,11 +4,37 @@ import android.content.res.Configuration import androidx.compose.foundation.Image import androidx.compose.foundation.background import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.* +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +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.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.only +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.layout.windowInsetsPadding import androidx.compose.foundation.shape.CircleShape -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.NavigationBarDefaults +import androidx.compose.material3.Slider +import androidx.compose.material3.Text +import androidx.compose.material3.surfaceColorAtElevation +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 @@ -21,9 +47,12 @@ import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.compose.ui.util.fastForEachIndexed +import androidx.media3.common.C +import androidx.media3.common.Player.REPEAT_MODE_ALL +import androidx.media3.common.Player.REPEAT_MODE_OFF +import androidx.media3.common.Player.REPEAT_MODE_ONE +import androidx.media3.common.Player.STATE_READY import androidx.navigation.NavController -import com.google.android.exoplayer2.C -import com.google.android.exoplayer2.Player.* import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.QueuePeekHeight diff --git a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt index 5a41530db..12fba6f07 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/Queue.kt @@ -34,8 +34,8 @@ 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.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.navigation.NavController -import com.google.android.exoplayer2.source.ShuffleOrder.DefaultShuffleOrder import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight @@ -81,8 +81,8 @@ fun Queue( var showLyrics by rememberPreference(ShowLyricsKey, defaultValue = false) - val sleepTimerEnabled = remember(playerConnection.songPlayer.sleepTimerTriggerTime, playerConnection.songPlayer.pauseWhenSongEnd) { - playerConnection.songPlayer.sleepTimerTriggerTime != -1L || playerConnection.songPlayer.pauseWhenSongEnd + val sleepTimerEnabled = remember(playerConnection.service.sleepTimerTriggerTime, playerConnection.service.pauseWhenSongEnd) { + playerConnection.service.sleepTimerTriggerTime != -1L || playerConnection.service.pauseWhenSongEnd } var sleepTimerTimeLeft by remember { @@ -92,10 +92,10 @@ fun Queue( LaunchedEffect(sleepTimerEnabled) { if (sleepTimerEnabled) { while (isActive) { - sleepTimerTimeLeft = if (playerConnection.songPlayer.pauseWhenSongEnd) { + sleepTimerTimeLeft = if (playerConnection.service.pauseWhenSongEnd) { playerConnection.player.duration - playerConnection.player.currentPosition } else { - playerConnection.songPlayer.sleepTimerTriggerTime - System.currentTimeMillis() + playerConnection.service.sleepTimerTriggerTime - System.currentTimeMillis() } delay(1000L) } @@ -119,7 +119,7 @@ fun Queue( TextButton( onClick = { showSleepTimerDialog = false - playerConnection.songPlayer.setSleepTimer(sleepTimerValue.roundToInt()) + playerConnection.service.setSleepTimer(sleepTimerValue.roundToInt()) } ) { Text(stringResource(android.R.string.ok)) @@ -149,7 +149,7 @@ fun Queue( OutlinedButton( onClick = { showSleepTimerDialog = false - playerConnection.songPlayer.setSleepTimer(-1) + playerConnection.service.setSleepTimer(-1) } ) { Text(stringResource(R.string.end_of_song)) @@ -260,7 +260,7 @@ fun Queue( style = MaterialTheme.typography.labelLarge, modifier = Modifier .clip(RoundedCornerShape(50)) - .clickable(onClick = playerConnection.songPlayer::clearSleepTimer) + .clickable(onClick = playerConnection.service::clearSleepTimer) .padding(8.dp) ) } else { @@ -486,7 +486,7 @@ fun PlayerMenu( ) { mediaMetadata ?: return val context = LocalContext.current - val playerVolume = playerConnection.songPlayer.playerVolume.collectAsState() + val playerVolume = playerConnection.service.playerVolume.collectAsState() val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } var showSelectArtistDialog by rememberSaveable { @@ -539,7 +539,7 @@ fun PlayerMenu( BigSeekBar( progressProvider = playerVolume::value, - onProgressChange = { playerConnection.songPlayer.playerVolume.value = it }, + onProgressChange = { playerConnection.service.playerVolume.value = it }, modifier = Modifier.weight(1f) ) } @@ -556,7 +556,7 @@ fun PlayerMenu( icon = R.drawable.ic_radio, title = R.string.start_radio ) { - playerConnection.songPlayer.startRadioSeamlessly() + playerConnection.service.startRadioSeamlessly() onDismiss() } GridMenuItem( 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 eb4b57c66..37aa6446f 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 @@ -44,6 +44,8 @@ fun AboutScreen( .verticalScroll(rememberScrollState()), horizontalAlignment = Alignment.CenterHorizontally ) { + Spacer(Modifier.height(4.dp)) + Image( painter = painterResource(R.drawable.ic_launcher_monochrome), contentDescription = null, diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt index 97a819c9d..69be68bb7 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PlayerSettings.kt @@ -13,6 +13,7 @@ import androidx.navigation.NavController import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R import com.zionhuang.music.constants.AudioNormalizationKey +import com.zionhuang.music.constants.AudioQuality import com.zionhuang.music.constants.AudioQualityKey import com.zionhuang.music.constants.PersistentQueueKey import com.zionhuang.music.constants.SkipSilenceKey diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt index 926beed08..09a38edd0 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/settings/PrivacySettings.kt @@ -112,7 +112,3 @@ fun PrivacySettings( scrollBehavior = scrollBehavior ) } - -enum class AudioQuality { - AUTO, HIGH, LOW -} \ No newline at end of file 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 d122ce959..882c40126 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 @@ -6,8 +6,21 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LinearProgressIndicator +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +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.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource @@ -39,7 +52,7 @@ fun StorageSettings( ) { val context = LocalContext.current val imageDiskCache = context.imageLoader.diskCache ?: return - val playerCache = LocalPlayerConnection.current?.songPlayer?.cache ?: return + val playerCache = LocalPlayerConnection.current?.service?.cache ?: return val coroutineScope = rememberCoroutineScope() diff --git a/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt new file mode 100644 index 000000000..1b4beff72 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/utils/CoilBitmapLoader.kt @@ -0,0 +1,34 @@ +package com.zionhuang.music.utils + +import android.content.Context +import android.graphics.Bitmap +import android.graphics.BitmapFactory +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import androidx.media3.session.BitmapLoader +import coil.imageLoader +import coil.request.ImageRequest +import com.google.common.util.concurrent.ListenableFuture +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.guava.future + +class CoilBitmapLoader( + private val context: Context, + private val scope: CoroutineScope, +) : BitmapLoader { + override fun decodeBitmap(data: ByteArray): ListenableFuture = + scope.future(Dispatchers.IO) { + BitmapFactory.decodeByteArray(data, 0, data.size) ?: error("Could not decode image data") + } + + override fun loadBitmap(uri: Uri): ListenableFuture = + scope.future(Dispatchers.IO) { + val result = context.imageLoader.execute( + ImageRequest.Builder(context) + .data(uri) + .build() + ) + (result.drawable as BitmapDrawable).bitmap + } +} 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 7c45c1e36..56df178fc 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -13,7 +13,7 @@ import com.zionhuang.music.extensions.div import com.zionhuang.music.extensions.zipInputStream import com.zionhuang.music.extensions.zipOutputStream import com.zionhuang.music.playback.MusicService -import com.zionhuang.music.playback.SongPlayer +import com.zionhuang.music.playback.MusicService.Companion.PERSISTENT_QUEUE_FILE import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.runBlocking @@ -79,7 +79,7 @@ class BackupRestoreViewModel @Inject constructor( } } context.stopService(Intent(context, MusicService::class.java)) - context.filesDir.resolve(SongPlayer.PERSISTENT_QUEUE_FILE).delete() + context.filesDir.resolve(PERSISTENT_QUEUE_FILE).delete() context.startActivity(Intent(context, MainActivity::class.java)) exitProcess(0) }.onFailure { diff --git a/app/src/main/res/drawable/small_icon.xml b/app/src/main/res/drawable/small_icon.xml new file mode 100644 index 000000000..3da94f137 --- /dev/null +++ b/app/src/main/res/drawable/small_icon.xml @@ -0,0 +1,48 @@ + + + + + + + + + + + + + diff --git a/app/src/main/res/values/values.xml b/app/src/main/res/values/values.xml new file mode 100644 index 000000000..c786401e2 --- /dev/null +++ b/app/src/main/res/values/values.xml @@ -0,0 +1,8 @@ + + + + @drawable/ic_play + @drawable/ic_pause + @drawable/ic_skip_previous + @drawable/ic_skip_next + \ No newline at end of file diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1038db4de..c7836d6e3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,16 +3,20 @@ androidGradlePlugin = "7.4.2" kotlin = "1.8.0" compose-compiler = "1.4.0" compose = "1.3.3" -lifecycle = "2.5.1" +lifecycle = "2.6.1" material3 = "1.1.0-alpha05" -exoplayer = "2.18.2" -room = "2.5.0" +media3 = "1.0.2" +room = "2.5.1" hilt = "2.46.1" ktor = "2.2.2" [libraries] +guava = { group = "com.google.guava", name = "guava", version = "31.0.1-android" } +coroutines-guava = { group = "org.jetbrains.kotlinx", name = "kotlinx-coroutines-guava", version = "1.7.1" } +concurrent-futures = { group = "androidx.concurrent", name = "concurrent-futures-ktx", version = "1.1.0" } + gradle = { group = "com.android.tools.build", name = "gradle", version.ref = "androidGradlePlugin" } -activity = { group = "androidx.activity", name = "activity-compose", version = "1.5.1" } +activity = { group = "androidx.activity", name = "activity-compose", version = "1.7.2" } navigation = { group = "androidx.navigation", name = "navigation-compose", version = "2.5.3" } hilt-navigation = { group = "androidx.hilt", name = "hilt-navigation-compose", version = "1.0.0" } datastore = { group = "androidx.datastore", name = "datastore-preferences", version = "1.0.0" } @@ -41,9 +45,9 @@ shimmer = { group = "com.valentinilk.shimmer", name = "compose-shimmer", version palette = { group = "androidx.palette", name = "palette", version = "1.0.0" } -exoplayer = { group = "com.google.android.exoplayer", name = "exoplayer", version.ref = "exoplayer" } -exoplayer-mediasession = { group = "com.google.android.exoplayer", name = "extension-mediasession", version.ref = "exoplayer" } -exoplayer-okhttp = { group = "com.google.android.exoplayer", name = "extension-okhttp", version.ref = "exoplayer" } +media3 = { group = "androidx.media3", name = "media3-exoplayer", version.ref = "media3" } +media3-okhttp = { group = "androidx.media3", name = "media3-datasource-okhttp", version.ref = "media3" } +media3-session = { group = "androidx.media3", name = "media3-session", version.ref = "media3" } paging-runtime = { group = "androidx.paging", name = "paging-runtime", version = "3.1.1" } paging-compose = { group = "androidx.paging", name = "paging-compose", version = "1.0.0-alpha17" } diff --git a/lint.xml b/lint.xml new file mode 100644 index 000000000..d5be0261b --- /dev/null +++ b/lint.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file From dd00b3d03a8173c53bde1163d1dc14a24c1ad487 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Tue, 20 Jun 2023 21:28:04 +0800 Subject: [PATCH 230/323] Use video subtitle as a lyrics source (#506) --- .../music/lyrics/KuGouLyricsProvider.kt | 4 +- .../zionhuang/music/lyrics/LyricsHelper.kt | 2 +- .../zionhuang/music/lyrics/LyricsProvider.kt | 6 +- .../music/lyrics/YouTubeLyricsProvider.kt | 8 +- .../lyrics/YouTubeSubtitleLyricsProvider.kt | 11 +++ .../java/com/zionhuang/innertube/InnerTube.kt | 95 ++++++++++++------- .../java/com/zionhuang/innertube/YouTube.kt | 60 ++++++++++-- .../innertube/models/YouTubeClient.kt | 7 ++ .../models/body/GetTranscriptBody.kt | 10 ++ .../models/response/GetTranscriptResponse.kt | 65 +++++++++++++ .../com/zionhuang/innertube/YouTubeTest.kt | 15 ++- 11 files changed, 227 insertions(+), 56 deletions(-) create mode 100644 app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/body/GetTranscriptBody.kt create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/response/GetTranscriptResponse.kt diff --git a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt index 7558df262..4c4438e3d 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/KuGouLyricsProvider.kt @@ -11,10 +11,10 @@ object KuGouLyricsProvider : LyricsProvider { override fun isEnabled(context: Context): Boolean = context.dataStore[EnableKugouKey] ?: true - override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = KuGou.getLyrics(title, artist, duration) - override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + override suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { KuGou.getAllLyrics(title, artist, duration, callback) } } 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 86b7f0efb..9c1121aa2 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsHelper.kt @@ -10,7 +10,7 @@ import javax.inject.Inject class LyricsHelper @Inject constructor( @ApplicationContext private val context: Context, ) { - private val lyricsProviders = listOf(KuGouLyricsProvider, YouTubeLyricsProvider) + private val lyricsProviders = listOf(YouTubeSubtitleLyricsProvider, KuGouLyricsProvider, YouTubeLyricsProvider) private val cache = LruCache>(MAX_CACHE_SIZE) suspend fun getLyrics(mediaMetadata: MediaMetadata): String { diff --git a/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt index a993fc8bd..d338710e9 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/LyricsProvider.kt @@ -5,6 +5,8 @@ import android.content.Context interface LyricsProvider { val name: String fun isEnabled(context: Context): Boolean - suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result - suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int, callback: (String) -> Unit) + suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result + suspend fun getAllLyrics(id: String, title: String, artist: String, duration: Int, callback: (String) -> Unit) { + getLyrics(id, title, artist, duration).onSuccess(callback) + } } \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt index 6a1911cde..dd876f75c 100644 --- a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt +++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeLyricsProvider.kt @@ -7,14 +7,10 @@ import com.zionhuang.innertube.models.WatchEndpoint object YouTubeLyricsProvider : LyricsProvider { override val name = "YouTube Music" override fun isEnabled(context: Context) = true - override suspend fun getLyrics(id: String?, title: String, artist: String, duration: Int): Result = runCatching { - val nextResult = YouTube.next(WatchEndpoint(videoId = id!!)).getOrThrow() + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = runCatching { + val nextResult = YouTube.next(WatchEndpoint(videoId = id)).getOrThrow() YouTube.lyrics( endpoint = nextResult.lyricsEndpoint ?: throw IllegalStateException("Lyrics endpoint not found") ).getOrThrow() ?: throw IllegalStateException("Lyrics unavailable") } - - override suspend fun getAllLyrics(id: String?, title: String, artist: String, duration: Int, callback: (String) -> Unit) { - getLyrics(id, title, artist, duration).onSuccess(callback) - } } diff --git a/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt new file mode 100644 index 000000000..781a88891 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/lyrics/YouTubeSubtitleLyricsProvider.kt @@ -0,0 +1,11 @@ +package com.zionhuang.music.lyrics + +import android.content.Context +import com.zionhuang.innertube.YouTube + +object YouTubeSubtitleLyricsProvider : LyricsProvider { + override val name = "YouTube Subtitle" + override fun isEnabled(context: Context) = true + override suspend fun getLyrics(id: String, title: String, artist: String, duration: Int): Result = + YouTube.transcript(id) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index b4519d638..a4b7ae406 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -14,6 +14,7 @@ import io.ktor.client.plugins.contentnegotiation.* import io.ktor.client.request.* import io.ktor.http.* import io.ktor.serialization.kotlinx.json.* +import io.ktor.util.encodeBase64 import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.json.Json import java.net.Proxy @@ -104,11 +105,13 @@ class InnerTube { continuation: String? = null, ) = httpClient.post("search") { configYTClient(client) - setBody(SearchBody( - context = client.toContext(locale, visitorData), - query = query, - params = params - )) + setBody( + SearchBody( + context = client.toContext(locale, visitorData), + query = query, + params = params + ) + ) parameter("continuation", continuation) parameter("ctoken", continuation) } @@ -119,11 +122,13 @@ class InnerTube { playlistId: String?, ) = httpClient.post("player") { configYTClient(client) - setBody(PlayerBody( - context = client.toContext(locale, visitorData), - videoId = videoId, - playlistId = playlistId - )) + setBody( + PlayerBody( + context = client.toContext(locale, visitorData), + videoId = videoId, + playlistId = playlistId + ) + ) } suspend fun browse( @@ -133,11 +138,13 @@ class InnerTube { continuation: String? = null, ) = httpClient.post("browse") { configYTClient(client) - setBody(BrowseBody( - context = client.toContext(locale, visitorData), - browseId = browseId, - params = params - )) + setBody( + BrowseBody( + context = client.toContext(locale, visitorData), + browseId = browseId, + params = params + ) + ) parameter("continuation", continuation) parameter("ctoken", continuation) if (continuation != null) { @@ -155,15 +162,17 @@ class InnerTube { continuation: String? = null, ) = httpClient.post("next") { configYTClient(client) - setBody(NextBody( - context = client.toContext(locale, visitorData), - videoId = videoId, - playlistId = playlistId, - playlistSetVideoId = playlistSetVideoId, - index = index, - params = params, - continuation = continuation - )) + setBody( + NextBody( + context = client.toContext(locale, visitorData), + videoId = videoId, + playlistId = playlistId, + playlistSetVideoId = playlistSetVideoId, + index = index, + params = params, + continuation = continuation + ) + ) } suspend fun getSearchSuggestions( @@ -171,10 +180,12 @@ class InnerTube { input: String, ) = httpClient.post("music/get_search_suggestions") { configYTClient(client) - setBody(GetSearchSuggestionsBody( - context = client.toContext(locale, visitorData), - input = input - )) + setBody( + GetSearchSuggestionsBody( + context = client.toContext(locale, visitorData), + input = input + ) + ) } suspend fun getQueue( @@ -183,11 +194,29 @@ class InnerTube { playlistId: String?, ) = httpClient.post("music/get_queue") { configYTClient(client) - setBody(GetQueueBody( - context = client.toContext(locale, visitorData), - videoIds = videoIds, - playlistId = playlistId - )) + setBody( + GetQueueBody( + context = client.toContext(locale, visitorData), + videoIds = videoIds, + playlistId = playlistId + ) + ) + } + + suspend fun getTranscript( + client: YouTubeClient, + videoId: String, + ) = httpClient.post("https://music.youtube.com/youtubei/v1/get_transcript") { + parameter("key", "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3") + headers { + append("Content-Type", "application/json") + } + setBody( + GetTranscriptBody( + context = client.toContext(locale, null), + params = "\n${11.toChar()}$videoId".encodeBase64() + ) + ) } suspend fun getSwJsData() = httpClient.get("https://music.youtube.com/sw.js_data") diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index b57023f52..82dfd6203 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -1,13 +1,47 @@ package com.zionhuang.innertube -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.AccountInfo +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.Artist +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.BrowseEndpoint +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SearchSuggestions +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint import com.zionhuang.innertube.models.WatchEndpoint.WatchEndpointMusicSupportedConfigs.WatchEndpointMusicConfig.Companion.MUSIC_VIDEO_TYPE_ATV import com.zionhuang.innertube.models.YouTubeClient.Companion.ANDROID_MUSIC +import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB import com.zionhuang.innertube.models.YouTubeClient.Companion.WEB_REMIX -import com.zionhuang.innertube.models.response.* -import com.zionhuang.innertube.pages.* -import io.ktor.client.call.* -import io.ktor.client.statement.* +import com.zionhuang.innertube.models.YouTubeLocale +import com.zionhuang.innertube.models.getContinuation +import com.zionhuang.innertube.models.oddElements +import com.zionhuang.innertube.models.response.AccountMenuResponse +import com.zionhuang.innertube.models.response.BrowseResponse +import com.zionhuang.innertube.models.response.GetQueueResponse +import com.zionhuang.innertube.models.response.GetSearchSuggestionsResponse +import com.zionhuang.innertube.models.response.GetTranscriptResponse +import com.zionhuang.innertube.models.response.NextResponse +import com.zionhuang.innertube.models.response.PlayerResponse +import com.zionhuang.innertube.models.response.SearchResponse +import com.zionhuang.innertube.models.splitBySeparator +import com.zionhuang.innertube.pages.AlbumPage +import com.zionhuang.innertube.pages.ArtistItemsContinuationPage +import com.zionhuang.innertube.pages.ArtistItemsPage +import com.zionhuang.innertube.pages.ArtistPage +import com.zionhuang.innertube.pages.NewReleaseAlbumPage +import com.zionhuang.innertube.pages.NextPage +import com.zionhuang.innertube.pages.NextResult +import com.zionhuang.innertube.pages.PlaylistContinuationPage +import com.zionhuang.innertube.pages.PlaylistPage +import com.zionhuang.innertube.pages.RelatedPage +import com.zionhuang.innertube.pages.SearchPage +import com.zionhuang.innertube.pages.SearchResult +import com.zionhuang.innertube.pages.SearchSuggestionPage +import com.zionhuang.innertube.pages.SearchSummary +import com.zionhuang.innertube.pages.SearchSummaryPage +import io.ktor.client.call.body +import io.ktor.client.statement.bodyAsText import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonPrimitive import kotlinx.serialization.json.jsonArray @@ -305,9 +339,8 @@ object YouTube { ?.musicPlayButtonRenderer?.playNavigationEndpoint ?.watchEndpoint?.watchEndpointMusicSupportedConfigs ?.watchEndpointMusicConfig?.musicVideoType == MUSIC_VIDEO_TYPE_ATV - ) { - songs.add(item) - } + ) songs.add(item) + is AlbumItem -> albums.add(item) is ArtistItem -> artists.add(item) is PlaylistItem -> playlists.add(item) @@ -330,6 +363,17 @@ object YouTube { } } + suspend fun transcript(videoId: String): Result = runCatching { + val response = innerTube.getTranscript(WEB, videoId).body() + response.actions[0].updateEngagementPanelAction.content.transcriptRenderer.body.transcriptBodyRenderer.cueGroups.joinToString(separator = "\n") { group -> + val time = group.transcriptCueGroupRenderer.cues[0].transcriptCueRenderer.startOffsetMs + val text = group.transcriptCueGroupRenderer.cues[0].transcriptCueRenderer.cue.simpleText + .trim('♪') + .trim(' ') + "[%02d:%02d.%03d]$text".format(time / 60000, (time / 1000) % 60, time % 1000) + } + } + suspend fun visitorData(): Result = runCatching { Json.parseToJsonElement(innerTube.getSwJsData().bodyAsText().substring(5)) .jsonArray[0] diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt index 873e4ad4e..43ffff9dd 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YouTubeClient.kt @@ -40,6 +40,13 @@ data class YouTubeClient( userAgent = USER_AGENT_ANDROID, ) + val WEB = YouTubeClient( + clientName = "WEB", + clientVersion = "2.2021111", + api_key = "AIzaSyC9XL3ZjWddXya6X74dJoCTL-WEYFDNX3", + userAgent = USER_AGENT_WEB + ) + val WEB_REMIX = YouTubeClient( clientName = "WEB_REMIX", clientVersion = "1.20220606.03.00", diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/body/GetTranscriptBody.kt b/innertube/src/main/java/com/zionhuang/innertube/models/body/GetTranscriptBody.kt new file mode 100644 index 000000000..0904e5be6 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/body/GetTranscriptBody.kt @@ -0,0 +1,10 @@ +package com.zionhuang.innertube.models.body + +import com.zionhuang.innertube.models.Context +import kotlinx.serialization.Serializable + +@Serializable +data class GetTranscriptBody( + val context: Context, + val params: String, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/GetTranscriptResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/GetTranscriptResponse.kt new file mode 100644 index 000000000..8f15014ff --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/GetTranscriptResponse.kt @@ -0,0 +1,65 @@ +package com.zionhuang.innertube.models.response + +import kotlinx.serialization.Serializable + +@Serializable +data class GetTranscriptResponse( + val actions: List, +) { + @Serializable + data class Action( + val updateEngagementPanelAction: UpdateEngagementPanelAction, + ) { + @Serializable + data class UpdateEngagementPanelAction( + val content: Content, + ) { + @Serializable + data class Content( + val transcriptRenderer: TranscriptRenderer, + ) { + @Serializable + data class TranscriptRenderer( + val body: Body, + ) { + @Serializable + data class Body( + val transcriptBodyRenderer: TranscriptBodyRenderer, + ) { + @Serializable + data class TranscriptBodyRenderer( + val cueGroups: List, + ) { + @Serializable + data class CueGroup( + val transcriptCueGroupRenderer: TranscriptCueGroupRenderer, + ) { + @Serializable + data class TranscriptCueGroupRenderer( + val cues: List, + ) { + @Serializable + data class Cue( + val transcriptCueRenderer: TranscriptCueRenderer, + ) { + @Serializable + data class TranscriptCueRenderer( + val cue: SimpleText, + val startOffsetMs: Long, + val durationMs: Long, + ) { + @Serializable + data class SimpleText( + val simpleText: String, + ) + } + } + } + } + } + } + } + } + } + } +} diff --git a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt index 55e429fe3..a2914a4fc 100644 --- a/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt +++ b/innertube/src/test/java/com/zionhuang/innertube/YouTubeTest.kt @@ -7,10 +7,11 @@ import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PL import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO import com.zionhuang.innertube.models.WatchEndpoint -import io.ktor.client.* -import io.ktor.client.engine.okhttp.* -import io.ktor.client.request.* -import io.ktor.http.* +import io.ktor.client.HttpClient +import io.ktor.client.engine.okhttp.OkHttp +import io.ktor.client.request.get +import io.ktor.client.request.headers +import io.ktor.http.isSuccess import kotlinx.coroutines.runBlocking import org.junit.Assert.assertEquals import org.junit.Assert.assertTrue @@ -165,6 +166,12 @@ class YouTubeTest { assertTrue(relatedPage.songs.isNotEmpty()) } + @Test + fun transcript() = runBlocking { + val lyrics = YouTube.transcript("7G0ovtPqHnI").getOrThrow() + assertTrue(lyrics.isNotEmpty()) + } + companion object { private val VIDEO_IDS = listOf( "4H-N260cPCg", From 46da42ed2de2f7d59560557f89aa4c143c8c46b1 Mon Sep 17 00:00:00 2001 From: Zion Huang Date: Wed, 21 Jun 2023 11:57:57 +0800 Subject: [PATCH 231/323] Fix search summary fetcher --- .../ui/screens/search/OnlineSearchResult.kt | 45 +++++++++-- .../java/com/zionhuang/innertube/YouTube.kt | 24 ++++-- .../com/zionhuang/innertube/models/Button.kt | 4 +- .../models/MusicCardShelfRenderer.kt | 30 +++++++ .../innertube/models/SectionListRenderer.kt | 1 + .../innertube/pages/SearchSummaryPage.kt | 80 ++++++++++++++++++- 6 files changed, 171 insertions(+), 13 deletions(-) create mode 100644 innertube/src/main/java/com/zionhuang/innertube/models/MusicCardShelfRenderer.kt 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 7d9ada7ff..6b168e09d 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 @@ -3,14 +3,41 @@ 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.* +import androidx.compose.foundation.layout.Box +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.height +import androidx.compose.foundation.layout.only +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.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.* -import androidx.compose.runtime.* +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 +import androidx.compose.runtime.derivedStateOf +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource @@ -24,7 +51,12 @@ import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_COMMUNITY_P import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_FEATURED_PLAYLIST import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_SONG import com.zionhuang.innertube.YouTube.SearchFilter.Companion.FILTER_VIDEO -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.models.YTItem import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R @@ -138,6 +170,7 @@ fun OnlineSearchResult( coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) + is AlbumItem -> YouTubeAlbumMenu( album = item, navController = navController, @@ -145,11 +178,13 @@ fun OnlineSearchResult( coroutineScope = coroutineScope, onDismiss = menuState::dismiss ) + is ArtistItem -> YouTubeArtistMenu( artist = item, playerConnection = playerConnection, onDismiss = menuState::dismiss ) + is PlaylistItem -> {} } } @@ -201,7 +236,7 @@ fun OnlineSearchResult( items( items = summary.items, - key = { it.id }, + key = { "${summary.title}/${it.id}" }, itemContent = ytItemContent ) } diff --git a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt index 82dfd6203..cb3883a5e 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -94,12 +94,24 @@ object YouTube { val response = innerTube.search(WEB_REMIX, query).body() SearchSummaryPage( summaries = response.contents?.tabbedSearchResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { it -> - 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 - ) + if (it.musicCardShelfRenderer != null) + SearchSummary( + title = it.musicCardShelfRenderer.header.musicCardShelfHeaderBasicRenderer.title.runs?.firstOrNull()?.text ?: return@mapNotNull null, + items = listOfNotNull(SearchSummaryPage.fromMusicCardShelfRenderer(it.musicCardShelfRenderer)) + .plus( + it.musicCardShelfRenderer.contents + ?.mapNotNull { it.musicResponsiveListItemRenderer } + ?.mapNotNull(SearchSummaryPage.Companion::fromMusicResponsiveListItemRenderer) + .orEmpty() + ).takeIf { it.isNotEmpty() } ?: 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 + ) }!! ) } diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt b/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt index fc3e31e43..219e30901 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/Button.kt @@ -9,6 +9,8 @@ data class Button( @Serializable data class ButtonRenderer( val text: Runs, - val navigationEndpoint: NavigationEndpoint, + val navigationEndpoint: NavigationEndpoint?, + val command: NavigationEndpoint?, + val icon: Icon?, ) } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/MusicCardShelfRenderer.kt b/innertube/src/main/java/com/zionhuang/innertube/models/MusicCardShelfRenderer.kt new file mode 100644 index 000000000..07088b2c0 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicCardShelfRenderer.kt @@ -0,0 +1,30 @@ +package com.zionhuang.innertube.models + +import kotlinx.serialization.Serializable + +@Serializable +data class MusicCardShelfRenderer( + val title: Runs, + val subtitle: Runs, + val thumbnail: ThumbnailRenderer, + val header: Header, + val contents: List?, + val buttons: List