Initial commit

This commit is contained in:
vfsfitvnm 2022-06-02 18:59:18 +02:00
commit 1e673ad582
160 changed files with 10800 additions and 0 deletions

10
.gitignore vendored Normal file
View file

@ -0,0 +1,10 @@
*.iml
.gradle
/local.properties
.idea
.DS_Store
/build
/captures
.externalNativeBuild
.cxx
local.properties

202
LICENSE.md Normal file
View file

@ -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.

55
README.md Normal file
View file

@ -0,0 +1,55 @@
# ViMusic
<p align="center">
<img src="./app/src/main/ic_launcher-playstore.png" width="100" style="display: block; margin: 0 auto"/>
</p>
<h3 align="center">A Jetpack Compose Android application for streaming music from YouTube Music</h3>
---
<p float="center">
<img src="https://user-images.githubusercontent.com/46219656/171632003-33a017d7-cdc8-4588-a1fc-45be294969a8.png" width="200" />
<img src="https://user-images.githubusercontent.com/46219656/171632005-f51cce3f-20d4-44e6-83dd-c1d27e9c63e0.png" width="200" />
<img src="https://user-images.githubusercontent.com/46219656/171632000-feadb479-41cf-43ba-8c8a-11aed303ad69.png" width="200" />
<img src="https://user-images.githubusercontent.com/46219656/171632008-e66392c7-9462-4383-b7cf-8ec5f5701f9c.png" width="200" />
</p>
## 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.

2
app/.gitignore vendored Normal file
View file

@ -0,0 +1,2 @@
/build
/release

97
app/build.gradle.kts Normal file
View file

@ -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)
}

24
app/proguard-rules.pro vendored Normal file
View file

@ -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

View file

@ -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')"
]
}
}

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
package="it.vfsfitvnm.vimusic">
<uses-permission android:name="android.permission.INTERNET"/>
<uses-permission android:name="android.permission.WAKE_LOCK"/>
<uses-permission android:name="android.permission.FOREGROUND_SERVICE"/>
<application
android:allowBackup="true"
android:configChanges="colorMode|density|fontScale|keyboard|keyboardHidden|layoutDirection|locale|mcc|mnc|navigation|orientation|screenLayout|screenSize|smallestScreenSize|touchscreen|uiMode"
android:hardwareAccelerated="true"
android:icon="@mipmap/ic_launcher_round"
android:label="${appName}"
android:name=".MainApplication"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:requestLegacyExternalStorage="true"
android:theme="@style/Theme.ViMusic.NoActionBar">
<activity
android:name=".MainActivity"
android:exported="true"
android:theme="@style/Theme.ViMusic.NoActionBar"
android:launchMode="singleTop"
android:windowSoftInputMode="adjustResize">
<intent-filter>
<action android:name="android.intent.action.MAIN"/>
<category android:name="android.intent.category.LAUNCHER"/>
</intent-filter>
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:scheme="https"
android:host="music.youtube.com"
android:pathPrefix="/watch" />
<data android:scheme="https"
android:host="www.youtube.com"
android:pathPrefix="/watch" />
</intent-filter>
</activity>
<service android:name=".services.PlayerService" android:exported="false">
<intent-filter>
<action android:name="androidx.media3.session.MediaSessionService"/>
<action android:name="android.media.browse.MediaBrowserService"/>
</intent-filter>
</service>
</application>
</manifest>

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View file

@ -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<List<SearchQuery>>
@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<Info>): List<Long>
@Query("SELECT * FROM Song WHERE id = :id")
fun songFlow(id: String): Flow<Song?>
@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<List<Song>>
@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<List<SongWithInfo>>
@Transaction
@Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC")
fun favorites(): Flow<List<SongWithInfo>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs >= 60000 ORDER BY totalPlayTimeMs DESC LIMIT 20")
fun mostPlayed(): Flow<List<SongWithInfo>>
@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<PlaylistWithSongs?>
@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<List<PlaylistPreview>>
@Query("SELECT thumbnailUrl FROM Song JOIN SongInPlaylist ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4")
fun playlistThumbnailUrls(id: Long): Flow<List<String?>>
}
@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

