diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index 901114823..9a63af9a4 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -1,8 +1,16 @@ name: Build debug APK on: + workflow_dispatch: push: branches: - '**' + paths-ignore: + - 'README.md' + - 'fastlane/**' + - 'assets/**' + - '.github/**/*.md' + - '.github/FUNDING.yml' + - '.github/ISSUE_TEMPLATE/**' jobs: build: @@ -10,12 +18,11 @@ jobs: steps: - uses: actions/checkout@v3 + - name: Decode google-services.json + run: echo ${{ secrets.GOOGLE_SERVICES }} | base64 -d >> app/google-services.json + - name: Decode Keystore - id: decode_keystore - uses: timheuer/base64-to-file@v1 - with: - fileName: 'Key/music-debug.jks' - encodedString: ${{ secrets.KEYSTORE }} + run: echo ${{ secrets.KEYSTORE }} | base64 -d >> app/music-debug.jks - name: set up JDK 11 uses: actions/setup-java@v3 @@ -27,13 +34,12 @@ jobs: - name: Build debug APK and run jvm tests run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint env: - MUSIC_DEBUG_KEYSTORE_FILE: 'Key/music-debug.jks' + MUSIC_DEBUG_KEYSTORE_FILE: 'music-debug.jks' MUSIC_DEBUG_SIGNING_STORE_PASSWORD: ${{ secrets.SIGNING_KEY_PASSWORD }} - MUSIC_DEBUG_SIGNING_KEY_ALIAS: ${{ secrets.SIGNING_KEY_ALIAS }} MUSIC_DEBUG_SIGNING_KEY_PASSWORD: ${{ secrets.SIGNING_STORE_PASSWORD }} - name: Upload APK - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: app - path: app/build/outputs/apk/debug/*.apk \ No newline at end of file + path: app/build/outputs/apk/debug/*.apk diff --git a/.github/workflows/build_pr.yml b/.github/workflows/build_pr.yml index 2857a57a0..6fafe23f7 100644 --- a/.github/workflows/build_pr.yml +++ b/.github/workflows/build_pr.yml @@ -19,9 +19,11 @@ jobs: - name: Build debug APK and run jvm tests run: ./gradlew assembleDebug lintDebug testDebugUnitTest --stacktrace -DskipFormatKtlint + env: + PULL_REQUEST: 'true' - name: Upload APK - uses: actions/upload-artifact@v2 + uses: actions/upload-artifact@v3 with: name: app - path: app/build/outputs/apk/debug/*.apk \ No newline at end of file + path: app/build/outputs/apk/debug/*.apk diff --git a/.gitignore b/.gitignore index 7a638a3dd..ecfc60f0e 100755 --- a/.gitignore +++ b/.gitignore @@ -61,7 +61,7 @@ captures/ .cxx/ # Google Services (e.g. APIs or Firebase) -# google-services.json +google-services.json # Freeline freeline.py diff --git a/README.md b/README.md index f40518fe7..442d9899c 100644 --- a/README.md +++ b/README.md @@ -16,11 +16,13 @@ A Material 3 YouTube Music client for Android - Play songs from YT/YT Music without ads - Background playback - Search songs, videos, albums, and playlists from YouTube Music +- Login support - Library management - Cache and download songs for offline playback - Synchronized lyrics - Skip silence - Audio normalization +- Adjust tempo/pitch - Dynamic theme - Localization - Android Auto support @@ -66,7 +68,8 @@ before you create a pull request. ## Donate -If you like InnerTune, you're welcome to send a donation. +If you like InnerTune, you're welcome to send a donation. Donations will support the development, +including bug fixes and new features. Liberapay Liberapay diff --git a/app/build.gradle.kts b/app/build.gradle.kts index eea305491..5b368d687 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -6,6 +6,11 @@ plugins { kotlin("kapt") id("com.google.dagger.hilt.android") id("com.google.devtools.ksp") + if (System.getenv("PULL_REQUEST") == null) { + id("com.google.gms.google-services") + id("com.google.firebase.crashlytics") + id("com.google.firebase.firebase-perf") + } } android { @@ -16,8 +21,8 @@ android { applicationId = "com.zionhuang.music" minSdk = 24 targetSdk = 33 - versionCode = 16 - versionName = "0.5.0" + versionCode = 17 + versionName = "0.5.1" testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" } buildTypes { @@ -35,12 +40,9 @@ android { signingConfigs { getByName("debug") { if (System.getenv("MUSIC_DEBUG_SIGNING_STORE_PASSWORD") != null) { - val tmpFilePath = System.getProperty("user.home") + "/work/_temp/Key/" - val allFilesFromDir = File(tmpFilePath).listFiles() - val keystoreFile = allFilesFromDir?.first() - storeFile = keystoreFile ?: file(System.getenv("MUSIC_DEBUG_KEYSTORE_FILE")) + storeFile = file(System.getenv("MUSIC_DEBUG_KEYSTORE_FILE")) storePassword = System.getenv("MUSIC_DEBUG_SIGNING_STORE_PASSWORD") - keyAlias = System.getenv("MUSIC_DEBUG_SIGNING_KEY_ALIAS") + keyAlias = "debug" keyPassword = System.getenv("MUSIC_DEBUG_SIGNING_KEY_PASSWORD") } } @@ -124,5 +126,11 @@ dependencies { coreLibraryDesugaring(libs.desugaring) + implementation(platform(libs.firebase.bom)) + implementation(libs.firebase.analytics) + implementation(libs.firebase.crashlytics) + implementation(libs.firebase.config) + implementation(libs.firebase.perf) + implementation(libs.timber) } diff --git a/app/schemas/com.zionhuang.music.db.InternalDatabase/11.json b/app/schemas/com.zionhuang.music.db.InternalDatabase/11.json new file mode 100644 index 000000000..c3981e170 --- /dev/null +++ b/app/schemas/com.zionhuang.music.db.InternalDatabase/11.json @@ -0,0 +1,796 @@ +{ + "formatVersion": 1, + "database": { + "version": 11, + "identityHash": "de2e37d1206f721ad51de3a08f66f99c", + "entities": [ + { + "tableName": "song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `duration` INTEGER NOT NULL, `thumbnailUrl` TEXT, `albumId` TEXT, `albumName` TEXT, `liked` INTEGER NOT NULL, `totalPlayTime` INTEGER NOT NULL, `inLibrary` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "duration", + "columnName": "duration", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumId", + "columnName": "albumId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "albumName", + "columnName": "albumName", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "liked", + "columnName": "liked", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "totalPlayTime", + "columnName": "totalPlayTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "inLibrary", + "columnName": "inLibrary", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "artist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `name` TEXT NOT NULL, `thumbnailUrl` TEXT, `lastUpdateTime` INTEGER NOT NULL, `bookmarkedAt` INTEGER, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "lastUpdateTime", + "columnName": "lastUpdateTime", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "bookmarkedAt", + "columnName": "bookmarkedAt", + "affinity": "INTEGER", + "notNull": false + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "album", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `year` INTEGER, `thumbnailUrl` TEXT, `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": "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, 'de2e37d1206f721ad51de3a08f66f99c')" + ] + } +} \ No newline at end of file diff --git a/app/src/debug/res/xml-v25/shortcuts.xml b/app/src/debug/res/xml-v25/shortcuts.xml new file mode 100644 index 000000000..a3ea2b929 --- /dev/null +++ b/app/src/debug/res/xml-v25/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index 302df9b66..d981d51ae 100755 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -104,6 +104,9 @@ + + NavigationTab.SONG + ACTION_ALBUMS -> NavigationTab.ALBUM + ACTION_PLAYLISTS -> NavigationTab.PLAYLIST + else -> null + } + } val (query, onQueryChange) = rememberSaveable(stateSaver = TextFieldValue.Saver) { mutableStateOf(TextFieldValue()) @@ -238,10 +261,12 @@ class MainActivity : ComponentActivity() { } var searchSource by rememberEnumPreference(SearchSourceKey, SearchSource.ONLINE) + val searchBarFocusRequester = remember { FocusRequester() } + val onSearch: (String) -> Unit = { if (it.isNotEmpty()) { onActiveChange(false) - navController.navigate("search/$it") + navController.navigate("search/${URLEncoder.encode(it, "UTF-8")}") if (dataStore[PauseSearchHistoryKey] != true) { database.query { insert(SearchHistory(query = it)) @@ -250,6 +275,10 @@ class MainActivity : ComponentActivity() { } } + var openSearchImmediately: Boolean by remember { + mutableStateOf(intent?.action == ACTION_SEARCH) + } + val shouldShowSearchBar = remember(active, navBackStackEntry) { active || navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route } || navBackStackEntry?.destination?.route?.startsWith("search/") == true @@ -288,7 +317,9 @@ class MainActivity : ComponentActivity() { LaunchedEffect(navBackStackEntry) { if (navBackStackEntry?.destination?.route?.startsWith("search/") == true) { - val searchQuery = navBackStackEntry?.arguments?.getString("query")!! + val searchQuery = withContext(Dispatchers.IO) { + URLDecoder.decode(navBackStackEntry?.arguments?.getString("query")!!, "UTF-8") + } onQueryChange(TextFieldValue(searchQuery, TextRange(searchQuery.length))) } else if (navigationItems.fastAny { it.route == navBackStackEntry?.destination?.route }) { onQueryChange(TextFieldValue()) @@ -344,6 +375,8 @@ class MainActivity : ComponentActivity() { songs.firstOrNull()?.album?.id?.let { browseId -> navController.navigate("album/$browseId") } + }.onFailure { + it.printStackTrace() } } } else { @@ -365,6 +398,8 @@ class MainActivity : ComponentActivity() { YouTube.queue(listOf(videoId)) }.onSuccess { sharedSong = it.firstOrNull() + }.onFailure { + it.printStackTrace() } } } @@ -385,7 +420,7 @@ class MainActivity : ComponentActivity() { ) { NavHost( navController = navController, - startDestination = when (defaultOpenTab) { + startDestination = when (tabOpenedFromShortcut ?: defaultOpenTab) { NavigationTab.HOME -> Screens.Home NavigationTab.SONG -> Screens.Songs NavigationTab.ARTIST -> Screens.Artists @@ -415,6 +450,12 @@ class MainActivity : ComponentActivity() { composable("stats") { StatsScreen(navController) } + composable("mood_and_genres") { + MoodAndGenresScreen(navController, scrollBehavior) + } + composable("account") { + AccountScreen(navController, scrollBehavior) + } composable("new_release") { NewReleaseScreen(navController, scrollBehavior) } @@ -506,8 +547,23 @@ class MainActivity : ComponentActivity() { LocalPlaylistScreen(navController, scrollBehavior) } } + composable( + route = "youtube_browse/{browseId}?params={params}", + arguments = listOf( + navArgument("browseId") { + type = NavType.StringType + nullable = true + }, + navArgument("params") { + type = NavType.StringType + nullable = true + } + ) + ) { + YouTubeBrowseScreen(navController, scrollBehavior) + } composable("settings") { - SettingsScreen(navController, scrollBehavior) + SettingsScreen(latestVersion, navController, scrollBehavior) } composable("settings/appearance") { AppearanceSettings(navController, scrollBehavior) @@ -530,6 +586,9 @@ class MainActivity : ComponentActivity() { composable("settings/about") { AboutScreen(navController, scrollBehavior) } + composable("login") { + LoginScreen(navController) + } } AnimatedVisibility( @@ -605,9 +664,41 @@ class MainActivity : ComponentActivity() { contentDescription = null ) } + } else if (navBackStackEntry?.destination?.route in listOf( + Screens.Home.route, + Screens.Songs.route, + Screens.Artists.route, + Screens.Albums.route, + Screens.Playlists.route + ) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(48.dp) + .clip(CircleShape) + .clickable { + navController.navigate("settings") + } + ) { + BadgedBox( + badge = { + if (latestVersion > BuildConfig.VERSION_CODE) { + Badge() + } + } + ) { + + Icon( + painter = painterResource(R.drawable.settings), + contentDescription = null + ) + } + } } }, - modifier = Modifier.align(Alignment.TopCenter) + focusRequester = searchBarFocusRequester, + modifier = Modifier.align(Alignment.TopCenter), ) { Crossfade( targetState = searchSource, @@ -629,7 +720,7 @@ class MainActivity : ComponentActivity() { onQueryChange = onQueryChange, navController = navController, onSearch = { - navController.navigate("search/$it") + navController.navigate("search/${URLEncoder.encode(it, "UTF-8")}") if (dataStore[PauseSearchHistoryKey] != true) { database.query { insert(SearchHistory(query = it)) @@ -725,6 +816,14 @@ class MainActivity : ComponentActivity() { } } } + + LaunchedEffect(shouldShowSearchBar, openSearchImmediately) { + if (shouldShowSearchBar && openSearchImmediately) { + onActiveChange(true) + searchBarFocusRequester.requestFocus() + openSearchImmediately = false + } + } } } } @@ -743,6 +842,34 @@ class MainActivity : ComponentActivity() { window.navigationBarColor = (if (isDark) Color.Transparent else Color.Black.copy(alpha = 0.2f)).toArgb() } } + + private fun setupRemoteConfig() { + val remoteConfig = Firebase.remoteConfig + remoteConfig.setConfigSettingsAsync(remoteConfigSettings { + minimumFetchIntervalInSeconds = 12.hours.inWholeSeconds + }) + remoteConfig.fetchAndActivate() + .addOnCompleteListener(this) { task -> + if (task.isSuccessful) { + latestVersion = remoteConfig.getLong("latest_version") + } + } + remoteConfig.addOnConfigUpdateListener(object : ConfigUpdateListener { + override fun onError(error: FirebaseRemoteConfigException) {} + override fun onUpdate(configUpdate: ConfigUpdate) { + remoteConfig.activate().addOnCompleteListener { + latestVersion = remoteConfig.getLong("latest_version") + } + } + }) + } + + companion object { + const val ACTION_SEARCH = "com.zionhuang.music.action.SEARCH" + const val ACTION_SONGS = "com.zionhuang.music.action.SONGS" + const val ACTION_ALBUMS = "com.zionhuang.music.action.ALBUMS" + const val ACTION_PLAYLISTS = "com.zionhuang.music.action.PLAYLISTS" + } } val LocalDatabase = staticCompositionLocalOf { error("No database provided") } 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 ac29554e1..4c1053692 100644 --- a/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt +++ b/app/src/main/java/com/zionhuang/music/constants/PreferenceKeys.kt @@ -38,8 +38,8 @@ val SongSortTypeKey = stringPreferencesKey("songSortType") val SongSortDescendingKey = booleanPreferencesKey("songSortDescending") val DownloadedSongSortTypeKey = stringPreferencesKey("songSortType") val DownloadedSongSortDescendingKey = booleanPreferencesKey("songSortDescending") -val PlaylistSongSortTypeKey = stringPreferencesKey("songSortType") -val PlaylistSongSortDescendingKey = booleanPreferencesKey("songSortDescending") +val PlaylistSongSortTypeKey = stringPreferencesKey("playlistSongSortType") +val PlaylistSongSortDescendingKey = booleanPreferencesKey("playlistSongSortDescending") val ArtistSortTypeKey = stringPreferencesKey("artistSortType") val ArtistSortDescendingKey = booleanPreferencesKey("artistSortDescending") val AlbumSortTypeKey = stringPreferencesKey("albumSortType") @@ -49,41 +49,45 @@ val PlaylistSortDescendingKey = booleanPreferencesKey("playlistSortDescending") val ArtistSongSortTypeKey = stringPreferencesKey("artistSongSortType") val ArtistSongSortDescendingKey = booleanPreferencesKey("artistSongSortDescending") +val ArtistViewTypeKey = stringPreferencesKey("artistViewType") + val PlaylistEditLockKey = booleanPreferencesKey("playlistEditLock") enum class SongSortType { - CREATE_DATE, NAME, ARTIST + CREATE_DATE, NAME, ARTIST, PLAY_TIME } enum class DownloadedSongSortType { - CREATE_DATE, NAME, ARTIST + CREATE_DATE, NAME, ARTIST, PLAY_TIME } enum class PlaylistSongSortType { - CUSTOM, CREATE_DATE, NAME, ARTIST + CUSTOM, CREATE_DATE, NAME, ARTIST, PLAY_TIME } enum class ArtistSortType { - CREATE_DATE, NAME, SONG_COUNT + CREATE_DATE, NAME, SONG_COUNT, PLAY_TIME } enum class ArtistSongSortType { - CREATE_DATE, NAME + CREATE_DATE, NAME, PLAY_TIME } enum class AlbumSortType { - CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH + CREATE_DATE, NAME, ARTIST, YEAR, SONG_COUNT, LENGTH, PLAY_TIME } enum class PlaylistSortType { CREATE_DATE, NAME, SONG_COUNT } +enum class ArtistViewType { + ALL, BOOKMARKED +} + val ShowLyricsKey = booleanPreferencesKey("showLyrics") val LyricsTextPositionKey = stringPreferencesKey("lyricsTextPosition") -val NavTabConfigKey = stringPreferencesKey("navTabConfig") - val PlayerVolumeKey = floatPreferencesKey("playerVolume") val RepeatModeKey = intPreferencesKey("repeatMode") diff --git a/app/src/main/java/com/zionhuang/music/constants/StatPeriod.kt b/app/src/main/java/com/zionhuang/music/constants/StatPeriod.kt new file mode 100644 index 000000000..6127aae46 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/constants/StatPeriod.kt @@ -0,0 +1,18 @@ +package com.zionhuang.music.constants + +import java.time.LocalDateTime +import java.time.ZoneOffset + +enum class StatPeriod { + `1_WEEK`, `1_MONTH`, `3_MONTH`, `6_MONTH`, `1_YEAR`, ALL; + + fun toTimeMillis(): Long = + when (this) { + `1_WEEK` -> LocalDateTime.now().minusWeeks(1).toInstant(ZoneOffset.UTC).toEpochMilli() + `1_MONTH` -> LocalDateTime.now().minusMonths(1).toInstant(ZoneOffset.UTC).toEpochMilli() + `3_MONTH` -> LocalDateTime.now().minusMonths(3).toInstant(ZoneOffset.UTC).toEpochMilli() + `6_MONTH` -> LocalDateTime.now().minusMonths(6).toInstant(ZoneOffset.UTC).toEpochMilli() + `1_YEAR` -> LocalDateTime.now().minusMonths(12).toInstant(ZoneOffset.UTC).toEpochMilli() + ALL -> 0 + } +} \ 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 index e387ae520..7c86c4c31 100644 --- a/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt +++ b/app/src/main/java/com/zionhuang/music/db/DatabaseDao.kt @@ -30,6 +30,10 @@ interface DatabaseDao { @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY title") fun songsByNameAsc(): Flow> + @Transaction + @Query("SELECT * FROM song WHERE inLibrary IS NOT NULL ORDER BY totalPlayTime") + fun songsByPlayTimeAsc(): Flow> + fun songs(sortType: SongSortType, descending: Boolean) = when (sortType) { SongSortType.CREATE_DATE -> songsByCreateDateAsc() @@ -39,6 +43,8 @@ interface DatabaseDao { song.artists.joinToString(separator = "") { it.name } } } + + SongSortType.PLAY_TIME -> songsByPlayTimeAsc() }.map { it.reversed(descending) } @Transaction @@ -53,6 +59,10 @@ interface DatabaseDao { @Query("SELECT * FROM song WHERE liked ORDER BY title") fun likedSongsByNameAsc(): Flow> + @Transaction + @Query("SELECT * FROM song WHERE liked ORDER BY totalPlayTime") + fun likedSongsByPlayTimeAsc(): Flow> + fun likedSongs(sortType: SongSortType, descending: Boolean) = when (sortType) { SongSortType.CREATE_DATE -> likedSongsByCreateDateAsc() @@ -62,6 +72,8 @@ interface DatabaseDao { song.artists.joinToString(separator = "") { it.name } } } + + SongSortType.PLAY_TIME -> likedSongsByPlayTimeAsc() }.map { it.reversed(descending) } @Query("SELECT COUNT(1) FROM song WHERE liked") @@ -83,10 +95,15 @@ interface DatabaseDao { @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") fun artistSongsByNameAsc(artistId: String): Flow> + @Transaction + @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 totalPlayTime") + fun artistSongsByPlayTimeAsc(artistId: String): Flow> + fun artistSongs(artistId: String, sortType: ArtistSongSortType, descending: Boolean) = when (sortType) { ArtistSongSortType.CREATE_DATE -> artistSongsByCreateDateAsc(artistId) ArtistSongSortType.NAME -> artistSongsByNameAsc(artistId) + ArtistSongSortType.PLAY_TIME -> artistSongsByPlayTimeAsc(artistId) }.map { it.reversed(descending) } @Transaction @@ -127,8 +144,19 @@ interface DatabaseDao { fun quickPicks(now: Long = System.currentTimeMillis()): Flow> @Transaction - @Query("SELECT * FROM song ORDER BY totalPlayTime DESC LIMIT :limit") - fun mostPlayedSongs(limit: Int = 6): Flow> + @Query( + """ + SELECT * + FROM song + WHERE id IN (SELECT songId + FROM event + WHERE timestamp > :fromTimeStamp + GROUP BY songId + ORDER BY SUM(playTime) DESC + LIMIT :limit) + """ + ) + fun mostPlayedSongs(fromTimeStamp: Long, limit: Int = 6): Flow> @Transaction @Query( @@ -139,17 +167,39 @@ interface DatabaseDao { JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount - FROM (SELECT artistId, SUM(playtime) AS totalPlaytime - FROM (SELECT *, (SELECT totalPlayTime FROM song WHERE id = songId) AS playtime - FROM song_artist_map) - GROUP BY artistId) - JOIN artist - ON artist.id = artistId - ORDER BY totalPlaytime DESC + FROM artist + JOIN(SELECT artistId, SUM(songTotalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime + FROM event + WHERE timestamp > :fromTimeStamp + GROUP BY songId) AS e + ON song_artist_map.songId = e.songId + GROUP BY artistId + ORDER BY totalPlayTime DESC + LIMIT :limit) + ON artist.id = artistId + """ + ) + fun mostPlayedArtists(fromTimeStamp: Long, limit: Int = 6): Flow> + + @Transaction + @Query( + """ + SELECT albumId + FROM song + JOIN (SELECT songId, SUM(playTime) AS songTotalPlayTime + FROM event + WHERE timestamp > :fromTimeStamp + GROUP BY songId) AS e + ON song.id = e.songId + WHERE albumId IS NOT NULL + GROUP BY albumId + ORDER BY SUM(songTotalPlayTime) DESC LIMIT :limit """ ) - fun mostPlayedArtists(limit: Int = 6): Flow> + fun mostPlayedAlbums(fromTimeStamp: Long, limit: Int = 6): Flow> @Transaction @Query("SELECT * FROM song WHERE id = :songId") @@ -166,7 +216,7 @@ interface DatabaseDao { fun lyrics(id: String?): Flow @Transaction - @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY rowId") + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE songCount > 0 ORDER BY bookmarkedAt") fun artistsByCreateDateAsc(): Flow> @Transaction @@ -177,11 +227,76 @@ interface DatabaseDao { @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") fun artistsBySongCountAsc(): Flow> + @Transaction + @Query( + """ + SELECT artist.*, + (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 + JOIN(SELECT artistId, SUM(totalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN song + ON song_artist_map.songId = song.id + GROUP BY artistId + ORDER BY totalPlayTime) + ON artist.id = artistId + WHERE songCount > 0 + """ + ) + fun artistsByPlayTimeAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY bookmarkedAt") + fun artistsBookmarkedByCreateDateAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY name") + fun artistsBookmarkedByNameAsc(): Flow> + + @Transaction + @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE bookmarkedAt IS NOT NULL ORDER BY songCount") + fun artistsBookmarkedBySongCountAsc(): Flow> + + @Transaction + @Query( + """ + SELECT artist.*, + (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 + JOIN(SELECT artistId, SUM(totalPlayTime) AS totalPlayTime + FROM song_artist_map + JOIN song + ON song_artist_map.songId = song.id + GROUP BY artistId + ORDER BY totalPlayTime) + ON artist.id = artistId + WHERE bookmarkedAt IS NOT NULL + """ + ) + fun artistsBookmarkedByPlayTimeAsc(): Flow> + fun artists(sortType: ArtistSortType, descending: Boolean) = when (sortType) { ArtistSortType.CREATE_DATE -> artistsByCreateDateAsc() ArtistSortType.NAME -> artistsByNameAsc() ArtistSortType.SONG_COUNT -> artistsBySongCountAsc() + ArtistSortType.PLAY_TIME -> artistsByPlayTimeAsc() + }.map { it.reversed(descending) } + + fun artistsBookmarked(sortType: ArtistSortType, descending: Boolean) = + when (sortType) { + ArtistSortType.CREATE_DATE -> artistsBookmarkedByCreateDateAsc() + ArtistSortType.NAME -> artistsBookmarkedByNameAsc() + ArtistSortType.SONG_COUNT -> artistsBookmarkedBySongCountAsc() + ArtistSortType.PLAY_TIME -> artistsBookmarkedByPlayTimeAsc() }.map { it.reversed(descending) } @Query("SELECT * FROM artist WHERE id = :id") @@ -211,6 +326,19 @@ interface DatabaseDao { @Query("SELECT * FROM album ORDER BY duration") fun albumsByLengthAsc(): Flow> + @Transaction + @Query( + """ + SELECT album.* + FROM album + JOIN song + ON song.albumId = album.id + GROUP BY album.id + ORDER BY SUM(song.totalPlayTime) + """ + ) + fun albumsByPlayTimeAsc(): Flow> + fun albums(sortType: AlbumSortType, descending: Boolean) = when (sortType) { AlbumSortType.CREATE_DATE -> albumsByCreateDateAsc() @@ -224,6 +352,7 @@ interface DatabaseDao { AlbumSortType.YEAR -> albumsByYearAsc() AlbumSortType.SONG_COUNT -> albumsBySongCountAsc() AlbumSortType.LENGTH -> albumsByLengthAsc() + AlbumSortType.PLAY_TIME -> albumsByPlayTimeAsc() }.map { it.reversed(descending) } @Transaction @@ -262,7 +391,7 @@ interface DatabaseDao { fun searchSongs(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction - @Query("SELECT *, (SELECT COUNT(1) FROM song_artist_map JOIN song ON song_artist_map.songId = song.id WHERE artistId = artist.id AND song.inLibrary IS NOT NULL) AS songCount FROM artist WHERE name LIKE '%' || :query || '%' AND songCount > 0 LIMIT :previewSize") + @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 name LIKE '%' || :query || '%' AND bookmarkedAt IS NOT NULL LIMIT :previewSize") fun searchArtists(query: String, previewSize: Int = Int.MAX_VALUE): Flow> @Transaction 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 42597f518..a867fa5d8 100644 --- a/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt +++ b/app/src/main/java/com/zionhuang/music/db/MusicDatabase.kt @@ -59,7 +59,7 @@ class MusicDatabase( SortedSongAlbumMap::class, PlaylistSongMapPreview::class ], - version = 10, + version = 11, exportSchema = true, autoMigrations = [ AutoMigration(from = 2, to = 3), @@ -69,7 +69,8 @@ class MusicDatabase( AutoMigration(from = 6, to = 7, spec = Migration6To7::class), AutoMigration(from = 7, to = 8, spec = Migration7To8::class), AutoMigration(from = 8, to = 9), - AutoMigration(from = 9, to = 10, spec = Migration9To10::class) + AutoMigration(from = 9, to = 10, spec = Migration9To10::class), + AutoMigration(from = 10, to = 11, spec = Migration10To11::class) ] ) @TypeConverters(Converters::class) @@ -99,7 +100,7 @@ val MIGRATION_1_2 = object : Migration(1, 2) { val albumName: String? = null, val liked: Boolean = false, val totalPlayTime: Long = 0, // in milliseconds - val downloadState: Int = SongEntity.STATE_NOT_DOWNLOADED, + val downloadState: Int = 0, val createDate: LocalDateTime = LocalDateTime.now(), val modifyDate: LocalDateTime = LocalDateTime.now(), ) @@ -211,7 +212,7 @@ val MIGRATION_1_2 = object : Migration(1, 2) { "artist", SQLiteDatabase.CONFLICT_ABORT, contentValuesOf( "id" to artist.id, "name" to artist.name, - "createDate" to converters.dateToTimestamp(artist.createDate), + "createDate" to converters.dateToTimestamp(artist.lastUpdateTime), "lastUpdateTime" to converters.dateToTimestamp(artist.lastUpdateTime) ) ) @@ -305,4 +306,12 @@ class Migration7To8 : AutoMigrationSpec @DeleteTable.Entries( DeleteTable(tableName = "download") ) -class Migration9To10 : AutoMigrationSpec \ No newline at end of file +class Migration9To10 : AutoMigrationSpec + +@DeleteColumn.Entries( + DeleteColumn(tableName = "song", columnName = "downloadState"), + DeleteColumn(tableName = "artist", columnName = "bannerUrl"), + DeleteColumn(tableName = "artist", columnName = "description"), + DeleteColumn(tableName = "artist", columnName = "createDate") +) +class Migration10To11 : AutoMigrationSpec \ 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 e054e2e22..e38a3a937 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 @@ -12,13 +12,9 @@ data class ArtistEntity( @PrimaryKey val id: String, val name: String, val thumbnailUrl: String? = null, - val bannerUrl: String? = null, - val description: String? = null, - val createDate: LocalDateTime = LocalDateTime.now(), val lastUpdateTime: LocalDateTime = LocalDateTime.now(), + val bookmarkedAt: LocalDateTime? = null, ) { - override fun toString(): String = name - val isYouTubeArtist: Boolean get() = id.startsWith("UC") 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 dfed7f0b7..c5a1d3681 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 @@ -16,7 +16,6 @@ data class SongEntity( val albumName: String? = null, val liked: Boolean = false, val totalPlayTime: Long = 0, // in milliseconds - val downloadState: Int = STATE_NOT_DOWNLOADED, val inLibrary: LocalDateTime? = null, ) { fun toggleLike() = copy( @@ -25,11 +24,4 @@ data class SongEntity( ) fun toggleLibrary() = copy(inLibrary = if (inLibrary == null) LocalDateTime.now() else null) - - 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/extensions/MediaItemExt.kt b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt index 50ae92c12..f0717300f 100644 --- a/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt +++ b/app/src/main/java/com/zionhuang/music/extensions/MediaItemExt.kt @@ -22,6 +22,7 @@ fun Song.toMediaItem() = MediaItem.Builder() .setSubtitle(artists.joinToString { it.name }) .setArtist(artists.joinToString { it.name }) .setArtworkUri(song.thumbnailUrl?.toUri()) + .setAlbumTitle(song.albumName) .setMediaType(MEDIA_TYPE_MUSIC) .build() ) @@ -38,6 +39,7 @@ fun SongItem.toMediaItem() = MediaItem.Builder() .setSubtitle(artists.joinToString { it.name }) .setArtist(artists.joinToString { it.name }) .setArtworkUri(thumbnail.toUri()) + .setAlbumTitle(album?.name) .setMediaType(MEDIA_TYPE_MUSIC) .build() ) @@ -54,6 +56,7 @@ fun MediaMetadata.toMediaItem() = MediaItem.Builder() .setSubtitle(artists.joinToString { it.name }) .setArtist(artists.joinToString { it.name }) .setArtworkUri(thumbnailUrl?.toUri()) + .setAlbumTitle(album?.title) .setMediaType(MEDIA_TYPE_MUSIC) .build() ) diff --git a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt b/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt deleted file mode 100644 index 6043651de..000000000 --- a/app/src/main/java/com/zionhuang/music/playback/BitmapProvider.kt +++ /dev/null @@ -1,60 +0,0 @@ -package com.zionhuang.music.playback - -import android.content.Context -import android.graphics.Bitmap -import android.graphics.drawable.BitmapDrawable -import android.util.LruCache -import coil.imageLoader -import coil.request.Disposable -import coil.request.ImageRequest - -class BitmapProvider(private val context: Context) { - var currentUrl: String? = null - 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? { - if (url == currentUrl) return map.get(url) - currentUrl = url - 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() - ) - } else { - currentBitmap = cache - onBitmapChanged(cache) - } - return cache - } - - fun clear() { - disposable?.dispose() - currentUrl = null - currentBitmap = null - onBitmapChanged(null) - } - - companion object { - 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 b85b9bdb9..219738c80 100644 --- a/app/src/main/java/com/zionhuang/music/playback/MusicService.kt +++ b/app/src/main/java/com/zionhuang/music/playback/MusicService.kt @@ -171,7 +171,6 @@ class MusicService : MediaLibraryService(), private val binder = MusicBinder() private lateinit var connectivityManager: ConnectivityManager - val bitmapProvider = BitmapProvider(this) private val audioQuality by enumPreference(this, AudioQualityKey, AudioQuality.AUTO) @@ -503,9 +502,6 @@ class MusicService : MediaLibraryService(), } } } - if (mediaItem == null) { - bitmapProvider.clear() - } if (pauseWhenSongEnd) { pauseWhenSongEnd = false player.pause() 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 1fa3de085..74c63cb2c 100644 --- a/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt +++ b/app/src/main/java/com/zionhuang/music/playback/PlayerConnection.kt @@ -1,6 +1,5 @@ package com.zionhuang.music.playback -import android.graphics.Bitmap import androidx.media3.common.MediaItem import androidx.media3.common.PlaybackException import androidx.media3.common.Player @@ -37,7 +36,7 @@ class PlayerConnection( val player = service.player val playbackState = MutableStateFlow(player.playbackState) - val playWhenReady = MutableStateFlow(player.playWhenReady) + private val playWhenReady = MutableStateFlow(player.playWhenReady) val isPlaying = combine(playbackState, playWhenReady) { playbackState, playWhenReady -> playWhenReady && playbackState != STATE_ENDED }.stateIn(scope, SharingStarted.Lazily, player.playWhenReady && player.playbackState != STATE_ENDED) @@ -63,17 +62,10 @@ class PlayerConnection( val canSkipPrevious = MutableStateFlow(true) val canSkipNext = MutableStateFlow(true) - var onBitmapChanged: (Bitmap?) -> Unit = {} - set(value) { - field = value - service.bitmapProvider.onBitmapChanged = value - } - val error = MutableStateFlow(null) init { player.addListener(this) - service.bitmapProvider.onBitmapChanged = onBitmapChanged playbackState.value = player.playbackState playWhenReady.value = player.playWhenReady @@ -174,7 +166,6 @@ class PlayerConnection( } fun dispose() { - service.bitmapProvider.onBitmapChanged = {} player.removeListener(this) } } diff --git a/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt b/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt new file mode 100644 index 000000000..64581e51b --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/HideOnScrollFAB.kt @@ -0,0 +1,82 @@ +package com.zionhuang.music.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.slideInVertically +import androidx.compose.animation.slideOutVertically +import androidx.compose.foundation.ScrollState +import androidx.compose.foundation.layout.BoxScope +import androidx.compose.foundation.layout.WindowInsetsSides +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.lazy.LazyListState +import androidx.compose.material3.FloatingActionButton +import androidx.compose.material3.Icon +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.ui.utils.isScrollingUp + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + lazyListState: LazyListState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && lazyListState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + } +} + +@Composable +fun BoxScope.HideOnScrollFAB( + visible: Boolean = true, + scrollState: ScrollState, + @DrawableRes icon: Int, + onClick: () -> Unit, +) { + AnimatedVisibility( + visible = visible && scrollState.isScrollingUp(), + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = Modifier + .align(Alignment.BottomEnd) + .windowInsetsPadding( + LocalPlayerAwareWindowInsets.current + .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) + ) + ) { + FloatingActionButton( + modifier = Modifier.padding(16.dp), + onClick = onClick + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + } + } +} \ 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 c2a46449c..1d35bf1aa 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 @@ -230,7 +230,7 @@ fun SongListItem( ) = ListItem( title = song.song.title, subtitle = joinByBullet( - song.artists.joinToString(), + song.artists.joinToString { it.name }, makeTimeString(song.song.duration * 1000L) ), badges = badges, @@ -308,7 +308,7 @@ fun AlbumListItem( ) = ListItem( title = album.album.title, subtitle = joinByBullet( - album.artists.joinToString(), + album.artists.joinToString { it.name }, pluralStringResource(R.plurals.n_song, album.album.songCount, album.album.songCount), album.album.year?.toString() ), @@ -551,7 +551,7 @@ fun YouTubeListItem( 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) + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) }, badges = badges, thumbnailContent = { @@ -744,7 +744,7 @@ fun YouTubeGridItem( 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) + is PlaylistItem -> joinByBullet(item.author?.name, item.songCountText) } if (subtitle != null) { diff --git a/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt new file mode 100644 index 000000000..ecd3771f9 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTile.kt @@ -0,0 +1,47 @@ +package com.zionhuang.music.ui.component + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +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.draw.clip +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp + +@Composable +fun NavigationTile( + title: String, + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(4.dp), + modifier = modifier + .clip(RoundedCornerShape(4.dp)) + .clickable(onClick = onClick) + .padding(4.dp), + ) { + Icon( + painter = painterResource(icon), + contentDescription = null + ) + + Text( + text = title, + style = MaterialTheme.typography.labelMedium, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt new file mode 100644 index 000000000..37551bdc8 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/component/NavigationTitle.kt @@ -0,0 +1,55 @@ +package com.zionhuang.music.ui.component + +import androidx.compose.foundation.clickable +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.fillMaxWidth +import androidx.compose.foundation.layout.only +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.layout.windowInsetsPadding +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.res.painterResource +import androidx.compose.ui.unit.dp +import com.zionhuang.music.R + +@Composable +fun NavigationTitle( + title: String, + modifier: Modifier = Modifier, + onClick: (() -> Unit)? = null, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + .fillMaxWidth() + .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) + .clickable(enabled = onClick != null) { + onClick?.invoke() + } + .padding(12.dp) + ) { + Column( + modifier = Modifier.weight(1f) + ) { + Text( + text = title, + style = MaterialTheme.typography.headlineSmall + ) + } + + if (onClick != null) { + Icon( + painter = painterResource(R.drawable.navigate_next), + contentDescription = null + ) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt b/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt index e0f0f8d4a..65f591d1e 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/Preference.kt @@ -1,15 +1,28 @@ package com.zionhuang.music.ui.component -import androidx.annotation.DrawableRes 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.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.items -import androidx.compose.material3.* -import androidx.compose.runtime.* +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.ProvideTextStyle +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Switch +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha -import androidx.compose.ui.res.painterResource import androidx.compose.ui.text.TextRange import androidx.compose.ui.text.input.TextFieldValue import androidx.compose.ui.unit.dp @@ -17,10 +30,10 @@ import androidx.compose.ui.unit.dp @Composable fun PreferenceEntry( modifier: Modifier = Modifier, - title: String, + title: @Composable () -> Unit, description: String? = null, content: (@Composable () -> Unit)? = null, - @DrawableRes icon: Int? = null, + icon: (@Composable () -> Unit)? = null, trailingContent: (@Composable () -> Unit)? = null, onClick: () -> Unit, isEnabled: Boolean = true, @@ -40,10 +53,7 @@ fun PreferenceEntry( Box( modifier = Modifier.padding(horizontal = 4.dp) ) { - Icon( - painter = painterResource(icon), - contentDescription = null - ) + icon() } Spacer(Modifier.width(12.dp)) @@ -53,10 +63,9 @@ fun PreferenceEntry( verticalArrangement = Arrangement.Center, modifier = Modifier.weight(1f) ) { - Text( - text = title, - style = MaterialTheme.typography.titleMedium - ) + ProvideTextStyle(MaterialTheme.typography.titleMedium) { + title() + } if (description != null) { Text( @@ -80,8 +89,8 @@ fun PreferenceEntry( @Composable fun ListPreference( modifier: Modifier = Modifier, - title: String, - @DrawableRes icon: Int? = null, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, selectedValue: T, values: List, valueText: @Composable (T) -> String, @@ -134,8 +143,8 @@ fun ListPreference( @Composable inline fun > EnumListPreference( modifier: Modifier = Modifier, - title: String, - @DrawableRes icon: Int, + noinline title: @Composable () -> Unit, + noinline icon: (@Composable () -> Unit)?, selectedValue: T, noinline valueText: @Composable (T) -> String, noinline onValueSelected: (T) -> Unit, @@ -156,9 +165,9 @@ inline fun > EnumListPreference( @Composable fun SwitchPreference( modifier: Modifier = Modifier, - title: String, + title: @Composable () -> Unit, description: String? = null, - @DrawableRes icon: Int? = null, + icon: (@Composable () -> Unit)? = null, checked: Boolean, onCheckedChange: (Boolean) -> Unit, isEnabled: Boolean = true, @@ -182,8 +191,8 @@ fun SwitchPreference( @Composable fun EditTextPreference( modifier: Modifier = Modifier, - title: String, - @DrawableRes icon: Int? = null, + title: @Composable () -> Unit, + icon: (@Composable () -> Unit)? = null, value: String, onValueChange: (String) -> Unit, singleLine: Boolean = true, 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 index e6db353d1..42db9b6c6 100644 --- a/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt +++ b/app/src/main/java/com/zionhuang/music/ui/component/SearchBar.kt @@ -68,6 +68,7 @@ fun SearchBar( tonalElevation: Dp = SearchBarDefaults.Elevation, windowInsets: WindowInsets = WindowInsets.systemBars, interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusRequester: FocusRequester = remember { FocusRequester() }, content: @Composable ColumnScope.() -> Unit, ) { val heightOffsetLimit = with(LocalDensity.current) { @@ -172,6 +173,7 @@ fun SearchBar( trailingIcon = trailingIcon, colors = colors.inputFieldColors, interactionSource = interactionSource, + focusRequester = focusRequester, ) if (animationProgress > 0) { @@ -204,8 +206,8 @@ private fun SearchBarInputField( trailingIcon: @Composable (() -> Unit)? = null, colors: TextFieldColors = SearchBarDefaults.inputFieldColors(), interactionSource: MutableInteractionSource = remember { MutableInteractionSource() }, + focusRequester: FocusRequester = remember { FocusRequester() } ) { - val focusRequester = remember { FocusRequester() } val searchSemantics = getString(Strings.SearchBarSearch) val suggestionsAvailableSemantics = getString(Strings.SuggestionsAvailable) val textColor = LocalTextStyle.current.color.takeOrElse { diff --git a/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt b/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt new file mode 100644 index 000000000..057e75b66 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/menu/PlayerMenu.kt @@ -0,0 +1,387 @@ +package com.zionhuang.music.ui.menu + +import android.content.Intent +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import android.media.audiofx.AudioEffect +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.annotation.DrawableRes +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.PaddingValues +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.systemBars +import androidx.compose.foundation.lazy.items +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.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.net.toUri +import androidx.media3.common.PlaybackParameters +import androidx.media3.exoplayer.offline.DownloadRequest +import androidx.media3.exoplayer.offline.DownloadService +import androidx.navigation.NavController +import com.zionhuang.music.LocalDatabase +import com.zionhuang.music.LocalDownloadUtil +import com.zionhuang.music.R +import com.zionhuang.music.constants.ListItemHeight +import com.zionhuang.music.db.entities.PlaylistSongMap +import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.playback.ExoDownloadService +import com.zionhuang.music.playback.PlayerConnection +import com.zionhuang.music.ui.component.BigSeekBar +import com.zionhuang.music.ui.component.BottomSheetState +import com.zionhuang.music.ui.component.DownloadGridMenu +import com.zionhuang.music.ui.component.GridMenu +import com.zionhuang.music.ui.component.GridMenuItem +import com.zionhuang.music.ui.component.ListDialog +import kotlin.math.log2 +import kotlin.math.pow +import kotlin.math.round + +@Composable +fun PlayerMenu( + mediaMetadata: MediaMetadata?, + navController: NavController, + playerBottomSheetState: BottomSheetState, + playerConnection: PlayerConnection, + onShowDetailsDialog: () -> Unit, + onDismiss: () -> Unit, +) { + mediaMetadata ?: return + val context = LocalContext.current + val database = LocalDatabase.current + val localConfiguration = LocalConfiguration.current + val playerVolume = playerConnection.service.playerVolume.collectAsState() + val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } + + val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id).collectAsState(initial = null) + + var showChoosePlaylistDialog by rememberSaveable { + mutableStateOf(false) + } + + AddToPlaylistDialog( + isVisible = showChoosePlaylistDialog, + onAdd = { playlist -> + database.transaction { + insert(mediaMetadata) + insert( + PlaylistSongMap( + songId = mediaMetadata.id, + playlistId = playlist.id, + position = playlist.songCount + ) + ) + } + }, + onDismiss = { + showChoosePlaylistDialog = false + } + ) + + var showSelectArtistDialog by rememberSaveable { + mutableStateOf(false) + } + + if (showSelectArtistDialog) { + ListDialog( + onDismiss = { showSelectArtistDialog = false } + ) { + items(mediaMetadata.artists) { artist -> + Box( + contentAlignment = Alignment.CenterStart, + modifier = Modifier + .fillParentMaxWidth() + .height(ListItemHeight) + .clickable { + navController.navigate("artist/${artist.id}") + showSelectArtistDialog = false + playerBottomSheetState.collapseSoft() + onDismiss() + } + .padding(horizontal = 24.dp), + ) { + Text( + text = artist.name, + fontSize = 18.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } + } + } + } + + var tempo by remember { + mutableStateOf(playerConnection.player.playbackParameters.speed) + } + var transposeValue by remember { + mutableStateOf(round(12 * log2(playerConnection.player.playbackParameters.pitch)).toInt()) + } + val updatePlaybackParameters = { + playerConnection.player.playbackParameters = PlaybackParameters(tempo, 2f.pow(transposeValue.toFloat() / 12)) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp) + .padding(top = 24.dp, bottom = 6.dp) + ) { + Icon( + painter = painterResource(R.drawable.volume_up), + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + + BigSeekBar( + progressProvider = playerVolume::value, + onProgressChange = { playerConnection.service.playerVolume.value = it }, + modifier = Modifier.weight(1f) + ) + } + + if (localConfiguration.orientation == ORIENTATION_LANDSCAPE) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier.padding(horizontal = 24.dp, vertical = 6.dp) + ) { + ValueAdjuster( + icon = R.drawable.slow_motion_video, + currentValue = tempo, + values = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f), + onValueUpdate = { + tempo = it + updatePlaybackParameters() + }, + valueText = { "x$it" }, + modifier = Modifier.weight(1f) + ) + + ValueAdjuster( + icon = R.drawable.tune, + currentValue = transposeValue, + values = (-12..12).toList(), + onValueUpdate = { + transposeValue = it + updatePlaybackParameters() + }, + valueText = { "${if (it > 0) "+" else ""}$it" }, + modifier = Modifier.weight(1f) + ) + } + } else { + ValueAdjuster( + icon = R.drawable.slow_motion_video, + currentValue = tempo, + values = listOf(0.25f, 0.5f, 0.75f, 1f, 1.25f, 1.5f, 1.75f, 2f), + onValueUpdate = { + tempo = it + updatePlaybackParameters() + }, + valueText = { "x$it" }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 6.dp) + ) + + ValueAdjuster( + icon = R.drawable.tune, + currentValue = transposeValue, + values = (-12..12).toList(), + onValueUpdate = { + transposeValue = it + updatePlaybackParameters() + }, + valueText = { "${if (it > 0) "+" else ""}$it" }, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 6.dp) + ) + } + + GridMenu( + contentPadding = PaddingValues( + start = 8.dp, + top = 8.dp, + end = 8.dp, + bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() + ) + ) { + GridMenuItem( + icon = R.drawable.radio, + title = R.string.start_radio + ) { + playerConnection.service.startRadioSeamlessly() + onDismiss() + } + GridMenuItem( + icon = R.drawable.playlist_add, + title = R.string.add_to_playlist + ) { + showChoosePlaylistDialog = true + } + DownloadGridMenu( + state = download?.state, + onDownload = { + database.transaction { + insert(mediaMetadata) + } + val downloadRequest = DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) + .setCustomCacheKey(mediaMetadata.id) + .setData(mediaMetadata.title.toByteArray()) + .build() + DownloadService.sendAddDownload( + context, + ExoDownloadService::class.java, + downloadRequest, + false + ) + }, + onRemoveDownload = { + DownloadService.sendRemoveDownload( + context, + ExoDownloadService::class.java, + mediaMetadata.id, + false + ) + } + ) + GridMenuItem( + icon = R.drawable.artist, + title = R.string.view_artist + ) { + if (mediaMetadata.artists.size == 1) { + navController.navigate("artist/${mediaMetadata.artists[0].id}") + playerBottomSheetState.collapseSoft() + onDismiss() + } else { + showSelectArtistDialog = true + } + } + if (mediaMetadata.album != null) { + GridMenuItem( + icon = R.drawable.album, + title = R.string.view_album + ) { + navController.navigate("album/${mediaMetadata.album.id}") + playerBottomSheetState.collapseSoft() + onDismiss() + } + } + GridMenuItem( + icon = R.drawable.share, + title = R.string.share + ) { + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaMetadata.id}") + } + context.startActivity(Intent.createChooser(intent, null)) + onDismiss() + } + GridMenuItem( + icon = R.drawable.info, + title = R.string.details + ) { + onShowDetailsDialog() + onDismiss() + } + GridMenuItem( + icon = R.drawable.equalizer, + title = R.string.equalizer + ) { + val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { + putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playerConnection.player.audioSessionId) + putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) + putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) + } + if (intent.resolveActivity(context.packageManager) != null) { + activityResultLauncher.launch(intent) + } + onDismiss() + } + } +} + +@Composable +fun ValueAdjuster( + @DrawableRes icon: Int, + currentValue: T, + values: List, + onValueUpdate: (T) -> Unit, + valueText: (T) -> String, + modifier: Modifier, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(24.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Icon( + painter = painterResource(icon), + contentDescription = null, + modifier = Modifier.size(28.dp) + ) + + IconButton( + enabled = currentValue != values.first(), + onClick = { + onValueUpdate(values[values.indexOf(currentValue) - 1]) + } + ) { + Icon( + painter = painterResource(R.drawable.remove), + contentDescription = null + ) + } + + Text( + text = valueText(currentValue), + style = MaterialTheme.typography.titleMedium, + textAlign = TextAlign.Center, + modifier = Modifier.weight(1f) + ) + + IconButton( + enabled = currentValue != values.last(), + onClick = { + onValueUpdate(values[values.indexOf(currentValue) + 1]) + } + ) { + Icon( + painter = painterResource(R.drawable.add), + contentDescription = null + ) + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt b/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt index 830e3a928..badff73af 100644 --- a/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt +++ b/app/src/main/java/com/zionhuang/music/ui/player/MiniPlayer.kt @@ -2,7 +2,9 @@ package com.zionhuang.music.ui.player import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeOut +import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.snapping.rememberSnapFlingBehavior import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row @@ -16,6 +18,7 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.pager.rememberPagerState import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.material3.Icon import androidx.compose.material3.IconButton @@ -23,8 +26,11 @@ import androidx.compose.material3.LinearProgressIndicator import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.snapshotFlow import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip @@ -34,28 +40,66 @@ 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.PlaybackException import androidx.media3.common.Player import coil.compose.AsyncImage import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.MiniPlayerHeight import com.zionhuang.music.constants.ThumbnailCornerRadius +import com.zionhuang.music.extensions.metadata import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.MediaMetadata +import com.zionhuang.music.ui.utils.HorizontalPager +import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider +import kotlinx.coroutines.flow.drop +import kotlin.math.abs +@OptIn(ExperimentalFoundationApi::class) @Composable fun MiniPlayer( - mediaMetadata: MediaMetadata?, position: Long, duration: Long, modifier: Modifier = Modifier, ) { - mediaMetadata ?: return val playerConnection = LocalPlayerConnection.current ?: return val isPlaying by playerConnection.isPlaying.collectAsState() val playbackState by playerConnection.playbackState.collectAsState() - val canSkipNext by playerConnection.canSkipNext.collectAsState() val error by playerConnection.error.collectAsState() + val windows by playerConnection.queueWindows.collectAsState() + val currentWindowIndex by playerConnection.currentWindowIndex.collectAsState() + + val pagerState = rememberPagerState( + initialPage = currentWindowIndex.takeIf { it != -1 } ?: 0 + ) + + val snapLayoutInfoProvider = remember(pagerState) { + SnapLayoutInfoProvider( + pagerState = pagerState, + positionInLayout = { _, _ -> 0f } + ) + } + + LaunchedEffect(pagerState, currentWindowIndex) { + if (windows.isNotEmpty()) { + try { + if (abs(pagerState.currentPage - currentWindowIndex) <= 1) { + pagerState.animateScrollToPage(currentWindowIndex) + } else { + pagerState.scrollToPage(currentWindowIndex) + } + } catch (_: Exception) { + } + } + } + + LaunchedEffect(pagerState) { + snapshotFlow { pagerState.settledPage }.drop(1).collect { index -> + if (!pagerState.isScrollInProgress && index != currentWindowIndex && windows.isNotEmpty()) { + playerConnection.player.seekToDefaultPosition(windows[index].firstPeriodIndex) + } + } + } Box( modifier = modifier @@ -74,62 +118,25 @@ fun MiniPlayer( verticalAlignment = Alignment.CenterVertically, modifier = modifier .fillMaxSize() - .padding(horizontal = 6.dp), + .padding(end = 12.dp), ) { - Box(modifier = Modifier.padding(6.dp)) { - AsyncImage( - model = mediaMetadata.thumbnailUrl, - contentDescription = null, - modifier = Modifier - .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.info), - contentDescription = null, - tint = MaterialTheme.colorScheme.error, - modifier = Modifier - .align(Alignment.Center) - ) - } + HorizontalPager( + state = pagerState, + flingBehavior = rememberSnapFlingBehavior(snapLayoutInfoProvider), + items = windows, + key = { it.uid.hashCode() }, + beyondBoundsPageCount = 2, + modifier = Modifier.weight(1f) + ) { window -> + window.mediaItem.metadata?.let { + MiniMediaInfo( + mediaMetadata = it, + error = error, + modifier = Modifier.padding(horizontal = 6.dp) + ) } } - Column( - modifier = Modifier - .weight(1f) - .padding(horizontal = 6.dp) - ) { - Text( - text = mediaMetadata.title, - color = MaterialTheme.colorScheme.onSurface, - fontSize = 16.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - Text( - text = mediaMetadata.artists.joinToString { it.name }, - color = MaterialTheme.colorScheme.secondary, - fontSize = 12.sp, - maxLines = 1, - overflow = TextOverflow.Ellipsis, - ) - } - IconButton( onClick = { if (playbackState == Player.STATE_ENDED) { @@ -145,15 +152,72 @@ fun MiniPlayer( contentDescription = null ) } - IconButton( - enabled = canSkipNext, - onClick = playerConnection.player::seekToNext + } + } +} + +@Composable +fun MiniMediaInfo( + mediaMetadata: MediaMetadata, + error: PlaybackException?, + modifier: Modifier = Modifier, +) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = modifier + ) { + Box(modifier = Modifier.padding(6.dp)) { + AsyncImage( + model = mediaMetadata.thumbnailUrl, + contentDescription = null, + modifier = Modifier + .size(48.dp) + .clip(RoundedCornerShape(ThumbnailCornerRadius)) + ) + androidx.compose.animation.AnimatedVisibility( + visible = error != null, + enter = fadeIn(), + exit = fadeOut() ) { - Icon( - painter = painterResource(R.drawable.skip_next), - contentDescription = null - ) + Box( + Modifier + .size(48.dp) + .background( + color = Color.Black.copy(alpha = 0.6f), + shape = RoundedCornerShape(ThumbnailCornerRadius) + ) + ) { + Icon( + painter = painterResource(R.drawable.info), + contentDescription = null, + tint = MaterialTheme.colorScheme.error, + modifier = Modifier + .align(Alignment.Center) + ) + } } } + + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 6.dp) + ) { + Text( + text = mediaMetadata.title, + color = MaterialTheme.colorScheme.onSurface, + fontSize = 16.sp, + fontWeight = FontWeight.Bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + Text( + text = mediaMetadata.artists.joinToString { it.name }, + color = MaterialTheme.colorScheme.secondary, + fontSize = 12.sp, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } } -} \ 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 71a388061..f5766cbfb 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 @@ -119,7 +119,6 @@ fun BottomSheetPlayer( }, collapsedContent = { MiniPlayer( - mediaMetadata = mediaMetadata, position = position, duration = duration ) 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 444c5c393..0719dc1b8 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 @@ -1,11 +1,7 @@ package com.zionhuang.music.ui.player -import android.content.Intent -import android.media.audiofx.AudioEffect 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.background @@ -14,7 +10,6 @@ import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -26,11 +21,9 @@ 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.sizeIn import androidx.compose.foundation.layout.systemBars import androidx.compose.foundation.layout.windowInsetsPadding -import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.rememberScrollState @@ -71,39 +64,23 @@ import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.AnnotatedString -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.window.DialogProperties -import androidx.core.net.toUri -import androidx.media3.exoplayer.offline.DownloadRequest -import androidx.media3.exoplayer.offline.DownloadService import androidx.media3.exoplayer.source.ShuffleOrder.DefaultShuffleOrder import androidx.navigation.NavController -import com.zionhuang.music.LocalDatabase -import com.zionhuang.music.LocalDownloadUtil import com.zionhuang.music.LocalPlayerConnection import com.zionhuang.music.R import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.constants.ShowLyricsKey -import com.zionhuang.music.db.entities.PlaylistSongMap 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.ExoDownloadService -import com.zionhuang.music.playback.PlayerConnection -import com.zionhuang.music.ui.component.BigSeekBar import com.zionhuang.music.ui.component.BottomSheet import com.zionhuang.music.ui.component.BottomSheetState -import com.zionhuang.music.ui.component.DownloadGridMenu -import com.zionhuang.music.ui.component.GridMenu -import com.zionhuang.music.ui.component.GridMenuItem -import com.zionhuang.music.ui.component.ListDialog import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.MediaMetadataListItem -import com.zionhuang.music.ui.menu.AddToPlaylistDialog +import com.zionhuang.music.ui.menu.PlayerMenu import com.zionhuang.music.ui.utils.reordering.ReorderingLazyColumn import com.zionhuang.music.ui.utils.reordering.draggedItem import com.zionhuang.music.ui.utils.reordering.rememberReorderingState @@ -549,203 +526,3 @@ fun Queue( } } } - -@Composable -fun PlayerMenu( - mediaMetadata: MediaMetadata?, - navController: NavController, - playerBottomSheetState: BottomSheetState, - playerConnection: PlayerConnection, - onShowDetailsDialog: () -> Unit, - onDismiss: () -> Unit, -) { - mediaMetadata ?: return - val context = LocalContext.current - val database = LocalDatabase.current - val playerVolume = playerConnection.service.playerVolume.collectAsState() - val activityResultLauncher = rememberLauncherForActivityResult(ActivityResultContracts.StartActivityForResult()) { } - - val download by LocalDownloadUtil.current.getDownload(mediaMetadata.id).collectAsState(initial = null) - - var showChoosePlaylistDialog by rememberSaveable { - mutableStateOf(false) - } - - AddToPlaylistDialog( - isVisible = showChoosePlaylistDialog, - onAdd = { playlist -> - database.transaction { - insert(mediaMetadata) - insert( - PlaylistSongMap( - songId = mediaMetadata.id, - playlistId = playlist.id, - position = playlist.songCount - ) - ) - } - }, - onDismiss = { - showChoosePlaylistDialog = false - } - ) - - var showSelectArtistDialog by rememberSaveable { - mutableStateOf(false) - } - - if (showSelectArtistDialog) { - ListDialog( - onDismiss = { showSelectArtistDialog = false } - ) { - items(mediaMetadata.artists) { artist -> - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier - .fillParentMaxWidth() - .height(ListItemHeight) - .clickable { - navController.navigate("artist/${artist.id}") - showSelectArtistDialog = false - playerBottomSheetState.collapseSoft() - onDismiss() - } - .padding(horizontal = 24.dp), - ) { - Text( - text = artist.name, - fontSize = 18.sp, - fontWeight = FontWeight.Bold, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) - } - } - } - } - - Row( - horizontalArrangement = Arrangement.spacedBy(24.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(horizontal = 24.dp) - .padding(top = 24.dp, bottom = 12.dp) - ) { - Icon( - painter = painterResource(R.drawable.volume_up), - contentDescription = null, - modifier = Modifier.size(28.dp) - ) - - BigSeekBar( - progressProvider = playerVolume::value, - onProgressChange = { playerConnection.service.playerVolume.value = it }, - modifier = Modifier.weight(1f) - ) - } - - GridMenu( - contentPadding = PaddingValues( - start = 8.dp, - top = 8.dp, - end = 8.dp, - bottom = 8.dp + WindowInsets.systemBars.asPaddingValues().calculateBottomPadding() - ) - ) { - GridMenuItem( - icon = R.drawable.radio, - title = R.string.start_radio - ) { - playerConnection.service.startRadioSeamlessly() - onDismiss() - } - GridMenuItem( - icon = R.drawable.playlist_add, - title = R.string.add_to_playlist - ) { - showChoosePlaylistDialog = true - } - DownloadGridMenu( - state = download?.state, - onDownload = { - database.transaction { - insert(mediaMetadata) - } - val downloadRequest = DownloadRequest.Builder(mediaMetadata.id, mediaMetadata.id.toUri()) - .setCustomCacheKey(mediaMetadata.id) - .setData(mediaMetadata.title.toByteArray()) - .build() - DownloadService.sendAddDownload( - context, - ExoDownloadService::class.java, - downloadRequest, - false - ) - }, - onRemoveDownload = { - DownloadService.sendRemoveDownload( - context, - ExoDownloadService::class.java, - mediaMetadata.id, - false - ) - } - ) - GridMenuItem( - icon = R.drawable.artist, - title = R.string.view_artist - ) { - if (mediaMetadata.artists.size == 1) { - navController.navigate("artist/${mediaMetadata.artists[0].id}") - playerBottomSheetState.collapseSoft() - onDismiss() - } else { - showSelectArtistDialog = true - } - } - if (mediaMetadata.album != null) { - GridMenuItem( - icon = R.drawable.album, - title = R.string.view_album - ) { - navController.navigate("album/${mediaMetadata.album.id}") - playerBottomSheetState.collapseSoft() - onDismiss() - } - } - GridMenuItem( - icon = R.drawable.share, - title = R.string.share - ) { - val intent = Intent().apply { - action = Intent.ACTION_SEND - type = "text/plain" - putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaMetadata.id}") - } - context.startActivity(Intent.createChooser(intent, null)) - onDismiss() - } - GridMenuItem( - icon = R.drawable.info, - title = R.string.details - ) { - onShowDetailsDialog() - onDismiss() - } - GridMenuItem( - icon = R.drawable.equalizer, - title = R.string.equalizer - ) { - val intent = Intent(AudioEffect.ACTION_DISPLAY_AUDIO_EFFECT_CONTROL_PANEL).apply { - putExtra(AudioEffect.EXTRA_AUDIO_SESSION, playerConnection.player.audioSessionId) - putExtra(AudioEffect.EXTRA_PACKAGE_NAME, context.packageName) - putExtra(AudioEffect.EXTRA_CONTENT_TYPE, AudioEffect.CONTENT_TYPE_MUSIC) - } - if (intent.resolveActivity(context.packageManager) != null) { - activityResultLauncher.launch(intent) - } - onDismiss() - } - } -} 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 07d761da2..8950e644a 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 @@ -26,6 +26,7 @@ import com.zionhuang.music.ui.utils.HorizontalPager import com.zionhuang.music.ui.utils.SnapLayoutInfoProvider import com.zionhuang.music.utils.rememberPreference import kotlinx.coroutines.flow.drop +import kotlin.math.abs @OptIn(ExperimentalFoundationApi::class) @Composable @@ -56,7 +57,11 @@ fun Thumbnail( LaunchedEffect(pagerState, currentWindowIndex) { if (windows.isNotEmpty()) { try { - pagerState.animateScrollToPage(currentWindowIndex) + if (abs(pagerState.currentPage - currentWindowIndex) <= 1) { + pagerState.animateScrollToPage(currentWindowIndex) + } else { + pagerState.scrollToPage(currentWindowIndex) + } } catch (_: Exception) { } } @@ -70,6 +75,12 @@ fun Thumbnail( } } + LaunchedEffect(showLyrics) { + if (!showLyrics) { + pagerState.scrollToPage(currentWindowIndex) + } + } + DisposableEffect(showLyrics) { currentView.keepScreenOn = showLyrics onDispose { diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt new file mode 100644 index 000000000..ad104f7f6 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/AccountScreen.kt @@ -0,0 +1,101 @@ +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.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyVerticalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.runtime.Composable +import androidx.compose.runtime.collectAsState +import androidx.compose.runtime.getValue +import androidx.compose.runtime.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.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.YouTubePlaylistMenu +import com.zionhuang.music.viewmodels.AccountViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun AccountScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: AccountViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + + val coroutineScope = rememberCoroutineScope() + + val playlists by viewModel.playlists.collectAsState() + + LazyVerticalGrid( + columns = GridCells.Adaptive(minSize = GridThumbnailHeight + 24.dp), + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + items( + items = playlists.orEmpty(), + key = { it.id } + ) { item -> + YouTubeGridItem( + item = item, + fillMaxWidth = true, + modifier = Modifier + .combinedClickable( + onClick = { + navController.navigate("online_playlist/${item.id}") + }, + onLongClick = { + menuState.show { + YouTubePlaylistMenu( + playlist = item, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + ) + ) + } + + if (playlists == null) { + items(8) { + ShimmerHost { + GridItemPlaceHolder(fillMaxWidth = true) + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.account)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} 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 57244fae3..287f05e9e 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 @@ -181,6 +181,7 @@ fun AlbumScreen( albumIndex = index + 1, isActive = song.id == mediaMetadata?.id, isPlaying = isPlaying, + showInLibraryIcon = true, 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 65437edf3..56ccaf206 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 @@ -7,7 +7,6 @@ import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items @@ -23,8 +22,6 @@ import androidx.compose.runtime.getValue 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 @@ -35,6 +32,7 @@ import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.menu.SongMenu import com.zionhuang.music.viewmodels.DateAgo @@ -60,20 +58,17 @@ fun HistoryScreen( ) { events.forEach { (dateAgo, events) -> stickyHeader { - Text( - text = when (dateAgo) { + NavigationTitle( + title = 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) ) } 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 2f2065f9e..191d0282d 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,6 +1,5 @@ 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.* @@ -10,37 +9,39 @@ 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.* 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 -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.google.accompanist.swiperefresh.SwipeRefresh import com.google.accompanist.swiperefresh.rememberSwipeRefreshState import com.zionhuang.innertube.models.WatchEndpoint +import com.zionhuang.innertube.utils.parseCookieString 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.InnerTubeCookieKey import com.zionhuang.music.constants.ListItemHeight import com.zionhuang.music.extensions.togglePlayPause 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.HideOnScrollFAB import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.NavigationTile +import com.zionhuang.music.ui.component.NavigationTitle 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.utils.rememberPreference import com.zionhuang.music.viewmodels.HomeViewModel import kotlin.random.Random @@ -58,11 +59,18 @@ fun HomeScreen( val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val quickPicks by viewModel.quickPicks.collectAsState() - val newReleaseAlbums by viewModel.newReleaseAlbums.collectAsState() + val explorePage by viewModel.explorePage.collectAsState() val isRefreshing by viewModel.isRefreshing.collectAsState() val mostPlayedLazyGridState = rememberLazyGridState() + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } + + val scrollState = rememberScrollState() + SwipeRefresh( state = rememberSwipeRefreshState(isRefreshing), onRefresh = viewModel::refresh, @@ -83,12 +91,12 @@ fun HomeScreen( } Column( - modifier = Modifier.verticalScroll(rememberScrollState()) + modifier = Modifier.verticalScroll(scrollState) ) { Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateTopPadding())) Row( - horizontalArrangement = Arrangement.spacedBy(12.dp), + horizontalArrangement = Arrangement.spacedBy(4.dp), modifier = Modifier .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) .padding(horizontal = 12.dp, vertical = 6.dp) @@ -99,26 +107,28 @@ fun HomeScreen( onClick = { navController.navigate("history") }, modifier = Modifier.weight(1f) ) + NavigationTile( title = stringResource(R.string.stats), icon = R.drawable.trending_up, onClick = { navController.navigate("stats") }, modifier = Modifier.weight(1f) ) - NavigationTile( - title = stringResource(R.string.settings), - icon = R.drawable.settings, - onClick = { navController.navigate("settings") }, - modifier = Modifier.weight(1f) - ) + + if (isLoggedIn) { + NavigationTile( + title = stringResource(R.string.account), + icon = R.drawable.person, + onClick = { + navController.navigate("account") + }, + modifier = Modifier.weight(1f) + ) + } } - Text( - text = stringResource(R.string.quick_picks), - style = MaterialTheme.typography.headlineSmall, - modifier = Modifier - .windowInsetsPadding(WindowInsets.systemBars.only(WindowInsetsSides.Horizontal)) - .padding(12.dp) + NavigationTitle( + title = stringResource(R.string.quick_picks) ) quickPicks?.let { quickPicks -> @@ -191,31 +201,13 @@ fun HomeScreen( } } - 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) - ) { - Text( - text = stringResource(R.string.new_release_albums), - style = MaterialTheme.typography.headlineSmall - ) + explorePage?.newReleaseAlbums?.let { newReleaseAlbums -> + NavigationTitle( + title = stringResource(R.string.new_release_albums), + onClick = { + navController.navigate("new_release") } - - Icon( - painter = painterResource(R.drawable.navigate_next), - contentDescription = null - ) - } + ) LazyRow( contentPadding = WindowInsets.systemBars @@ -252,67 +244,50 @@ fun HomeScreen( } } - Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) - } - - if (!quickPicks.isNullOrEmpty() || newReleaseAlbums.isNotEmpty()) { - FloatingActionButton( - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - ) - .padding(16.dp), - onClick = { - if (Random.nextBoolean() && !quickPicks.isNullOrEmpty()) { - 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)) + explorePage?.moodAndGenres?.let { moodAndGenres -> + NavigationTitle( + title = stringResource(R.string.mood_and_genres), + onClick = { + navController.navigate("mood_and_genres") } - }) { - Icon( - painter = painterResource(R.drawable.casino), - contentDescription = null ) - } - } - } - } -} -@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 - ) + LazyHorizontalGrid( + rows = GridCells.Fixed(4), + contentPadding = PaddingValues(6.dp), + modifier = Modifier.height((MoodAndGenresButtonHeight + 12.dp) * 4 + 12.dp) + ) { + items(moodAndGenres) { + MoodAndGenresButton( + title = it.title, + onClick = { + navController.navigate("youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}") + }, + modifier = Modifier + .padding(6.dp) + .width(180.dp) + ) + } + } + } - Spacer(Modifier.height(6.dp)) + Spacer(Modifier.height(LocalPlayerAwareWindowInsets.current.asPaddingValues().calculateBottomPadding())) + } - Text( - text = title, - style = MaterialTheme.typography.labelLarge, - maxLines = 1, - overflow = TextOverflow.Ellipsis - ) + HideOnScrollFAB( + visible = !quickPicks.isNullOrEmpty() || explorePage?.newReleaseAlbums?.isNotEmpty() == true, + scrollState = scrollState, + icon = R.drawable.casino, + onClick = { + if (Random.nextBoolean() && !quickPicks.isNullOrEmpty()) { + val song = quickPicks!!.random() + playerConnection.playQueue(YouTubeQueue(WatchEndpoint(videoId = song.id), song.toMediaMetadata())) + } else if (explorePage?.newReleaseAlbums?.isNotEmpty() == true) { + val album = explorePage?.newReleaseAlbums!!.random() + playerConnection.playQueue(YouTubeAlbumRadio(album.playlistId)) + } + } + ) + } } } diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt new file mode 100644 index 000000000..63e4e0c12 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/LoginScreen.kt @@ -0,0 +1,108 @@ +package com.zionhuang.music.ui.screens + +import android.annotation.SuppressLint +import android.webkit.CookieManager +import android.webkit.JavascriptInterface +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.viewinterop.AndroidView +import androidx.navigation.NavController +import com.zionhuang.innertube.YouTube +import com.zionhuang.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.constants.AccountEmailKey +import com.zionhuang.music.constants.AccountNameKey +import com.zionhuang.music.constants.InnerTubeCookieKey +import com.zionhuang.music.constants.VisitorDataKey +import com.zionhuang.music.utils.rememberPreference +import kotlinx.coroutines.DelicateCoroutinesApi +import kotlinx.coroutines.GlobalScope +import kotlinx.coroutines.launch + +@SuppressLint("SetJavaScriptEnabled") +@OptIn(ExperimentalMaterial3Api::class, DelicateCoroutinesApi::class) +@Composable +fun LoginScreen( + navController: NavController, +) { + var visitorData by rememberPreference(VisitorDataKey, "") + var innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + var accountName by rememberPreference(AccountNameKey, "") + var accountEmail by rememberPreference(AccountEmailKey, "") + + var webView: WebView? = null + + AndroidView( + modifier = Modifier + .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) + .fillMaxSize(), + factory = { context -> + WebView(context).apply { + webViewClient = object : WebViewClient() { + override fun doUpdateVisitedHistory(view: WebView, url: String, isReload: Boolean) { + if (url.startsWith("https://music.youtube.com")) { + innerTubeCookie = CookieManager.getInstance().getCookie(url) + GlobalScope.launch { + YouTube.accountInfo().onSuccess { + accountName = it?.name.orEmpty() + accountEmail = it?.email.orEmpty() + }.onFailure { + it.printStackTrace() + } + } + } + } + + override fun onPageFinished(view: WebView, url: String?) { + loadUrl("javascript:Android.onRetrieveVisitorData(window.yt.config_.VISITOR_DATA)") + } + } + settings.apply { + javaScriptEnabled = true + setSupportZoom(true) + builtInZoomControls = true + } + addJavascriptInterface(object { + @JavascriptInterface + fun onRetrieveVisitorData(newVisitorData: String?) { + if (newVisitorData != null) { + visitorData = newVisitorData + } + } + }, "Android") + webView = this + loadUrl("https://accounts.google.com/ServiceLogin?ltmpl=music&service=youtube&passive=true&continue=https%3A%2F%2Fwww.youtube.com%2Fsignin%3Faction_handle_signin%3Dtrue%26next%3Dhttps%253A%252F%252Fmusic.youtube.com%252F") + } + } + ) + + TopAppBar( + title = { Text(stringResource(R.string.login)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + } + ) + + BackHandler(enabled = webView?.canGoBack() == true) { + webView?.goBack() + } +} diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt new file mode 100644 index 000000000..03adecc6f --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/MoodAndGenresScreen.kt @@ -0,0 +1,143 @@ +package com.zionhuang.music.ui.screens + +import android.content.res.Configuration.ORIENTATION_LANDSCAPE +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +import androidx.compose.material3.surfaceColorAtElevation +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.platform.LocalConfiguration +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.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.R +import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder +import com.zionhuang.music.ui.component.shimmer.ShimmerHost +import com.zionhuang.music.viewmodels.MoodAndGenresViewModel + +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun MoodAndGenresScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: MoodAndGenresViewModel = hiltViewModel(), +) { + val localConfiguration = LocalConfiguration.current + val itemsPerRow = if (localConfiguration.orientation == ORIENTATION_LANDSCAPE) 3 else 2 + + val moodAndGenresList by viewModel.moodAndGenres.collectAsState() + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + if (moodAndGenresList == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + + moodAndGenresList?.forEach { moodAndGenres -> + item { + Text( + text = moodAndGenres.title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + + Column( + modifier = Modifier.padding(horizontal = 6.dp) + ) { + moodAndGenres.items.chunked(itemsPerRow).forEach { row -> + Row { + row.forEach { + MoodAndGenresButton( + title = it.title, + onClick = { + navController.navigate("youtube_browse/${it.endpoint.browseId}?params=${it.endpoint.params}") + }, + modifier = Modifier + .weight(1f) + .padding(6.dp) + ) + } + + repeat(itemsPerRow - row.size) { + Spacer(Modifier.weight(1f)) + } + } + } + } + } + } + } + + TopAppBar( + title = { Text(stringResource(R.string.mood_and_genres)) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.arrow_back), + contentDescription = null + ) + } + }, + scrollBehavior = scrollBehavior + ) +} + +@Composable +fun MoodAndGenresButton( + title: String, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + Box( + contentAlignment = Alignment.CenterStart, + modifier = modifier + .height(MoodAndGenresButtonHeight) + .clip(RoundedCornerShape(6.dp)) + .background(MaterialTheme.colorScheme.surfaceColorAtElevation(6.dp)) + .clickable(onClick = onClick) + .padding(horizontal = 12.dp) + ) { + Text( + text = title, + style = MaterialTheme.typography.labelLarge, + maxLines = 1, + overflow = TextOverflow.Ellipsis + ) + } +} + +val MoodAndGenresButtonHeight = 48.dp \ No newline at end of file diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt index 46c6143ac..a72f2b024 100644 --- a/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt +++ b/app/src/main/java/com/zionhuang/music/ui/screens/StatsScreen.kt @@ -1,18 +1,23 @@ 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.horizontalScroll +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -23,8 +28,8 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue 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.style.TextOverflow import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @@ -32,13 +37,17 @@ 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.constants.StatPeriod import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.ArtistListItem import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.SongListItem +import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.ui.menu.SongMenu +import com.zionhuang.music.ui.menu.YouTubeAlbumMenu import com.zionhuang.music.viewmodels.StatsViewModel @OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) @@ -52,23 +61,50 @@ fun StatsScreen( val isPlaying by playerConnection.isPlaying.collectAsState() val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + val statPeriod by viewModel.statPeriod.collectAsState() val mostPlayedSongs by viewModel.mostPlayedSongs.collectAsState() val mostPlayedArtists by viewModel.mostPlayedArtists.collectAsState() + val mostPlayedAlbums by viewModel.mostPlayedAlbums.collectAsState() LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Horizontal + WindowInsetsSides.Bottom).asPaddingValues(), modifier = Modifier.windowInsetsPadding(LocalPlayerAwareWindowInsets.current.only(WindowInsetsSides.Top)) ) { item { - Text( - text = stringResource(R.string.most_played_songs), - style = MaterialTheme.typography.headlineMedium, - overflow = TextOverflow.Ellipsis, + Row( modifier = Modifier .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) + .horizontalScroll(rememberScrollState()) + ) { + Spacer(Modifier.width(8.dp)) + + StatPeriod.values().forEach { period -> + FilterChip( + label = { + Text( + when (period) { + StatPeriod.`1_WEEK` -> pluralStringResource(R.plurals.n_week, 1, 1) + StatPeriod.`1_MONTH` -> pluralStringResource(R.plurals.n_month, 1, 1) + StatPeriod.`3_MONTH` -> pluralStringResource(R.plurals.n_month, 3, 3) + StatPeriod.`6_MONTH` -> pluralStringResource(R.plurals.n_month, 6, 6) + StatPeriod.`1_YEAR` -> pluralStringResource(R.plurals.n_year, 1, 1) + StatPeriod.ALL -> stringResource(R.string.filter_all) + } + ) + }, + selected = statPeriod == period, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), + onClick = { + viewModel.statPeriod.value = period + } + ) + Spacer(Modifier.width(8.dp)) + } + } + } + + item { + NavigationTitle(stringResource(R.string.most_played_songs)) } items( items = mostPlayedSongs, @@ -114,16 +150,9 @@ fun StatsScreen( .animateItemPlacement() ) } + item { - Text( - text = stringResource(R.string.most_played_artists), - style = MaterialTheme.typography.headlineMedium, - overflow = TextOverflow.Ellipsis, - modifier = Modifier - .fillMaxWidth() - .background(MaterialTheme.colorScheme.background) - .padding(horizontal = 12.dp, vertical = 8.dp) - ) + NavigationTitle(stringResource(R.string.most_played_artists)) } items( items = mostPlayedArtists, @@ -139,6 +168,46 @@ fun StatsScreen( .animateItemPlacement() ) } + + if (mostPlayedAlbums.isNotEmpty()) { + item { + NavigationTitle(stringResource(R.string.most_played_albums)) + } + items( + items = mostPlayedAlbums, + key = { it.id } + ) { item -> + YouTubeListItem( + item = item, + isActive = mediaMetadata?.album?.id == item.id, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + navController.navigate("album/${item.id}") + } + .animateItemPlacement() + ) + } + } } TopAppBar( diff --git a/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt b/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt new file mode 100644 index 000000000..02a948503 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/ui/screens/YouTubeBrowseScreen.kt @@ -0,0 +1,173 @@ +package com.zionhuang.music.ui.screens + +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.asPaddingValues +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.material3.TopAppBar +import androidx.compose.material3.TopAppBarScrollBehavior +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.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.music.LocalPlayerAwareWindowInsets +import com.zionhuang.music.LocalPlayerConnection +import com.zionhuang.music.R +import com.zionhuang.music.extensions.togglePlayPause +import com.zionhuang.music.models.toMediaMetadata +import com.zionhuang.music.playback.queues.YouTubeQueue +import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.YouTubeListItem +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.YouTubePlaylistMenu +import com.zionhuang.music.ui.menu.YouTubeSongMenu +import com.zionhuang.music.viewmodels.YouTubeBrowseViewModel + +@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class) +@Composable +fun YouTubeBrowseScreen( + navController: NavController, + scrollBehavior: TopAppBarScrollBehavior, + viewModel: YouTubeBrowseViewModel = hiltViewModel(), +) { + val menuState = LocalMenuState.current + val playerConnection = LocalPlayerConnection.current ?: return + val isPlaying by playerConnection.isPlaying.collectAsState() + val mediaMetadata by playerConnection.mediaMetadata.collectAsState() + + val browseResult by viewModel.result.collectAsState() + + val coroutineScope = rememberCoroutineScope() + + LazyColumn( + contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() + ) { + if (browseResult == null) { + item { + ShimmerHost { + repeat(8) { + ListItemPlaceHolder() + } + } + } + } + + browseResult?.items?.forEach { + it.title?.let { title -> + item { + Text( + text = title, + style = MaterialTheme.typography.headlineMedium, + modifier = Modifier + .fillMaxWidth() + .padding(horizontal = 12.dp, vertical = 8.dp) + ) + } + } + + items(it.items) { item -> + YouTubeListItem( + item = item, + isActive = when (item) { + is SongItem -> mediaMetadata?.id == item.id + is AlbumItem -> mediaMetadata?.album?.id == item.id + else -> false + }, + isPlaying = isPlaying, + trailingContent = { + IconButton( + onClick = { + menuState.show { + when (item) { + is SongItem -> YouTubeSongMenu( + song = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is AlbumItem -> YouTubeAlbumMenu( + album = item, + navController = navController, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is ArtistItem -> YouTubeArtistMenu( + artist = item, + playerConnection = playerConnection, + onDismiss = menuState::dismiss + ) + + is PlaylistItem -> YouTubePlaylistMenu( + playlist = item, + playerConnection = playerConnection, + coroutineScope = coroutineScope, + onDismiss = menuState::dismiss + ) + } + } + } + ) { + Icon( + painter = painterResource(R.drawable.more_vert), + contentDescription = null + ) + } + }, + modifier = Modifier + .clickable { + when (item) { + is SongItem -> { + if (item.id == mediaMetadata?.id) { + playerConnection.player.togglePlayPause() + } else { + 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("online_playlist/${item.id}") + } + } + .animateItemPlacement() + ) + } + } + } + + TopAppBar( + title = { Text(browseResult?.title.orEmpty()) }, + navigationIcon = { + IconButton(onClick = navController::navigateUp) { + Icon( + painterResource(R.drawable.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 7efc0238e..14b0c453a 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,5 +1,6 @@ package com.zionhuang.music.ui.screens.artist +import android.content.Intent import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -27,6 +28,7 @@ import androidx.compose.material3.ButtonDefaults import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.Icon import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton import androidx.compose.material3.Text @@ -42,6 +44,7 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +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 @@ -59,16 +62,19 @@ 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.music.LocalDatabase 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.db.entities.ArtistEntity import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.AutoResizeText import com.zionhuang.music.ui.component.FontSizeRange import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.YouTubeGridItem import com.zionhuang.music.ui.component.YouTubeListItem @@ -84,6 +90,7 @@ 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.viewmodels.ArtistViewModel +import java.time.LocalDateTime @OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable @@ -92,6 +99,8 @@ fun ArtistScreen( scrollBehavior: TopAppBarScrollBehavior, viewModel: ArtistViewModel = hiltViewModel(), ) { + val context = LocalContext.current + val database = LocalDatabase.current val menuState = LocalMenuState.current val coroutineScope = rememberCoroutineScope() val playerConnection = LocalPlayerConnection.current ?: return @@ -99,6 +108,7 @@ fun ArtistScreen( val mediaMetadata by playerConnection.mediaMetadata.collectAsState() val artistPage = viewModel.artistPage + val libraryArtist by viewModel.libraryArtist.collectAsState() val librarySongs by viewModel.librarySongs.collectAsState() val lazyListState = rememberLazyListState() @@ -197,28 +207,12 @@ fun ArtistScreen( if (librarySongs.isNotEmpty()) { item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable { - navController.navigate("artist/${viewModel.artistId}/songs") - } - .padding(12.dp) - ) { - Column( - modifier = Modifier.weight(1f) - ) { - Text( - text = stringResource(R.string.from_your_library), - style = MaterialTheme.typography.headlineMedium - ) + NavigationTitle( + title = stringResource(R.string.from_your_library), + onClick = { + navController.navigate("artist/${viewModel.artistId}/songs") } - Icon( - painter = painterResource(R.drawable.navigate_next), - contentDescription = null - ) - } + ) } items( @@ -264,30 +258,14 @@ fun ArtistScreen( artistPage.sections.fastForEach { section -> item { - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .clickable(enabled = section.moreEndpoint != null) { - navController.navigate("artist/${viewModel.artistId}/items?browseId=${section.moreEndpoint?.browseId}?params=${section.moreEndpoint?.params}") + NavigationTitle( + title = section.title, + onClick = section.moreEndpoint?.let { + { + navController.navigate("artist/${viewModel.artistId}/items?browseId=${it.browseId}?params=${it.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.navigate_next), - contentDescription = null - ) } - } + ) } if ((section.items.firstOrNull() as? SongItem)?.album != null) { @@ -450,6 +428,57 @@ fun ArtistScreen( ) } }, + actions = { + IconButton( + onClick = { + database.transaction { + val artist = libraryArtist + if (artist != null) { + update( + artist.copy( + bookmarkedAt = if (artist.bookmarkedAt != null) null else LocalDateTime.now() + ) + ) + } else { + artistPage?.artist?.let { + insert( + ArtistEntity( + id = it.id, + name = it.title, + thumbnailUrl = it.thumbnail, + bookmarkedAt = LocalDateTime.now() + ) + ) + } + } + } + } + ) { + Icon( + painter = painterResource(if (libraryArtist?.bookmarkedAt != null) R.drawable.bookmark_filled else R.drawable.bookmark), + tint = if (libraryArtist?.bookmarkedAt != null) MaterialTheme.colorScheme.primary else LocalContentColor.current, + contentDescription = null + ) + } + + IconButton( + onClick = { + viewModel.artistPage?.artist?.shareLink?.let { link -> + val intent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, link) + } + context.startActivity(Intent.createChooser(intent, null)) + } + } + ) { + Icon( + painterResource(R.drawable.share), + contentDescription = null + ) + } + }, scrollBehavior = scrollBehavior, colors = if (transparentAppBar) { TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent) 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 583f2aea8..0e3ba8458 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 @@ -1,23 +1,15 @@ package com.zionhuang.music.ui.screens.artist -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -26,12 +18,10 @@ import androidx.compose.material3.TopAppBarScrollBehavior 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.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 @@ -45,11 +35,11 @@ import com.zionhuang.music.constants.CONTENT_TYPE_SONG import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.HideOnScrollFAB import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.ui.utils.isScrollingUp import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.ArtistSongsViewModel @@ -95,6 +85,7 @@ fun ArtistSongsScreen( when (sortType) { ArtistSongSortType.CREATE_DATE -> R.string.sort_by_create_date ArtistSongSortType.NAME -> R.string.sort_by_name + ArtistSongSortType.PLAY_TIME -> R.string.sort_by_play_time } }, trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) @@ -162,33 +153,17 @@ fun ArtistSongsScreen( scrollBehavior = scrollBehavior ) - AnimatedVisibility( - visible = lazyListState.isScrollingUp(), - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - ) - ) { - FloatingActionButton( - modifier = Modifier.padding(16.dp), - onClick = { - playerConnection.playQueue( - ListQueue( - title = artist?.name, - items = songs.shuffled().map { it.toMediaItem() }, - ) + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = artist?.name, + items = songs.shuffled().map { it.toMediaItem() }, ) - } - ) { - Icon( - painter = painterResource(R.drawable.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 66a385115..7c222e653 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 @@ -69,6 +69,7 @@ fun LibraryAlbumsScreen( AlbumSortType.YEAR -> R.string.sort_by_year AlbumSortType.SONG_COUNT -> R.string.sort_by_song_count AlbumSortType.LENGTH -> R.string.sort_by_length + AlbumSortType.PLAY_TIME -> R.string.sort_by_play_time } }, 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 5b3eed2e1..cfdb49da7 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,34 +2,60 @@ package com.zionhuang.music.ui.screens.library import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable +import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.width import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.rememberScrollState +import androidx.compose.material3.ExperimentalMaterial3Api +import androidx.compose.material3.FilterChip +import androidx.compose.material3.FilterChipDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController +import com.zionhuang.music.LocalDatabase import com.zionhuang.music.LocalPlayerAwareWindowInsets import com.zionhuang.music.R -import com.zionhuang.music.constants.* +import com.zionhuang.music.constants.ArtistSortDescendingKey +import com.zionhuang.music.constants.ArtistSortType +import com.zionhuang.music.constants.ArtistSortTypeKey +import com.zionhuang.music.constants.ArtistViewType +import com.zionhuang.music.constants.ArtistViewTypeKey +import com.zionhuang.music.constants.CONTENT_TYPE_ARTIST import com.zionhuang.music.ui.component.ArtistListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryArtistsViewModel +import java.time.LocalDateTime -@OptIn(ExperimentalFoundationApi::class) +@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @Composable fun LibraryArtistsScreen( navController: NavController, viewModel: LibraryArtistsViewModel = hiltViewModel(), ) { + val database = LocalDatabase.current + var viewType by rememberEnumPreference(ArtistViewTypeKey, ArtistViewType.ALL) val (sortType, onSortTypeChange) = rememberEnumPreference(ArtistSortTypeKey, ArtistSortType.CREATE_DATE) val (sortDescending, onSortDescendingChange) = rememberPreference(ArtistSortDescendingKey, true) @@ -41,10 +67,30 @@ fun LibraryArtistsScreen( LazyColumn( contentPadding = LocalPlayerAwareWindowInsets.current.asPaddingValues() ) { - item( - key = "header", - contentType = CONTENT_TYPE_HEADER - ) { + item(key = "viewType") { + Row( + modifier = Modifier + .fillMaxWidth() + .horizontalScroll(rememberScrollState()) + ) { + Spacer(Modifier.width(16.dp)) + + listOf( + ArtistViewType.ALL to stringResource(R.string.filter_all), + ArtistViewType.BOOKMARKED to stringResource(R.string.filter_bookmarked) + ).forEach { + FilterChip( + label = { Text(it.second) }, + selected = viewType == it.first, + colors = FilterChipDefaults.filterChipColors(containerColor = MaterialTheme.colorScheme.background), + onClick = { viewType = it.first } + ) + Spacer(Modifier.width(8.dp)) + } + } + } + + item(key = "header") { SortHeader( sortType = sortType, sortDescending = sortDescending, @@ -55,6 +101,7 @@ fun LibraryArtistsScreen( 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 + ArtistSortType.PLAY_TIME -> R.string.sort_by_play_time } }, trailingText = pluralStringResource(R.plurals.n_artist, artists.size, artists.size) @@ -68,6 +115,25 @@ fun LibraryArtistsScreen( ) { artist -> ArtistListItem( artist = artist, + trailingContent = { + IconButton( + onClick = { + database.transaction { + update( + artist.artist.copy( + bookmarkedAt = if (artist.artist.bookmarkedAt != null) null else LocalDateTime.now() + ) + ) + } + } + ) { + Icon( + painter = painterResource(if (artist.artist.bookmarkedAt != null) R.drawable.bookmark_filled else R.drawable.bookmark), + tint = if (artist.artist.bookmarkedAt != null) MaterialTheme.colorScheme.primary else LocalContentColor.current, + contentDescription = null + ) + } + }, modifier = Modifier .fillMaxWidth() .clickable { 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 65942a76b..68580b118 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 @@ -1,23 +1,15 @@ package com.zionhuang.music.ui.screens.library -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.rememberLazyListState -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme @@ -29,12 +21,10 @@ import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.rememberCoroutineScope 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.res.painterResource import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.stringResource -import androidx.compose.ui.unit.dp import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController import com.zionhuang.music.LocalDatabase @@ -49,13 +39,13 @@ import com.zionhuang.music.constants.PlaylistSortTypeKey import com.zionhuang.music.db.entities.PlaylistEntity import com.zionhuang.music.db.entities.PlaylistEntity.Companion.DOWNLOADED_PLAYLIST_ID import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_ID +import com.zionhuang.music.ui.component.HideOnScrollFAB import com.zionhuang.music.ui.component.ListItem import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.PlaylistListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.component.TextFieldDialog import com.zionhuang.music.ui.menu.PlaylistMenu -import com.zionhuang.music.ui.utils.isScrollingUp import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibraryPlaylistsViewModel @@ -208,26 +198,12 @@ fun LibraryPlaylistsScreen( } } - AnimatedVisibility( - visible = lazyListState.isScrollingUp(), - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - ) - ) { - FloatingActionButton( - modifier = Modifier.padding(16.dp), - onClick = { showAddPlaylistDialog = true } - ) { - Icon( - painter = painterResource(R.drawable.add), - contentDescription = null - ) + HideOnScrollFAB( + lazyListState = lazyListState, + icon = R.drawable.add, + 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 51487e3d7..4503ffc35 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,26 +1,20 @@ package com.zionhuang.music.ui.screens.library -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically 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.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.ui.Alignment 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 @@ -30,11 +24,11 @@ import com.zionhuang.music.constants.* import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.HideOnScrollFAB import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.ui.utils.isScrollingUp import com.zionhuang.music.utils.rememberEnumPreference import com.zionhuang.music.utils.rememberPreference import com.zionhuang.music.viewmodels.LibrarySongsViewModel @@ -79,6 +73,7 @@ fun LibrarySongsScreen( SongSortType.CREATE_DATE -> R.string.sort_by_create_date SongSortType.NAME -> R.string.sort_by_name SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time } }, trailingText = pluralStringResource(R.plurals.n_song, songs.size, songs.size) @@ -133,33 +128,18 @@ fun LibrarySongsScreen( } } - AnimatedVisibility( - visible = songs.isNotEmpty() && lazyListState.isScrollingUp(), - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - ) - ) { - FloatingActionButton( - modifier = Modifier.padding(16.dp), - onClick = { - playerConnection.playQueue( - ListQueue( - title = context.getString(R.string.queue_all_songs), - items = songs.shuffled().map { it.toMediaItem() }, - ) + HideOnScrollFAB( + visible = songs.isNotEmpty(), + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = context.getString(R.string.queue_all_songs), + items = songs.shuffled().map { it.toMediaItem() }, ) - } - ) { - Icon( - painter = painterResource(R.drawable.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 index e9f9be42c..41e68f994 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 @@ -1,23 +1,15 @@ package com.zionhuang.music.ui.screens.playlist -import androidx.compose.animation.AnimatedVisibility -import androidx.compose.animation.slideInVertically -import androidx.compose.animation.slideOutVertically import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.layout.Box -import androidx.compose.foundation.layout.WindowInsetsSides import androidx.compose.foundation.layout.asPaddingValues import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.only -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.windowInsetsPadding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.itemsIndexed import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.material3.ExperimentalMaterial3Api -import androidx.compose.material3.FloatingActionButton import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.Text @@ -27,12 +19,10 @@ 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.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.compose.ui.util.fastSumBy import androidx.hilt.navigation.compose.hiltViewModel import androidx.navigation.NavController @@ -50,11 +40,11 @@ import com.zionhuang.music.db.entities.PlaylistEntity.Companion.LIKED_PLAYLIST_I import com.zionhuang.music.extensions.toMediaItem import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.playback.queues.ListQueue +import com.zionhuang.music.ui.component.HideOnScrollFAB import com.zionhuang.music.ui.component.LocalMenuState import com.zionhuang.music.ui.component.SongListItem import com.zionhuang.music.ui.component.SortHeader import com.zionhuang.music.ui.menu.SongMenu -import com.zionhuang.music.ui.utils.isScrollingUp import com.zionhuang.music.utils.joinByBullet import com.zionhuang.music.utils.makeTimeString import com.zionhuang.music.utils.rememberEnumPreference @@ -114,6 +104,7 @@ fun BuiltInPlaylistScreen( SongSortType.CREATE_DATE -> R.string.sort_by_create_date SongSortType.NAME -> R.string.sort_by_name SongSortType.ARTIST -> R.string.sort_by_artist + SongSortType.PLAY_TIME -> R.string.sort_by_play_time } }, trailingText = joinByBullet( @@ -132,6 +123,7 @@ fun BuiltInPlaylistScreen( DownloadedSongSortType.CREATE_DATE -> R.string.sort_by_create_date DownloadedSongSortType.NAME -> R.string.sort_by_name DownloadedSongSortType.ARTIST -> R.string.sort_by_artist + DownloadedSongSortType.PLAY_TIME -> R.string.sort_by_play_time } }, trailingText = joinByBullet( @@ -205,33 +197,18 @@ fun BuiltInPlaylistScreen( scrollBehavior = scrollBehavior ) - AnimatedVisibility( - visible = songs.isNotEmpty() && lazyListState.isScrollingUp(), - enter = slideInVertically { it }, - exit = slideOutVertically { it }, - modifier = Modifier - .align(Alignment.BottomEnd) - .windowInsetsPadding( - LocalPlayerAwareWindowInsets.current - .only(WindowInsetsSides.Bottom + WindowInsetsSides.Horizontal) - ) - ) { - FloatingActionButton( - modifier = Modifier.padding(16.dp), - onClick = { - playerConnection.playQueue( - ListQueue( - title = playlistName, - items = songs.shuffled().map { it.toMediaItem() }, - ) + HideOnScrollFAB( + visible = songs.isNotEmpty(), + lazyListState = lazyListState, + icon = R.drawable.shuffle, + onClick = { + playerConnection.playQueue( + ListQueue( + title = playlistName, + items = songs.shuffled().map { it.toMediaItem() }, ) - } - ) { - Icon( - painter = painterResource(R.drawable.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 051bd7aee..d6cddc2b3 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 @@ -25,6 +25,7 @@ import androidx.compose.material3.Icon import androidx.compose.material3.IconButton import androidx.compose.material3.MaterialTheme import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.SnackbarDuration import androidx.compose.material3.SnackbarHost import androidx.compose.material3.SnackbarHostState import androidx.compose.material3.SnackbarResult @@ -443,6 +444,7 @@ fun LocalPlaylistScreen( PlaylistSongSortType.CREATE_DATE -> R.string.sort_by_create_date PlaylistSongSortType.NAME -> R.string.sort_by_name PlaylistSongSortType.ARTIST -> R.string.sort_by_artist + PlaylistSongSortType.PLAY_TIME -> R.string.sort_by_play_time } }, trailingText = "", @@ -481,7 +483,8 @@ fun LocalPlaylistScreen( coroutineScope.launch { val snackbarResult = snackbarHostState.showSnackbar( message = context.getString(R.string.removed_song_from_playlist, currentItem.song.song.title), - actionLabel = context.getString(R.string.undo) + actionLabel = context.getString(R.string.undo), + duration = SnackbarDuration.Short ) if (snackbarResult == SnackbarResult.ActionPerformed) { database.transaction { 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 93202821d..3902578e2 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 @@ -143,25 +143,27 @@ fun OnlinePlaylistScreen( 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) + playlist.author?.let { artist -> + val annotatedString = buildAnnotatedString { + withStyle( + style = MaterialTheme.typography.titleMedium.copy( + fontWeight = FontWeight.Normal, + color = MaterialTheme.colorScheme.onBackground + ).toSpanStyle() + ) { + if (artist.id != null) { + pushStringAnnotation(artist.id!!, artist.name) + append(artist.name) + pop() + } else { + append(artist.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}") + } } } 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 f02282d0a..ffec47295 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,7 +3,6 @@ package com.zionhuang.music.ui.screens.search import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.clickable import androidx.compose.foundation.horizontalScroll -import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.WindowInsets @@ -11,7 +10,6 @@ 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.systemBars @@ -37,7 +35,6 @@ 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 import androidx.compose.ui.res.stringResource @@ -60,13 +57,13 @@ 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.constants.ListItemHeight import com.zionhuang.music.constants.SearchFilterHeight import com.zionhuang.music.extensions.togglePlayPause import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.queues.YouTubeQueue import com.zionhuang.music.ui.component.EmptyPlaceholder import com.zionhuang.music.ui.component.LocalMenuState +import com.zionhuang.music.ui.component.NavigationTitle import com.zionhuang.music.ui.component.YouTubeListItem import com.zionhuang.music.ui.component.shimmer.ListItemPlaceHolder import com.zionhuang.music.ui.component.shimmer.ShimmerHost @@ -189,20 +186,7 @@ fun OnlineSearchResult( if (searchFilter == null) { searchSummary?.summaries?.forEach { summary -> item { - Box( - contentAlignment = Alignment.CenterStart, - modifier = Modifier - .fillMaxWidth() - .height(ListItemHeight) - .padding(12.dp) - .animateItemPlacement() - ) { - Text( - text = summary.title, - style = MaterialTheme.typography.headlineMedium, - maxLines = 1 - ) - } + NavigationTitle(summary.title) } items( 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 9a010bb04..460e2a05b 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 @@ -40,14 +40,14 @@ fun AppearanceSettings( .verticalScroll(rememberScrollState()) ) { SwitchPreference( - title = stringResource(R.string.enable_dynamic_theme), - icon = R.drawable.palette, + title = { Text(stringResource(R.string.enable_dynamic_theme)) }, + icon = { Icon(painterResource(R.drawable.palette), null) }, checked = dynamicTheme, onCheckedChange = onDynamicThemeChange ) EnumListPreference( - title = stringResource(R.string.dark_theme), - icon = R.drawable.dark_mode, + title = { Text(stringResource(R.string.dark_theme)) }, + icon = { Icon(painterResource(R.drawable.dark_mode), null) }, selectedValue = darkMode, onValueSelected = onDarkModeChange, valueText = { @@ -59,14 +59,14 @@ fun AppearanceSettings( } ) SwitchPreference( - title = stringResource(R.string.pure_black), - icon = R.drawable.contrast, + title = { Text(stringResource(R.string.pure_black)) }, + icon = { Icon(painterResource(R.drawable.contrast), null) }, checked = pureBlack, onCheckedChange = onPureBlackChange ) EnumListPreference( - title = stringResource(R.string.default_open_tab), - icon = R.drawable.tab, + title = { Text(stringResource(R.string.default_open_tab)) }, + icon = { Icon(painterResource(R.drawable.tab), null) }, selectedValue = defaultOpenTab, onValueSelected = onDefaultOpenTabChange, valueText = { @@ -80,8 +80,8 @@ fun AppearanceSettings( } ) EnumListPreference( - title = stringResource(R.string.lyrics_text_position), - icon = R.drawable.lyrics, + title = { Text(stringResource(R.string.lyrics_text_position)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, selectedValue = lyricsPosition, onValueSelected = onLyricsPositionChange, valueText = { 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 c3adc8ac7..8480cfe85 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 @@ -39,11 +39,6 @@ fun BackupAndRestore( viewModel.restore(context, uri) } } - val importLauncher = rememberLauncherForActivityResult(ActivityResultContracts.OpenDocument()) { uri -> - if (uri != null) { - viewModel.import(context, uri) - } - } Column( Modifier @@ -51,28 +46,20 @@ fun BackupAndRestore( .verticalScroll(rememberScrollState()) ) { PreferenceEntry( - title = stringResource(R.string.backup), - icon = R.drawable.backup, + title = { Text(stringResource(R.string.backup)) }, + icon = { Icon(painterResource(R.drawable.backup), null) }, onClick = { val formatter = DateTimeFormatter.ofPattern("yyyyMMddHHmmss") backupLauncher.launch("${context.getString(R.string.app_name)}_${LocalDateTime.now().format(formatter)}.backup") } ) PreferenceEntry( - title = stringResource(R.string.restore), - icon = R.drawable.restore, + title = { Text(stringResource(R.string.restore)) }, + icon = { Icon(painterResource(R.drawable.restore), null) }, onClick = { restoreLauncher.launch(arrayOf("application/octet-stream")) } ) - PreferenceEntry( - title = stringResource(R.string.import_playlist), - description = stringResource(R.string.choose_csv_file_from_google_takeout), - icon = R.drawable.input, - onClick = { - importLauncher.launch(arrayOf("*/*")) - } - ) } TopAppBar( 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 ed49b9e1a..42e3e7952 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 @@ -6,15 +6,19 @@ import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.verticalScroll import androidx.compose.material3.* import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.res.painterResource import androidx.compose.ui.res.stringResource import androidx.navigation.NavController +import com.zionhuang.innertube.utils.parseCookieString 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.PreferenceEntry import com.zionhuang.music.ui.component.PreferenceGroupTitle import com.zionhuang.music.ui.component.SwitchPreference import com.zionhuang.music.utils.rememberEnumPreference @@ -27,6 +31,12 @@ fun ContentSettings( navController: NavController, scrollBehavior: TopAppBarScrollBehavior, ) { + val accountName by rememberPreference(AccountNameKey, "") + val accountEmail by rememberPreference(AccountEmailKey, "") + val innerTubeCookie by rememberPreference(InnerTubeCookieKey, "") + val isLoggedIn = remember(innerTubeCookie) { + "SAPISID" in parseCookieString(innerTubeCookie) + } val (contentLanguage, onContentLanguageChange) = rememberPreference(key = ContentLanguageKey, defaultValue = "system") val (contentCountry, onContentCountryChange) = rememberPreference(key = ContentCountryKey, defaultValue = "system") val (proxyEnabled, onProxyEnabledChange) = rememberPreference(key = ProxyEnabledKey, defaultValue = false) @@ -39,9 +49,15 @@ fun ContentSettings( .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { + PreferenceEntry( + title = { Text(if (isLoggedIn) accountName else stringResource(R.string.login)) }, + description = if (isLoggedIn) accountEmail else null, + icon = { Icon(painterResource(R.drawable.person), null) }, + onClick = { navController.navigate("login") } + ) ListPreference( - title = stringResource(R.string.content_language), - icon = R.drawable.language, + title = { Text(stringResource(R.string.content_language)) }, + icon = { Icon(painterResource(R.drawable.language), null) }, selectedValue = contentLanguage, values = listOf(SYSTEM_DEFAULT) + LanguageCodeToName.keys.toList(), valueText = { @@ -52,8 +68,8 @@ fun ContentSettings( onValueSelected = onContentLanguageChange ) ListPreference( - title = stringResource(R.string.content_country), - icon = R.drawable.location_on, + title = { Text(stringResource(R.string.content_country)) }, + icon = { Icon(painterResource(R.drawable.location_on), null) }, selectedValue = contentCountry, values = listOf(SYSTEM_DEFAULT) + CountryCodeToName.keys.toList(), valueText = { @@ -69,21 +85,21 @@ fun ContentSettings( ) SwitchPreference( - title = stringResource(R.string.enable_proxy), + title = { Text(stringResource(R.string.enable_proxy)) }, checked = proxyEnabled, onCheckedChange = onProxyEnabledChange ) if (proxyEnabled) { ListPreference( - title = stringResource(R.string.proxy_type), + title = { Text(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.proxy_url), + title = { Text(stringResource(R.string.proxy_url)) }, value = proxyUrl, onValueChange = onProxyUrlChange ) 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 5679864a8..49a988efb 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 @@ -39,8 +39,8 @@ fun PlayerSettings( .verticalScroll(rememberScrollState()) ) { EnumListPreference( - title = stringResource(R.string.audio_quality), - icon = R.drawable.graphic_eq, + title = { Text(stringResource(R.string.audio_quality)) }, + icon = { Icon(painterResource(R.drawable.graphic_eq), null) }, selectedValue = audioQuality, onValueSelected = onAudioQualityChange, valueText = { @@ -52,20 +52,20 @@ fun PlayerSettings( } ) SwitchPreference( - title = stringResource(R.string.persistent_queue), - icon = R.drawable.queue_music, + title = { Text(stringResource(R.string.persistent_queue)) }, + icon = { Icon(painterResource(R.drawable.queue_music), null) }, checked = persistentQueue, onCheckedChange = onPersistentQueueChange ) SwitchPreference( - title = stringResource(R.string.skip_silence), - icon = R.drawable.skip_next, + title = { Text(stringResource(R.string.skip_silence)) }, + icon = { Icon(painterResource(R.drawable.skip_next), null) }, checked = skipSilence, onCheckedChange = onSkipSilenceChange ) SwitchPreference( - title = stringResource(R.string.audio_normalization), - icon = R.drawable.volume_up, + title = { Text(stringResource(R.string.audio_normalization)) }, + icon = { Icon(painterResource(R.drawable.volume_up), null) }, checked = audioNormalization, onCheckedChange = onAudioNormalizationChange ) 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 d8e1fd7db..e43a25b1d 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 @@ -110,30 +110,30 @@ fun PrivacySettings( .verticalScroll(rememberScrollState()) ) { SwitchPreference( - title = stringResource(R.string.pause_listen_history), - icon = R.drawable.history, + title = { Text(stringResource(R.string.pause_listen_history)) }, + icon = { Icon(painterResource(R.drawable.history), null) }, checked = pauseListenHistory, onCheckedChange = onPauseListenHistoryChange ) PreferenceEntry( - title = stringResource(R.string.clear_listen_history), - icon = R.drawable.clear_all, + title = { Text(stringResource(R.string.clear_listen_history)) }, + icon = { Icon(painterResource(R.drawable.clear_all), null) }, onClick = { showClearListenHistoryDialog = true } ) SwitchPreference( - title = stringResource(R.string.pause_search_history), - icon = R.drawable.manage_search, + title = { Text(stringResource(R.string.pause_search_history)) }, + icon = { Icon(painterResource(R.drawable.manage_search), null) }, checked = pauseSearchHistory, onCheckedChange = onPauseSearchHistoryChange ) PreferenceEntry( - title = stringResource(R.string.clear_search_history), - icon = R.drawable.clear_all, + title = { Text(stringResource(R.string.clear_search_history)) }, + icon = { Icon(painterResource(R.drawable.clear_all), null) }, onClick = { showClearSearchHistoryDialog = true } ) SwitchPreference( - title = stringResource(R.string.enable_kugou), - icon = R.drawable.lyrics, + title = { Text(stringResource(R.string.enable_kugou)) }, + icon = { Icon(painterResource(R.drawable.lyrics), null) }, checked = enableKugou, onCheckedChange = onEnableKugouChange ) 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 4bb4c24bb..698323091 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 @@ -7,9 +7,11 @@ 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 @@ -17,49 +19,71 @@ import com.zionhuang.music.ui.component.PreferenceEntry @OptIn(ExperimentalMaterial3Api::class) @Composable fun SettingsScreen( + latestVersion: Long, navController: NavController, scrollBehavior: TopAppBarScrollBehavior, ) { + val uriHandler = LocalUriHandler.current + Column( modifier = Modifier .windowInsetsPadding(LocalPlayerAwareWindowInsets.current) .verticalScroll(rememberScrollState()) ) { PreferenceEntry( - title = stringResource(R.string.appearance), - icon = R.drawable.palette, + title = { Text(stringResource(R.string.appearance)) }, + icon = { Icon(painterResource(R.drawable.palette), null) }, onClick = { navController.navigate("settings/appearance") } ) PreferenceEntry( - title = stringResource(R.string.content), - icon = R.drawable.language, + title = { Text(stringResource(R.string.content)) }, + icon = { Icon(painterResource(R.drawable.language), null) }, onClick = { navController.navigate("settings/content") } ) PreferenceEntry( - title = stringResource(R.string.player_and_audio), - icon = R.drawable.play, + title = { Text(stringResource(R.string.player_and_audio)) }, + icon = { Icon(painterResource(R.drawable.play), null) }, onClick = { navController.navigate("settings/player") } ) PreferenceEntry( - title = stringResource(R.string.storage), - icon = R.drawable.storage, + title = { Text(stringResource(R.string.storage)) }, + icon = { Icon(painterResource(R.drawable.storage), null) }, onClick = { navController.navigate("settings/storage") } ) PreferenceEntry( - title = stringResource(R.string.privacy), - icon = R.drawable.security, + title = { Text(stringResource(R.string.privacy)) }, + icon = { Icon(painterResource(R.drawable.security), null) }, onClick = { navController.navigate("settings/privacy") } ) PreferenceEntry( - title = stringResource(R.string.backup_restore), - icon = R.drawable.restore, + title = { Text(stringResource(R.string.backup_restore)) }, + icon = { Icon(painterResource(R.drawable.restore), null) }, onClick = { navController.navigate("settings/backup_restore") } ) PreferenceEntry( - title = stringResource(R.string.about), - icon = R.drawable.info, + title = { Text(stringResource(R.string.about)) }, + icon = { Icon(painterResource(R.drawable.info), null) }, onClick = { navController.navigate("settings/about") } ) + if (latestVersion > BuildConfig.VERSION_CODE) { + PreferenceEntry( + title = { + Text( + text = stringResource(R.string.new_version_available), + ) + }, + icon = { + BadgedBox( + badge = { Badge() } + ) { + Icon(painterResource(R.drawable.update), null) + } + }, + onClick = { + uriHandler.openUri("https://github.com/z-huang/InnerTune/releases/latest") + } + ) + } } TopAppBar( 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 5ea11de3a..2ec8fa899 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 @@ -105,7 +105,7 @@ fun StorageSettings( ) PreferenceEntry( - title = stringResource(R.string.clear_all_downloads), + title = { Text(stringResource(R.string.clear_all_downloads)) }, onClick = { coroutineScope.launch(Dispatchers.IO) { downloadCache.keys.forEach { key -> @@ -141,7 +141,7 @@ fun StorageSettings( } ListPreference( - title = stringResource(R.string.max_cache_size), + title = { Text(stringResource(R.string.max_cache_size)) }, selectedValue = maxSongCacheSize, values = listOf(128, 256, 512, 1024, 2048, 4096, 8192, -1), valueText = { @@ -151,7 +151,7 @@ fun StorageSettings( ) PreferenceEntry( - title = stringResource(R.string.clear_song_cache), + title = { Text(stringResource(R.string.clear_song_cache)) }, onClick = { coroutineScope.launch(Dispatchers.IO) { playerCache.keys.forEach { key -> @@ -179,7 +179,7 @@ fun StorageSettings( ) ListPreference( - title = stringResource(R.string.max_cache_size), + title = { Text(stringResource(R.string.max_cache_size)) }, selectedValue = maxImageCacheSize, values = listOf(128, 256, 512, 1024, 2048, 4096, 8192), valueText = { formatFileSize(it * 1024 * 1024L) }, @@ -187,7 +187,7 @@ fun StorageSettings( ) PreferenceEntry( - title = stringResource(R.string.clear_image_cache), + title = { Text(stringResource(R.string.clear_image_cache)) }, onClick = { coroutineScope.launch(Dispatchers.IO) { imageDiskCache.clear() diff --git a/app/src/main/java/com/zionhuang/music/ui/utils/LazyListStateUtils.kt b/app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt similarity index 73% rename from app/src/main/java/com/zionhuang/music/ui/utils/LazyListStateUtils.kt rename to app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt index cc7061c2e..975e195f4 100644 --- a/app/src/main/java/com/zionhuang/music/ui/utils/LazyListStateUtils.kt +++ b/app/src/main/java/com/zionhuang/music/ui/utils/ScrollUtils.kt @@ -1,5 +1,6 @@ package com.zionhuang.music.ui.utils +import androidx.compose.foundation.ScrollState import androidx.compose.foundation.lazy.LazyListState import androidx.compose.runtime.Composable import androidx.compose.runtime.derivedStateOf @@ -24,4 +25,16 @@ fun LazyListState.isScrollingUp(): Boolean { } } }.value -} \ No newline at end of file +} + +@Composable +fun ScrollState.isScrollingUp(): Boolean { + var previousScrollOffset by remember(this) { mutableStateOf(value) } + return remember(this) { + derivedStateOf { + (previousScrollOffset >= value).also { + previousScrollOffset = value + } + } + }.value +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.kt new file mode 100644 index 000000000..dd73c3124 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/AccountViewModel.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.PlaylistItem +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class AccountViewModel @Inject constructor() : ViewModel() { + val playlists = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + YouTube.likedPlaylists().onSuccess { + playlists.value = it + }.onFailure { + it.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 d90d047ed..7f61d6e80 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/ArtistViewModel.kt @@ -22,6 +22,8 @@ class ArtistViewModel @Inject constructor( ) : ViewModel() { val artistId = savedStateHandle.get("artistId")!! var artistPage by mutableStateOf(null) + val libraryArtist = database.artist(artistId) + .stateIn(viewModelScope, SharingStarted.Lazily, null) val librarySongs = database.artistSongsPreview(artistId) .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) 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 a1483f963..60b92321f 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BackupRestoreViewModel.kt @@ -3,31 +3,20 @@ package com.zionhuang.music.viewmodels import android.content.Context import android.content.Intent import android.net.Uri -import android.provider.OpenableColumns import android.widget.Toast import androidx.lifecycle.ViewModel -import androidx.lifecycle.viewModelScope -import com.zionhuang.innertube.YouTube -import com.zionhuang.innertube.YouTube.MAX_GET_QUEUE_SIZE -import com.zionhuang.innertube.models.SongItem 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.db.entities.PlaylistEntity -import com.zionhuang.music.db.entities.PlaylistEntity.Companion.generatePlaylistId -import com.zionhuang.music.db.entities.PlaylistSongMap import com.zionhuang.music.extensions.div import com.zionhuang.music.extensions.zipInputStream import com.zionhuang.music.extensions.zipOutputStream -import com.zionhuang.music.models.toMediaMetadata import com.zionhuang.music.playback.MusicService import com.zionhuang.music.playback.MusicService.Companion.PERSISTENT_QUEUE_FILE import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking -import kotlinx.coroutines.withContext import java.io.FileInputStream import java.io.FileOutputStream import java.util.zip.ZipEntry @@ -100,64 +89,6 @@ class BackupRestoreViewModel @Inject constructor( } } - fun import(context: Context, uri: Uri) { - runCatching { - val videoIds = mutableListOf() - context.applicationContext.contentResolver.openInputStream(uri)?.use { inputStream -> - val br = inputStream.bufferedReader() - repeat(8) { - br.readLine() - } - var line = br.readLine() - while (line != null) { - line.split(",").firstOrNull() - ?.takeIf { it.isNotEmpty() } - ?.let { - videoIds.add(it.trim()) - } - line = br.readLine() - } - } - val playlistName = context.contentResolver.query(uri, null, null, null, null)?.use { cursor -> - val nameIndex = cursor.getColumnIndex(OpenableColumns.DISPLAY_NAME) - cursor.moveToFirst() - cursor.getString(nameIndex) - }?.removeSuffix(".csv") ?: context.getString(R.string.imported_playlist) - viewModelScope.launch { - val songs = videoIds.chunked(MAX_GET_QUEUE_SIZE).flatMap { - withContext(Dispatchers.IO) { - YouTube.queue(videoIds = it) - }.getOrNull().orEmpty() - } - database.transaction { - val playlistId = generatePlaylistId() - var position = 0 - insert( - PlaylistEntity( - id = playlistId, - name = playlistName - ) - ) - songs.map(SongItem::toMediaMetadata) - .onEach(::insert) - .forEach { - insert( - PlaylistSongMap( - playlistId = playlistId, - songId = it.id, - position = position++ - ) - ) - } - } - Toast.makeText(context, context.resources.getQuantityString(R.plurals.import_success, songs.size, playlistName, songs.size), Toast.LENGTH_SHORT).show() - } - }.onFailure { - it.printStackTrace() - Toast.makeText(context, R.string.restore_failed, Toast.LENGTH_SHORT).show() - } - } - companion object { const val SETTINGS_FILENAME = "settings.preferences_pb" } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt index a53f6afa8..6f813253d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/BuiltInPlaylistViewModel.kt @@ -71,6 +71,7 @@ class BuiltInPlaylistViewModel @Inject constructor( DownloadedSongSortType.ARTIST -> songs.sortedBy { song -> song.first.artists.joinToString(separator = "") { it.name } } + DownloadedSongSortType.PLAY_TIME -> songs.sortedBy { it.first.song.totalPlayTime } } .map { it.first } .reversed(descending) 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 9aa7de8c4..cabe66ff9 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/HomeViewModel.kt @@ -3,7 +3,7 @@ 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 com.zionhuang.innertube.pages.ExplorePage import com.zionhuang.music.db.MusicDatabase import com.zionhuang.music.db.entities.Song import dagger.hilt.android.lifecycle.HiltViewModel @@ -20,12 +20,14 @@ class HomeViewModel @Inject constructor( val isRefreshing = MutableStateFlow(false) val quickPicks = MutableStateFlow?>(null) - val newReleaseAlbums = MutableStateFlow>(emptyList()) + val explorePage = MutableStateFlow(null) private suspend fun load() { quickPicks.value = database.quickPicks().first().shuffled().take(20) - YouTube.newReleaseAlbumsPreview().onSuccess { - newReleaseAlbums.value = it + YouTube.explore().onSuccess { + explorePage.value = it + }.onFailure { + it.printStackTrace() } } 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 c6784e8e1..b5ff00181 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LibraryViewModels.kt @@ -44,11 +44,18 @@ class LibraryArtistsViewModel @Inject constructor( ) : ViewModel() { val allArtists = context.dataStore.data .map { - it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE) to (it[ArtistSortDescendingKey] ?: true) + Triple( + it[ArtistViewTypeKey].toEnum(ArtistViewType.ALL), + it[ArtistSortTypeKey].toEnum(ArtistSortType.CREATE_DATE), + it[ArtistSortDescendingKey] ?: true + ) } .distinctUntilChanged() - .flatMapLatest { (sortType, descending) -> - database.artists(sortType, descending) + .flatMapLatest { (viewType, sortType, descending) -> + when (viewType) { + ArtistViewType.ALL -> database.artists(sortType, descending) + ArtistViewType.BOOKMARKED -> database.artistsBookmarked(sortType, descending) + } } .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 959dfe7d6..380a41cbf 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/LocalPlaylistViewModel.kt @@ -44,6 +44,7 @@ class LocalPlaylistViewModel @Inject constructor( PlaylistSongSortType.ARTIST -> songs.sortedBy { song -> song.song.artists.joinToString { it.name } } + PlaylistSongSortType.PLAY_TIME -> songs.sortedBy { it.song.song.totalPlayTime } }.reversed(sortDescending && sortType != PlaylistSongSortType.CUSTOM) }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.kt new file mode 100644 index 000000000..ea2a29d51 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/MoodAndGenresViewModel.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.pages.MoodAndGenres +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class MoodAndGenresViewModel @Inject constructor() : ViewModel() { + val moodAndGenres = MutableStateFlow?>(null) + + init { + viewModelScope.launch { + YouTube.moodAndGenres().onSuccess { + moodAndGenres.value = it + }.onFailure { + it.printStackTrace() + } + } + } +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt index 8153e8cb6..8770dfe64 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/NewReleaseViewModel.kt @@ -19,6 +19,8 @@ class NewReleaseViewModel @Inject constructor() : ViewModel() { viewModelScope.launch { YouTube.newReleaseAlbums().onSuccess { _newReleaseAlbums.value = it + }.onFailure { + it.printStackTrace() } } } 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 3baf5146a..0de0e189c 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/OnlineSearchViewModel.kt @@ -13,13 +13,14 @@ import com.zionhuang.music.models.ItemsPage import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.launch +import java.net.URLDecoder import javax.inject.Inject @HiltViewModel class OnlineSearchViewModel @Inject constructor( savedStateHandle: SavedStateHandle, ) : ViewModel() { - val query = savedStateHandle.get("query")!! + val query = URLDecoder.decode(savedStateHandle.get("query")!!, "UTF-8")!! val filter = MutableStateFlow(null) var summaryPage by mutableStateOf(null) val viewStateMap = mutableStateMapOf() @@ -33,8 +34,8 @@ class OnlineSearchViewModel @Inject constructor( } } else { if (viewStateMap[filter.value] == null) { - viewStateMap[filter.value] = YouTube.search(query, filter).getOrNull()?.let { - ItemsPage(it.items, it.continuation) + viewStateMap[filter.value] = YouTube.search(query, filter).getOrNull()?.let { result -> + ItemsPage(result.items.distinctBy { it.id }, result.continuation) } } } diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt index 499a08417..d8dda558d 100644 --- a/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt +++ b/app/src/main/java/com/zionhuang/music/viewmodels/StatsViewModel.kt @@ -3,23 +3,43 @@ package com.zionhuang.music.viewmodels import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import com.zionhuang.innertube.YouTube +import com.zionhuang.music.constants.StatPeriod import com.zionhuang.music.db.MusicDatabase import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.flatMapLatest +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch import java.time.Duration import java.time.LocalDateTime import javax.inject.Inject +@OptIn(ExperimentalCoroutinesApi::class) @HiltViewModel class StatsViewModel @Inject constructor( val database: MusicDatabase, ) : ViewModel() { - val mostPlayedSongs = database.mostPlayedSongs() - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) - val mostPlayedArtists = database.mostPlayedArtists() - .stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + val statPeriod = MutableStateFlow(StatPeriod.`1_WEEK`) + + val mostPlayedSongs = statPeriod.flatMapLatest { period -> + database.mostPlayedSongs(period.toTimeMillis()) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + val mostPlayedArtists = statPeriod.flatMapLatest { period -> + database.mostPlayedArtists(period.toTimeMillis()) + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) + + + val mostPlayedAlbums = statPeriod.flatMapLatest { period -> + database.mostPlayedAlbums(period.toTimeMillis()) + }.map { albums -> + albums.mapNotNull { id -> + YouTube.album(id, withSongs = false).getOrNull()?.album + } + }.stateIn(viewModelScope, SharingStarted.Lazily, emptyList()) init { viewModelScope.launch { @@ -39,4 +59,4 @@ class StatsViewModel @Inject constructor( } } } -} \ No newline at end of file +} diff --git a/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt new file mode 100644 index 000000000..2e53332d3 --- /dev/null +++ b/app/src/main/java/com/zionhuang/music/viewmodels/YouTubeBrowseViewModel.kt @@ -0,0 +1,31 @@ +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.pages.BrowseResult +import dagger.hilt.android.lifecycle.HiltViewModel +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.launch +import javax.inject.Inject + +@HiltViewModel +class YouTubeBrowseViewModel @Inject constructor( + savedStateHandle: SavedStateHandle, +) : ViewModel() { + private val browseId = savedStateHandle.get("browseId")!! + private val params = savedStateHandle.get("params") + + val result = MutableStateFlow(null) + + init { + viewModelScope.launch { + YouTube.browse(browseId, params).onSuccess { + result.value = it + }.onFailure { + it.printStackTrace() + } + } + } +} diff --git a/app/src/main/res/drawable/bookmark.xml b/app/src/main/res/drawable/bookmark.xml new file mode 100644 index 000000000..e69165b59 --- /dev/null +++ b/app/src/main/res/drawable/bookmark.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/bookmark_filled.xml b/app/src/main/res/drawable/bookmark_filled.xml new file mode 100644 index 000000000..c3cb3d600 --- /dev/null +++ b/app/src/main/res/drawable/bookmark_filled.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/mood.xml b/app/src/main/res/drawable/mood.xml new file mode 100644 index 000000000..adb1afa06 --- /dev/null +++ b/app/src/main/res/drawable/mood.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/person.xml b/app/src/main/res/drawable/person.xml new file mode 100644 index 000000000..cac31b256 --- /dev/null +++ b/app/src/main/res/drawable/person.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/remove.xml b/app/src/main/res/drawable/remove.xml new file mode 100644 index 000000000..b13fa153f --- /dev/null +++ b/app/src/main/res/drawable/remove.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/settings.xml b/app/src/main/res/drawable/settings.xml index 48db39640..57c7adb7c 100644 --- a/app/src/main/res/drawable/settings.xml +++ b/app/src/main/res/drawable/settings.xml @@ -5,5 +5,5 @@ android:viewportHeight="960"> + android:pathData="M370,880L354,752Q341,747 329.5,740Q318,733 307,725L188,775L78,585L181,507Q180,500 180,493.5Q180,487 180,480Q180,473 180,466.5Q180,460 181,453L78,375L188,185L307,235Q318,227 330,220Q342,213 354,208L370,80L590,80L606,208Q619,213 630.5,220Q642,227 653,235L772,185L882,375L779,453Q780,460 780,466.5Q780,473 780,480Q780,487 780,493.5Q780,500 778,507L881,585L771,775L653,725Q642,733 630,740Q618,747 606,752L590,880L370,880ZM482,620Q540,620 581,579Q622,538 622,480Q622,422 581,381Q540,340 482,340Q423,340 382.5,381Q342,422 342,480Q342,538 382.5,579Q423,620 482,620ZM482,540Q457,540 439.5,522.5Q422,505 422,480Q422,455 439.5,437.5Q457,420 482,420Q507,420 524.5,437.5Q542,455 542,480Q542,505 524.5,522.5Q507,540 482,540ZM480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480Q480,480 480,480L480,480L480,480L480,480Q480,480 480,480Q480,480 480,480L480,480ZM440,800L519,800L533,694Q564,686 590.5,670.5Q617,655 639,633L738,674L777,606L691,541Q696,527 698,511.5Q700,496 700,480Q700,464 698,448.5Q696,433 691,419L777,354L738,286L639,328Q617,305 590.5,289.5Q564,274 533,266L520,160L441,160L427,266Q396,274 369.5,289.5Q343,305 321,327L222,286L183,354L269,418Q264,433 262,448Q260,463 260,480Q260,496 262,511Q264,526 269,541L183,606L222,674L321,632Q343,655 369.5,670.5Q396,686 427,694L440,800Z" /> diff --git a/app/src/main/res/drawable/shortcut_albums.xml b/app/src/main/res/drawable/shortcut_albums.xml new file mode 100644 index 000000000..7aed64f3a --- /dev/null +++ b/app/src/main/res/drawable/shortcut_albums.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_playlists.xml b/app/src/main/res/drawable/shortcut_playlists.xml new file mode 100644 index 000000000..eba0fe7ea --- /dev/null +++ b/app/src/main/res/drawable/shortcut_playlists.xml @@ -0,0 +1,10 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_search.xml b/app/src/main/res/drawable/shortcut_search.xml new file mode 100644 index 000000000..c56846b00 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_search.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/shortcut_songs.xml b/app/src/main/res/drawable/shortcut_songs.xml new file mode 100644 index 000000000..710ec0019 --- /dev/null +++ b/app/src/main/res/drawable/shortcut_songs.xml @@ -0,0 +1,9 @@ + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/slow_motion_video.xml b/app/src/main/res/drawable/slow_motion_video.xml new file mode 100644 index 000000000..69ed770ae --- /dev/null +++ b/app/src/main/res/drawable/slow_motion_video.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/tune.xml b/app/src/main/res/drawable/tune.xml new file mode 100644 index 000000000..9ab9dfc71 --- /dev/null +++ b/app/src/main/res/drawable/tune.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/update.xml b/app/src/main/res/drawable/update.xml new file mode 100644 index 000000000..65fd69ac7 --- /dev/null +++ b/app/src/main/res/drawable/update.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 75d7584a0..adc998ec7 100644 --- a/app/src/main/res/values-DE/strings.xml +++ b/app/src/main/res/values-DE/strings.xml @@ -13,21 +13,24 @@ - History - Stats - Quick picks - Listen some songs to generate your quick picks - New release albums + Hörverlauf + Statistiken + Stimmungen und Genres + Account + Schnellauswahl + Hören Sie sich einige Songs an, um Ihre Schnellauswahl zu treffen + Neu veröffentlichte Alben - Today - Yesterday - This week - Last week + Heute + Gestern + Diese Woche + Letzte Woche - Most played songs - Most played artists + Meist gespielte Songs + Meist gespielte Künstler + Meist gespielte Alben Suche @@ -41,7 +44,8 @@ Wiedergabelisten Community-Wiedergabelisten Ausgewählte Wiedergabelisten - No results found + Bookmarked + Keine Ergebnisse gefunden Aus der Bibliothek @@ -49,7 +53,7 @@ Beliebte Titel Heruntergeladene Titel - The playlist is empty + Die Wiedergabeliste ist leer Wiederholen @@ -75,7 +79,7 @@ Neu laden Teilen Löschen - Remove from history + aus dem Wiedergabeverlauf entfernen Online-Suche Sync @@ -87,7 +91,7 @@ Anzahl der Lieder Länge Spielzeit - Custom order + Individuelle Reinfolge Medien-ID @@ -138,20 +142,32 @@ %d Wiedergabeliste %d Wiedergabelisten + + %d Woche + %d Wochen + + + %d Monat + %d Monate + + + %d Jahr + %d Jahre + Wiedergabeliste importiert - Removed \"%s\" from playlist - Playlist synced - Undo + Entfernt \"%s\" aus der Wiedergabeliste + Wiedergabeliste synchronisiert + Rückgängig machen Liedtext nicht gefunden - Sleep timer - End of song + Schlaf-Timer + Ende des Liedes 1 minute - %d minutes + %d Minuten Kein Stream verfügbar Keine Netzverbindung @@ -172,12 +188,12 @@ Einstellungen Erscheinungsbild - Enable dynamic theme + Dynamisches Design aktivieren Dunkles Thema An Aus System folgen - Pure black + Reines Schwarz Standardmäßig geöffnete Registerkarte Anpassen der Navigationsregisterkarten Position des Liedtextes @@ -207,39 +223,36 @@ Speicher Zwischenspeicher - Image Cache - Song Cache - Max cache size - Unlimited - Clear all downloads + Bild-Cache + Song-Cache + Maximale Cache-Größe + Unbegrenzt + Alle Downloads entfernen Maximale Größe des Bild-Caches Bild-Cache löschen Maximale Größe des Song-Cache - Clear song cache + Song-Cache löschen %s verwendet Privatsphäre - Pause listen history - Clear listen history - Are you sure to clear all listen history? + Pausieren des Hörverlaufs + Hörverlauf löschen + Sind Sie sicher, dass Sie den gesamten Hörverlauf löschen wollen? Suchverlauf anhalten Suchverlauf löschen - Sind Sie sicher, dass Sie den gesamten Suchverlauf löschen? + Sind Sie sicher, dass Sie den gesamten Suchverlauf löschen wollen? KuGou-Liedtextanbieter aktivieren Sichern und Wiederherstellen Datensicherung Wiederherstellen - Choose a csv file from Google Takeout - Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - + Importierte Wiedergabeliste Sicherung erfolgreich erstellt Konnte keine Sicherung erstellen Wiederherstellung der Sicherung fehlgeschlagen Über App-Version + + New version available diff --git a/app/src/main/res/values-be/strings.xml b/app/src/main/res/values-be/strings.xml new file mode 100644 index 000000000..5e4f5bfec --- /dev/null +++ b/app/src/main/res/values-be/strings.xml @@ -0,0 +1,276 @@ + + + Галоўнае + Песні + Выканаўцы + Альбомы + Плэй-лісты + + + + %d абрана + %d абраны + %d абрана + %d абрана + + + + Гісторыя + Статыстыка + Настроі і жанры + Уліковы запіс + Хуткі выбар + Праслухайце якія-небудзь кампазіцыі, каб стварыць ваш хуткі выбар. + Новыя рэлізы альбомаў + + + Сёння + Учора + На гэтым тыднія + На тым тыдні + + + Лепшыя кампазіцыі + Лепшыя выканаўцы + Most played albums + + + Пошук + Пошук у YouTube Music… + Пошук у бібліятэцы… + Усе + Песні + Відэа + Альбомы + Выканаўцы + Плэй-лісты + Плэй-лісты aд cупольнасці + Вартыя ўвагі плэй-лісты + Bookmarked + Вынікі не знойдзены + + + З вашай бібліятэкі + + + Упадабаныя кампазіцыi + Спампаваныя кампазіцыі + Плай-ліст пусты + + + Паўтарыць + Радыё + Перамяшаць + + + Падрабязнасці + Змяніць + Уключыць радыё + Прайграць + Прайграць наступным + Дадаць у чаргу + Дадаць у бібліятэку + Выдаліць з бібліятэкі + Спампаваць + Спампоўка + Выдаліць са спамповак + Імпартаваць плей-ліст + Дадаць у плэй-ліст + Перайсці да выканаўца + Перайсці да альбома + Абнавіць + Абагуліць + Выдаліць + Выдаліць з гісторыі + Шукаць у сетцы + Cінхранізацыя + + + Нядаўна дададзена + Назва + Выканаўца + Год + Колькасць песен + Працягласць + Колькасць прайграванняў + Уласны парадак + + + Iдэнтыфікатар медыя + Тып MIME + Кодэкі + Хуткасць + Частата дыскрэтызацыі + Гучнасць + Узровень гучнасці + Памер файла + Невядомы + Скапіявана + + Змяниць тэкст песні + Пошук тэкста песні + + Змяніць песню + Назва песні + Выканаўца песні + Песня павінна мець назву. + Песня павінна мець выканаўца. + Захаваць + + Выбраць плэй-ліст + Змяніць плей-ліст + Стварыць плей-ліст + Назва плей-ліста + Плей-ліст павінен мець назву. + + Змяніць выканайца + Імя ваканаўца + Выканаўца павінен мець імя. + + + + %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 raдоў + %d гады + + + + Плей-ліст імпартаваны + \"%s\" выдалена з плей-ліста + Плей-ліст сінхранізаваны + Адрабіць + + + Тэкст песні не знойдзены + Таймер рэжыму сну + Канец песні + + 1 хвіліна + %d хвіліны + %d хвілін + %d хвілін + + Няма даступных плыняў + Няма падлучэння да сеткі + Час чакання + Невядомая памылка + + + Упадабаць + Выдаліць з упадабаных + + + Усе песні + Шуканыя кампазіцыі + + + Музычны прайгравальнік + + + Налады + Выгляд + Уключыць дынамічную каляровую тэму + Цёмная каляровая тэма + Укл. + Выкл. + Прытрымлівацца сістэмнай каляровай тэмы + Рэжым чыстага чорнага колеру + Прадвызначаная укладка + Дапасаваць укладак + Пазіцыя тэкста песні + Па левым краі + Па цэнтры + Па правым краі + + Змесціва + Лагін + Мова змесціва + Краіна змесціва + Прытрымлівацца сістэмнай + Уключыць проксі + Тып проксі + URL проксі + Перазапусціць каб ужыць змяненні + + Прайгравальнік ды аўдыя + Якасць аўдыя + Аўта + Высокая + Нізкая + Сталая чарга + Прапусціць цішыню + Нармалізацыя аўдыя + Эквалайзер + + Сховішча + Кеш + Кеш выяў + Кеш песен + Максімальны памер кэшу + Безліміт + Ачысціць усе спампоўкі + Максімальны памер кэшу выяў + Ачысціць кэш выяў + Максімальны памер кэшу песен + Ачысціць кэш песен + %s выкарыстоўваецца + + Прыватнасць + Прыпыніць гісторыю праслухоўвання + Ачысціць гісторыю праслухоўвання + Сапраўды ачысціць гісторыю праслухоўвання? + Прыпыніць гісторыю пошуку + Ачысціць гісторыю пошуку + Сапраўды ачысціць гісторыю пошуку? + Шукаць тэксты песен у KuGou + + Рэзервовае капіраванне ды аднеўленне + Рэзервовае капіраванне + Аднаўленне з рэзервовай копіі + Імпартаваны плей-ліст + Рэзервовная копія паспяхова створана + Немагчыма стапрыць рэзервовую копію + Немагчыма ужывіць рэзервовую копію + + Аб праграме + Версія праграмы + + New version available + diff --git a/app/src/main/res/values-bn-rIN/strings.xml b/app/src/main/res/values-bn-rIN/strings.xml new file mode 100644 index 000000000..9088e7ba1 --- /dev/null +++ b/app/src/main/res/values-bn-rIN/strings.xml @@ -0,0 +1,258 @@ + + + হোম + গান + শিল্পী + অ্যালবাম + প্লেলিস্ট + + + + %d নির্বাচিত + %d নির্বাচিত + + + + ইতিহাস + পরিসংখ্যান + মেজাজ এবং শৈলী + অ্যাকাউন্ট + দ্রুত পছন্দ + আপনার নিজের শর্টকাট তৈরি করতে কিছু টিউন শুনুন + নতুন অ্যালবাম ও সিঙ্গেল + + + আজ + গতকাল + এই সপ্তাহ + গত সপ্তাহ + + + সর্বাধিক শোনা গানগুলি + সর্বাধিক শোনা শিল্পী + সর্বাধিক শোনা অ্যালবাম + + + খুঁজুন + খুঁজুন YouTube Music এ + খুঁজুন লাইব্রেরি তে + সব + সংগীত + ভিডিও + অ্যালবাম + শিল্পী + প্লেলিস্ট + প্লেলিস্ট ক্রম + বিশিষ্ট প্লেলিস্ট + Bookmarked + কিছু পাওয়া যায়নি + + + আপনার লাইব্রেরি থেকে + + + আপনার পছন্দের গান + ডাউনলোড করা গান + প্লেলিস্ট খালি + + + পুনরায় চেষ্টা করুন + বেতার + এলোমেলো + + + বিস্তারিত + সম্পাদনা + বেতার খুলুন + গান বাজান + পরবর্তী গান বাজান + পরবর্তী গান যোগ করুন + লাইব্রেরি তে যোগ করুন + লাইব্রেরি থেকে সরান + ডাউনলোড + ডাউনলোড হচ্ছে + ডাউনলোড মুছে ফেলুন + প্লেলিস্ট আমদানি করুন + প্লেলিস্টে যোগ করুন + শিল্পী দেখুন + অ্যালবাম দেখুন + আবার আনুন + শেয়ার + মুছুন + ইতিহাস থেকে অপসারণ + অনলাইন এ খুঁজুন + সুসংগত + + + সময় সম্পাদনা করা হয়েছে + নাম + শিল্পী + বছর + প্লে সংখ্যান + দৈর্ঘ্য + বাজানো হয়েছে + অনুকুলিত + + + Media ID + MIME Type + Codec + Bitrate + Sample Rate + শব্দের মাত্রা + ধ্বনির মাত্রা + ফাইলের আকার + অজানা + ক্লিপবোর্ডে কপি করা হয়েছে + + গানের কথা সম্পাদনা + গানের কথা অনুসন্ধান + + গান সম্পাদনা করুন + শিরোনাম + শিল্পী + গানের শিরোনাম খালি হতে পারে না। + গানের শিল্পী খালি থাকতে পারে না। + সংরক্ষণ + + একটি প্লেলিস্ট চয়ন করুন + প্লেলিস্ট সম্পাদনা করুন + প্লেলিস্ট তৈরি করুন + প্লেলিস্টের নাম + প্লেলিস্টের নাম খালি রাখা যাবে না। + + শিল্পী সম্পাদনা করুন + শিল্পীর নাম + শিল্পীর নাম খালি রাখা যাবে না। + + + + %d গান + %d গান + + + %d শিল্পী + %d শিল্পী + + + %d অ্যালবাম + %d অ্যালবাম + + + %d প্লেলিস্ট + %d প্লেলিস্ট + + + %d সপ্তাহ + %d সপ্তাহ + + + %d মাস + %d মাস + + + %d বছর + %d বছর + + + + আমদানি করা প্লেলিস্ট + প্লেলিস্ট থেকে \"%s\" সরানো হয়েছে + সিঙ্ক্রোনাইজ করা প্লেলিস্ট + বাতিল করুন + + + গানের কথা পাওয়া যায়নি + ঘুমের টাইমার + গানের শেষ + + %d মিনিট + %d মিনিট + + স্ট্রিম উপলব্ধ নয় + নেটওয়ার্ক সংযোগ নেই + ত্রুটি সময়সীমা শেষ + অজানা ত্রুটি + + + পছন্দ + অপছন্দ + + + সব গান + অনুসন্ধান করা গান + + + প্লেয়ার + + + সেটিংস + দৃষ্টিগোচরতা + গতিশীল থিম সক্ষম করুন + অন্ধকার থিম + সক্রিয় + নিষ্ক্রিয় + সিস্টেম অনুসরণ করুন + কালো + ডিফল্ট প্রধান ট্যাব + নেভিগেশন ট্যাব কাস্টমাইজ করুন + গানের কথার অবস্থান + বাম + কেন্দ্র + ডান + + কনটেন্ট + সাইন ইন + ডিফল্ট কন্টেন্টের ভাষা + ডিফল্ট কনটেন্ট দেশ + সিস্টেমের ডিফল্ট + প্রক্সি সক্রিয় করুন + প্রক্সি ধরণ + প্রক্সি URL + পরিবর্তনগুলি প্রয়োগ করতে অ্যাপটি পুনরায় চালু করুন + + প্লেয়ার এবং অডিও + অডিওর মান + স্বয়ংক্রিয় + উচ্চ + নিম্ন + অবিরাম সারি + নীরবতা এড়িয়ে যান + অডিও স্বাভাবিকীকরণ + অডিও টিউনার + + স্টোরেজ + ক্যাশে + ইমেজ ক্যাশে + অডিও ক্যাশে + সর্বাধিক ক্যাশে আকার + সীমাহীন + সমস্ত ডাউনলোড মুছুন + ইমেজ ক্যাশে সর্বাধিক আকার + ইমেজ ক্যাশে সাফ করুন + অডিও ক্যাশে সর্বোচ্চ আকার + অডিও ক্যাশে সাফ করুন + %s ব্যবহৃত + + গোপনীয়তা + আপনার শোনার ইতিহাস থামান + আপনার শোনার ইতিহাস সাফ করুন + আপনি কি আপনার শোনার ইতিহাস মুছে ফেলার বিষয়ে নিশ্চিত? + আপনার অনুসন্ধান ইতিহাস স্থগিত + আপনার অনুসন্ধান ইতিহাস সাফ করুন + আপনি কি আপনার অনুসন্ধান ইতিহাস মুছে ফেলার বিষয়ে নিশ্চিত? + KuGou দ্বারা প্রদত্ত গানের কথা সক্রিয় করুন + + ব্যাকআপ এবং পুনঃস্থাপন + ব্যাকআপ + পুনঃস্থাপন + আমদানি করা প্লেলিস্ট + ব্যাকআপ সফলভাবে তৈরি করা হয়েছে + ব্যাকআপ করতে অক্ষম + ব্যাকআপ থেকে পুনরুদ্ধার করতে অক্ষম + + সম্পর্কিত + অ্যাপ সংস্করণ + + New version available + diff --git a/app/src/main/res/values-cs/strings.xml b/app/src/main/res/values-cs/strings.xml index 9cc60ac2d..a7642851e 100644 --- a/app/src/main/res/values-cs/strings.xml +++ b/app/src/main/res/values-cs/strings.xml @@ -17,8 +17,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -30,6 +32,7 @@ Most played songs Most played artists + Most played albums Vyhledávání @@ -43,6 +46,7 @@ Playlisty Komunitní playlisty Doporučené playlisty + Bookmarked No results found @@ -148,6 +152,18 @@ %d playlistů %d playlistů + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Playlist importován @@ -163,6 +179,7 @@ 1 minute %d minutes %d minutes + %d minutes Není dostupný žádný stream Není dostupné připojení k internetu @@ -241,16 +258,13 @@ Záloha a obnovení Zálohovat Obnovit - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Záloha úspěšně vytvořena Nepodařilo se vytvořit zálohu Nepodařilo se obnovit zálohu O aplikaci Verze aplikace + + New version available diff --git a/app/src/main/res/values-es-rUS/strings.xml b/app/src/main/res/values-es-rUS/strings.xml deleted file mode 100644 index e9991abd7..000000000 --- a/app/src/main/res/values-es-rUS/strings.xml +++ /dev/null @@ -1,245 +0,0 @@ - - - Home - Canciones - Artistas - Albums - Playlists - - - - %d seleccionada - %d seleccionadas - - - - History - Stats - Quick picks - Listen some songs to generate your quick picks - New release albums - - - Today - Yesterday - This week - Last week - - - Most played songs - Most played artists - - - 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 - - - Reintentar - Radio - Aleatorio - - - Details - Editar - Start radio - Play - Reproducir luego - Añadir a la cola - Añadir a la biblioteca - Remove from library - Descargar - Downloading - Remover archivo descargado - Import playlist - Añadir a una playlist - View artist - View album - Refetch - Share - Eliminar - Remove from history - Search online - Sync - - - Fecha de incorporación - Nombre - Artista - Year - Song count - Length - Play time - Custom order - - - Media id - MIME type - Codecs - Bitrate - Sample rate - Loudness - Volume - File size - Unknown - Copied to clipboard - - Edit lyrics - Search lyrics - - Editar canción - Título - Artista - El título no puede estar vacío. - El artista no puede estar vacío. - Guardar - - Elige una playlist - Editar playlist - Crear playlist - Nombre - El nombre no puede estar vacío. - - Editar artista - Nombre - El nombre no puede estar vacío. - - - - %d canción - %d canciones - - - %d artist - %d artists - - - %d album - %d albums - - - %d playlist - %d playlists - - - - Playlist imported - Removed \"%s\" from playlist - Playlist synced - Undo - - - Lyrics not found - Sleep timer - End of song - - 1 minute - %d minutes - - No stream available - No network connection - Timeout - Unknown error - - - Like - Remove like - - - All songs - Searched songs - - - Reproductor - - - Configuración - Apariencia - Enable dynamic theme - Modo oscuro - Encendido - Apagado - Predeterminado del sistema - Pure black - 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 - Clear all downloads - Max image cache size - Clear image cache - Max song cache size - Clear song cache - %s used - - Privacy - Pause listen history - Clear listen history - Are you sure to clear all listen history? - Pause search history - Clear search history - Are you sure to clear all search history? - Enable KuGou lyrics provider - - Backup and restore - Backup - Restore - Choose a csv file from Google Takeout - Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - - 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 b493d8f27..90924cfd5 100644 --- a/app/src/main/res/values-es/strings.xml +++ b/app/src/main/res/values-es/strings.xml @@ -1,4 +1,4 @@ - + Inicio Canciones @@ -9,14 +9,17 @@ %d seleccionada + %d seleccionadas %d seleccionadas Historial Estadísticas + Ánimo y géneros + Cuenta Selecciones rápidas - Listen some songs to generate your quick picks + Escucha algunas canciones para generar tus selecciones rápidas Nuevos lanzamientos @@ -27,12 +30,13 @@ Canciones más reproducidas - Most played artists + Artistas más reproducidos + Álbumes más reproducidos Buscar Buscar en Youtube Music… - Buscar en biblioteca… + Buscar en la biblioteca… Todo Canciones Vídeos @@ -41,6 +45,7 @@ Listas de reproducción Listas de la comunidad Listas destacadas + En marcadores No se han encontrado resultados @@ -66,7 +71,7 @@ Agregar a la biblioteca Quitar de la biblioteca Descargar - Downloading + Descargando Borrar descarga Importar lista de reproducción Agregar a la lista de reproducción @@ -87,7 +92,7 @@ Número de canciones Duración Tiempo de reproducción - Custom order + Orden personalizado ID multimedia @@ -124,26 +129,45 @@ %d canción + %d canciones %d canciones %d artista + %d artistas %d artistas %d álbum + %d álbumes %d álbumes %d lista de reproducción + %d listas de reproducción %d listas de reproducción + + %d semana + %d semanas + %d semanas + + + %d mes + %d meses + %d meses + + + %d año + %d años + %d años + Lista de reproducción importada - Removed \"%s\" from playlist - Playlist synced - Undo + Eliminado \"%s\" de la lista de reproducción + Lista de reproducción sincronizada + Deshacer Letras no encontradas @@ -151,6 +175,7 @@ Al finalizar la canción 1 minuto + %d minutos %d minutos No hay un stream disponible @@ -211,7 +236,7 @@ Caché de canciones Tamaño máximo de la caché Ilimitado - Clear all downloads + Borrar todas las descargas 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 @@ -219,9 +244,9 @@ %s usado Privacidad - Pausar historial de escucha - Clear listen history - Are you sure to clear all listen history? + Pausar historial de escuchas + Borrar historial de escuchas + ¿Estás seguro de que quieres borrar todo el historial de escuchas? Pausar historial de búsquedas Borrar historial de búsquedas ¿Estás seguro de que quieres borrar todo el historial de búsquedas? @@ -230,16 +255,13 @@ Copias de seguridad y restauración Hacer copia de seguridad Restaurar - Choose a csv file from Google Takeout - Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - + Lista de reproducción importada Copia de seguridad creada con éxito No se ha podido crear la copia de seguridad Error al restaurar la copia de seguridad Acerca de Versión de la app + + New version available diff --git a/app/src/main/res/values-fa-rIR/strings.xml b/app/src/main/res/values-fa-rIR/strings.xml index 0dca69e70..152449763 100644 --- a/app/src/main/res/values-fa-rIR/strings.xml +++ b/app/src/main/res/values-fa-rIR/strings.xml @@ -15,8 +15,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -28,6 +30,7 @@ Most played songs Most played artists + Most played albums جستجو @@ -41,6 +44,7 @@ لیست‌پخش‌ها لیست‌پخش‌های انجمن لیست‌پخش‌های ویژه + Bookmarked No results found @@ -138,6 +142,18 @@ %d لیست‌پخش %d لیست‌پخش‌ها + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + لیست‌پخش واردشد @@ -230,16 +246,13 @@ پشتیبان‌گیری و بازگردانی پشتیبان‌گیری بازگردانی - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - پشتیبان باموفقیت ایجادشد پشتیبان ایجاد نشد بازیابی پشتیبان انجام‌نشد درباره نسخه‌ی برنامه + + New version available diff --git a/app/src/main/res/values-fi-rFI/strings.xml b/app/src/main/res/values-fi-rFI/strings.xml index 62e6deafe..2e830364e 100644 --- a/app/src/main/res/values-fi-rFI/strings.xml +++ b/app/src/main/res/values-fi-rFI/strings.xml @@ -15,8 +15,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -28,6 +30,7 @@ Most played songs Most played artists + Most played albums Etsi @@ -41,6 +44,7 @@ Soittolistat Community playlists Featured playlists + Bookmarked No results found @@ -138,6 +142,18 @@ %d playlist %d playlists + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Playlist imported @@ -230,16 +246,13 @@ Backup and restore Backup Restore - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Backup created successfully Couldn\'t create backup Failed to restore backup Tietoa Sovelluksen versio + + New version available diff --git a/app/src/main/res/values-fr-rFR/strings.xml b/app/src/main/res/values-fr-rFR/strings.xml index 7df9f1acf..342970be2 100644 --- a/app/src/main/res/values-fr-rFR/strings.xml +++ b/app/src/main/res/values-fr-rFR/strings.xml @@ -16,8 +16,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -29,6 +31,7 @@ Most played songs Most played artists + Most played albums Recherche @@ -42,6 +45,7 @@ Listes de lecture Community playlists Featured playlists + Bookmarked No results found @@ -143,6 +147,18 @@ %d playlists %d albums + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Playlist imported @@ -236,16 +252,13 @@ Backup and restore Backup Restore - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Backup created successfully Couldn\'t create backup Failed to restore backup À propos Version de l\'application + + New version available diff --git a/app/src/main/res/values-hu/strings.xml b/app/src/main/res/values-hu/strings.xml index fd519f52c..cc35c1078 100644 --- a/app/src/main/res/values-hu/strings.xml +++ b/app/src/main/res/values-hu/strings.xml @@ -13,21 +13,24 @@ - History - Stats - Quick picks - Listen some songs to generate your quick picks - New release albums + Előzmény + Statisztika + Mood and Genres + Account + Gyors választás + Hallgasson meg néhány dalt a gyors választások elkészítéséhez + Új kiadású albumok - Today - Yesterday - This week - Last week + Ma + Tegnap + A héten + Múlt héten - Most played songs - Most played artists + A legtöbbet játszott dalok + A legtöbbet játszott előadók + Most played albums Keresés @@ -41,7 +44,8 @@ Listák Közösségi listák Kiemelt lejátszási listák - No results found + Bookmarked + Nincs találat Saját könyvtárból @@ -49,7 +53,7 @@ Lájkolt dalok Letöltött dalok - The playlist is empty + A lejátszólista üres Újra @@ -66,7 +70,7 @@ Könyvtárhoz ad Eltávolít a könyvtárból Letöltés - Downloading + Letöltés Letöltés eltávolítása Lista importálása Lejátszólistához ad @@ -75,9 +79,9 @@ Újrahív Megosztás Törlés - Remove from history + Előzményből eltávolít Keresés online - Sync + Szinkron. Létrehozás dátuma @@ -87,7 +91,7 @@ Dal szám Hossz Lejátszási idő - Custom order + Egyéni sorrend Média id @@ -138,20 +142,32 @@ %d lejátszólista %d lejátszólista + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Lista importálva - Removed \"%s\" from playlist - Playlist synced - Undo + \"%s\" eltávolítva a lejátszólistából + Lejátszólista szinkronban + Visszavon A dalszöveg nem található - Sleep timer - End of song + Alvás időzítő + A dal vége - 1 minute - %d minutes + 1 perc + %d perc Nincs elérhető adatfolyam Nincs hálózati kapcsolat @@ -172,12 +188,12 @@ Beállítások Megjelenés - Enable dynamic theme + Dinamikus téma bekapcsolás Sötét téma Be Ki Rendszer szerint - Pure black + Tiszta fekete Alapért. nyitott lap A navigációs lapok testreszabása Dalszöveg szöveg pozíció @@ -207,21 +223,21 @@ Tárhely Gyorsítótár - Image Cache - Song Cache - Max cache size - Unlimited - Clear all downloads + Kép gyorsítótár + Dal gyorsítótár + Max. gyors.tár méret + Korlátlan + Minden letöltés törlése Max. kép gyorsítótár Kép gyors.tár törlés Max. dal gyorsítótár - Clear song cache + Dal gyors.tár törlése %s használva Adatvédelem - Pause listen history - Clear listen history - Are you sure to clear all listen history? + A hallgatási előzmények szüneteltetése + Hallgatási előzmény törlése + Biztosan törli az összes hallgatási előzményt? 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? @@ -230,16 +246,13 @@ Bizt.mentés és visszaállítás Bizt.mentés Visszaállítás - Choose a csv file from Google Takeout - Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - + Importált lejátszási lista 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ó + + New version available diff --git a/app/src/main/res/values-id/strings.xml b/app/src/main/res/values-id/strings.xml index dba0e8fcb..95bede859 100644 --- a/app/src/main/res/values-id/strings.xml +++ b/app/src/main/res/values-id/strings.xml @@ -1,4 +1,4 @@ - + Beranda Lagu @@ -14,8 +14,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -27,6 +29,7 @@ Most played songs Most played artists + Most played albums Caru @@ -40,6 +43,7 @@ Daftar putar Daftar putar komunitas Daftar putar unggulan + Bookmarked No results found @@ -133,6 +137,15 @@ %d daftar putar + + %d weeks + + + %d months + + + %d years + Daftar putar dipindahkan @@ -224,16 +237,13 @@ Cadangkan dan pulihkan Cadangkan Pulihkan - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Cadangan berhasil dibuat Tidak dapat membuat cadangan Gagal untuk memulihkan cadangan Tentang Versi aplikasi + + New version available diff --git a/app/src/main/res/values-it/strings.xml b/app/src/main/res/values-it/strings.xml index cc1935e48..b82d543f7 100644 --- a/app/src/main/res/values-it/strings.xml +++ b/app/src/main/res/values-it/strings.xml @@ -1,4 +1,4 @@ - + Home Brani @@ -10,24 +10,28 @@ %d selezionato %d selezionati + %d selezionati - History - Stats - Quick picks - Listen some songs to generate your quick picks - New release albums + Cronologia + Statistiche + Mood e generi + Account + Scelte rapide + Ascolta alcuni brani per generare le tue scelte rapide + Nuovi album in uscita - Today - Yesterday - This week - Last week + Oggi + Ieri + Questa settimana + Settimana scorsa - Most played songs - Most played artists + Brani più ascoltati + Artisti più ascoltati + Album più ascoltati Cerca @@ -39,9 +43,10 @@ Album Artisti Playlist - Playlist della comunità + Playlist della community Playlist in rilievo - No results found + Bookmarked + Nessun risultato trovato Dalla tua libreria @@ -49,7 +54,7 @@ Brani piaciuti Brani scaricati - The playlist is empty + La playlist è vuota Riprova @@ -59,25 +64,25 @@ Dettagli Modifica - Apri la radio + Apri radio Riproduci Riproduci come successiva - Aggiungi alla coda - Aggiungi alla libreria - Rimuovi dalla libreria + Metti in coda + Aggiungi a libreria + Rimuovi da libreria Scarica - Downloading + In scaricamento Rimuovi scaricamento Importa playlist - Aggiungi alla playlist + Aggiungi a playlist Mostra artista Mostra album - Riottieni + Aggiorna Condividi Elimina - Remove from history + Rimuovi da cronologia Cerca online - Sync + Sincronizza Data di aggiunta @@ -87,7 +92,7 @@ Numero brani Durata Numero di riproduzioni - Custom order + Ordine personalizzato ID media @@ -105,8 +110,8 @@ Cerca testo Modifica brano - Titolo - Artista + Titolo del brano + Artisti del brano Il titolo del brano non può essere vuoto. L\'artista del brano non può essere vuoto. Salva @@ -125,42 +130,62 @@ %d brano %d brani + %d brani %d artista %d artisti + %d artisti %d album %d album + %d album %d playlist %d playlist + %d playlist + + + %d settimana + %d settimane + %d settimane + + + %d mese + %d mesi + %d mesi + + + %d anno + %d anni + %d anni Playlist importata - Removed \"%s\" from playlist - Playlist synced - Undo + Rimosso \"%s\" da playlist + Playlist sincronizzata + Annulla Testo non trovato - Sleep timer - End of song + Timer per il sonno + Fine del brano - 1 minute - %d minutes + 1 minuto + %d minuti + %d minuti Stream non disponibile - Nessuna connessione a internet presente + Nessuna connessione di rete Tempo scaduto Errore sconosciuto Mi piace - Non mi piace più + Rimuovi mi piace Tutti i brani @@ -172,14 +197,14 @@ Impostazioni Aspetto - Enable dynamic theme + Abilita il tema dinamico Tema scuro Attivato Disattivato Segui sistema - Pure black + Nero Scheda principale predefinita - Personalizza schede di navigazione + Personalizza le schede di navigazione Posizione del testo dei brani Sinistra Centro @@ -191,7 +216,7 @@ Paese predefinito dei contenuti Predefinito di sistema Attiva proxy - Tipo di proxy + Tipologia del proxy URL proxy Riavvia l\'app per applicare le modifiche @@ -201,45 +226,42 @@ Alta Bassa Coda persistente - Salta silenzio + Salta il silenzio Normalizzazione dell\'audio Equalizzatore Archiviazione Cache - Image Cache - Song Cache - Max cache size - Unlimited - Clear all downloads + Cache delle immagini + Cache dei brani + Dimensione massima della cache + Illimitata + Cancella tutti i download Grandezza massima della cache delle immagini - Pulisci cache delle immagini + Pulisci la cache delle immagini Grandezza massima della cache dei brani - Clear song cache - %s usato + Pulisci la cache dei brani + %s usati Privacy - Pause listen history - Clear listen history - Are you sure to clear all listen history? + Sospendi la cronologia degli ascolti + Cancella la cronologia degli ascolti + Sei sicuro di voler cancellare la cronologia degli ascolti? 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 e ripristino Backup Ripristina - Choose a csv file from Google Takeout - Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - + Playlist importata Backup creato con successo - Impossibile fare il backup + Impossibile eseguire il backup Impossibile eseguire il ripristino dal backup Informazioni Versione dell\'app + + New version available diff --git a/app/src/main/res/values-ja-rJP/strings.xml b/app/src/main/res/values-ja-rJP/strings.xml index 9b0815e4e..f95b6b252 100644 --- a/app/src/main/res/values-ja-rJP/strings.xml +++ b/app/src/main/res/values-ja-rJP/strings.xml @@ -1,4 +1,4 @@ - + ホーム @@ -12,21 +12,24 @@ - History - Stats - Quick picks - Listen some songs to generate your quick picks - New release albums + 履歴 + 統計 + ムードとジャンル + アカウント + おすすめ + 何曲か再生するとおすすめを生成します + 新作アルバム - Today - Yesterday - This week - Last week + 今日 + 昨日 + 今週 + 先週 - Most played songs - Most played artists + 最も再生された曲 + 最も再生されたアーティスト + 最も再生されたアルバム 検索 @@ -39,8 +42,9 @@ アーティスト プレイリスト コミュニティのプレイリスト - 注目のプレイリスト - No results found + おすすめのプレイリスト + Bookmarked + 見つかりませんでした ライブラリから @@ -48,7 +52,7 @@ いいねした曲 ダウンロードした曲 - The playlist is empty + プレイリストが空です 再試行 @@ -56,7 +60,7 @@ シャッフル - Details + 詳細 編集 ラジオを再生 再生 @@ -65,18 +69,18 @@ ライブラリに追加 ライブラリから削除 ダウンロード - Downloading - ダウンロードを削除 + ダウンロード中 + ダウンロード削除 プレイリストをインポート プレイリストに追加 - アーティストを見る - アルバムを見る + アーティスト表示 + アルバムを表示 再取得 共有 削除 - Remove from history + 履歴から削除 オンラインで検索 - Sync + 同期 追加日時 @@ -86,7 +90,7 @@ 曲数 長さ 再生時間 - Custom order + カスタム メディアID @@ -95,7 +99,7 @@ ビットレート サンプリングレート ラウドネス - ボリューム + 音量 ファイルサイズ 不明 クリップボードにコピー @@ -106,8 +110,8 @@ 曲を編集 曲名 曲のアーティスト - 曲名は空白にできません。 - 曲のアーティストは空白にできません。 + 曲名は空にできません。 + 曲のアーティストは空にできません。 保存 プレイリストを選択 @@ -133,24 +137,33 @@ %d プレイリスト + + %d週間 + + + %dか月 + + + %d年 + プレイリストをインポートしました - Removed \"%s\" from playlist - Playlist synced - Undo + プレイリストから「%s」を削除しました + プレイリストが同期されました + 元に戻す 歌詞が見つかりません - Sleep timer - End of song + スリープタイマー + 曲の終わり - %d minutes + %d 分 ストリームが利用できません ネットワーク接続がありません タイムアウトしました - 不明なエラーです + 不明なエラー いいね @@ -166,7 +179,7 @@ 設定 外観 - Enable dynamic theme + 動的なテーマを有効化 ダークテーマ オン オフ @@ -181,59 +194,56 @@ コンテンツ ログイン - デフォルトのコンテンツの言語 - デフォルトのコンテンツの国 + コンテンツの既定の言語 + コンテンツの既定の国 システムに従う プロキシを有効化 プロキシのタイプ プロキシのURL 再起動して反映 - プレイヤーとオーディオ - オーディオ品質 + プレイヤーと音声 + 音声品質 自動 再生キューを保持 無音部分をスキップ - オーディオノーマライゼーション + 音声の正規化 イコライザー ストレージ キャッシュ - Image Cache - Song Cache - Max cache size - Unlimited - Clear all downloads + 画像のキャッシュ + 曲のキャッシュ + 最大キャッシュサイズ + 無制限 + すべてのダウンロードを削除 画像の最大キャッシュサイズ 画像のキャッシュを削除 曲の最大キャッシュサイズ - Clear song cache + 曲のキャッシュを削除 %s 使用中 プライバシー - Pause listen history - Clear listen history - Are you sure to clear all listen history? - 履歴の記録を一時停止 - 履歴を削除 + 再生履歴を一時停止 + 再生履歴を削除 + すべての再生履歴を削除しますか? + 検索履歴の記録を一時停止 + 検索履歴を削除 すべての検索履歴を削除しますか? - 酷狗からの歌詞取得を有効化 + 酷狗 (Kugou) からの歌詞取得を有効化 - バックアップとリストア + バックアップと復元 バックアップ - リストア - Choose a csv file from Google Takeout - Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - + 復元 + インポートしたプレイリスト バックアップの作成に成功しました バックアップを作成できませんでした - バックアップのリストアに失敗しました + バックアップの復元に失敗しました アプリについて アプリのバージョン + + New version available diff --git a/app/src/main/res/values-ko-rKR/strings.xml b/app/src/main/res/values-ko-rKR/strings.xml index 1c51a9aa2..62486122a 100644 --- a/app/src/main/res/values-ko-rKR/strings.xml +++ b/app/src/main/res/values-ko-rKR/strings.xml @@ -1,4 +1,4 @@ - + Home 노래 @@ -14,8 +14,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -27,6 +29,7 @@ Most played songs Most played artists + Most played albums 검색 @@ -40,6 +43,7 @@ 플레이리스트 Community playlists Featured playlists + Bookmarked No results found @@ -133,6 +137,15 @@ %d playlists + + %d weeks + + + %d months + + + %d years + Playlist imported @@ -224,16 +237,13 @@ Backup and restore Backup Restore - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Backup created successfully Couldn\'t create backup Failed to restore backup 정보 앱 버전 + + New version available diff --git a/app/src/main/res/values-ml-rIN/strings.xml b/app/src/main/res/values-ml-rIN/strings.xml index 9205fb986..02283bfca 100644 --- a/app/src/main/res/values-ml-rIN/strings.xml +++ b/app/src/main/res/values-ml-rIN/strings.xml @@ -15,8 +15,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -28,6 +30,7 @@ Most played songs Most played artists + Most played albums തിരയുക @@ -41,6 +44,7 @@ പ്ലേലിസ്റ്റുകൾ കമ്മ്യൂണിറ്റി പ്ലേലിസ്റ്റുകൾ തിരഞ്ഞെടുത്ത പ്ലേലിസ്റ്റുകൾ + Bookmarked No results found @@ -138,6 +142,18 @@ %d പ്ലേലിസ്റ്റ് %d പ്ലേലിസ്റ്റുകൾ + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + പ്ലേലിസ്റ്റ് ഇറക്കുമതി ചെയ്തു @@ -230,16 +246,13 @@ ബാക്കപ്പും വീണ്ടെടുക്കലും ബാക്കപ്പ് വീണ്ടെടുക്കൽ - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - ബാക്കപ്പ് സൃഷ്‌ടിച്ചു ബാക്കപ്പ് സൃഷ്ടിക്കാൻ കഴിഞ്ഞില്ല ബാക്കപ്പ് പുനഃസ്ഥാപിക്കാൻ കഴിഞ്ഞില്ല കുറിച്ച് അപ്ലിക്കേഷൻ പതിപ്പ് + + New version available diff --git a/app/src/main/res/values-nl/strings.xml b/app/src/main/res/values-nl/strings.xml index 30fe5e150..cee9296e8 100644 --- a/app/src/main/res/values-nl/strings.xml +++ b/app/src/main/res/values-nl/strings.xml @@ -15,6 +15,8 @@ Geschiedenis Statistieken + Stemmingen en genres + Account Snelle keuzes Beluister een aantal nummers om je snelle keuzes te genereren Nieuwe albums @@ -28,11 +30,12 @@ Meest afgespeelde nummers Meest afgespeelde artiesten + Meest afgespeelde artiesten Zoeken Zoeken via YouTube Music… - Zoeken in bibliotheek… + Zoeken in bibliotheek Alles Nummers Videos @@ -41,6 +44,7 @@ Afspeellijsten Afspeellijsten van de community Voorgestelde afspeellijsten + Bookmarked Geen resultaten gevonden @@ -138,6 +142,18 @@ %d afspeellijst %d afspeellijsten + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Afspeellijst geimporteerd @@ -230,16 +246,13 @@ Backup en herstel Backup Herstellen - Kies een csv-bestand van Google Takeout Afspeellijst geimporteerd - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Back-up succesvol gemaakt. Back-up maken mislukt. Fout bij terugzetten back-up. Over App versie + + New version available diff --git a/app/src/main/res/values-or-rIN/strings.xml b/app/src/main/res/values-or-rIN/strings.xml index e1c3fc854..940e2e4c2 100644 --- a/app/src/main/res/values-or-rIN/strings.xml +++ b/app/src/main/res/values-or-rIN/strings.xml @@ -15,8 +15,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -28,6 +30,7 @@ Most played songs Most played artists + Most played albums ସନ୍ଧାନ କରନ୍ତୁ @@ -41,6 +44,7 @@ ପ୍ଲେଲିଷ୍ଟ ଗୁଡ଼ିକ ସମ୍ପ୍ରଦାୟ ପ୍ଲେଲିଷ୍ଟଗୁଡିକ ବୈଶିଷ୍ଟ୍ୟଯୁକ୍ତ ତାଲିକାଗୁଡ଼ିକ + Bookmarked No results found @@ -138,6 +142,18 @@ %d playlist %d playlists + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + ପ୍ଲେଲିଷ୍ଟ ଆମଦାନୀ ହୋଇଛି @@ -230,16 +246,13 @@ ନକଲ ସଂରକ୍ଷଣ ଏବଂ ପୁନରୁଦ୍ଧାର କରନ୍ତୁ ନକଲ ସଂରକ୍ଷଣ ପୁନରୁଦ୍ଧାର କରନ୍ତୁ - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - ବ୍ୟାକଅପ୍ ସଫଳତାର ସହିତ ସୃଷ୍ଟି ହେଲା ବ୍ୟାକଅପ୍ ସୃଷ୍ଟି କରିପାରିବ ନାହିଁ ବ୍ୟାକଅପ୍ ପୁନରୁଦ୍ଧାର କରିବାରେ ବିଫଳ ବିଷୟରେ ଆପ୍ ସଂସ୍କରଣ + + New version available diff --git a/app/src/main/res/values-pa/strings.xml b/app/src/main/res/values-pa/strings.xml index 6d657d45d..716626bb7 100644 --- a/app/src/main/res/values-pa/strings.xml +++ b/app/src/main/res/values-pa/strings.xml @@ -15,8 +15,10 @@ ਅਤੀਤ ਅੰਕੜੇ + Mood and Genres + Account ਉਂਗਲਾਂ ਤੇ ਚੁਣੇ ਗੀਤ - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks ਨਵੀਆਂ ਜਾਰੀ ਐਲਬਮਾਂ @@ -28,6 +30,7 @@ ਵੱਧ ਸੁਣੇ ਗਏ ਗੀਤ Most played artists + Most played albums ਖੋਜੋ @@ -41,6 +44,7 @@ ਪਲੇਲਿਸਟਾਂ ਕਮਿਊਨਿਟੀ ਪਲੇਲਿਸਟਾਂ ਪ੍ਰਦਰਸ਼ਿਤ ਪਲੇਲਿਸਟਾਂ + Bookmarked ਕੋਈ ਨਤੀਜੇ ਨਹੀਂ ਲੱਭੇ @@ -138,6 +142,18 @@ %d ਪਲੇਲਿਸਟ %d ਪਲੇਲਿਸਟਾਂ + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + ਪਲੇਲਿਸਟ ਅਯਾਤ ਕੀਤੀ ਗਈ @@ -230,16 +246,13 @@ ਬੈਕਅੱਪ ਅਤੇ ਰੀਸਟੋਰ ਬੈਕਅੱਪ ਰੀਸਟੋਰ - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - ਬੈਕਅੱਪ ਸਫਲਤਾਪੂਰਵਕ ਬਣਾਇਆ ਗਿਆ ਬੈਕਅੱਪ ਨਹੀਂ ਬਣਾਇਆ ਜਾ ਸਕਿਆ ਬੈਕਅੱਪ ਰੀਸਟੋਰ ਕਰਨਾ ਅਸਫਲ ਰਿਹਾ ਦੇ ਬਾਰੇ ਐਪ ਸੰਸਕਰਣ + + New version available diff --git a/app/src/main/res/values-pl/strings.xml b/app/src/main/res/values-pl/strings.xml new file mode 100644 index 000000000..234a85b47 --- /dev/null +++ b/app/src/main/res/values-pl/strings.xml @@ -0,0 +1,276 @@ + + + Główna + Utwory + Wykonawcy + Albumy + Playlisty + + + + %d zaznaczony + %d zaznaczone + %d zaznaczonych + %d zaznaczonych + + + + Historia + Statystyki + Nastroje i gatunki + Konto + Szybki wybór + Słuchaj utworów, aby wygenerować szybkie wybory + Nowo wydane albumy + + + Dzisiaj + Wczoraj + Ten tydzień + Ostatni tydzień + + + Najczęściej słuchane utwory + Najczęściej słuchani wykonawcy + Najczęściej słuchane albumy + + + Szukaj + Szukaj w YouTube Music… + Szukaj w bibliotece… + Wszystko + Utwory + Filmy + Albumy + Wykonawcy + Playlisty + Playlisty tworzone przez społeczność + Polecane playlisty + Ulubione + Brak wyników + + + Z twojej biblioteki + + + Polubione utwory + Pobrane utwory + Playlista jest pusta + + + Spróbuj ponownie + Radio + Losowo + + + Szczegóły + Edytuj + Włącz radio + Odtwórz + Odtwórz jako następny + Dodaj do kolejki + Dodaj do biblioteki + Usuń z biblioteki + Pobierz + Pobieranie + Usuń z pobranych + Importuj playlistę + Dodaj do playlisty + Pokaż wykonawcę + Pokaż album + Odśwież + Udostępnij + Usuń + Usuń z historii + Szukaj online + Synchronizuj + + + Data dodania + Nazwa + Wykonawca + Rok + Liczba utworów + Długość + Długość utworu + Niestandardowa kolejność + + + ID media + Typ MIME + Kodeki + Przepływność + Częstotliwość próbkowania + Natężenie + Głośność + Rozmiar pliku + Nieznane + Skopiowano do schowka + + Edytuj tekst + Szukaj tekstu + + Edytuj utwór + Tytuł utworu + Wykonawcy utworu + Tytuł utworu nie może być pusty. + Wykonawca utworu nie może być pusty. + Zapisz + + Wybierz playlistę + Edytuj playlistę + Utwórz playlistę + Nazwa playlisty + Nazwa playlisty nie może być pusta. + + Edytuj wykonawcę + Nazwa wykonawcy + Nazwa wykonawcy nie może być pusta. + + + + %d utwór + %d utwory + %d utworów + %d utworów + + + %d wykonawca + %d wykonawców + %d wykonawców + %d wykonawców + + + %d album + %d albumy + %d albumów + %d albumów + + + %d playlista + %d playlisty + %d playlist + %d playlist + + + %d tydzień + %d tygodnie + %d tygodni + %d tygodni + + + %d miesiąc + %d miesiące + %d miesięcy + %d miesięcy + + + %d rok + %d lata + %d lat + %d lat + + + + Playlista zaimportowana + Usunięto \"%s\" z playlisty + Playlista zsynchronizowana + Cofnij + + + Nie znaleziono tekstu + Wyłącznik czasowy + Koniec utworu + + 1 minuta + %d minuty + %d minut + %d minut + + Brak dostępnego źródła + Brak połączenia z internetem + Upłynął limit czasu + Nieznany błąd + + + Polub + Usuń polubienie + + + Wszystkie utwory + Wyszukane utwory + + + Odtwarzacz + + + Ustawienia + Wygląd + Włącz motyw dynamiczny + Ciemny motyw + Włącz + Wyłącz + Zgodnie z systemem + Czysta czerń + Domyślnie otwarta zakładka + Modyfikuj zakładki nawigacji + Położenie tekstu utworu + Z lewej + Na środku + Z prawej + + Zawartość + Zaloguj się + Domyślny język zawartości + Domyślny kraj zawartości + Domyślny systemowy + Włącz proxy + Typ proxy + URL proxy + Uruchom ponownie, aby zastosować zmiany + + Odtwarzacz i audio + Jakość audio + Automatyczna + Wysoka + Niska + Trwała kolejka + Pomiń ciszę + Normalizacja audio + Korektor graficzny + + Pamięć + Cache + Cache obrazów + Cache utworów + Maksymalny rozmiar cache + Nieskończony + Wyczyść pobrane + Maksymalny rozmiar cache obrazów + Wyczyść cache obrazów + Maksymalny rozmiar cache utworów + Wyczyść cache utworów + %s w użyciu + + Prywatność + Wstrzymaj historię odtwarzania + Wyczyść historię odtwarzania + Czy na pewno chcesz wyczyścić całą historię odtwarzania? + Wstrzymaj historię wyszukiwania + Wyczyść historię wyszukiwania + Czy na pewno chcesz wyczyścić całą historię wyszukiwania? + Pobieraj teksty utworów z KuGou + + Kopia zapasowa i przywracanie + Utwórz kopię zapasową + Przywróć + Zaimportowane playlisty + Kopia zapasowa utworzona pomyślnie + Nie udało się stworzyć kopii zapasowej + Nie udało się przywrócić kopii zapasowej + + O aplikacji + Wersja aplikacji + + Dostępna nowa wersja + diff --git a/app/src/main/res/values-pt-rBR/strings.xml b/app/src/main/res/values-pt-rBR/strings.xml index 494be8452..e671ed8ba 100644 --- a/app/src/main/res/values-pt-rBR/strings.xml +++ b/app/src/main/res/values-pt-rBR/strings.xml @@ -1,4 +1,4 @@ - + Início Músicas @@ -10,13 +10,16 @@ %d selecionada %d selecionadas + %d selecionadas History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release álbuns @@ -28,6 +31,7 @@ Most played songs Most played artists + Most played álbuns Pesquisar @@ -41,6 +45,7 @@ Playlists Playlists da comunidade Playlists em destaque + Bookmarked No results found @@ -125,18 +130,37 @@ %d música %d músicas + %d músicas %d artista %d artistas + %d artistas %d álbum %d álbuns + %d álbuns %d playlist %d playlists + %d playlists + + + %d week + %d weeks + %d weeks + + + %d month + %d months + %d months + + + %d year + %d years + %d years @@ -150,8 +174,9 @@ Sleep timer End of song - 1 minute + %d minute %d minutes + %d minutes Nenhum canal de reprodução disponível Sem conexão com à internet @@ -230,16 +255,13 @@ Backup e restauração Backup Restaurar - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Backup criado com sucesso Não foi possível criar o backup Falha ao restaurar o backup Sobre Versão do aplicativo + + New version available diff --git a/app/src/main/res/values-ru-rRU/strings.xml b/app/src/main/res/values-ru-rRU/strings.xml index 95624559a..bae17e29e 100644 --- a/app/src/main/res/values-ru-rRU/strings.xml +++ b/app/src/main/res/values-ru-rRU/strings.xml @@ -17,8 +17,10 @@ История Статистика + Настроение и жанры + Аккаунт Быстрый выбор - Listen some songs to generate your quick picks + Послушайте несколько песен, чтобы создать ваш быстрый выбор Новые релизы альбомов @@ -30,6 +32,7 @@ Самые популярные песни Самые популярные исполнители + Самые популярные альбомы Поиск @@ -43,6 +46,7 @@ Плейлисты Плейлисты сообщества Избранные плейлисты + Добавлено в закладки Результаты не найдены @@ -148,11 +152,29 @@ %d плейлистов %d плейлистов + + %d неделя + %d недели + %d недель + %d недель + + + %d месяц + %d месяца + %d месяцев + %d месяцев + + + %d год + %d года + %d лет + %d лет + Плейлист импортирован «%s» удалено из списка воспроизведения - Playlist synced + Плейлист синхронизирован Отменить @@ -242,16 +264,13 @@ Резервное копирование Создать резервную копию Восстановить из резервной копии - Выбрать csv-файл из Google Takeout Импортированный плейлист - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Резервная копия создана успешно Не удалось создать резервную копию Не удалось восстановить резервную копию О приложении Версия приложения + + Доступна новая версия diff --git a/app/src/main/res/values-sv-rSE/strings.xml b/app/src/main/res/values-sv-rSE/strings.xml index 24522ccdf..eac93c378 100644 --- a/app/src/main/res/values-sv-rSE/strings.xml +++ b/app/src/main/res/values-sv-rSE/strings.xml @@ -15,8 +15,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -28,6 +30,7 @@ Most played songs Most played artists + Most played albums Sök @@ -41,6 +44,7 @@ Spellistor Community playlists Featured playlists + Bookmarked No results found @@ -138,6 +142,18 @@ %d playlist %d playlists + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Playlist imported @@ -230,16 +246,13 @@ Backup and restore Backup Restore - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Backup created successfully Couldn\'t create backup Failed to restore backup Om App version + + New version available diff --git a/app/src/main/res/values-tr/strings.xml b/app/src/main/res/values-tr/strings.xml index 2a0169930..42441d045 100644 --- a/app/src/main/res/values-tr/strings.xml +++ b/app/src/main/res/values-tr/strings.xml @@ -15,6 +15,8 @@ Geçmiş İstatistikler + Ruh Hali ve Türler + Hesap Hızlı seçimler Hızlı seçimlerinizi oluşturmak için birkaç şarkı dinleyin Yeni çıkan albümler @@ -28,6 +30,7 @@ En çok dinlenen şarkılar En çok dinlenen sanatçılar + En çok dinlenen albümler Arama @@ -41,6 +44,7 @@ Çalma Listeleri Topluluk çalma listeleri Öne çıkan listeler + Favoriler Sonuç bulunamadı @@ -61,7 +65,7 @@ Düzenle Radyo başlat Çal - Sonraki olarak çal + Bir sonra çal Sıraya ekle Kütüphaneye ekle Kütüphaneden kaldır @@ -138,11 +142,23 @@ %d çalma listesi %d çalma listesi + + %d hafta + %d hafta + + + %d ay + %d ay + + + %d yıl + %d yıl + Çalma listesi içe aktarıldı - Çalma listesinden \"%s\" kaldırıldı - Playlist synced + \"%s\" çalma listesinden kaldırıldı + Çalma listesi eşitlendi Geri al @@ -230,16 +246,13 @@ Yedekleme ve geri yükleme Yedekle Geri yükle - Google Takeout\'tan bir csv dosyası seçin Çalma listesi içe aktarıldı - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Yedekleme başarıyla oluşturuldu Yedekleme oluşturulamadı Yedekleme geri yüklenemedi Hakkında Uygulama sürümü + + Yeni sürüm mevcut diff --git a/app/src/main/res/values-uk-rUA/strings.xml b/app/src/main/res/values-uk-rUA/strings.xml index ae3f24e7c..686cf8591 100644 --- a/app/src/main/res/values-uk-rUA/strings.xml +++ b/app/src/main/res/values-uk-rUA/strings.xml @@ -17,8 +17,10 @@ Історія Статистика + Настрій та жанри + Акаунт Швидкий вибір - Listen some songs to generate your quick picks + Послухайте кілька пісень, щоб створити ваш швидкий вибір Нові релізи альбомів @@ -30,6 +32,7 @@ Найпопулярніші пісні Найпопулярніші виконавці + Найпопулярніші альбоми Пошук @@ -43,6 +46,7 @@ Плейлисти Плейлисти спільноти Обрані плейлисти + Додано в закладки Результатів не знайдено @@ -148,11 +152,29 @@ %d плейлистів %d плейлистів + + %d тиждень + %d тижні + %d тижнів + %d тижнів + + + %d місяць + %d місяці + %d місяців + %d місяців + + + %d рік + %d роки + %d років + %d років + Плейлист імпортовано «%s» видалено зі списку відтворення - Playlist synced + Плейлист синхронізовано Скасувати @@ -242,16 +264,13 @@ Резервне копіювання Створити резервну копію Відновити з резервної копії - Вибрати csv-файл із Google Takeout Імпортований плейлист - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Резервну копію створено успішно Не вдалося створити резервну копію Не вдалося відновити з резервної копії Про програму Версія застосунку + + Доступна нова версія diff --git a/app/src/main/res/values-vn/strings.xml b/app/src/main/res/values-vn/strings.xml new file mode 100644 index 000000000..659aff00b --- /dev/null +++ b/app/src/main/res/values-vn/strings.xml @@ -0,0 +1,257 @@ + + + Trang chủ + Bài hát + Tác giả + Albums + Danh sách phát + + + + Đã chọn %d + + + + Lịch sử + Thống kê + Tâm trạng và thể loại + Tài Khoản + Chọn nhanh + Nghe các bài hát để tạo danh sách chọn nhanh của bạn + Các albums mới phát hành + + + Hôm nay + Hôm qua + Tuần này + Tuần trước + + + Bài hát được nghe nhiều nhất + Tác giả được nghe nhiều nhất + Album được nghe nhiều nhất + + + Tìm kiếm + Tìm kiếm YouTube Music… + Tìm kiếm thư viện… + Tất cả + Bài hát + Videos + Albums + Tác giả + Danh sách phát + Danh sách phát cộng đồng + Danh sách phát tiêu biểu + Bookmarked + Không tìm thấy kết quả + + + Từ thư viện của bạn + + + Bài hát được yêu thích + Bài hát được tải về + Danh sách phát trống + + + Thử lại + Đài + Trộn + + + Chi tiết + Tuỳ chỉnh + Bắt đầu nghe đài + Chạy + Chạy bài tiếp theo + Thêm vào hàng chờ + Thêm vào thư viện + Xoá khỏi thư viện + Tải xuống + Đang tải xuống + Xoá tải + Thêm danh sách phát + Thêm vào danh sách phát + Xem tác giả + Xem album + Làm mới + Chia sẻ + Xoá + Xoá khỏi lịch sử + Tìm kiếm trực tuyến + Đồng bộ + + + Ngày thêm vào + Tên + Tác giả + Năm + Số lượng bài hát + Độ dài + Thời gian phát + Tuỳ chỉnh bộ lọc + + + Media id + MIME type + Codecs + Bitrate + Sample rate + Loudness + Volume + File size + Unknown + Copied to clipboard + + Sửa lời bài hát + Tìm kiếm lời bài hát + + Sửa bài hát + Tiêu đề bài hát + Tác giả bài hát + Tiêu đề không được để trống. + Tác giả không được để trống. + Lưu lại + + Lựa chọn danh sách phát + Tuỳ chọn danh sách phát + Tạo danh sách phát + Tên danh sách phát + Tên danh sách phát không được để trống. + + Tuỳ chỉnh tác giả + Tên tác giả + Tên tác giả không được để trống. + + + + Bài hát %d + Các bài hát %d + + + Tác giả %d + Các tác giả %d + + + %d album + %d albums + + + Danh sách phát %d + Các danh sách phát %d + + + %d tuần + %d tuần + + + %d tháng + %d tháng + + + %d năm + %d năm + + + + Danh sách phát đã được thêm vào + Đã xoá \"%s\" từ danh sách phát + Danh sách phát được đồng bộ + Làm lại + + + Không tìm thấy lời bài hát + Hẹn giờ + Kết thúc bài hát + + 1 phút + %d phút + + Không có stream + Không có kết nối mạng + Hết giờ + Lỗi không được xác định + + + Thích + Bỏ thích + + + Tất cả bài hát + Những bài hát được tìm kiếm + + + Music Player + + + Cài đặt + Hiện thị + Bật chủ đề theo màu sắc + Chủ đề tối + Bật + Tắt + Theo hệ thống + Tối hoàn toàn + Tab mặc định + Tuỳ chỉnh tab tuỳ biền + Vị trí lời bài hát + Trái + Canh giữa + Phải + + Nội dung + Đăng nhập + Ngôn ngữ nội dung mặc định + Quốc gia nội dung mặc định + Hệ thống mặc định + Bật proxy + Loại Proxy + Liên kết Proxy + Khởi động lại để áp dụng + + Player and audio + Chất lượng âm thanh + Tự động + Cao + Thấp + Hàng đợi liên tục + bỏ qua đoạn im lặng + Chuẩn hoá âm thanh + Bộ chỉnh âm + + Lưu trữ + Cache + Cache hình ảnh + Cache bài hát + Tối đa dung lượng cache + Không giới hạn + Xoá tất cả đã tải xuống + Dung lượng cache hình ảnh tối đa + Xoá hết cache hình ảnh + Dung lượng cache bài hát tối đa + Xoá hết cache nhạc + Đã sử dụng %s + + Riêng tư + Dừng lịch sử nghe + Xoá hết lịch sử nghe + Bạn có chắc muốn xoá hết lịch sử nghe? + Tạm dừng lịch sử tìm kiếm + Xoá hết lịch sử tìm kiếm + Bạn có chắc muốn xoá hết lịch sử tìm kiếm? + Kích hoạt nhà cung cấp lời bài hát KuGou + + Sao lưu và khôi phục + Sao lưu + Khôi phục + Đã thêm danh sách phát + Sao lưu thành công + Không thể sao lưu + Khôi phục thất bại + + Tác giả + Phiên bản ứng dụng + + New version available + diff --git a/app/src/main/res/values-zh-rCN/strings.xml b/app/src/main/res/values-zh-rCN/strings.xml index a8d24ed0e..423d76a33 100644 --- a/app/src/main/res/values-zh-rCN/strings.xml +++ b/app/src/main/res/values-zh-rCN/strings.xml @@ -14,8 +14,10 @@ 历史记录 统计 + Mood and Genres + Account 歌曲快选 - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks 新专辑 @@ -27,6 +29,7 @@ 最常播放 Most played artists + Most played albums 搜索 @@ -40,6 +43,7 @@ 播放列表 社区播放列表 精选播放列表 + Bookmarked 找不到结果 @@ -133,6 +137,15 @@ %d 个播放列表 + + %d weeks + + + %d months + + + %d years + 已导入此播放列表 @@ -224,16 +237,13 @@ 备份与还原 备份 还原 - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - 成功新建备份 无法新建备份 无法还原备份 关于 应用版本 + + New version available diff --git a/app/src/main/res/values-zh-rTW/strings.xml b/app/src/main/res/values-zh-rTW/strings.xml index a4fd3e908..2cd8eadef 100755 --- a/app/src/main/res/values-zh-rTW/strings.xml +++ b/app/src/main/res/values-zh-rTW/strings.xml @@ -14,6 +14,8 @@ 歷史記錄 統計 + 情境與類型 + 帳號 歌曲快選 聽一些音樂讓我們知道您的喜好 新專輯 @@ -27,6 +29,7 @@ 最常播放的歌曲 最常播放的藝人 + 最常播放的專輯 搜尋 @@ -40,6 +43,7 @@ 播放清單 社群播放清單 精選播放清單 + 收藏 找不到結果 @@ -133,6 +137,15 @@ %d 個播放清單 + + %d 週 + + + %d 個月 + + + %d 年 + 已匯入此播放清單 @@ -224,15 +237,13 @@ 備份與還原 備份 還原 - 選擇從 Google Takeout 匯出的 csv 檔 已匯入的播放清單 - - 已匯入「%s」,有%d首歌曲 - 成功建立備份 無法建立備份 無法還原備份 關於 應用程式版本 + + 發現新版本 diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 3ec15c363..eda7659dc 100755 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -14,8 +14,10 @@ History Stats + Mood and Genres + Account Quick picks - Listen some songs to generate your quick picks + Listen to songs to generate your quick picks New release albums @@ -27,6 +29,7 @@ Most played songs Most played artists + Most played albums Search @@ -40,6 +43,7 @@ Playlists Community playlists Featured playlists + Bookmarked No results found @@ -137,6 +141,18 @@ %d playlist %d playlists + + %d week + %d weeks + + + %d month + %d months + + + %d year + %d years + Playlist imported @@ -229,16 +245,13 @@ Backup and restore Backup Restore - Choose a csv file from Google Takeout Imported playlist - - Imported \"%s\" with %d song - Imported \"%s\" with %d songs - Backup created successfully Couldn\'t create backup Failed to restore backup About App version + + New version available diff --git a/app/src/main/res/xml-v25/shortcuts.xml b/app/src/main/res/xml-v25/shortcuts.xml new file mode 100644 index 000000000..4d027a670 --- /dev/null +++ b/app/src/main/res/xml-v25/shortcuts.xml @@ -0,0 +1,43 @@ + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/build.gradle.kts b/build.gradle.kts index 42d159bfa..c5b81cacb 100755 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -12,6 +12,9 @@ buildscript { dependencies { classpath(libs.gradle) classpath(kotlin("gradle-plugin", libs.versions.kotlin.get())) + classpath(libs.google.services) + classpath(libs.firebase.crashlytics.plugin) + classpath(libs.firebase.perf.plugin) } } diff --git a/fastlane/metadata/android/en-US/changelogs/17.txt b/fastlane/metadata/android/en-US/changelogs/17.txt new file mode 100644 index 000000000..70719318c --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/17.txt @@ -0,0 +1,5 @@ +- Login support +- Mood & Genres +- Better stats screen +- Bookmark artists +- Minor enhancement and bug fixes \ No newline at end of file diff --git a/fastlane/metadata/android/es/full_description.txt b/fastlane/metadata/android/es/full_description.txt new file mode 100644 index 000000000..9b26f0a96 --- /dev/null +++ b/fastlane/metadata/android/es/full_description.txt @@ -0,0 +1,17 @@ +Un cliente de YouTube Music con Material 3 para Android + +
Características: + +- Reproduce canciones de YT/YT Music sin anuncios +- Reproducción en segundo plano +- Busca canciones, vídeos, álbumes y listas de reproducción de YouTube Music +- Gestión de biblioteca +- Guarda en caché y descarga canciones para reproducirlas sin conexión +- Letras sincronizadas +- Saltar silencio +- Normalización de audio +- Tema dinámico +- Localización +- Compatibilidad con Android Auto +- Selecciones rápidas personalizadas +- Material 3 \ No newline at end of file diff --git a/fastlane/metadata/android/es/short_description.txt b/fastlane/metadata/android/es/short_description.txt new file mode 100644 index 000000000..a8a999bb8 --- /dev/null +++ b/fastlane/metadata/android/es/short_description.txt @@ -0,0 +1 @@ +Un cliente de YouTube Music con Material 3 para Android \ No newline at end of file diff --git a/fastlane/metadata/android/it/full_description.txt b/fastlane/metadata/android/it/full_description.txt index 0b21305f8..63534faf2 100644 --- a/fastlane/metadata/android/it/full_description.txt +++ b/fastlane/metadata/android/it/full_description.txt @@ -1,27 +1,17 @@ -Con quest'app, avrai un servizio di streaming musicale gratuito. Puoi ascoltare musica da YouTube Music e crearti la tua libreria musicale personale. Inoltre, i brani possono essere scaricati per un ascolto offline. Puoi anche creare delle playlist per organizzare la tua musica. L'obiettivo di InnerTune è quello di dare la possibilità a chiunque di ascoltare musica senza costi e facilmente, in maniera pratica e priva di pubblicità. - -
Nota: - -Il progetto è attualmente in una fase instabile. Se trovi degli errori, per favore segnalali facendolo su GitHub. +Un client di YouTube Music in Material 3 per Android
Caratteristiche: -* Ascolta brani senza nessuna pubblicità -* Naviga attraverso quasi tutte le pagine di YouTube Music -* Cerca brani, video, playlist e canali da YouTube Music -* Apri link di YouTube Music -* Salva brani, album e playlist in un database locale -* Scarica musica per un ascolto offline -* Modifica il titolo dei brani -* Aggiungi collegamenti alle tue playlist preferite di YouTube Music -* Riproduttore in Material Design -* Riproduzione anche con lo schermo bloccato -* Controlli in notifica -* Passa al prossimo brano o a quello precedente -* Modalità a ripetizione o casuale -* Modifica la coda dei brani in riproduzione -* Temi personalizzati -* Tema scuro -* Localizzazione -* Proxy -* Backup e ripristina +- Ascolto dei brani da YT/YT Music senza pubblicità +- Riproduzione in background +- Ricerca dei brani, video, album e playlist da YouTube Music +- Gestione della libreria +- Cache e download dei brani per la riproduzione offline +- Testi sincronizzati +- Salto del silenzio +- Normalizzazione dell'audio +- Tema dinamico +- Varie lingue disponibili +- Supporto per Android Auto +- Scelte rapide personalizzate +- Material 3 diff --git a/fastlane/metadata/android/it/short_description.txt b/fastlane/metadata/android/it/short_description.txt index fb186a850..ac29f8275 100644 --- a/fastlane/metadata/android/it/short_description.txt +++ b/fastlane/metadata/android/it/short_description.txt @@ -1 +1 @@ -un riproduttore musicale fatto in Material Design per YouTube Music +Un client di YouTube Music in Material 3 per Android diff --git a/fastlane/metadata/android/ru-RU/full_description.txt b/fastlane/metadata/android/ru-RU/full_description.txt new file mode 100644 index 000000000..2ec4ab3b1 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/full_description.txt @@ -0,0 +1,17 @@ +Клиент YouTube Music для Android в стиле Material 3 + +
Особенности: + +- Воспроизведение песен с YT/YT Music без рекламы +- Фоновое воспроизведение +- Поиск песен, видео, альбомов и плейлистов в YouTube Music +- Управление библиотекой +- Кэширование и загрузка песен для офлайн-воспроизведения +- Синхронизированный текст песен +- Пропуск тишины +- Нормализация аудио +- Динамическая тема +- Локализация +- Поддержка Android Auto +- Персонализированные быстрые выборки +- Material 3 diff --git a/fastlane/metadata/android/ru-RU/short_description.txt b/fastlane/metadata/android/ru-RU/short_description.txt new file mode 100644 index 000000000..1e6c1efe2 --- /dev/null +++ b/fastlane/metadata/android/ru-RU/short_description.txt @@ -0,0 +1 @@ +Клиент YouTube Music для Android в стиле Material 3 diff --git a/fastlane/metadata/android/ru-RU/title.txt b/fastlane/metadata/android/ru-RU/title.txt new file mode 100644 index 000000000..0facb35fe --- /dev/null +++ b/fastlane/metadata/android/ru-RU/title.txt @@ -0,0 +1 @@ +InnerTune diff --git a/fastlane/metadata/android/uk-UA/full_description.txt b/fastlane/metadata/android/uk-UA/full_description.txt new file mode 100644 index 000000000..8cc1dc47d --- /dev/null +++ b/fastlane/metadata/android/uk-UA/full_description.txt @@ -0,0 +1,17 @@ +Клієнт YouTube Music для Android у стилі Material 3 + +Особливості: + +- Відтворення пісень з YT/YT Music без реклами +- Фонове відтворення +- Пошук пісень, відео, альбомів та плейлистів в YouTube Music +- Керування бібліотекою +- Кешування та завантаження пісень для офлайн-відтворення +- Синхронізований текст пісень +- Пропуск тиші +- Нормалізація аудіо +- Динамічна тема +- Локалізація +- Підтримка Android Auto +- Персоналізовані швидкі вибірки +- Material 3 diff --git a/fastlane/metadata/android/uk-UA/short_description.txt b/fastlane/metadata/android/uk-UA/short_description.txt new file mode 100644 index 000000000..56c1e10fc --- /dev/null +++ b/fastlane/metadata/android/uk-UA/short_description.txt @@ -0,0 +1 @@ +Клієнт YouTube Music для Android у стилі Material 3 diff --git a/fastlane/metadata/android/uk-UA/title.txt b/fastlane/metadata/android/uk-UA/title.txt new file mode 100644 index 000000000..0facb35fe --- /dev/null +++ b/fastlane/metadata/android/uk-UA/title.txt @@ -0,0 +1 @@ +InnerTune diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 002915e6f..31f77b1d0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -74,5 +74,14 @@ junit = { group = "junit", name = "junit", version = "4.13.2" } timber = { group = "com.jakewharton.timber", name = "timber", version = "4.7.1" } +google-services = { module = "com.google.gms:google-services", version = "4.3.15" } +firebase-bom = { group = "com.google.firebase", name = "firebase-bom", version = "32.2.0" } +firebase-analytics = { group = "com.google.firebase", name = "firebase-analytics-ktx" } +firebase-crashlytics = { group = "com.google.firebase", name = "firebase-crashlytics-ktx" } +firebase-crashlytics-plugin = { module = "com.google.firebase:firebase-crashlytics-gradle", version = "2.9.7" } +firebase-config = { group = "com.google.firebase", name = "firebase-config-ktx" } +firebase-perf = { group = "com.google.firebase", name = "firebase-perf-ktx" } +firebase-perf-plugin = { module = "com.google.firebase:perf-plugin", version = "1.4.2" } + [plugins] kotlin-serialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } \ No newline at end of file diff --git a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt index e37a90e38..f97b3dc88 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/InnerTube.kt @@ -76,7 +76,7 @@ class InnerTube { } } - private fun HttpRequestBuilder.configYTClient(client: YouTubeClient) { + private fun HttpRequestBuilder.ytClient(client: YouTubeClient, setLogin: Boolean = false) { contentType(ContentType.Application.Json) headers { append("X-Goog-Api-Format-Version", "1") @@ -86,12 +86,14 @@ class InnerTube { if (client.referer != null) { append("Referer", client.referer) } - cookie?.let { cookie -> - append("cookie", cookie) - if ("SAPISID" !in cookieMap) return@let - val currentTime = System.currentTimeMillis() / 1000 - val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} https://music.youtube.com") - append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") + if (setLogin) { + cookie?.let { cookie -> + append("cookie", cookie) + if ("SAPISID" !in cookieMap) return@let + val currentTime = System.currentTimeMillis() / 1000 + val sapisidHash = sha1("$currentTime ${cookieMap["SAPISID"]} https://music.youtube.com") + append("Authorization", "SAPISIDHASH ${currentTime}_${sapisidHash}") + } } } userAgent(client.userAgent) @@ -105,7 +107,7 @@ class InnerTube { params: String? = null, continuation: String? = null, ) = httpClient.post("search") { - configYTClient(client) + ytClient(client) setBody( SearchBody( context = client.toContext(locale, visitorData), @@ -122,7 +124,7 @@ class InnerTube { videoId: String, playlistId: String?, ) = httpClient.post("player") { - configYTClient(client) + ytClient(client, setLogin = true) setBody( PlayerBody( context = client.toContext(locale, visitorData).let { @@ -141,7 +143,7 @@ class InnerTube { } suspend fun pipedStreams(videoId: String) = - httpClient.get("https://watchapi.whatever.social/streams/${videoId}") { + httpClient.get("https://pipedapi.kavin.rocks/streams/${videoId}") { contentType(ContentType.Application.Json) } @@ -150,8 +152,9 @@ class InnerTube { browseId: String? = null, params: String? = null, continuation: String? = null, + setLogin: Boolean = false, ) = httpClient.post("browse") { - configYTClient(client) + ytClient(client, setLogin) setBody( BrowseBody( context = client.toContext(locale, visitorData), @@ -175,7 +178,7 @@ class InnerTube { params: String?, continuation: String? = null, ) = httpClient.post("next") { - configYTClient(client) + ytClient(client, setLogin = true) setBody( NextBody( context = client.toContext(locale, visitorData), @@ -193,7 +196,7 @@ class InnerTube { client: YouTubeClient, input: String, ) = httpClient.post("music/get_search_suggestions") { - configYTClient(client) + ytClient(client) setBody( GetSearchSuggestionsBody( context = client.toContext(locale, visitorData), @@ -207,7 +210,7 @@ class InnerTube { videoIds: List?, playlistId: String?, ) = httpClient.post("music/get_queue") { - configYTClient(client) + ytClient(client) setBody( GetQueueBody( context = client.toContext(locale, visitorData), @@ -236,7 +239,7 @@ class InnerTube { suspend fun getSwJsData() = httpClient.get("https://music.youtube.com/sw.js_data") suspend fun accountMenu(client: YouTubeClient) = httpClient.post("account/account_menu") { - configYTClient(client) + ytClient(client) setBody(AccountMenuBody(client.toContext(locale, visitorData))) } -} \ 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 627d8eec5..0376212e2 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/YouTube.kt @@ -5,6 +5,8 @@ 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.GridRenderer +import com.zionhuang.innertube.models.MusicCarouselShelfRenderer import com.zionhuang.innertube.models.PlaylistItem import com.zionhuang.innertube.models.SearchSuggestions import com.zionhuang.innertube.models.SongItem @@ -31,6 +33,9 @@ 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.BrowseResult +import com.zionhuang.innertube.pages.ExplorePage +import com.zionhuang.innertube.pages.MoodAndGenres import com.zionhuang.innertube.pages.NewReleaseAlbumPage import com.zionhuang.innertube.pages.NextPage import com.zionhuang.innertube.pages.NextResult @@ -143,7 +148,7 @@ object YouTube { ) } - suspend fun album(browseId: String): Result = runCatching { + suspend fun album(browseId: String, withSongs: Boolean = true): Result = runCatching { val response = innerTube.browse(WEB_REMIX, browseId).body() val playlistId = response.microformat?.microformatDataRenderer?.urlCanonical?.substringAfterLast('=')!! AlbumPage( @@ -160,7 +165,7 @@ object YouTube { year = response.header.musicDetailHeaderRenderer.subtitle.runs.lastOrNull()?.text?.toIntOrNull(), thumbnail = response.header.musicDetailHeaderRenderer.thumbnail.croppedSquareThumbnailRenderer?.getThumbnailUrl()!! ), - songs = albumSongs(playlistId).getOrThrow() + songs = if (withSongs) albumSongs(playlistId).getOrThrow() else emptyList() ) } @@ -234,22 +239,27 @@ object YouTube { } suspend fun playlist(playlistId: String): Result = runCatching { - val response = innerTube.browse(WEB_REMIX, "VL$playlistId").body() + val response = innerTube.browse( + client = WEB_REMIX, + browseId = "VL$playlistId", + setLogin = true + ).body() + val header = response.header?.musicDetailHeaderRenderer ?: response.header?.musicEditablePlaylistDetailHeaderRenderer?.header?.musicDetailHeaderRenderer!! PlaylistPage( playlist = PlaylistItem( id = playlistId, - title = response.header?.musicDetailHeaderRenderer?.title?.runs?.firstOrNull()?.text!!, - author = response.header.musicDetailHeaderRenderer.subtitle.runs?.getOrNull(2)?.let { + title = header.title.runs?.firstOrNull()?.text!!, + author = header.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()!!, + }, + songCountText = header.secondSubtitle.runs?.firstOrNull()?.text, + thumbnail = header.thumbnail.croppedSquareThumbnailRenderer?.getThumbnailUrl()!!, playEndpoint = null, - shuffleEndpoint = response.header.musicDetailHeaderRenderer.menu.menuRenderer.topLevelButtons?.firstOrNull()?.buttonRenderer?.navigationEndpoint?.watchPlaylistEndpoint!!, - radioEndpoint = response.header.musicDetailHeaderRenderer.menu.menuRenderer.items.find { + shuffleEndpoint = header.menu.menuRenderer.topLevelButtons?.firstOrNull()?.buttonRenderer?.navigationEndpoint?.watchPlaylistEndpoint!!, + radioEndpoint = header.menu.menuRenderer.items.find { it.menuNavigationItemRenderer?.icon?.iconType == "MIX" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint!! ), @@ -267,7 +277,11 @@ object YouTube { } suspend fun playlistContinuation(continuation: String) = runCatching { - val response = innerTube.browse(WEB_REMIX, continuation = continuation).body() + val response = innerTube.browse( + client = WEB_REMIX, + continuation = continuation, + setLogin = true + ).body() PlaylistContinuationPage( songs = response.continuationContents?.musicPlaylistShelfContinuation?.contents?.mapNotNull { PlaylistPage.fromMusicResponsiveListItemRenderer(it.musicResponsiveListItemRenderer) @@ -276,23 +290,79 @@ object YouTube { ) } - - suspend fun newReleaseAlbumsPreview(): Result> = runCatching { + suspend fun explore(): 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() + ExplorePage( + newReleaseAlbums = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find { + it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint?.browseId == "FEmusic_new_releases_albums" + }?.musicCarouselShelfRenderer?.contents + ?.mapNotNull { it.musicTwoRowItemRenderer } + ?.mapNotNull(NewReleaseAlbumPage::fromMusicTwoRowItemRenderer).orEmpty(), + moodAndGenres = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.find { + it.musicCarouselShelfRenderer?.header?.musicCarouselShelfBasicHeaderRenderer?.moreContentButton?.buttonRenderer?.navigationEndpoint?.browseEndpoint?.browseId == "FEmusic_moods_and_genres" + }?.musicCarouselShelfRenderer?.contents + ?.mapNotNull { it.musicNavigationButtonRenderer } + ?.mapNotNull(MoodAndGenres.Companion::fromMusicNavigationButtonRenderer) + .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) + response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.gridRenderer?.items + ?.mapNotNull { it.musicTwoRowItemRenderer } + ?.mapNotNull(NewReleaseAlbumPage::fromMusicTwoRowItemRenderer) + .orEmpty() + } + + suspend fun moodAndGenres(): Result> = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId = "FEmusic_moods_and_genres").body() + response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents!! + .mapNotNull(MoodAndGenres.Companion::fromSectionListRendererContent) + } + + suspend fun browse(browseId: String, params: String?): Result = runCatching { + val response = innerTube.browse(WEB_REMIX, browseId = browseId, params = params).body() + BrowseResult( + title = response.header?.musicHeaderRenderer?.title?.runs?.firstOrNull()?.text, + items = response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.mapNotNull { content -> + when { + content.gridRenderer != null -> { + BrowseResult.Item( + title = content.gridRenderer.header?.gridHeaderRenderer?.title?.runs?.firstOrNull()?.text, + items = content.gridRenderer.items + .mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) + .mapNotNull(RelatedPage.Companion::fromMusicTwoRowItemRenderer) + ) + } + + content.musicCarouselShelfRenderer != null -> { + BrowseResult.Item( + title = content.musicCarouselShelfRenderer.header?.musicCarouselShelfBasicHeaderRenderer?.title?.runs?.firstOrNull()?.text, + items = content.musicCarouselShelfRenderer.contents + .mapNotNull(MusicCarouselShelfRenderer.Content::musicTwoRowItemRenderer) + .mapNotNull(RelatedPage.Companion::fromMusicTwoRowItemRenderer) + ) + } + + else -> null + } + }.orEmpty() + ) + } + + suspend fun likedPlaylists(): Result> = runCatching { + val response = innerTube.browse( + client = WEB_REMIX, + browseId = "FEmusic_liked_playlists", + setLogin = true + ).body() + response.contents?.singleColumnBrowseResultsRenderer?.tabs?.firstOrNull()?.tabRenderer?.content?.sectionListRenderer?.contents?.firstOrNull()?.gridRenderer?.items!! + .drop(1) // the first item is "create new playlist" + .mapNotNull(GridRenderer.Item::musicTwoRowItemRenderer) + .mapNotNull { + ArtistItemsPage.fromMusicTwoRowItemRenderer(it) as? PlaylistItem } - }.orEmpty() } suspend fun player(videoId: String, playlistId: String? = null): Result = runCatching { 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 c07e06d7e..22b86f2a6 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/MusicTwoRowItemRenderer.kt @@ -14,9 +14,9 @@ import kotlinx.serialization.Serializable @Serializable data class MusicTwoRowItemRenderer( val title: Runs, - val subtitle: Runs, + val subtitle: Runs?, val subtitleBadges: List?, - val menu: Menu, + val menu: Menu?, val thumbnailRenderer: ThumbnailRenderer, val navigationEndpoint: NavigationEndpoint, val thumbnailOverlay: MusicResponsiveListItemRenderer.Overlay?, 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 63fdd7a62..b85f79f20 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/SectionListRenderer.kt @@ -12,7 +12,7 @@ data class SectionListRenderer( ) { @Serializable data class Header( - val chipCloudRenderer: ChipCloudRenderer, + val chipCloudRenderer: ChipCloudRenderer?, ) { @Serializable data class ChipCloudRenderer( 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 e71e088e2..d35765650 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/YTItem.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/YTItem.kt @@ -49,7 +49,7 @@ data class AlbumItem( data class PlaylistItem( override val id: String, override val title: String, - val author: Artist, + val author: Artist?, val songCountText: String?, override val thumbnail: String, val playEndpoint: WatchEndpoint?, diff --git a/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt b/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt index 7c1118771..b343157ce 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/models/response/AccountMenuResponse.kt @@ -34,8 +34,8 @@ data class AccountMenuResponse( val email: Runs, ) { fun toAccountInfo() = AccountInfo( - accountName.toString(), - email.toString() + accountName.runs!!.first().text, + email.runs!!.first().text ) } } 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 bf9380546..897c8bcc5 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 @@ -1,6 +1,14 @@ package com.zionhuang.innertube.models.response -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.Button +import com.zionhuang.innertube.models.Continuation +import com.zionhuang.innertube.models.Menu +import com.zionhuang.innertube.models.MusicShelfRenderer +import com.zionhuang.innertube.models.ResponseContext +import com.zionhuang.innertube.models.Runs +import com.zionhuang.innertube.models.SectionListRenderer +import com.zionhuang.innertube.models.Tabs +import com.zionhuang.innertube.models.ThumbnailRenderer import kotlinx.serialization.Serializable @Serializable @@ -39,6 +47,7 @@ data class BrowseResponse( data class Header( val musicImmersiveHeaderRenderer: MusicImmersiveHeaderRenderer?, val musicDetailHeaderRenderer: MusicDetailHeaderRenderer?, + val musicEditablePlaylistDetailHeaderRenderer: MusicEditablePlaylistDetailHeaderRenderer?, val musicVisualHeaderRenderer: MusicVisualHeaderRenderer?, val musicHeaderRenderer: MusicHeaderRenderer?, ) { @@ -62,6 +71,16 @@ data class BrowseResponse( val menu: Menu, ) + @Serializable + data class MusicEditablePlaylistDetailHeaderRenderer( + val header: Header, + ) { + @Serializable + data class Header( + val musicDetailHeaderRenderer: MusicDetailHeaderRenderer, + ) + } + @Serializable data class MusicVisualHeaderRenderer( val title: Runs, 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 bb4866d66..04a7d6dca 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistItemsPage.kt @@ -1,6 +1,15 @@ package com.zionhuang.innertube.pages -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.Album +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.Artist +import com.zionhuang.innertube.models.MusicResponsiveListItemRenderer +import com.zionhuang.innertube.models.MusicTwoRowItemRenderer +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.innertube.models.oddElements +import com.zionhuang.innertube.models.splitBySeparator import com.zionhuang.innertube.utils.parseTime data class ArtistItemsPage( @@ -48,7 +57,7 @@ data class ArtistItemsPage( ?.watchPlaylistEndpoint?.playlistId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, artists = null, - year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), + 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" @@ -58,7 +67,7 @@ data class ArtistItemsPage( 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 { + artists = renderer.subtitle?.runs?.splitBySeparator()?.firstOrNull()?.oddElements()?.map { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId @@ -72,19 +81,19 @@ data class ArtistItemsPage( 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 { + 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, + }, + 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 { + shuffleEndpoint = renderer.menu?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, radioEndpoint = renderer.menu.menuRenderer.items.find { 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 0e1ba7d7e..fec6bdb54 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ArtistPage.kt @@ -1,6 +1,19 @@ package com.zionhuang.innertube.pages -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.Album +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.MusicCarouselShelfRenderer +import com.zionhuang.innertube.models.MusicResponsiveListItemRenderer +import com.zionhuang.innertube.models.MusicShelfRenderer +import com.zionhuang.innertube.models.MusicTwoRowItemRenderer +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SectionListRenderer +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.innertube.models.oddElements data class ArtistSection( val title: String, @@ -76,7 +89,7 @@ data class ArtistPage( SongItem( id = renderer.navigationEndpoint.watchEndpoint?.videoId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, - artists = listOfNotNull(renderer.subtitle.runs?.firstOrNull()?.let { + artists = listOfNotNull(renderer.subtitle?.runs?.firstOrNull()?.let { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId @@ -90,29 +103,30 @@ data class ArtistPage( } != null ) } + renderer.isAlbum -> { AlbumItem( browseId = renderer.navigationEndpoint.browseEndpoint?.browseId ?: return null, - playlistId = renderer.thumbnailOverlay - ?.musicItemThumbnailOverlayRenderer?.content + 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(), + 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?.removePrefix("VL") ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, author = Artist( - name = renderer.subtitle.runs?.lastOrNull()?.text ?: return null, + name = renderer.subtitle?.runs?.lastOrNull()?.text ?: return null, id = null ), songCountText = null, @@ -121,7 +135,7 @@ data class ArtistPage( ?.musicItemThumbnailOverlayRenderer?.content ?.musicPlayButtonRenderer?.playNavigationEndpoint ?.watchPlaylistEndpoint ?: return null, - shuffleEndpoint = renderer.menu.menuRenderer.items.find { + shuffleEndpoint = renderer.menu?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, radioEndpoint = renderer.menu.menuRenderer.items.find { @@ -129,12 +143,13 @@ data class ArtistPage( }?.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 { + shuffleEndpoint = renderer.menu?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, radioEndpoint = renderer.menu.menuRenderer.items.find { @@ -142,6 +157,7 @@ data class ArtistPage( }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, ) } + else -> null } } diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/BrowseResult.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/BrowseResult.kt new file mode 100644 index 000000000..2099183ec --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/BrowseResult.kt @@ -0,0 +1,13 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.YTItem + +data class BrowseResult( + val title: String?, + val items: List, +) { + data class Item( + val title: String?, + val items: List, + ) +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/ExplorePage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/ExplorePage.kt new file mode 100644 index 000000000..faee829de --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/ExplorePage.kt @@ -0,0 +1,8 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.AlbumItem + +data class ExplorePage( + val newReleaseAlbums: List, + val moodAndGenres: List, +) diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/MoodAndGenres.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/MoodAndGenres.kt new file mode 100644 index 000000000..ecaed16e4 --- /dev/null +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/MoodAndGenres.kt @@ -0,0 +1,36 @@ +package com.zionhuang.innertube.pages + +import com.zionhuang.innertube.models.BrowseEndpoint +import com.zionhuang.innertube.models.GridRenderer +import com.zionhuang.innertube.models.MusicNavigationButtonRenderer +import com.zionhuang.innertube.models.SectionListRenderer + +data class MoodAndGenres( + val title: String, + val items: List, +) { + data class Item( + val title: String, + val stripeColor: Long, + val endpoint: BrowseEndpoint, + ) + + companion object { + fun fromSectionListRendererContent(content: SectionListRenderer.Content): MoodAndGenres? { + return MoodAndGenres( + title = content.gridRenderer?.header?.gridHeaderRenderer?.title?.runs?.firstOrNull()?.text ?: return null, + items = content.gridRenderer.items + .mapNotNull(GridRenderer.Item::musicNavigationButtonRenderer) + .mapNotNull(::fromMusicNavigationButtonRenderer) + ) + } + + fun fromMusicNavigationButtonRenderer(renderer: MusicNavigationButtonRenderer): Item? { + return Item( + title = renderer.buttonText.runs?.firstOrNull()?.text ?: return null, + stripeColor = renderer.solid?.leftStripeColor ?: return null, + endpoint = renderer.clickCommand.browseEndpoint ?: return null, + ) + } + } +} diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt index fbf840d61..04d8d5c58 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/NewReleaseAlbumPage.kt @@ -1,6 +1,10 @@ package com.zionhuang.innertube.pages -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.Artist +import com.zionhuang.innertube.models.MusicTwoRowItemRenderer +import com.zionhuang.innertube.models.oddElements +import com.zionhuang.innertube.models.splitBySeparator object NewReleaseAlbumPage { fun fromMusicTwoRowItemRenderer(renderer: MusicTwoRowItemRenderer): AlbumItem? { @@ -11,7 +15,7 @@ object NewReleaseAlbumPage { ?.musicPlayButtonRenderer?.playNavigationEndpoint ?.watchPlaylistEndpoint?.playlistId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, - artists = renderer.subtitle.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map { + artists = renderer.subtitle?.runs?.splitBySeparator()?.getOrNull(1)?.oddElements()?.map { Artist( name = it.text, id = it.navigationEndpoint?.browseEndpoint?.browseId diff --git a/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt b/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt index 9460c9350..3272f54f5 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/pages/RelatedPage.kt @@ -1,6 +1,15 @@ package com.zionhuang.innertube.pages -import com.zionhuang.innertube.models.* +import com.zionhuang.innertube.models.Album +import com.zionhuang.innertube.models.AlbumItem +import com.zionhuang.innertube.models.Artist +import com.zionhuang.innertube.models.ArtistItem +import com.zionhuang.innertube.models.MusicResponsiveListItemRenderer +import com.zionhuang.innertube.models.MusicTwoRowItemRenderer +import com.zionhuang.innertube.models.PlaylistItem +import com.zionhuang.innertube.models.SongItem +import com.zionhuang.innertube.models.YTItem +import com.zionhuang.innertube.models.oddElements data class RelatedPage( val songs: List, @@ -44,7 +53,7 @@ data class RelatedPage( ?.watchPlaylistEndpoint?.playlistId ?: return null, title = renderer.title.runs?.firstOrNull()?.text ?: return null, artists = null, - year = renderer.subtitle.runs?.lastOrNull()?.text?.toIntOrNull(), + 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" @@ -53,19 +62,19 @@ data class RelatedPage( 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 { + 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, + }, + 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 { + shuffleEndpoint = renderer.menu?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, radioEndpoint = renderer.menu.menuRenderer.items.find { @@ -77,7 +86,7 @@ data class RelatedPage( 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 { + shuffleEndpoint = renderer.menu?.menuRenderer?.items?.find { it.menuNavigationItemRenderer?.icon?.iconType == "MUSIC_SHUFFLE" }?.menuNavigationItemRenderer?.navigationEndpoint?.watchPlaylistEndpoint ?: return null, radioEndpoint = renderer.menu.menuRenderer.items.find { 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 8f63cc50b..2e727f0e6 100644 --- a/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt +++ b/innertube/src/main/java/com/zionhuang/innertube/utils/Utils.kt @@ -26,10 +26,12 @@ fun ByteArray.toHex(): String = joinToString(separator = "") { eachByte -> "%02x fun sha1(str: String): String = MessageDigest.getInstance("SHA-1").digest(str.toByteArray()).toHex() fun parseCookieString(cookie: String): Map = - cookie.split("; ").associate { - val (key, value) = it.split("=") - key to value - } + cookie.split("; ") + .filter { it.isNotEmpty() } + .associate { + val (key, value) = it.split("=") + key to value + } fun String.parseTime(): Int? { try {