Initial commit

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

2
app/.gitignore vendored Normal file
View File

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

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

@@ -0,0 +1,97 @@
plugins {
id("com.android.application")
kotlin("android")
kotlin("kapt")
}
android {
signingConfigs {
create("release") {
}
}
compileSdk = 32
defaultConfig {
applicationId = "it.vfsfitvnm.vimusic"
minSdk = 21
targetSdk = 32
versionCode = 1
versionName = "0.1.0"
}
splits {
abi {
reset()
isUniversalApk = true
}
}
buildTypes {
debug {
applicationIdSuffix = ".debug"
manifestPlaceholders["appName"] = "Debug"
}
release {
isMinifyEnabled = true
isShrinkResources = true
manifestPlaceholders["appName"] = "ViMusic"
signingConfig = signingConfigs.getByName("debug")
proguardFiles(getDefaultProguardFile("proguard-android-optimize.txt"), "proguard-rules.pro")
}
}
sourceSets.all {
kotlin.srcDir("src/$name/kotlin")
}
buildFeatures {
compose = true
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_1_8
targetCompatibility = JavaVersion.VERSION_1_8
}
composeOptions {
kotlinCompilerExtensionVersion = libs.versions.compose.get()
}
kotlinOptions {
freeCompilerArgs += "-Xcontext-receivers"
jvmTarget = "1.8"
}
}
kapt {
arguments {
arg("room.schemaLocation", "$projectDir/schemas")
}
}
dependencies {
implementation(projects.composeRouting)
implementation(projects.composeReordering)
implementation(libs.compose.activity)
implementation(libs.compose.foundation)
implementation(libs.compose.ui)
implementation(libs.compose.ui.util)
implementation(libs.compose.ripple)
implementation(libs.compose.shimmer)
implementation(libs.compose.coil)
implementation(libs.accompanist.systemuicontroller)
implementation(libs.accompanist.flowlayout)
implementation(libs.android.media)
implementation(libs.bundles.media3)
implementation(libs.room)
kapt(libs.room.compiler)
implementation(libs.guava.coroutines)
implementation(projects.youtubeMusic)
}

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

@@ -0,0 +1,24 @@
-keep,allowobfuscation,allowshrinking class kotlin.coroutines.Continuation
-if @kotlinx.serialization.Serializable class **
-keepclassmembers class <1> {
static <1>$Companion Companion;
}
-if @kotlinx.serialization.Serializable class ** {
static **$* *;
}
-keepclassmembers class <2>$<3> {
kotlinx.serialization.KSerializer serializer(...);
}
-if @kotlinx.serialization.Serializable class ** {
public static ** INSTANCE;
}
-keepclassmembers class <1> {
public static <1> INSTANCE;
kotlinx.serialization.KSerializer serializer(...);
}
-keepattributes RuntimeVisibleAnnotations,AnnotationDefault

View File

@@ -0,0 +1,304 @@
{
"formatVersion": 1,
"database": {
"version": 1,
"identityHash": "b93575bd08c10513f0bfc997b832c280",
"entities": [
{
"tableName": "Song",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` TEXT NOT NULL, `title` TEXT NOT NULL, `albumInfoId` INTEGER, `durationText` TEXT NOT NULL, `thumbnailUrl` TEXT, `likedAt` INTEGER, `totalPlayTimeMs` INTEGER NOT NULL, PRIMARY KEY(`id`))",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "title",
"columnName": "title",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "albumInfoId",
"columnName": "albumInfoId",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "durationText",
"columnName": "durationText",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "thumbnailUrl",
"columnName": "thumbnailUrl",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "likedAt",
"columnName": "likedAt",
"affinity": "INTEGER",
"notNull": false
},
{
"fieldPath": "totalPlayTimeMs",
"columnName": "totalPlayTimeMs",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongInPlaylist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `playlistId` INTEGER NOT NULL, `position` INTEGER NOT NULL, PRIMARY KEY(`songId`, `playlistId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`playlistId`) REFERENCES `Playlist`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "playlistId",
"columnName": "playlistId",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "position",
"columnName": "position",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"playlistId"
]
},
"indices": [
{
"name": "index_SongInPlaylist_songId",
"unique": false,
"columnNames": [
"songId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_songId` ON `${TABLE_NAME}` (`songId`)"
},
{
"name": "index_SongInPlaylist_playlistId",
"unique": false,
"columnNames": [
"playlistId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongInPlaylist_playlistId` ON `${TABLE_NAME}` (`playlistId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Playlist",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"playlistId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "Playlist",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `name` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "name",
"columnName": "name",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "Info",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `browseId` TEXT, `text` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "browseId",
"columnName": "browseId",
"affinity": "TEXT",
"notNull": false
},
{
"fieldPath": "text",
"columnName": "text",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [],
"foreignKeys": []
},
{
"tableName": "SongWithAuthors",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`songId` TEXT NOT NULL, `authorInfoId` INTEGER NOT NULL, PRIMARY KEY(`songId`, `authorInfoId`), FOREIGN KEY(`songId`) REFERENCES `Song`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE , FOREIGN KEY(`authorInfoId`) REFERENCES `Info`(`id`) ON UPDATE NO ACTION ON DELETE CASCADE )",
"fields": [
{
"fieldPath": "songId",
"columnName": "songId",
"affinity": "TEXT",
"notNull": true
},
{
"fieldPath": "authorInfoId",
"columnName": "authorInfoId",
"affinity": "INTEGER",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": false,
"columnNames": [
"songId",
"authorInfoId"
]
},
"indices": [
{
"name": "index_SongWithAuthors_authorInfoId",
"unique": false,
"columnNames": [
"authorInfoId"
],
"orders": [],
"createSql": "CREATE INDEX IF NOT EXISTS `index_SongWithAuthors_authorInfoId` ON `${TABLE_NAME}` (`authorInfoId`)"
}
],
"foreignKeys": [
{
"table": "Song",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"songId"
],
"referencedColumns": [
"id"
]
},
{
"table": "Info",
"onDelete": "CASCADE",
"onUpdate": "NO ACTION",
"columns": [
"authorInfoId"
],
"referencedColumns": [
"id"
]
}
]
},
{
"tableName": "SearchQuery",
"createSql": "CREATE TABLE IF NOT EXISTS `${TABLE_NAME}` (`id` INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, `query` TEXT NOT NULL)",
"fields": [
{
"fieldPath": "id",
"columnName": "id",
"affinity": "INTEGER",
"notNull": true
},
{
"fieldPath": "query",
"columnName": "query",
"affinity": "TEXT",
"notNull": true
}
],
"primaryKey": {
"autoGenerate": true,
"columnNames": [
"id"
]
},
"indices": [
{
"name": "index_SearchQuery_query",
"unique": true,
"columnNames": [
"query"
],
"orders": [],
"createSql": "CREATE UNIQUE INDEX IF NOT EXISTS `index_SearchQuery_query` ON `${TABLE_NAME}` (`query`)"
}
],
"foreignKeys": []
}
],
"views": [
{
"viewName": "SortedSongInPlaylist",
"createSql": "CREATE VIEW `${VIEW_NAME}` AS SELECT * FROM SongInPlaylist ORDER BY position"
}
],
"setupQueries": [
"CREATE TABLE IF NOT EXISTS room_master_table (id INTEGER PRIMARY KEY,identity_hash TEXT)",
"INSERT OR REPLACE INTO room_master_table (id,identity_hash) VALUES(42, 'b93575bd08c10513f0bfc997b832c280')"
]
}
}

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 14 KiB

View File

@@ -0,0 +1,140 @@
package it.vfsfitvnm.vimusic
import android.content.Context
import androidx.room.*
import it.vfsfitvnm.vimusic.models.*
import kotlinx.coroutines.flow.Flow
@Dao
interface Database {
companion object : Database by DatabaseInitializer.Instance.database
@Query("SELECT * FROM SearchQuery WHERE query LIKE :query ORDER BY id DESC")
fun getRecentQueries(query: String): Flow<List<SearchQuery>>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(searchQuery: SearchQuery)
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(info: Info): Long
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(playlist: Playlist): Long
@Insert(onConflict = OnConflictStrategy.IGNORE)
fun insert(info: SongInPlaylist): Long
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(info: List<Info>): List<Long>
@Query("SELECT * FROM Song WHERE id = :id")
fun songFlow(id: String): Flow<Song?>
@Query("SELECT * FROM Song WHERE id = :id")
fun song(id: String): Song?
@Query("SELECT * FROM Playlist WHERE id = :id")
fun playlist(id: Long): Playlist?
@Query("SELECT * FROM Song")
fun songs(): Flow<List<Song>>
@Transaction
@Query("SELECT * FROM Song WHERE id = :id")
fun songWithInfo(id: String): SongWithInfo?
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs >= 15000 ORDER BY ROWID DESC")
fun history(): Flow<List<SongWithInfo>>
@Transaction
@Query("SELECT * FROM Song WHERE likedAt IS NOT NULL ORDER BY likedAt DESC")
fun favorites(): Flow<List<SongWithInfo>>
@Transaction
@Query("SELECT * FROM Song WHERE totalPlayTimeMs >= 60000 ORDER BY totalPlayTimeMs DESC LIMIT 20")
fun mostPlayed(): Flow<List<SongWithInfo>>
@Query("UPDATE Song SET totalPlayTimeMs = totalPlayTimeMs + :addition WHERE id = :id")
fun incrementTotalPlayTimeMs(id: String, addition: Long)
@Transaction
@Query("SELECT * FROM Playlist WHERE id = :id")
fun playlistWithSongs(id: Long): Flow<PlaylistWithSongs?>
@Query("SELECT COUNT(*) FROM SongInPlaylist WHERE playlistId = :id")
fun playlistSongCount(id: Long): Int
@Query("UPDATE SongInPlaylist SET position = position - 1 WHERE playlistId = :playlistId AND position >= :fromPosition")
fun decrementSongPositions(playlistId: Long, fromPosition: Int)
@Query("UPDATE SongInPlaylist SET position = position - 1 WHERE playlistId = :playlistId AND position >= :fromPosition AND position <= :toPosition")
fun decrementSongPositions(playlistId: Long, fromPosition: Int, toPosition: Int)
@Query("UPDATE SongInPlaylist SET position = position + 1 WHERE playlistId = :playlistId AND position >= :fromPosition AND position <= :toPosition")
fun incrementSongPositions(playlistId: Long, fromPosition: Int, toPosition: Int)
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(songWithAuthors: SongWithAuthors): Long
@Insert(onConflict = OnConflictStrategy.ABORT)
fun insert(song: Song): Long
@Update
fun update(song: Song)
@Update
fun update(songInPlaylist: SongInPlaylist)
@Update
fun update(playlist: Playlist)
@Delete
fun delete(searchQuery: SearchQuery)
@Delete
fun delete(playlist: Playlist)
@Delete
fun delete(song: Song)
@Delete
fun delete(songInPlaylist: SongInPlaylist)
@Transaction
@Query("SELECT id, name, (SELECT COUNT(*) FROM SongInPlaylist WHERE playlistId = id) as songCount FROM Playlist")
fun playlistPreviews(): Flow<List<PlaylistPreview>>
@Query("SELECT thumbnailUrl FROM Song JOIN SongInPlaylist ON id = songId WHERE playlistId = :id ORDER BY position LIMIT 4")
fun playlistThumbnailUrls(id: Long): Flow<List<String?>>
}
@androidx.room.Database(
entities = [
Song::class, SongInPlaylist::class, Playlist::class, Info::class, SongWithAuthors::class, SearchQuery::class
],
views = [
SortedSongInPlaylist::class
],
version = 1,
exportSchema = true
)
abstract class DatabaseInitializer protected constructor() : RoomDatabase() {
abstract val database: Database
companion object {
lateinit var Instance: DatabaseInitializer
context(Context)
operator fun invoke() {
if (!::Instance.isInitialized) {
Instance = Room
.databaseBuilder(this@Context, DatabaseInitializer::class.java, "data.db")
.build()
}
}
}
}
val Database.internal: RoomDatabase
get() = DatabaseInitializer.Instance

View File

@@ -0,0 +1,145 @@
package it.vfsfitvnm.vimusic
import android.content.ComponentName
import android.os.Bundle
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.LinearEasing
import androidx.compose.animation.core.RepeatMode
import androidx.compose.animation.core.infiniteRepeatable
import androidx.compose.animation.core.tween
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.LocalIndication
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.LocalOverScrollConfiguration
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material.ripple.LocalRippleTheme
import androidx.compose.material.ripple.RippleAlpha
import androidx.compose.material.ripple.RippleTheme
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.SideEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.media3.session.MediaController
import androidx.media3.session.SessionToken
import com.google.accompanist.systemuicontroller.rememberSystemUiController
import com.google.common.util.concurrent.ListenableFuture
import com.valentinilk.shimmer.LocalShimmerTheme
import com.valentinilk.shimmer.defaultShimmerTheme
import it.vfsfitvnm.vimusic.services.PlayerService
import it.vfsfitvnm.vimusic.ui.components.BottomSheetMenu
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.rememberMenuState
import it.vfsfitvnm.vimusic.ui.screens.HomeScreen
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.styling.rememberColorPalette
import it.vfsfitvnm.vimusic.ui.styling.rememberTypography
import it.vfsfitvnm.vimusic.utils.*
@ExperimentalAnimationApi
@ExperimentalFoundationApi
@ExperimentalTextApi
class MainActivity : ComponentActivity() {
private lateinit var mediaControllerFuture: ListenableFuture<MediaController>
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
val sessionToken = SessionToken(this, ComponentName(this, PlayerService::class.java))
mediaControllerFuture = MediaController.Builder(this, sessionToken).buildAsync()
val intentVideoId = intent?.data?.getQueryParameter("v")
setContent {
val preferences = rememberPreferences()
val systemUiController = rememberSystemUiController()
val isDarkTheme = isSystemInDarkTheme()
val colorPalette = rememberColorPalette(isDarkTheme)
val rippleTheme = remember(colorPalette.text, isDarkTheme) {
object : RippleTheme {
@Composable
override fun defaultColor(): Color = RippleTheme.defaultRippleColor(
contentColor = colorPalette.text,
lightTheme = !isDarkTheme
)
@Composable
override fun rippleAlpha(): RippleAlpha = RippleTheme.defaultRippleAlpha(
contentColor = colorPalette.text,
lightTheme = !isDarkTheme
)
}
}
val shimmerTheme = remember {
defaultShimmerTheme.copy(
animationSpec = infiniteRepeatable(
animation = tween(
durationMillis = 800,
easing = LinearEasing,
delayMillis = 250,
),
repeatMode = RepeatMode.Restart
),
shaderColors = listOf(
Color.Unspecified.copy(alpha = 0.25f),
Color.White.copy(alpha = 0.50f),
Color.Unspecified.copy(alpha = 0.25f),
),
)
}
SideEffect {
systemUiController.setSystemBarsColor(colorPalette.background, !isDarkTheme)
}
CompositionLocalProvider(
LocalOverScrollConfiguration provides null,
LocalIndication provides rememberRipple(bounded = false),
LocalRippleTheme provides rippleTheme,
LocalPreferences provides preferences,
LocalColorPalette provides colorPalette,
LocalShimmerTheme provides shimmerTheme,
LocalTypography provides rememberTypography(colorPalette.text),
LocalYoutubePlayer provides rememberYoutubePlayer(
mediaControllerFuture,
preferences.repeatMode
),
LocalMenuState provides rememberMenuState(),
LocalHapticFeedback provides rememberHapticFeedback()
) {
Box(
modifier = Modifier
.fillMaxSize()
.background(LocalColorPalette.current.background)
) {
HomeScreen(intentVideoId = intentVideoId)
BottomSheetMenu(
state = LocalMenuState.current,
modifier = Modifier
.align(Alignment.BottomCenter)
)
}
}
}
}
override fun onDestroy() {
MediaController.releaseFuture(mediaControllerFuture)
super.onDestroy()
}
}

View File

@@ -0,0 +1,26 @@
package it.vfsfitvnm.vimusic
import android.app.Application
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.disk.DiskCache
class MainApplication : Application(), ImageLoaderFactory {
override fun onCreate() {
super.onCreate()
DatabaseInitializer()
}
override fun newImageLoader(): ImageLoader {
return ImageLoader.Builder(this)
.crossfade(true)
.diskCache(
DiskCache.Builder()
.directory(filesDir.resolve("coil"))
.maxSizeBytes(1024 * 1024 * 1024)
.build()
)
.build()
}
}

View File

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

View File

@@ -0,0 +1,12 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.Entity
import androidx.room.PrimaryKey
// I know...
@Entity
data class Info(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val browseId: String?,
val text: String
)

View File

@@ -0,0 +1,17 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.Entity
import androidx.room.PrimaryKey
@Entity
data class Playlist(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val name: String,
) {
companion object {
val Empty = Playlist(
id = 0,
name = ""
)
}
}

View File

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

View File

@@ -0,0 +1,24 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.*
data class PlaylistWithSongs(
@Embedded val playlist: Playlist,
@Relation(
entity = Song::class,
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = SortedSongInPlaylist::class,
parentColumn = "playlistId",
entityColumn = "songId"
)
)
val songs: List<SongWithInfo>
) {
companion object {
val Empty = PlaylistWithSongs(Playlist(-1, ""), emptyList())
val NotFound = PlaylistWithSongs(Playlist(-2, "Not found"), emptyList())
}
}

View File

@@ -0,0 +1,21 @@
package it.vfsfitvnm.vimusic.models
import androidx.compose.runtime.Immutable
import androidx.room.Entity
import androidx.room.Index
import androidx.room.PrimaryKey
@Immutable
@Entity(
indices = [
Index(
value = ["query"],
unique = true
)
]
)
data class SearchQuery(
@PrimaryKey(autoGenerate = true) val id: Long = 0,
val query: String
)

View File

@@ -0,0 +1,34 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.*
@Entity
data class Song(
@PrimaryKey val id: String,
val title: String,
val albumInfoId: Long?,
val durationText: String,
val thumbnailUrl: String?,
val likedAt: Long? = null,
val totalPlayTimeMs: Long = 0
) {
val formattedTotalPlayTime: String
get() {
val seconds = totalPlayTimeMs / 1000
val hours = seconds / 3600
return when {
hours == 0L -> "${seconds / 60}m"
hours < 24L -> "${hours}h"
else -> "${hours / 24}d"
}
}
fun toggleLike(): Song {
return copy(
likedAt = if (likedAt == null) System.currentTimeMillis() else null
)
}
}