View file

@ -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<MediaController>
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()
}
}

View file

@ -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()
}
}

View file

@ -0,0 +1,7 @@
package it.vfsfitvnm.vimusic.enums
enum class SongCollection {
MostPlayed,
Favorites,
History
}

View file

@ -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
)

View file

@ -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 = ""
)
}
}

View file

@ -0,0 +1,8 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.Embedded
data class PlaylistPreview(
@Embedded val playlist: Playlist,
val songCount: Int
)

View file

@ -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<SongWithInfo>
) {
companion object {
val Empty = PlaylistWithSongs(Playlist(-1, ""), emptyList())
val NotFound = PlaylistWithSongs(Playlist(-2, "Not found"), emptyList())
}
}

View file

@ -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
)

View file

@ -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
)
}
}

View file

@ -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
)

View file

@ -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
)

View file

@ -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<Info>?
)

View file

@ -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
)

View file

@ -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<Pair<String, Uri>?>(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
)
)
}
}

View file

@ -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<Dp>,
isRunningState: State<Boolean>,
isCollapsedState: State<Boolean>,
isExpandedState: State<Boolean>,
progressState: State<Float>,
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!!)
}
}
)
}
}

View file

@ -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)
)
}
}
}

View file

@ -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 <T>ChipGroup(
items: List<ChipItem<T>>,
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<T>(
val text: String,
val value: T
)

View file

@ -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 }
)
)
}
}

View file

@ -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<MenuState> { TODO() }
class MenuState(isDisplayedState: MutableState<Boolean>) {
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()
}
}

View file

@ -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)
)
}
}
}

View file

@ -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 <T> OutcomeItem(
outcome: Outcome<T>,
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,
)
}
}

View file

@ -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
)
}

View file

@ -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
)
}
}

View file

@ -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()
}
)
}
}
}
}
}
}

View file

@ -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
)
}

View file

@ -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)
)
}

View file

@ -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<YouTube.Album>>(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)
)
}
}
}
}
}

View file

@ -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<YouTube.Artist>>(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)
)
}
}
}

View file

@ -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)
)
}
}

View file

@ -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<Outcome<MediaItem>>(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)
}
)
}
}
}
}
}

View file

@ -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
)
)
}
}
)
)
}
}
}
}
}

View file

@ -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<String?>>(Outcome.Initial)
}
val items = remember(preferences.searchFilter) {
mutableStateListOf<YouTube.Item>()
}
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)
)
}
}

View file

@ -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<Outcome<List<String>?>>(
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)
)
}
}
}
}
}
}
}
}

View file

@ -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<String?> {
val videoId = rememberSaveable {
mutableStateOf(intentVideoId)
}
return remember {
Route1("rememberIntentVideoRoute", videoId)
}
}
@Composable
fun rememberAlbumRoute(): Route1<String?> {
val browseId = rememberSaveable {
mutableStateOf<String?>(null)
}
return remember {
Route1("AlbumRoute", browseId)
}
}
@Composable
fun rememberArtistRoute(): Route1<String?> {
val browseId = rememberSaveable {
mutableStateOf<String?>(null)
}
return remember {
Route1("ArtistRoute", browseId)
}
}
@Composable
fun rememberLocalPlaylistRoute(): Route1<Long?> {
val playlistType = rememberSaveable {
mutableStateOf<Long?>(null)
}
return remember {
Route1("LocalPlaylistRoute", playlistType)
}
}
@Composable
fun rememberSearchRoute(): Route1<String> {
val initialTextInput = remember {
mutableStateOf("")
}
return remember {
Route1("SearchRoute", initialTextInput)
}
}
@Composable
fun rememberCreatePlaylistRoute(): Route0 {
return remember {
Route0("CreatePlaylistRoute")
}
}
@Composable
fun rememberSearchResultRoute(): Route1<String> {
val searchQuery = rememberSaveable {
mutableStateOf("")
}
return remember {
Route1("SearchResultRoute", searchQuery)
}
}
@Composable
fun rememberLyricsRoute(): Route0 {
return remember {
Route0("LyricsRoute")
}
}

