commit 1e673ad582c300933b8233e445ee17a7ed3aa30f Author: vfsfitvnm Date: Thu Jun 2 18:59:18 2022 +0200 Initial commit diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..878c2cd --- /dev/null +++ b/.gitignore @@ -0,0 +1,10 @@ +*.iml +.gradle +/local.properties +.idea +.DS_Store +/build +/captures +.externalNativeBuild +.cxx +local.properties diff --git a/LICENSE.md b/LICENSE.md new file mode 100644 index 0000000..dc8b239 --- /dev/null +++ b/LICENSE.md @@ -0,0 +1,202 @@ + + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + +TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + +1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + +2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + +3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + +4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + +5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + +6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + +7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + +8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + +9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + +END OF TERMS AND CONDITIONS + +APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + +Copyright [yyyy] [name of copyright owner] + +Licensed under the Apache License, Version 2.0 (the "License"); +you may not use this file except in compliance with the License. +You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + +Unless required by applicable law or agreed to in writing, software +distributed under the License is distributed on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +See the License for the specific language governing permissions and +limitations under the License. diff --git a/README.md b/README.md new file mode 100644 index 0000000..659474f --- /dev/null +++ b/README.md @@ -0,0 +1,55 @@ +# ViMusic + +

+ +

+ +

A Jetpack Compose Android application for streaming music from YouTube Music

+ +--- + +

+ + + + +