View File

@@ -0,0 +1,31 @@
package it.vfsfitvnm.vimusic.models
import androidx.compose.runtime.Immutable
import androidx.room.ColumnInfo
import androidx.room.Entity
import androidx.room.ForeignKey
@Immutable
@Entity(
primaryKeys = ["songId", "playlistId"],
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["id"],
childColumns = ["songId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Playlist::class,
parentColumns = ["id"],
childColumns = ["playlistId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class SongInPlaylist(
@ColumnInfo(index = true) val songId: String,
@ColumnInfo(index = true) val playlistId: Long,
val position: Int
)

View File

@@ -0,0 +1,28 @@
package it.vfsfitvnm.vimusic.models
import androidx.compose.runtime.Immutable
import androidx.room.*
@Immutable
@Entity(
primaryKeys = ["songId", "authorInfoId"],
foreignKeys = [
ForeignKey(
entity = Song::class,
parentColumns = ["id"],
childColumns = ["songId"],
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = Info::class,
parentColumns = ["id"],
childColumns = ["authorInfoId"],
onDelete = ForeignKey.CASCADE
)
]
)
data class SongWithAuthors(
val songId: String,
@ColumnInfo(index = true) val authorInfoId: Long
)

View File

@@ -0,0 +1,25 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.Embedded
import androidx.room.Junction
import androidx.room.Relation
open class SongWithInfo(
@Embedded val song: Song,
@Relation(
entity = Info::class,
parentColumn = "albumInfoId",
entityColumn = "id"
) val album: Info?,
@Relation(
entity = Info::class,
parentColumn = "id",
entityColumn = "id",
associateBy = Junction(
value = SongWithAuthors::class,
parentColumn = "songId",
entityColumn = "authorInfoId"
)
)
val authors: List<Info>?
)

View File

@@ -0,0 +1,11 @@
package it.vfsfitvnm.vimusic.models
import androidx.room.ColumnInfo
import androidx.room.DatabaseView
@DatabaseView("SELECT * FROM SongInPlaylist ORDER BY position")
data class SortedSongInPlaylist(
@ColumnInfo(index = true) val songId: String,
@ColumnInfo(index = true) val playlistId: Long,
val position: Int
)

View File

@@ -0,0 +1,354 @@
package it.vfsfitvnm.vimusic.services
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.content.Context
import android.content.Intent
import android.graphics.Bitmap
import android.graphics.drawable.BitmapDrawable
import android.net.Uri
import android.os.Build
import android.os.Bundle
import androidx.annotation.DrawableRes
import androidx.annotation.StringRes
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.core.app.NotificationCompat
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import androidx.core.net.toUri
import androidx.media3.common.*
import androidx.media3.common.util.Util
import androidx.media3.database.StandaloneDatabaseProvider
import androidx.media3.datasource.DataSource
import androidx.media3.datasource.DefaultHttpDataSource
import androidx.media3.datasource.ResolvingDataSource
import androidx.media3.datasource.cache.CacheDataSource
import androidx.media3.datasource.cache.NoOpCacheEvictor
import androidx.media3.datasource.cache.SimpleCache
import androidx.media3.exoplayer.ExoPlayer
import androidx.media3.exoplayer.analytics.AnalyticsListener
import androidx.media3.exoplayer.analytics.PlaybackStats
import androidx.media3.exoplayer.analytics.PlaybackStatsListener
import androidx.media3.exoplayer.source.DefaultMediaSourceFactory
import androidx.media3.session.MediaController
import androidx.media3.session.MediaNotification
import androidx.media3.session.MediaNotification.ActionFactory
import androidx.media3.session.MediaSession
import androidx.media3.session.MediaSessionService
import coil.ImageLoader
import coil.request.ImageRequest
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.MainActivity
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.utils.RingBuffer
import it.vfsfitvnm.vimusic.utils.YoutubePlayer
import it.vfsfitvnm.vimusic.utils.insert
import it.vfsfitvnm.youtubemusic.Outcome
import kotlinx.coroutines.*
import kotlin.math.roundToInt
@ExperimentalAnimationApi
@ExperimentalFoundationApi
@ExperimentalTextApi
class PlayerService : MediaSessionService(), MediaSession.MediaItemFiller,
MediaNotification.Provider,
PlaybackStatsListener.Callback, Player.Listener,YoutubePlayer.Radio.Listener {
companion object {
private const val NotificationId = 1001
private const val NotificationChannelId = "default_channel_id"
}
private val cache: SimpleCache by lazy(LazyThreadSafetyMode.NONE) {
SimpleCache(cacheDir, NoOpCacheEvictor(), StandaloneDatabaseProvider(this))
}
private lateinit var mediaSession: MediaSession
private val notificationManager by lazy(LazyThreadSafetyMode.NONE) {
getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
}
private var lastArtworkUri: Uri? = null
private var lastBitmap: Bitmap? = null
private val coroutineScope = CoroutineScope(Dispatchers.IO) + Job()
override fun onCreate() {
super.onCreate()
createNotificationChannel()
setMediaNotificationProvider(this)
val player = ExoPlayer.Builder(this)
.setHandleAudioBecomingNoisy(true)
.setWakeMode(C.WAKE_MODE_LOCAL)
.setMediaSourceFactory(DefaultMediaSourceFactory(createDataSourceFactory()))
.setAudioAttributes(
AudioAttributes.Builder()
.setUsage(C.USAGE_MEDIA)
.setContentType(C.CONTENT_TYPE_MUSIC)
.build(),
true
)
.build()
.also { player ->
player.playWhenReady = true
player.addAnalyticsListener(PlaybackStatsListener(false, this))
}
mediaSession = MediaSession.Builder(this, player)
.withSessionActivity()
.setMediaItemFiller(this)
.build()
player.addListener(this)
YoutubePlayer.Radio.listener = this
}
override fun onDestroy() {
mediaSession.player.release()
mediaSession.release()
cache.release()
super.onDestroy()
}
override fun onGetSession(controllerInfo: MediaSession.ControllerInfo): MediaSession {
return mediaSession
}
override fun onPlaybackStatsReady(
eventTime: AnalyticsListener.EventTime,
playbackStats: PlaybackStats
) {
val mediaItem =
eventTime.timeline.getWindow(eventTime.windowIndex, Timeline.Window()).mediaItem
coroutineScope.launch(Dispatchers.IO) {
Database.insert(mediaItem)
Database.incrementTotalPlayTimeMs(mediaItem.mediaId, playbackStats.totalPlayTimeMs)
}
}
override fun process(play: Boolean) {
if (YoutubePlayer.Radio.isActive) {
coroutineScope.launch {
YoutubePlayer.Radio.process(mediaSession.player, play = play)
}
}
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
if (YoutubePlayer.Radio.isActive) {
coroutineScope.launch {
YoutubePlayer.Radio.process(mediaSession.player)
}
}
}
override fun fillInLocalConfiguration(
session: MediaSession,
controller: MediaSession.ControllerInfo,
mediaItem: MediaItem
): MediaItem {
return mediaItem.buildUpon()
.setUri(mediaItem.mediaId)
.setCustomCacheKey(mediaItem.mediaId)
.build()
}
override fun createNotification(
mediaController: MediaController,
actionFactory: ActionFactory,
onNotificationChangedCallback: MediaNotification.Provider.Callback
): MediaNotification {
fun NotificationCompat.Builder.addMediaAction(
@DrawableRes resId: Int,
@StringRes stringId: Int,
@Player.Command command: Long
): NotificationCompat.Builder {
return addAction(
actionFactory.createMediaAction(
IconCompat.createWithResource(this@PlayerService, resId),
getString(stringId),
command
)
)
}
val mediaMetadata = mediaController.mediaMetadata
val builder = NotificationCompat.Builder(applicationContext, NotificationChannelId)
.setContentTitle(mediaMetadata.title)
.setContentText(mediaMetadata.artist)
.addMediaAction(
R.drawable.play_skip_back,
R.string.media3_controls_seek_to_previous_description,
ActionFactory.COMMAND_SKIP_TO_PREVIOUS
).run {
if (mediaController.playbackState == Player.STATE_ENDED || !mediaController.playWhenReady) {
addMediaAction(
R.drawable.play,
R.string.media3_controls_play_description,
ActionFactory.COMMAND_PLAY
)
} else {
addMediaAction(
R.drawable.pause,
R.string.media3_controls_pause_description,
ActionFactory.COMMAND_PAUSE
)
}
}.addMediaAction(
R.drawable.play_skip_forward,
R.string.media3_controls_seek_to_next_description,
ActionFactory.COMMAND_SKIP_TO_NEXT
)
.setContentIntent(mediaController.sessionActivity)
.setDeleteIntent(
actionFactory.createMediaActionPendingIntent(
ActionFactory.COMMAND_STOP
)
)
.setAutoCancel(true)
.setOnlyAlertOnce(true)
.setShowWhen(false)
.setSmallIcon(R.drawable.app_icon)
.setOngoing(false)
.setStyle(
androidx.media.app.NotificationCompat.MediaStyle()
.setShowActionsInCompactView(0, 1, 2)
.setMediaSession(mediaSession.sessionCompatToken as android.support.v4.media.session.MediaSessionCompat.Token)
)
.setVisibility(NotificationCompat.VISIBILITY_PUBLIC)
if (lastArtworkUri == mediaMetadata.artworkUri) {
builder.setLargeIcon(lastBitmap)
} else {
val size = (96 * resources.displayMetrics.density).roundToInt()
builder.setLargeIcon(
resources.getDrawable(R.drawable.disc_placeholder, null)?.toBitmap(size, size)
)
ImageLoader(applicationContext)
.enqueue(
ImageRequest.Builder(applicationContext)
.listener { _, result ->
lastBitmap = (result.drawable as BitmapDrawable).bitmap
lastArtworkUri = mediaMetadata.artworkUri
onNotificationChangedCallback.onNotificationChanged(
MediaNotification(
NotificationId,
builder.setLargeIcon(lastBitmap).build()
)
)
}
.data("${mediaMetadata.artworkUri}-w${size}-h${size}")
.build()
)
}
return MediaNotification(NotificationId, builder.build())
}
override fun handleCustomAction(
mediaController: MediaController,
action: String,
extras: Bundle
) = Unit
private fun createNotificationChannel() {
if (Util.SDK_INT >= 26 && notificationManager.getNotificationChannel(NotificationChannelId) == null) {
notificationManager.createNotificationChannel(
NotificationChannel(
NotificationChannelId,
getString(R.string.default_notification_channel_name),
NotificationManager.IMPORTANCE_LOW
)
)
}
}
private fun createCacheDataSource(): DataSource.Factory {
return CacheDataSource.Factory().setCache(cache).apply {
setUpstreamDataSourceFactory(
DefaultHttpDataSource.Factory()
.setConnectTimeoutMs(16000)
.setReadTimeoutMs(8000)
.setUserAgent("Mozilla/5.0 (Windows NT 10.0; rv:91.0) Gecko/20100101 Firefox/91.0")
)
}
}
private fun createDataSourceFactory(): DataSource.Factory {
val chunkLength = 512 * 1024L
val ringBuffer = RingBuffer<Pair<String, Uri>?>(2) { null }
return ResolvingDataSource.Factory(createCacheDataSource()) { dataSpec ->
val videoId = dataSpec.key ?: error("A key must be set")
if (cache.isCached(videoId, dataSpec.position, chunkLength)) {
dataSpec
} else {
when (videoId) {
ringBuffer.getOrNull(0)?.first -> dataSpec.withUri(ringBuffer.getOrNull(0)!!.second)
ringBuffer.getOrNull(1)?.first -> dataSpec.withUri(ringBuffer.getOrNull(1)!!.second)
else -> {
val url = runBlocking(Dispatchers.IO) {
it.vfsfitvnm.youtubemusic.YouTube.player(videoId)
}.flatMap { body ->
when (val status = body.playabilityStatus.status) {
"OK" -> body.streamingData?.adaptiveFormats?.findLast { format ->
format.itag == 251 || format.itag == 140
}?.url?.let { Outcome.Success(it) } ?: Outcome.Error.Unhandled(
PlaybackException(
"Couldn't find a playable audio format",
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
)
else -> Outcome.Error.Unhandled(
PlaybackException(
status,
null,
PlaybackException.ERROR_CODE_REMOTE_ERROR
)
)
}
}
when (url) {
is Outcome.Success -> {
ringBuffer.append(videoId to url.value.toUri())
dataSpec.withUri(url.value.toUri())
.subrange(dataSpec.uriPositionOffset, chunkLength)
}
// TODO
is Outcome.Error.Network -> throw Error("no network")
is Outcome.Error.Unhandled -> throw url.throwable
else -> TODO("unreachable")
}
}
}
}
}
}
private fun MediaSession.Builder.withSessionActivity(): MediaSession.Builder {
return setSessionActivity(
PendingIntent.getActivity(
this@PlayerService,
0,
Intent(this@PlayerService, MainActivity::class.java),
if (Build.VERSION.SDK_INT >= 23) PendingIntent.FLAG_IMMUTABLE else 0
)
)
}
}

View File

@@ -0,0 +1,304 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.core.Animatable
import androidx.compose.animation.core.VectorConverter
import androidx.compose.foundation.clickable
import androidx.compose.foundation.gestures.DraggableState
import androidx.compose.foundation.gestures.Orientation
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.gestures.draggable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.drawBehind
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.RectangleShape
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
import androidx.compose.ui.input.nestedscroll.NestedScrollSource
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.IntOffset
import androidx.compose.ui.unit.Velocity
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.launch
import kotlin.math.absoluteValue
@Composable
@NonRestartableComposable
fun BottomSheet(
lowerBound: Dp,
upperBound: Dp,
modifier: Modifier = Modifier,
peekHeight: Dp = 0.dp,
elevation: Dp = 8.dp,
shape: Shape = RectangleShape,
handleOutsideInteractionsWhenExpanded: Boolean = false,
interactionSource: MutableInteractionSource? = null,
collapsedContent: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
) {
BottomSheet(
state = rememberBottomSheetState(lowerBound, upperBound),
modifier = modifier,
peekHeight = peekHeight,
elevation = elevation,
shape = shape,
handleOutsideInteractionsWhenExpanded = handleOutsideInteractionsWhenExpanded,
interactionSource = interactionSource,
collapsedContent = collapsedContent,
content = content
)
}
@Composable
fun BottomSheet(
state: BottomSheetState,
modifier: Modifier = Modifier,
peekHeight: Dp = 0.dp,
elevation: Dp = 8.dp,
shape: Shape = RectangleShape,
handleOutsideInteractionsWhenExpanded: Boolean = false,
interactionSource: MutableInteractionSource? = null,
collapsedContent: @Composable BoxScope.() -> Unit,
content: @Composable BoxScope.() -> Unit
) {
var lastOffset by remember {
mutableStateOf(state.value)
}
BackHandler(enabled = !state.isCollapsed, onBack = state.collapse)
Box {
if (handleOutsideInteractionsWhenExpanded && !state.isCollapsed) {
Spacer(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
state.collapse()
}
}
.draggable(
state = state,
onDragStarted = {
lastOffset = state.value
},
onDragStopped = { velocity ->
if (velocity.absoluteValue > 300 && lastOffset != state.value) {
if (lastOffset > state.value) {
state.collapse()
} else {
state.expand()
}
} else {
if (state.upperBound - state.value > state.value - state.lowerBound) {
state.collapse()
} else {
state.expand()
}
}
},
orientation = Orientation.Vertical
)
.drawBehind {
drawRect(color = Color.Black.copy(alpha = 0.5f * state.progress))
}
.fillMaxSize()
)
}
Box(
modifier = modifier
.offset {
val y = (state.upperBound - state.value + peekHeight)
.roundToPx()
.coerceAtLeast(0)
IntOffset(x = 0, y = y)
}
.shadow(elevation = elevation, shape = shape)
.clip(shape)
.draggable(
state = state,
interactionSource = interactionSource,
onDragStarted = {
lastOffset = state.value
},
onDragStopped = { velocity ->
if (velocity.absoluteValue > 300 && lastOffset != state.value) {
if (lastOffset > state.value) {
state.collapse()
} else {
state.expand()
}
} else {
if (state.upperBound - state.value > state.value - state.lowerBound) {
state.collapse()
} else {
state.expand()
}
}
},
orientation = Orientation.Vertical
)
.clickable(
enabled = !state.isRunning && state.isCollapsed,
indication = null,
interactionSource = interactionSource
?: remember { MutableInteractionSource() },
onClick = state.expand
)
.fillMaxSize()
) {
if (!state.isCollapsed) {
content()
}
collapsedContent()
}
}
}
@Stable
class BottomSheetState(
draggableState: DraggableState,
valueState: State<Dp>,
isRunningState: State<Boolean>,
isCollapsedState: State<Boolean>,
isExpandedState: State<Boolean>,
progressState: State<Float>,
val lowerBound: Dp,
val upperBound: Dp,
val collapse: () -> Unit,
val expand: () -> Unit,
) : DraggableState by draggableState {
val value by valueState
val isRunning by isRunningState
val isCollapsed by isCollapsedState
val isExpanded by isExpandedState
val progress by progressState
fun nestedScrollConnection(initialIsTopReached: Boolean = true): NestedScrollConnection {
return object : NestedScrollConnection {
var isTopReached = initialIsTopReached
override fun onPreScroll(available: Offset, source: NestedScrollSource): Offset {
if (isExpanded && available.y < 0) {
isTopReached = false
}
if (isTopReached) {
dispatchRawDelta(available.y)
return available
}
return Offset.Zero
}
override fun onPostScroll(
consumed: Offset,
available: Offset,
source: NestedScrollSource
): Offset {
if (!isTopReached) {
isTopReached = consumed.y == 0f && available.y > 0
}
return Offset.Zero
}
override suspend fun onPreFling(available: Velocity): Velocity {
if (isTopReached) {
coroutineScope {
if (available.y.absoluteValue > 1000) {
collapse()
} else {
if (upperBound - value > value - lowerBound) {
collapse()
} else {
expand()
}
}
}
return available
}
return Velocity.Zero
}
override suspend fun onPostFling(consumed: Velocity, available: Velocity): Velocity {
isTopReached = false
return super.onPostFling(consumed, available)
}
}
}
}
@Composable
fun rememberBottomSheetState(lowerBound: Dp, upperBound: Dp): BottomSheetState {
val density = LocalDensity.current
val coroutineScope = rememberCoroutineScope()
var wasExpanded by rememberSaveable {
mutableStateOf(false)
}
val animatable = remember(lowerBound, upperBound) {
Animatable(if (wasExpanded) upperBound else lowerBound, Dp.VectorConverter).also {
it.updateBounds(lowerBound, upperBound)
}
}
LaunchedEffect(animatable.value == upperBound) {
wasExpanded = animatable.value == upperBound
}
return remember(animatable, coroutineScope) {
BottomSheetState(
draggableState = DraggableState { delta ->
coroutineScope.launch {
animatable.snapTo(animatable.value - density.run { delta.toDp() })
}
},
valueState = animatable.asState(),
lowerBound = lowerBound,
upperBound = upperBound,
isRunningState = derivedStateOf {
animatable.isRunning
},
isCollapsedState = derivedStateOf {
animatable.value == lowerBound
},
isExpandedState = derivedStateOf {
animatable.value == upperBound
},
progressState = derivedStateOf {
1f - (upperBound - animatable.value) / (upperBound - lowerBound)
},
collapse = {
coroutineScope.launch {
animatable.animateTo(animatable.lowerBound!!)
}
},
expand = {
coroutineScope.launch {
animatable.animateTo(animatable.upperBound!!)
}
}
)
}
}

View File

@@ -0,0 +1,98 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
@Composable
fun ChunkyButton(
onClick: () -> Unit,
backgroundColor: Color,
modifier: Modifier = Modifier,
text: String? = null,
secondaryText: String? = null,
textStyle: TextStyle = TextStyle.Default,
secondaryTextStyle: TextStyle = TextStyle.Default,
rippleColor: Color = Color.Unspecified,
@DrawableRes icon: Int? = null,
shape: Shape = RoundedCornerShape(16.dp),
colorFilter: ColorFilter = ColorFilter.tint(rippleColor),
onMore: (() -> Unit)? = null
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = modifier
.clip(shape)
.background(backgroundColor)
.clickable(
indication = rememberRipple(bounded = true, color = rippleColor),
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
icon?.let { icon ->
Image(
painter = painterResource(icon),
contentDescription = null,
colorFilter = colorFilter,
modifier = Modifier
.size(20.dp)
)
}
text?.let { text ->
Column {
BasicText(
text = text,
style = textStyle
)
secondaryText?.let { secondaryText ->
BasicText(
text = secondaryText,
style = secondaryTextStyle
)
}
}
}
onMore?.let { onMore ->
Spacer(
modifier = Modifier
.background(rippleColor.copy(alpha = 0.6f))
.width(1.dp)
.height(24.dp)
)
Image(
// TODO: this is themed...
painter = painterResource(it.vfsfitvnm.vimusic.R.drawable.ellipsis_vertical),
contentDescription = null,
colorFilter = ColorFilter.tint(rippleColor.copy(alpha = 0.6f)),
modifier = Modifier
.clickable(onClick = onMore)
.size(20.dp)
)
}
}
}

View File

@@ -0,0 +1,50 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
@Composable
fun <T>ChipGroup(
items: List<ChipItem<T>>,
value: T,
selectedBackgroundColor: Color,
unselectedBackgroundColor: Color,
selectedTextStyle: TextStyle,
unselectedTextStyle: TextStyle,
modifier: Modifier = Modifier,
shape: Shape = RoundedCornerShape(16.dp),
onValueChanged: (T) -> Unit
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.horizontalScroll(rememberScrollState())
.then(modifier)
) {
items.forEach { chipItem ->
ChunkyButton(
text = chipItem.text,
textStyle = if (chipItem.value == value) selectedTextStyle else unselectedTextStyle,
backgroundColor = if (chipItem.value == value) selectedBackgroundColor else unselectedBackgroundColor,
shape = shape,
onClick = {
onValueChanged(chipItem.value)
}
)
}
}
}
data class ChipItem<T>(
val text: String,
val value: T
)