View file

@ -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
}
}

View file

@ -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<Typography> { 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),
)
}
}
}

View file

@ -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 -> {}
}
}
}
}

View file

@ -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<YouTube.NextResult>>(Outcome.Initial)
}
var lyricsOutcome by remember(player.mediaItem!!.mediaId) {
mutableStateOf<Outcome<String?>>(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)
)
}
}
}

View file

@ -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<Long?>(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)
)
}
}

View file

@ -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)
)
}
}

View file

@ -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)
)
}
}

View file

@ -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<MediaItem>, mediaItemIndex: Int) {
if (mediaItems.isEmpty()) return
setMediaItems(mediaItems, true)
playWhenReady = true
seekToDefaultPosition(mediaItemIndex)
prepare()
}
fun Player.forcePlayFromBeginning(mediaItems: List<MediaItem>) =
forcePlayAtIndex(mediaItems, 0)
val Player.lastMediaItem: MediaItem?
get() = mediaItemCount.takeIf { it > 0 }?.let { it - 1 }?.let(::getMediaItemAt)
val Timeline.mediaItems: List<MediaItem>
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<MediaItem>) {
addMediaItems(mediaItemCount, mediaItems)
}

View file

@ -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
}
}

View file

@ -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<Preferences> { 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<String>) =
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 <reified T : Enum<T>> SharedPreferences.preference(
key: String,
defaultValue: T
) =
mutableStateOf(value = enumValueOf<T>(getString(key, defaultValue.name)!!)) {
edit {
putString(key, it.name)
}
}
private fun <T> mutableStateOf(value: T, onStructuralInequality: (newValue: T) -> Unit) =
mutableStateOf(
value = value,
policy = object : SnapshotMutationPolicy<T> {
override fun equivalent(a: T, b: T): Boolean {
val areEquals = a == b
if (!areEquals) onStructuralInequality(b)
return areEquals
}
})

View file

@ -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
}

View file

@ -0,0 +1,11 @@
package it.vfsfitvnm.vimusic.utils
class RingBuffer<T>(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)
}

View file

@ -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)

View file

@ -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<String?>>(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<YoutubePlayer?> { null }
@Composable
fun rememberYoutubePlayer(
mediaControllerFuture: ListenableFuture<MediaController>,
repeatMode: Int
): YoutubePlayer? {
val mediaController by produceState<MediaController?>(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
}

View file

@ -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
)
}
}
}
}

View file

@ -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<Song> {
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()
}

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M256,112L256,400"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M400,256L112,256"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,48C141.31,48 48,141.31 48,256s93.31,208 208,208 208,-93.31 208,-208S370.69,48 256,48zM256,367.91a20,20 0,1 1,20 -20,20 20,0 0,1 -20,20zM277.72,166.76l-5.74,122a16,16 0,0 1,-32 0l-5.74,-121.94v-0.05a21.74,21.74 0,1 1,43.44 0z"/>
</vector>

View file

