Initial commit
This commit is contained in:
commit
1e673ad582
160 changed files with 10800 additions and 0 deletions
10
.gitignore
vendored
Normal file
10
.gitignore
vendored
Normal file
|
@ -0,0 +1,10 @@
|
|||
*.iml
|
||||
.gradle
|
||||
/local.properties
|
||||
.idea
|
||||
.DS_Store
|
||||
/build
|
||||
/captures
|
||||
.externalNativeBuild
|
||||
.cxx
|
||||
local.properties
|
202
LICENSE.md
Normal file
202
LICENSE.md
Normal 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
55
README.md
Normal 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
2
app/.gitignore
vendored
Normal file
|
@ -0,0 +1,2 @@
|
|||
/build
|
||||
/release
|
97
app/build.gradle.kts
Normal file
97
app/build.gradle.kts
Normal 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
24
app/proguard-rules.pro
vendored
Normal 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
|
||||
|
304
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json
Normal file
304
app/schemas/it.vfsfitvnm.vimusic.DatabaseInitializer/1.json
Normal 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')"
|
||||
]
|
||||
}
|
||||
}
|
57
app/src/main/AndroidManifest.xml
Normal file
57
app/src/main/AndroidManifest.xml
Normal 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>
|
BIN
app/src/main/ic_launcher-playstore.png
Normal file
BIN
app/src/main/ic_launcher-playstore.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 14 KiB |
140
app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt
Normal file
140
app/src/main/kotlin/it/vfsfitvnm/vimusic/Database.kt
Normal 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
|
145
app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt
Normal file
145
app/src/main/kotlin/it/vfsfitvnm/vimusic/MainActivity.kt
Normal 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()
|
||||
}
|
||||
}
|
26
app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt
Normal file
26
app/src/main/kotlin/it/vfsfitvnm/vimusic/MainApplication.kt
Normal 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()
|
||||
}
|
||||
}
|
|
@ -0,0 +1,7 @@
|
|||
package it.vfsfitvnm.vimusic.enums
|
||||
|
||||
enum class SongCollection {
|
||||
MostPlayed,
|
||||
Favorites,
|
||||
History
|
||||
}
|
12
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt
Normal file
12
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Info.kt
Normal 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
|
||||
)
|
17
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt
Normal file
17
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Playlist.kt
Normal 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 = ""
|
||||
)
|
||||
}
|
||||
}
|
|
@ -0,0 +1,8 @@
|
|||
package it.vfsfitvnm.vimusic.models
|
||||
|
||||
import androidx.room.Embedded
|
||||
|
||||
data class PlaylistPreview(
|
||||
@Embedded val playlist: Playlist,
|
||||
val songCount: Int
|
||||
)
|
|
@ -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())
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
34
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt
Normal file
34
app/src/main/kotlin/it/vfsfitvnm/vimusic/models/Song.kt
Normal 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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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>?
|
||||
)
|
|
@ -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
|
||||
)
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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!!)
|
||||
}
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
|
@ -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 }
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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")
|
||||
}
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
|
@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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 -> {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
466
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt
Normal file
466
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/PlayerView.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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)
|
||||
)
|
||||
}
|
||||
}
|
199
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt
Normal file
199
app/src/main/kotlin/it/vfsfitvnm/vimusic/ui/views/SongItem.kt
Normal 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)
|
||||
)
|
||||
}
|
||||
}
|
44
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt
Normal file
44
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Player.kt
Normal 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)
|
||||
}
|
|
@ -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
|
||||
}
|
||||
}
|
108
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt
Normal file
108
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/Preferences.kt
Normal 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
|
||||
}
|
||||
})
|
|
@ -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
|
||||
}
|
11
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt
Normal file
11
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/RingBuffer.kt
Normal 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)
|
||||
}
|
43
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt
Normal file
43
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/TextStyle.kt
Normal 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)
|
129
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt
Normal file
129
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/YoutubePlayer.kt
Normal 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
|
||||
}
|
|
@ -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
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
172
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt
Normal file
172
app/src/main/kotlin/it/vfsfitvnm/vimusic/utils/utils.kt
Normal 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()
|
||||
}
|
20
app/src/main/res/drawable/add.xml
Normal file
20
app/src/main/res/drawable/add.xml
Normal 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>
|
9
app/src/main/res/drawable/alert_circle.xml
Normal file
9
app/src/main/res/drawable/alert_circle.xml
Normal 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>
|
7
app/src/main/res/drawable/app_icon.xml
Normal file
7
app/src/main/res/drawable/app_icon.xml
Normal 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>
|
20
app/src/main/res/drawable/arrow_forward.xml
Normal file
20
app/src/main/res/drawable/arrow_forward.xml
Normal 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>
|
13
app/src/main/res/drawable/chevron_back.xml
Normal file
13
app/src/main/res/drawable/chevron_back.xml
Normal 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>
|
9
app/src/main/res/drawable/close.xml
Normal file
9
app/src/main/res/drawable/close.xml
Normal 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>
|
12
app/src/main/res/drawable/disc.xml
Normal file
12
app/src/main/res/drawable/disc.xml
Normal 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>
|
12
app/src/main/res/drawable/disc_placeholder.xml
Normal file
12
app/src/main/res/drawable/disc_placeholder.xml
Normal 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>
|
15
app/src/main/res/drawable/ellipsis_horizontal.xml
Normal file
15
app/src/main/res/drawable/ellipsis_horizontal.xml
Normal 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>
|
15
app/src/main/res/drawable/ellipsis_vertical.xml
Normal file
15
app/src/main/res/drawable/ellipsis_vertical.xml
Normal 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>
|
9
app/src/main/res/drawable/heart.xml
Normal file
9
app/src/main/res/drawable/heart.xml
Normal 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>
|
15
app/src/main/res/drawable/heart_dislike.xml
Normal file
15
app/src/main/res/drawable/heart_dislike.xml
Normal 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>
|
19
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal file
19
app/src/main/res/drawable/ic_launcher_foreground.xml
Normal 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>
|
48
app/src/main/res/drawable/list.xml
Normal file
48
app/src/main/res/drawable/list.xml
Normal 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>
|
12
app/src/main/res/drawable/pause.xml
Normal file
12
app/src/main/res/drawable/pause.xml
Normal 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>
|
9
app/src/main/res/drawable/pause_circle.xml
Normal file
9
app/src/main/res/drawable/pause_circle.xml
Normal 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>
|
20
app/src/main/res/drawable/pencil.xml
Normal file
20
app/src/main/res/drawable/pencil.xml
Normal 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>
|
12
app/src/main/res/drawable/person.xml
Normal file
12
app/src/main/res/drawable/person.xml
Normal 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>
|
9
app/src/main/res/drawable/play.xml
Normal file
9
app/src/main/res/drawable/play.xml
Normal 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>
|
9
app/src/main/res/drawable/play_circle.xml
Normal file
9
app/src/main/res/drawable/play_circle.xml
Normal 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>
|
9
app/src/main/res/drawable/play_skip_back.xml
Normal file
9
app/src/main/res/drawable/play_skip_back.xml
Normal 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>
|
9
app/src/main/res/drawable/play_skip_forward.xml
Normal file
9
app/src/main/res/drawable/play_skip_forward.xml
Normal 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>
|
27
app/src/main/res/drawable/radio.xml
Normal file
27
app/src/main/res/drawable/radio.xml
Normal 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>
|
34
app/src/main/res/drawable/repeat.xml
Normal file
34
app/src/main/res/drawable/repeat.xml
Normal 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>
|
41
app/src/main/res/drawable/repeat_one.xml
Normal file
41
app/src/main/res/drawable/repeat_one.xml
Normal 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>
|
9
app/src/main/res/drawable/sad.xml
Normal file
9
app/src/main/res/drawable/sad.xml
Normal 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>
|
9
app/src/main/res/drawable/search.xml
Normal file
9
app/src/main/res/drawable/search.xml
Normal 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>
|
9
app/src/main/res/drawable/share_social.xml
Normal file
9
app/src/main/res/drawable/share_social.xml
Normal 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>
|
41
app/src/main/res/drawable/shuffle.xml
Normal file
41
app/src/main/res/drawable/shuffle.xml
Normal 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>
|
12
app/src/main/res/drawable/text.xml
Normal file
12
app/src/main/res/drawable/text.xml
Normal 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>
|
9
app/src/main/res/drawable/time.xml
Normal file
9
app/src/main/res/drawable/time.xml
Normal 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>
|
9
app/src/main/res/drawable/trash.xml
Normal file
9
app/src/main/res/drawable/trash.xml
Normal 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>
|
BIN
app/src/main/res/font/poppins_w300.ttf
Normal file
BIN
app/src/main/res/font/poppins_w300.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/poppins_w400.ttf
Normal file
BIN
app/src/main/res/font/poppins_w400.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/poppins_w400_italic.ttf
Normal file
BIN
app/src/main/res/font/poppins_w400_italic.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/poppins_w500.ttf
Normal file
BIN
app/src/main/res/font/poppins_w500.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/poppins_w600.ttf
Normal file
BIN
app/src/main/res/font/poppins_w600.ttf
Normal file
Binary file not shown.
BIN
app/src/main/res/font/poppins_w700.ttf
Normal file
BIN
app/src/main/res/font/poppins_w700.ttf
Normal file
Binary file not shown.
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal 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>
|
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml
Normal 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
Loading…
Reference in a new issue