View File

@@ -0,0 +1,80 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.unit.dp
@Composable
fun ExpandableText(
text: String,
style: TextStyle,
showMoreTextStyle: TextStyle,
minimizedMaxLines: Int,
backgroundColor: Color,
modifier: Modifier = Modifier
) {
var isExpanded by remember {
mutableStateOf(false)
}
var hasVisualOverflow by remember {
mutableStateOf(true)
}
Column(
modifier = modifier
) {
Box {
BasicText(
text = text,
maxLines = if (isExpanded) Int.MAX_VALUE else minimizedMaxLines,
onTextLayout = {
hasVisualOverflow = it.hasVisualOverflow
},
style = style
)
if (hasVisualOverflow) {
Spacer(
modifier = Modifier
.align(Alignment.BottomCenter)
.fillMaxWidth()
.height(14.dp)
.background(
brush = Brush.verticalGradient(
colors = listOf(
backgroundColor.copy(alpha = 0.5f),
backgroundColor
)
)
)
)
}
}
BasicText(
text = if (isExpanded) "Less" else "More",
style = showMoreTextStyle,
modifier = Modifier
.align(Alignment.End)
.clickable(
indication = null,
interactionSource = remember { MutableInteractionSource() },
onClick = { isExpanded = !isExpanded }
)
)
}
}

View File

@@ -0,0 +1,77 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.activity.compose.BackHandler
import androidx.compose.animation.*
import androidx.compose.foundation.background
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.*
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.input.pointer.pointerInput
val LocalMenuState = compositionLocalOf<MenuState> { TODO() }
class MenuState(isDisplayedState: MutableState<Boolean>) {
var isDisplayed by isDisplayedState
private set
var content: @Composable () -> Unit = {}
fun display(content: @Composable () -> Unit) {
this.content = content
isDisplayed = true
}
fun hide() {
isDisplayed = false
}
}
@Composable
fun rememberMenuState(): MenuState {
val isDisplayedState = remember {
mutableStateOf(false)
}
return remember {
MenuState(
isDisplayedState = isDisplayedState
)
}
}
@Composable
fun BottomSheetMenu(
state: MenuState,
modifier: Modifier = Modifier
) {
AnimatedVisibility(
visible = state.isDisplayed,
enter = fadeIn(),
exit = fadeOut()
) {
BackHandler(onBack = state::hide)
Spacer(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures {
state.hide()
}
}
.background(Color.Black.copy(alpha = 0.5f))
.fillMaxSize()
)
}
AnimatedVisibility(
visible = state.isDisplayed,
enter = slideInVertically { it },
exit = slideOutVertically { it },
modifier = modifier
) {
state.content()
}
}

View File

@@ -0,0 +1,59 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.compose.animation.core.Animatable
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import kotlinx.coroutines.launch
@Composable
fun MusicBars(
color: Color,
modifier: Modifier = Modifier,
barWidth: Dp = 4.dp,
shape: Shape = CircleShape
) {
val animatablesWithSteps = remember {
listOf(
Animatable(0f) to listOf(0.2f, 0.8f, 0.1f, 0.1f, 0.3f, 0.1f, 0.2f, 0.8f, 0.7f, 0.2f, 0.4f, 0.9f, 0.7f, 0.6f, 0.1f, 0.3f, 0.1f, 0.4f, 0.1f, 0.8f, 0.7f, 0.9f, 0.5f, 0.6f, 0.3f, 0.1f),
Animatable(0f) to listOf(0.2f, 0.5f, 1.0f, 0.5f, 0.3f, 0.1f, 0.2f, 0.3f, 0.5f, 0.1f, 0.6f, 0.5f, 0.3f, 0.7f, 0.8f, 0.9f, 0.3f, 0.1f, 0.5f, 0.3f, 0.6f, 1.0f, 0.6f, 0.7f, 0.4f, 0.1f),
Animatable(0f) to listOf(0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.6f, 0.5f, 1.0f, 0.5f, 0.6f, 0.7f, 0.2f, 0.3f, 0.1f, 0.5f, 0.4f, 0.6f, 0.7f, 0.1f, 0.4f, 0.3f, 0.1f, 0.4f, 0.3f, 0.7f)
)
}
LaunchedEffect(Unit) {
animatablesWithSteps.forEach { (animatable, steps) ->
launch {
while (true) {
steps.forEach { step ->
animatable.animateTo(step)
}
}
}
}
}
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
verticalAlignment = Alignment.Bottom,
modifier = modifier
) {
animatablesWithSteps.forEach { (animatable) ->
Spacer(
modifier = Modifier
.background(color = color, shape = shape)
.fillMaxHeight(animatable.value)
.width(barWidth)
)
}
}
}

View File

@@ -0,0 +1,127 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.SideEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.italic
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.youtubemusic.Outcome
@Composable
inline fun <T> OutcomeItem(
outcome: Outcome<T>,
noinline onInitialize: (() -> Unit)? = null,
noinline onRetry: (() -> Unit)? = onInitialize,
onUninitialized: @Composable () -> Unit = {
onInitialize?.let {
SideEffect(it)
}
},
onLoading: @Composable () -> Unit = {},
onError: @Composable (Outcome.Error) -> Unit = {
Error(
error = it,
onRetry = onRetry,
)
},
onSuccess: @Composable (T) -> Unit
) {
when (outcome) {
is Outcome.Initial -> onUninitialized()
is Outcome.Loading -> onLoading()
is Outcome.Error -> onError(outcome)
is Outcome.Recovered -> onError(outcome.error)
is Outcome.Success -> onSuccess(outcome.value)
}
}
@Composable
fun Error(
error: Outcome.Error,
modifier: Modifier = Modifier,
onRetry: (() -> Unit)? = null
) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.alert_circle),
contentDescription = null,
colorFilter = ColorFilter.tint(Color(0xFFFC5F5F)),
modifier = Modifier
.padding(horizontal = 16.dp)
.size(48.dp)
)
BasicText(
text = when (error) {
is Outcome.Error.Network -> "Couldn't reach the Internet"
is Outcome.Error.Unhandled -> (error.throwable.message ?: error.throwable.toString())
},
style = LocalTypography.current.xxs.medium.secondary,
)
onRetry?.let { retry ->
BasicText(
text = "Retry",
style = LocalTypography.current.xxs.medium,
modifier = Modifier
.clickable(onClick = retry)
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
)
}
}
}
@Composable
fun Message(
text: String,
modifier: Modifier = Modifier,
@DrawableRes icon: Int = R.drawable.alert_circle
) {
Column(
verticalArrangement = Arrangement.spacedBy(
space = 8.dp,
alignment = Alignment.CenterVertically
),
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.fillMaxSize()
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(icon),
contentDescription = null,
colorFilter = ColorFilter.tint(LocalColorPalette.current.darkGray),
modifier = Modifier
.padding(horizontal = 16.dp)
.size(36.dp)
)
BasicText(
text = text,
style = LocalTypography.current.xs.medium.secondary.italic,
)
}
}

View File

@@ -0,0 +1,23 @@
package it.vfsfitvnm.vimusic.ui.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
@Composable
inline fun TopAppBar(
modifier: Modifier = Modifier,
content: @Composable RowScope.() -> Unit
) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = modifier
.fillMaxWidth(),
content = content
)
}

View File

@@ -0,0 +1,217 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.window.Dialog
import it.vfsfitvnm.vimusic.ui.components.ChunkyButton
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.delay
@Composable
fun TextFieldDialog(
hintText: String,
onDismiss: () -> Unit,
onDone: (String) -> Unit,
modifier: Modifier = Modifier,
cancelText: String = "Cancel",
doneText: String = "Done",
initialTextInput: String = "",
onCancel: () -> Unit = onDismiss,
isTextInputValid: (String) -> Boolean = { it.isNotEmpty() }
) {
val focusRequester = remember {
FocusRequester()
}
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(
text = initialTextInput,
selection = TextRange(initialTextInput.length)
)
)
}
DefaultDialog(
onDismiss = onDismiss,
modifier = modifier
) {
BasicTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
},
textStyle = typography.xs.semiBold.center,
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Done),
keyboardActions = KeyboardActions(
onDone = {
if (isTextInputValid(textFieldValue.text)) {
onDismiss()
onDone(textFieldValue.text)
}
}
),
cursorBrush = SolidColor(colorPalette.text),
decorationBox = { innerTextField ->
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.weight(1f)
) {
androidx.compose.animation.AnimatedVisibility(
visible = textFieldValue.text.isEmpty(),
enter = fadeIn(tween(100)),
exit = fadeOut(tween(100)),
) {
BasicText(
text = hintText,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = typography.xs.semiBold.secondary,
)
}
innerTextField()
}
},
modifier = Modifier
.padding(all = 16.dp)
.focusRequester(focusRequester)
)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
) {
ChunkyButton(
backgroundColor = colorPalette.lightBackground,
text = cancelText,
textStyle = typography.xs.semiBold,
shape = RoundedCornerShape(36.dp),
onClick = onCancel
)
ChunkyButton(
backgroundColor = colorPalette.primaryContainer,
text = doneText,
textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer),
shape = RoundedCornerShape(36.dp),
onClick = {
if (isTextInputValid(textFieldValue.text)) {
onDismiss()
onDone(textFieldValue.text)
}
}
)
}
}
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
}
@Composable
fun ConfirmationDialog(
text: String,
onDismiss: () -> Unit,
onConfirm: () -> Unit,
modifier: Modifier = Modifier,
cancelText: String = "Cancel",
confirmText: String = "Confirm",
onCancel: () -> Unit = onDismiss
) {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
DefaultDialog(
onDismiss = onDismiss,
modifier = modifier
) {
BasicText(
text = text,
style = typography.xs.semiBold.center,
modifier = Modifier
.padding(all = 16.dp)
)
Row(
horizontalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
) {
ChunkyButton(
backgroundColor = colorPalette.lightBackground,
text = cancelText,
textStyle = typography.xs.semiBold,
shape = RoundedCornerShape(36.dp),
onClick = onCancel
)
ChunkyButton(
backgroundColor = colorPalette.primaryContainer,
text = confirmText,
textStyle = typography.xs.semiBold.color(colorPalette.onPrimaryContainer),
shape = RoundedCornerShape(36.dp),
onClick = {
onConfirm()
onDismiss()
}
)
}
}
}
@Composable
private inline fun DefaultDialog(
noinline onDismiss: () -> Unit,
modifier: Modifier = Modifier,
crossinline content: @Composable ColumnScope.() -> Unit
) {
Dialog(
onDismissRequest = onDismiss
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
.padding(all = 48.dp)
.background(
color = LocalColorPalette.current.lightBackground,
shape = RoundedCornerShape(8.dp)
)
.padding(horizontal = 24.dp, vertical = 16.dp),
content = content
)
}
}

View File