@ -0,0 +1,7 @@
<vector android:height="24dp" android:viewportHeight="126.97"
android:viewportWidth="122.98" android:width="23.245806dp" xmlns:android="http://schemas.android.com/apk/res/android">
<path android:fillColor="#FF000000"
android:pathData="m75.89,0.01c-3.13,0.14 -7,2.17 -7,4.64l1.16,72.06c-9.91,-10.11 -23.45,-11.88 -35.27,-4.1 -11.82,7.78 -16.48,22.84 -11.12,35.94 5.36,13.1 19.23,20.58 33.12,17.84 13.88,-2.74 23.89,-14.92 23.88,-29.07L80.65,36.94c11.72,6.28 24.81,9.57 38.1,9.57 2.34,-0 4.23,-1.9 4.23,-4.23L122.98,16.87c0,-2.34 -1.9,-4.23 -4.23,-4.23 -13.08,0 -25.92,-3.56 -37.14,-10.29L78.6,0.55C77.89,0.12 76.93,-0.04 75.89,0.01ZM32.54,94.08c4.27,-0.08 10.76,3.69 15.83,9.07 7.22,7.65 7.8,14.4 3.38,17.5 -3.88,2.71 -9.57,4.37 -16.79,-3.28 -7.22,-7.65 -8.62,-16.19 -6.37,-20.96 0.74,-1.57 2.15,-2.29 3.95,-2.32z" android:strokeWidth="4.23"/>
<path android:fillColor="#FF000000"
android:pathData="M15.11,3.8 L4.29,10.33C-1.54,13.85 -1.3,19.19 4.23,23.15 19.07,33.77 33.15,49.88 48.09,65.99c0.65,0.7 0.4,1.32 -0.55,1.43 -10.69,1.33 -19.82,8.47 -24.07,18.07 -2.73,6.18 -2.75,17.75 -0.04,23.94 3.01,6.88 8.53,12.44 15.39,15.48 6.17,2.74 17.76,2.74 23.93,0 6.86,-3.04 12.38,-8.6 15.4,-15.48 2.71,-6.19 2.54,-14.24 2.09,-16.98C69.9,58.31 51.71,28.16 33.82,6.65 29.47,1.42 20.93,0.28 15.11,3.8ZM32.54,94.08c1.74,-0.03 3.84,0.57 6.06,1.66 6.08,2.98 14.8,11.3 15.83,17.93 0.47,2.98 -0.48,5.41 -2.65,6.95 -0.02,0.01 -0.05,0.04 -0.07,0.05 -1.71,1.19 -3.77,2.17 -6.16,2.24 -6.6,0.19 -14.91,-9.54 -16.99,-15.96 -1.34,-4.14 -1.21,-7.91 0.03,-10.54 0,-0 0,-0.01 0.01,-0.01 0.74,-1.57 2.15,-2.28 3.95,-2.31z" android:strokeWidth="7.69"/>
</vector>

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M268,112l144,144l-144,144"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M392,256L100,256"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,13 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M328,112l-144,144l144,144"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M289.94,256l95,-95A24,24 0,0 0,351 127l-95,95 -95,-95A24,24 0,0 0,127 161l95,95 -95,95A24,24 0,1 0,161 385l95,-95 95,95A24,24 0,0 0,385 351Z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,176a80,80 0,1 0,80 80A80.09,80.09 0,0 0,256 176ZM256,288a32,32 0,1 1,32 -32A32,32 0,0 1,256 288Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M414.39,97.61A224,224 0,1 0,97.61 414.39,224 224,0 1,0 414.39,97.61ZM256,368A112,112 0,1 1,368 256,112.12 112.12,0 0,1 256,368Z"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#BDBDBD"
android:pathData="M256,176a80,80 0,1 0,80 80A80.09,80.09 0,0 0,256 176ZM256,288a32,32 0,1 1,32 -32A32,32 0,0 1,256 288Z"/>
<path
android:fillColor="#BDBDBD"
android:pathData="M414.39,97.61A224,224 0,1 0,97.61 414.39,224 224,0 1,0 414.39,97.61ZM256,368A112,112 0,1 1,368 256,112.12 112.12,0 0,1 256,368Z"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,256m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0"/>
<path
android:fillColor="#FF000000"
android:pathData="M416,256m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0"/>
<path
android:fillColor="#FF000000"
android:pathData="M96,256m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,256m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0"/>
<path
android:fillColor="#FF000000"
android:pathData="M256,416m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0"/>
<path
android:fillColor="#FF000000"
android:pathData="M256,96m-48,0a48,48 0,1 1,96 0a48,48 0,1 1,-96 0"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,448a32,32 0,0 1,-18 -5.57c-78.59,-53.35 -112.62,-89.93 -131.39,-112.8 -40,-48.75 -59.15,-98.8 -58.61,-153C48.63,114.52 98.46,64 159.08,64c44.08,0 74.61,24.83 92.39,45.51a6,6 0,0 0,9.06 0C278.31,88.81 308.84,64 352.92,64 413.54,64 463.37,114.52 464,176.64c0.54,54.21 -18.63,104.26 -58.61,153 -18.77,22.87 -52.8,59.45 -131.39,112.8A32,32 0,0 1,256 448Z"/>
</vector>