+ +## Known problems +The application is using `androidx.media3`, which is unstable. Expect random crashes or buggy notification behaviours. Hopefully, they will be fixed within the next alpha release. + +## Features +- Play any non-age-restricted song/video from YouTube Music +- Background playback +- Cache audio chunks for offline playback +- Search for songs, albums, artists and videos +- Display songs lyrics +- Local playlist management +- Reorder songs in playlist or queue +- Light/Dark theme +- ... + +## TODO +- **Improve UI/UX** (help needed) +- Settings page +- Support YouTube playlists (and other stuff to improve features parity) +- Download songs (not sure about this) +- Play local songs (not sure about this, too) +- Translation + +## Installation +You can download the latest apk [here](https://github.com/vfsfitvnm/ViMusic/releases), **the unique distribution channel**. + +After installing, I recommend executing the following ADB command to neutralize some animation lags you may experience in cold starts: +``` +adb shell cmd package compile -r bg-dexopt it.vfsfitvnm.vimusic +``` + +## Contributions +There's a huge room for improvements! Please open an issue to report bugs, discuss ideas and so on. + +## Similar projects, inspirations and acknowledgments +- [**Beatbump**](https://github.com/snuffyDev/Beatbump): Alternative YouTube Music frontend built with Svelte/SvelteKit. +- [**music**](https://github.com/z-huang/music): A material design music player with music from YouTube/YouTube Music. +- [**YouTube-Internal-Clients**](https://github.com/zerodytrash/YouTube-Internal-Clients): A python script that discovers hidden YouTube API clients. Just a research project. +- [**ionicons**](https://github.com/ionic-team/ionicons): Premium hand-crafted icons built by Ionic, for Ionic apps and web apps everywhere. diff --git a/app/.gitignore b/app/.gitignore new file mode 100644 index 0000000..956c004 --- /dev/null +++ b/app/.gitignore @@ -0,0 +1,2 @@ +/build +/release \ No newline at end of file diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..42cdbc5 --- /dev/null +++ b/app/build.gradle.kts @@ -0,0 +1,97 @@ +plugins { + id("com.android.application") + kotlin("android") + kotlin("kapt") +} + +android { + signingConfigs { + create("release") { + } + } + compileSdk = 32 + + defaultConfig { + applicationId = "it.vfsfitvnm.vimusic" + minSdk = 21 + targetSdk = 32 + versionCode = 1 + versionName = "0.1.0" + } + + splits { + abi { + reset() + isUniversalApk = true + } + } + + buildTypes { + debug { + applicationIdSuffix = ".debug" + manifestPlaceholders["appName"] = "Debug" + } + + release { + isMinifyEnabled = true + isShrinkResources = true + manifestPlaceholders["appName"] = "ViMusic" + signingConfig = signingConfigs.getByName("debug") + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro") + } + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.get() + } + + kotlinOptions { + freeCompilerArgs += "-Xcontext-receivers" + jvmTarget = "1.8" + } +} + +kapt { + arguments { + arg("room.schemaLocation", "$projectDir/schemas") + } +} + +dependencies { + implementation(projects.composeRouting) + implementation(projects.composeReordering) + + implementation(libs.compose.activity) + implementation(libs.compose.foundation) + implementation(libs.compose.ui) + implementation(libs.compose.ui.util) + implementation(libs.compose.ripple) + implementation(libs.compose.shimmer) + implementation(libs.compose.coil) + + implementation(libs.accompanist.systemuicontroller) + implementation(libs.accompanist.flowlayout) + + implementation(libs.android.media) + implementation(libs.bundles.media3) + + implementation(libs.room) + kapt(libs.room.compiler) + + implementation(libs.guava.coroutines) + + implementation(projects.youtubeMusic) +} diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro new file mode 100644 index 0000000..134e059 --- /dev/null +++ b/app/proguard-rules.pro @@ -0,0 +1,24 @@ +-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation + +-if @kotlinx.serialization.Serializable class ** +-keepclassmembers class <1> { + static <1>$Companion Companion; +} + +-if @kotlinx.serialization.Serializable class ** { + static **$* *; +} +-keepclassmembers class <2>$<3> { + kotlinx.serialization.KSerializer serializer(...); +} + +-if @kotlinx.serialization.Serializable class ** { + public static ** INSTANCE; +} +-keepclassmembers class <1> { + public static <1> INSTANCE; + kotlinx.serialization.KSerializer serializer(...); +} + +-keepattributes RuntimeVisibleAnnotations,AnnotationDefault + diff --git a/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json new file mode 100644 index 0000000..2ccea01 --- /dev/null +++ b/app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json @@ -0,0 +1,304 @@ +{ + "formatVersion": 1, + "database": { + "version": 1, + "identityHash": "b93575bd08c10513f0bfc997b832c280", + "entities": [ + { + "tableName": "Song", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "title", + "columnName": "title", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "albumInfoId", + "columnName": "albumInfoId", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "durationText", + "columnName": "durationText", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "thumbnailUrl", + "columnName": "thumbnailUrl", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "likedAt", + "columnName": "likedAt", + "affinity": "INTEGER", + "notNull": false + }, + { + "fieldPath": "totalPlayTimeMs", + "columnName": "totalPlayTimeMs", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongInPlaylist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "playlistId", + "columnName": "playlistId", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "position", + "columnName": "position", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "playlistId" + ] + }, + "indices": [ + { + "name": "index_SongInPlaylist_songId", + "unique": false, + "columnNames": [ + "songId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)" + }, + { + "name": "index_SongInPlaylist_playlistId", + "unique": false, + "columnNames": [ + "playlistId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Playlist", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "playlistId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "Playlist", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "name", + "columnName": "name", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "Info", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)", + "fields": [ + { + "fieldPath": "id", + "columnName": "id", + "affinity": "INTEGER", + "notNull": true + }, + { + "fieldPath": "browseId", + "columnName": "browseId", + "affinity": "TEXT", + "notNull": false + }, + { + "fieldPath": "text", + "columnName": "text", + "affinity": "TEXT", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": true, + "columnNames": [ + "id" + ] + }, + "indices": [], + "foreignKeys": [] + }, + { + "tableName": "SongWithAuthors", + "createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )", + "fields": [ + { + "fieldPath": "songId", + "columnName": "songId", + "affinity": "TEXT", + "notNull": true + }, + { + "fieldPath": "authorInfoId", + "columnName": "authorInfoId", + "affinity": "INTEGER", + "notNull": true + } + ], + "primaryKey": { + "autoGenerate": false, + "columnNames": [ + "songId", + "authorInfoId" + ] + }, + "indices": [ + { + "name": "index_SongWithAuthors_authorInfoId", + "unique": false, + "columnNames": [ + "authorInfoId" + ], + "orders": [], + "createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)" + } + ], + "foreignKeys": [ + { + "table": "Song", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "songId" + ], + "referencedColumns": [ + "id" + ] + }, + { + "table": "Info", + "onDelete": "CASCADE", + "onUpdate": "NO ACTION", + "columns": [ + "authorInfoId" + ], + "referencedColumns": [ + "id" + ] + } + ] + }, + { + "tableName": "SearchQuery", + "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_SearchQuery_query", + "unique": true, + "columnNames": [ + "query" + ], + "orders": [], + "createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)" + } + ], + "foreignKeys": [] + } + ], + "views": [ + { + "viewName": "SortedSongInPlaylist", + "createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist 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, 'b93575bd08c10513f0bfc997b832c280')" + ] + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..8b869af --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,57 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/main/ic_launcher-playstore.png b/app/src/main/ic_launcher-playstore.png new file mode 100644 index 0000000..f23c265 Binary files /dev/null and b/app/src/main/ic_launcher-playstore.png differ diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt new file mode 100644 index 0000000..5123052 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt @@ -0,0 +1,140 @@ +package it.vfsfitvnm.vimusic + +import android.content.Context +import androidx.room.* +import it.vfsfitvnm.vimusic.models.* +import kotlinx.coroutines.flow.Flow + +@Dao +interface Database { + companion object : Database by DatabaseInitializer.Instance.database + + @Query("SELECT * FROM SearchQuery WHERE query LIKE :query ORDER BY id DESC") + fun getRecentQueries(query: String): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + fun insert(searchQuery: SearchQuery) + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(info: Info): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(playlist: Playlist): Long + + @Insert(onConflict = OnConflictStrategy.IGNORE) + fun insert(info: SongInPlaylist): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(info: List): List + + @Query("SELECT * FROM Song WHERE id = :id") + fun songFlow(id: String): Flow + + @Query("SELECT * FROM Song WHERE id = :id") + fun song(id: String): Song? + + @Query("SELECT * FROM Playlist WHERE id = :id") + fun playlist(id: Long): Playlist? + + @Query("SELECT * FROM Song") + fun songs(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE id = :id") + fun songWithInfo(id: String): SongWithInfo? + + @Transaction + @Query("SELECT * FROM Song WHERE totalPlayTimeMs >= 15000 ORDER BY ROWID DESC") + fun history(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC") + fun favorites(): Flow> + + @Transaction + @Query("SELECT * FROM Song WHERE totalPlayTimeMs >= 60000 ORDER BY totalPlayTimeMs DESC LIMIT 20") + fun mostPlayed(): Flow> + + @Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id") + fun incrementTotalPlayTimeMs(id: String, addition: Long) + + @Transaction + @Query("SELECT * FROM Playlist WHERE id = :id") + fun playlistWithSongs(id: Long): Flow + + @Query("SELECT COUNT(*) FROM SongInPlaylist WHERE playlistId = :id") + fun playlistSongCount(id: Long): Int + + @Query("UPDATE SongInPlaylist SET position = position - 1 WHERE playlistId = :playlistId AND position >= :fromPosition") + fun decrementSongPositions(playlistId: Long, fromPosition: Int) + + @Query("UPDATE SongInPlaylist SET position = position - 1 WHERE playlistId = :playlistId AND position >= :fromPosition AND position <= :toPosition") + fun decrementSongPositions(playlistId: Long, fromPosition: Int, toPosition: Int) + + @Query("UPDATE SongInPlaylist SET position = position + 1 WHERE playlistId = :playlistId AND position >= :fromPosition AND position <= :toPosition") + fun incrementSongPositions(playlistId: Long, fromPosition: Int, toPosition: Int) + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(songWithAuthors: SongWithAuthors): Long + + @Insert(onConflict = OnConflictStrategy.ABORT) + fun insert(song: Song): Long + + @Update + fun update(song: Song) + + @Update + fun update(songInPlaylist: SongInPlaylist) + + @Update + fun update(playlist: Playlist) + + @Delete + fun delete(searchQuery: SearchQuery) + + @Delete + fun delete(playlist: Playlist) + + @Delete + fun delete(song: Song) + + @Delete + fun delete(songInPlaylist: SongInPlaylist) + + @Transaction + @Query("SELECT id, name, (SELECT COUNT(*) FROM SongInPlaylist WHERE playlistId = id) as songCount FROM Playlist") + fun playlistPreviews(): Flow> + + @Query("SELECT thumbnailUrl FROM Song JOIN SongInPlaylist ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4") + fun playlistThumbnailUrls(id: Long): Flow> +} + +@androidx.room.Database( + entities = [ + Song::class, SongInPlaylist::class, Playlist::class, Info::class, SongWithAuthors::class, SearchQuery::class + ], + views = [ + SortedSongInPlaylist::class + ], + version = 1, + exportSchema = true +) +abstract class DatabaseInitializer protected constructor() : RoomDatabase() { + abstract val database: Database + + companion object { + lateinit var Instance: DatabaseInitializer + + context(Context) + operator fun invoke() { + if (!::Instance.isInitialized) { + Instance = Room + .databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db") + .build() + } + } + } +} + +val Database.internal: RoomDatabase + get() = DatabaseInitializer.Instance diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt new file mode 100644 index 0000000..f2e74b1 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt @@ -0,0 +1,145 @@ +package it.vfsfitvnm.vimusic + +import android.content.ComponentName +import android.os.Bundle +import androidx.activity.ComponentActivity +import androidx.activity.compose.setContent +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.LinearEasing +import androidx.compose.animation.core.RepeatMode +import androidx.compose.animation.core.infiniteRepeatable +import androidx.compose.animation.core.tween +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.foundation.LocalIndication +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.LocalOverScrollConfiguration +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.material.ripple.LocalRippleTheme +import androidx.compose.material.ripple.RippleAlpha +import androidx.compose.material.ripple.RippleTheme +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.SideEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.media3.session.MediaController +import androidx.media3.session.SessionToken +import com.google.accompanist.systemuicontroller.rememberSystemUiController +import com.google.common.util.concurrent.ListenableFuture +import com.valentinilk.shimmer.LocalShimmerTheme +import com.valentinilk.shimmer.defaultShimmerTheme +import it.vfsfitvnm.vimusic.services.PlayerService +import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.rememberMenuState +import it.vfsfitvnm.vimusic.ui.screens.HomeScreen +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.ui.styling.rememberColorPalette +import it.vfsfitvnm.vimusic.ui.styling.rememberTypography +import it.vfsfitvnm.vimusic.utils.* + + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalTextApi +class MainActivity : ComponentActivity() { + private lateinit var mediaControllerFuture: ListenableFuture + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java)) + mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync() + + val intentVideoId = intent?.data?.getQueryParameter("v") + + setContent { + val preferences = rememberPreferences() + val systemUiController = rememberSystemUiController() + val isDarkTheme = isSystemInDarkTheme() + + val colorPalette = rememberColorPalette(isDarkTheme) + + val rippleTheme = remember(colorPalette.text, isDarkTheme) { + object : RippleTheme { + @Composable + override fun defaultColor(): Color = RippleTheme.defaultRippleColor( + contentColor = colorPalette.text, + lightTheme = !isDarkTheme + ) + + @Composable + override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha( + contentColor = colorPalette.text, + lightTheme = !isDarkTheme + ) + } + } + + val shimmerTheme = remember { + defaultShimmerTheme.copy( + animationSpec = infiniteRepeatable( + animation = tween( + durationMillis = 800, + easing = LinearEasing, + delayMillis = 250, + ), + repeatMode = RepeatMode.Restart + ), + shaderColors = listOf( + Color.Unspecified.copy(alpha = 0.25f), + Color.White.copy(alpha = 0.50f), + Color.Unspecified.copy(alpha = 0.25f), + ), + ) + } + + SideEffect { + systemUiController.setSystemBarsColor(colorPalette.background, !isDarkTheme) + } + + CompositionLocalProvider( + LocalOverScrollConfiguration provides null, + LocalIndication provides rememberRipple(bounded = false), + LocalRippleTheme provides rippleTheme, + LocalPreferences provides preferences, + LocalColorPalette provides colorPalette, + LocalShimmerTheme provides shimmerTheme, + LocalTypography provides rememberTypography(colorPalette.text), + LocalYoutubePlayer provides rememberYoutubePlayer( + mediaControllerFuture, + preferences.repeatMode + ), + LocalMenuState provides rememberMenuState(), + LocalHapticFeedback provides rememberHapticFeedback() + ) { + Box( + modifier = Modifier + .fillMaxSize() + .background(LocalColorPalette.current.background) + ) { + HomeScreen(intentVideoId = intentVideoId) + + BottomSheetMenu( + state = LocalMenuState.current, + modifier = Modifier + .align(Alignment.BottomCenter) + ) + } + } + } + } + + override fun onDestroy() { + MediaController.releaseFuture(mediaControllerFuture) + super.onDestroy() + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt new file mode 100644 index 0000000..46e8b70 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt @@ -0,0 +1,26 @@ +package it.vfsfitvnm.vimusic + +import android.app.Application +import coil.ImageLoader +import coil.ImageLoaderFactory +import coil.disk.DiskCache + + +class MainApplication : Application(), ImageLoaderFactory { + override fun onCreate() { + super.onCreate() + DatabaseInitializer() + } + + override fun newImageLoader(): ImageLoader { + return ImageLoader.Builder(this) + .crossfade(true) + .diskCache( + DiskCache.Builder() + .directory(filesDir.resolve("coil")) + .maxSizeBytes(1024 * 1024 * 1024) + .build() + ) + .build() + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongCollection.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongCollection.kt new file mode 100644 index 0000000..83af2f2 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/enums/SongCollection.kt @@ -0,0 +1,7 @@ +package it.vfsfitvnm.vimusic.enums + +enum class SongCollection { + MostPlayed, + Favorites, + History +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt new file mode 100644 index 0000000..9e7c601 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt @@ -0,0 +1,12 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.Entity +import androidx.room.PrimaryKey + +// I know... +@Entity +data class Info( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val browseId: String?, + val text: String +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt new file mode 100644 index 0000000..f428263 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt @@ -0,0 +1,17 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity +data class Playlist( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val name: String, +) { + companion object { + val Empty = Playlist( + id = 0, + name = "" + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt new file mode 100644 index 0000000..1ea25d0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistPreview.kt @@ -0,0 +1,8 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.Embedded + +data class PlaylistPreview( + @Embedded val playlist: Playlist, + val songCount: Int +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt new file mode 100644 index 0000000..fa512c0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/PlaylistWithSongs.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.* + + +data class PlaylistWithSongs( + @Embedded val playlist: Playlist, + @Relation( + entity = Song::class, + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = SortedSongInPlaylist::class, + parentColumn = "playlistId", + entityColumn = "songId" + ) + ) + val songs: List +) { + companion object { + val Empty = PlaylistWithSongs(Playlist(-1, ""), emptyList()) + val NotFound = PlaylistWithSongs(Playlist(-2, "Not found"), emptyList()) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt new file mode 100644 index 0000000..de7417e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SearchQuery.kt @@ -0,0 +1,21 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.compose.runtime.Immutable +import androidx.room.Entity +import androidx.room.Index +import androidx.room.PrimaryKey + + +@Immutable +@Entity( + indices = [ + Index( + value = ["query"], + unique = true + ) + ] +) +data class SearchQuery( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val query: String +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt new file mode 100644 index 0000000..c6a9ea6 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt @@ -0,0 +1,34 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.* + + +@Entity +data class Song( + @PrimaryKey val id: String, + val title: String, + val albumInfoId: Long?, + val durationText: String, + val thumbnailUrl: String?, + val likedAt: Long? = null, + val totalPlayTimeMs: Long = 0 +) { + val formattedTotalPlayTime: String + get() { + val seconds = totalPlayTimeMs / 1000 + + val hours = seconds / 3600 + + return when { + hours == 0L -> "${seconds / 60}m" + hours < 24L -> "${hours}h" + else -> "${hours / 24}d" + } + } + + fun toggleLike(): Song { + return copy( + likedAt = if (likedAt == null) System.currentTimeMillis() else null + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongInPlaylist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongInPlaylist.kt new file mode 100644 index 0000000..ed711e8 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongInPlaylist.kt @@ -0,0 +1,31 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.compose.runtime.Immutable +import androidx.room.ColumnInfo +import androidx.room.Entity +import androidx.room.ForeignKey + + +@Immutable +@Entity( + primaryKeys = ["songId", "playlistId"], + foreignKeys = [ + ForeignKey( + entity = Song::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Playlist::class, + parentColumns = ["id"], + childColumns = ["playlistId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class SongInPlaylist( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val playlistId: Long, + val position: Int +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithAuthors.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithAuthors.kt new file mode 100644 index 0000000..e8216d0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithAuthors.kt @@ -0,0 +1,28 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.compose.runtime.Immutable +import androidx.room.* + + +@Immutable +@Entity( + primaryKeys = ["songId", "authorInfoId"], + foreignKeys = [ + ForeignKey( + entity = Song::class, + parentColumns = ["id"], + childColumns = ["songId"], + onDelete = ForeignKey.CASCADE + ), + ForeignKey( + entity = Info::class, + parentColumns = ["id"], + childColumns = ["authorInfoId"], + onDelete = ForeignKey.CASCADE + ) + ] +) +data class SongWithAuthors( + val songId: String, + @ColumnInfo(index = true) val authorInfoId: Long +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithInfo.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithInfo.kt new file mode 100644 index 0000000..066b041 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SongWithInfo.kt @@ -0,0 +1,25 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.Embedded +import androidx.room.Junction +import androidx.room.Relation + +open class SongWithInfo( + @Embedded val song: Song, + @Relation( + entity = Info::class, + parentColumn = "albumInfoId", + entityColumn = "id" + ) val album: Info?, + @Relation( + entity = Info::class, + parentColumn = "id", + entityColumn = "id", + associateBy = Junction( + value = SongWithAuthors::class, + parentColumn = "songId", + entityColumn = "authorInfoId" + ) + ) + val authors: List? +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongInPlaylist.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongInPlaylist.kt new file mode 100644 index 0000000..b269174 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/models/SortedSongInPlaylist.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.vimusic.models + +import androidx.room.ColumnInfo +import androidx.room.DatabaseView + +@DatabaseView("SELECT * FROM SongInPlaylist ORDER BY position") +data class SortedSongInPlaylist( + @ColumnInfo(index = true) val songId: String, + @ColumnInfo(index = true) val playlistId: Long, + val position: Int +) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt new file mode 100644 index 0000000..30f0d3f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/services/PlayerService.kt @@ -0,0 +1,354 @@ +package it.vfsfitvnm.vimusic.services + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.graphics.Bitmap +import android.graphics.drawable.BitmapDrawable +import android.net.Uri +import android.os.Build +import android.os.Bundle +import androidx.annotation.DrawableRes +import androidx.annotation.StringRes +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.ExperimentalFoundationApi +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.core.app.NotificationCompat +import androidx.core.graphics.drawable.IconCompat +import androidx.core.graphics.drawable.toBitmap +import androidx.core.net.toUri +import androidx.media3.common.* +import androidx.media3.common.util.Util +import androidx.media3.database.StandaloneDatabaseProvider +import androidx.media3.datasource.DataSource +import androidx.media3.datasource.DefaultHttpDataSource +import androidx.media3.datasource.ResolvingDataSource +import androidx.media3.datasource.cache.CacheDataSource +import androidx.media3.datasource.cache.NoOpCacheEvictor +import androidx.media3.datasource.cache.SimpleCache +import androidx.media3.exoplayer.ExoPlayer +import androidx.media3.exoplayer.analytics.AnalyticsListener +import androidx.media3.exoplayer.analytics.PlaybackStats +import androidx.media3.exoplayer.analytics.PlaybackStatsListener +import androidx.media3.exoplayer.source.DefaultMediaSourceFactory +import androidx.media3.session.MediaController +import androidx.media3.session.MediaNotification +import androidx.media3.session.MediaNotification.ActionFactory +import androidx.media3.session.MediaSession +import androidx.media3.session.MediaSessionService +import coil.ImageLoader +import coil.request.ImageRequest +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.MainActivity +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.utils.RingBuffer +import it.vfsfitvnm.vimusic.utils.YoutubePlayer +import it.vfsfitvnm.vimusic.utils.insert +import it.vfsfitvnm.youtubemusic.Outcome +import kotlinx.coroutines.* +import kotlin.math.roundToInt + + +@ExperimentalAnimationApi +@ExperimentalFoundationApi +@ExperimentalTextApi +class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller, + MediaNotification.Provider, + PlaybackStatsListener.Callback, Player.Listener,YoutubePlayer.Radio.Listener { + + companion object { + private const val NotificationId = 1001 + private const val NotificationChannelId = "default_channel_id" + } + + private val cache: SimpleCache by lazy(LazyThreadSafetyMode.NONE) { + SimpleCache(cacheDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(this)) + } + + private lateinit var mediaSession: MediaSession + + private val notificationManager by lazy(LazyThreadSafetyMode.NONE) { + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + } + + private var lastArtworkUri: Uri? = null + private var lastBitmap: Bitmap? = null + + private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job() + + override fun onCreate() { + super.onCreate() + + createNotificationChannel() + setMediaNotificationProvider(this) + + val player = ExoPlayer.Builder(this) + .setHandleAudioBecomingNoisy(true) + .setWakeMode(C.WAKE_MODE_LOCAL) + .setMediaSourceFactory(DefaultMediaSourceFactory(createDataSourceFactory())) + .setAudioAttributes( + AudioAttributes.Builder() + .setUsage(C.USAGE_MEDIA) + .setContentType(C.CONTENT_TYPE_MUSIC) + .build(), + true + ) + .build() + .also { player -> + player.playWhenReady = true + player.addAnalyticsListener(PlaybackStatsListener(false, this)) + } + + mediaSession = MediaSession.Builder(this, player) + .withSessionActivity() + .setMediaItemFiller(this) + .build() + + player.addListener(this) + YoutubePlayer.Radio.listener = this + } + + override fun onDestroy() { + mediaSession.player.release() + mediaSession.release() + cache.release() + super.onDestroy() + } + + override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession { + return mediaSession + } + + override fun onPlaybackStatsReady( + eventTime: AnalyticsListener.EventTime, + playbackStats: PlaybackStats + ) { + val mediaItem = + eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem + + coroutineScope.launch(Dispatchers.IO) { + Database.insert(mediaItem) + Database.incrementTotalPlayTimeMs(mediaItem.mediaId, playbackStats.totalPlayTimeMs) + } + } + + override fun process(play: Boolean) { + if (YoutubePlayer.Radio.isActive) { + coroutineScope.launch { + YoutubePlayer.Radio.process(mediaSession.player, play = play) + } + } + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + if (YoutubePlayer.Radio.isActive) { + coroutineScope.launch { + YoutubePlayer.Radio.process(mediaSession.player) + } + } + } + + override fun fillInLocalConfiguration( + session: MediaSession, + controller: MediaSession.ControllerInfo, + mediaItem: MediaItem + ): MediaItem { + return mediaItem.buildUpon() + .setUri(mediaItem.mediaId) + .setCustomCacheKey(mediaItem.mediaId) + .build() + } + + override fun createNotification( + mediaController: MediaController, + actionFactory: ActionFactory, + onNotificationChangedCallback: MediaNotification.Provider.Callback + ): MediaNotification { + fun NotificationCompat.Builder.addMediaAction( + @DrawableRes resId: Int, + @StringRes stringId: Int, + @Player.Command command: Long + ): NotificationCompat.Builder { + return addAction( + actionFactory.createMediaAction( + IconCompat.createWithResource(this@PlayerService, resId), + getString(stringId), + command + ) + ) + } + + val mediaMetadata = mediaController.mediaMetadata + + val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId) + .setContentTitle(mediaMetadata.title) + .setContentText(mediaMetadata.artist) + .addMediaAction( + R.drawable.play_skip_back, + R.string.media3_controls_seek_to_previous_description, + ActionFactory.COMMAND_SKIP_TO_PREVIOUS + ).run { + if (mediaController.playbackState == Player.STATE_ENDED || !mediaController.playWhenReady) { + addMediaAction( + R.drawable.play, + R.string.media3_controls_play_description, + ActionFactory.COMMAND_PLAY + ) + } else { + addMediaAction( + R.drawable.pause, + R.string.media3_controls_pause_description, + ActionFactory.COMMAND_PAUSE + ) + } + }.addMediaAction( + R.drawable.play_skip_forward, + R.string.media3_controls_seek_to_next_description, + ActionFactory.COMMAND_SKIP_TO_NEXT + ) + .setContentIntent(mediaController.sessionActivity) + .setDeleteIntent( + actionFactory.createMediaActionPendingIntent( + ActionFactory.COMMAND_STOP + ) + ) + .setAutoCancel(true) + .setOnlyAlertOnce(true) + .setShowWhen(false) + .setSmallIcon(R.drawable.app_icon) + .setOngoing(false) + .setStyle( + androidx.media.app.NotificationCompat.MediaStyle() + .setShowActionsInCompactView(0, 1, 2) + .setMediaSession(mediaSession.sessionCompatToken as android.support.v4.media.session.MediaSessionCompat.Token) + ) + .setVisibility(NotificationCompat.VISIBILITY_PUBLIC) + + + if (lastArtworkUri == mediaMetadata.artworkUri) { + builder.setLargeIcon(lastBitmap) + } else { + val size = (96 * resources.displayMetrics.density).roundToInt() + + builder.setLargeIcon( + resources.getDrawable(R.drawable.disc_placeholder, null)?.toBitmap(size, size) + ) + + ImageLoader(applicationContext) + .enqueue( + ImageRequest.Builder(applicationContext) + .listener { _, result -> + lastBitmap = (result.drawable as BitmapDrawable).bitmap + lastArtworkUri = mediaMetadata.artworkUri + + onNotificationChangedCallback.onNotificationChanged( + MediaNotification( + NotificationId, + builder.setLargeIcon(lastBitmap).build() + ) + ) + } + .data("${mediaMetadata.artworkUri}-w${size}-h${size}") + .build() + ) + } + + return MediaNotification(NotificationId, builder.build()) + } + + override fun handleCustomAction( + mediaController: MediaController, + action: String, + extras: Bundle + ) = Unit + + private fun createNotificationChannel() { + if (Util.SDK_INT >= 26 && notificationManager.getNotificationChannel(NotificationChannelId) == null) { + notificationManager.createNotificationChannel( + NotificationChannel( + NotificationChannelId, + getString(R.string.default_notification_channel_name), + NotificationManager.IMPORTANCE_LOW + ) + ) + } + } + + private fun createCacheDataSource(): DataSource.Factory { + return CacheDataSource.Factory().setCache(cache).apply { + setUpstreamDataSourceFactory( + DefaultHttpDataSource.Factory() + .setConnectTimeoutMs(16000) + .setReadTimeoutMs(8000) + .setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0") + ) + } + } + + private fun createDataSourceFactory(): DataSource.Factory { + val chunkLength = 512 * 1024L + val ringBuffer = RingBuffer?>(2) { null } + + return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec -> + val videoId = dataSpec.key ?: error("A key must be set") + + if (cache.isCached(videoId, dataSpec.position, chunkLength)) { + dataSpec + } else { + when (videoId) { + ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second) + ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second) + else -> { + val url = runBlocking(Dispatchers.IO) { + it.vfsfitvnm.youtubemusic.YouTube.player(videoId) + }.flatMap { body -> + when (val status = body.playabilityStatus.status) { + "OK" -> body.streamingData?.adaptiveFormats?.findLast { format -> + format.itag == 251 || format.itag == 140 + }?.url?.let { Outcome.Success(it) } ?: Outcome.Error.Unhandled( + PlaybackException( + "Couldn't find a playable audio format", + null, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + ) + else -> Outcome.Error.Unhandled( + PlaybackException( + status, + null, + PlaybackException.ERROR_CODE_REMOTE_ERROR + ) + ) + } + } + + when (url) { + is Outcome.Success -> { + ringBuffer.append(videoId to url.value.toUri()) + dataSpec.withUri(url.value.toUri()) + .subrange(dataSpec.uriPositionOffset, chunkLength) + } + // TODO + is Outcome.Error.Network -> throw Error("no network") + is Outcome.Error.Unhandled -> throw url.throwable + else -> TODO("unreachable") + } + } + } + } + } + } + + private fun MediaSession.Builder.withSessionActivity(): MediaSession.Builder { + return setSessionActivity( + PendingIntent.getActivity( + this@PlayerService, + 0, + Intent(this@PlayerService, MainActivity::class.java), + if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0 + ) + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt new file mode 100644 index 0000000..62db07a --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/BottomSheet.kt @@ -0,0 +1,304 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.VectorConverter +import androidx.compose.foundation.clickable +import androidx.compose.foundation.gestures.DraggableState +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.gestures.draggable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.drawBehind +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.RectangleShape +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.input.nestedscroll.NestedScrollConnection +import androidx.compose.ui.input.nestedscroll.NestedScrollSource +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.unit.Velocity +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.launch +import kotlin.math.absoluteValue + + +@Composable +@NonRestartableComposable +fun BottomSheet( + lowerBound: Dp, + upperBound: Dp, + modifier: Modifier = Modifier, + peekHeight: Dp = 0.dp, + elevation: Dp = 8.dp, + shape: Shape = RectangleShape, + handleOutsideInteractionsWhenExpanded: Boolean = false, + interactionSource: MutableInteractionSource? = null, + collapsedContent: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit +) { + BottomSheet( + state = rememberBottomSheetState(lowerBound, upperBound), + modifier = modifier, + peekHeight = peekHeight, + elevation = elevation, + shape = shape, + handleOutsideInteractionsWhenExpanded = handleOutsideInteractionsWhenExpanded, + interactionSource = interactionSource, + collapsedContent = collapsedContent, + content = content + ) +} + +@Composable +fun BottomSheet( + state: BottomSheetState, + modifier: Modifier = Modifier, + peekHeight: Dp = 0.dp, + elevation: Dp = 8.dp, + shape: Shape = RectangleShape, + handleOutsideInteractionsWhenExpanded: Boolean = false, + interactionSource: MutableInteractionSource? = null, + collapsedContent: @Composable BoxScope.() -> Unit, + content: @Composable BoxScope.() -> Unit +) { + var lastOffset by remember { + mutableStateOf(state.value) + } + + BackHandler(enabled = !state.isCollapsed, onBack = state.collapse) + + Box { + if (handleOutsideInteractionsWhenExpanded && !state.isCollapsed) { + Spacer( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + state.collapse() + } + } + .draggable( + state = state, + onDragStarted = { + lastOffset = state.value + }, + onDragStopped = { velocity -> + if (velocity.absoluteValue > 300 && lastOffset != state.value) { + if (lastOffset > state.value) { + state.collapse() + } else { + state.expand() + } + } else { + if (state.upperBound - state.value > state.value - state.lowerBound) { + state.collapse() + } else { + state.expand() + } + } + }, + orientation = Orientation.Vertical + ) + .drawBehind { + drawRect(color = Color.Black.copy(alpha = 0.5f * state.progress)) + } + .fillMaxSize() + ) + } + + Box( + modifier = modifier + .offset { + val y = (state.upperBound - state.value + peekHeight) + .roundToPx() + .coerceAtLeast(0) + IntOffset(x = 0, y = y) + } + .shadow(elevation = elevation, shape = shape) + .clip(shape) + .draggable( + state = state, + interactionSource = interactionSource, + onDragStarted = { + lastOffset = state.value + }, + onDragStopped = { velocity -> + if (velocity.absoluteValue > 300 && lastOffset != state.value) { + if (lastOffset > state.value) { + state.collapse() + } else { + state.expand() + } + } else { + if (state.upperBound - state.value > state.value - state.lowerBound) { + state.collapse() + } else { + state.expand() + } + } + }, + orientation = Orientation.Vertical + ) + .clickable( + enabled = !state.isRunning && state.isCollapsed, + indication = null, + interactionSource = interactionSource + ?: remember { MutableInteractionSource() }, + onClick = state.expand + ) + .fillMaxSize() + ) { + if (!state.isCollapsed) { + content() + } + + collapsedContent() + } + } +} + + +@Stable +class BottomSheetState( + draggableState: DraggableState, + valueState: State, + isRunningState: State, + isCollapsedState: State, + isExpandedState: State, + progressState: State, + val lowerBound: Dp, + val upperBound: Dp, + val collapse: () -> Unit, + val expand: () -> Unit, +) : DraggableState by draggableState { + val value by valueState + + val isRunning by isRunningState + + val isCollapsed by isCollapsedState + + val isExpanded by isExpandedState + + val progress by progressState + + fun nestedScrollConnection(initialIsTopReached: Boolean = true): NestedScrollConnection { + return object : NestedScrollConnection { + var isTopReached = initialIsTopReached + + override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset { + if (isExpanded && available.y < 0) { + isTopReached = false + } + + if (isTopReached) { + dispatchRawDelta(available.y) + return available + } + + return Offset.Zero + } + + override fun onPostScroll( + consumed: Offset, + available: Offset, + source: NestedScrollSource + ): Offset { + if (!isTopReached) { + isTopReached = consumed.y == 0f && available.y > 0 + } + + return Offset.Zero + } + + override suspend fun onPreFling(available: Velocity): Velocity { + if (isTopReached) { + coroutineScope { + if (available.y.absoluteValue > 1000) { + collapse() + } else { + if (upperBound - value > value - lowerBound) { + collapse() + } else { + expand() + } + } + } + + return available + } + + return Velocity.Zero + } + + override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity { + isTopReached = false + return super.onPostFling(consumed, available) + } + } + } +} + +@Composable +fun rememberBottomSheetState(lowerBound: Dp, upperBound: Dp): BottomSheetState { + val density = LocalDensity.current + val coroutineScope = rememberCoroutineScope() + + var wasExpanded by rememberSaveable { + mutableStateOf(false) + } + + val animatable = remember(lowerBound, upperBound) { + Animatable(if (wasExpanded) upperBound else lowerBound, Dp.VectorConverter).also { + it.updateBounds(lowerBound, upperBound) + } + } + + LaunchedEffect(animatable.value == upperBound) { + wasExpanded = animatable.value == upperBound + } + + return remember(animatable, coroutineScope) { + BottomSheetState( + draggableState = DraggableState { delta -> + coroutineScope.launch { + animatable.snapTo(animatable.value - density.run { delta.toDp() }) + } + }, + valueState = animatable.asState(), + lowerBound = lowerBound, + upperBound = upperBound, + isRunningState = derivedStateOf { + animatable.isRunning + }, + isCollapsedState = derivedStateOf { + animatable.value == lowerBound + }, + isExpandedState = derivedStateOf { + animatable.value == upperBound + }, + progressState = derivedStateOf { + 1f - (upperBound - animatable.value) / (upperBound - lowerBound) + }, + collapse = { + coroutineScope.launch { + animatable.animateTo(animatable.lowerBound!!) + } + }, + expand = { + coroutineScope.launch { + animatable.animateTo(animatable.upperBound!!) + } + } + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt new file mode 100644 index 0000000..765c1c4 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyButton.kt @@ -0,0 +1,98 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + + +@Composable +fun ChunkyButton( + onClick: () -> Unit, + backgroundColor: Color, + modifier: Modifier = Modifier, + text: String? = null, + secondaryText: String? = null, + textStyle: TextStyle = TextStyle.Default, + secondaryTextStyle: TextStyle = TextStyle.Default, + rippleColor: Color = Color.Unspecified, + @DrawableRes icon: Int? = null, + shape: Shape = RoundedCornerShape(16.dp), + colorFilter: ColorFilter = ColorFilter.tint(rippleColor), + onMore: (() -> Unit)? = null +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = modifier + .clip(shape) + .background(backgroundColor) + .clickable( + indication = rememberRipple(bounded = true, color = rippleColor), + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick + ) + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + icon?.let { icon -> + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = colorFilter, + modifier = Modifier + .size(20.dp) + ) + } + + text?.let { text -> + Column { + BasicText( + text = text, + style = textStyle + ) + + secondaryText?.let { secondaryText -> + BasicText( + text = secondaryText, + style = secondaryTextStyle + ) + } + } + } + + onMore?.let { onMore -> + Spacer( + modifier = Modifier + .background(rippleColor.copy(alpha = 0.6f)) + .width(1.dp) + .height(24.dp) + ) + + Image( + // TODO: this is themed... + painter = painterResource(it.vfsfitvnm.vimusic.R.drawable.ellipsis_vertical), + contentDescription = null, + colorFilter = ColorFilter.tint(rippleColor.copy(alpha = 0.6f)), + modifier = Modifier + .clickable(onClick = onMore) + .size(20.dp) + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt new file mode 100644 index 0000000..c739794 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ChunkyChipGroup.kt @@ -0,0 +1,50 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.foundation.horizontalScroll +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun ChipGroup( + items: List>, + value: T, + selectedBackgroundColor: Color, + unselectedBackgroundColor: Color, + selectedTextStyle: TextStyle, + unselectedTextStyle: TextStyle, + modifier: Modifier = Modifier, + shape: Shape = RoundedCornerShape(16.dp), + onValueChanged: (T) -> Unit +) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .horizontalScroll(rememberScrollState()) + .then(modifier) + ) { + items.forEach { chipItem -> + ChunkyButton( + text = chipItem.text, + textStyle = if (chipItem.value == value) selectedTextStyle else unselectedTextStyle, + backgroundColor = if (chipItem.value == value) selectedBackgroundColor else unselectedBackgroundColor, + shape = shape, + onClick = { + onValueChanged(chipItem.value) + } + ) + } + } +} + +data class ChipItem( + val text: String, + val value: T +) \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ExpandableText.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ExpandableText.kt new file mode 100644 index 0000000..bce3c24 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/ExpandableText.kt @@ -0,0 +1,80 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.getValue +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.unit.dp + +@Composable +fun ExpandableText( + text: String, + style: TextStyle, + showMoreTextStyle: TextStyle, + minimizedMaxLines: Int, + backgroundColor: Color, + modifier: Modifier = Modifier +) { + var isExpanded by remember { + mutableStateOf(false) + } + + var hasVisualOverflow by remember { + mutableStateOf(true) + } + + Column( + modifier = modifier + ) { + Box { + BasicText( + text = text, + maxLines = if (isExpanded) Int.MAX_VALUE else minimizedMaxLines, + onTextLayout = { + hasVisualOverflow = it.hasVisualOverflow + }, + style = style + ) + + if (hasVisualOverflow) { + Spacer( + modifier = Modifier + .align(Alignment.BottomCenter) + .fillMaxWidth() + .height(14.dp) + .background( + brush = Brush.verticalGradient( + colors = listOf( + backgroundColor.copy(alpha = 0.5f), + backgroundColor + ) + ) + ) + ) + } + } + + BasicText( + text = if (isExpanded) "Less" else "More", + style = showMoreTextStyle, + modifier = Modifier + .align(Alignment.End) + .clickable( + indication = null, + interactionSource = remember { MutableInteractionSource() }, + onClick = { isExpanded = !isExpanded } + ) + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt new file mode 100644 index 0000000..db41735 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/Menu.kt @@ -0,0 +1,77 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.activity.compose.BackHandler +import androidx.compose.animation.* +import androidx.compose.foundation.background +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.input.pointer.pointerInput + +val LocalMenuState = compositionLocalOf { TODO() } + +class MenuState(isDisplayedState: MutableState) { + var isDisplayed by isDisplayedState + private set + + var content: @Composable () -> Unit = {} + + fun display(content: @Composable () -> Unit) { + this.content = content + isDisplayed = true + } + + fun hide() { + isDisplayed = false + } +} + +@Composable +fun rememberMenuState(): MenuState { + val isDisplayedState = remember { + mutableStateOf(false) + } + + return remember { + MenuState( + isDisplayedState = isDisplayedState + ) + } +} + +@Composable +fun BottomSheetMenu( + state: MenuState, + modifier: Modifier = Modifier +) { + AnimatedVisibility( + visible = state.isDisplayed, + enter = fadeIn(), + exit = fadeOut() + ) { + BackHandler(onBack = state::hide) + + Spacer( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { + state.hide() + } + } + .background(Color.Black.copy(alpha = 0.5f)) + .fillMaxSize() + ) + } + + AnimatedVisibility( + visible = state.isDisplayed, + enter = slideInVertically { it }, + exit = slideOutVertically { it }, + modifier = modifier + ) { + state.content() + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt new file mode 100644 index 0000000..de0a79d --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/MusicBars.kt @@ -0,0 +1,59 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.animation.core.Animatable +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.Shape +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.launch + +@Composable +fun MusicBars( + color: Color, + modifier: Modifier = Modifier, + barWidth: Dp = 4.dp, + shape: Shape = CircleShape +) { + val animatablesWithSteps = remember { + listOf( + Animatable(0f) to listOf(0.2f, 0.8f, 0.1f, 0.1f, 0.3f, 0.1f, 0.2f, 0.8f, 0.7f, 0.2f, 0.4f, 0.9f, 0.7f, 0.6f, 0.1f, 0.3f, 0.1f, 0.4f, 0.1f, 0.8f, 0.7f, 0.9f, 0.5f, 0.6f, 0.3f, 0.1f), + Animatable(0f) to listOf(0.2f, 0.5f, 1.0f, 0.5f, 0.3f, 0.1f, 0.2f, 0.3f, 0.5f, 0.1f, 0.6f, 0.5f, 0.3f, 0.7f, 0.8f, 0.9f, 0.3f, 0.1f, 0.5f, 0.3f, 0.6f, 1.0f, 0.6f, 0.7f, 0.4f, 0.1f), + Animatable(0f) to listOf(0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.5f, 0.6f, 0.7f, 0.2f, 0.3f, 0.1f, 0.5f, 0.4f, 0.6f, 0.7f, 0.1f, 0.4f, 0.3f, 0.1f, 0.4f, 0.3f, 0.7f) + ) + } + + LaunchedEffect(Unit) { + animatablesWithSteps.forEach { (animatable, steps) -> + launch { + while (true) { + steps.forEach { step -> + animatable.animateTo(step) + } + } + } + } + } + + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.Bottom, + modifier = modifier + ) { + animatablesWithSteps.forEach { (animatable) -> + Spacer( + modifier = Modifier + .background(color = color, shape = shape) + .fillMaxHeight(animatable.value) + .width(barWidth) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/OutcomeItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/OutcomeItem.kt new file mode 100644 index 0000000..e22449f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/OutcomeItem.kt @@ -0,0 +1,127 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.Composable +import androidx.compose.runtime.SideEffect +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.italic +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.youtubemusic.Outcome + +@Composable +inline fun OutcomeItem( + outcome: Outcome, + noinline onInitialize: (() -> Unit)? = null, + noinline onRetry: (() -> Unit)? = onInitialize, + onUninitialized: @Composable () -> Unit = { + onInitialize?.let { + SideEffect(it) + } + }, + onLoading: @Composable () -> Unit = {}, + onError: @Composable (Outcome.Error) -> Unit = { + Error( + error = it, + onRetry = onRetry, + ) + }, + onSuccess: @Composable (T) -> Unit +) { + when (outcome) { + is Outcome.Initial -> onUninitialized() + is Outcome.Loading -> onLoading() + is Outcome.Error -> onError(outcome) + is Outcome.Recovered -> onError(outcome.error) + is Outcome.Success -> onSuccess(outcome.value) + } +} + +@Composable +fun Error( + error: Outcome.Error, + modifier: Modifier = Modifier, + onRetry: (() -> Unit)? = null +) { + Column( + verticalArrangement = Arrangement.spacedBy( + space = 8.dp, + alignment = Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(R.drawable.alert_circle), + contentDescription = null, + colorFilter = ColorFilter.tint(Color(0xFFFC5F5F)), + modifier = Modifier + .padding(horizontal = 16.dp) + .size(48.dp) + ) + BasicText( + text = when (error) { + is Outcome.Error.Network -> "Couldn't reach the Internet" + is Outcome.Error.Unhandled -> (error.throwable.message ?: error.throwable.toString()) + }, + style = LocalTypography.current.xxs.medium.secondary, + ) + + onRetry?.let { retry -> + BasicText( + text = "Retry", + style = LocalTypography.current.xxs.medium, + modifier = Modifier + .clickable(onClick = retry) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + ) + } + + } +} + +@Composable +fun Message( + text: String, + modifier: Modifier = Modifier, + @DrawableRes icon: Int = R.drawable.alert_circle +) { + Column( + verticalArrangement = Arrangement.spacedBy( + space = 8.dp, + alignment = Alignment.CenterVertically + ), + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .fillMaxSize() + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(LocalColorPalette.current.darkGray), + modifier = Modifier + .padding(horizontal = 16.dp) + .size(36.dp) + ) + BasicText( + text = text, + style = LocalTypography.current.xs.medium.secondary.italic, + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TopAppBar.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TopAppBar.kt new file mode 100644 index 0000000..6b9c50e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/TopAppBar.kt @@ -0,0 +1,23 @@ +package it.vfsfitvnm.vimusic.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.RowScope +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier + +@Composable +inline fun TopAppBar( + modifier: Modifier = Modifier, + content: @Composable RowScope.() -> Unit +) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = modifier + .fillMaxWidth(), + content = content + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt new file mode 100644 index 0000000..37e6f79 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Dialog.kt @@ -0,0 +1,217 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.Dialog +import it.vfsfitvnm.vimusic.ui.components.ChunkyButton +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold +import kotlinx.coroutines.delay + +@Composable +fun TextFieldDialog( + hintText: String, + onDismiss: () -> Unit, + onDone: (String) -> Unit, + modifier: Modifier = Modifier, + cancelText: String = "Cancel", + doneText: String = "Done", + initialTextInput: String = "", + onCancel: () -> Unit = onDismiss, + isTextInputValid: (String) -> Boolean = { it.isNotEmpty() } +) { + val focusRequester = remember { + FocusRequester() + } + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = initialTextInput, + selection = TextRange(initialTextInput.length) + ) + ) + } + + DefaultDialog( + onDismiss = onDismiss, + modifier = modifier + ) { + BasicTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + }, + textStyle = typography.xs.semiBold.center, + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done), + keyboardActions = KeyboardActions( + onDone = { + if (isTextInputValid(textFieldValue.text)) { + onDismiss() + onDone(textFieldValue.text) + } + } + ), + cursorBrush = SolidColor(colorPalette.text), + decorationBox = { innerTextField -> + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .weight(1f) + ) { + androidx.compose.animation.AnimatedVisibility( + visible = textFieldValue.text.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)), + ) { + BasicText( + text = hintText, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = typography.xs.semiBold.secondary, + ) + } + + innerTextField() + } + }, + modifier = Modifier + .padding(all = 16.dp) + .focusRequester(focusRequester) + ) + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + ) { + ChunkyButton( + backgroundColor = colorPalette.lightBackground, + text = cancelText, + textStyle = typography.xs.semiBold, + shape = RoundedCornerShape(36.dp), + onClick = onCancel + ) + + ChunkyButton( + backgroundColor = colorPalette.primaryContainer, + text = doneText, + textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer), + shape = RoundedCornerShape(36.dp), + onClick = { + if (isTextInputValid(textFieldValue.text)) { + onDismiss() + onDone(textFieldValue.text) + } + } + ) + } + } + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } +} + +@Composable +fun ConfirmationDialog( + text: String, + onDismiss: () -> Unit, + onConfirm: () -> Unit, + modifier: Modifier = Modifier, + cancelText: String = "Cancel", + confirmText: String = "Confirm", + onCancel: () -> Unit = onDismiss +) { + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + DefaultDialog( + onDismiss = onDismiss, + modifier = modifier + ) { + BasicText( + text = text, + style = typography.xs.semiBold.center, + modifier = Modifier + .padding(all = 16.dp) + ) + + Row( + horizontalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + ) { + ChunkyButton( + backgroundColor = colorPalette.lightBackground, + text = cancelText, + textStyle = typography.xs.semiBold, + shape = RoundedCornerShape(36.dp), + onClick = onCancel + ) + + ChunkyButton( + backgroundColor = colorPalette.primaryContainer, + text = confirmText, + textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer), + shape = RoundedCornerShape(36.dp), + onClick = { + onConfirm() + onDismiss() + } + ) + } + } +} + +@Composable +private inline fun DefaultDialog( + noinline onDismiss: () -> Unit, + modifier: Modifier = Modifier, + crossinline content: @Composable ColumnScope.() -> Unit +) { + Dialog( + onDismissRequest = onDismiss + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + .padding(all = 48.dp) + .background( + color = LocalColorPalette.current.lightBackground, + shape = RoundedCornerShape(8.dp) + ) + .padding(horizontal = 24.dp, vertical = 16.dp), + content = content + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt new file mode 100644 index 0000000..3f687c9 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/MediaItemMenu.kt @@ -0,0 +1,488 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.with +import androidx.compose.foundation.gestures.detectTapGestures +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Modifier +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.platform.LocalContext +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.route.empty +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.internal +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.SongInPlaylist +import it.vfsfitvnm.vimusic.models.SongWithInfo +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.screens.rememberAlbumRoute +import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute +import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute +import it.vfsfitvnm.vimusic.utils.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@ExperimentalAnimationApi +@Composable +fun InFavoritesMediaItemMenu( + song: SongWithInfo, + modifier: Modifier = Modifier, + // https://issuetracker.google.com/issues/226410236 + onDismiss: () -> Unit = LocalMenuState.current.let { it::hide } +) { + val coroutineScope = rememberCoroutineScope() + + NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = onDismiss, + onRemoveFromFavorites = { + coroutineScope.launch(Dispatchers.IO) { + Database.update(song.song.toggleLike()) + } + }, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun InHistoryMediaItemMenu( + song: SongWithInfo, + modifier: Modifier = Modifier, + // https://issuetracker.google.com/issues/226410236 + onDismiss: () -> Unit = LocalMenuState.current.let { it::hide } +) { + val coroutineScope = rememberCoroutineScope() + + var isDeletingFromDatabase by remember { + mutableStateOf(false) + } + + if (isDeletingFromDatabase) { + ConfirmationDialog( + text = "Do you really want to permanently delete this song? It will removed from any playlist as well.\nThis action is irreversible.", + onDismiss = { + isDeletingFromDatabase = false + }, + onConfirm = { + onDismiss() + coroutineScope.launch(Dispatchers.IO) { + Database.delete(song.song) + } + } + ) + } + + NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = onDismiss, + onDeleteFromDatabase = { + isDeletingFromDatabase = true + }, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun InPlaylistMediaItemMenu( + playlistId: Long, + positionInPlaylist: Int, + song: SongWithInfo, + modifier: Modifier = Modifier, + // https://issuetracker.google.com/issues/226410236 + onDismiss: () -> Unit = LocalMenuState.current.let { it::hide } +) { + val coroutineScope = rememberCoroutineScope() + + NonQueuedMediaItemMenu( + mediaItem = song.asMediaItem, + onDismiss = onDismiss, + onRemoveFromPlaylist = { + coroutineScope.launch(Dispatchers.IO) { + Database.internal.runInTransaction { + Database.delete( + SongInPlaylist( + songId = song.song.id, + playlistId = playlistId, + position = positionInPlaylist + ) + ) + Database.decrementSongPositions( + playlistId = playlistId, + fromPosition = positionInPlaylist + 1 + ) + } + } + }, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun NonQueuedMediaItemMenu( + mediaItem: MediaItem, + modifier: Modifier = Modifier, + // https://issuetracker.google.com/issues/226410236 + onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }, + onRemoveFromPlaylist: (() -> Unit)? = null, + onDeleteFromDatabase: (() -> Unit)? = null, + onRemoveFromFavorites: (() -> Unit)? = null, +) { + val player = LocalYoutubePlayer.current + + BaseMediaItemMenu( + mediaItem = mediaItem, + onDismiss = onDismiss, + onStartRadio = { + val playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId") + YoutubePlayer.Radio.setup(playlistId = playlistId) + player?.mediaController?.forcePlay(mediaItem) + }, + onPlayNext = if (player?.playbackState == Player.STATE_READY) ({ + player.mediaController.addNext(mediaItem) + }) else null, + onEnqueue = if (player?.playbackState == Player.STATE_READY) ({ + player.mediaController.enqueue(mediaItem) + }) else null, + onRemoveFromPlaylist = onRemoveFromPlaylist, + onDeleteFromDatabase = onDeleteFromDatabase, + onRemoveFromFavorites = onRemoveFromFavorites, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun QueuedMediaItemMenu( + mediaItem: MediaItem, + indexInQueue: Int, + modifier: Modifier = Modifier, + // https://issuetracker.google.com/issues/226410236 + onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }, + onGlobalRouteEmitted: (() -> Unit)? = null +) { + val player = LocalYoutubePlayer.current + + BaseMediaItemMenu( + mediaItem = mediaItem, + onDismiss = onDismiss, + onRemoveFromQueue = if (player?.mediaItemIndex != indexInQueue) ({ + player?.mediaController?.removeMediaItem(indexInQueue) + }) else null, + onGlobalRouteEmitted = onGlobalRouteEmitted, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun BaseMediaItemMenu( + mediaItem: MediaItem, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onStartRadio: (() -> Unit)? = null, + onPlayNext: (() -> Unit)? = null, + onEnqueue: (() -> Unit)? = null, + onRemoveFromQueue: (() -> Unit)? = null, + onRemoveFromPlaylist: (() -> Unit)? = null, + onDeleteFromDatabase: (() -> Unit)? = null, + onRemoveFromFavorites: (() -> Unit)? = null, + onGlobalRouteEmitted: (() -> Unit)? = null, +) { + val context = LocalContext.current + val coroutineScope = rememberCoroutineScope() + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + MediaItemMenu( + mediaItem = mediaItem, + onDismiss = onDismiss, + onStartRadio = onStartRadio, + onPlayNext = onPlayNext, + onEnqueue = onEnqueue, + onAddToPlaylist = { playlist, position -> + coroutineScope.launch(Dispatchers.IO) { + val playlistId = Database.playlist(playlist.id)?.id ?: Database.insert(playlist) + + if (Database.song(mediaItem.mediaId) == null) { + Database.insert(mediaItem) + } + + Database.insert( + SongInPlaylist( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = position + ) + ) + } + }, + onDeleteFromDatabase = onDeleteFromDatabase, + onRemoveFromFavorites = onRemoveFromFavorites, + onRemoveFromPlaylist = onRemoveFromPlaylist, + onRemoveFromQueue = onRemoveFromQueue, + onGoToAlbum = albumRoute::global, + onGoToArtist = artistRoute::global, + onShare = { + context.shareAsYouTubeSong(mediaItem) + }, + onGlobalRouteEmitted = onGlobalRouteEmitted, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun MediaItemMenu( + mediaItem: MediaItem, + onDismiss: () -> Unit, + modifier: Modifier = Modifier, + onStartRadio: (() -> Unit)? = null, + onPlayNext: (() -> Unit)? = null, + onEnqueue: (() -> Unit)? = null, + onDeleteFromDatabase: (() -> Unit)? = null, + onRemoveFromQueue: (() -> Unit)? = null, + onRemoveFromFavorites: (() -> Unit)? = null, + onRemoveFromPlaylist: (() -> Unit)? = null, + onAddToPlaylist: ((Playlist, Int) -> Unit)? = null, + onGoToAlbum: ((String) -> Unit)? = null, + onGoToArtist: ((String) -> Unit)? = null, + onShare: (() -> Unit)? = null, + onGlobalRouteEmitted: (() -> Unit)? = null, +) { + val playlistPreviews by remember { + Database.playlistPreviews() + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + val viewPlaylistsRoute = rememberCreatePlaylistRoute() + + Menu( + modifier = modifier + ) { + RouteHandler( + transitionSpec = { + when (targetState.route) { + viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with + slideOutOfContainer(AnimatedContentScope.SlideDirection.Left) + else -> when (initialState.route) { + viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with + slideOutOfContainer(AnimatedContentScope.SlideDirection.Right) + else -> empty + } + } + } + ) { + viewPlaylistsRoute { + var isCreatingNewPlaylist by rememberSaveable { + mutableStateOf(false) + } + + if (isCreatingNewPlaylist && onAddToPlaylist != null) { + TextFieldDialog( + hintText = "Enter the playlist name", + onDismiss = { + isCreatingNewPlaylist = false + }, + onDone = { text -> + onDismiss() + onAddToPlaylist(Playlist(name = text), 0) + } + ) + } + + Column { + Row( + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .fillMaxWidth() + ) { + MenuBackButton(onClick = pop) + + if (onAddToPlaylist != null) { + MenuIconButton( + icon = R.drawable.add, + onClick = { + isCreatingNewPlaylist = true + } + ) + } + } + + onAddToPlaylist?.let { onAddToPlaylist -> + playlistPreviews.forEach { playlistPreview -> + MenuEntry( + icon = R.drawable.list, + text = playlistPreview.playlist.name, + secondaryText = "${playlistPreview.songCount} songs", + onClick = { + onDismiss() + onAddToPlaylist( + playlistPreview.playlist, + playlistPreview.songCount + ) + } + ) + } + } + } + } + + host { + Column( + modifier = Modifier + .pointerInput(Unit) { + detectTapGestures { } + } + ) { + MenuCloseButton(onClick = onDismiss) + + onStartRadio?.let { onStartRadio -> + MenuEntry( + icon = R.drawable.radio, + text = "Start radio", + onClick = { + onDismiss() + onStartRadio() + } + ) + } + + onPlayNext?.let { onPlayNext -> + MenuEntry( + icon = R.drawable.play, + text = "Play next", + onClick = { + onDismiss() + onPlayNext() + } + ) + } + + onEnqueue?.let { onEnqueue -> + MenuEntry( + icon = R.drawable.time, + text = "Enqueue", + onClick = { + onDismiss() + onEnqueue() + } + ) + } + + onRemoveFromQueue?.let { onRemoveFromQueue -> + MenuEntry( + icon = R.drawable.trash, + text = "Remove", + onClick = { + onDismiss() + onRemoveFromQueue() + } + ) + } + + onRemoveFromFavorites?.let { onRemoveFromFavorites -> + MenuEntry( + icon = R.drawable.heart_dislike, + text = "Dislike", + onClick = { + onDismiss() + onRemoveFromFavorites() + } + ) + } + + onRemoveFromPlaylist?.let { onRemoveFromPlaylist -> + MenuEntry( + icon = R.drawable.trash, + text = "Remove", + onClick = { + onDismiss() + onRemoveFromPlaylist() + } + ) + } + + if (onAddToPlaylist != null) { + MenuEntry( + icon = R.drawable.list, + text = "Add to playlist", + onClick = { + viewPlaylistsRoute() + } + ) + } + + onGoToAlbum?.let { onGoToAlbum -> + mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> + MenuEntry( + icon = R.drawable.disc, + text = "Go to album", + onClick = { + onDismiss() + onGlobalRouteEmitted?.invoke() + onGoToAlbum(albumId) + } + ) + } + } + + onGoToArtist?.let { onGoToArtist -> + mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames") + ?.let { artistNames -> + mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds") + ?.let { artistIds -> + artistNames.zip(artistIds) + .forEach { (authorName, authorId) -> + if (authorId != null) { + MenuEntry( + icon = R.drawable.person, + text = "More of $authorName", + onClick = { + onDismiss() + onGlobalRouteEmitted?.invoke() + onGoToArtist(authorId) + } + ) + } + } + } + } + } + + onShare?.let { onShare -> + MenuEntry( + icon = R.drawable.share_social, + text = "Share", + onClick = { + onDismiss() + onShare() + } + ) + } + + onDeleteFromDatabase?.let { onDeleteFromDatabase -> + MenuEntry( + icon = R.drawable.trash, + text = "Delete", + onClick = { + onDeleteFromDatabase() + } + ) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt new file mode 100644 index 0000000..37ee5e8 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/Menu.kt @@ -0,0 +1,152 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.annotation.DrawableRes +import androidx.compose.foundation.* +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.semiBold + +@Composable +inline fun Menu( + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + val colorPalette = LocalColorPalette.current + + Column( + modifier = modifier + .verticalScroll(rememberScrollState()) + .width(256.dp) + .background( + color = colorPalette.elevatedBackground, + shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp) + ) + .padding(vertical = 8.dp), + content = content + ) +} + +@Composable +inline fun BasicMenu( + noinline onDismiss: () -> Unit, + modifier: Modifier = Modifier, + content: @Composable ColumnScope.() -> Unit +) { + Menu(modifier = modifier) { + MenuCloseButton(onClick = onDismiss) + content() + } +} + + +@Composable +fun MenuEntry( + @DrawableRes icon: Int, + text: String, + onClick: () -> Unit, + secondaryText: String? = null, + enabled: Boolean = true, +) { + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(24.dp), + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + enabled = enabled, + onClick = onClick + ) + .fillMaxWidth() + .padding(horizontal = 24.dp, vertical = 16.dp) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(if (enabled) colorPalette.textSecondary else colorPalette.textDisabled), + modifier = Modifier + .size(18.dp) + ) + + Column { + BasicText( + text = text, + style = typography.xs.semiBold.color(if (enabled) colorPalette.text else colorPalette.textDisabled) + ) + + secondaryText?.let { secondaryText -> + BasicText( + text = secondaryText, + style = typography.xxs.semiBold.color(if (enabled) colorPalette.textSecondary else colorPalette.textDisabled) + ) + } + } + } +} + +@Composable +fun MenuIconButton( + @DrawableRes icon: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + val colorPalette = LocalColorPalette.current + + Box( + modifier = modifier + .padding(horizontal = 12.dp) + ) { + Image( + painter = painterResource(icon), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = onClick) + .padding(horizontal = 8.dp, vertical = 16.dp) + .size(20.dp) + ) + } +} + +@Composable +fun MenuCloseButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + MenuIconButton( + icon = R.drawable.close, + onClick = onClick, + modifier = modifier + ) +} + +@Composable +fun MenuBackButton( + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + MenuIconButton( + icon = R.drawable.chevron_back, + onClick = onClick, + modifier = modifier + ) +} + + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt new file mode 100644 index 0000000..682d03f --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/components/themed/TextPlaceholder.kt @@ -0,0 +1,30 @@ +package it.vfsfitvnm.vimusic.ui.components.themed + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import kotlin.random.Random + +@Composable +fun TextPlaceholder( + modifier: Modifier = Modifier +) { + Spacer( + modifier = modifier + .padding(vertical = 4.dp) + .background( + color = LocalColorPalette.current.darkGray, + shape = RoundedCornerShape(0.dp) + ) + .fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f }) + .height(16.dp) + ) +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt new file mode 100644 index 0000000..3c7324e --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/AlbumScreen.kt @@ -0,0 +1,367 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +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.shadow +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.internal +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.SongInPlaylist +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.OutcomeItem +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.* +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.ui.components.themed.* +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + + +@ExperimentalAnimationApi +@Composable +fun AlbumScreen( + browseId: String, +) { + val scrollState = rememberScrollState() + + var album by remember { + mutableStateOf>(Outcome.Loading) + } + + val onLoad = relaunchableEffect(Unit) { + album = withContext(Dispatchers.IO) { + YouTube.album(browseId) + } + } + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val density = LocalDensity.current + val player = LocalYoutubePlayer.current + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + val menuState = LocalMenuState.current + + val (thumbnailSizeDp, thumbnailSizePx) = remember { + density.run { + 128.dp to 128.dp.roundToPx() + } + } + + val coroutineScope = rememberCoroutineScope() + + Column( + modifier = Modifier + .verticalScroll(scrollState) + .padding(bottom = 72.dp) + .background(colorPalette.background) + .fillMaxSize() + ) { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = pop) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + menuState.display { + Menu { + MenuCloseButton(onClick = menuState::hide) + + MenuEntry( + icon = R.drawable.time, + text = "Enqueue", + enabled = player?.playbackState == Player.STATE_READY, + onClick = { + menuState.hide() + album.valueOrNull?.let { album -> + player?.mediaController?.enqueue(album.items.mapNotNull { song -> + song.toMediaItem(browseId, album) + }) + } + } + ) + + MenuEntry( + icon = R.drawable.list, + text = "Import as playlist", + onClick = { + menuState.hide() + + album.valueOrNull?.let { album -> + coroutineScope.launch(Dispatchers.IO) { + Database.internal.runInTransaction { + val playlistId = Database.insert(Playlist(name = album.title)) + + album.items.forEachIndexed { index, song -> + song.toMediaItem(browseId, album)?.let { mediaItem -> + if (Database.song(mediaItem.mediaId) == null) { + Database.insert(mediaItem) + } + + Database.insert( + SongInPlaylist( + songId = mediaItem.mediaId, + playlistId = playlistId, + position = index + ) + ) + } + } + } + } + } + } + ) + } + } + } + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + } + + OutcomeItem( + outcome = album, + onRetry = onLoad, + onLoading = { + Loading() + } + ) { album -> + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .fillMaxWidth() + .height(IntrinsicSize.Max) + .padding(vertical = 8.dp, horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + AsyncImage( + model = album.thumbnail.size(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .size(thumbnailSizeDp) + ) + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxSize() + ) { + Column { + BasicText( + text = album.title, + style = typography.m.semiBold + ) + + BasicText( + text = "${album.authors.joinToString("") { it.name }} • ${album.year}", + style = typography.xs.secondary.semiBold, + maxLines = 2, + overflow = TextOverflow.Ellipsis, + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .align(Alignment.End) + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayFromBeginning( + album.items.shuffled().mapNotNull { song -> + song.toMediaItem(browseId, album) + }) + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background(color = colorPalette.elevatedBackground, shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + + Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayFromBeginning(album.items.mapNotNull { song -> + song.toMediaItem(browseId, album) + }) + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background(color = colorPalette.elevatedBackground, shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + } + } + } + + album.items.forEachIndexed { index, song -> + SongItem( + title = song.info.name, + authors = (song.authors ?: album.authors).joinToString("") { it.name }, + durationText = song.durationText, + onClick = { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayAtIndex(album.items.mapNotNull { song -> + song.toMediaItem(browseId, album) + }, index) + }, + startContent = { + BasicText( + text = "${index + 1}", + style = typography.xs.secondary.bold.center, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .width(36.dp) + ) + }, + menuContent = { + NonQueuedMediaItemMenu( + mediaItem = song.toMediaItem(browseId, album) ?: return@SongItem, + onDismiss = menuState::hide, + ) + } + ) + } + } + } + } + } +} + +@Composable +private fun Loading() { + val colorPalette = LocalColorPalette.current + + Column( + modifier = Modifier + .shimmer() + ) { + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .height(IntrinsicSize.Max) + .padding(vertical = 8.dp, horizontal = 16.dp) + .padding(bottom = 16.dp) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.darkGray) + .size(128.dp) + ) + + Column( + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxHeight() + ) { + Column { + TextPlaceholder() + + TextPlaceholder( + modifier = Modifier + .alpha(0.7f) + ) + } + } + } + + repeat(3) { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = Modifier + .alpha(0.6f - it * 0.1f) + .height(54.dp) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .size(36.dp) + ) { + Spacer( + modifier = Modifier + .size(8.dp) + .background(color = colorPalette.darkGray, shape = CircleShape) + ) + } + + Column( + verticalArrangement = Arrangement.spacedBy(4.dp) + ) { + TextPlaceholder() + + TextPlaceholder( + modifier = Modifier + .alpha(0.7f) + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt new file mode 100644 index 0000000..9cc8a63 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/ArtistScreen.kt @@ -0,0 +1,220 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateContentSize +import androidx.compose.foundation.* +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.ExpandableText +import it.vfsfitvnm.vimusic.ui.components.Message +import it.vfsfitvnm.vimusic.ui.components.OutcomeItem +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.* +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + + +@ExperimentalAnimationApi +@Composable +fun ArtistScreen( + browseId: String, +) { + val scrollState = rememberScrollState() + + var artist by remember { + mutableStateOf>(Outcome.Loading) + } + + val onLoad = relaunchableEffect(Unit) { + artist = withContext(Dispatchers.IO) { + YouTube.artist(browseId) + } + } + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val density = LocalDensity.current + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + val (thumbnailSizeDp, thumbnailSizePx) = remember { + density.run { + 192.dp to 192.dp.roundToPx() + } + } + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .verticalScroll(scrollState) + .padding(bottom = 72.dp) + .background(colorPalette.background) + .fillMaxSize() + ) { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = pop) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + } + + OutcomeItem( + outcome = artist, + onRetry = onLoad, + onLoading = { + Loading() + } + ) { artist -> + AsyncImage( + model = artist.thumbnail?.size(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(thumbnailSizeDp) + + ) + + BasicText( + text = artist.name, + style = typography.l.semiBold, + modifier = Modifier + .padding(vertical = 8.dp, horizontal = 16.dp) + ) + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + ) { + Image( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + YoutubePlayer.Radio.reset() + artist.shuffleEndpoint?.let(YoutubePlayer.Radio::setup) + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background(color = colorPalette.elevatedBackground, shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + + Image( + painter = painterResource(R.drawable.radio), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + YoutubePlayer.Radio.reset() + artist.radioEndpoint?.let(YoutubePlayer.Radio::setup) + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background(color = colorPalette.elevatedBackground, shape = CircleShape) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + } + + artist.description?.let { description -> + ExpandableText( + text = description, + style = typography.xxs.secondary.align(TextAlign.Justify), + minimizedMaxLines = 4, + backgroundColor = colorPalette.background, + showMoreTextStyle = typography.xxs.bold, + modifier = Modifier + .animateContentSize() + .padding(horizontal = 16.dp) + ) + } + + Message( + text = "Page under construction", + icon = R.drawable.sad, + modifier = Modifier + .padding(vertical = 64.dp) + ) + } + } + } + } +} + +@Composable +private fun Loading() { + val colorPalette = LocalColorPalette.current + + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .shimmer() + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.darkGray, shape = CircleShape) + .size(192.dp) + ) + + TextPlaceholder( + modifier = Modifier + .alpha(0.9f) + .padding(vertical = 8.dp, horizontal = 16.dp) + ) + + repeat(3) { + TextPlaceholder( + modifier = Modifier + .alpha(0.8f) + .padding(horizontal = 16.dp) + ) + } + } +} + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt new file mode 100644 index 0000000..9cfbd01 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/HomeScreen.kt @@ -0,0 +1,422 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.grid.GridCells +import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid +import androidx.compose.foundation.lazy.grid.items +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex +import androidx.media3.common.Player +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.route.rememberRoute +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.enums.SongCollection +import it.vfsfitvnm.vimusic.models.Playlist +import it.vfsfitvnm.vimusic.models.SearchQuery +import it.vfsfitvnm.vimusic.models.SongWithInfo +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState +import it.vfsfitvnm.vimusic.ui.components.themed.* +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.ui.views.PlayerView +import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +@ExperimentalAnimationApi +@Composable +fun HomeScreen(intentVideoId: String?) { + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + val coroutineScope = rememberCoroutineScope() + + val lazyListState = rememberLazyListState() + + val intentVideoRoute = rememberIntentVideoRoute(intentVideoId) + val playlistRoute = rememberLocalPlaylistRoute() + val searchRoute = rememberSearchRoute() + val searchResultRoute = rememberSearchResultRoute() + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + val (route, onRouteChanged) = rememberRoute(intentVideoId?.let { intentVideoRoute }) + + val playlistPreviews by remember { + Database.playlistPreviews() + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + val preferences = LocalPreferences.current + + val songCollection by remember(preferences.homePageSongCollection) { + when (preferences.homePageSongCollection) { + SongCollection.MostPlayed -> Database.mostPlayed() + SongCollection.Favorites -> Database.favorites() + SongCollection.History -> Database.history() + } + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + BoxWithConstraints( + modifier = Modifier + .fillMaxSize() + ) { + RouteHandler( + route = route, + onRouteChanged = onRouteChanged, + listenToGlobalEmitter = true + ) { + intentVideoRoute { videoId -> + IntentVideoScreen( + videoId = videoId ?: error("videoId must be not null") + ) + } + + playlistRoute { playlistId -> + LocalPlaylistScreen( + playlistId = playlistId ?: error("playlistId cannot be null") + ) + } + + searchResultRoute { query -> + SearchResultScreen( + query = query, + onSearchAgain = { + searchRoute(query) + }, + ) + } + + searchRoute { initialTextInput -> + SearchScreen( + initialTextInput = initialTextInput, + onSearch = { query -> + searchResultRoute(query) + + coroutineScope.launch(Dispatchers.IO) { + Database.insert(SearchQuery(query = query)) + } + } + ) + } + + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val player = LocalYoutubePlayer.current + val menuState = LocalMenuState.current + val density = LocalDensity.current + + val thumbnailSize = remember { + density.run { + 54.dp.roundToPx() + } + } + + var isCreatingANewPlaylist by rememberSaveable { + mutableStateOf(false) + } + + if (isCreatingANewPlaylist) { + TextFieldDialog( + hintText = "Enter the playlist name", + onDismiss = { + isCreatingANewPlaylist = false + }, + onDone = { text -> + coroutineScope.launch(Dispatchers.IO) { + Database.insert(Playlist(name = text)) + } + } + ) + } + + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(bottom = 72.dp), + modifier = Modifier + .background(colorPalette.background) + .fillMaxSize() + ) { + item { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + + Image( + painter = painterResource(R.drawable.search), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + searchRoute("") + } + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + } + } + + item { + BasicText( + text = "Your playlists", + style = typography.m.semiBold, + modifier = Modifier + .padding(horizontal = 16.dp) + ) + } + + item { + LazyHorizontalGrid( + rows = GridCells.Fixed(2), + contentPadding = PaddingValues(horizontal = 16.dp), + modifier = Modifier + .height(248.dp) + ) { + item { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .padding(all = 8.dp) + .width(108.dp) + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ) { + isCreatingANewPlaylist = true + } + .background(colorPalette.lightBackground) + .size(108.dp) + ) { + Image( + painter = painterResource(R.drawable.add), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .size(24.dp) + ) + } + } + } + + items(playlistPreviews) { playlistPreview -> + PlaylistPreviewItem( + playlistPreview = playlistPreview, + modifier = Modifier + .padding(all = 8.dp) + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ) { + playlistRoute(playlistPreview.playlist.id) + } + ) + } + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .zIndex(1f) + .padding(horizontal = 8.dp) + .padding(top = 32.dp) + ) { + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .weight(1f) + .padding(horizontal = 8.dp) + ) { + BasicText( + text = when (preferences.homePageSongCollection) { + SongCollection.MostPlayed -> "Most played" + SongCollection.Favorites -> "Favorites" + SongCollection.History -> "History" + }, + style = typography.m.semiBold, + modifier = Modifier + .animateContentSize() + ) + + Image( + painter = painterResource(R.drawable.repeat), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .clickable { + val values = SongCollection.values() + + preferences.homePageSongCollection = + values[(preferences.homePageSongCollection.ordinal + 1) % values.size] + } + .padding(horizontal = 8.dp, vertical = 8.dp) + .size(16.dp) + ) + } + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + menuState.display { + BasicMenu(onDismiss = menuState::hide) { + MenuEntry( + icon = R.drawable.play, + text = "Play", + enabled = songCollection.isNotEmpty(), + onClick = { + menuState.hide() + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayFromBeginning( + songCollection + .map(SongWithInfo::asMediaItem) + ) + } + ) + + MenuEntry( + icon = R.drawable.shuffle, + text = "Shuffle", + enabled = songCollection.isNotEmpty(), + onClick = { + menuState.hide() + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayFromBeginning( + songCollection + .shuffled() + .map(SongWithInfo::asMediaItem) + ) + } + ) + + MenuEntry( + icon = R.drawable.time, + text = "Enqueue", + enabled = songCollection.isNotEmpty() && player?.playbackState == Player.STATE_READY, + onClick = { + menuState.hide() + player?.mediaController?.enqueue( + songCollection.map(SongWithInfo::asMediaItem) + ) + } + ) + } + } + } + .padding(horizontal = 8.dp, vertical = 8.dp) + .size(20.dp) + ) + } + } + + itemsIndexed( + items = songCollection, + key = { _, song -> + song.song.id + } + ) { index, song -> + SongItem( + song = song, + thumbnailSize = thumbnailSize, + onClick = { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayAtIndex( + songCollection.map(SongWithInfo::asMediaItem), + index + ) + }, + menuContent = { + when (preferences.homePageSongCollection) { + SongCollection.MostPlayed -> NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) + SongCollection.Favorites -> InFavoritesMediaItemMenu(song = song) + SongCollection.History -> InHistoryMediaItemMenu(song = song) + } + }, + onThumbnailContent = { + AnimatedVisibility( + visible = preferences.homePageSongCollection == SongCollection.MostPlayed, + enter = fadeIn(), + exit = fadeOut(), + modifier = Modifier + .align(Alignment.BottomCenter) + ) { + BasicText( + text = song.song.formattedTotalPlayTime, + style = typography.xxs.semiBold.center.color(Color.White), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.75f) + ) + ) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } + } + ) + } + } + } + } + + PlayerView( + layoutState = rememberBottomSheetState(lowerBound = 64.dp, upperBound = maxHeight), + modifier = Modifier + .align(Alignment.BottomCenter) + ) + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt new file mode 100644 index 0000000..75b37a4 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/IntentVideoScreen.kt @@ -0,0 +1,104 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.runtime.remember +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import com.valentinilk.shimmer.ShimmerBounds +import com.valentinilk.shimmer.rememberShimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.ui.components.OutcomeItem +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer +import it.vfsfitvnm.vimusic.utils.asMediaItem +import it.vfsfitvnm.vimusic.utils.forcePlay +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.toNullable +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@Composable +fun IntentVideoScreen(videoId: String) { + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val colorPalette = LocalColorPalette.current + val density = LocalDensity.current + val player = LocalYoutubePlayer.current + + val mediaItem by produceState>(initialValue = Outcome.Loading) { + value = withContext(Dispatchers.IO) { + Database.songWithInfo(videoId)?.let { songWithInfo -> + Outcome.Success(songWithInfo.asMediaItem) + } ?: YouTube.getQueue(videoId).toNullable() + ?.map(YouTube.Item.Song::asMediaItem) + ?: Outcome.Error.Network + } + } + + Column( + modifier = Modifier + .background(colorPalette.background) + .fillMaxSize() + ) { + OutcomeItem( + outcome = mediaItem, + onLoading = { + SmallSongItemShimmer( + shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.View), + thumbnailSizeDp = 54.dp, + modifier = Modifier + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + } + ) { mediaItem -> + SongItem( + mediaItem = mediaItem, + thumbnailSize = remember { + density.run { + 54.dp.roundToPx() + } + }, + onClick = { + player?.mediaController?.forcePlay(mediaItem) + pop() + }, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = mediaItem) + } + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt new file mode 100644 index 0000000..bd3e377 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/PlaylistScreen.kt @@ -0,0 +1,337 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.itemsIndexed +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.shadow +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import it.vfsfitvnm.reordering.rememberReorderingState +import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.PlaylistWithSongs +import it.vfsfitvnm.vimusic.models.SongInPlaylist +import it.vfsfitvnm.vimusic.models.SongWithInfo +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.components.themed.* +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.* +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch + + +@ExperimentalAnimationApi +@Composable +fun LocalPlaylistScreen( + playlistId: Long, +) { + val playlistWithSongs by remember(playlistId) { + Database.playlistWithSongs(playlistId).map { it ?: PlaylistWithSongs.NotFound } + }.collectAsState(initial = PlaylistWithSongs.Empty, context = Dispatchers.IO) + + + val lazyListState = rememberLazyListState() + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val density = LocalDensity.current + val hapticFeedback = LocalHapticFeedback.current + val menuState = LocalMenuState.current + + val player = LocalYoutubePlayer.current + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + val thumbnailSize = remember { + density.run { + 54.dp.roundToPx() + } + } + + val coroutineScope = rememberCoroutineScope() + + val reorderingState = rememberReorderingState(playlistWithSongs.songs) + + var isRenaming by rememberSaveable { + mutableStateOf(false) + } + + if (isRenaming) { + TextFieldDialog( + hintText = "Enter the playlist name", + initialTextInput = playlistWithSongs.playlist.name, + onDismiss = { + isRenaming = false + }, + onDone = { text -> + coroutineScope.launch(Dispatchers.IO) { + Database.update(playlistWithSongs.playlist.copy(name = text)) + } + } + ) + } + + var isDeleting by rememberSaveable { + mutableStateOf(false) + } + + if (isDeleting) { + ConfirmationDialog( + text = "Do you really want to delete this playlist?", + onDismiss = { + isDeleting = false + }, + onConfirm = { + coroutineScope.launch(Dispatchers.IO) { + Database.delete(playlistWithSongs.playlist) + } + pop() + } + ) + } + + LazyColumn( + state = lazyListState, + contentPadding = PaddingValues(bottom = 64.dp), + modifier = Modifier + .background(colorPalette.background) + .fillMaxSize() + ) { + item { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = pop) + .padding(vertical = 8.dp, horizontal = 16.dp) + .size(24.dp) + ) + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + menuState.display { + Menu { + MenuCloseButton(onClick = menuState::hide) + + MenuEntry( + icon = R.drawable.time, + text = "Enqueue", + enabled = playlistWithSongs.songs.isNotEmpty() && player?.playbackState == Player.STATE_READY, + onClick = { + menuState.hide() + player?.mediaController?.enqueue( + playlistWithSongs.songs.map( + SongWithInfo::asMediaItem + ) + ) + } + ) + + MenuEntry( + icon = R.drawable.pencil, + text = "Rename", + onClick = { + menuState.hide() + isRenaming = true + } + ) + + MenuEntry( + icon = R.drawable.trash, + text = "Delete", + onClick = { + menuState.hide() + isDeleting = true + } + ) + } + } + } + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + } + } + + item { + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.SpaceBetween, + modifier = Modifier + .padding(top = 16.dp, bottom = 32.dp) + ) { + Column( + modifier = Modifier + .weight(1f) + .padding(horizontal = 16.dp) + ) { + BasicText( + text = playlistWithSongs.playlist.name, + style = typography.m.semiBold + ) + + BasicText( + text = "${playlistWithSongs.songs.size} songs", + style = typography.xxs.semiBold.secondary + ) + } + + Row( + horizontalArrangement = Arrangement.spacedBy(16.dp), + modifier = Modifier + .padding(horizontal = 16.dp) + ) { + Image( + painter = painterResource(R.drawable.shuffle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayFromBeginning( + playlistWithSongs.songs + .map(SongWithInfo::asMediaItem) + .shuffled() + ) + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background( + color = colorPalette.elevatedBackground, + shape = CircleShape + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + + Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayFromBeginning( + playlistWithSongs.songs.map( + SongWithInfo::asMediaItem + ) + ) + } + .shadow(elevation = 2.dp, shape = CircleShape) + .background( + color = colorPalette.elevatedBackground, + shape = CircleShape + ) + .padding(horizontal = 16.dp, vertical = 16.dp) + .size(20.dp) + ) + } + } + } + + + itemsIndexed(items = playlistWithSongs.songs, key = { _, song -> song.song.id }) { index, song -> + SongItem( + song = song, + thumbnailSize = thumbnailSize, + onClick = { + YoutubePlayer.Radio.reset() + player?.mediaController?.forcePlayAtIndex( + playlistWithSongs.songs.map( + SongWithInfo::asMediaItem + ), index + ) + }, + menuContent = { + InPlaylistMediaItemMenu( + playlistId = playlistId, + positionInPlaylist = index, + song = song + ) + }, + modifier = Modifier + .verticalDragAfterLongPressToReorder( + reorderingState = reorderingState, + index = index, + onDragStart = { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress + ) + }, + onDragEnd = { reachedIndex -> + coroutineScope.launch(Dispatchers.IO) { + if (index > reachedIndex) { + Database.incrementSongPositions( + playlistId = playlistWithSongs.playlist.id, + fromPosition = reachedIndex, + toPosition = index - 1 + ) + } else if (index < reachedIndex) { + Database.decrementSongPositions( + playlistId = playlistWithSongs.playlist.id, + fromPosition = index + 1, + toPosition = reachedIndex + ) + } + + Database.update( + SongInPlaylist( + songId = playlistWithSongs.songs[index].song.id, + playlistId = playlistWithSongs.playlist.id, + position = reachedIndex + ) + ) + } + } + ) + ) + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt new file mode 100644 index 0000000..03dbe5b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchResultScreen.kt @@ -0,0 +1,493 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.foundation.lazy.rememberLazyListState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +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.graphics.ColorFilter +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import com.valentinilk.shimmer.Shimmer +import com.valentinilk.shimmer.ShimmerBounds +import com.valentinilk.shimmer.rememberShimmer +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.* +import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.ui.views.SongItem +import it.vfsfitvnm.vimusic.utils.* +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext + + +@ExperimentalAnimationApi +@Composable +fun SearchResultScreen( + query: String, + onSearchAgain: () -> Unit, +) { + val density = LocalDensity.current + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + val preferences = LocalPreferences.current + val player = LocalYoutubePlayer.current + + val lazyListState = rememberLazyListState() + + var continuation by remember(preferences.searchFilter) { + mutableStateOf>(Outcome.Initial) + } + + val items = remember(preferences.searchFilter) { + mutableStateListOf() + } + + val onLoad = relaunchableEffect(preferences.searchFilter) { + withContext(Dispatchers.Main) { + val token = continuation.valueOrNull + + continuation = Outcome.Loading + + continuation = withContext(Dispatchers.IO) { + YouTube.search(query, preferences.searchFilter, token) + }.map { searchResult -> + items.addAll(searchResult.items) + searchResult.continuation + }.recoverWith(token) + } + } + + val thumbnailSizePx = remember { + density.run { + 54.dp.roundToPx() + } + } + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler( + listenToGlobalEmitter = true + ) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: "browseId cannot be null" + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: "browseId cannot be null" + ) + } + + host { + val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window) + + LazyColumn( + state = lazyListState, + horizontalAlignment = Alignment.CenterHorizontally, + contentPadding = PaddingValues(bottom = 64.dp), + modifier = Modifier + .background(colorPalette.background) + .fillMaxSize() + ) { + item { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable(onClick = pop) + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + + BasicText( + text = query, + style = typography.m.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .clickable( + interactionSource = remember { MutableInteractionSource() }, + indication = null, + onClick = onSearchAgain + ) + ) + + Spacer( + modifier = Modifier + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + } + } + + item { + ChipGroup( + items = listOf( + ChipItem( + text = "Songs", + value = YouTube.Item.Song.Filter.value + ), + ChipItem( + text = "Albums", + value = YouTube.Item.Album.Filter.value + ), + ChipItem( + text = "Artists", + value = YouTube.Item.Artist.Filter.value + ), + ChipItem( + text = "Videos", + value = YouTube.Item.Video.Filter.value + ), + ), + value = preferences.searchFilter, + selectedBackgroundColor = colorPalette.primaryContainer, + unselectedBackgroundColor = colorPalette.lightBackground, + selectedTextStyle = typography.xs.medium.color(colorPalette.onPrimaryContainer), + unselectedTextStyle = typography.xs.medium, + shape = RoundedCornerShape(36.dp), + onValueChanged = { filter -> + preferences.searchFilter = filter + }, + modifier = Modifier + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .padding(bottom = 8.dp) + ) + } + + items(items) { item -> + SmallItem( + item = item, + thumbnailSizeDp = 54.dp, + thumbnailSizePx = thumbnailSizePx, + onClick = { + when (item) { + is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId) + is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId) + is YouTube.Item.Song -> { + player?.mediaController?.forcePlay(item.asMediaItem) + item.info.endpoint?.let(YoutubePlayer.Radio::setup) + } + is YouTube.Item.Video -> { + player?.mediaController?.forcePlay(item.asMediaItem) + item.info.endpoint?.let(YoutubePlayer.Radio::setup) + } + } + } + ) + } + + when (val currentResult = continuation) { + is Outcome.Error -> item { + Error( + error = currentResult, + onRetry = onLoad, + modifier = Modifier + .padding(vertical = 16.dp) + ) + } + is Outcome.Recovered -> item { + Error( + error = currentResult.error, + onRetry = onLoad, + modifier = Modifier + .padding(vertical = 16.dp) + ) + } + is Outcome.Success -> { + if (items.isEmpty()) { + item { + Message( + text = "No results found", + modifier = Modifier + ) + } + } + + if (currentResult.value != null) { + item { + SideEffect(onLoad) + } + } + } + else -> {} + } + + if (continuation is Outcome.Loading || (continuation is Outcome.Success && continuation.valueOrNull != null)) { + items(count = if (items.isEmpty()) 8 else 3, key = { it }) { index -> + when (preferences.searchFilter) { + YouTube.Item.Artist.Filter.value -> SmallArtistItemShimmer( + shimmer = shimmer, + thumbnailSizeDp = 54.dp, + modifier = Modifier + .alpha(1f - index * 0.125f) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + else -> SmallSongItemShimmer( + shimmer = shimmer, + thumbnailSizeDp = 54.dp, + modifier = Modifier + .alpha(1f - index * 0.125f) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + } + } + } + } + } + } +} + +@Composable +fun SmallSongItemShimmer( + shimmer: Shimmer, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier +) { + val colorPalette = LocalColorPalette.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .shimmer(shimmer) + ) { + Spacer( + modifier = Modifier + .background(colorPalette.darkGray) + .size(thumbnailSizeDp) + ) + + Column { + TextPlaceholder() + TextPlaceholder() + } + } +} + +@Composable +fun SmallArtistItemShimmer( + shimmer: Shimmer, + thumbnailSizeDp: Dp, + modifier: Modifier = Modifier +) { + val colorPalette = LocalColorPalette.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .shimmer(shimmer) + ) { + Spacer( + modifier = Modifier + .background(color = colorPalette.darkGray, shape = CircleShape) + .size(thumbnailSizeDp) + ) + + TextPlaceholder() + } +} + +@ExperimentalAnimationApi +@Composable +fun SmallItem( + item: YouTube.Item, + thumbnailSizeDp: Dp, + thumbnailSizePx: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier, +) { + when (item) { + is YouTube.Item.Artist -> SmallArtistItem( + artist = item, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailSizePx = thumbnailSizePx, + modifier = modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick + ) + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + is YouTube.Item.Song -> SmallSongItem( + song = item, + thumbnailSizePx = thumbnailSizePx, + onClick = onClick, + modifier = modifier + ) + is YouTube.Item.Album -> SmallAlbumItem( + album = item, + thumbnailSizeDp = thumbnailSizeDp, + thumbnailSizePx = thumbnailSizePx, + modifier = modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick + ) + + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + is YouTube.Item.Video -> SmallVideoItem( + video = item, + thumbnailSizePx = thumbnailSizePx, + onClick = onClick, + modifier = modifier + ) + } +} + +@ExperimentalAnimationApi +@Composable +fun SmallSongItem( + song: YouTube.Item.Song, + thumbnailSizePx: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + SongItem( + thumbnailModel = song.thumbnail.size(thumbnailSizePx), + title = song.info.name, + authors = song.authors.joinToString("") { it.name }, + durationText = song.durationText, + onClick = onClick, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = song.asMediaItem) + }, + modifier = modifier + ) +} + +@ExperimentalAnimationApi +@Composable +fun SmallVideoItem( + video: YouTube.Item.Video, + thumbnailSizePx: Int, + onClick: () -> Unit, + modifier: Modifier = Modifier +) { + SongItem( + thumbnailModel = video.thumbnail.size(thumbnailSizePx), + title = video.info.name, + authors = video.views.joinToString("") { it.name }, + durationText = video.durationText, + onClick = onClick, + menuContent = { + NonQueuedMediaItemMenu(mediaItem = video.asMediaItem) + }, + modifier = modifier + ) +} + +@Composable +fun SmallAlbumItem( + album: YouTube.Item.Album, + thumbnailSizeDp: Dp, + thumbnailSizePx: Int, + modifier: Modifier = Modifier, +) { + val typography = LocalTypography.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + AsyncImage( + model = album.thumbnail.size(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .size(thumbnailSizeDp) + ) + + Column( + modifier = Modifier + .weight(1f) + ) { + BasicText( + text = album.info.name, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText( + text = "${album.authors.joinToString("") { it.name }} • ${album.year}", + style = typography.xs, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } +} + +@Composable +fun SmallArtistItem( + artist: YouTube.Item.Artist, + thumbnailSizeDp: Dp, + thumbnailSizePx: Int, + modifier: Modifier = Modifier, +) { + val typography = LocalTypography.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + ) { + AsyncImage( + model = artist.thumbnail.size(thumbnailSizePx), + contentDescription = null, + modifier = Modifier + .clip(CircleShape) + .size(thumbnailSizeDp) + ) + + BasicText( + text = artist.info.name, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .weight(1f) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt new file mode 100644 index 0000000..8b882c0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/SearchScreen.kt @@ -0,0 +1,307 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.tween +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.text.BasicTextField +import androidx.compose.foundation.text.KeyboardActions +import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.rotate +import androidx.compose.ui.focus.FocusRequester +import androidx.compose.ui.focus.focusRequester +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.SolidColor +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.TextRange +import androidx.compose.ui.text.input.ImeAction +import androidx.compose.ui.text.input.TextFieldValue +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.OutcomeItem +import it.vfsfitvnm.vimusic.ui.components.TopAppBar +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@Composable +fun SearchScreen( + initialTextInput: String, + onSearch: (String) -> Unit +) { + var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) { + mutableStateOf( + TextFieldValue( + text = initialTextInput, + selection = TextRange(initialTextInput.length) + ) + ) + } + + val focusRequester = remember { + FocusRequester() + } + + val searchSuggestions by produceState?>>( + initialValue = Outcome.Initial, + key1 = textFieldValue + ) { + value = if (textFieldValue.text.isNotEmpty()) { + withContext(Dispatchers.IO) { + YouTube.getSearchSuggestions(textFieldValue.text) + } + } else { + Outcome.Initial + } + } + + val history by remember(textFieldValue.text) { + Database.getRecentQueries("%${textFieldValue.text}%").distinctUntilChanged { old, new -> + old.size == new.size + } + }.collectAsState(initial = null, context = Dispatchers.IO) + + val albumRoute = rememberAlbumRoute() + val artistRoute = rememberArtistRoute() + + RouteHandler(listenToGlobalEmitter = true) { + albumRoute { browseId -> + AlbumScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + artistRoute { browseId -> + ArtistScreen( + browseId = browseId ?: error("browseId cannot be null") + ) + } + + host { + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + val coroutineScope = rememberCoroutineScope() + + LaunchedEffect(Unit) { + delay(300) + focusRequester.requestFocus() + } + + Column( + modifier = Modifier + .fillMaxSize() + ) { + TopAppBar( + modifier = Modifier + .height(52.dp) + ) { + BasicTextField( + value = textFieldValue, + onValueChange = { + textFieldValue = it + }, + textStyle = typography.m.medium, + singleLine = true, + maxLines = 1, + keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search), + keyboardActions = KeyboardActions( + onSearch = { + if (textFieldValue.text.isNotEmpty()) { + onSearch(textFieldValue.text) + } + } + ), + cursorBrush = SolidColor(colorPalette.text), + decorationBox = { innerTextField -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + ) { + Image( + painter = painterResource(R.drawable.chevron_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + pop() + focusRequester.freeFocus() + } + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + + Box( + modifier = Modifier + .weight(1f) + ) { + androidx.compose.animation.AnimatedVisibility( + visible = textFieldValue.text.isEmpty(), + enter = fadeIn(tween(100)), + exit = fadeOut(tween(100)), + ) { + BasicText( + text = "Enter a song, an album, an artist name...", + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = typography.m.secondary, + ) + } + + innerTextField() + } + } + }, + modifier = Modifier + .padding(end = 16.dp) + .weight(1f) + .focusRequester(focusRequester) + ) + } + + Column( + modifier = Modifier + .verticalScroll(rememberScrollState()) + .padding(bottom = 64.dp) + ) { + history?.forEach { searchQuery -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ) { + onSearch(searchQuery.query) + } + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 8.dp) + ) { + Image( + painter = painterResource(R.drawable.time), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.darkGray), + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + ) + + BasicText( + text = searchQuery.query, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + Image( + painter = painterResource(R.drawable.close), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.darkGray), + modifier = Modifier + .clickable { + coroutineScope.launch(Dispatchers.IO) { + Database.delete(searchQuery) + } + } + .padding(horizontal = 8.dp) + .size(20.dp) + ) + + Image( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.darkGray), + modifier = Modifier + .clickable { + textFieldValue = TextFieldValue( + text = searchQuery.query, + selection = TextRange(searchQuery.query.length) + ) + } + .rotate(225f) + .padding(horizontal = 8.dp) + .size(20.dp) + ) + } + } + + OutcomeItem( + outcome = searchSuggestions + ) { suggestions -> + suggestions?.forEach { suggestion -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ) { + onSearch(suggestion) + } + .fillMaxWidth() + .padding(vertical = 16.dp, horizontal = 8.dp) + ) { + Spacer( + modifier = Modifier + .padding(horizontal = 8.dp) + .size(20.dp) + ) + + BasicText( + text = suggestion, + style = typography.s.secondary, + modifier = Modifier + .padding(horizontal = 8.dp) + .weight(1f) + ) + + + Image( + painter = painterResource(R.drawable.arrow_forward), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.darkGray), + modifier = Modifier + .clickable { + textFieldValue = TextFieldValue( + text = suggestion, + selection = TextRange(suggestion.length) + ) + } + .rotate(225f) + .padding(horizontal = 8.dp) + .size(22.dp) + ) + } + } + } + } + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt new file mode 100644 index 0000000..e904261 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/screens/routes.kt @@ -0,0 +1,82 @@ +package it.vfsfitvnm.vimusic.ui.screens + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.saveable.rememberSaveable +import it.vfsfitvnm.route.Route0 +import it.vfsfitvnm.route.Route1 + +@Composable +fun rememberIntentVideoRoute(intentVideoId: String?): Route1 { + val videoId = rememberSaveable { + mutableStateOf(intentVideoId) + } + return remember { + Route1("rememberIntentVideoRoute", videoId) + } +} + +@Composable +fun rememberAlbumRoute(): Route1 { + val browseId = rememberSaveable { + mutableStateOf(null) + } + return remember { + Route1("AlbumRoute", browseId) + } +} + +@Composable +fun rememberArtistRoute(): Route1 { + val browseId = rememberSaveable { + mutableStateOf(null) + } + return remember { + Route1("ArtistRoute", browseId) + } +} + +@Composable +fun rememberLocalPlaylistRoute(): Route1 { + val playlistType = rememberSaveable { + mutableStateOf(null) + } + return remember { + Route1("LocalPlaylistRoute", playlistType) + } +} + +@Composable +fun rememberSearchRoute(): Route1 { + val initialTextInput = remember { + mutableStateOf("") + } + return remember { + Route1("SearchRoute", initialTextInput) + } +} + +@Composable +fun rememberCreatePlaylistRoute(): Route0 { + return remember { + Route0("CreatePlaylistRoute") + } +} + +@Composable +fun rememberSearchResultRoute(): Route1 { + val searchQuery = rememberSaveable { + mutableStateOf("") + } + return remember { + Route1("SearchResultRoute", searchQuery) + } +} + +@Composable +fun rememberLyricsRoute(): Route0 { + return remember { + Route0("LyricsRoute") + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt new file mode 100644 index 0000000..2031198 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/ColorPalette.kt @@ -0,0 +1,78 @@ +package it.vfsfitvnm.vimusic.ui.styling + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.runtime.* +import androidx.compose.ui.graphics.Color + +@Immutable +data class ColorPalette( + val background: Color, + val elevatedBackground: Color, + val lightBackground: Color, + val text: Color, + val textSecondary: Color, + val textDisabled: Color, + val lightGray: Color, + val gray: Color, + val darkGray: Color, + val blue: Color, + val red: Color, + val green: Color, + val orange: Color, + + val primaryContainer: Color, + val onPrimaryContainer: Color, + val iconOnPrimaryContainer: Color, +) + +val DarkColorPalette = ColorPalette( + background = Color(0xff16171d), + lightBackground = Color(0xff1f2029), + elevatedBackground = Color(0xff1f2029), + text = Color(0xffe1e1e2), + textSecondary = Color(0xffa3a4a6), + textDisabled = Color(0xff6f6f73), + lightGray = Color(0xfff8f8f8), + gray = Color(0xFFE5E5E5), + darkGray = Color(0xFF838383), + blue = Color(0xff4046bf), + red = Color(0xffbf4040), + green = Color(0xff7fbf40), + orange = Color(0xffe8820e), + + primaryContainer = Color(0xff4046bf), + onPrimaryContainer = Color.White, + iconOnPrimaryContainer = Color.White, +) + +val LightColorPalette = ColorPalette( + background = Color(0xfffdfdfe), + lightBackground = Color(0xFFf8f8fc), + elevatedBackground = Color(0xfffdfdfe), + lightGray = Color(0xfff8f8f8), + gray = Color(0xFFE5E5E5), + darkGray = Color(0xFF838383), + text = Color(0xff212121), + textSecondary = Color(0xFF656566), + textDisabled = Color(0xFF9d9d9d), + blue = Color(0xff4059bf), + red = Color(0xffbf4040), + green = Color(0xff7fbf40), + orange = Color(0xffe8730e), + + primaryContainer = Color(0xff4046bf), + onPrimaryContainer = Color.White, + iconOnPrimaryContainer = Color.White, +// primaryContainer = Color(0xffecedf9), +// onPrimaryContainer = Color(0xff121212), +// iconOnPrimaryContainer = Color(0xff2e30b8), +) + +val LocalColorPalette = staticCompositionLocalOf { LightColorPalette } + +@Composable +fun rememberColorPalette(isDarkTheme: Boolean = isSystemInDarkTheme()): ColorPalette { + return remember(isDarkTheme) { + if (isDarkTheme) DarkColorPalette else LightColorPalette + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt new file mode 100644 index 0000000..e369c15 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/styling/Typography.kt @@ -0,0 +1,74 @@ +package it.vfsfitvnm.vimusic.ui.styling + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable +import androidx.compose.runtime.remember +import androidx.compose.runtime.staticCompositionLocalOf +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.ExperimentalTextApi +import androidx.compose.ui.text.PlatformTextStyle +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.Font +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.unit.sp +import it.vfsfitvnm.vimusic.R + +@Immutable +data class Typography( + val xxs: TextStyle, + val xs: TextStyle, + val s: TextStyle, + val m: TextStyle, + val l: TextStyle, +) + +val LocalTypography = staticCompositionLocalOf { TODO() } + +@ExperimentalTextApi +@Composable +fun rememberTypography(color: Color): Typography { + return remember(color) { + TextStyle( + fontFamily = FontFamily( + Font( + resId = R.font.poppins_w300, + weight = FontWeight.Light + ), + Font( + resId = R.font.poppins_w400, + weight = FontWeight.Normal + ), + Font( + resId = R.font.poppins_w400_italic, + weight = FontWeight.Normal, + style = FontStyle.Italic + ), + Font( + resId = R.font.poppins_w500, + weight = FontWeight.Medium + ), + Font( + resId = R.font.poppins_w600, + weight = FontWeight.SemiBold + ), + Font( + resId = R.font.poppins_w700, + weight = FontWeight.Bold + ), + ), + fontWeight = FontWeight.Normal, + color = color, + platformStyle = PlatformTextStyle(includeFontPadding = false) + ).run { + Typography( + xxs = copy(fontSize = 12.sp), + xs = copy(fontSize = 14.sp), + s = copy(fontSize = 16.sp), + m = copy(fontSize = 18.sp), + l = copy(fontSize = 20.sp), + ) + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt new file mode 100644 index 0000000..837a2bf --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/CurrentPlaylistView.kt @@ -0,0 +1,205 @@ +package it.vfsfitvnm.vimusic.ui.views + +import androidx.compose.animation.AnimatedVisibility +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.fadeIn +import androidx.compose.animation.fadeOut +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +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.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.alpha +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.platform.LocalHapticFeedback +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.unit.dp +import androidx.media3.common.Player +import com.valentinilk.shimmer.ShimmerBounds +import com.valentinilk.shimmer.rememberShimmer +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.BottomSheetState +import it.vfsfitvnm.vimusic.ui.components.Error +import it.vfsfitvnm.vimusic.ui.components.MusicBars +import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer +import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer +import it.vfsfitvnm.vimusic.utils.YoutubePlayer +import it.vfsfitvnm.reordering.rememberReorderingState +import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder +import it.vfsfitvnm.youtubemusic.Outcome +import kotlinx.coroutines.launch + + +@ExperimentalAnimationApi +@Composable +fun CurrentPlaylistView( + layoutState: BottomSheetState, + onGlobalRouteEmitted: () -> Unit, + modifier: Modifier = Modifier, +) { + val hapticFeedback = LocalHapticFeedback.current + val density = LocalDensity.current + val player = LocalYoutubePlayer.current + val colorPalette = LocalColorPalette.current + + val thumbnailSize = remember { + density.run { + 54.dp.roundToPx() + } + } + + val isPaused by derivedStateOf { + player?.playbackState == Player.STATE_ENDED || player?.playWhenReady == false + } + + val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window) + + val coroutineScope = rememberCoroutineScope() + + val lazyListState = + rememberLazyListState(initialFirstVisibleItemIndex = player?.mediaItemIndex ?: 0) + + val reorderingState = rememberReorderingState(player?.mediaItems ?: emptyList()) + + LazyColumn( + state = lazyListState, + modifier = modifier + .nestedScroll(remember { + layoutState.nestedScrollConnection(player?.mediaItemIndex == 0) + }) + ) { + itemsIndexed( + items = player?.mediaItems ?: emptyList() + ) { index, mediaItem -> + val isPlayingThisMediaItem by derivedStateOf { + player?.mediaItemIndex == index + } + + SongItem( + mediaItem = mediaItem, + thumbnailSize = thumbnailSize, + onClick = { + if (isPlayingThisMediaItem) { + if (isPaused) { + player?.mediaController?.play() + } else { + player?.mediaController?.pause() + } + } else { + player?.mediaController?.playWhenReady = true + player?.mediaController?.seekToDefaultPosition(index) + } + }, + menuContent = { + QueuedMediaItemMenu( + mediaItem = mediaItem, + indexInQueue = index, + onGlobalRouteEmitted = onGlobalRouteEmitted + ) + }, + onThumbnailContent = { + AnimatedVisibility( + visible = isPlayingThisMediaItem, + enter = fadeIn(), + exit = fadeOut(), + ) { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .background(Color.Black.copy(alpha = 0.25f)) + .size(54.dp) + ) { + if (isPaused) { + Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(LightColorPalette.background), + modifier = Modifier + .size(24.dp) + ) + } else { + MusicBars( + color = LightColorPalette.background, +// shape = RectangleShape, + modifier = Modifier + .height(24.dp) + ) + } + } + } + }, + backgroundColor = colorPalette.elevatedBackground, + modifier = Modifier + .verticalDragAfterLongPressToReorder( + reorderingState = reorderingState, + index = index, + onDragStart = { + hapticFeedback.performHapticFeedback( + HapticFeedbackType.LongPress + ) + }, + onDragEnd = { reachedIndex -> + player?.mediaController?.moveMediaItem(index, reachedIndex) + } + ) + ) + } + + if (YoutubePlayer.Radio.isActive && player != null) { + when (val nextContinuation = YoutubePlayer.Radio.nextContinuation) { + is Outcome.Loading, is Outcome.Success<*> -> { + if (nextContinuation is Outcome.Success<*>) { + item { + SideEffect { + coroutineScope.launch { + YoutubePlayer.Radio.process( + player.mediaController, + force = true + ) + } + } + } + } + + items(count = 3, key = { it }) { index -> + SmallSongItemShimmer( + shimmer = shimmer, + thumbnailSizeDp = 54.dp, + modifier = Modifier + .alpha(1f - index * 0.125f) + .fillMaxWidth() + .padding(vertical = 4.dp, horizontal = 16.dp) + ) + } + } + is Outcome.Error -> item { + Error( + error = nextContinuation + ) + } + is Outcome.Recovered<*> -> item { + Error( + error = nextContinuation.error, + onRetry = { + coroutineScope.launch { + YoutubePlayer.Radio.process(player.mediaController, force = true) + } + } + ) + } + else -> {} + } + } + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt new file mode 100644 index 0000000..0d6d32b --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerBottomSheet.kt @@ -0,0 +1,259 @@ +package it.vfsfitvnm.vimusic.ui.views + +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.animateFloatAsState +import androidx.compose.animation.with +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.foundation.text.BasicText +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.ripple.rememberRipple +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.scale +import androidx.compose.ui.input.nestedscroll.nestedScroll +import androidx.compose.ui.unit.dp +import com.valentinilk.shimmer.shimmer +import it.vfsfitvnm.route.Route +import it.vfsfitvnm.route.RouteHandler +import it.vfsfitvnm.route.empty +import it.vfsfitvnm.route.rememberRoute +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.BottomSheet +import it.vfsfitvnm.vimusic.ui.components.BottomSheetState +import it.vfsfitvnm.vimusic.ui.components.Message +import it.vfsfitvnm.vimusic.ui.components.OutcomeItem +import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder +import it.vfsfitvnm.vimusic.ui.screens.rememberLyricsRoute +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer +import it.vfsfitvnm.vimusic.utils.center +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.medium +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.isEvaluable +import it.vfsfitvnm.youtubemusic.toNotNull +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext + +@ExperimentalAnimationApi +@Composable +fun PlayerBottomSheet( + layoutState: BottomSheetState, + onGlobalRouteEmitted: () -> Unit, + modifier: Modifier = Modifier, +) { + val player = LocalYoutubePlayer.current ?: return + + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + val coroutineScope = rememberCoroutineScope() + + val lyricsRoute = rememberLyricsRoute() + + var route by rememberRoute() + + var nextOutcome by remember(player.mediaItem!!.mediaId) { + mutableStateOf>(Outcome.Initial) + } + + var lyricsOutcome by remember(player.mediaItem!!.mediaId) { + mutableStateOf>(Outcome.Initial) + } + + BottomSheet( + state = layoutState, + peekHeight = 128.dp, + elevation = 16.dp, + shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp), + handleOutsideInteractionsWhenExpanded = true, + modifier = modifier, + collapsedContent = { + Column( + verticalArrangement = Arrangement.SpaceEvenly, + modifier = Modifier + .fillMaxWidth() + .height(layoutState.lowerBound) + .background(colorPalette.elevatedBackground) + ) { + Spacer( + modifier = Modifier + .align(Alignment.CenterHorizontally) + .background(color = colorPalette.textDisabled, shape = RoundedCornerShape(16.dp)) + .width(36.dp) + .height(4.dp) + .padding(top = 8.dp) + ) + + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + ) { + @Composable + fun Element( + text: String, + targetRoute: Route? + ) { + val color by animateColorAsState( + if (targetRoute == route) { + colorPalette.text + } else { + colorPalette.textDisabled + } + ) + + val scale by animateFloatAsState( + if (targetRoute == route) { + 1f + } else { + 0.9f + } + ) + + BasicText( + text = text, + style = typography.xs.medium.color(color).center, + modifier = Modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() } + ) { + route = targetRoute + coroutineScope.launch(Dispatchers.Main) { + layoutState.expand() + } + } + .padding(vertical = 8.dp) + .scale(scale) + .weight(1f) + ) + } + + Element( + text = "UP NEXT", + targetRoute = null + ) + + Element( + text = "LYRICS", + targetRoute = lyricsRoute + ) + } + } + } + ) { + RouteHandler( + route = route, + onRouteChanged = { + route = it + }, + handleBackPress = false, + transitionSpec = { + when (targetState.route) { + lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with + slideOutOfContainer(AnimatedContentScope.SlideDirection.Left) + else -> when (initialState.route) { + lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with + slideOutOfContainer(AnimatedContentScope.SlideDirection.Right) + else -> empty + } + } + }, + modifier = Modifier + .background(colorPalette.elevatedBackground) + .fillMaxSize() + ) { + lyricsRoute { + OutcomeItem( + outcome = lyricsOutcome, + onInitialize = { + lyricsOutcome = Outcome.Loading + + coroutineScope.launch(Dispatchers.Main) { + if (nextOutcome.isEvaluable) { + nextOutcome = Outcome.Loading + nextOutcome = withContext(Dispatchers.IO) { + YouTube.next( + player.mediaItem!!.mediaId, + player.mediaItem!!.mediaMetadata.extras?.getString("playlistId"), + player.mediaItemIndex + ) + } + } + + lyricsOutcome = nextOutcome.flatMap { + it.lyrics?.text().toNotNull() + } + } + }, + onLoading = { + LyricsShimmer( + modifier = Modifier + .shimmer() + ) + } + ) { lyrics -> + if (lyrics != null) { + BasicText( + text = lyrics, + style = typography.xs.center, + modifier = Modifier + .padding(top = 64.dp) + .nestedScroll(remember { layoutState.nestedScrollConnection() }) + .verticalScroll(rememberScrollState()) + .fillMaxWidth() + .padding(vertical = 16.dp) + .padding(horizontal = 48.dp) + ) + } else { + Message( + text = "Lyrics not available", + icon = R.drawable.text, + modifier = Modifier + .padding(top = 64.dp) + ) + } + } + } + + host { + CurrentPlaylistView( + layoutState = layoutState, + onGlobalRouteEmitted = onGlobalRouteEmitted, + modifier = Modifier + .padding(top = 64.dp) + ) + } + } + } +} + +@Composable +fun LyricsShimmer( + modifier: Modifier = Modifier +) { + Column( + verticalArrangement = Arrangement.Center, + horizontalAlignment = Alignment.CenterHorizontally, + modifier = modifier + ) { + repeat(16) { index -> + TextPlaceholder( + modifier = Modifier + .alpha(1f - index * 0.05f) + ) + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt new file mode 100644 index 0000000..d0ae6a5 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt @@ -0,0 +1,466 @@ +package it.vfsfitvnm.vimusic.ui.views + +import android.text.format.DateUtils +import androidx.compose.animation.* +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicText +import androidx.compose.runtime.* +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.drawWithCache +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.graphics.graphicsLayer +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalConfiguration +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.media3.common.C +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.ui.DefaultTimeBar +import androidx.media3.ui.TimeBar +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.ui.components.* +import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.* +import it.vfsfitvnm.youtubemusic.Outcome +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.launch + + +@ExperimentalAnimationApi +@Composable +fun PlayerView( + layoutState: BottomSheetState, + modifier: Modifier = Modifier, +) { + val menuState = LocalMenuState.current + val preferences = LocalPreferences.current + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + val density = LocalDensity.current + val configuration = LocalConfiguration.current + val player = LocalYoutubePlayer.current + + val coroutineScope = rememberCoroutineScope() + + player?.mediaItem ?: return + + val smallThumbnailSize = remember { + density.run { 64.dp.roundToPx() } + } + + val (thumbnailSizeDp, thumbnailSizePx) = remember { + val size = minOf(configuration.screenHeightDp, configuration.screenWidthDp).dp + size to density.run { size.minus(64.dp).roundToPx() } + } + + val song by remember(player.mediaItem?.mediaId) { + player.mediaItem?.mediaId?.let(Database::songFlow)?.distinctUntilChanged() ?: flowOf(null) + }.collectAsState(initial = null, context = Dispatchers.IO) + + + BottomSheet( + state = layoutState, + modifier = modifier, + collapsedContent = { + if (!layoutState.isExpanded) { + Row( + horizontalArrangement = Arrangement.spacedBy(8.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .height(layoutState.lowerBound) + .fillMaxWidth() + .graphicsLayer { + alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f) + } + .drawWithCache { + val offset = 64.dp.toPx() + val x = ((size.width - offset) * player.progress) + offset + + onDrawWithContent { + drawContent() + drawLine( + color = colorPalette.text, + start = Offset( + x = offset, + y = 1.dp.toPx() + ), + end = Offset( + x = x, + y = 1.dp.toPx() + ), + strokeWidth = 2.dp.toPx() + ) + } + } + .background(colorPalette.elevatedBackground) + ) { + AsyncImage( + model = "${player.mediaMetadata.artworkUri}-w$smallThumbnailSize-h$smallThumbnailSize", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(64.dp) + ) + + Column( + verticalArrangement = Arrangement.Center, + modifier = Modifier + .weight(1f) + ) { + BasicText( + text = player.mediaMetadata.title?.toString() ?: "", + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText( + text = player.mediaMetadata.artist?.toString() ?: "", + style = typography.xs, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + when { + player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image( + painter = painterResource(R.drawable.play), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + if (player.playbackState == Player.STATE_IDLE) { + player.mediaController.prepare() + } + player.mediaController.play() + } + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + else -> Image( + painter = painterResource(R.drawable.pause), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + player.mediaController.pause() + } + .padding(vertical = 8.dp) + .padding(horizontal = 16.dp) + .size(24.dp) + ) + } + } + } + } + ) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier + .background(colorPalette.background) + .padding(bottom = 72.dp) + .fillMaxSize() + ) { + var scrubbingPosition by remember { + mutableStateOf(null) + } + + TopAppBar { + Spacer( + modifier = Modifier + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + + Image( + painter = painterResource(R.drawable.ellipsis_horizontal), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + menuState.display { + QueuedMediaItemMenu( + mediaItem = player.mediaItem ?: MediaItem.EMPTY, + indexInQueue = player.mediaItemIndex, + onDismiss = menuState::hide, + onGlobalRouteEmitted = layoutState.collapse + ) + } + } + .padding(horizontal = 16.dp, vertical = 8.dp) + .size(24.dp) + ) + } + + if (player.error == null) { + AnimatedContent( + targetState = player.mediaItemIndex, + transitionSpec = { + val slideDirection = + if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right + + (slideIntoContainer(slideDirection) + fadeIn() with + slideOutOfContainer(slideDirection) + fadeOut()).using( + SizeTransform(clip = false) + ) + }, + modifier = Modifier + .weight(1f) + .align(Alignment.CenterHorizontally) + ) { + val artworkUri = remember(it) { + player.mediaController.getMediaItemAt(it).mediaMetadata.artworkUri + } + + AsyncImage( + model = "$artworkUri-w$thumbnailSizePx-h$thumbnailSizePx", + contentDescription = null, + modifier = Modifier + .padding(bottom = 32.dp) + .padding(horizontal = 32.dp) + .size(thumbnailSizeDp) + ) + } + } else { + Box( + contentAlignment = Alignment.Center, + modifier = Modifier + .align(Alignment.CenterHorizontally) + .padding(bottom = 32.dp) + .padding(horizontal = 32.dp) + .size(thumbnailSizeDp) + ) { +// BasicText( +// text = playerState.error?.message ?: "", +// style = typography.xs.medium +// ) + Error( + error = Outcome.Error.Unhandled(player.error!!), + onRetry = { + player.mediaController.playWhenReady = true + player.mediaController.prepare() + player.error = null + } + ) + } + } + + BasicText( + text = player.mediaMetadata.title?.toString() ?: "", + style = typography.l.bold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 32.dp) + ) + + + BasicText( + text = player.mediaMetadata.extras?.getStringArrayList("artistNames") + ?.joinToString("") ?: "", + style = typography.s.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .padding(horizontal = 32.dp) + ) + + AndroidView( + factory = { context -> + DefaultTimeBar(context).also { + it.setPlayedColor(colorPalette.text.toArgb()) + it.setUnplayedColor(colorPalette.textDisabled.toArgb()) + it.setScrubberColor(colorPalette.text.toArgb()) + it.addListener(object : TimeBar.OnScrubListener { + override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit + + override fun onScrubMove(timeBar: TimeBar, position: Long) { + scrubbingPosition = position + } + + override fun onScrubStop( + timeBar: TimeBar, + position: Long, + canceled: Boolean + ) { + if (!canceled) { + scrubbingPosition = position + player.mediaController.seekTo(position) + player.currentPosition = player.mediaController.currentPosition + } + scrubbingPosition = null + } + }) + } + }, + update = { + it.setDuration(player.duration) + it.setPosition(player.currentPosition) + }, + modifier = Modifier + .padding(top = 16.dp) + .padding(horizontal = 32.dp) + .fillMaxWidth() + ) + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(horizontal = 32.dp) + .fillMaxWidth() + .padding(bottom = 16.dp) + ) { + val text by remember { + derivedStateOf { + DateUtils.formatElapsedTime((scrubbingPosition ?: player.currentPosition) / 1000) + } + } + + BasicText( + text = text, + style = typography.xxs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + + if (player.duration != C.TIME_UNSET) { + BasicText( + text = DateUtils.formatElapsedTime(player.duration / 1000), + style = typography.xxs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + } + + Row( + horizontalArrangement = Arrangement.SpaceBetween, + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .padding(vertical = 32.dp) + ) { + Image( + painter = painterResource(R.drawable.heart), + contentDescription = null, + colorFilter = ColorFilter.tint( + song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled + ), + modifier = Modifier + .clickable { + coroutineScope.launch(Dispatchers.IO) { + Database.update( + (song ?: Database.insert(player.mediaItem!!)).toggleLike() + ) + } + } + .padding(horizontal = 16.dp) + .size(28.dp) + ) + + Image( + painter = painterResource(R.drawable.play_skip_back), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + player.mediaController.seekToPrevious() + } + .padding(horizontal = 16.dp) + .size(32.dp) + ) + + when { + player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image( + painter = painterResource(R.drawable.play_circle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + if (player.playbackState == Player.STATE_IDLE) { + player.mediaController.prepare() + } + + player.mediaController.play() + } + .size(64.dp) + ) + else -> Image( + painter = painterResource(R.drawable.pause_circle), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + player.mediaController.pause() + } + .size(64.dp) + ) + } + + Image( + painter = painterResource(R.drawable.play_skip_forward), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.text), + modifier = Modifier + .clickable { + player.mediaController.seekToNext() + } + .padding(horizontal = 16.dp) + .size(32.dp) + ) + + + Image( + painter = painterResource( + if (player.repeatMode == Player.REPEAT_MODE_ONE) { + R.drawable.repeat_one + } else { + R.drawable.repeat + } + ), + contentDescription = null, + colorFilter = ColorFilter.tint( + if (player.repeatMode == Player.REPEAT_MODE_OFF) { + colorPalette.textDisabled + } else { + colorPalette.text + } + ), + modifier = Modifier + .clickable { + player.mediaController.repeatMode = + (player.mediaController.repeatMode + 2) % 3 + preferences.repeatMode = player.mediaController.repeatMode + } + .padding(horizontal = 16.dp) + .size(28.dp) + ) + } + } + + PlayerBottomSheet( + layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound - 128.dp), + onGlobalRouteEmitted = layoutState.collapse, + modifier = Modifier + .padding(bottom = 128.dp) + .align(Alignment.BottomCenter) + ) + } +} + diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt new file mode 100644 index 0000000..e50d2f0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlaylistPreviewItem.kt @@ -0,0 +1,100 @@ +package it.vfsfitvnm.vimusic.ui.views + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.text.BasicText +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.graphics.Brush +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalDensity +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.dp +import coil.compose.AsyncImage +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.models.PlaylistPreview +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.color +import it.vfsfitvnm.vimusic.utils.semiBold +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.distinctUntilChanged + +@Composable +fun PlaylistPreviewItem( + playlistPreview: PlaylistPreview, + modifier: Modifier = Modifier, + thumbnailSize: Dp = 54.dp, +) { + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + val density = LocalDensity.current + + val thumbnailSizePx = density.run { + thumbnailSize.toPx().toInt() + } + + val thumbnails by remember(playlistPreview.playlist.id) { + Database.playlistThumbnailUrls(playlistPreview.playlist.id).distinctUntilChanged() + }.collectAsState(initial = emptyList(), context = Dispatchers.IO) + + Box( + modifier = modifier + .background(colorPalette.lightBackground) + .size(thumbnailSize * 2) + ) { + if (thumbnails.toSet().size == 1) { + AsyncImage( + model = "${thumbnails.first()}-w${thumbnailSizePx * 2}-h${thumbnailSizePx * 2}", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .size(thumbnailSize * 2) + ) + } else { + listOf( + Alignment.TopStart, + Alignment.TopEnd, + Alignment.BottomStart, + Alignment.BottomEnd + ).forEachIndexed { index, alignment -> + AsyncImage( + model = "${thumbnails.getOrNull(index)}-w$thumbnailSizePx-h$thumbnailSizePx", + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .align(alignment) + .size(thumbnailSize) + ) + } + } + + BasicText( + text = playlistPreview.playlist.name, + style = typography.xxs.semiBold.color(Color.White), + maxLines = 2, + overflow = TextOverflow.Ellipsis, + modifier = Modifier + .fillMaxWidth() + .align(Alignment.BottomStart) + .background( + Brush.verticalGradient( + colors = listOf( + Color.Transparent, + Color.Black.copy(alpha = 0.75f) + ) + ) + ) + .padding(horizontal = 8.dp, vertical = 4.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt new file mode 100644 index 0000000..70a15ac --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt @@ -0,0 +1,199 @@ +package it.vfsfitvnm.vimusic.ui.views + +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.text.BasicText +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.runtime.Composable +import androidx.compose.runtime.NonRestartableComposable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.graphics.ColorFilter +import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.painterResource +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import androidx.media3.common.MediaItem +import coil.compose.AsyncImage +import coil.request.ImageRequest +import it.vfsfitvnm.vimusic.ui.components.LocalMenuState +import it.vfsfitvnm.vimusic.R +import it.vfsfitvnm.vimusic.models.SongWithInfo +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette +import it.vfsfitvnm.vimusic.ui.styling.LocalTypography +import it.vfsfitvnm.vimusic.utils.secondary +import it.vfsfitvnm.vimusic.utils.semiBold + + +@ExperimentalAnimationApi +@Composable +@NonRestartableComposable +fun SongItem( + mediaItem: MediaItem, + thumbnailSize: Int, + onClick: () -> Unit, + menuContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color? = null, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, +) { + SongItem( + thumbnailModel = ImageRequest.Builder(LocalContext.current) + .diskCacheKey(mediaItem.mediaId) + .data("${mediaItem.mediaMetadata.artworkUri}-w$thumbnailSize-h$thumbnailSize") + .build(), + title = mediaItem.mediaMetadata.title!!.toString(), + authors = mediaItem.mediaMetadata.artist.toString(), + durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?", + menuContent = menuContent, + onClick = onClick, + onThumbnailContent = onThumbnailContent, + backgroundColor = backgroundColor, + modifier = modifier, + ) +} + +@ExperimentalAnimationApi +@Composable +@NonRestartableComposable +fun SongItem( + song: SongWithInfo, + thumbnailSize: Int, + onClick: () -> Unit, + menuContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color? = null, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, +) { + SongItem( + thumbnailModel = "${song.song.thumbnailUrl}-w$thumbnailSize-h$thumbnailSize", + title = song.song.title, + authors = song.authors?.joinToString("") { it.text } ?: "", + durationText = song.song.durationText, + menuContent = menuContent, + onClick = onClick, + onThumbnailContent = onThumbnailContent, + backgroundColor = backgroundColor, + modifier = modifier, + ) +} + + +@ExperimentalAnimationApi +@Composable +@NonRestartableComposable +fun SongItem( + thumbnailModel: Any?, + title: String, + authors: String, + durationText: String, + onClick: () -> Unit, + menuContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color? = null, + onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null, +) { + SongItem( + title = title, + authors = authors, + durationText = durationText, + onClick = onClick, + startContent = { + Box( + modifier = Modifier + .size(54.dp) + ) { + AsyncImage( + model = thumbnailModel, + contentDescription = null, + contentScale = ContentScale.Crop, + modifier = Modifier + .fillMaxSize() + ) + + onThumbnailContent?.invoke(this) + } + }, + menuContent = menuContent, + backgroundColor = backgroundColor, + modifier = modifier, + ) +} + +@ExperimentalAnimationApi +@Composable +fun SongItem( + title: String, + authors: String, + durationText: String?, + onClick: () -> Unit, + startContent: @Composable () -> Unit, + menuContent: @Composable () -> Unit, + modifier: Modifier = Modifier, + backgroundColor: Color? = null, +) { + val menuState = LocalMenuState.current + val colorPalette = LocalColorPalette.current + val typography = LocalTypography.current + + Row( + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(8.dp), + modifier = modifier + .clickable( + indication = rememberRipple(bounded = true), + interactionSource = remember { MutableInteractionSource() }, + onClick = onClick + ) + .fillMaxWidth() + .padding(vertical = 4.dp) + .background(backgroundColor ?: colorPalette.background) + .padding(start = 16.dp, end = 8.dp) + ) { + startContent() + + Column( + modifier = Modifier + .weight(1f) + ) { + BasicText( + text = title, + style = typography.xs.semiBold, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + BasicText( + text = buildString { + append(authors) + if (authors.isNotEmpty() && durationText != null) { + append(" • ") + } + append(durationText) + }, + style = typography.xs.semiBold.secondary, + maxLines = 1, + overflow = TextOverflow.Ellipsis, + ) + } + + Image( + painter = painterResource(R.drawable.ellipsis_vertical), + contentDescription = null, + colorFilter = ColorFilter.tint(colorPalette.textSecondary), + modifier = Modifier + .clickable { + menuState.display(menuContent) + } + .padding(horizontal = 8.dp, vertical = 4.dp) + .size(20.dp) + ) + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt new file mode 100644 index 0000000..0f09d39 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt @@ -0,0 +1,44 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.media3.common.MediaItem +import androidx.media3.common.Player +import androidx.media3.common.Timeline + + +fun Player.forcePlay(mediaItem: MediaItem) { + setMediaItem(mediaItem, true) + playWhenReady = true + prepare() +} + +fun Player.forcePlayAtIndex(mediaItems: List, mediaItemIndex: Int) { + if (mediaItems.isEmpty()) return + + setMediaItems(mediaItems, true) + playWhenReady = true + seekToDefaultPosition(mediaItemIndex) + prepare() +} + +fun Player.forcePlayFromBeginning(mediaItems: List) = + forcePlayAtIndex(mediaItems, 0) + +val Player.lastMediaItem: MediaItem? + get() = mediaItemCount.takeIf { it > 0 }?.let { it - 1 }?.let(::getMediaItemAt) + +val Timeline.mediaItems: List + get() = (0 until windowCount).map { index -> + getWindow(index, Timeline.Window()).mediaItem + } + +fun Player.addNext(mediaItem: MediaItem) { + addMediaItem(currentMediaItemIndex + 1, mediaItem) +} + +fun Player.enqueue(mediaItem: MediaItem) { + addMediaItem(mediaItemCount, mediaItem) +} + +fun Player.enqueue(mediaItems: List) { + addMediaItems(mediaItemCount, mediaItems) +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt new file mode 100644 index 0000000..7f832ec --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/PlayerState.kt @@ -0,0 +1,92 @@ +package it.vfsfitvnm.vimusic.utils + +import android.os.Handler +import android.os.Looper +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import androidx.media3.common.* +import androidx.media3.session.MediaController +import kotlin.math.absoluteValue + +open class PlayerState(val mediaController: MediaController) : Player.Listener { + private val handler = Handler(Looper.getMainLooper()) + + var currentPosition by mutableStateOf(mediaController.currentPosition) + + var duration by mutableStateOf(mediaController.duration) + private set + + val progress: Float + get() = currentPosition.toFloat() / duration.absoluteValue + + var playbackState by mutableStateOf(mediaController.playbackState) + private set + + var mediaItemIndex by mutableStateOf(mediaController.currentMediaItemIndex) + private set + + var mediaItem by mutableStateOf(mediaController.currentMediaItem) + private set + + var mediaMetadata by mutableStateOf(mediaController.mediaMetadata) + private set + + var isPlaying by mutableStateOf(mediaController.isPlaying) + private set + + var playWhenReady by mutableStateOf(mediaController.playWhenReady) + private set + + var repeatMode by mutableStateOf(mediaController.repeatMode) + private set + + var error by mutableStateOf(mediaController.playerError) + + var mediaItems by mutableStateOf(mediaController.currentTimeline.mediaItems) + private set + + init { + handler.post(object : Runnable { + override fun run() { + duration = mediaController.duration + currentPosition = mediaController.currentPosition + handler.postDelayed(this, 500) + } + }) + } + + override fun onPlaybackStateChanged(playbackState: Int) { + this.playbackState = playbackState + } + + override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) { + this.mediaMetadata = mediaMetadata + } + + override fun onIsPlayingChanged(isPlaying: Boolean) { + this.isPlaying = isPlaying + } + + override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) { + this.playWhenReady = playWhenReady + } + + override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) { + this.mediaItem = mediaItem + mediaItemIndex = mediaController.currentMediaItemIndex + } + + override fun onRepeatModeChanged(repeatMode: Int) { + this.repeatMode = repeatMode + } + + override fun onPlayerError(playbackException: PlaybackException) { + error = playbackException + } + + override fun onTimelineChanged(timeline: Timeline, reason: Int) { + mediaItems = timeline.mediaItems + mediaItemIndex = mediaController.currentMediaItemIndex + } +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt new file mode 100644 index 0000000..f38b2f3 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt @@ -0,0 +1,108 @@ +package it.vfsfitvnm.vimusic.utils + +import android.content.Context +import android.content.SharedPreferences +import androidx.compose.runtime.* +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.unit.Dp +import androidx.compose.ui.unit.TextUnit +import androidx.compose.ui.unit.dp +import androidx.compose.ui.unit.sp +import androidx.core.content.edit +import androidx.media3.common.Player +import it.vfsfitvnm.vimusic.enums.SongCollection +import it.vfsfitvnm.youtubemusic.YouTube + +@Stable +class Preferences(holder: SharedPreferences) : SharedPreferences by holder { + var searchFilter by preference("searchFilter", YouTube.Item.Song.Filter.value) + var repeatMode by preference("repeatMode", Player.REPEAT_MODE_OFF) + var homePageSongCollection by preference("homePageSongCollection", SongCollection.MostPlayed) +} + +val LocalPreferences = staticCompositionLocalOf { TODO() } + +@Composable +fun rememberPreferences(): Preferences { + val context = LocalContext.current + return remember { + Preferences(context.getSharedPreferences("preferences", Context.MODE_PRIVATE)) + } +} + +private fun SharedPreferences.preference(key: String, defaultValue: Boolean) = + mutableStateOf(value = getBoolean(key, defaultValue)) { + edit { + putBoolean(key, it) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: Int) = + mutableStateOf(value = getInt(key, defaultValue)) { + edit { + putInt(key, it) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: Long) = + mutableStateOf(value = getLong(key, defaultValue)) { + edit { + putLong(key, it) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: Float) = + mutableStateOf(value = getFloat(key, defaultValue)) { + edit { + putFloat(key, it) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: String) = + mutableStateOf(value = getString(key, defaultValue)!!) { + edit { + putString(key, it) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: Set) = + mutableStateOf(value = getStringSet(key, defaultValue)!!) { + edit { + putStringSet(key, it) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: Dp) = + mutableStateOf(value = getFloat(key, defaultValue.value).dp) { + edit { + putFloat(key, it.value) + } + } + +private fun SharedPreferences.preference(key: String, defaultValue: TextUnit) = + mutableStateOf(value = getFloat(key, defaultValue.value).sp) { + edit { + putFloat(key, it.value) + } + } + +private inline fun > SharedPreferences.preference( + key: String, + defaultValue: T +) = + mutableStateOf(value = enumValueOf(getString(key, defaultValue.name)!!)) { + edit { + putString(key, it.name) + } + } + +private fun mutableStateOf(value: T, onStructuralInequality: (newValue: T) -> Unit) = + mutableStateOf( + value = value, + policy = object : SnapshotMutationPolicy { + override fun equivalent(a: T, b: T): Boolean { + val areEquals = a == b + if (!areEquals) onStructuralInequality(b) + return areEquals + } + }) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt new file mode 100644 index 0000000..74987a0 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RelaunchableEffect.kt @@ -0,0 +1,19 @@ + +@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE") +@file:OptIn(InternalComposeApi::class) + +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.runtime.* +import kotlinx.coroutines.CoroutineScope + +@Composable +@NonRestartableComposable +fun relaunchableEffect( + key1: Any?, + block: suspend CoroutineScope.() -> Unit +): () -> Unit { + val applyContext = currentComposer.applyCoroutineContext + val launchedEffect = remember(key1) { LaunchedEffectImpl(applyContext, block) } + return launchedEffect::onRemembered +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt new file mode 100644 index 0000000..64be0c3 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt @@ -0,0 +1,11 @@ +package it.vfsfitvnm.vimusic.utils + +class RingBuffer(val size: Int, init: (index: Int) -> T) { + private val list = MutableList(2, init) + + private var index = 0 + + fun getOrNull(index: Int): T? = list.getOrNull(index) + + fun append(element: T) = list.set(index++ % size, element) +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt new file mode 100644 index 0000000..6002856 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt @@ -0,0 +1,43 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.runtime.Composable +import androidx.compose.runtime.ReadOnlyComposable +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.TextStyle +import androidx.compose.ui.text.font.FontStyle +import androidx.compose.ui.text.font.FontWeight +import androidx.compose.ui.text.style.TextAlign +import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette + +fun TextStyle.style(style: FontStyle) = copy(fontStyle = style) + +fun TextStyle.weight(weight: FontWeight) = copy(fontWeight = weight) + +fun TextStyle.align(align: TextAlign) = copy(textAlign = align) + +fun TextStyle.color(color: Color) = copy(color = color) + +inline val TextStyle.italic: TextStyle + get() = style(FontStyle.Italic) + +inline val TextStyle.medium: TextStyle + get() = weight(FontWeight.Medium) + +inline val TextStyle.semiBold: TextStyle + get() = weight(FontWeight.SemiBold) + +inline val TextStyle.bold: TextStyle + get() = weight(FontWeight.Bold) + +inline val TextStyle.center: TextStyle + get() = align(TextAlign.Center) + +inline val TextStyle.secondary: TextStyle + @Composable + @ReadOnlyComposable + get() = color(LocalColorPalette.current.textSecondary) + +inline val TextStyle.disabled: TextStyle + @Composable + @ReadOnlyComposable + get() = color(LocalColorPalette.current.textDisabled) diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt new file mode 100644 index 0000000..efbdf14 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt @@ -0,0 +1,129 @@ +package it.vfsfitvnm.vimusic.utils + +import androidx.compose.runtime.* +import androidx.media3.common.Player +import androidx.media3.session.MediaController +import com.google.common.util.concurrent.ListenableFuture +import it.vfsfitvnm.youtubemusic.Outcome +import it.vfsfitvnm.youtubemusic.YouTube +import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint +import kotlinx.coroutines.* +import kotlinx.coroutines.guava.await +import kotlinx.coroutines.sync.Mutex + +class YoutubePlayer(mediaController: MediaController) : PlayerState(mediaController) { + object Radio { + var isActive by mutableStateOf(false) + + var listener: Listener? = null + + private var videoId: String? = null + private var playlistId: String? = null + private var playlistSetVideoId: String? = null + private var parameters: String? = null + + var nextContinuation by mutableStateOf>(Outcome.Initial) + + fun setup(videoId: String? = null, playlistId: String? = null, playlistSetVideoId: String? = null, parameters: String? = null) { + this.videoId = videoId + this.playlistId = playlistId + this.playlistSetVideoId = playlistSetVideoId + this.parameters = parameters + + isActive = true + nextContinuation = Outcome.Initial + } + + fun setup(watchEndpoint: NavigationEndpoint.Endpoint.Watch?) { + setup( + videoId = watchEndpoint?.videoId, + playlistId = watchEndpoint?.playlistId, + parameters = watchEndpoint?.params, + playlistSetVideoId = watchEndpoint?.playlistSetVideoId + ) + + listener?.process(true) + } + + suspend fun process(player: Player, force: Boolean = false, play: Boolean = false) { + if (!isActive) return + + if (!force && !play) { + val isFirstSong = withContext(Dispatchers.Main) { + player.mediaItemCount == 0 || (player.currentMediaItemIndex == 0 && player.mediaItemCount == 1) + } + val isNearEndSong = withContext(Dispatchers.Main) { + player.mediaItemCount - player.currentMediaItemIndex <= 3 + } + + if (!isFirstSong && !isNearEndSong) { + return + } + } + + val token = nextContinuation.valueOrNull + + nextContinuation = Outcome.Loading + + nextContinuation = withContext(Dispatchers.IO) { + YouTube.next( + videoId = videoId ?: withContext(Dispatchers.Main) { + player.lastMediaItem?.mediaId ?: error("This should not happen") + }, + playlistId = playlistId, + params = parameters, + playlistSetVideoId = playlistSetVideoId, + continuation = token + ) + }.map { nextResult -> + nextResult.items?.map(it.vfsfitvnm.youtubemusic.YouTube.Item.Song::asMediaItem)?.let { mediaItems -> + withContext(Dispatchers.Main) { + if (play) { + player.forcePlayFromBeginning(mediaItems) + } else { + player.addMediaItems(mediaItems.drop(if (token == null) 1 else 0)) + } + } + } + + nextResult.continuation?.takeUnless { token == nextResult.continuation } + }.recoverWith(token) + } + + fun reset() { + videoId = null + playlistId = null + playlistSetVideoId = null + parameters = null + isActive = false + nextContinuation = Outcome.Initial + } + + interface Listener { + fun process(play: Boolean) + } + } +} + +val LocalYoutubePlayer = compositionLocalOf { null } + +@Composable +fun rememberYoutubePlayer( + mediaControllerFuture: ListenableFuture, + repeatMode: Int +): YoutubePlayer? { + val mediaController by produceState(initialValue = null) { + value = mediaControllerFuture.await().also { + it.repeatMode = repeatMode + } + } + + val playerState = remember(mediaController) { + YoutubePlayer(mediaController ?: return@remember null).also { + // TODO: should we remove the listener later on? + mediaController?.addListener(it) + } + } + + return playerState +} diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/rememberHapticFeedback.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/rememberHapticFeedback.kt new file mode 100644 index 0000000..ef0fa42 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/rememberHapticFeedback.kt @@ -0,0 +1,24 @@ +package it.vfsfitvnm.vimusic.utils + +import android.view.HapticFeedbackConstants +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.hapticfeedback.HapticFeedback +import androidx.compose.ui.hapticfeedback.HapticFeedbackType +import androidx.compose.ui.platform.LocalView + +@Composable +fun rememberHapticFeedback(): HapticFeedback { + val view = LocalView.current + + return remember { + object : HapticFeedback { + override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) { + view.performHapticFeedback( + HapticFeedbackConstants.LONG_PRESS, + HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING + ) + } + } + } +} \ No newline at end of file diff --git a/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt new file mode 100644 index 0000000..d213886 --- /dev/null +++ b/app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt @@ -0,0 +1,172 @@ +package it.vfsfitvnm.vimusic.utils + +import android.content.Context +import android.content.Intent +import androidx.core.net.toUri +import androidx.core.os.bundleOf +import androidx.media3.common.MediaItem +import androidx.media3.common.MediaMetadata +import it.vfsfitvnm.vimusic.Database +import it.vfsfitvnm.vimusic.internal +import it.vfsfitvnm.vimusic.models.Info +import it.vfsfitvnm.vimusic.models.Song +import it.vfsfitvnm.vimusic.models.SongWithAuthors +import it.vfsfitvnm.vimusic.models.SongWithInfo +import it.vfsfitvnm.youtubemusic.YouTube + +fun Context.shareAsYouTubeSong(mediaItem: MediaItem) { + val sendIntent = Intent().apply { + action = Intent.ACTION_SEND + type = "text/plain" + putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}") + } + + startActivity(Intent.createChooser(sendIntent, null)) +} + +fun Database.insert(mediaItem: MediaItem): Song { + return internal.runInTransaction { + Database.song(mediaItem.mediaId)?.let { + return@runInTransaction it + } + + val albumInfo = mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId -> + Info( + text = mediaItem.mediaMetadata.albumTitle!!.toString(), + browseId = albumId + ) + } + + val albumInfoId = albumInfo?.let { insert(it) } + + val authorsInfo = + mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames -> + mediaItem.mediaMetadata.extras!!.getStringArrayList("artistIds")?.let { artistIds -> + artistNames.mapIndexed { index, artistName -> + Info( + text = artistName, + browseId = artistIds.getOrNull(index) + ) + } + } + } + + val song = Song( + id = mediaItem.mediaId, + title = mediaItem.mediaMetadata.title!!.toString(), + albumInfoId = albumInfoId, + durationText = mediaItem.mediaMetadata.extras?.getString("durationText")!!, + thumbnailUrl = mediaItem.mediaMetadata.artworkUri!!.toString() + ) + + insert(song) + + val authorsInfoId = authorsInfo?.let { insert(authorsInfo) } + + authorsInfoId?.forEach { authorInfoId -> + insert( + SongWithAuthors( + songId = mediaItem.mediaId, + authorInfoId = authorInfoId + ) + ) + } + + return@runInTransaction song + } +} + +val YouTube.Item.Song.asMediaItem: MediaItem + get() = MediaItem.Builder() + .setMediaId(info.endpoint!!.videoId) + .setUri(info.endpoint!!.videoId) + .setCustomCacheKey(info.endpoint!!.videoId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(info.name) + .setArtist(authors.joinToString("") { it.name }) + .setAlbumTitle(album?.name) + .setArtworkUri(thumbnail.url.toUri()) + .setExtras( + bundleOf( + "videoId" to info.endpoint!!.videoId, + "albumId" to album?.endpoint?.browseId, + "durationText" to durationText, + "artistNames" to authors.map { it.name }, + "artistIds" to authors.map { it.endpoint?.browseId }, + ) + ) + .build() + ) + .build() + +val YouTube.Item.Video.asMediaItem: MediaItem + get() = MediaItem.Builder() + .setMediaId(info.endpoint!!.videoId) + .setUri(info.endpoint!!.videoId) + .setCustomCacheKey(info.endpoint!!.videoId) + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(info.name) + .setArtist(authors.joinToString("") { it.name }) + .setArtworkUri(thumbnail.url.toUri()) + .setExtras( + bundleOf( + "videoId" to info.endpoint!!.videoId, + "durationText" to durationText, + "artistNames" to if (isOfficialMusicVideo) authors.map { it.name } else null, + "artistIds" to if (isOfficialMusicVideo) authors.map { it.endpoint?.browseId } else null, + ) + ) + .build() + ) + .build() + +val SongWithInfo.asMediaItem: MediaItem + get() = MediaItem.Builder() + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(song.title) + .setArtist(authors?.joinToString("") { it.text }) + .setAlbumTitle(album?.text) + .setArtworkUri(song.thumbnailUrl?.toUri()) + .setExtras( + bundleOf( + "videoId" to song.id, + "albumId" to album?.browseId, + "artistNames" to authors?.map { it.text }, + "artistIds" to authors?.map { it.browseId }, + "durationText" to song.durationText + ) + ) + .build() + ) + .setMediaId(song.id) + .build() + +fun YouTube.AlbumItem.toMediaItem( + albumId: String, + album: YouTube.Album +): MediaItem? { + return MediaItem.Builder() + .setMediaMetadata( + MediaMetadata.Builder() + .setTitle(info.name) + .setArtist((authors ?: album.authors).joinToString("") { it.name }) + .setAlbumTitle(album.title) + .setArtworkUri(album.thumbnail.url.toUri()) + .setExtras( + bundleOf( + "videoId" to info.endpoint?.videoId, + "playlistId" to info.endpoint?.playlistId, + "albumId" to albumId, + "durationText" to durationText, + "artistNames" to (authors ?: album.authors).map { it.name }, + "artistIds" to (authors ?: album.authors).map { it.endpoint?.browseId } + ) + ) + .build() + ) + .setMediaId(info.endpoint?.videoId ?: return null) + .build() +} \ No newline at end of file diff --git a/app/src/main/res/drawable/add.xml b/app/src/main/res/drawable/add.xml new file mode 100644 index 0000000..a522620 --- /dev/null +++ b/app/src/main/res/drawable/add.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/alert_circle.xml b/app/src/main/res/drawable/alert_circle.xml new file mode 100644 index 0000000..6300810 --- /dev/null +++ b/app/src/main/res/drawable/alert_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/app_icon.xml b/app/src/main/res/drawable/app_icon.xml new file mode 100644 index 0000000..8cafd0a --- /dev/null +++ b/app/src/main/res/drawable/app_icon.xml @@ -0,0 +1,7 @@ + + + + diff --git a/app/src/main/res/drawable/arrow_forward.xml b/app/src/main/res/drawable/arrow_forward.xml new file mode 100644 index 0000000..386591b --- /dev/null +++ b/app/src/main/res/drawable/arrow_forward.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/chevron_back.xml b/app/src/main/res/drawable/chevron_back.xml new file mode 100644 index 0000000..1b7aef3 --- /dev/null +++ b/app/src/main/res/drawable/chevron_back.xml @@ -0,0 +1,13 @@ + + + diff --git a/app/src/main/res/drawable/close.xml b/app/src/main/res/drawable/close.xml new file mode 100644 index 0000000..3b93ed8 --- /dev/null +++ b/app/src/main/res/drawable/close.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/disc.xml b/app/src/main/res/drawable/disc.xml new file mode 100644 index 0000000..6649e6f --- /dev/null +++ b/app/src/main/res/drawable/disc.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/disc_placeholder.xml b/app/src/main/res/drawable/disc_placeholder.xml new file mode 100644 index 0000000..64c4cb8 --- /dev/null +++ b/app/src/main/res/drawable/disc_placeholder.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/ellipsis_horizontal.xml b/app/src/main/res/drawable/ellipsis_horizontal.xml new file mode 100644 index 0000000..2016bf8 --- /dev/null +++ b/app/src/main/res/drawable/ellipsis_horizontal.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ellipsis_vertical.xml b/app/src/main/res/drawable/ellipsis_vertical.xml new file mode 100644 index 0000000..595e74b --- /dev/null +++ b/app/src/main/res/drawable/ellipsis_vertical.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/heart.xml b/app/src/main/res/drawable/heart.xml new file mode 100644 index 0000000..01d5418 --- /dev/null +++ b/app/src/main/res/drawable/heart.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/heart_dislike.xml b/app/src/main/res/drawable/heart_dislike.xml new file mode 100644 index 0000000..be909f6 --- /dev/null +++ b/app/src/main/res/drawable/heart_dislike.xml @@ -0,0 +1,15 @@ + + + + + diff --git a/app/src/main/res/drawable/ic_launcher_foreground.xml b/app/src/main/res/drawable/ic_launcher_foreground.xml new file mode 100644 index 0000000..18d9456 --- /dev/null +++ b/app/src/main/res/drawable/ic_launcher_foreground.xml @@ -0,0 +1,19 @@ + + + + + + diff --git a/app/src/main/res/drawable/list.xml b/app/src/main/res/drawable/list.xml new file mode 100644 index 0000000..6deb54e --- /dev/null +++ b/app/src/main/res/drawable/list.xml @@ -0,0 +1,48 @@ + + + + + + + + diff --git a/app/src/main/res/drawable/pause.xml b/app/src/main/res/drawable/pause.xml new file mode 100644 index 0000000..3280645 --- /dev/null +++ b/app/src/main/res/drawable/pause.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/pause_circle.xml b/app/src/main/res/drawable/pause_circle.xml new file mode 100644 index 0000000..bd2c5c7 --- /dev/null +++ b/app/src/main/res/drawable/pause_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/pencil.xml b/app/src/main/res/drawable/pencil.xml new file mode 100644 index 0000000..c1c2df0 --- /dev/null +++ b/app/src/main/res/drawable/pencil.xml @@ -0,0 +1,20 @@ + + + + diff --git a/app/src/main/res/drawable/person.xml b/app/src/main/res/drawable/person.xml new file mode 100644 index 0000000..4fcfdc8 --- /dev/null +++ b/app/src/main/res/drawable/person.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/play.xml b/app/src/main/res/drawable/play.xml new file mode 100644 index 0000000..af49010 --- /dev/null +++ b/app/src/main/res/drawable/play.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/play_circle.xml b/app/src/main/res/drawable/play_circle.xml new file mode 100644 index 0000000..224b3eb --- /dev/null +++ b/app/src/main/res/drawable/play_circle.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/play_skip_back.xml b/app/src/main/res/drawable/play_skip_back.xml new file mode 100644 index 0000000..14602d8 --- /dev/null +++ b/app/src/main/res/drawable/play_skip_back.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/play_skip_forward.xml b/app/src/main/res/drawable/play_skip_forward.xml new file mode 100644 index 0000000..24f14c4 --- /dev/null +++ b/app/src/main/res/drawable/play_skip_forward.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/radio.xml b/app/src/main/res/drawable/radio.xml new file mode 100644 index 0000000..9511af1 --- /dev/null +++ b/app/src/main/res/drawable/radio.xml @@ -0,0 +1,27 @@ + + + + + + + + + diff --git a/app/src/main/res/drawable/repeat.xml b/app/src/main/res/drawable/repeat.xml new file mode 100644 index 0000000..65e522f --- /dev/null +++ b/app/src/main/res/drawable/repeat.xml @@ -0,0 +1,34 @@ + + + + + + diff --git a/app/src/main/res/drawable/repeat_one.xml b/app/src/main/res/drawable/repeat_one.xml new file mode 100644 index 0000000..26013d7 --- /dev/null +++ b/app/src/main/res/drawable/repeat_one.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/app/src/main/res/drawable/sad.xml b/app/src/main/res/drawable/sad.xml new file mode 100644 index 0000000..502d7a0 --- /dev/null +++ b/app/src/main/res/drawable/sad.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/search.xml b/app/src/main/res/drawable/search.xml new file mode 100644 index 0000000..7a42efa --- /dev/null +++ b/app/src/main/res/drawable/search.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/share_social.xml b/app/src/main/res/drawable/share_social.xml new file mode 100644 index 0000000..3565911 --- /dev/null +++ b/app/src/main/res/drawable/share_social.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/shuffle.xml b/app/src/main/res/drawable/shuffle.xml new file mode 100644 index 0000000..439e8d6 --- /dev/null +++ b/app/src/main/res/drawable/shuffle.xml @@ -0,0 +1,41 @@ + + + + + + + diff --git a/app/src/main/res/drawable/text.xml b/app/src/main/res/drawable/text.xml new file mode 100644 index 0000000..0755fea --- /dev/null +++ b/app/src/main/res/drawable/text.xml @@ -0,0 +1,12 @@ + + + + diff --git a/app/src/main/res/drawable/time.xml b/app/src/main/res/drawable/time.xml new file mode 100644 index 0000000..13be868 --- /dev/null +++ b/app/src/main/res/drawable/time.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/drawable/trash.xml b/app/src/main/res/drawable/trash.xml new file mode 100644 index 0000000..9025eaa --- /dev/null +++ b/app/src/main/res/drawable/trash.xml @@ -0,0 +1,9 @@ + + + diff --git a/app/src/main/res/font/poppins_w300.ttf b/app/src/main/res/font/poppins_w300.ttf new file mode 100644 index 0000000..2ab0221 Binary files /dev/null and b/app/src/main/res/font/poppins_w300.ttf differ diff --git a/app/src/main/res/font/poppins_w400.ttf b/app/src/main/res/font/poppins_w400.ttf new file mode 100644 index 0000000..9f0c71b Binary files /dev/null and b/app/src/main/res/font/poppins_w400.ttf differ diff --git a/app/src/main/res/font/poppins_w400_italic.ttf b/app/src/main/res/font/poppins_w400_italic.ttf new file mode 100644 index 0000000..12b7b3c Binary files /dev/null and b/app/src/main/res/font/poppins_w400_italic.ttf differ diff --git a/app/src/main/res/font/poppins_w500.ttf b/app/src/main/res/font/poppins_w500.ttf new file mode 100644 index 0000000..6bcdcc2 Binary files /dev/null and b/app/src/main/res/font/poppins_w500.ttf differ diff --git a/app/src/main/res/font/poppins_w600.ttf b/app/src/main/res/font/poppins_w600.ttf new file mode 100644 index 0000000..74c726e Binary files /dev/null and b/app/src/main/res/font/poppins_w600.ttf differ diff --git a/app/src/main/res/font/poppins_w700.ttf b/app/src/main/res/font/poppins_w700.ttf new file mode 100644 index 0000000..00559ee Binary files /dev/null and b/app/src/main/res/font/poppins_w700.ttf differ diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml new file mode 100644 index 0000000..7353dbd --- /dev/null +++ b/app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher.png b/app/src/main/res/mipmap-hdpi/ic_launcher.png new file mode 100644 index 0000000..afe9f1c Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-hdpi/ic_launcher_round.png b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png new file mode 100644 index 0000000..56b0759 Binary files /dev/null and b/app/src/main/res/mipmap-hdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher.png b/app/src/main/res/mipmap-mdpi/ic_launcher.png new file mode 100644 index 0000000..c822878 Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-mdpi/ic_launcher_round.png b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png new file mode 100644 index 0000000..ea3fa6e Binary files /dev/null and b/app/src/main/res/mipmap-mdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher.png b/app/src/main/res/mipmap-xhdpi/ic_launcher.png new file mode 100644 index 0000000..afaeaa5 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png new file mode 100644 index 0000000..2246402 Binary files /dev/null and b/app/src/main/res/mipmap-xhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png new file mode 100644 index 0000000..377fe44 Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..07d8d8a Binary files /dev/null and b/app/src/main/res/mipmap-xxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png new file mode 100644 index 0000000..2a7da37 Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher.png differ diff --git a/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png new file mode 100644 index 0000000..bf7b0ae Binary files /dev/null and b/app/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png differ diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml new file mode 100644 index 0000000..fc37647 --- /dev/null +++ b/app/src/main/res/values-night/themes.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/app/src/main/res/values/ic_launcher_background.xml b/app/src/main/res/values/ic_launcher_background.xml new file mode 100644 index 0000000..ce20861 --- /dev/null +++ b/app/src/main/res/values/ic_launcher_background.xml @@ -0,0 +1,4 @@ + + + #4046BF + \ No newline at end of file diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml new file mode 100644 index 0000000..4a5ddb4 --- /dev/null +++ b/app/src/main/res/values/themes.xml @@ -0,0 +1,18 @@ + + + + + diff --git a/build.gradle.kts b/build.gradle.kts new file mode 100644 index 0000000..f20ff45 --- /dev/null +++ b/build.gradle.kts @@ -0,0 +1,16 @@ +buildscript { + repositories { + google() + mavenCentral() + gradlePluginPortal() + } + + dependencies { + classpath("com.android.tools.build", "gradle", "7.2.0") + classpath(kotlin("gradle-plugin", "1.6.21")) + } +} + +tasks.register("clean", Delete::class) { + delete(rootProject.buildDir) +} diff --git a/compose-reordering/.gitignore b/compose-reordering/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/compose-reordering/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/compose-reordering/build.gradle.kts b/compose-reordering/build.gradle.kts new file mode 100644 index 0000000..616e2e7 --- /dev/null +++ b/compose-reordering/build.gradle.kts @@ -0,0 +1,46 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "it.vfsfitvnm.reordering" + compileSdk = 32 + + defaultConfig { + minSdk = 21 + targetSdk = 32 + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.get() + } + + kotlinOptions { + jvmTarget = "1.8" + } +} + +dependencies { + implementation(libs.compose.foundation) +} \ No newline at end of file diff --git a/compose-reordering/src/main/AndroidManifest.xml b/compose-reordering/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10728cc --- /dev/null +++ b/compose-reordering/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt new file mode 100644 index 0000000..4e2e6f7 --- /dev/null +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/ReorderingState.kt @@ -0,0 +1,65 @@ +package it.vfsfitvnm.reordering + +import androidx.compose.animation.core.Animatable +import androidx.compose.animation.core.AnimationVector1D +import androidx.compose.animation.core.VectorConverter +import androidx.compose.animation.core.animateIntAsState +import androidx.compose.runtime.* + +class ReorderingState( + draggingIndexState: MutableState, + reachedIndexState: MutableState, + draggingItemSizeState: MutableState, + internal val offset: Animatable, + internal val lastIndex: Int, + internal val areEquals: (Int, Int) -> Boolean +) { + internal var draggingIndex by draggingIndexState + internal var reachedIndex by reachedIndexState + internal var draggingItemSize by draggingItemSizeState + + @Composable + internal fun translationFor(index: Int): State = when (draggingIndex) { + -1 -> derivedStateOf { 0 } + index -> offset.asState() + else -> animateIntAsState( + when (index) { + in (draggingIndex + 1)..reachedIndex -> -draggingItemSize + in reachedIndex until draggingIndex -> draggingItemSize + else -> 0 + } + ) + } +} + +@Composable +fun rememberReorderingState(items: List): ReorderingState { + val draggingIndexState = remember(items) { + mutableStateOf(-1) + } + + val reachedIndexState = remember(items) { + mutableStateOf(-1) + } + + val draggingItemHeightState = remember { + mutableStateOf(0) + } + + val offset = remember(items) { + Animatable(0, Int.VectorConverter) + } + + return remember(items) { + ReorderingState( + draggingIndexState = draggingIndexState, + reachedIndexState = reachedIndexState, + draggingItemSizeState = draggingItemHeightState, + offset = offset, + lastIndex = items.lastIndex, + areEquals = { i, j -> + items[i] == items[j] + } + ) + } +} diff --git a/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/dragToReorder.kt b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/dragToReorder.kt new file mode 100644 index 0000000..11f22e9 --- /dev/null +++ b/compose-reordering/src/main/kotlin/it/vfsfitvnm/reordering/dragToReorder.kt @@ -0,0 +1,208 @@ +package it.vfsfitvnm.reordering + +import androidx.compose.foundation.gestures.Orientation +import androidx.compose.foundation.gestures.detectDragGestures +import androidx.compose.foundation.gestures.detectDragGesturesAfterLongPress +import androidx.compose.foundation.layout.offset +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier +import androidx.compose.ui.composed +import androidx.compose.ui.geometry.Offset +import androidx.compose.ui.input.pointer.PointerInputChange +import androidx.compose.ui.input.pointer.PointerInputScope +import androidx.compose.ui.input.pointer.pointerInput +import androidx.compose.ui.unit.IntOffset +import androidx.compose.ui.zIndex +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlin.math.roundToInt +import kotlin.reflect.KSuspendFunction5 + +private fun Modifier.dragToReorder( + reorderingState: ReorderingState, + index: Int, + orientation: Orientation, + function: KSuspendFunction5 Unit, () -> Unit, () -> Unit, (change: PointerInputChange, dragAmount: Offset) -> Unit, Unit>, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = composed { + val coroutineScope = rememberCoroutineScope() + val translation by reorderingState.translationFor(index) + + pointerInput(reorderingState) { +// require(index in 0..reorderingState.lastIndex) + + var previousItemSize = 0 + var nextItemSize = 0 + + function( + this, + { + onDragStart?.invoke() + reorderingState.draggingIndex = index + reorderingState.reachedIndex = index + reorderingState.draggingItemSize = size.height + + nextItemSize = reorderingState.draggingItemSize + previousItemSize = -reorderingState.draggingItemSize + + reorderingState.offset.updateBounds( + lowerBound = -index * reorderingState.draggingItemSize, + upperBound = (reorderingState.lastIndex - index) * reorderingState.draggingItemSize + ) + }, + { + coroutineScope.launch { + reorderingState.offset.animateTo((previousItemSize + nextItemSize) / 2) + + withContext(Dispatchers.Main) { + onDragEnd?.invoke(reorderingState.reachedIndex) + } + + if (reorderingState.areEquals( + reorderingState.draggingIndex, + reorderingState.reachedIndex + ) + ) { + reorderingState.draggingIndex = -1 + reorderingState.reachedIndex = -1 + reorderingState.draggingItemSize = 0 + reorderingState.offset.snapTo(0) + } + } + }, + {}, + { _, offset -> + val delta = when (orientation) { + Orientation.Vertical -> offset.y + Orientation.Horizontal -> offset.x + }.roundToInt() + + val targetOffset = reorderingState.offset.value + delta + + if (targetOffset > nextItemSize) { + if (reorderingState.reachedIndex < reorderingState.lastIndex) { + reorderingState.reachedIndex += 1 + nextItemSize += reorderingState.draggingItemSize + previousItemSize += reorderingState.draggingItemSize + onMove?.invoke() + } + } else if (targetOffset < previousItemSize) { + if (reorderingState.reachedIndex > 0) { + reorderingState.reachedIndex -= 1 + previousItemSize -= reorderingState.draggingItemSize + nextItemSize -= reorderingState.draggingItemSize + onMove?.invoke() + } + } + + coroutineScope.launch { + reorderingState.offset.snapTo(targetOffset) + } + }, + ) + } + .offset { + when (orientation) { + Orientation.Vertical -> IntOffset(0, translation) + Orientation.Horizontal -> IntOffset(translation, 0) + } + } + .zIndex(if (reorderingState.draggingIndex == index) 1f else 0f) +} + +fun Modifier.dragToReorder( + reorderingState: ReorderingState, + index: Int, + orientation: Orientation, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = dragToReorder( + reorderingState = reorderingState, + index = index, + orientation = orientation, + function = PointerInputScope::detectDragGestures, + onDragStart = onDragStart, + onMove = onMove, + onDragEnd = onDragEnd, +) + +fun Modifier.verticalDragToReorder( + reorderingState: ReorderingState, + index: Int, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = dragToReorder( + reorderingState = reorderingState, + index = index, + orientation = Orientation.Vertical, + onDragStart = onDragStart, + onMove = onMove, + onDragEnd = onDragEnd, +) + +fun Modifier.horizontalDragToReorder( + reorderingState: ReorderingState, + index: Int, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = dragToReorder( + reorderingState = reorderingState, + index = index, + orientation = Orientation.Horizontal, + onDragStart = onDragStart, + onMove = onMove, + onDragEnd = onDragEnd, +) + +fun Modifier.dragAfterLongPressToReorder( + reorderingState: ReorderingState, + index: Int, + orientation: Orientation, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = dragToReorder( + reorderingState = reorderingState, + index = index, + orientation = orientation, + function = PointerInputScope::detectDragGesturesAfterLongPress, + onDragStart = onDragStart, + onMove = onMove, + onDragEnd = onDragEnd, +) + +fun Modifier.verticalDragAfterLongPressToReorder( + reorderingState: ReorderingState, + index: Int, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = dragAfterLongPressToReorder( + reorderingState = reorderingState, + index = index, + orientation = Orientation.Vertical, + onDragStart = onDragStart, + onMove = onMove, + onDragEnd = onDragEnd, +) + +fun Modifier.horizontalDragAfterLongPressToReorder( + reorderingState: ReorderingState, + index: Int, + onDragStart: (() -> Unit)? = null, + onMove: (() -> Unit)? = null, + onDragEnd: ((Int) -> Unit)? = null +): Modifier = dragAfterLongPressToReorder( + reorderingState = reorderingState, + index = index, + orientation = Orientation.Horizontal, + onDragStart = onDragStart, + onMove = onMove, + onDragEnd = onDragEnd, +) diff --git a/compose-routing/.gitignore b/compose-routing/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/compose-routing/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/compose-routing/build.gradle.kts b/compose-routing/build.gradle.kts new file mode 100644 index 0000000..89bfbb2 --- /dev/null +++ b/compose-routing/build.gradle.kts @@ -0,0 +1,48 @@ +plugins { + id("com.android.library") + kotlin("android") +} + +android { + namespace = "it.vfsfitvnm.route" + compileSdk = 32 + + defaultConfig { + minSdk = 21 + targetSdk = 32 + } + + buildTypes { + release { + isMinifyEnabled = true + proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt")) + } + } + + sourceSets.all { + kotlin.srcDir("src/$name/kotlin") + } + + buildFeatures { + compose = true + } + + compileOptions { + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 + } + + composeOptions { + kotlinCompilerExtensionVersion = libs.versions.compose.get() + } + + kotlinOptions { + freeCompilerArgs += "-Xcontext-receivers" + jvmTarget = "1.8" + } +} + +dependencies { + implementation(libs.compose.activity) + implementation(libs.compose.foundation) +} \ No newline at end of file diff --git a/compose-routing/src/main/AndroidManifest.xml b/compose-routing/src/main/AndroidManifest.xml new file mode 100644 index 0000000..10728cc --- /dev/null +++ b/compose-routing/src/main/AndroidManifest.xml @@ -0,0 +1,2 @@ + + \ No newline at end of file diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt new file mode 100644 index 0000000..df0fc47 --- /dev/null +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Route.kt @@ -0,0 +1,111 @@ +package it.vfsfitvnm.route + +import androidx.compose.runtime.* +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable + +@Stable +open class Route internal constructor(val tag: String) { + override fun equals(other: Any?): Boolean { + return when { + this === other -> true + other is Route -> tag == other.tag + else -> false + } + } + + override fun hashCode(): Int { + return tag.hashCode() + } + + object GlobalEmitter { + var listener: ((Route) -> Unit)? = null + } + + object Saver : androidx.compose.runtime.saveable.Saver { + override fun restore(value: String): Route? = value.takeIf(String::isNotEmpty)?.let(::Route) + override fun SaverScope.save(value: Route?): String = value?.tag ?: "" + } +} + +@Composable +fun rememberRoute(route: Route? = null): MutableState { + return rememberSaveable(stateSaver = Route.Saver) { + mutableStateOf(route) + } +} + +@Stable +class Route0( + tag: String +) : Route(tag) { + context(RouteHandlerScope) + @Composable + inline operator fun invoke(content: @Composable () -> Unit) { + if (this == route) { + content() + } + } + + fun global() { + GlobalEmitter.listener?.invoke(this) + } +} + +@Stable +class Route1( + tag: String, + state0: MutableState +) : Route(tag) { + var p0 by state0 + + context(RouteHandlerScope) + @Composable + inline operator fun invoke(content: @Composable (P0) -> Unit) { + if (this == route) { + if (route is Route1<*>) { + @Suppress("UNCHECKED_CAST") + (route as Route1).let { route -> + this.p0 = route.p0 + } + } + content(this.p0) + } + } + + fun global(p0: P0 = this.p0) { + this.p0 = p0 + GlobalEmitter.listener?.invoke(this) + } +} + +@Stable +class Route2( + tag: String, + state0: MutableState, + state1: MutableState +) : Route(tag) { + var p0 by state0 + var p1 by state1 + + context(RouteHandlerScope) + @Composable + inline operator fun invoke(content: @Composable (P0, P1) -> Unit) { + if (this == route) { + if (route is Route2<*, *>) { + @Suppress("UNCHECKED_CAST") + (route as Route2).let { route -> + this.p0 = route.p0 + this.p1 = route.p1 + } + } + content(this.p0, this.p1) + } + } + + fun global(p0: P0 = this.p0, p1: P1 = this.p1) { + this.p0 = p0 + this.p1 = p1 + GlobalEmitter.listener?.invoke(this) + } +} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt new file mode 100644 index 0000000..2ff315d --- /dev/null +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandler.kt @@ -0,0 +1,73 @@ +package it.vfsfitvnm.route + +import androidx.activity.compose.BackHandler +import androidx.activity.compose.LocalOnBackPressedDispatcherOwner +import androidx.compose.animation.AnimatedContent +import androidx.compose.animation.AnimatedContentScope +import androidx.compose.animation.ContentTransform +import androidx.compose.animation.ExperimentalAnimationApi +import androidx.compose.animation.core.updateTransition +import androidx.compose.runtime.* +import androidx.compose.ui.Modifier + +@ExperimentalAnimationApi +@Composable +fun RouteHandler( + modifier: Modifier = Modifier, + listenToGlobalEmitter: Boolean = false, + handleBackPress: Boolean = true, + transitionSpec: AnimatedContentScope.() -> ContentTransform = { fastFade }, + content: @Composable RouteHandlerScope.() -> Unit +) { + var route by rememberRoute() + + RouteHandler( + route = route, + onRouteChanged = { route = it }, + listenToGlobalEmitter = listenToGlobalEmitter, + handleBackPress = handleBackPress, + transitionSpec = transitionSpec, + modifier = modifier, + content = content + ) +} + +@ExperimentalAnimationApi +@Composable +fun RouteHandler( + route: Route?, + onRouteChanged: (Route?) -> Unit, + modifier: Modifier = Modifier, + listenToGlobalEmitter: Boolean = false, + handleBackPress: Boolean = true, + transitionSpec: AnimatedContentScope.() -> ContentTransform = { fastFade }, + content: @Composable RouteHandlerScope.() -> Unit +) { + val backDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher + + val scope = remember(route) { + RouteHandlerScope( + route = route, + push = onRouteChanged, + pop = { if (handleBackPress) backDispatcher?.onBackPressed() else onRouteChanged(null) } + ) + } + + if (listenToGlobalEmitter) { + LaunchedEffect(route) { + Route.GlobalEmitter.listener = if (route == null) onRouteChanged else null + } + } + + BackHandler(enabled = handleBackPress && route != null) { + onRouteChanged(null) + } + + updateTransition(targetState = scope, label = null).AnimatedContent( + transitionSpec = transitionSpec, + contentKey = { it.route?.tag }, + modifier = modifier, + ) { + it.content() + } +} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandlerScope.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandlerScope.kt new file mode 100644 index 0000000..549bc9b --- /dev/null +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/RouteHandlerScope.kt @@ -0,0 +1,40 @@ +package it.vfsfitvnm.route + +import android.annotation.SuppressLint +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Immutable + +@Immutable +class RouteHandlerScope( + val route: Route?, + private val push: (Route?) -> Unit, + val pop: () -> Unit, +) { + @SuppressLint("ComposableNaming") + @Composable + inline fun host(content: @Composable () -> Unit) { + if (route == null) { + content() + } + } + + operator fun Route0.invoke() { + push(this) + } + + operator fun Route1.invoke( + p0: P0 = this.p0 + ) { + this.p0 = p0 + push(this) + } + + operator fun Route2.invoke( + p0: P0 = this.p0, + p1: P1 = this.p1 + ) { + this.p0 = p0 + this.p1 = p1 + push(this) + } +} diff --git a/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt new file mode 100644 index 0000000..36d128a --- /dev/null +++ b/compose-routing/src/main/kotlin/it/vfsfitvnm/route/Transitions.kt @@ -0,0 +1,13 @@ +package it.vfsfitvnm.route + +import androidx.compose.animation.* +import androidx.compose.animation.core.tween + + +@ExperimentalAnimationApi +val AnimatedContentScope.fastFade: ContentTransform + get() = fadeIn(tween(200)) with fadeOut(tween(200)) + +@ExperimentalAnimationApi +val AnimatedContentScope.empty: ContentTransform + get() = EnterTransition.None with ExitTransition.None diff --git a/gradle.properties b/gradle.properties new file mode 100644 index 0000000..a0153bd --- /dev/null +++ b/gradle.properties @@ -0,0 +1,5 @@ +org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8 +android.useAndroidX=true +android.enableJetifier=false +kotlin.code.style=official +android.enableR8.fullMode=true diff --git a/gradle/wrapper/gradle-wrapper.jar b/gradle/wrapper/gradle-wrapper.jar new file mode 100644 index 0000000..e708b1c Binary files /dev/null and b/gradle/wrapper/gradle-wrapper.jar differ diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties new file mode 100644 index 0000000..55b4b2c --- /dev/null +++ b/gradle/wrapper/gradle-wrapper.properties @@ -0,0 +1,6 @@ +#Wed Apr 20 21:22:44 CEST 2022 +distributionBase=GRADLE_USER_HOME +distributionUrl=https\://services.gradle.org/distributions/gradle-7.3.3-bin.zip +distributionPath=wrapper/dists +zipStorePath=wrapper/dists +zipStoreBase=GRADLE_USER_HOME diff --git a/gradlew b/gradlew new file mode 100755 index 0000000..4f906e0 --- /dev/null +++ b/gradlew @@ -0,0 +1,185 @@ +#!/usr/bin/env sh + +# +# Copyright 2015 the original author or authors. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# https://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. +# + +############################################################################## +## +## Gradle start up script for UN*X +## +############################################################################## + +# Attempt to set APP_HOME +# Resolve links: $0 may be a link +PRG="$0" +# Need this for relative symlinks. +while [ -h "$PRG" ] ; do + ls=`ls -ld "$PRG"` + link=`expr "$ls" : '.*-> \(.*\)$'` + if expr "$link" : '/.*' > /dev/null; then + PRG="$link" + else + PRG=`dirname "$PRG"`"/$link" + fi +done +SAVED="`pwd`" +cd "`dirname \"$PRG\"`/" >/dev/null +APP_HOME="`pwd -P`" +cd "$SAVED" >/dev/null + +APP_NAME="Gradle" +APP_BASE_NAME=`basename "$0"` + +# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"' + +# Use the maximum available, or set MAX_FD != -1 to use that value. +MAX_FD="maximum" + +warn () { + echo "$*" +} + +die () { + echo + echo "$*" + echo + exit 1 +} + +# OS specific support (must be 'true' or 'false'). +cygwin=false +msys=false +darwin=false +nonstop=false +case "`uname`" in + CYGWIN* ) + cygwin=true + ;; + Darwin* ) + darwin=true + ;; + MINGW* ) + msys=true + ;; + NONSTOP* ) + nonstop=true + ;; +esac + +CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar + + +# Determine the Java command to use to start the JVM. +if [ -n "$JAVA_HOME" ] ; then + if [ -x "$JAVA_HOME/jre/sh/java" ] ; then + # IBM's JDK on AIX uses strange locations for the executables + JAVACMD="$JAVA_HOME/jre/sh/java" + else + JAVACMD="$JAVA_HOME/bin/java" + fi + if [ ! -x "$JAVACMD" ] ; then + die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." + fi +else + JAVACMD="java" + which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. + +Please set the JAVA_HOME variable in your environment to match the +location of your Java installation." +fi + +# Increase the maximum file descriptors if we can. +if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then + MAX_FD_LIMIT=`ulimit -H -n` + if [ $? -eq 0 ] ; then + if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then + MAX_FD="$MAX_FD_LIMIT" + fi + ulimit -n $MAX_FD + if [ $? -ne 0 ] ; then + warn "Could not set maximum file descriptor limit: $MAX_FD" + fi + else + warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" + fi +fi + +# For Darwin, add options to specify how the application appears in the dock +if $darwin; then + GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" +fi + +# For Cygwin or MSYS, switch paths to Windows format before running java +if [ "$cygwin" = "true" -o "$msys" = "true" ] ; then + APP_HOME=`cygpath --path --mixed "$APP_HOME"` + CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` + + JAVACMD=`cygpath --unix "$JAVACMD"` + + # We build the pattern for arguments to be converted via cygpath + ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` + SEP="" + for dir in $ROOTDIRSRAW ; do + ROOTDIRS="$ROOTDIRS$SEP$dir" + SEP="|" + done + OURCYGPATTERN="(^($ROOTDIRS))" + # Add a user-defined pattern to the cygpath arguments + if [ "$GRADLE_CYGPATTERN" != "" ] ; then + OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" + fi + # Now convert the arguments - kludge to limit ourselves to /bin/sh + i=0 + for arg in "$@" ; do + CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` + CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option + + if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition + eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` + else + eval `echo args$i`="\"$arg\"" + fi + i=`expr $i + 1` + done + case $i in + 0) set -- ;; + 1) set -- "$args0" ;; + 2) set -- "$args0" "$args1" ;; + 3) set -- "$args0" "$args1" "$args2" ;; + 4) set -- "$args0" "$args1" "$args2" "$args3" ;; + 5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; + 6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; + 7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; + 8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; + 9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; + esac +fi + +# Escape application args +save () { + for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done + echo " " +} +APP_ARGS=`save "$@"` + +# Collect all arguments for the java command, following the shell quoting and substitution rules +eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" + +exec "$JAVACMD" "$@" diff --git a/gradlew.bat b/gradlew.bat new file mode 100644 index 0000000..107acd3 --- /dev/null +++ b/gradlew.bat @@ -0,0 +1,89 @@ +@rem +@rem Copyright 2015 the original author or authors. +@rem +@rem Licensed under the Apache License, Version 2.0 (the "License"); +@rem you may not use this file except in compliance with the License. +@rem You may obtain a copy of the License at +@rem +@rem https://www.apache.org/licenses/LICENSE-2.0 +@rem +@rem Unless required by applicable law or agreed to in writing, software +@rem distributed under the License is distributed on an "AS IS" BASIS, +@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +@rem See the License for the specific language governing permissions and +@rem limitations under the License. +@rem + +@if "%DEBUG%" == "" @echo off +@rem ########################################################################## +@rem +@rem Gradle startup script for Windows +@rem +@rem ########################################################################## + +@rem Set local scope for the variables with windows NT shell +if "%OS%"=="Windows_NT" setlocal + +set DIRNAME=%~dp0 +if "%DIRNAME%" == "" set DIRNAME=. +set APP_BASE_NAME=%~n0 +set APP_HOME=%DIRNAME% + +@rem Resolve any "." and ".." in APP_HOME to make it shorter. +for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi + +@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. +set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m" + +@rem Find java.exe +if defined JAVA_HOME goto findJavaFromJavaHome + +set JAVA_EXE=java.exe +%JAVA_EXE% -version >NUL 2>&1 +if "%ERRORLEVEL%" == "0" goto execute + +echo. +echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:findJavaFromJavaHome +set JAVA_HOME=%JAVA_HOME:"=% +set JAVA_EXE=%JAVA_HOME%/bin/java.exe + +if exist "%JAVA_EXE%" goto execute + +echo. +echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% +echo. +echo Please set the JAVA_HOME variable in your environment to match the +echo location of your Java installation. + +goto fail + +:execute +@rem Setup the command line + +set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar + + +@rem Execute Gradle +"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %* + +:end +@rem End local scope for the variables with windows NT shell +if "%ERRORLEVEL%"=="0" goto mainEnd + +:fail +rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of +rem the _cmd.exe /c_ return code! +if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 +exit /b 1 + +:mainEnd +if "%OS%"=="Windows_NT" endlocal + +:omega diff --git a/ktor-client-brotli/.gitignore b/ktor-client-brotli/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/ktor-client-brotli/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/ktor-client-brotli/build.gradle.kts b/ktor-client-brotli/build.gradle.kts new file mode 100644 index 0000000..564d1d3 --- /dev/null +++ b/ktor-client-brotli/build.gradle.kts @@ -0,0 +1,12 @@ +plugins { + kotlin("jvm") +} + +sourceSets.all { + java.srcDir("src/$name/kotlin") +} + +dependencies { + implementation(libs.ktor.client.encoding) + implementation(libs.brotli) +} \ No newline at end of file diff --git a/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt new file mode 100644 index 0000000..87df655 --- /dev/null +++ b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/BrotliEncoder.kt @@ -0,0 +1,16 @@ +package io.ktor.client.plugins.compression + +import io.ktor.utils.io.* +import io.ktor.utils.io.jvm.javaio.* +import kotlinx.coroutines.CoroutineScope +import org.brotli.dec.BrotliInputStream + +internal object BrotliEncoder : ContentEncoder { + override val name: String = "br" + + override fun CoroutineScope.encode(source: ByteReadChannel) = + error("BrotliOutputStream not available (https://github.com/google/brotli/issues/715)") + + override fun CoroutineScope.decode(source: ByteReadChannel): ByteReadChannel = + BrotliInputStream(source.toInputStream()).toByteReadChannel() +} diff --git a/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt new file mode 100644 index 0000000..4344a5a --- /dev/null +++ b/ktor-client-brotli/src/main/kotlin/io/ktor/client/plugins/compression/brotli.kt @@ -0,0 +1,5 @@ +package io.ktor.client.plugins.compression + +fun ContentEncoding.Config.brotli(quality: Float? = null) { + customEncoder(BrotliEncoder, quality) +} \ No newline at end of file diff --git a/settings.gradle.kts b/settings.gradle.kts new file mode 100644 index 0000000..a7128a7 --- /dev/null +++ b/settings.gradle.kts @@ -0,0 +1,69 @@ +enableFeaturePreview("VERSION_CATALOGS") +enableFeaturePreview("TYPESAFE_PROJECT_ACCESSORS") + +dependencyResolutionManagement { + repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS) + + repositories { + google() + mavenCentral() + } + + versionCatalogs { + create("libs") { + version("kotlin", "1.6.21") + alias("kotlin-serialization").toPluginId("org.jetbrains.kotlin.plugin.serialization").versionRef("kotlin") + + alias("android-media").to("androidx.media", "media").version("1.6.0") + + version("compose", "1.2.0-beta03") + alias("compose-foundation").to("androidx.compose.foundation", "foundation").versionRef("compose") + alias("compose-ui").to("androidx.compose.ui", "ui").versionRef("compose") + alias("compose-ui-util").to("androidx.compose.ui", "ui-util").versionRef("compose") + alias("compose-ripple").to("androidx.compose.material", "material-ripple").versionRef("compose") + + alias("compose-shimmer").to("com.valentinilk.shimmer", "compose-shimmer").version("1.0.2") + + alias("compose-activity").to("androidx.activity", "activity-compose").version("1.5.0-alpha03") + + alias("compose-coil").to("io.coil-kt", "coil-compose").version("2.0.0-rc03") + + version("accompanist", "0.24.9-beta") + alias("accompanist-systemuicontroller").to("com.google.accompanist", "accompanist-systemuicontroller").versionRef("accompanist") + alias("accompanist-flowlayout").to("com.google.accompanist", "accompanist-flowlayout").versionRef("accompanist") + + version("room", "2.5.0-alpha01") + alias("room").to("androidx.room", "room-ktx").versionRef("room") + alias("room-compiler").to("androidx.room", "room-compiler").versionRef("room") + + version("media3", "1.0.0-alpha03") + alias("media3-ui").to("androidx.media3", "media3-ui").versionRef("media3") + alias("media3-session").to("androidx.media3", "media3-session").versionRef("media3") + alias("media3-exoplayer").to("androidx.media3", "media3-exoplayer").versionRef("media3") + bundle("media3", listOf("media3-ui", "media3-session", "media3-exoplayer")) + + version("ktor", "2.0.1") + alias("ktor-client-core").to("io.ktor", "ktor-client-core").versionRef("ktor") + alias("ktor-client-cio").to("io.ktor", "ktor-client-cio").versionRef("ktor") + alias("ktor-client-content-negotiation").to("io.ktor", "ktor-client-content-negotiation").versionRef("ktor") + alias("ktor-client-encoding").to("io.ktor", "ktor-client-encoding").versionRef("ktor") + alias("ktor-client-serialization").to("io.ktor", "ktor-client-serialization").versionRef("ktor") + alias("ktor-serialization-json").to("io.ktor", "ktor-serialization-kotlinx-json").versionRef("ktor") + + alias("brotli").to("org.brotli", "dec").version("0.1.2") + + alias("guava-coroutines").to("org.jetbrains.kotlinx", "kotlinx-coroutines-guava").version("1.6.0") + } + + create("testLibs") { + alias("junit").to("junit", "junit").version("4.13.2") + } + } +} + +rootProject.name = "ViMusic" +include(":app") +include(":compose-routing") +include(":compose-reordering") +include(":youtube-music") +include(":ktor-client-brotli") diff --git a/youtube-music/.gitignore b/youtube-music/.gitignore new file mode 100644 index 0000000..42afabf --- /dev/null +++ b/youtube-music/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/youtube-music/build.gradle.kts b/youtube-music/build.gradle.kts new file mode 100644 index 0000000..3450816 --- /dev/null +++ b/youtube-music/build.gradle.kts @@ -0,0 +1,21 @@ +plugins { + kotlin("jvm") + kotlin("plugin.serialization") version "1.6.21" +} + +sourceSets.all { + java.srcDir("src/$name/kotlin") +} + +dependencies { + implementation(projects.ktorClientBrotli) + + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.encoding) + implementation(libs.ktor.client.serialization) + implementation(libs.ktor.serialization.json) + + testImplementation(testLibs.junit) +} \ No newline at end of file diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Extensions.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Extensions.kt new file mode 100644 index 0000000..e1e8753 --- /dev/null +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Extensions.kt @@ -0,0 +1,42 @@ +package it.vfsfitvnm.youtubemusic + +import io.ktor.client.* +import io.ktor.client.call.* +import io.ktor.client.request.* +import io.ktor.client.statement.* +import io.ktor.util.network.* +import io.ktor.utils.io.* + + +suspend inline fun Outcome.bodyCatching(): Outcome { + return when (this) { + is Outcome.Success -> value.bodyCatching() + is Outcome.Recovered -> value.bodyCatching() + is Outcome.Initial -> this + is Outcome.Loading -> this + is Outcome.Error -> this + } +} + +suspend inline fun HttpClient.postCatching( + urlString: String, + block: HttpRequestBuilder.() -> Unit = {} +): Outcome { + return runCatching { + Outcome.Success(post(urlString, block)) + }.getOrElse { throwable -> + when (throwable) { + is CancellationException -> Outcome.Loading + is UnresolvedAddressException -> Outcome.Error.Network + else -> Outcome.Error.Unhandled(throwable) + } + } +} + +suspend inline fun HttpResponse.bodyCatching(): Outcome { + return runCatching { + Outcome.Success(body()) + }.getOrElse { throwable -> + Outcome.Error.Unhandled(throwable) + } +} \ No newline at end of file diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Outcome.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Outcome.kt new file mode 100644 index 0000000..735beca --- /dev/null +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/Outcome.kt @@ -0,0 +1,68 @@ +package it.vfsfitvnm.youtubemusic + + +sealed class Outcome { + val valueOrNull: T? + get() = when (this) { + is Success -> value + is Recovered -> value + else -> null + } + + fun recoverWith(value: @UnsafeVariance T): Outcome { + return when (this) { + is Error -> Recovered(value, this) + else -> this + } + } + + inline fun map(block: (T) -> R): Outcome { + return when (this) { + is Success -> Success(block(value)) + is Recovered -> Success(block(value)) + is Initial -> this + is Loading -> this + is Error -> this + } + } + + inline fun flatMap(block: (T) -> Outcome): Outcome { + return when (this) { + is Success -> block(value) + is Recovered -> block(value) + is Initial -> this + is Loading -> this + is Error -> this + } + } + + object Initial : Outcome() + + object Loading : Outcome() + + sealed class Error : Outcome() { + object Network : Error() + class Unhandled(val throwable: Throwable) : Error() + } + + class Recovered(val value: T, val error: Error) : Outcome() + + class Success(val value: T) : Outcome() +} + +fun Outcome?.toNotNull(): Outcome { + return when (this) { + null -> Outcome.Success(null) + else -> this + } +} + +fun Outcome.toNullable(): Outcome? { + return valueOrNull?.let { + Outcome.Success(it) + } +} + +val Outcome<*>.isEvaluable: Boolean + get() = this !is Outcome.Success && this !is Outcome.Loading + diff --git a/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt new file mode 100644 index 0000000..ebf57df --- /dev/null +++ b/youtube-music/src/main/kotlin/it/vfsfitvnm/youtubemusic/YouTube.kt @@ -0,0 +1,659 @@ +package it.vfsfitvnm.youtubemusic + +import io.ktor.client.* +import io.ktor.client.engine.cio.* +import io.ktor.client.plugins.* +import io.ktor.client.plugins.compression.* +import io.ktor.client.plugins.contentnegotiation.* +import io.ktor.client.request.* +import io.ktor.http.* +import io.ktor.serialization.kotlinx.json.* +import it.vfsfitvnm.youtubemusic.models.* +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json + + +object YouTube { + private const val Key = "AIzaSyAO_FJ2SlqU8Q4STEHLGCilw_Y9_11qcW8" + + @OptIn(ExperimentalSerializationApi::class) + val client = HttpClient(CIO) { + BrowserUserAgent() + + expectSuccess = true + + install(ContentNegotiation) { + json(Json { + ignoreUnknownKeys = true + explicitNulls = false + encodeDefaults = true + }) + } + + install(ContentEncoding) { + brotli() + } + + defaultRequest { + url("https://music.youtube.com") + } + } + + @Serializable + data class BrowseBody( + val context: Context, + val browseId: String, + ) + + @Serializable + data class SearchBody( + val context: Context, + val query: String, + val params: String + ) + + @Serializable + data class PlayerBody( + val context: Context, + val videoId: String, + val playlistId: String? + ) + + @Serializable + data class GetQueueBody( + val context: Context, + val videoIds: List + ) + + @Serializable + data class NextBody( + val context: Context, + val isAudioOnly: Boolean, + val videoId: String, + val playlistId: String?, + val tunerSettingValue: String, + val index: Int?, + val params: String?, + val playlistSetVideoId: String?, + val continuation: String?, + val watchEndpointMusicSupportedConfigs: WatchEndpointMusicSupportedConfigs + ) { + @Serializable + data class WatchEndpointMusicSupportedConfigs( + val musicVideoType: String + ) + } + + @Serializable + data class GetSearchSuggestionsBody( + val context: Context, + val input: String + ) + + @Serializable + data class Context( + val client: Client, + val thirdParty: ThirdParty? = null, + ) { + @Serializable + data class Client( + val clientName: String, + val clientVersion: String, + val visitorData: String?, +// val gl: String = "US", + val hl: String = "en", + ) + + @Serializable + data class ThirdParty( + val embedUrl: String, + ) + + companion object { + val DefaultWeb = Context( + client = Client( + clientName = "WEB_REMIX", + clientVersion = "1.20220328.01.00", + visitorData = "CgtsZG1ySnZiQWtSbyiMjuGSBg%3D%3D" + ) + ) + val DefaultAndroid = Context( + client = Client( + clientName = "ANDROID", + clientVersion = "16.50", + visitorData = null, + ) + ) + } + } + + data class Info( + val name: String, + val endpoint: T? + ) { + companion object { + inline fun from(run: Runs.Run): Info { + return Info( + name = run.text, + endpoint = run.navigationEndpoint?.endpoint as T? + ) + } + } + } + + sealed class Item { + abstract val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail + + data class Song( + val info: Info, + val authors: List>, + val album: Info?, + val durationText: String, + override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail + ) : Item() { + companion object : FromMusicShelfRendererContent { + val Filter = Filter("EgWKAQIIAWoKEAkQBRAKEAMQBA%3D%3D") + + override fun from(content: MusicShelfRenderer.Content): Song { + val (mainRuns, otherRuns) = content.runs + + return Song( + info = Info.from(mainRuns.first()), + authors = otherRuns.getOrNull(otherRuns.lastIndex - 2) + ?.map(Info.Companion::from) ?: emptyList(), + album = otherRuns.getOrNull(otherRuns.lastIndex - 1)?.firstOrNull()?.let( + Info.Companion::from + ), + durationText = otherRuns.getOrNull(otherRuns.lastIndex)?.first()?.text + ?: "?", + thumbnail = content.thumbnail + ) + } + } + } + + + data class Video( + val info: Info, + val authors: List>, + val views: List>, + val durationText: String, + override val thumbnail: ThumbnailRenderer.MusicThumbnailRenderer.Thumbnail.Thumbnail + ) : Item() { + val isOfficialMusicVideo: Boolean + get() = info.endpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType == "MUSIC_VIDEO_TYPE_OMV" + + val isUserGeneratedContent: Boolean + get() = info.endpoint?.watchEndpointMusicSupportedConfigs?.watchEndpointMusicConfig?.musicVideoType == "MUSIC_VIDEO_TYPE_UGC" + + companion object : FromMusicShelfRendererContent