@@ -0,0 +1,488 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.with
import androidx.compose.foundation.gestures.detectTapGestures
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Modifier
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.LocalContext
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.empty
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.screens.rememberAlbumRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberArtistRoute
import it.vfsfitvnm.vimusic.ui.screens.rememberCreatePlaylistRoute
import it.vfsfitvnm.vimusic.utils.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun InFavoritesMediaItemMenu(
song: SongWithInfo,
modifier: Modifier = Modifier,
// https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
) {
val coroutineScope = rememberCoroutineScope()
NonQueuedMediaItemMenu(
mediaItem = song.asMediaItem,
onDismiss = onDismiss,
onRemoveFromFavorites = {
coroutineScope.launch(Dispatchers.IO) {
Database.update(song.song.toggleLike())
}
},
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun InHistoryMediaItemMenu(
song: SongWithInfo,
modifier: Modifier = Modifier,
// https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
) {
val coroutineScope = rememberCoroutineScope()
var isDeletingFromDatabase by remember {
mutableStateOf(false)
}
if (isDeletingFromDatabase) {
ConfirmationDialog(
text = "Do you really want to permanently delete this song? It will removed from any playlist as well.\nThis action is irreversible.",
onDismiss = {
isDeletingFromDatabase = false
},
onConfirm = {
onDismiss()
coroutineScope.launch(Dispatchers.IO) {
Database.delete(song.song)
}
}
)
}
NonQueuedMediaItemMenu(
mediaItem = song.asMediaItem,
onDismiss = onDismiss,
onDeleteFromDatabase = {
isDeletingFromDatabase = true
},
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun InPlaylistMediaItemMenu(
playlistId: Long,
positionInPlaylist: Int,
song: SongWithInfo,
modifier: Modifier = Modifier,
// https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide }
) {
val coroutineScope = rememberCoroutineScope()
NonQueuedMediaItemMenu(
mediaItem = song.asMediaItem,
onDismiss = onDismiss,
onRemoveFromPlaylist = {
coroutineScope.launch(Dispatchers.IO) {
Database.internal.runInTransaction {
Database.delete(
SongInPlaylist(
songId = song.song.id,
playlistId = playlistId,
position = positionInPlaylist
)
)
Database.decrementSongPositions(
playlistId = playlistId,
fromPosition = positionInPlaylist + 1
)
}
}
},
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun NonQueuedMediaItemMenu(
mediaItem: MediaItem,
modifier: Modifier = Modifier,
// https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide },
onRemoveFromPlaylist: (() -> Unit)? = null,
onDeleteFromDatabase: (() -> Unit)? = null,
onRemoveFromFavorites: (() -> Unit)? = null,
) {
val player = LocalYoutubePlayer.current
BaseMediaItemMenu(
mediaItem = mediaItem,
onDismiss = onDismiss,
onStartRadio = {
val playlistId = mediaItem.mediaMetadata.extras?.getString("playlistId")
YoutubePlayer.Radio.setup(playlistId = playlistId)
player?.mediaController?.forcePlay(mediaItem)
},
onPlayNext = if (player?.playbackState == Player.STATE_READY) ({
player.mediaController.addNext(mediaItem)
}) else null,
onEnqueue = if (player?.playbackState == Player.STATE_READY) ({
player.mediaController.enqueue(mediaItem)
}) else null,
onRemoveFromPlaylist = onRemoveFromPlaylist,
onDeleteFromDatabase = onDeleteFromDatabase,
onRemoveFromFavorites = onRemoveFromFavorites,
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun QueuedMediaItemMenu(
mediaItem: MediaItem,
indexInQueue: Int,
modifier: Modifier = Modifier,
// https://issuetracker.google.com/issues/226410236
onDismiss: () -> Unit = LocalMenuState.current.let { it::hide },
onGlobalRouteEmitted: (() -> Unit)? = null
) {
val player = LocalYoutubePlayer.current
BaseMediaItemMenu(
mediaItem = mediaItem,
onDismiss = onDismiss,
onRemoveFromQueue = if (player?.mediaItemIndex != indexInQueue) ({
player?.mediaController?.removeMediaItem(indexInQueue)
}) else null,
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun BaseMediaItemMenu(
mediaItem: MediaItem,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onStartRadio: (() -> Unit)? = null,
onPlayNext: (() -> Unit)? = null,
onEnqueue: (() -> Unit)? = null,
onRemoveFromQueue: (() -> Unit)? = null,
onRemoveFromPlaylist: (() -> Unit)? = null,
onDeleteFromDatabase: (() -> Unit)? = null,
onRemoveFromFavorites: (() -> Unit)? = null,
onGlobalRouteEmitted: (() -> Unit)? = null,
) {
val context = LocalContext.current
val coroutineScope = rememberCoroutineScope()
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
MediaItemMenu(
mediaItem = mediaItem,
onDismiss = onDismiss,
onStartRadio = onStartRadio,
onPlayNext = onPlayNext,
onEnqueue = onEnqueue,
onAddToPlaylist = { playlist, position ->
coroutineScope.launch(Dispatchers.IO) {
val playlistId = Database.playlist(playlist.id)?.id ?: Database.insert(playlist)
if (Database.song(mediaItem.mediaId) == null) {
Database.insert(mediaItem)
}
Database.insert(
SongInPlaylist(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = position
)
)
}
},
onDeleteFromDatabase = onDeleteFromDatabase,
onRemoveFromFavorites = onRemoveFromFavorites,
onRemoveFromPlaylist = onRemoveFromPlaylist,
onRemoveFromQueue = onRemoveFromQueue,
onGoToAlbum = albumRoute::global,
onGoToArtist = artistRoute::global,
onShare = {
context.shareAsYouTubeSong(mediaItem)
},
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun MediaItemMenu(
mediaItem: MediaItem,
onDismiss: () -> Unit,
modifier: Modifier = Modifier,
onStartRadio: (() -> Unit)? = null,
onPlayNext: (() -> Unit)? = null,
onEnqueue: (() -> Unit)? = null,
onDeleteFromDatabase: (() -> Unit)? = null,
onRemoveFromQueue: (() -> Unit)? = null,
onRemoveFromFavorites: (() -> Unit)? = null,
onRemoveFromPlaylist: (() -> Unit)? = null,
onAddToPlaylist: ((Playlist, Int) -> Unit)? = null,
onGoToAlbum: ((String) -> Unit)? = null,
onGoToArtist: ((String) -> Unit)? = null,
onShare: (() -> Unit)? = null,
onGlobalRouteEmitted: (() -> Unit)? = null,
) {
val playlistPreviews by remember {
Database.playlistPreviews()
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
val viewPlaylistsRoute = rememberCreatePlaylistRoute()
Menu(
modifier = modifier
) {
RouteHandler(
transitionSpec = {
when (targetState.route) {
viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left)
else -> when (initialState.route) {
viewPlaylistsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right)
else -> empty
}
}
}
) {
viewPlaylistsRoute {
var isCreatingNewPlaylist by rememberSaveable {
mutableStateOf(false)
}
if (isCreatingNewPlaylist && onAddToPlaylist != null) {
TextFieldDialog(
hintText = "Enter the playlist name",
onDismiss = {
isCreatingNewPlaylist = false
},
onDone = { text ->
onDismiss()
onAddToPlaylist(Playlist(name = text), 0)
}
)
}
Column {
Row(
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.fillMaxWidth()
) {
MenuBackButton(onClick = pop)
if (onAddToPlaylist != null) {
MenuIconButton(
icon = R.drawable.add,
onClick = {
isCreatingNewPlaylist = true
}
)
}
}
onAddToPlaylist?.let { onAddToPlaylist ->
playlistPreviews.forEach { playlistPreview ->
MenuEntry(
icon = R.drawable.list,
text = playlistPreview.playlist.name,
secondaryText = "${playlistPreview.songCount} songs",
onClick = {
onDismiss()
onAddToPlaylist(
playlistPreview.playlist,
playlistPreview.songCount
)
}
)
}
}
}
}
host {
Column(
modifier = Modifier
.pointerInput(Unit) {
detectTapGestures { }
}
) {
MenuCloseButton(onClick = onDismiss)
onStartRadio?.let { onStartRadio ->
MenuEntry(
icon = R.drawable.radio,
text = "Start radio",
onClick = {
onDismiss()
onStartRadio()
}
)
}
onPlayNext?.let { onPlayNext ->
MenuEntry(
icon = R.drawable.play,
text = "Play next",
onClick = {
onDismiss()
onPlayNext()
}
)
}
onEnqueue?.let { onEnqueue ->
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
onClick = {
onDismiss()
onEnqueue()
}
)
}
onRemoveFromQueue?.let { onRemoveFromQueue ->
MenuEntry(
icon = R.drawable.trash,
text = "Remove",
onClick = {
onDismiss()
onRemoveFromQueue()
}
)
}
onRemoveFromFavorites?.let { onRemoveFromFavorites ->
MenuEntry(
icon = R.drawable.heart_dislike,
text = "Dislike",
onClick = {
onDismiss()
onRemoveFromFavorites()
}
)
}
onRemoveFromPlaylist?.let { onRemoveFromPlaylist ->
MenuEntry(
icon = R.drawable.trash,
text = "Remove",
onClick = {
onDismiss()
onRemoveFromPlaylist()
}
)
}
if (onAddToPlaylist != null) {
MenuEntry(
icon = R.drawable.list,
text = "Add to playlist",
onClick = {
viewPlaylistsRoute()
}
)
}
onGoToAlbum?.let { onGoToAlbum ->
mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId ->
MenuEntry(
icon = R.drawable.disc,
text = "Go to album",
onClick = {
onDismiss()
onGlobalRouteEmitted?.invoke()
onGoToAlbum(albumId)
}
)
}
}
onGoToArtist?.let { onGoToArtist ->
mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")
?.let { artistNames ->
mediaItem.mediaMetadata.extras?.getStringArrayList("artistIds")
?.let { artistIds ->
artistNames.zip(artistIds)
.forEach { (authorName, authorId) ->
if (authorId != null) {
MenuEntry(
icon = R.drawable.person,
text = "More of $authorName",
onClick = {
onDismiss()
onGlobalRouteEmitted?.invoke()
onGoToArtist(authorId)
}
)
}
}
}
}
}
onShare?.let { onShare ->
MenuEntry(
icon = R.drawable.share_social,
text = "Share",
onClick = {
onDismiss()
onShare()
}
)
}
onDeleteFromDatabase?.let { onDeleteFromDatabase ->
MenuEntry(
icon = R.drawable.trash,
text = "Delete",
onClick = {
onDeleteFromDatabase()
}
)
}
}
}
}
}
}

View File

@@ -0,0 +1,152 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.annotation.DrawableRes
import androidx.compose.foundation.*
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.semiBold
@Composable
inline fun Menu(
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
val colorPalette = LocalColorPalette.current
Column(
modifier = modifier
.verticalScroll(rememberScrollState())
.width(256.dp)
.background(
color = colorPalette.elevatedBackground,
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp)
)
.padding(vertical = 8.dp),
content = content
)
}
@Composable
inline fun BasicMenu(
noinline onDismiss: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable ColumnScope.() -> Unit
) {
Menu(modifier = modifier) {
MenuCloseButton(onClick = onDismiss)
content()
}
}
@Composable
fun MenuEntry(
@DrawableRes icon: Int,
text: String,
onClick: () -> Unit,
secondaryText: String? = null,
enabled: Boolean = true,
) {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(24.dp),
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
enabled = enabled,
onClick = onClick
)
.fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 16.dp)
) {
Image(
painter = painterResource(icon),
contentDescription = null,
colorFilter = ColorFilter.tint(if (enabled) colorPalette.textSecondary else colorPalette.textDisabled),
modifier = Modifier
.size(18.dp)
)
Column {
BasicText(
text = text,
style = typography.xs.semiBold.color(if (enabled) colorPalette.text else colorPalette.textDisabled)
)
secondaryText?.let { secondaryText ->
BasicText(
text = secondaryText,
style = typography.xxs.semiBold.color(if (enabled) colorPalette.textSecondary else colorPalette.textDisabled)
)
}
}
}
}
@Composable
fun MenuIconButton(
@DrawableRes icon: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
val colorPalette = LocalColorPalette.current
Box(
modifier = modifier
.padding(horizontal = 12.dp)
) {
Image(
painter = painterResource(icon),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = onClick)
.padding(horizontal = 8.dp, vertical = 16.dp)
.size(20.dp)
)
}
}
@Composable
fun MenuCloseButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
MenuIconButton(
icon = R.drawable.close,
onClick = onClick,
modifier = modifier
)
}
@Composable
fun MenuBackButton(
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
MenuIconButton(
icon = R.drawable.chevron_back,
onClick = onClick,
modifier = modifier
)
}

View File

@@ -0,0 +1,30 @@
package it.vfsfitvnm.vimusic.ui.components.themed
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import kotlin.random.Random
@Composable
fun TextPlaceholder(
modifier: Modifier = Modifier
) {
Spacer(
modifier = modifier
.padding(vertical = 4.dp)
.background(
color = LocalColorPalette.current.darkGray,
shape = RoundedCornerShape(0.dp)
)
.fillMaxWidth(remember { 0.25f + Random.nextFloat() * 0.5f })
.height(16.dp)
)
}

View File

@@ -0,0 +1,367 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun AlbumScreen(
browseId: String,
) {
val scrollState = rememberScrollState()
var album by remember {
mutableStateOf<Outcome<YouTube.Album>>(Outcome.Loading)
}
val onLoad = relaunchableEffect(Unit) {
album = withContext(Dispatchers.IO) {
YouTube.album(browseId)
}
}
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val menuState = LocalMenuState.current
val (thumbnailSizeDp, thumbnailSizePx) = remember {
density.run {
128.dp to 128.dp.roundToPx()
}
}
val coroutineScope = rememberCoroutineScope()
Column(
modifier = Modifier
.verticalScroll(scrollState)
.padding(bottom = 72.dp)
.background(colorPalette.background)
.fillMaxSize()
) {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = pop)
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
menuState.display {
Menu {
MenuCloseButton(onClick = menuState::hide)
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
enabled = player?.playbackState == Player.STATE_READY,
onClick = {
menuState.hide()
album.valueOrNull?.let { album ->
player?.mediaController?.enqueue(album.items.mapNotNull { song ->
song.toMediaItem(browseId, album)
})
}
}
)
MenuEntry(
icon = R.drawable.list,
text = "Import as playlist",
onClick = {
menuState.hide()
album.valueOrNull?.let { album ->
coroutineScope.launch(Dispatchers.IO) {
Database.internal.runInTransaction {
val playlistId = Database.insert(Playlist(name = album.title))
album.items.forEachIndexed { index, song ->
song.toMediaItem(browseId, album)?.let { mediaItem ->
if (Database.song(mediaItem.mediaId) == null) {
Database.insert(mediaItem)
}
Database.insert(
SongInPlaylist(
songId = mediaItem.mediaId,
playlistId = playlistId,
position = index
)
)
}
}
}
}
}
}
)
}
}
}
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
OutcomeItem(
outcome = album,
onRetry = onLoad,
onLoading = {
Loading()
}
) { album ->
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.fillMaxWidth()
.height(IntrinsicSize.Max)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
AsyncImage(
model = album.thumbnail.size(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.size(thumbnailSizeDp)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxSize()
) {
Column {
BasicText(
text = album.title,
style = typography.m.semiBold
)
BasicText(
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
style = typography.xs.secondary.semiBold,
maxLines = 2,
overflow = TextOverflow.Ellipsis,
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.align(Alignment.End)
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(
album.items.shuffled().mapNotNull { song ->
song.toMediaItem(browseId, album)
})
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(album.items.mapNotNull { song ->
song.toMediaItem(browseId, album)
})
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
}
}
}
album.items.forEachIndexed { index, song ->
SongItem(
title = song.info.name,
authors = (song.authors ?: album.authors).joinToString("") { it.name },
durationText = song.durationText,
onClick = {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayAtIndex(album.items.mapNotNull { song ->
song.toMediaItem(browseId, album)
}, index)
},
startContent = {
BasicText(
text = "${index + 1}",
style = typography.xs.secondary.bold.center,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.width(36.dp)
)
},
menuContent = {
NonQueuedMediaItemMenu(
mediaItem = song.toMediaItem(browseId, album) ?: return@SongItem,
onDismiss = menuState::hide,
)
}
)
}
}
}
}
}
}
@Composable
private fun Loading() {
val colorPalette = LocalColorPalette.current
Column(
modifier = Modifier
.shimmer()
) {
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.height(IntrinsicSize.Max)
.padding(vertical = 8.dp, horizontal = 16.dp)
.padding(bottom = 16.dp)
) {
Spacer(
modifier = Modifier
.background(color = colorPalette.darkGray)
.size(128.dp)
)
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxHeight()
) {
Column {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
repeat(3) {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = Modifier
.alpha(0.6f - it * 0.1f)
.height(54.dp)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.size(36.dp)
) {
Spacer(
modifier = Modifier
.size(8.dp)
.background(color = colorPalette.darkGray, shape = CircleShape)
)
}
Column(
verticalArrangement = Arrangement.spacedBy(4.dp)
) {
TextPlaceholder()
TextPlaceholder(
modifier = Modifier
.alpha(0.7f)
)
}
}
}
}
}

View File

@@ -0,0 +1,220 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateContentSize
import androidx.compose.foundation.*
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.ExpandableText
import it.vfsfitvnm.vimusic.ui.components.Message
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun ArtistScreen(
browseId: String,
) {
val scrollState = rememberScrollState()
var artist by remember {
mutableStateOf<Outcome<YouTube.Artist>>(Outcome.Loading)
}
val onLoad = relaunchableEffect(Unit) {
artist = withContext(Dispatchers.IO) {
YouTube.artist(browseId)
}
}
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val density = LocalDensity.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val (thumbnailSizeDp, thumbnailSizePx) = remember {
density.run {
192.dp to 192.dp.roundToPx()
}
}
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.verticalScroll(scrollState)
.padding(bottom = 72.dp)
.background(colorPalette.background)
.fillMaxSize()
) {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = pop)
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
}
OutcomeItem(
outcome = artist,
onRetry = onLoad,
onLoading = {
Loading()
}
) { artist ->
AsyncImage(
model = artist.thumbnail?.size(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.clip(CircleShape)
.size(thumbnailSizeDp)
)
BasicText(
text = artist.name,
style = typography.l.semiBold,
modifier = Modifier
.padding(vertical = 8.dp, horizontal = 16.dp)
)
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
artist.shuffleEndpoint?.let(YoutubePlayer.Radio::setup)
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.radio),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
artist.radioEndpoint?.let(YoutubePlayer.Radio::setup)
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(color = colorPalette.elevatedBackground, shape = CircleShape)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
}
artist.description?.let { description ->
ExpandableText(
text = description,
style = typography.xxs.secondary.align(TextAlign.Justify),
minimizedMaxLines = 4,
backgroundColor = colorPalette.background,
showMoreTextStyle = typography.xxs.bold,
modifier = Modifier
.animateContentSize()
.padding(horizontal = 16.dp)
)
}
Message(
text = "Page under construction",
icon = R.drawable.sad,
modifier = Modifier
.padding(vertical = 64.dp)
)
}
}
}
}
}
@Composable
private fun Loading() {
val colorPalette = LocalColorPalette.current
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.shimmer()
) {
Spacer(
modifier = Modifier
.background(color = colorPalette.darkGray, shape = CircleShape)
.size(192.dp)
)
TextPlaceholder(
modifier = Modifier
.alpha(0.9f)
.padding(vertical = 8.dp, horizontal = 16.dp)
)
repeat(3) {
TextPlaceholder(
modifier = Modifier
.alpha(0.8f)
.padding(horizontal = 16.dp)
)
}
}
}

View File