View file

@ -0,0 +1,15 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M417.84,448a16,16 0,0 1,-11.35 -4.72L40.65,75.28a16,16 0,1 1,22.7 -22.56l365.83,368A16,16 0,0 1,417.84 448Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M364.92,80c-44.09,0 -74.61,24.82 -92.39,45.5a6,6 0,0 1,-9.06 0C245.69,104.82 215.16,80 171.08,80a107.71,107.71 0,0 0,-31 4.54l269.13,270.7c3,-3.44 5.7,-6.64 8.14,-9.6 40,-48.75 59.15,-98.79 58.61,-153C475.37,130.53 425.54,80 364.92,80Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M69,149.15a115.06,115.06 0,0 0,-9 43.49c-0.54,54.21 18.63,104.25 58.61,153 18.77,22.87 52.8,59.45 131.39,112.8a31.88,31.88 0,0 0,36 0c20.35,-13.82 37.7,-26.5 52.58,-38.12Z"/>
</vector>

View file

@ -0,0 +1,19 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group android:scaleX="0.31472"
android:scaleY="0.31472"
android:translateX="34.647865"
android:translateY="34.02">
<path
android:pathData="m75.89,0.01c-3.13,0.14 -7,2.17 -7,4.64l1.16,72.06c-9.91,-10.11 -23.45,-11.88 -35.27,-4.1 -11.82,7.78 -16.48,22.84 -11.12,35.94 5.36,13.1 19.23,20.58 33.12,17.84 13.88,-2.74 23.89,-14.92 23.88,-29.07L80.65,36.94c11.72,6.28 24.81,9.57 38.1,9.57 2.34,-0 4.23,-1.9 4.23,-4.23L122.98,16.87c0,-2.34 -1.9,-4.23 -4.23,-4.23 -13.08,0 -25.92,-3.56 -37.14,-10.29L78.6,0.55C77.89,0.12 76.93,-0.04 75.89,0.01ZM32.54,94.08c4.27,-0.08 10.76,3.69 15.83,9.07 7.22,7.65 7.8,14.4 3.38,17.5 -3.88,2.71 -9.57,4.37 -16.79,-3.28 -7.22,-7.65 -8.62,-16.19 -6.37,-20.96 0.74,-1.57 2.15,-2.29 3.95,-2.32z"
android:strokeWidth="4.23"
android:fillColor="#ffffff"/>
<path
android:pathData="M15.11,3.8 L4.29,10.33C-1.54,13.85 -1.3,19.19 4.23,23.15 19.07,33.77 33.15,49.88 48.09,65.99c0.65,0.7 0.4,1.32 -0.55,1.43 -10.69,1.33 -19.82,8.47 -24.07,18.07 -2.73,6.18 -2.75,17.75 -0.04,23.94 3.01,6.88 8.53,12.44 15.39,15.48 6.17,2.74 17.76,2.74 23.93,0 6.86,-3.04 12.38,-8.6 15.4,-15.48 2.71,-6.19 2.54,-14.24 2.09,-16.98C69.9,58.31 51.71,28.16 33.82,6.65 29.47,1.42 20.93,0.28 15.11,3.8ZM32.54,94.08c1.74,-0.03 3.84,0.57 6.06,1.66 6.08,2.98 14.8,11.3 15.83,17.93 0.47,2.98 -0.48,5.41 -2.65,6.95 -0.02,0.01 -0.05,0.04 -0.07,0.05 -1.71,1.19 -3.77,2.17 -6.16,2.24 -6.6,0.19 -14.91,-9.54 -16.99,-15.96 -1.34,-4.14 -1.21,-7.91 0.03,-10.54 0,-0 0,-0.01 0.01,-0.01 0.74,-1.57 2.15,-2.28 3.95,-2.31z"
android:strokeWidth="7.69"
android:fillColor="#ffffff"/>
</group>
</vector>