@@ -0,0 +1,422 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyHorizontalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import androidx.media3.common.Player
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.rememberRoute
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.enums.SongCollection
import it.vfsfitvnm.vimusic.models.Playlist
import it.vfsfitvnm.vimusic.models.SearchQuery
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.rememberBottomSheetState
import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.PlayerView
import it.vfsfitvnm.vimusic.ui.views.PlaylistPreviewItem
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun HomeScreen(intentVideoId: String?) {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val coroutineScope = rememberCoroutineScope()
val lazyListState = rememberLazyListState()
val intentVideoRoute = rememberIntentVideoRoute(intentVideoId)
val playlistRoute = rememberLocalPlaylistRoute()
val searchRoute = rememberSearchRoute()
val searchResultRoute = rememberSearchResultRoute()
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
val (route, onRouteChanged) = rememberRoute(intentVideoId?.let { intentVideoRoute })
val playlistPreviews by remember {
Database.playlistPreviews()
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
val preferences = LocalPreferences.current
val songCollection by remember(preferences.homePageSongCollection) {
when (preferences.homePageSongCollection) {
SongCollection.MostPlayed -> Database.mostPlayed()
SongCollection.Favorites -> Database.favorites()
SongCollection.History -> Database.history()
}
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
BoxWithConstraints(
modifier = Modifier
.fillMaxSize()
) {
RouteHandler(
route = route,
onRouteChanged = onRouteChanged,
listenToGlobalEmitter = true
) {
intentVideoRoute { videoId ->
IntentVideoScreen(
videoId = videoId ?: error("videoId must be not null")
)
}
playlistRoute { playlistId ->
LocalPlaylistScreen(
playlistId = playlistId ?: error("playlistId cannot be null")
)
}
searchResultRoute { query ->
SearchResultScreen(
query = query,
onSearchAgain = {
searchRoute(query)
},
)
}
searchRoute { initialTextInput ->
SearchScreen(
initialTextInput = initialTextInput,
onSearch = { query ->
searchResultRoute(query)
coroutineScope.launch(Dispatchers.IO) {
Database.insert(SearchQuery(query = query))
}
}
)
}
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val player = LocalYoutubePlayer.current
val menuState = LocalMenuState.current
val density = LocalDensity.current
val thumbnailSize = remember {
density.run {
54.dp.roundToPx()
}
}
var isCreatingANewPlaylist by rememberSaveable {
mutableStateOf(false)
}
if (isCreatingANewPlaylist) {
TextFieldDialog(
hintText = "Enter the playlist name",
onDismiss = {
isCreatingANewPlaylist = false
},
onDone = { text ->
coroutineScope.launch(Dispatchers.IO) {
Database.insert(Playlist(name = text))
}
}
)
}
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = 72.dp),
modifier = Modifier
.background(colorPalette.background)
.fillMaxSize()
) {
item {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Spacer(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
Image(
painter = painterResource(R.drawable.search),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
searchRoute("")
}
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
}
item {
BasicText(
text = "Your playlists",
style = typography.m.semiBold,
modifier = Modifier
.padding(horizontal = 16.dp)
)
}
item {
LazyHorizontalGrid(
rows = GridCells.Fixed(2),
contentPadding = PaddingValues(horizontal = 16.dp),
modifier = Modifier
.height(248.dp)
) {
item {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.padding(all = 8.dp)
.width(108.dp)
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }
) {
isCreatingANewPlaylist = true
}
.background(colorPalette.lightBackground)
.size(108.dp)
) {
Image(
painter = painterResource(R.drawable.add),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.size(24.dp)
)
}
}
}
items(playlistPreviews) { playlistPreview ->
PlaylistPreviewItem(
playlistPreview = playlistPreview,
modifier = Modifier
.padding(all = 8.dp)
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }
) {
playlistRoute(playlistPreview.playlist.id)
}
)
}
}
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.zIndex(1f)
.padding(horizontal = 8.dp)
.padding(top = 32.dp)
) {
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.weight(1f)
.padding(horizontal = 8.dp)
) {
BasicText(
text = when (preferences.homePageSongCollection) {
SongCollection.MostPlayed -> "Most played"
SongCollection.Favorites -> "Favorites"
SongCollection.History -> "History"
},
style = typography.m.semiBold,
modifier = Modifier
.animateContentSize()
)
Image(
painter = painterResource(R.drawable.repeat),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable {
val values = SongCollection.values()
preferences.homePageSongCollection =
values[(preferences.homePageSongCollection.ordinal + 1) % values.size]
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(16.dp)
)
}
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
menuState.display {
BasicMenu(onDismiss = menuState::hide) {
MenuEntry(
icon = R.drawable.play,
text = "Play",
enabled = songCollection.isNotEmpty(),
onClick = {
menuState.hide()
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(
songCollection
.map(SongWithInfo::asMediaItem)
)
}
)
MenuEntry(
icon = R.drawable.shuffle,
text = "Shuffle",
enabled = songCollection.isNotEmpty(),
onClick = {
menuState.hide()
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(
songCollection
.shuffled()
.map(SongWithInfo::asMediaItem)
)
}
)
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
enabled = songCollection.isNotEmpty() && player?.playbackState == Player.STATE_READY,
onClick = {
menuState.hide()
player?.mediaController?.enqueue(
songCollection.map(SongWithInfo::asMediaItem)
)
}
)
}
}
}
.padding(horizontal = 8.dp, vertical = 8.dp)
.size(20.dp)
)
}
}
itemsIndexed(
items = songCollection,
key = { _, song ->
song.song.id
}
) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
onClick = {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayAtIndex(
songCollection.map(SongWithInfo::asMediaItem),
index
)
},
menuContent = {
when (preferences.homePageSongCollection) {
SongCollection.MostPlayed -> NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
SongCollection.Favorites -> InFavoritesMediaItemMenu(song = song)
SongCollection.History -> InHistoryMediaItemMenu(song = song)
}
},
onThumbnailContent = {
AnimatedVisibility(
visible = preferences.homePageSongCollection == SongCollection.MostPlayed,
enter = fadeIn(),
exit = fadeOut(),
modifier = Modifier
.align(Alignment.BottomCenter)
) {
BasicText(
text = song.song.formattedTotalPlayTime,
style = typography.xxs.semiBold.center.color(Color.White),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.75f)
)
)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}
)
}
}
}
}
PlayerView(
layoutState = rememberBottomSheetState(lowerBound = 64.dp, upperBound = maxHeight),
modifier = Modifier
.align(Alignment.BottomCenter)
)
}
}

View File

@@ -0,0 +1,104 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
import it.vfsfitvnm.vimusic.utils.asMediaItem
import it.vfsfitvnm.vimusic.utils.forcePlay
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.toNullable
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun IntentVideoScreen(videoId: String) {
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val colorPalette = LocalColorPalette.current
val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val mediaItem by produceState<Outcome<MediaItem>>(initialValue = Outcome.Loading) {
value = withContext(Dispatchers.IO) {
Database.songWithInfo(videoId)?.let { songWithInfo ->
Outcome.Success(songWithInfo.asMediaItem)
} ?: YouTube.getQueue(videoId).toNullable()
?.map(YouTube.Item.Song::asMediaItem)
?: Outcome.Error.Network
}
}
Column(
modifier = Modifier
.background(colorPalette.background)
.fillMaxSize()
) {
OutcomeItem(
outcome = mediaItem,
onLoading = {
SmallSongItemShimmer(
shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.View),
thumbnailSizeDp = 54.dp,
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
) { mediaItem ->
SongItem(
mediaItem = mediaItem,
thumbnailSize = remember {
density.run {
54.dp.roundToPx()
}
},
onClick = {
player?.mediaController?.forcePlay(mediaItem)
pop()
},
menuContent = {
NonQueuedMediaItemMenu(mediaItem = mediaItem)
}
)
}
}
}
}
}

View File

@@ -0,0 +1,337 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.shadow
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.PlaylistWithSongs
import it.vfsfitvnm.vimusic.models.SongInPlaylist
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.components.themed.*
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.*
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun LocalPlaylistScreen(
playlistId: Long,
) {
val playlistWithSongs by remember(playlistId) {
Database.playlistWithSongs(playlistId).map { it ?: PlaylistWithSongs.NotFound }
}.collectAsState(initial = PlaylistWithSongs.Empty, context = Dispatchers.IO)
val lazyListState = rememberLazyListState()
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val density = LocalDensity.current
val hapticFeedback = LocalHapticFeedback.current
val menuState = LocalMenuState.current
val player = LocalYoutubePlayer.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val thumbnailSize = remember {
density.run {
54.dp.roundToPx()
}
}
val coroutineScope = rememberCoroutineScope()
val reorderingState = rememberReorderingState(playlistWithSongs.songs)
var isRenaming by rememberSaveable {
mutableStateOf(false)
}
if (isRenaming) {
TextFieldDialog(
hintText = "Enter the playlist name",
initialTextInput = playlistWithSongs.playlist.name,
onDismiss = {
isRenaming = false
},
onDone = { text ->
coroutineScope.launch(Dispatchers.IO) {
Database.update(playlistWithSongs.playlist.copy(name = text))
}
}
)
}
var isDeleting by rememberSaveable {
mutableStateOf(false)
}
if (isDeleting) {
ConfirmationDialog(
text = "Do you really want to delete this playlist?",
onDismiss = {
isDeleting = false
},
onConfirm = {
coroutineScope.launch(Dispatchers.IO) {
Database.delete(playlistWithSongs.playlist)
}
pop()
}
)
}
LazyColumn(
state = lazyListState,
contentPadding = PaddingValues(bottom = 64.dp),
modifier = Modifier
.background(colorPalette.background)
.fillMaxSize()
) {
item {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = pop)
.padding(vertical = 8.dp, horizontal = 16.dp)
.size(24.dp)
)
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
menuState.display {
Menu {
MenuCloseButton(onClick = menuState::hide)
MenuEntry(
icon = R.drawable.time,
text = "Enqueue",
enabled = playlistWithSongs.songs.isNotEmpty() && player?.playbackState == Player.STATE_READY,
onClick = {
menuState.hide()
player?.mediaController?.enqueue(
playlistWithSongs.songs.map(
SongWithInfo::asMediaItem
)
)
}
)
MenuEntry(
icon = R.drawable.pencil,
text = "Rename",
onClick = {
menuState.hide()
isRenaming = true
}
)
MenuEntry(
icon = R.drawable.trash,
text = "Delete",
onClick = {
menuState.hide()
isDeleting = true
}
)
}
}
}
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
}
item {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier
.padding(top = 16.dp, bottom = 32.dp)
) {
Column(
modifier = Modifier
.weight(1f)
.padding(horizontal = 16.dp)
) {
BasicText(
text = playlistWithSongs.playlist.name,
style = typography.m.semiBold
)
BasicText(
text = "${playlistWithSongs.songs.size} songs",
style = typography.xxs.semiBold.secondary
)
}
Row(
horizontalArrangement = Arrangement.spacedBy(16.dp),
modifier = Modifier
.padding(horizontal = 16.dp)
) {
Image(
painter = painterResource(R.drawable.shuffle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(
playlistWithSongs.songs
.map(SongWithInfo::asMediaItem)
.shuffled()
)
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayFromBeginning(
playlistWithSongs.songs.map(
SongWithInfo::asMediaItem
)
)
}
.shadow(elevation = 2.dp, shape = CircleShape)
.background(
color = colorPalette.elevatedBackground,
shape = CircleShape
)
.padding(horizontal = 16.dp, vertical = 16.dp)
.size(20.dp)
)
}
}
}
itemsIndexed(items = playlistWithSongs.songs, key = { _, song -> song.song.id }) { index, song ->
SongItem(
song = song,
thumbnailSize = thumbnailSize,
onClick = {
YoutubePlayer.Radio.reset()
player?.mediaController?.forcePlayAtIndex(
playlistWithSongs.songs.map(
SongWithInfo::asMediaItem
), index
)
},
menuContent = {
InPlaylistMediaItemMenu(
playlistId = playlistId,
positionInPlaylist = index,
song = song
)
},
modifier = Modifier
.verticalDragAfterLongPressToReorder(
reorderingState = reorderingState,
index = index,
onDragStart = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
},
onDragEnd = { reachedIndex ->
coroutineScope.launch(Dispatchers.IO) {
if (index > reachedIndex) {
Database.incrementSongPositions(
playlistId = playlistWithSongs.playlist.id,
fromPosition = reachedIndex,
toPosition = index - 1
)
} else if (index < reachedIndex) {
Database.decrementSongPositions(
playlistId = playlistWithSongs.playlist.id,
fromPosition = index + 1,
toPosition = reachedIndex
)
}
Database.update(
SongInPlaylist(
songId = playlistWithSongs.songs[index].song.id,
playlistId = playlistWithSongs.playlist.id,
position = reachedIndex
)
)
}
}
)
)
}
}
}
}
}

View File

@@ -0,0 +1,493 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import com.valentinilk.shimmer.Shimmer
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.NonQueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.ui.views.SongItem
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun SearchResultScreen(
query: String,
onSearchAgain: () -> Unit,
) {
val density = LocalDensity.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val preferences = LocalPreferences.current
val player = LocalYoutubePlayer.current
val lazyListState = rememberLazyListState()
var continuation by remember(preferences.searchFilter) {
mutableStateOf<Outcome<String?>>(Outcome.Initial)
}
val items = remember(preferences.searchFilter) {
mutableStateListOf<YouTube.Item>()
}
val onLoad = relaunchableEffect(preferences.searchFilter) {
withContext(Dispatchers.Main) {
val token = continuation.valueOrNull
continuation = Outcome.Loading
continuation = withContext(Dispatchers.IO) {
YouTube.search(query, preferences.searchFilter, token)
}.map { searchResult ->
items.addAll(searchResult.items)
searchResult.continuation
}.recoverWith(token)
}
}
val thumbnailSizePx = remember {
density.run {
54.dp.roundToPx()
}
}
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(
listenToGlobalEmitter = true
) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: "browseId cannot be null"
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: "browseId cannot be null"
)
}
host {
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
LazyColumn(
state = lazyListState,
horizontalAlignment = Alignment.CenterHorizontally,
contentPadding = PaddingValues(bottom = 64.dp),
modifier = Modifier
.background(colorPalette.background)
.fillMaxSize()
) {
item {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable(onClick = pop)
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
BasicText(
text = query,
style = typography.m.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.clickable(
interactionSource = remember { MutableInteractionSource() },
indication = null,
onClick = onSearchAgain
)
)
Spacer(
modifier = Modifier
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
}
}
item {
ChipGroup(
items = listOf(
ChipItem(
text = "Songs",
value = YouTube.Item.Song.Filter.value
),
ChipItem(
text = "Albums",
value = YouTube.Item.Album.Filter.value
),
ChipItem(
text = "Artists",
value = YouTube.Item.Artist.Filter.value
),
ChipItem(
text = "Videos",
value = YouTube.Item.Video.Filter.value
),
),
value = preferences.searchFilter,
selectedBackgroundColor = colorPalette.primaryContainer,
unselectedBackgroundColor = colorPalette.lightBackground,
selectedTextStyle = typography.xs.medium.color(colorPalette.onPrimaryContainer),
unselectedTextStyle = typography.xs.medium,
shape = RoundedCornerShape(36.dp),
onValueChanged = { filter ->
preferences.searchFilter = filter
},
modifier = Modifier
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.padding(bottom = 8.dp)
)
}
items(items) { item ->
SmallItem(
item = item,
thumbnailSizeDp = 54.dp,
thumbnailSizePx = thumbnailSizePx,
onClick = {
when (item) {
is YouTube.Item.Album -> albumRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Artist -> artistRoute(item.info.endpoint!!.browseId)
is YouTube.Item.Song -> {
player?.mediaController?.forcePlay(item.asMediaItem)
item.info.endpoint?.let(YoutubePlayer.Radio::setup)
}
is YouTube.Item.Video -> {
player?.mediaController?.forcePlay(item.asMediaItem)
item.info.endpoint?.let(YoutubePlayer.Radio::setup)
}
}
}
)
}
when (val currentResult = continuation) {
is Outcome.Error -> item {
Error(
error = currentResult,
onRetry = onLoad,
modifier = Modifier
.padding(vertical = 16.dp)
)
}
is Outcome.Recovered -> item {
Error(
error = currentResult.error,
onRetry = onLoad,
modifier = Modifier
.padding(vertical = 16.dp)
)
}
is Outcome.Success -> {
if (items.isEmpty()) {
item {
Message(
text = "No results found",
modifier = Modifier
)
}
}
if (currentResult.value != null) {
item {
SideEffect(onLoad)
}
}
}
else -> {}
}
if (continuation is Outcome.Loading || (continuation is Outcome.Success && continuation.valueOrNull != null)) {
items(count = if (items.isEmpty()) 8 else 3, key = { it }) { index ->
when (preferences.searchFilter) {
YouTube.Item.Artist.Filter.value -> SmallArtistItemShimmer(
shimmer = shimmer,
thumbnailSizeDp = 54.dp,
modifier = Modifier
.alpha(1f - index * 0.125f)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
)
else -> SmallSongItemShimmer(
shimmer = shimmer,
thumbnailSizeDp = 54.dp,
modifier = Modifier
.alpha(1f - index * 0.125f)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
}
}
}
}
}
}
@Composable
fun SmallSongItemShimmer(
shimmer: Shimmer,
thumbnailSizeDp: Dp,
modifier: Modifier = Modifier
) {
val colorPalette = LocalColorPalette.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.shimmer(shimmer)
) {
Spacer(
modifier = Modifier
.background(colorPalette.darkGray)
.size(thumbnailSizeDp)
)
Column {
TextPlaceholder()
TextPlaceholder()
}
}
}
@Composable
fun SmallArtistItemShimmer(
shimmer: Shimmer,
thumbnailSizeDp: Dp,
modifier: Modifier = Modifier
) {
val colorPalette = LocalColorPalette.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.shimmer(shimmer)
) {
Spacer(
modifier = Modifier
.background(color = colorPalette.darkGray, shape = CircleShape)
.size(thumbnailSizeDp)
)
TextPlaceholder()
}
}
@ExperimentalAnimationApi
@Composable
fun SmallItem(
item: YouTube.Item,
thumbnailSizeDp: Dp,
thumbnailSizePx: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier,
) {
when (item) {
is YouTube.Item.Artist -> SmallArtistItem(
artist = item,
thumbnailSizeDp = thumbnailSizeDp,
thumbnailSizePx = thumbnailSizePx,
modifier = modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
.padding(vertical = 4.dp, horizontal = 16.dp)
)
is YouTube.Item.Song -> SmallSongItem(
song = item,
thumbnailSizePx = thumbnailSizePx,
onClick = onClick,
modifier = modifier
)
is YouTube.Item.Album -> SmallAlbumItem(
album = item,
thumbnailSizeDp = thumbnailSizeDp,
thumbnailSizePx = thumbnailSizePx,
modifier = modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
.padding(vertical = 4.dp, horizontal = 16.dp)
)
is YouTube.Item.Video -> SmallVideoItem(
video = item,
thumbnailSizePx = thumbnailSizePx,
onClick = onClick,
modifier = modifier
)
}
}
@ExperimentalAnimationApi
@Composable
fun SmallSongItem(
song: YouTube.Item.Song,
thumbnailSizePx: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
SongItem(
thumbnailModel = song.thumbnail.size(thumbnailSizePx),
title = song.info.name,
authors = song.authors.joinToString("") { it.name },
durationText = song.durationText,
onClick = onClick,
menuContent = {
NonQueuedMediaItemMenu(mediaItem = song.asMediaItem)
},
modifier = modifier
)
}
@ExperimentalAnimationApi
@Composable
fun SmallVideoItem(
video: YouTube.Item.Video,
thumbnailSizePx: Int,
onClick: () -> Unit,
modifier: Modifier = Modifier
) {
SongItem(
thumbnailModel = video.thumbnail.size(thumbnailSizePx),
title = video.info.name,
authors = video.views.joinToString("") { it.name },
durationText = video.durationText,
onClick = onClick,
menuContent = {
NonQueuedMediaItemMenu(mediaItem = video.asMediaItem)
},
modifier = modifier
)
}
@Composable
fun SmallAlbumItem(
album: YouTube.Item.Album,
thumbnailSizeDp: Dp,
thumbnailSizePx: Int,
modifier: Modifier = Modifier,
) {
val typography = LocalTypography.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
) {
AsyncImage(
model = album.thumbnail.size(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.size(thumbnailSizeDp)
)
Column(
modifier = Modifier
.weight(1f)
) {
BasicText(
text = album.info.name,
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = "${album.authors.joinToString("") { it.name }} • ${album.year}",
style = typography.xs,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
}
@Composable
fun SmallArtistItem(
artist: YouTube.Item.Artist,
thumbnailSizeDp: Dp,
thumbnailSizePx: Int,
modifier: Modifier = Modifier,
) {
val typography = LocalTypography.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
) {
AsyncImage(
model = artist.thumbnail.size(thumbnailSizePx),
contentDescription = null,
modifier = Modifier
.clip(CircleShape)
.size(thumbnailSizeDp)
)
BasicText(
text = artist.info.name,
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.weight(1f)
)
}
}

View File

@@ -0,0 +1,307 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.text.BasicTextField
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.rotate
import androidx.compose.ui.focus.FocusRequester
import androidx.compose.ui.focus.focusRequester
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.SolidColor
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.TextRange
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.TextFieldValue
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.TopAppBar
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun SearchScreen(
initialTextInput: String,
onSearch: (String) -> Unit
) {
var textFieldValue by rememberSaveable(initialTextInput, stateSaver = TextFieldValue.Saver) {
mutableStateOf(
TextFieldValue(
text = initialTextInput,
selection = TextRange(initialTextInput.length)
)
)
}
val focusRequester = remember {
FocusRequester()
}
val searchSuggestions by produceState<Outcome<List<String>?>>(
initialValue = Outcome.Initial,
key1 = textFieldValue
) {
value = if (textFieldValue.text.isNotEmpty()) {
withContext(Dispatchers.IO) {
YouTube.getSearchSuggestions(textFieldValue.text)
}
} else {
Outcome.Initial
}
}
val history by remember(textFieldValue.text) {
Database.getRecentQueries("%${textFieldValue.text}%").distinctUntilChanged { old, new ->
old.size == new.size
}
}.collectAsState(initial = null, context = Dispatchers.IO)
val albumRoute = rememberAlbumRoute()
val artistRoute = rememberArtistRoute()
RouteHandler(listenToGlobalEmitter = true) {
albumRoute { browseId ->
AlbumScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
artistRoute { browseId ->
ArtistScreen(
browseId = browseId ?: error("browseId cannot be null")
)
}
host {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val coroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
delay(300)
focusRequester.requestFocus()
}
Column(
modifier = Modifier
.fillMaxSize()
) {
TopAppBar(
modifier = Modifier
.height(52.dp)
) {
BasicTextField(
value = textFieldValue,
onValueChange = {
textFieldValue = it
},
textStyle = typography.m.medium,
singleLine = true,
maxLines = 1,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActions(
onSearch = {
if (textFieldValue.text.isNotEmpty()) {
onSearch(textFieldValue.text)
}
}
),
cursorBrush = SolidColor(colorPalette.text),
decorationBox = { innerTextField ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
) {
Image(
painter = painterResource(R.drawable.chevron_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
pop()
focusRequester.freeFocus()
}
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
Box(
modifier = Modifier
.weight(1f)
) {
androidx.compose.animation.AnimatedVisibility(
visible = textFieldValue.text.isEmpty(),
enter = fadeIn(tween(100)),
exit = fadeOut(tween(100)),
) {
BasicText(
text = "Enter a song, an album, an artist name...",
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = typography.m.secondary,
)
}
innerTextField()
}
}
},
modifier = Modifier
.padding(end = 16.dp)
.weight(1f)
.focusRequester(focusRequester)
)
}
Column(
modifier = Modifier
.verticalScroll(rememberScrollState())
.padding(bottom = 64.dp)
) {
history?.forEach { searchQuery ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }
) {
onSearch(searchQuery.query)
}
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 8.dp)
) {
Image(
painter = painterResource(R.drawable.time),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.darkGray),
modifier = Modifier
.padding(horizontal = 8.dp)
.size(20.dp)
)
BasicText(
text = searchQuery.query,
style = typography.s.secondary,
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
)
Image(
painter = painterResource(R.drawable.close),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.darkGray),
modifier = Modifier
.clickable {
coroutineScope.launch(Dispatchers.IO) {
Database.delete(searchQuery)
}
}
.padding(horizontal = 8.dp)
.size(20.dp)
)
Image(
painter = painterResource(R.drawable.arrow_forward),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.darkGray),
modifier = Modifier
.clickable {
textFieldValue = TextFieldValue(
text = searchQuery.query,
selection = TextRange(searchQuery.query.length)
)
}
.rotate(225f)
.padding(horizontal = 8.dp)
.size(20.dp)
)
}
}
OutcomeItem(
outcome = searchSuggestions
) { suggestions ->
suggestions?.forEach { suggestion ->
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }
) {
onSearch(suggestion)
}
.fillMaxWidth()
.padding(vertical = 16.dp, horizontal = 8.dp)
) {
Spacer(
modifier = Modifier
.padding(horizontal = 8.dp)
.size(20.dp)
)
BasicText(
text = suggestion,
style = typography.s.secondary,
modifier = Modifier
.padding(horizontal = 8.dp)
.weight(1f)
)
Image(
painter = painterResource(R.drawable.arrow_forward),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.darkGray),
modifier = Modifier
.clickable {
textFieldValue = TextFieldValue(
text = suggestion,
selection = TextRange(suggestion.length)
)
}
.rotate(225f)
.padding(horizontal = 8.dp)
.size(22.dp)
)
}
}
}
}
}
}
}
}

View File

@@ -0,0 +1,82 @@
package it.vfsfitvnm.vimusic.ui.screens
import androidx.compose.runtime.Composable
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
import it.vfsfitvnm.route.Route0
import it.vfsfitvnm.route.Route1
@Composable
fun rememberIntentVideoRoute(intentVideoId: String?): Route1<String?> {
val videoId = rememberSaveable {
mutableStateOf(intentVideoId)
}
return remember {
Route1("rememberIntentVideoRoute", videoId)
}
}
@Composable
fun rememberAlbumRoute(): Route1<String?> {
val browseId = rememberSaveable {
mutableStateOf<String?>(null)
}
return remember {
Route1("AlbumRoute", browseId)
}
}
@Composable
fun rememberArtistRoute(): Route1<String?> {
val browseId = rememberSaveable {
mutableStateOf<String?>(null)
}
return remember {
Route1("ArtistRoute", browseId)
}
}
@Composable
fun rememberLocalPlaylistRoute(): Route1<Long?> {
val playlistType = rememberSaveable {
mutableStateOf<Long?>(null)
}
return remember {
Route1("LocalPlaylistRoute", playlistType)
}
}
@Composable
fun rememberSearchRoute(): Route1<String> {
val initialTextInput = remember {
mutableStateOf("")
}
return remember {
Route1("SearchRoute", initialTextInput)
}
}
@Composable
fun rememberCreatePlaylistRoute(): Route0 {
return remember {
Route0("CreatePlaylistRoute")
}
}
@Composable
fun rememberSearchResultRoute(): Route1<String> {
val searchQuery = rememberSaveable {
mutableStateOf("")
}
return remember {
Route1("SearchResultRoute", searchQuery)
}
}
@Composable
fun rememberLyricsRoute(): Route0 {
return remember {
Route0("LyricsRoute")
}
}

View File

@@ -0,0 +1,78 @@
package it.vfsfitvnm.vimusic.ui.styling
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.runtime.*
import androidx.compose.ui.graphics.Color
@Immutable
data class ColorPalette(
val background: Color,
val elevatedBackground: Color,
val lightBackground: Color,
val text: Color,
val textSecondary: Color,
val textDisabled: Color,
val lightGray: Color,
val gray: Color,
val darkGray: Color,
val blue: Color,
val red: Color,
val green: Color,
val orange: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val iconOnPrimaryContainer: Color,
)
val DarkColorPalette = ColorPalette(
background = Color(0xff16171d),
lightBackground = Color(0xff1f2029),
elevatedBackground = Color(0xff1f2029),
text = Color(0xffe1e1e2),
textSecondary = Color(0xffa3a4a6),
textDisabled = Color(0xff6f6f73),
lightGray = Color(0xfff8f8f8),
gray = Color(0xFFE5E5E5),
darkGray = Color(0xFF838383),
blue = Color(0xff4046bf),
red = Color(0xffbf4040),
green = Color(0xff7fbf40),
orange = Color(0xffe8820e),
primaryContainer = Color(0xff4046bf),
onPrimaryContainer = Color.White,
iconOnPrimaryContainer = Color.White,
)
val LightColorPalette = ColorPalette(
background = Color(0xfffdfdfe),
lightBackground = Color(0xFFf8f8fc),
elevatedBackground = Color(0xfffdfdfe),
lightGray = Color(0xfff8f8f8),
gray = Color(0xFFE5E5E5),
darkGray = Color(0xFF838383),
text = Color(0xff212121),
textSecondary = Color(0xFF656566),
textDisabled = Color(0xFF9d9d9d),
blue = Color(0xff4059bf),
red = Color(0xffbf4040),
green = Color(0xff7fbf40),
orange = Color(0xffe8730e),
primaryContainer = Color(0xff4046bf),
onPrimaryContainer = Color.White,
iconOnPrimaryContainer = Color.White,
// primaryContainer = Color(0xffecedf9),
// onPrimaryContainer = Color(0xff121212),
// iconOnPrimaryContainer = Color(0xff2e30b8),
)
val LocalColorPalette = staticCompositionLocalOf { LightColorPalette }
@Composable
fun rememberColorPalette(isDarkTheme: Boolean = isSystemInDarkTheme()): ColorPalette {
return remember(isDarkTheme) {
if (isDarkTheme) DarkColorPalette else LightColorPalette
}
}

View File

@@ -0,0 +1,74 @@
package it.vfsfitvnm.vimusic.ui.styling
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember
import androidx.compose.runtime.staticCompositionLocalOf
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.ExperimentalTextApi
import androidx.compose.ui.text.PlatformTextStyle
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.Font
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.sp
import it.vfsfitvnm.vimusic.R
@Immutable
data class Typography(
val xxs: TextStyle,
val xs: TextStyle,
val s: TextStyle,
val m: TextStyle,
val l: TextStyle,
)
val LocalTypography = staticCompositionLocalOf<Typography> { TODO() }
@ExperimentalTextApi
@Composable
fun rememberTypography(color: Color): Typography {
return remember(color) {
TextStyle(
fontFamily = FontFamily(
Font(
resId = R.font.poppins_w300,
weight = FontWeight.Light
),
Font(
resId = R.font.poppins_w400,
weight = FontWeight.Normal
),
Font(
resId = R.font.poppins_w400_italic,
weight = FontWeight.Normal,
style = FontStyle.Italic
),
Font(
resId = R.font.poppins_w500,
weight = FontWeight.Medium
),
Font(
resId = R.font.poppins_w600,
weight = FontWeight.SemiBold
),
Font(
resId = R.font.poppins_w700,
weight = FontWeight.Bold
),
),
fontWeight = FontWeight.Normal,
color = color,
platformStyle = PlatformTextStyle(includeFontPadding = false)
).run {
Typography(
xxs = copy(fontSize = 12.sp),
xs = copy(fontSize = 14.sp),
s = copy(fontSize = 16.sp),
m = copy(fontSize = 18.sp),
l = copy(fontSize = 20.sp),
)
}
}
}

View File

@@ -0,0 +1,205 @@
package it.vfsfitvnm.vimusic.ui.views
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalHapticFeedback
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.unit.dp
import androidx.media3.common.Player
import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.Error
import it.vfsfitvnm.vimusic.ui.components.MusicBars
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.screens.SmallSongItemShimmer
import it.vfsfitvnm.vimusic.ui.styling.LightColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
import it.vfsfitvnm.vimusic.utils.YoutubePlayer
import it.vfsfitvnm.reordering.rememberReorderingState
import it.vfsfitvnm.reordering.verticalDragAfterLongPressToReorder
import it.vfsfitvnm.youtubemusic.Outcome
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun CurrentPlaylistView(
layoutState: BottomSheetState,
onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier,
) {
val hapticFeedback = LocalHapticFeedback.current
val density = LocalDensity.current
val player = LocalYoutubePlayer.current
val colorPalette = LocalColorPalette.current
val thumbnailSize = remember {
density.run {
54.dp.roundToPx()
}
}
val isPaused by derivedStateOf {
player?.playbackState == Player.STATE_ENDED || player?.playWhenReady == false
}
val shimmer = rememberShimmer(shimmerBounds = ShimmerBounds.Window)
val coroutineScope = rememberCoroutineScope()
val lazyListState =
rememberLazyListState(initialFirstVisibleItemIndex = player?.mediaItemIndex ?: 0)
val reorderingState = rememberReorderingState(player?.mediaItems ?: emptyList())
LazyColumn(
state = lazyListState,
modifier = modifier
.nestedScroll(remember {
layoutState.nestedScrollConnection(player?.mediaItemIndex == 0)
})
) {
itemsIndexed(
items = player?.mediaItems ?: emptyList()
) { index, mediaItem ->
val isPlayingThisMediaItem by derivedStateOf {
player?.mediaItemIndex == index
}
SongItem(
mediaItem = mediaItem,
thumbnailSize = thumbnailSize,
onClick = {
if (isPlayingThisMediaItem) {
if (isPaused) {
player?.mediaController?.play()
} else {
player?.mediaController?.pause()
}
} else {
player?.mediaController?.playWhenReady = true
player?.mediaController?.seekToDefaultPosition(index)
}
},
menuContent = {
QueuedMediaItemMenu(
mediaItem = mediaItem,
indexInQueue = index,
onGlobalRouteEmitted = onGlobalRouteEmitted
)
},
onThumbnailContent = {
AnimatedVisibility(
visible = isPlayingThisMediaItem,
enter = fadeIn(),
exit = fadeOut(),
) {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.background(Color.Black.copy(alpha = 0.25f))
.size(54.dp)
) {
if (isPaused) {
Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(LightColorPalette.background),
modifier = Modifier
.size(24.dp)
)
} else {
MusicBars(
color = LightColorPalette.background,
// shape = RectangleShape,
modifier = Modifier
.height(24.dp)
)
}
}
}
},
backgroundColor = colorPalette.elevatedBackground,
modifier = Modifier
.verticalDragAfterLongPressToReorder(
reorderingState = reorderingState,
index = index,
onDragStart = {
hapticFeedback.performHapticFeedback(
HapticFeedbackType.LongPress
)
},
onDragEnd = { reachedIndex ->
player?.mediaController?.moveMediaItem(index, reachedIndex)
}
)
)
}
if (YoutubePlayer.Radio.isActive && player != null) {
when (val nextContinuation = YoutubePlayer.Radio.nextContinuation) {
is Outcome.Loading, is Outcome.Success<*> -> {
if (nextContinuation is Outcome.Success<*>) {
item {
SideEffect {
coroutineScope.launch {
YoutubePlayer.Radio.process(
player.mediaController,
force = true
)
}
}
}
}
items(count = 3, key = { it }) { index ->
SmallSongItemShimmer(
shimmer = shimmer,
thumbnailSizeDp = 54.dp,
modifier = Modifier
.alpha(1f - index * 0.125f)
.fillMaxWidth()
.padding(vertical = 4.dp, horizontal = 16.dp)
)
}
}
is Outcome.Error -> item {
Error(
error = nextContinuation
)
}
is Outcome.Recovered<*> -> item {
Error(
error = nextContinuation.error,
onRetry = {
coroutineScope.launch {
YoutubePlayer.Radio.process(player.mediaController, force = true)
}
}
)
}
else -> {}
}
}
}
}

View File