View file

@ -0,0 +1,48 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M160,144L448,144"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M160,256L448,256"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M160,368L448,368"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M80,144m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M80,256m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M80,368m-16,0a16,16 0,1 1,32 0a16,16 0,1 1,-32 0"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M208,432H160a16,16 0,0 1,-16 -16V96a16,16 0,0 1,16 -16h48a16,16 0,0 1,16 16V416A16,16 0,0 1,208 432Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M352,432H304a16,16 0,0 1,-16 -16V96a16,16 0,0 1,16 -16h48a16,16 0,0 1,16 16V416A16,16 0,0 1,352 432Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,48C141.31,48 48,141.31 48,256s93.31,208 208,208 208,-93.31 208,-208S370.69,48 256,48zM224,320a16,16 0,0 1,-32 0L192,192a16,16 0,0 1,32 0zM320,320a16,16 0,0 1,-32 0L288,192a16,16 0,0 1,32 0z"/>
</vector>

View file

@ -0,0 +1,20 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M358.62,129.28l-272.13,272.8l-16.49,39.92l39.92,-16.49l272.8,-272.13l-24.1,-24.1z"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M413.07,74.84 L401.28,86.62l24.1,24.1 11.79,-11.79a16.51,16.51 0,0 0,0 -23.34l-0.75,-0.75A16.51,16.51 0,0 0,413.07 74.84Z"
android:strokeLineJoin="round"
android:strokeWidth="48"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M332.64,64.58C313.18,43.57 286,32 256,32c-30.16,0 -57.43,11.5 -76.8,32.38 -19.58,21.11 -29.12,49.8 -26.88,80.78C156.76,206.28 203.27,256 256,256s99.16,-49.71 103.67,-110.82C361.94,114.48 352.34,85.85 332.64,64.58Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M432,480H80A31,31 0,0 1,55.8 468.87c-6.5,-7.77 -9.12,-18.38 -7.18,-29.11C57.06,392.94 83.4,353.61 124.8,326c36.78,-24.51 83.37,-38 131.2,-38s94.42,13.5 131.2,38c41.4,27.6 67.74,66.93 76.18,113.75 1.94,10.73 -0.68,21.34 -7.18,29.11A31,31 0,0 1,432 480Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M133,440a35.37,35.37 0,0 1,-17.5 -4.67c-12,-6.8 -19.46,-20 -19.46,-34.33V111c0,-14.37 7.46,-27.53 19.46,-34.33a35.13,35.13 0,0 1,35.77 0.45L399.12,225.48a36,36 0,0 1,0 61L151.23,434.88A35.5,35.5 0,0 1,133 440Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,48C141.31,48 48,141.31 48,256s93.31,208 208,208 208,-93.31 208,-208S370.69,48 256,48zM330.77,265.3l-114.45,69.14a10.78,10.78 0,0 1,-16.32 -9.31L200,186.87a10.78,10.78 0,0 1,16.32 -9.31l114.45,69.14a10.89,10.89 0,0 1,0 18.6z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M112,64a16,16 0,0 1,16 16V216.43L360.77,77.11a35.13,35.13 0,0 1,35.77 -0.44c12,6.8 19.46,20 19.46,34.33V401c0,14.37 -7.46,27.53 -19.46,34.33a35.14,35.14 0,0 1,-35.77 -0.45L128,295.57V432a16,16 0,0 1,-32 0V80A16,16 0,0 1,112 64Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M400,64a16,16 0,0 0,-16 16V216.43L151.23,77.11a35.13,35.13 0,0 0,-35.77 -0.44C103.46,83.47 96,96.63 96,111V401c0,14.37 7.46,27.53 19.46,34.33a35.14,35.14 0,0 0,35.77 -0.45L384,295.57V432a16,16 0,0 0,32 0V80A16,16 0,0 0,400 64Z"/>
</vector>