@@ -0,0 +1,259 @@
package it.vfsfitvnm.vimusic.ui.views
import androidx.compose.animation.AnimatedContentScope
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.animation.animateColorAsState
import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.with
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicText
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha
import androidx.compose.ui.draw.scale
import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.unit.dp
import com.valentinilk.shimmer.shimmer
import it.vfsfitvnm.route.Route
import it.vfsfitvnm.route.RouteHandler
import it.vfsfitvnm.route.empty
import it.vfsfitvnm.route.rememberRoute
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.BottomSheet
import it.vfsfitvnm.vimusic.ui.components.BottomSheetState
import it.vfsfitvnm.vimusic.ui.components.Message
import it.vfsfitvnm.vimusic.ui.components.OutcomeItem
import it.vfsfitvnm.vimusic.ui.components.themed.TextPlaceholder
import it.vfsfitvnm.vimusic.ui.screens.rememberLyricsRoute
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.LocalYoutubePlayer
import it.vfsfitvnm.vimusic.utils.center
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.medium
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.isEvaluable
import it.vfsfitvnm.youtubemusic.toNotNull
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@ExperimentalAnimationApi
@Composable
fun PlayerBottomSheet(
layoutState: BottomSheetState,
onGlobalRouteEmitted: () -> Unit,
modifier: Modifier = Modifier,
) {
val player = LocalYoutubePlayer.current ?: return
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val coroutineScope = rememberCoroutineScope()
val lyricsRoute = rememberLyricsRoute()
var route by rememberRoute()
var nextOutcome by remember(player.mediaItem!!.mediaId) {
mutableStateOf<Outcome<YouTube.NextResult>>(Outcome.Initial)
}
var lyricsOutcome by remember(player.mediaItem!!.mediaId) {
mutableStateOf<Outcome<String?>>(Outcome.Initial)
}
BottomSheet(
state = layoutState,
peekHeight = 128.dp,
elevation = 16.dp,
shape = RoundedCornerShape(topStart = 8.dp, topEnd = 8.dp),
handleOutsideInteractionsWhenExpanded = true,
modifier = modifier,
collapsedContent = {
Column(
verticalArrangement = Arrangement.SpaceEvenly,
modifier = Modifier
.fillMaxWidth()
.height(layoutState.lowerBound)
.background(colorPalette.elevatedBackground)
) {
Spacer(
modifier = Modifier
.align(Alignment.CenterHorizontally)
.background(color = colorPalette.textDisabled, shape = RoundedCornerShape(16.dp))
.width(36.dp)
.height(4.dp)
.padding(top = 8.dp)
)
Row(
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
) {
@Composable
fun Element(
text: String,
targetRoute: Route?
) {
val color by animateColorAsState(
if (targetRoute == route) {
colorPalette.text
} else {
colorPalette.textDisabled
}
)
val scale by animateFloatAsState(
if (targetRoute == route) {
1f
} else {
0.9f
}
)
BasicText(
text = text,
style = typography.xs.medium.color(color).center,
modifier = Modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() }
) {
route = targetRoute
coroutineScope.launch(Dispatchers.Main) {
layoutState.expand()
}
}
.padding(vertical = 8.dp)
.scale(scale)
.weight(1f)
)
}
Element(
text = "UP NEXT",
targetRoute = null
)
Element(
text = "LYRICS",
targetRoute = lyricsRoute
)
}
}
}
) {
RouteHandler(
route = route,
onRouteChanged = {
route = it
},
handleBackPress = false,
transitionSpec = {
when (targetState.route) {
lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Left) with
slideOutOfContainer(AnimatedContentScope.SlideDirection.Left)
else -> when (initialState.route) {
lyricsRoute -> slideIntoContainer(AnimatedContentScope.SlideDirection.Right) with
slideOutOfContainer(AnimatedContentScope.SlideDirection.Right)
else -> empty
}
}
},
modifier = Modifier
.background(colorPalette.elevatedBackground)
.fillMaxSize()
) {
lyricsRoute {
OutcomeItem(
outcome = lyricsOutcome,
onInitialize = {
lyricsOutcome = Outcome.Loading
coroutineScope.launch(Dispatchers.Main) {
if (nextOutcome.isEvaluable) {
nextOutcome = Outcome.Loading
nextOutcome = withContext(Dispatchers.IO) {
YouTube.next(
player.mediaItem!!.mediaId,
player.mediaItem!!.mediaMetadata.extras?.getString("playlistId"),
player.mediaItemIndex
)
}
}
lyricsOutcome = nextOutcome.flatMap {
it.lyrics?.text().toNotNull()
}
}
},
onLoading = {
LyricsShimmer(
modifier = Modifier
.shimmer()
)
}
) { lyrics ->
if (lyrics != null) {
BasicText(
text = lyrics,
style = typography.xs.center,
modifier = Modifier
.padding(top = 64.dp)
.nestedScroll(remember { layoutState.nestedScrollConnection() })
.verticalScroll(rememberScrollState())
.fillMaxWidth()
.padding(vertical = 16.dp)
.padding(horizontal = 48.dp)
)
} else {
Message(
text = "Lyrics not available",
icon = R.drawable.text,
modifier = Modifier
.padding(top = 64.dp)
)
}
}
}
host {
CurrentPlaylistView(
layoutState = layoutState,
onGlobalRouteEmitted = onGlobalRouteEmitted,
modifier = Modifier
.padding(top = 64.dp)
)
}
}
}
}
@Composable
fun LyricsShimmer(
modifier: Modifier = Modifier
) {
Column(
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
modifier = modifier
) {
repeat(16) { index ->
TextPlaceholder(
modifier = Modifier
.alpha(1f - index * 0.05f)
)
}
}
}

View File

@@ -0,0 +1,466 @@
package it.vfsfitvnm.vimusic.ui.views
import android.text.format.DateUtils
import androidx.compose.animation.*
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.*
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithCache
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.graphics.toArgb
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalConfiguration
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.media3.common.C
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.ui.DefaultTimeBar
import androidx.media3.ui.TimeBar
import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.ui.components.*
import it.vfsfitvnm.vimusic.ui.components.themed.QueuedMediaItemMenu
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.*
import it.vfsfitvnm.youtubemusic.Outcome
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.launch
@ExperimentalAnimationApi
@Composable
fun PlayerView(
layoutState: BottomSheetState,
modifier: Modifier = Modifier,
) {
val menuState = LocalMenuState.current
val preferences = LocalPreferences.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val density = LocalDensity.current
val configuration = LocalConfiguration.current
val player = LocalYoutubePlayer.current
val coroutineScope = rememberCoroutineScope()
player?.mediaItem ?: return
val smallThumbnailSize = remember {
density.run { 64.dp.roundToPx() }
}
val (thumbnailSizeDp, thumbnailSizePx) = remember {
val size = minOf(configuration.screenHeightDp, configuration.screenWidthDp).dp
size to density.run { size.minus(64.dp).roundToPx() }
}
val song by remember(player.mediaItem?.mediaId) {
player.mediaItem?.mediaId?.let(Database::songFlow)?.distinctUntilChanged() ?: flowOf(null)
}.collectAsState(initial = null, context = Dispatchers.IO)
BottomSheet(
state = layoutState,
modifier = modifier,
collapsedContent = {
if (!layoutState.isExpanded) {
Row(
horizontalArrangement = Arrangement.spacedBy(8.dp),
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.height(layoutState.lowerBound)
.fillMaxWidth()
.graphicsLayer {
alpha = 1f - (layoutState.progress * 16).coerceAtMost(1f)
}
.drawWithCache {
val offset = 64.dp.toPx()
val x = ((size.width - offset) * player.progress) + offset
onDrawWithContent {
drawContent()
drawLine(
color = colorPalette.text,
start = Offset(
x = offset,
y = 1.dp.toPx()
),
end = Offset(
x = x,
y = 1.dp.toPx()
),
strokeWidth = 2.dp.toPx()
)
}
}
.background(colorPalette.elevatedBackground)
) {
AsyncImage(
model = "${player.mediaMetadata.artworkUri}-w$smallThumbnailSize-h$smallThumbnailSize",
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(64.dp)
)
Column(
verticalArrangement = Arrangement.Center,
modifier = Modifier
.weight(1f)
) {
BasicText(
text = player.mediaMetadata.title?.toString() ?: "",
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = player.mediaMetadata.artist?.toString() ?: "",
style = typography.xs,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
when {
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
painter = painterResource(R.drawable.play),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
if (player.playbackState == Player.STATE_IDLE) {
player.mediaController.prepare()
}
player.mediaController.play()
}
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
else -> Image(
painter = painterResource(R.drawable.pause),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.pause()
}
.padding(vertical = 8.dp)
.padding(horizontal = 16.dp)
.size(24.dp)
)
}
}
}
}
) {
Column(
horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier
.background(colorPalette.background)
.padding(bottom = 72.dp)
.fillMaxSize()
) {
var scrubbingPosition by remember {
mutableStateOf<Long?>(null)
}
TopAppBar {
Spacer(
modifier = Modifier
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
Image(
painter = painterResource(R.drawable.ellipsis_horizontal),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
menuState.display {
QueuedMediaItemMenu(
mediaItem = player.mediaItem ?: MediaItem.EMPTY,
indexInQueue = player.mediaItemIndex,
onDismiss = menuState::hide,
onGlobalRouteEmitted = layoutState.collapse
)
}
}
.padding(horizontal = 16.dp, vertical = 8.dp)
.size(24.dp)
)
}
if (player.error == null) {
AnimatedContent(
targetState = player.mediaItemIndex,
transitionSpec = {
val slideDirection =
if (targetState > initialState) AnimatedContentScope.SlideDirection.Left else AnimatedContentScope.SlideDirection.Right
(slideIntoContainer(slideDirection) + fadeIn() with
slideOutOfContainer(slideDirection) + fadeOut()).using(
SizeTransform(clip = false)
)
},
modifier = Modifier
.weight(1f)
.align(Alignment.CenterHorizontally)
) {
val artworkUri = remember(it) {
player.mediaController.getMediaItemAt(it).mediaMetadata.artworkUri
}
AsyncImage(
model = "$artworkUri-w$thumbnailSizePx-h$thumbnailSizePx",
contentDescription = null,
modifier = Modifier
.padding(bottom = 32.dp)
.padding(horizontal = 32.dp)
.size(thumbnailSizeDp)
)
}
} else {
Box(
contentAlignment = Alignment.Center,
modifier = Modifier
.align(Alignment.CenterHorizontally)
.padding(bottom = 32.dp)
.padding(horizontal = 32.dp)
.size(thumbnailSizeDp)
) {
// BasicText(
// text = playerState.error?.message ?: "",
// style = typography.xs.medium
// )
Error(
error = Outcome.Error.Unhandled(player.error!!),
onRetry = {
player.mediaController.playWhenReady = true
player.mediaController.prepare()
player.error = null
}
)
}
}
BasicText(
text = player.mediaMetadata.title?.toString() ?: "",
style = typography.l.bold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 32.dp)
)
BasicText(
text = player.mediaMetadata.extras?.getStringArrayList("artistNames")
?.joinToString("") ?: "",
style = typography.s.semiBold.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.padding(horizontal = 32.dp)
)
AndroidView(
factory = { context ->
DefaultTimeBar(context).also {
it.setPlayedColor(colorPalette.text.toArgb())
it.setUnplayedColor(colorPalette.textDisabled.toArgb())
it.setScrubberColor(colorPalette.text.toArgb())
it.addListener(object : TimeBar.OnScrubListener {
override fun onScrubStart(timeBar: TimeBar, position: Long) = Unit
override fun onScrubMove(timeBar: TimeBar, position: Long) {
scrubbingPosition = position
}
override fun onScrubStop(
timeBar: TimeBar,
position: Long,
canceled: Boolean
) {
if (!canceled) {
scrubbingPosition = position
player.mediaController.seekTo(position)
player.currentPosition = player.mediaController.currentPosition
}
scrubbingPosition = null
}
})
}
},
update = {
it.setDuration(player.duration)
it.setPosition(player.currentPosition)
},
modifier = Modifier
.padding(top = 16.dp)
.padding(horizontal = 32.dp)
.fillMaxWidth()
)
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(horizontal = 32.dp)
.fillMaxWidth()
.padding(bottom = 16.dp)
) {
val text by remember {
derivedStateOf {
DateUtils.formatElapsedTime((scrubbingPosition ?: player.currentPosition) / 1000)
}
}
BasicText(
text = text,
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
if (player.duration != C.TIME_UNSET) {
BasicText(
text = DateUtils.formatElapsedTime(player.duration / 1000),
style = typography.xxs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
}
Row(
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
modifier = Modifier
.padding(vertical = 32.dp)
) {
Image(
painter = painterResource(R.drawable.heart),
contentDescription = null,
colorFilter = ColorFilter.tint(
song?.likedAt?.let { colorPalette.red } ?: colorPalette.textDisabled
),
modifier = Modifier
.clickable {
coroutineScope.launch(Dispatchers.IO) {
Database.update(
(song ?: Database.insert(player.mediaItem!!)).toggleLike()
)
}
}
.padding(horizontal = 16.dp)
.size(28.dp)
)
Image(
painter = painterResource(R.drawable.play_skip_back),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.seekToPrevious()
}
.padding(horizontal = 16.dp)
.size(32.dp)
)
when {
player.playbackState == Player.STATE_ENDED || !player.playWhenReady -> Image(
painter = painterResource(R.drawable.play_circle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
if (player.playbackState == Player.STATE_IDLE) {
player.mediaController.prepare()
}
player.mediaController.play()
}
.size(64.dp)
)
else -> Image(
painter = painterResource(R.drawable.pause_circle),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.pause()
}
.size(64.dp)
)
}
Image(
painter = painterResource(R.drawable.play_skip_forward),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.text),
modifier = Modifier
.clickable {
player.mediaController.seekToNext()
}
.padding(horizontal = 16.dp)
.size(32.dp)
)
Image(
painter = painterResource(
if (player.repeatMode == Player.REPEAT_MODE_ONE) {
R.drawable.repeat_one
} else {
R.drawable.repeat
}
),
contentDescription = null,
colorFilter = ColorFilter.tint(
if (player.repeatMode == Player.REPEAT_MODE_OFF) {
colorPalette.textDisabled
} else {
colorPalette.text
}
),
modifier = Modifier
.clickable {
player.mediaController.repeatMode =
(player.mediaController.repeatMode + 2) % 3
preferences.repeatMode = player.mediaController.repeatMode
}
.padding(horizontal = 16.dp)
.size(28.dp)
)
}
}
PlayerBottomSheet(
layoutState = rememberBottomSheetState(64.dp, layoutState.upperBound - 128.dp),
onGlobalRouteEmitted = layoutState.collapse,
modifier = Modifier
.padding(bottom = 128.dp)
.align(Alignment.BottomCenter)
)
}
}

View File

@@ -0,0 +1,100 @@
package it.vfsfitvnm.vimusic.ui.views
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.text.BasicText
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.models.PlaylistPreview
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.color
import it.vfsfitvnm.vimusic.utils.semiBold
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.distinctUntilChanged
@Composable
fun PlaylistPreviewItem(
playlistPreview: PlaylistPreview,
modifier: Modifier = Modifier,
thumbnailSize: Dp = 54.dp,
) {
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
val density = LocalDensity.current
val thumbnailSizePx = density.run {
thumbnailSize.toPx().toInt()
}
val thumbnails by remember(playlistPreview.playlist.id) {
Database.playlistThumbnailUrls(playlistPreview.playlist.id).distinctUntilChanged()
}.collectAsState(initial = emptyList(), context = Dispatchers.IO)
Box(
modifier = modifier
.background(colorPalette.lightBackground)
.size(thumbnailSize * 2)
) {
if (thumbnails.toSet().size == 1) {
AsyncImage(
model = "${thumbnails.first()}-w${thumbnailSizePx * 2}-h${thumbnailSizePx * 2}",
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.size(thumbnailSize * 2)
)
} else {
listOf(
Alignment.TopStart,
Alignment.TopEnd,
Alignment.BottomStart,
Alignment.BottomEnd
).forEachIndexed { index, alignment ->
AsyncImage(
model = "${thumbnails.getOrNull(index)}-w$thumbnailSizePx-h$thumbnailSizePx",
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.align(alignment)
.size(thumbnailSize)
)
}
}
BasicText(
text = playlistPreview.playlist.name,
style = typography.xxs.semiBold.color(Color.White),
maxLines = 2,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.fillMaxWidth()
.align(Alignment.BottomStart)
.background(
Brush.verticalGradient(
colors = listOf(
Color.Transparent,
Color.Black.copy(alpha = 0.75f)
)
)
)
.padding(horizontal = 8.dp, vertical = 4.dp)
)
}
}

View File