View file

@ -0,0 +1,27 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,256m-36,0a36,36 0,1 1,72 0a36,36 0,1 1,-72 0"/>
<path
android:fillColor="#FF000000"
android:pathData="M190.24,341.77a22,22 0,0 1,-16.46 -7.38,118 118,0 0,1 0,-156.76 22,22 0,1 1,32.87 29.24,74 74,0 0,0 0,98.29 22,22 0,0 1,-16.43 36.61Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M321.76,341.77a22,22 0,0 1,-16.43 -36.61,74 74,0 0,0 0,-98.29 22,22 0,1 1,32.87 -29.24,118 118,0 0,1 0,156.76A22,22 0,0 1,321.76 341.77Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M139.29,392.72a21.92,21.92 0,0 1,-16.08 -7,190 190,0 0,1 0,-259.49 22,22 0,1 1,32.13 30.06,146 146,0 0,0 0,199.38 22,22 0,0 1,-16.06 37Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M372.71,392.72a22,22 0,0 1,-16.06 -37,146 146,0 0,0 0,-199.38 22,22 0,1 1,32.13 -30.06,190 190,0 0,1 0,259.49A21.92,21.92 0,0 1,372.71 392.72Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M429,438a22,22 0,0 1,-16.39 -36.67,218.34 218.34,0 0,0 0,-290.66 22,22 0,0 1,32.78 -29.34,262.34 262.34,0 0,1 0,349.34A22,22 0,0 1,429 438Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M83,438a21.94,21.94 0,0 1,-16.41 -7.33,262.34 262.34,0 0,1 0,-349.34 22,22 0,0 1,32.78 29.34,218.34 218.34,0 0,0 0,290.66A22,22 0,0 1,83 438Z"/>
</vector>

View file

@ -0,0 +1,34 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M320,120l48,48l-48,48"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M352,168H144a80.24,80.24 0,0 0,-80 80v16"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M192,392l-48,-48l48,-48"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M160,344H368a80.24,80.24 0,0 0,80 -80V248"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,41 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M320,120l48,48l-48,48"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M352,168H144a80.24,80.24 0,0 0,-80 80v16"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M192,392l-48,-48l48,-48"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M160,344H353.113"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
<path
android:pathData="m446.293,243.326 l-48.027,30.375v0l44.094,-21.604v95.885H448V248v0,-2.322 -2.352z"
android:strokeLineJoin="round"
android:strokeWidth="28.021"
android:fillColor="#ff0000"
android:strokeColor="#000000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M414.39,97.61A224,224 0,1 0,97.61 414.39,224 224,0 1,0 414.39,97.61ZM184,208a24,24 0,1 1,-24 24A23.94,23.94 0,0 1,184 208ZM160.33,357.83c12,-40.3 50.2,-69.83 95.62,-69.83s83.62,29.53 95.71,69.83A8,8 0,0 1,343.84 368H168.15A8,8 0,0 1,160.33 357.83ZM328,256a24,24 0,1 1,24 -24A23.94,23.94 0,0 1,328 256Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M456.69,421.39 L362.6,327.3a173.81,173.81 0,0 0,34.84 -104.58C397.44,126.38 319.06,48 222.72,48S48,126.38 48,222.72s78.38,174.72 174.72,174.72A173.81,173.81 0,0 0,327.3 362.6l94.09,94.09a25,25 0,0 0,35.3 -35.3ZM97.92,222.72a124.8,124.8 0,1 1,124.8 124.8A124.95,124.95 0,0 1,97.92 222.72Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M384,336a63.78,63.78 0,0 0,-46.12 19.7l-148,-83.27a63.85,63.85 0,0 0,0 -32.86l148,-83.27a63.8,63.8 0,1 0,-15.73 -27.87l-148,83.27a64,64 0,1 0,0 88.6l148,83.27A64,64 0,1 0,384 336Z"/>
</vector>

View file

@ -0,0 +1,41 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:pathData="M400,304l48,48l-48,48"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M400,112l48,48l-48,48"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M64,352h85.19a80,80 0,0 0,66.56 -35.62L256,256"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M64,160h85.19a80,80 0,0 1,66.56 35.62l80.5,120.76A80,80 0,0 0,362.81 352H416"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
<path
android:pathData="M416,160H362.81a80,80 0,0 0,-66.56 35.62L288,208"
android:strokeLineJoin="round"
android:strokeWidth="32"
android:fillColor="#00000000"
android:strokeColor="#000"
android:strokeLineCap="round"/>
</vector>

View file

@ -0,0 +1,12 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="512dp"
android:height="512dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M292.6,407.78l-120,-320a22,22 0,0 0,-41.2 0l-120,320a22,22 0,0 0,41.2 15.44L88.76,326.8a2,2 0,0 1,1.87 -1.3L213.37,325.5a2,2 0,0 1,1.87 1.3l36.16,96.42a22,22 0,0 0,41.2 -15.44ZM106.76,278.78 L150.13,163.13a2,2 0,0 1,3.74 0L197.24,278.8a2,2 0,0 1,-1.87 2.7L108.63,281.5A2,2 0,0 1,106.76 278.8Z"/>
<path
android:fillColor="#FF000000"
android:pathData="M400.77,169.5c-41.72,-0.3 -79.08,23.87 -95,61.4a22,22 0,0 0,40.5 17.2c8.88,-20.89 29.77,-34.44 53.32,-34.6C431.91,213.28 458,240 458,272.35h0a1.5,1.5 0,0 1,-1.45 1.5c-21.92,0.61 -47.92,2.07 -71.12,4.8C330.68,285.09 298,314.94 298,358.5c0,23.19 8.76,44 24.67,58.68C337.6,430.93 358,438.5 380,438.5c31,0 57.69,-8 77.94,-23.22 0,0 0.06,0 0.06,0h0a22,22 0,1 0,44 0.19v-143C502,216.29 457,169.91 400.77,169.5ZM380,394.5c-17.53,0 -38,-9.43 -38,-36 0,-10.67 3.83,-18.14 12.43,-24.23 8.37,-5.93 21.2,-10.16 36.14,-11.92 21.12,-2.49 44.82,-3.86 65.14,-4.47a2,2 0,0 1,2 2.1C455,370.1 429.46,394.5 380,394.5Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M256,48C141.13,48 48,141.13 48,256s93.13,208 208,208 208,-93.13 208,-208S370.87,48 256,48ZM352,288L256,288a16,16 0,0 1,-16 -16L240,128a16,16 0,0 1,32 0L272,256h80a16,16 0,0 1,0 32Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="512"
android:viewportHeight="512">
<path
android:fillColor="#FF000000"
android:pathData="M432,96L336,96L336,72a40,40 0,0 0,-40 -40L216,32a40,40 0,0 0,-40 40L176,96L80,96a16,16 0,0 0,0 32L97,128L116,432.92c1.42,26.85 22,47.08 48,47.08L348,480c26.13,0 46.3,-19.78 48,-47L415,128h17a16,16 0,0 0,0 -32ZM192.57,416L192,416a16,16 0,0 1,-16 -15.43l-8,-224a16,16 0,1 1,32 -1.14l8,224A16,16 0,0 1,192.57 416ZM272,400a16,16 0,0 1,-32 0L240,176a16,16 0,0 1,32 0ZM304,96L208,96L208,72a7.91,7.91 0,0 1,8 -8h80a7.91,7.91 0,0 1,8 8ZM336,400.57A16,16 0,0 1,320 416h-0.58A16,16 0,0 1,304 399.43l8,-224a16,16 0,1 1,32 1.14Z"/>
</vector>

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View file

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

Some files were not shown because too many files have changed in this diff Show more