@@ -0,0 +1,199 @@
package it.vfsfitvnm.vimusic.ui.views
import androidx.compose.animation.ExperimentalAnimationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.text.BasicText
import androidx.compose.material.ripple.rememberRipple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.NonRestartableComposable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.ColorFilter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.media3.common.MediaItem
import coil.compose.AsyncImage
import coil.request.ImageRequest
import it.vfsfitvnm.vimusic.ui.components.LocalMenuState
import it.vfsfitvnm.vimusic.R
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
import it.vfsfitvnm.vimusic.ui.styling.LocalTypography
import it.vfsfitvnm.vimusic.utils.secondary
import it.vfsfitvnm.vimusic.utils.semiBold
@ExperimentalAnimationApi
@Composable
@NonRestartableComposable
fun SongItem(
mediaItem: MediaItem,
thumbnailSize: Int,
onClick: () -> Unit,
menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color? = null,
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
) {
SongItem(
thumbnailModel = ImageRequest.Builder(LocalContext.current)
.diskCacheKey(mediaItem.mediaId)
.data("${mediaItem.mediaMetadata.artworkUri}-w$thumbnailSize-h$thumbnailSize")
.build(),
title = mediaItem.mediaMetadata.title!!.toString(),
authors = mediaItem.mediaMetadata.artist.toString(),
durationText = mediaItem.mediaMetadata.extras?.getString("durationText") ?: "?",
menuContent = menuContent,
onClick = onClick,
onThumbnailContent = onThumbnailContent,
backgroundColor = backgroundColor,
modifier = modifier,
)
}
@ExperimentalAnimationApi
@Composable
@NonRestartableComposable
fun SongItem(
song: SongWithInfo,
thumbnailSize: Int,
onClick: () -> Unit,
menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color? = null,
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
) {
SongItem(
thumbnailModel = "${song.song.thumbnailUrl}-w$thumbnailSize-h$thumbnailSize",
title = song.song.title,
authors = song.authors?.joinToString("") { it.text } ?: "",
durationText = song.song.durationText,
menuContent = menuContent,
onClick = onClick,
onThumbnailContent = onThumbnailContent,
backgroundColor = backgroundColor,
modifier = modifier,
)
}
@ExperimentalAnimationApi
@Composable
@NonRestartableComposable
fun SongItem(
thumbnailModel: Any?,
title: String,
authors: String,
durationText: String,
onClick: () -> Unit,
menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color? = null,
onThumbnailContent: (@Composable BoxScope.() -> Unit)? = null,
) {
SongItem(
title = title,
authors = authors,
durationText = durationText,
onClick = onClick,
startContent = {
Box(
modifier = Modifier
.size(54.dp)
) {
AsyncImage(
model = thumbnailModel,
contentDescription = null,
contentScale = ContentScale.Crop,
modifier = Modifier
.fillMaxSize()
)
onThumbnailContent?.invoke(this)
}
},
menuContent = menuContent,
backgroundColor = backgroundColor,
modifier = modifier,
)
}
@ExperimentalAnimationApi
@Composable
fun SongItem(
title: String,
authors: String,
durationText: String?,
onClick: () -> Unit,
startContent: @Composable () -> Unit,
menuContent: @Composable () -> Unit,
modifier: Modifier = Modifier,
backgroundColor: Color? = null,
) {
val menuState = LocalMenuState.current
val colorPalette = LocalColorPalette.current
val typography = LocalTypography.current
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(8.dp),
modifier = modifier
.clickable(
indication = rememberRipple(bounded = true),
interactionSource = remember { MutableInteractionSource() },
onClick = onClick
)
.fillMaxWidth()
.padding(vertical = 4.dp)
.background(backgroundColor ?: colorPalette.background)
.padding(start = 16.dp, end = 8.dp)
) {
startContent()
Column(
modifier = Modifier
.weight(1f)
) {
BasicText(
text = title,
style = typography.xs.semiBold,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
BasicText(
text = buildString {
append(authors)
if (authors.isNotEmpty() && durationText != null) {
append("")
}
append(durationText)
},
style = typography.xs.semiBold.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
}
Image(
painter = painterResource(R.drawable.ellipsis_vertical),
contentDescription = null,
colorFilter = ColorFilter.tint(colorPalette.textSecondary),
modifier = Modifier
.clickable {
menuState.display(menuContent)
}
.padding(horizontal = 8.dp, vertical = 4.dp)
.size(20.dp)
)
}
}

View File

@@ -0,0 +1,44 @@
package it.vfsfitvnm.vimusic.utils
import androidx.media3.common.MediaItem
import androidx.media3.common.Player
import androidx.media3.common.Timeline
fun Player.forcePlay(mediaItem: MediaItem) {
setMediaItem(mediaItem, true)
playWhenReady = true
prepare()
}
fun Player.forcePlayAtIndex(mediaItems: List<MediaItem>, mediaItemIndex: Int) {
if (mediaItems.isEmpty()) return
setMediaItems(mediaItems, true)
playWhenReady = true
seekToDefaultPosition(mediaItemIndex)
prepare()
}
fun Player.forcePlayFromBeginning(mediaItems: List<MediaItem>) =
forcePlayAtIndex(mediaItems, 0)
val Player.lastMediaItem: MediaItem?
get() = mediaItemCount.takeIf { it > 0 }?.let { it - 1 }?.let(::getMediaItemAt)
val Timeline.mediaItems: List<MediaItem>
get() = (0 until windowCount).map { index ->
getWindow(index, Timeline.Window()).mediaItem
}
fun Player.addNext(mediaItem: MediaItem) {
addMediaItem(currentMediaItemIndex + 1, mediaItem)
}
fun Player.enqueue(mediaItem: MediaItem) {
addMediaItem(mediaItemCount, mediaItem)
}
fun Player.enqueue(mediaItems: List<MediaItem>) {
addMediaItems(mediaItemCount, mediaItems)
}

View File

@@ -0,0 +1,92 @@
package it.vfsfitvnm.vimusic.utils
import android.os.Handler
import android.os.Looper
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
import androidx.media3.common.*
import androidx.media3.session.MediaController
import kotlin.math.absoluteValue
open class PlayerState(val mediaController: MediaController) : Player.Listener {
private val handler = Handler(Looper.getMainLooper())
var currentPosition by mutableStateOf(mediaController.currentPosition)
var duration by mutableStateOf(mediaController.duration)
private set
val progress: Float
get() = currentPosition.toFloat() / duration.absoluteValue
var playbackState by mutableStateOf(mediaController.playbackState)
private set
var mediaItemIndex by mutableStateOf(mediaController.currentMediaItemIndex)
private set
var mediaItem by mutableStateOf(mediaController.currentMediaItem)
private set
var mediaMetadata by mutableStateOf(mediaController.mediaMetadata)
private set
var isPlaying by mutableStateOf(mediaController.isPlaying)
private set
var playWhenReady by mutableStateOf(mediaController.playWhenReady)
private set
var repeatMode by mutableStateOf(mediaController.repeatMode)
private set
var error by mutableStateOf(mediaController.playerError)
var mediaItems by mutableStateOf(mediaController.currentTimeline.mediaItems)
private set
init {
handler.post(object : Runnable {
override fun run() {
duration = mediaController.duration
currentPosition = mediaController.currentPosition
handler.postDelayed(this, 500)
}
})
}
override fun onPlaybackStateChanged(playbackState: Int) {
this.playbackState = playbackState
}
override fun onMediaMetadataChanged(mediaMetadata: MediaMetadata) {
this.mediaMetadata = mediaMetadata
}
override fun onIsPlayingChanged(isPlaying: Boolean) {
this.isPlaying = isPlaying
}
override fun onPlayWhenReadyChanged(playWhenReady: Boolean, reason: Int) {
this.playWhenReady = playWhenReady
}
override fun onMediaItemTransition(mediaItem: MediaItem?, reason: Int) {
this.mediaItem = mediaItem
mediaItemIndex = mediaController.currentMediaItemIndex
}
override fun onRepeatModeChanged(repeatMode: Int) {
this.repeatMode = repeatMode
}
override fun onPlayerError(playbackException: PlaybackException) {
error = playbackException
}
override fun onTimelineChanged(timeline: Timeline, reason: Int) {
mediaItems = timeline.mediaItems
mediaItemIndex = mediaController.currentMediaItemIndex
}
}

View File

@@ -0,0 +1,108 @@
package it.vfsfitvnm.vimusic.utils
import android.content.Context
import android.content.SharedPreferences
import androidx.compose.runtime.*
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.TextUnit
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.core.content.edit
import androidx.media3.common.Player
import it.vfsfitvnm.vimusic.enums.SongCollection
import it.vfsfitvnm.youtubemusic.YouTube
@Stable
class Preferences(holder: SharedPreferences) : SharedPreferences by holder {
var searchFilter by preference("searchFilter", YouTube.Item.Song.Filter.value)
var repeatMode by preference("repeatMode", Player.REPEAT_MODE_OFF)
var homePageSongCollection by preference("homePageSongCollection", SongCollection.MostPlayed)
}
val LocalPreferences = staticCompositionLocalOf<Preferences> { TODO() }
@Composable
fun rememberPreferences(): Preferences {
val context = LocalContext.current
return remember {
Preferences(context.getSharedPreferences("preferences", Context.MODE_PRIVATE))
}
}
private fun SharedPreferences.preference(key: String, defaultValue: Boolean) =
mutableStateOf(value = getBoolean(key, defaultValue)) {
edit {
putBoolean(key, it)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: Int) =
mutableStateOf(value = getInt(key, defaultValue)) {
edit {
putInt(key, it)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: Long) =
mutableStateOf(value = getLong(key, defaultValue)) {
edit {
putLong(key, it)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: Float) =
mutableStateOf(value = getFloat(key, defaultValue)) {
edit {
putFloat(key, it)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: String) =
mutableStateOf(value = getString(key, defaultValue)!!) {
edit {
putString(key, it)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: Set<String>) =
mutableStateOf(value = getStringSet(key, defaultValue)!!) {
edit {
putStringSet(key, it)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: Dp) =
mutableStateOf(value = getFloat(key, defaultValue.value).dp) {
edit {
putFloat(key, it.value)
}
}
private fun SharedPreferences.preference(key: String, defaultValue: TextUnit) =
mutableStateOf(value = getFloat(key, defaultValue.value).sp) {
edit {
putFloat(key, it.value)
}
}
private inline fun <reified T : Enum<T>> SharedPreferences.preference(
key: String,
defaultValue: T
) =
mutableStateOf(value = enumValueOf<T>(getString(key, defaultValue.name)!!)) {
edit {
putString(key, it.name)
}
}
private fun <T> mutableStateOf(value: T, onStructuralInequality: (newValue: T) -> Unit) =
mutableStateOf(
value = value,
policy = object : SnapshotMutationPolicy<T> {
override fun equivalent(a: T, b: T): Boolean {
val areEquals = a == b
if (!areEquals) onStructuralInequality(b)
return areEquals
}
})

View File

@@ -0,0 +1,19 @@
@file:Suppress("INVISIBLE_MEMBER", "INVISIBLE_REFERENCE")
@file:OptIn(InternalComposeApi::class)
package it.vfsfitvnm.vimusic.utils
import androidx.compose.runtime.*
import kotlinx.coroutines.CoroutineScope
@Composable
@NonRestartableComposable
fun relaunchableEffect(
key1: Any?,
block: suspend CoroutineScope.() -> Unit
): () -> Unit {
val applyContext = currentComposer.applyCoroutineContext
val launchedEffect = remember(key1) { LaunchedEffectImpl(applyContext, block) }
return launchedEffect::onRemembered
}

View File

@@ -0,0 +1,11 @@
package it.vfsfitvnm.vimusic.utils
class RingBuffer<T>(val size: Int, init: (index: Int) -> T) {
private val list = MutableList(2, init)
private var index = 0
fun getOrNull(index: Int): T? = list.getOrNull(index)
fun append(element: T) = list.set(index++ % size, element)
}

View File

@@ -0,0 +1,43 @@
package it.vfsfitvnm.vimusic.utils
import androidx.compose.runtime.Composable
import androidx.compose.runtime.ReadOnlyComposable
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextAlign
import it.vfsfitvnm.vimusic.ui.styling.LocalColorPalette
fun TextStyle.style(style: FontStyle) = copy(fontStyle = style)
fun TextStyle.weight(weight: FontWeight) = copy(fontWeight = weight)
fun TextStyle.align(align: TextAlign) = copy(textAlign = align)
fun TextStyle.color(color: Color) = copy(color = color)
inline val TextStyle.italic: TextStyle
get() = style(FontStyle.Italic)
inline val TextStyle.medium: TextStyle
get() = weight(FontWeight.Medium)
inline val TextStyle.semiBold: TextStyle
get() = weight(FontWeight.SemiBold)
inline val TextStyle.bold: TextStyle
get() = weight(FontWeight.Bold)
inline val TextStyle.center: TextStyle
get() = align(TextAlign.Center)
inline val TextStyle.secondary: TextStyle
@Composable
@ReadOnlyComposable
get() = color(LocalColorPalette.current.textSecondary)
inline val TextStyle.disabled: TextStyle
@Composable
@ReadOnlyComposable
get() = color(LocalColorPalette.current.textDisabled)

View File

@@ -0,0 +1,129 @@
package it.vfsfitvnm.vimusic.utils
import androidx.compose.runtime.*
import androidx.media3.common.Player
import androidx.media3.session.MediaController
import com.google.common.util.concurrent.ListenableFuture
import it.vfsfitvnm.youtubemusic.Outcome
import it.vfsfitvnm.youtubemusic.YouTube
import it.vfsfitvnm.youtubemusic.models.NavigationEndpoint
import kotlinx.coroutines.*
import kotlinx.coroutines.guava.await
import kotlinx.coroutines.sync.Mutex
class YoutubePlayer(mediaController: MediaController) : PlayerState(mediaController) {
object Radio {
var isActive by mutableStateOf(false)
var listener: Listener? = null
private var videoId: String? = null
private var playlistId: String? = null
private var playlistSetVideoId: String? = null
private var parameters: String? = null
var nextContinuation by mutableStateOf<Outcome<String?>>(Outcome.Initial)
fun setup(videoId: String? = null, playlistId: String? = null, playlistSetVideoId: String? = null, parameters: String? = null) {
this.videoId = videoId
this.playlistId = playlistId
this.playlistSetVideoId = playlistSetVideoId
this.parameters = parameters
isActive = true
nextContinuation = Outcome.Initial
}
fun setup(watchEndpoint: NavigationEndpoint.Endpoint.Watch?) {
setup(
videoId = watchEndpoint?.videoId,
playlistId = watchEndpoint?.playlistId,
parameters = watchEndpoint?.params,
playlistSetVideoId = watchEndpoint?.playlistSetVideoId
)
listener?.process(true)
}
suspend fun process(player: Player, force: Boolean = false, play: Boolean = false) {
if (!isActive) return
if (!force && !play) {
val isFirstSong = withContext(Dispatchers.Main) {
player.mediaItemCount == 0 || (player.currentMediaItemIndex == 0 && player.mediaItemCount == 1)
}
val isNearEndSong = withContext(Dispatchers.Main) {
player.mediaItemCount - player.currentMediaItemIndex <= 3
}
if (!isFirstSong && !isNearEndSong) {
return
}
}
val token = nextContinuation.valueOrNull
nextContinuation = Outcome.Loading
nextContinuation = withContext(Dispatchers.IO) {
YouTube.next(
videoId = videoId ?: withContext(Dispatchers.Main) {
player.lastMediaItem?.mediaId ?: error("This should not happen")
},
playlistId = playlistId,
params = parameters,
playlistSetVideoId = playlistSetVideoId,
continuation = token
)
}.map { nextResult ->
nextResult.items?.map(it.vfsfitvnm.youtubemusic.YouTube.Item.Song::asMediaItem)?.let { mediaItems ->
withContext(Dispatchers.Main) {
if (play) {
player.forcePlayFromBeginning(mediaItems)
} else {
player.addMediaItems(mediaItems.drop(if (token == null) 1 else 0))
}
}
}
nextResult.continuation?.takeUnless { token == nextResult.continuation }
}.recoverWith(token)
}
fun reset() {
videoId = null
playlistId = null
playlistSetVideoId = null
parameters = null
isActive = false
nextContinuation = Outcome.Initial
}
interface Listener {
fun process(play: Boolean)
}
}
}
val LocalYoutubePlayer = compositionLocalOf<YoutubePlayer?> { null }
@Composable
fun rememberYoutubePlayer(
mediaControllerFuture: ListenableFuture<MediaController>,
repeatMode: Int
): YoutubePlayer? {
val mediaController by produceState<MediaController?>(initialValue = null) {
value = mediaControllerFuture.await().also {
it.repeatMode = repeatMode
}
}
val playerState = remember(mediaController) {
YoutubePlayer(mediaController ?: return@remember null).also {
// TODO: should we remove the listener later on?
mediaController?.addListener(it)
}
}
return playerState
}

View File

@@ -0,0 +1,24 @@
package it.vfsfitvnm.vimusic.utils
import android.view.HapticFeedbackConstants
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.hapticfeedback.HapticFeedback
import androidx.compose.ui.hapticfeedback.HapticFeedbackType
import androidx.compose.ui.platform.LocalView
@Composable
fun rememberHapticFeedback(): HapticFeedback {
val view = LocalView.current
return remember {
object : HapticFeedback {
override fun performHapticFeedback(hapticFeedbackType: HapticFeedbackType) {
view.performHapticFeedback(
HapticFeedbackConstants.LONG_PRESS,
HapticFeedbackConstants.FLAG_IGNORE_GLOBAL_SETTING
)
}
}
}
}

View File

@@ -0,0 +1,172 @@
package it.vfsfitvnm.vimusic.utils
import android.content.Context
import android.content.Intent
import androidx.core.net.toUri
import androidx.core.os.bundleOf
import androidx.media3.common.MediaItem
import androidx.media3.common.MediaMetadata
import it.vfsfitvnm.vimusic.Database
import it.vfsfitvnm.vimusic.internal
import it.vfsfitvnm.vimusic.models.Info
import it.vfsfitvnm.vimusic.models.Song
import it.vfsfitvnm.vimusic.models.SongWithAuthors
import it.vfsfitvnm.vimusic.models.SongWithInfo
import it.vfsfitvnm.youtubemusic.YouTube
fun Context.shareAsYouTubeSong(mediaItem: MediaItem) {
val sendIntent = Intent().apply {
action = Intent.ACTION_SEND
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, "https://music.youtube.com/watch?v=${mediaItem.mediaId}")
}
startActivity(Intent.createChooser(sendIntent, null))
}
fun Database.insert(mediaItem: MediaItem): Song {
return internal.runInTransaction<Song> {
Database.song(mediaItem.mediaId)?.let {
return@runInTransaction it
}
val albumInfo = mediaItem.mediaMetadata.extras?.getString("albumId")?.let { albumId ->
Info(
text = mediaItem.mediaMetadata.albumTitle!!.toString(),
browseId = albumId
)
}
val albumInfoId = albumInfo?.let { insert(it) }
val authorsInfo =
mediaItem.mediaMetadata.extras?.getStringArrayList("artistNames")?.let { artistNames ->
mediaItem.mediaMetadata.extras!!.getStringArrayList("artistIds")?.let { artistIds ->
artistNames.mapIndexed { index, artistName ->
Info(
text = artistName,
browseId = artistIds.getOrNull(index)
)
}
}
}
val song = Song(
id = mediaItem.mediaId,
title = mediaItem.mediaMetadata.title!!.toString(),
albumInfoId = albumInfoId,
durationText = mediaItem.mediaMetadata.extras?.getString("durationText")!!,
thumbnailUrl = mediaItem.mediaMetadata.artworkUri!!.toString()
)
insert(song)
val authorsInfoId = authorsInfo?.let { insert(authorsInfo) }
authorsInfoId?.forEach { authorInfoId ->
insert(
SongWithAuthors(
songId = mediaItem.mediaId,
authorInfoId = authorInfoId
)
)
}
return@runInTransaction song
}
}
val YouTube.Item.Song.asMediaItem: MediaItem
get() = MediaItem.Builder()
.setMediaId(info.endpoint!!.videoId)
.setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist(authors.joinToString("") { it.name })
.setAlbumTitle(album?.name)
.setArtworkUri(thumbnail.url.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint!!.videoId,
"albumId" to album?.endpoint?.browseId,
"durationText" to durationText,
"artistNames" to authors.map { it.name },
"artistIds" to authors.map { it.endpoint?.browseId },
)
)
.build()
)
.build()
val YouTube.Item.Video.asMediaItem: MediaItem
get() = MediaItem.Builder()
.setMediaId(info.endpoint!!.videoId)
.setUri(info.endpoint!!.videoId)
.setCustomCacheKey(info.endpoint!!.videoId)
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist(authors.joinToString("") { it.name })
.setArtworkUri(thumbnail.url.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint!!.videoId,
"durationText" to durationText,
"artistNames" to if (isOfficialMusicVideo) authors.map { it.name } else null,
"artistIds" to if (isOfficialMusicVideo) authors.map { it.endpoint?.browseId } else null,
)
)
.build()
)
.build()
val SongWithInfo.asMediaItem: MediaItem
get() = MediaItem.Builder()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(song.title)
.setArtist(authors?.joinToString("") { it.text })
.setAlbumTitle(album?.text)
.setArtworkUri(song.thumbnailUrl?.toUri())
.setExtras(
bundleOf(
"videoId" to song.id,
"albumId" to album?.browseId,
"artistNames" to authors?.map { it.text },
"artistIds" to authors?.map { it.browseId },
"durationText" to song.durationText
)
)
.build()
)
.setMediaId(song.id)
.build()
fun YouTube.AlbumItem.toMediaItem(
albumId: String,
album: YouTube.Album
): MediaItem? {
return MediaItem.Builder()
.setMediaMetadata(
MediaMetadata.Builder()
.setTitle(info.name)
.setArtist((authors ?: album.authors).joinToString("") { it.name })
.setAlbumTitle(album.title)
.setArtworkUri(album.thumbnail.url.toUri())
.setExtras(
bundleOf(
"videoId" to info.endpoint?.videoId,
"playlistId" to info.endpoint?.playlistId,
"albumId" to albumId,
"durationText" to durationText,
"artistNames" to (authors ?: album.authors).map { it.name },
"artistIds" to (authors ?: album.authors).map { it.endpoint?.browseId }
)
)
.build()
)
.setMediaId(info.endpoint?.videoId ?: return null)
.build()
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

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

View File

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

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.6 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.2 KiB

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