mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
refactor: integrate millennium UI
This commit is contained in:
parent
963b360a81
commit
cfd472016e
@ -119,7 +119,6 @@ kotlin {
|
|||||||
// Jetpack Compose dependencies
|
// Jetpack Compose dependencies
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.ktor3)
|
implementation(libs.coil.network.ktor3)
|
||||||
@ -132,6 +131,9 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.dns.sd.kt)
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
|
// Millennium UI
|
||||||
|
implementation(libs.millennium)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
@ -164,12 +166,6 @@ kotlin {
|
|||||||
// icons
|
// icons
|
||||||
implementation(libs.feather.icons)
|
implementation(libs.feather.icons)
|
||||||
|
|
||||||
// Material3 adaptive
|
|
||||||
implementation(libs.jetbrains.material3.adaptive)
|
|
||||||
implementation(libs.jetbrains.material3.adaptive.layout)
|
|
||||||
implementation(libs.jetbrains.material3.adaptive.suite)
|
|
||||||
implementation(libs.jetbrains.material3.adaptiveNavigation3)
|
|
||||||
implementation(libs.jetbrains.material3.window.size)
|
|
||||||
// androidx-datastore
|
// androidx-datastore
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
@ -184,8 +180,6 @@ kotlin {
|
|||||||
|
|
||||||
implementation(libs.semver)
|
implementation(libs.semver)
|
||||||
|
|
||||||
implementation(libs.material.kolor)
|
|
||||||
|
|
||||||
// crypto
|
// crypto
|
||||||
implementation(libs.cryptography.core)
|
implementation(libs.cryptography.core)
|
||||||
implementation(libs.cryptography.provider.optimal)
|
implementation(libs.cryptography.provider.optimal)
|
||||||
@ -206,7 +200,6 @@ kotlin {
|
|||||||
|
|
||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
implementation(libs.compose.shimmer)
|
implementation(libs.compose.shimmer)
|
||||||
implementation(libs.compose.placeholder.material3)
|
|
||||||
|
|
||||||
// DLNA
|
// DLNA
|
||||||
implementation(libs.dns.sd.kt)
|
implementation(libs.dns.sd.kt)
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube
|
|||||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
|
|
||||||
actual class NewPipeService {
|
actual class NewPipeService {
|
||||||
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
@ -47,8 +46,7 @@ actual class NewPipeService {
|
|||||||
url = item.url,
|
url = item.url,
|
||||||
uploader = item.uploaderName,
|
uploader = item.uploaderName,
|
||||||
durationMs = item.duration,
|
durationMs = item.duration,
|
||||||
thumbnailUrl = item.thumbnails.first().url,
|
thumbnailUrl = item.thumbnails.first().url)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -67,8 +65,7 @@ actual class NewPipeService {
|
|||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = stream.codec,
|
||||||
container = stream.format!!.suffix,
|
container = stream.format!!.suffix,
|
||||||
bitrate = stream.bitrate,
|
bitrate = stream.bitrate)
|
||||||
)
|
|
||||||
} + buildList {
|
} + buildList {
|
||||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||||
AudioStream.Lossy(
|
AudioStream.Lossy(
|
||||||
@ -90,7 +87,6 @@ actual class NewPipeService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val videoStreams = extractor.videoStreams
|
val videoStreams = extractor.videoStreams
|
||||||
.filter { stream -> stream.isUrl && stream.format != null }
|
.filter { stream -> stream.isUrl && stream.format != null }
|
||||||
.map { stream ->
|
.map { stream ->
|
||||||
@ -98,8 +94,7 @@ actual class NewPipeService {
|
|||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = stream.codec,
|
||||||
bitrate = stream.bitrate,
|
bitrate = stream.bitrate,
|
||||||
container = stream.format!!.suffix,
|
container = stream.format!!.suffix)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
VideoInfo(
|
VideoInfo(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
|||||||
collectionPlaybackHelper = get(),
|
collectionPlaybackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat
|
|||||||
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ }
|
||||||
) { /* result is picked up by discovery/advertising retry loops */ }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
val granted = ContextCompat.checkSelfPermission(
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,8 +53,7 @@ class MediaBrowseHelper(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) {
|
||||||
) {
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MEDIA_ID_ROOT = "root"
|
const val MEDIA_ID_ROOT = "root"
|
||||||
@ -253,8 +252,7 @@ class MediaBrowseHelper(
|
|||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||||
@ -390,8 +388,7 @@ class MediaBrowseHelper(
|
|||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||||
@ -475,8 +472,7 @@ class MediaBrowseHelper(
|
|||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||||
|
|||||||
@ -32,8 +32,7 @@ import java.io.File
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AndroidLocalMediaDiscoveryService(
|
class AndroidLocalMediaDiscoveryService(
|
||||||
private val context: Context,
|
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||||
) : LocalMediaDiscoveryService, KoinComponent {
|
|
||||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||||
|
|
||||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
||||||
@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
MediaStore.Audio.Media.ARTIST,
|
MediaStore.Audio.Media.ARTIST,
|
||||||
MediaStore.Audio.Media.ALBUM,
|
MediaStore.Audio.Media.ALBUM,
|
||||||
MediaStore.Audio.Media.DURATION,
|
MediaStore.Audio.Media.DURATION,
|
||||||
MediaStore.Audio.Media.MIME_TYPE,
|
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
|
||||||
) + if (Build.VERSION.SDK_INT >= 29) {
|
|
||||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||||
} else {
|
} else {
|
||||||
emptyArray()
|
emptyArray()
|
||||||
@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
artists = listOfNotNull(artist),
|
artists = listOfNotNull(artist),
|
||||||
durationMs = durationMs,
|
durationMs = durationMs,
|
||||||
album = album,
|
album = album,
|
||||||
coverBytes = null,
|
coverBytes = null)
|
||||||
)
|
|
||||||
|
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||||
acceptedRows += 1
|
acceptedRows += 1
|
||||||
@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = folderPath,
|
path = folderPath,
|
||||||
name = normalizeFolderName(folderPath),
|
name = normalizeFolderName(folderPath),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
|
|
||||||
@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
override fun observeChanges(
|
override fun observeChanges(
|
||||||
roots: List<String>,
|
roots: List<String>,
|
||||||
onChanged: LocalMediaChangeCallback,
|
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
||||||
): LocalMediaObservation? {
|
|
||||||
logger.i { "observeChanges: start roots=${roots.size}" }
|
logger.i { "observeChanges: start roots=${roots.size}" }
|
||||||
if (!hasReadPermission()) {
|
if (!hasReadPermission()) {
|
||||||
logger.w { "observeChanges: missing media permission, observer not registered" }
|
logger.w { "observeChanges: missing media permission, observer not registered" }
|
||||||
@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
context.contentResolver.registerContentObserver(
|
context.contentResolver.registerContentObserver(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
true,
|
true,
|
||||||
observer,
|
observer)
|
||||||
)
|
|
||||||
|
|
||||||
logger.i { "observeChanges: observer registered" }
|
logger.i { "observeChanges: observer registered" }
|
||||||
|
|
||||||
@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
artists = emptyList(),
|
artists = emptyList(),
|
||||||
durationMs = 0L,
|
durationMs = 0L,
|
||||||
album = null,
|
album = null,
|
||||||
coverBytes = null,
|
coverBytes = null)
|
||||||
)
|
|
||||||
|
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||||
}
|
}
|
||||||
@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = folderPath,
|
path = folderPath,
|
||||||
name = normalizeFolderName(folderPath),
|
name = normalizeFolderName(folderPath),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
|
|
||||||
@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
|
||||||
)
|
|
||||||
|
|
||||||
return (configured + defaults)
|
return (configured + defaults)
|
||||||
.map { it.absoluteFile }
|
.map { it.absoluteFile }
|
||||||
@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
private fun mergeFolders(
|
private fun mergeFolders(
|
||||||
mediaStoreFolders: List<LocalMediaFolder>,
|
mediaStoreFolders: List<LocalMediaFolder>,
|
||||||
fallbackFolders: List<LocalMediaFolder>,
|
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
||||||
): List<LocalMediaFolder> {
|
|
||||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
||||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
||||||
|
|
||||||
@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = path,
|
path = path,
|
||||||
name = normalizeFolderName(path),
|
name = normalizeFolderName(path),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
}
|
}
|
||||||
@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
"wav",
|
"wav",
|
||||||
"ogg",
|
"ogg",
|
||||||
"opus",
|
"opus",
|
||||||
"wma",
|
"wma")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
|
|||||||
|
|
||||||
var isGranted by remember {
|
var isGranted by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
) { granted ->
|
|
||||||
isGranted = granted ||
|
isGranted = granted ||
|
||||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
return LocalMediaPermissionState(
|
return LocalMediaPermissionState(
|
||||||
isGranted = isGranted,
|
isGranted = isGranted,
|
||||||
requestPermission = { launcher.launch(permission) },
|
requestPermission = { launcher.launch(permission) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -76,6 +76,16 @@
|
|||||||
<string name="settings_appearance_accent_label">Accent</string>
|
<string name="settings_appearance_accent_label">Accent</string>
|
||||||
<string name="settings_preview_secondary">Secondary</string>
|
<string name="settings_preview_secondary">Secondary</string>
|
||||||
|
|
||||||
|
<string name="settings_palette_title">Palette</string>
|
||||||
|
<string name="settings_palette_subtitle_current">Millennium accent palette. Current: %1$s</string>
|
||||||
|
<string name="settings_palette_dialog_title">Palette</string>
|
||||||
|
<string name="settings_palette_dialog_description">Choose a Millennium accent palette and preview it in light and dark.</string>
|
||||||
|
<string name="settings_palette_label">Palette</string>
|
||||||
|
<string name="settings_appearance_subtitle_palette">Millennium palette and accent. Current: %1$s</string>
|
||||||
|
<string name="settings_palette_blue">Blue</string>
|
||||||
|
<string name="settings_palette_orange">Orange</string>
|
||||||
|
<string name="settings_palette_lime">Lime</string>
|
||||||
|
|
||||||
<string name="settings_download_folder_title">Download folder override</string>
|
<string name="settings_download_folder_title">Download folder override</string>
|
||||||
<string name="settings_download_folder_current">Current folder: %1$s</string>
|
<string name="settings_download_folder_current">Current folder: %1$s</string>
|
||||||
<string name="settings_download_folder_default">Current folder: default music folder</string>
|
<string name="settings_download_folder_default">Current folder: default music folder</string>
|
||||||
|
|||||||
@ -22,13 +22,11 @@ import androidx.compose.animation.SharedTransitionLayout
|
|||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
@ -37,9 +35,6 @@ import dev.krtirtho.spotube.core.navigation.Routes
|
|||||||
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
|
||||||
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
import dev.krtirtho.spotube.core.navigation.rememberNavigationState
|
||||||
import dev.krtirtho.spotube.core.navigation.toEntries
|
import dev.krtirtho.spotube.core.navigation.toEntries
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||||
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
|
||||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||||
@ -81,8 +76,7 @@ interface NavigationItem {
|
|||||||
|
|
||||||
private val tabs = listOf<NavigationItem>(
|
private val tabs = listOf<NavigationItem>(
|
||||||
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
|
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
|
||||||
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search),
|
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search))
|
||||||
)
|
|
||||||
|
|
||||||
val sidebarTabs = tabs + listOf(
|
val sidebarTabs = tabs + listOf(
|
||||||
NavigationItem.Group(
|
NavigationItem.Group(
|
||||||
@ -95,8 +89,7 @@ val sidebarTabs = tabs + listOf(
|
|||||||
LibraryTab.Playlists
|
LibraryTab.Playlists
|
||||||
),
|
),
|
||||||
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
|
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
|
||||||
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists),
|
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists))
|
||||||
)
|
|
||||||
),
|
),
|
||||||
NavigationItem.Group(
|
NavigationItem.Group(
|
||||||
"On Device",
|
"On Device",
|
||||||
@ -112,17 +105,13 @@ val sidebarTabs = tabs + listOf(
|
|||||||
Iconsax.IconsaxDirectboxReceive,
|
Iconsax.IconsaxDirectboxReceive,
|
||||||
Routes.Library,
|
Routes.Library,
|
||||||
LibraryTab.Downloads
|
LibraryTab.Downloads
|
||||||
),
|
))
|
||||||
)
|
|
||||||
),
|
),
|
||||||
NavigationItem.Group(
|
NavigationItem.Group(
|
||||||
"Connect",
|
"Connect",
|
||||||
items = listOf(
|
items = listOf(
|
||||||
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
|
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
|
||||||
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam),
|
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam))))
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val bottombarTabs = tabs + listOf(
|
val bottombarTabs = tabs + listOf(
|
||||||
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
|
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
|
||||||
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
|
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
|
||||||
@ -159,69 +148,51 @@ fun App(
|
|||||||
}
|
}
|
||||||
|
|
||||||
SpotubeTheme(settings = userSettings) {
|
SpotubeTheme(settings = userSettings) {
|
||||||
val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val accent = if (isDark) {
|
val currentRoute =
|
||||||
userSettings.accentColor.toDarkColor()
|
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||||
} else {
|
|
||||||
userSettings.accentColor.toLightColor()
|
|
||||||
}
|
|
||||||
val baseUITheme = rememberBaseUITheme(
|
|
||||||
surfaceTheme = userSettings.surfaceTheme,
|
|
||||||
accentColor = accent,
|
|
||||||
)
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalBaseUITheme provides baseUITheme,
|
|
||||||
LocalBaseUIColors provides baseUITheme.colors,
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
val currentRoute =
|
|
||||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AppShell(navigator, navigationState) {
|
AppShell(navigator, navigationState) {
|
||||||
SharedTransitionLayout {
|
SharedTransitionLayout {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
LocalSharedTransitionScope provides this@SharedTransitionLayout) {
|
||||||
) {
|
Column {
|
||||||
Column {
|
NavDisplay(
|
||||||
NavDisplay(
|
modifier = Modifier.fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxSize(),
|
onBack = navigator::pop,
|
||||||
onBack = navigator::pop,
|
entries = navigationState.toEntries(koinEntryProvider())
|
||||||
entries = navigationState.toEntries(koinEntryProvider())
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
when (currentRoute) {
|
}
|
||||||
Routes.WebView -> WebViewScreen(koinInject())
|
when (currentRoute) {
|
||||||
else -> {}
|
Routes.WebView -> WebViewScreen(koinInject())
|
||||||
}
|
else -> {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
}
|
||||||
availableAppUpdate?.let { update ->
|
content()
|
||||||
AppUpdateDialog(
|
availableAppUpdate?.let { update ->
|
||||||
update = update,
|
AppUpdateDialog(
|
||||||
onDismiss = appUpdateService::dismissUpdate,
|
update = update,
|
||||||
onIgnore = appUpdateService::ignoreUpdate,
|
onDismiss = appUpdateService::dismissUpdate,
|
||||||
onUpdate = {
|
onIgnore = appUpdateService::ignoreUpdate,
|
||||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
onUpdate = {
|
||||||
appUpdateService.dismissUpdate()
|
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||||
},
|
appUpdateService.dismissUpdate()
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
availablePluginUpdate?.let { update ->
|
availablePluginUpdate?.let { update ->
|
||||||
PluginUpdateDialog(
|
PluginUpdateDialog(
|
||||||
update = update,
|
update = update,
|
||||||
onDismiss = pluginUpdateService::dismissUpdate,
|
onDismiss = pluginUpdateService::dismissUpdate,
|
||||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||||
onUpdate = {
|
onUpdate = {
|
||||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||||
pluginUpdateService.dismissUpdate()
|
pluginUpdateService.dismissUpdate()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
||||||
interface AudioPlayerInterface {
|
interface AudioPlayerInterface {
|
||||||
// // Playback
|
// // Playback
|
||||||
suspend fun play()
|
suspend fun play()
|
||||||
|
|||||||
@ -30,8 +30,7 @@ data class MediaItem(
|
|||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val coverURL: String,
|
val coverURL: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
|
||||||
)
|
|
||||||
|
|
||||||
enum class LoopState {
|
enum class LoopState {
|
||||||
NONE, ONE, ALL;
|
NONE, ONE, ALL;
|
||||||
@ -60,8 +59,7 @@ sealed interface QueueEntry {
|
|||||||
val track: MetadataTrack,
|
val track: MetadataTrack,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
override val addedBy: String = "",
|
override val addedBy: String = "") : QueueEntry
|
||||||
) : QueueEntry
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("local")
|
@SerialName("local")
|
||||||
@ -72,8 +70,7 @@ sealed interface QueueEntry {
|
|||||||
val album: String?,
|
val album: String?,
|
||||||
val coverBytes: ByteArray?,
|
val coverBytes: ByteArray?,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
override val addedBy: String = "",
|
override val addedBy: String = "") : QueueEntry {
|
||||||
) : QueueEntry {
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other == null || this::class != other::class) return false
|
if (other == null || this::class != other::class) return false
|
||||||
@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playlist")
|
@SerialName("playlist")
|
||||||
data class Playlist(
|
data class Playlist(
|
||||||
override val id: String,
|
override val id: String) : QueueCollectionEntry
|
||||||
) : QueueCollectionEntry
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("album")
|
@SerialName("album")
|
||||||
data class Album(
|
data class Album(
|
||||||
override val id: String,
|
override val id: String) : QueueCollectionEntry
|
||||||
) : QueueCollectionEntry
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("saved_tracks")
|
@SerialName("saved_tracks")
|
||||||
@ -132,5 +127,4 @@ data class PersistedQueueState(
|
|||||||
val entries: List<QueueEntry>,
|
val entries: List<QueueEntry>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int,
|
||||||
val currentCollectionEntry: QueueCollectionEntry? = null,
|
val currentCollectionEntry: QueueCollectionEntry? = null,
|
||||||
val collectionHistory: List<QueueCollectionEntry> = emptyList(),
|
val collectionHistory: List<QueueCollectionEntry> = emptyList())
|
||||||
)
|
|
||||||
|
|||||||
@ -29,8 +29,7 @@ interface AudioPlayerQueue {
|
|||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
autoPlay: Boolean = true,
|
autoPlay: Boolean = true,
|
||||||
startPosition: Int = 0,
|
startPosition: Int = 0,
|
||||||
collectionEntry: QueueCollectionEntry? = null,
|
collectionEntry: QueueCollectionEntry? = null)
|
||||||
)
|
|
||||||
suspend fun addToQueue(entry: QueueEntry)
|
suspend fun addToQueue(entry: QueueEntry)
|
||||||
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
||||||
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
||||||
|
|||||||
@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
private val repository: QueueStateRepository,
|
private val repository: QueueStateRepository,
|
||||||
private val pluginProvider: PluginProvider,
|
private val pluginProvider: PluginProvider,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||||
) : AudioPlayerQueue, KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
|
|
||||||
@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
entryByMediaUrl,
|
entryByMediaUrl) { playlist, knownEntries ->
|
||||||
) { playlist, knownEntries ->
|
|
||||||
playlist.map { mediaItem ->
|
playlist.map { mediaItem ->
|
||||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||||
}
|
}
|
||||||
@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
entryByMediaUrl,
|
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||||
) { mediaItem, knownEntries ->
|
|
||||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
combine(
|
combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
audioPlayer.loopStateFlow,
|
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
|
||||||
) { currentMedia, playlist, loopState ->
|
|
||||||
Triple(currentMedia, playlist, loopState)
|
Triple(currentMedia, playlist, loopState)
|
||||||
}.collect { (currentMedia, playlist, loopState) ->
|
}.collect { (currentMedia, playlist, loopState) ->
|
||||||
if (currentMedia == null || playlist.isEmpty()) return@collect
|
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||||
@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
collectionEntry: QueueCollectionEntry?,
|
collectionEntry: QueueCollectionEntry?) {
|
||||||
) {
|
|
||||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||||
if (mediaItems.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
entries = queueEntries,
|
entries = queueEntries,
|
||||||
currentIndex = normalizedCurrentIndex,
|
currentIndex = normalizedCurrentIndex,
|
||||||
currentCollectionEntry = currentCollectionEntryState.value,
|
currentCollectionEntry = currentCollectionEntryState.value,
|
||||||
collectionHistory = collectionHistoryState.value,
|
collectionHistory = collectionHistoryState.value)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = track.durationMs.milliseconds,
|
duration = track.durationMs.milliseconds,
|
||||||
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
||||||
url = resolvedUrl,
|
url = resolvedUrl,
|
||||||
protocol = protocol,
|
protocol = protocol)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> {
|
is QueueEntry.LocalTrack -> {
|
||||||
@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
album = album.orEmpty(),
|
album = album.orEmpty(),
|
||||||
duration = duration.milliseconds,
|
duration = duration.milliseconds,
|
||||||
coverURL = "",
|
coverURL = "",
|
||||||
url = url,
|
url = url)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = duration.inWholeMilliseconds,
|
duration = duration.inWholeMilliseconds,
|
||||||
album = album.ifBlank { null },
|
album = album.ifBlank { null },
|
||||||
coverBytes = null,
|
coverBytes = null,
|
||||||
url = url,
|
url = url)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
||||||
|
|||||||
@ -86,8 +86,7 @@ class Database(val paths: Paths) {
|
|||||||
"spotube_audio_player_queue.preferences_pb",
|
"spotube_audio_player_queue.preferences_pb",
|
||||||
"spotube_local_media.preferences_pb",
|
"spotube_local_media.preferences_pb",
|
||||||
"spotube_blacklist.preferences_pb",
|
"spotube_blacklist.preferences_pb",
|
||||||
"spotube_cookies.preferences_pb",
|
"spotube_cookies.preferences_pb")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -168,8 +168,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved Tracks
|
// Saved Tracks
|
||||||
@ -183,8 +182,7 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
@ -199,8 +197,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blacklist
|
// Blacklist
|
||||||
@ -212,8 +209,7 @@ val sharedModules = module {
|
|||||||
JamViewModel(
|
JamViewModel(
|
||||||
jamRoomService = get(),
|
jamRoomService = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
settingsProvider = get(),
|
settingsProvider = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
@ -229,8 +225,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lyrics
|
// Lyrics
|
||||||
@ -244,8 +239,7 @@ val sharedModules = module {
|
|||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
artistRepository = get(),
|
artistRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||||
single {
|
single {
|
||||||
@ -253,8 +247,7 @@ val sharedModules = module {
|
|||||||
pluginManager = get(),
|
pluginManager = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
matchedTracksRepository = get(),
|
matchedTracksRepository = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
singleOf(::CacheManager)
|
singleOf(::CacheManager)
|
||||||
singleOf(::AlternativeTracksRepository)
|
singleOf(::AlternativeTracksRepository)
|
||||||
|
|||||||
@ -34,8 +34,7 @@ import kotlin.time.Clock
|
|||||||
|
|
||||||
class DiscordRpcService(
|
class DiscordRpcService(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
|
||||||
) : KoinComponent, AutoCloseable {
|
|
||||||
|
|
||||||
private val logger by injectLogger<DiscordRpcService>()
|
private val logger by injectLogger<DiscordRpcService>()
|
||||||
|
|
||||||
@ -61,8 +60,7 @@ class DiscordRpcService(
|
|||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow,
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
audioPlayer.positionFlow,
|
audioPlayer.positionFlow,
|
||||||
audioPlayer.durationFlow,
|
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
|
||||||
) { settings, playerState, mediaItem, position, duration ->
|
|
||||||
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
||||||
}.collect { state ->
|
}.collect { state ->
|
||||||
handleStateChange(state)
|
handleStateChange(state)
|
||||||
@ -86,8 +84,7 @@ class DiscordRpcService(
|
|||||||
album = mediaItem.album,
|
album = mediaItem.album,
|
||||||
coverUrl = mediaItem.coverURL,
|
coverUrl = mediaItem.coverURL,
|
||||||
positionMs = state.position.inWholeMilliseconds,
|
positionMs = state.position.inWholeMilliseconds,
|
||||||
durationMs = state.duration.inWholeMilliseconds,
|
durationMs = state.duration.inWholeMilliseconds)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PlayerState.PAUSED -> {
|
PlayerState.PAUSED -> {
|
||||||
val mediaItem = state.mediaItem ?: return
|
val mediaItem = state.mediaItem ?: return
|
||||||
@ -98,8 +95,7 @@ class DiscordRpcService(
|
|||||||
album = mediaItem.album,
|
album = mediaItem.album,
|
||||||
coverUrl = mediaItem.coverURL,
|
coverUrl = mediaItem.coverURL,
|
||||||
positionMs = state.position.inWholeMilliseconds,
|
positionMs = state.position.inWholeMilliseconds,
|
||||||
durationMs = state.duration.inWholeMilliseconds,
|
durationMs = state.duration.inWholeMilliseconds)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
||||||
clearPresence()
|
clearPresence()
|
||||||
@ -158,8 +154,7 @@ class DiscordRpcService(
|
|||||||
album: String,
|
album: String,
|
||||||
coverUrl: String,
|
coverUrl: String,
|
||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
durationMs: Long,
|
durationMs: Long) {
|
||||||
) {
|
|
||||||
val client = rpcClient ?: return
|
val client = rpcClient ?: return
|
||||||
try {
|
try {
|
||||||
client.updatePresence(
|
client.updatePresence(
|
||||||
@ -168,8 +163,7 @@ class DiscordRpcService(
|
|||||||
album = album,
|
album = album,
|
||||||
coverUrl = coverUrl,
|
coverUrl = coverUrl,
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
rpcClient = null
|
rpcClient = null
|
||||||
connectionFailed = true
|
connectionFailed = true
|
||||||
@ -210,6 +204,5 @@ class DiscordRpcService(
|
|||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||||
val position: kotlin.time.Duration,
|
val position: kotlin.time.Duration,
|
||||||
val duration: kotlin.time.Duration,
|
val duration: kotlin.time.Duration)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,8 +30,7 @@ data class DiscoveredDevice(
|
|||||||
val type: String,
|
val type: String,
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
val deviceId: String,
|
val deviceId: String) {
|
||||||
) {
|
|
||||||
val key: String get() = "$name$type".replace(".", "")
|
val key: String get() = "$name$type".replace(".", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,8 +54,7 @@ class DeviceDiscoveryService {
|
|||||||
type = event.service.type,
|
type = event.service.type,
|
||||||
host = event.service.host,
|
host = event.service.host,
|
||||||
port = event.service.port,
|
port = event.service.port,
|
||||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
|
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||||
)
|
|
||||||
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,8 +64,7 @@ class DeviceDiscoveryService {
|
|||||||
type = event.service.type,
|
type = event.service.type,
|
||||||
host = event.service.host,
|
host = event.service.host,
|
||||||
port = event.service.port,
|
port = event.service.port,
|
||||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
|
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||||
)
|
|
||||||
DiscoveryState.Resolved(device = device)
|
DiscoveryState.Resolved(device = device)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,8 +74,7 @@ class DeviceDiscoveryService {
|
|||||||
type = event.service.type,
|
type = event.service.type,
|
||||||
host = event.service.host,
|
host = event.service.host,
|
||||||
port = event.service.port,
|
port = event.service.port,
|
||||||
deviceId = "",
|
deviceId = "")
|
||||||
)
|
|
||||||
DiscoveryState.Removed(device = device)
|
DiscoveryState.Removed(device = device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,8 +84,7 @@ class DeviceDiscoveryService {
|
|||||||
name: String,
|
name: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
registerTimeoutMs: Long = 5_000,
|
registerTimeoutMs: Long = 5_000): NetService {
|
||||||
): NetService {
|
|
||||||
val service = createService(name, port, deviceId)
|
val service = createService(name, port, deviceId)
|
||||||
service.register(timeoutInMs = registerTimeoutMs)
|
service.register(timeoutInMs = registerTimeoutMs)
|
||||||
return service
|
return service
|
||||||
@ -98,11 +93,9 @@ class DeviceDiscoveryService {
|
|||||||
fun createService(
|
fun createService(
|
||||||
name: String,
|
name: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
deviceId: String,
|
deviceId: String): NetService = createNetService(
|
||||||
): NetService = createNetService(
|
|
||||||
type = SERVICE_TYPE,
|
type = SERVICE_TYPE,
|
||||||
name = name,
|
name = name,
|
||||||
port = port,
|
port = port,
|
||||||
txt = mapOf(TXT_DEVICE_ID to deviceId),
|
txt = mapOf(TXT_DEVICE_ID to deviceId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@ -46,8 +46,7 @@ sealed class JamMessage {
|
|||||||
*/
|
*/
|
||||||
val follow: Boolean = false,
|
val follow: Boolean = false,
|
||||||
/** Host's live play state — late-joining guests start with it. */
|
/** Host's live play state — late-joining guests start with it. */
|
||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false) : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playbackCommand")
|
@SerialName("playbackCommand")
|
||||||
@ -57,22 +56,19 @@ sealed class JamMessage {
|
|||||||
@SerialName("suggestTrack")
|
@SerialName("suggestTrack")
|
||||||
data class SuggestTrack(
|
data class SuggestTrack(
|
||||||
val mediaItem: JamMediaItem,
|
val mediaItem: JamMediaItem,
|
||||||
val addedBy: String = "",
|
val addedBy: String = "") : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("suggestPlaylist")
|
@SerialName("suggestPlaylist")
|
||||||
data class SuggestPlaylist(
|
data class SuggestPlaylist(
|
||||||
val tracks: List<JamMediaItem>,
|
val tracks: List<JamMediaItem>,
|
||||||
val addedBy: String = "",
|
val addedBy: String = "") : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("kick")
|
@SerialName("kick")
|
||||||
data class Kick(
|
data class Kick(
|
||||||
val participantId: String,
|
val participantId: String,
|
||||||
val reason: String = "kicked",
|
val reason: String = "kicked") : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("leave")
|
@SerialName("leave")
|
||||||
@ -104,8 +100,7 @@ data class JamPresence(
|
|||||||
val clientId: String,
|
val clientId: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val isHost: Boolean,
|
val isHost: Boolean,
|
||||||
val left: Boolean = false,
|
val left: Boolean = false)
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JamMediaItem(
|
data class JamMediaItem(
|
||||||
@ -117,8 +112,7 @@ data class JamMediaItem(
|
|||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val protocol: String,
|
val protocol: String,
|
||||||
val addedBy: String = "",
|
val addedBy: String = "") {
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||||
is QueueEntry.StreamingTrack -> JamMediaItem(
|
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||||
@ -132,8 +126,7 @@ data class JamMediaItem(
|
|||||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
protocol = entry.protocol.name,
|
protocol = entry.protocol.name,
|
||||||
addedBy = entry.addedBy,
|
addedBy = entry.addedBy)
|
||||||
)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||||
url = entry.url,
|
url = entry.url,
|
||||||
@ -144,8 +137,7 @@ data class JamMediaItem(
|
|||||||
durationMs = entry.duration,
|
durationMs = entry.duration,
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
protocol = "PROGRESSIVE",
|
protocol = "PROGRESSIVE",
|
||||||
addedBy = entry.addedBy,
|
addedBy = entry.addedBy)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
||||||
@ -158,8 +150,7 @@ data class JamMediaItem(
|
|||||||
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
protocol = "PROGRESSIVE",
|
protocol = "PROGRESSIVE")
|
||||||
)
|
|
||||||
|
|
||||||
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||||
url = item.url,
|
url = item.url,
|
||||||
@ -168,8 +159,7 @@ data class JamMediaItem(
|
|||||||
album = item.album,
|
album = item.album,
|
||||||
durationMs = item.duration.inWholeMilliseconds,
|
durationMs = item.duration.inWholeMilliseconds,
|
||||||
coverUrl = item.coverURL,
|
coverUrl = item.coverURL,
|
||||||
protocol = item.protocol.name,
|
protocol = item.protocol.name)
|
||||||
)
|
|
||||||
|
|
||||||
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
||||||
title = item.title,
|
title = item.title,
|
||||||
@ -179,8 +169,7 @@ data class JamMediaItem(
|
|||||||
coverURL = item.coverUrl,
|
coverURL = item.coverUrl,
|
||||||
url = item.url,
|
url = item.url,
|
||||||
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }),
|
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,8 +177,7 @@ data class JamMediaItem(
|
|||||||
data class JamParticipant(
|
data class JamParticipant(
|
||||||
val id: String,
|
val id: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val isHost: Boolean,
|
val isHost: Boolean)
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class JamRole {
|
enum class JamRole {
|
||||||
|
|||||||
@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName
|
|||||||
import com.ditchoom.mqtt.controlpacket.WillConfig
|
import com.ditchoom.mqtt.controlpacket.WillConfig
|
||||||
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
||||||
import dev.krtirtho.spotube.modules.settings.JamBroker
|
import dev.krtirtho.spotube.modules.settings.JamBroker
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -47,6 +45,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||||
@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
code: String,
|
code: String,
|
||||||
clientId: String,
|
clientId: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
isHost: Boolean,
|
isHost: Boolean): Result<Unit> {
|
||||||
): Result<Unit> {
|
|
||||||
disconnect()
|
disconnect()
|
||||||
if (broker.host.isBlank()) {
|
if (broker.host.isBlank()) {
|
||||||
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
||||||
@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
isHost = isHost,
|
isHost = isHost,
|
||||||
left = false,
|
left = false)
|
||||||
)
|
|
||||||
|
|
||||||
val mqtt = startClient(broker, clientId)
|
val mqtt = startClient(broker, clientId)
|
||||||
client = mqtt
|
client = mqtt
|
||||||
@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
publishJson(
|
publishJson(
|
||||||
presenceTopic(code, presence.clientId),
|
presenceTopic(code, presence.clientId),
|
||||||
json.encodeToString(JamPresence.serializer(), presence),
|
json.encodeToString(JamPresence.serializer(), presence),
|
||||||
retain = true,
|
retain = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marks us as the host in presence (host takeover). */
|
/** Marks us as the host in presence (host takeover). */
|
||||||
@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
publishJson(
|
publishJson(
|
||||||
presenceTopic(code, presence.clientId),
|
presenceTopic(code, presence.clientId),
|
||||||
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
||||||
retain = true,
|
retain = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent {
|
|||||||
host = broker.host,
|
host = broker.host,
|
||||||
port = broker.port,
|
port = broker.port,
|
||||||
tlsEnabled = broker.useTls,
|
tlsEnabled = broker.useTls,
|
||||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
connectionTimeout = broker.connectionTimeoutSeconds.seconds)
|
||||||
)
|
|
||||||
val code = roomCode ?: "unset"
|
val code = roomCode ?: "unset"
|
||||||
val will = WillConfig.Enabled(
|
val will = WillConfig.Enabled(
|
||||||
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
||||||
payload = json.encodeToString(
|
payload = json.encodeToString(
|
||||||
JamPresence.serializer(),
|
JamPresence.serializer(),
|
||||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
|
||||||
).toReadBuffer(Charset.UTF8),
|
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
retain = true,
|
retain = true)
|
||||||
)
|
|
||||||
val request = ConnectionRequest(
|
val request = ConnectionRequest(
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
keepAliveSeconds = broker.keepAliveSeconds,
|
keepAliveSeconds = broker.keepAliveSeconds,
|
||||||
cleanStart = true,
|
cleanStart = true,
|
||||||
userName = broker.username,
|
userName = broker.username,
|
||||||
password = broker.password,
|
password = broker.password,
|
||||||
will = will,
|
will = will)
|
||||||
)
|
|
||||||
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
||||||
val brokerRef = persistence.addBroker(connection, request)
|
val brokerRef = persistence.addBroker(connection, request)
|
||||||
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
||||||
@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
val operation = mqtt.subscribe(
|
val operation = mqtt.subscribe(
|
||||||
roomFilter(code),
|
roomFilter(code),
|
||||||
OpaquePublishPayloadCodec,
|
OpaquePublishPayloadCodec,
|
||||||
QualityOfService.AT_LEAST_ONCE,
|
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
||||||
) { publish, payload ->
|
|
||||||
route(publish.topic.toString(), payload)
|
route(publish.topic.toString(), payload)
|
||||||
}
|
}
|
||||||
runCatching { operation.subAck.await() }
|
runCatching { operation.subAck.await() }
|
||||||
@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
topicName = topic,
|
topicName = topic,
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
payload = payload.toReadBuffer(Charset.UTF8),
|
payload = payload.toReadBuffer(Charset.UTF8),
|
||||||
retain = retain,
|
retain = retain)
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.w(e) { "Failed to publish to $topic" }
|
log.w(e) { "Failed to publish to $topic" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,15 +59,13 @@ private data class PlaybackBroadcast(
|
|||||||
val queue: List<QueueEntry>,
|
val queue: List<QueueEntry>,
|
||||||
val current: QueueEntry?,
|
val current: QueueEntry?,
|
||||||
val shuffle: Boolean,
|
val shuffle: Boolean,
|
||||||
val playerState: PlayerState,
|
val playerState: PlayerState)
|
||||||
)
|
|
||||||
|
|
||||||
class JamRoomService(
|
class JamRoomService(
|
||||||
private val jamClient: JamRoomClient,
|
private val jamClient: JamRoomClient,
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository) {
|
||||||
) {
|
|
||||||
private val log = Logger.withTag("JamRoomService")
|
private val log = Logger.withTag("JamRoomService")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@ -171,8 +169,7 @@ class JamRoomService(
|
|||||||
code = code,
|
code = code,
|
||||||
clientId = localClientId,
|
clientId = localClientId,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
isHost = true,
|
isHost = true).map {
|
||||||
).map {
|
|
||||||
_role.value = JamRole.Host
|
_role.value = JamRole.Host
|
||||||
_roomCode.value = code
|
_roomCode.value = code
|
||||||
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
||||||
@ -206,8 +203,7 @@ class JamRoomService(
|
|||||||
code = normalized,
|
code = normalized,
|
||||||
clientId = localClientId,
|
clientId = localClientId,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
isHost = false,
|
isHost = false).map {
|
||||||
).map {
|
|
||||||
_role.value = JamRole.Guest
|
_role.value = JamRole.Guest
|
||||||
_roomCode.value = normalized
|
_roomCode.value = normalized
|
||||||
_participants.value = emptyList()
|
_participants.value = emptyList()
|
||||||
@ -261,8 +257,7 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestTrack(
|
JamMessage.SuggestTrack(
|
||||||
mediaItem = JamMediaItem.fromTrack(track),
|
mediaItem = JamMediaItem.fromTrack(track),
|
||||||
addedBy = localClientId,
|
addedBy = localClientId)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,8 +266,7 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestPlaylist(
|
JamMessage.SuggestPlaylist(
|
||||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||||
addedBy = localClientId,
|
addedBy = localClientId)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,8 +290,7 @@ class JamRoomService(
|
|||||||
audioPlayerQueue.queueFlow,
|
audioPlayerQueue.queueFlow,
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
audioPlayer.shuffleModeFlow,
|
audioPlayer.shuffleModeFlow,
|
||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
||||||
) { queue, current, shuffle, playerState ->
|
|
||||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||||
}
|
}
|
||||||
.onEach { broadcast ->
|
.onEach { broadcast ->
|
||||||
@ -314,8 +307,7 @@ class JamRoomService(
|
|||||||
currentIndex = index.coerceAtLeast(0),
|
currentIndex = index.coerceAtLeast(0),
|
||||||
shuffleEnabled = broadcast.shuffle,
|
shuffleEnabled = broadcast.shuffle,
|
||||||
follow = now() > autoAdvanceDeadlineMs,
|
follow = now() > autoAdvanceDeadlineMs,
|
||||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
isPlaying = broadcast.playerState == PlayerState.PLAYING)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
@ -374,8 +366,7 @@ class JamRoomService(
|
|||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = items.map { it.toQueueEntry() },
|
entries = items.map { it.toQueueEntry() },
|
||||||
autoPlay = wasPlaying,
|
autoPlay = wasPlaying,
|
||||||
startPosition = startIndex,
|
startPosition = startIndex)
|
||||||
)
|
|
||||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -524,13 +515,11 @@ class JamRoomService(
|
|||||||
explicit = null,
|
explicit = null,
|
||||||
popularity = null,
|
popularity = null,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
url = "",
|
url = "",
|
||||||
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||||
addedBy = addedBy,
|
addedBy = addedBy)
|
||||||
)
|
|
||||||
|
|
||||||
else -> QueueEntry.LocalTrack(
|
else -> QueueEntry.LocalTrack(
|
||||||
name = title,
|
name = title,
|
||||||
@ -539,8 +528,7 @@ class JamRoomService(
|
|||||||
album = album.ifBlank { null },
|
album = album.ifBlank { null },
|
||||||
coverBytes = null,
|
coverBytes = null,
|
||||||
url = url,
|
url = url,
|
||||||
addedBy = addedBy,
|
addedBy = addedBy)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
||||||
|
|||||||
@ -125,8 +125,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Artist> {
|
navigation<Routes.Artist> {
|
||||||
ArtistScreen(
|
ArtistScreen(
|
||||||
@ -136,8 +135,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Album> {
|
navigation<Routes.Album> {
|
||||||
AlbumScreen(
|
AlbumScreen(
|
||||||
@ -148,8 +146,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Lyrics> {
|
navigation<Routes.Lyrics> {
|
||||||
LyricsScreen(viewModel = koinViewModel())
|
LyricsScreen(viewModel = koinViewModel())
|
||||||
@ -162,8 +159,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Blacklist> {
|
navigation<Routes.Blacklist> {
|
||||||
BlacklistScreen()
|
BlacklistScreen()
|
||||||
|
|||||||
@ -46,8 +46,7 @@ val TOP_LEVEL_ROUTES = setOf(
|
|||||||
Routes.Library,
|
Routes.Library,
|
||||||
Routes.Settings,
|
Routes.Settings,
|
||||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
// Bootstrap destination: needs its own back stack when the app starts on it.
|
||||||
Routes.Welcome,
|
Routes.Welcome)
|
||||||
)
|
|
||||||
|
|
||||||
val serializersConfig = SavedStateConfiguration {
|
val serializersConfig = SavedStateConfiguration {
|
||||||
serializersModule = SerializersModule {
|
serializersModule = SerializersModule {
|
||||||
@ -123,8 +122,7 @@ fun NavigationState.toEntries(
|
|||||||
): SnapshotStateList<NavEntry<NavKey>> {
|
): SnapshotStateList<NavEntry<NavKey>> {
|
||||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||||
val decorators = listOf(
|
val decorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
rememberSaveableStateHolderNavEntryDecorator<NavKey>())
|
||||||
)
|
|
||||||
rememberDecoratedNavEntries(
|
rememberDecoratedNavEntries(
|
||||||
backStack = stack,
|
backStack = stack,
|
||||||
entryDecorators = decorators,
|
entryDecorators = decorators,
|
||||||
|
|||||||
@ -49,5 +49,4 @@ data class MediaStream(
|
|||||||
val container: String,
|
val container: String,
|
||||||
val bitrate: Int,
|
val bitrate: Int,
|
||||||
val isAudio: Boolean,
|
val isAudio: Boolean,
|
||||||
val isVideo: Boolean,
|
val isVideo: Boolean)
|
||||||
)
|
|
||||||
@ -34,8 +34,7 @@ class CollectionPlaybackHelper(
|
|||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val artistRepository: ArtistRepository,
|
private val artistRepository: ArtistRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) {
|
||||||
) {
|
|
||||||
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
||||||
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||||
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
||||||
@ -44,8 +43,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,8 +52,7 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,8 +84,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = startPosition,
|
startPosition = startPosition,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
||||||
@ -99,8 +95,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,8 +104,7 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,8 +136,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = startPosition,
|
startPosition = startPosition,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
||||||
@ -154,8 +147,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,8 +158,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,8 +181,7 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,8 +206,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = startPosition,
|
startPosition = startPosition,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
||||||
@ -313,8 +302,7 @@ class CollectionPlaybackHelper(
|
|||||||
private fun isTrackBlacklisted(
|
private fun isTrackBlacklisted(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
blacklistedTrackIds: Set<String>,
|
blacklistedTrackIds: Set<String>,
|
||||||
blacklistedArtistIds: Set<String>,
|
blacklistedArtistIds: Set<String>): Boolean {
|
||||||
): Boolean {
|
|
||||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,17 +17,24 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.remote
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumDialog
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingRequest?.let { request ->
|
pendingRequest?.let { request ->
|
||||||
AdaptiveDialogBottomSheet(
|
MillenniumDialog(
|
||||||
onDismiss = {
|
visible = true,
|
||||||
|
onDismissRequest = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
title = {
|
title = "Remote Control Request") {
|
||||||
Text(
|
Text(
|
||||||
text = "Remote Control Request",
|
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = AppTheme.typography.bodyMedium,
|
||||||
)
|
textAlign = TextAlign.Start)
|
||||||
},
|
Row(
|
||||||
content = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Text(
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
MillenniumButton(
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
Text("Deny")
|
Text("Deny")
|
||||||
}
|
}
|
||||||
Button(
|
MillenniumButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
}) {
|
||||||
) {
|
|
||||||
Text("Allow")
|
Text("Allow")
|
||||||
}
|
}
|
||||||
Button(
|
MillenniumButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
Text("Allow Always")
|
Text("Allow Always")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -162,8 +162,7 @@ class RemoteControlClient {
|
|||||||
|
|
||||||
val envelope = CommandEnvelope(
|
val envelope = CommandEnvelope(
|
||||||
commandId = randomShortId(),
|
commandId = randomShortId(),
|
||||||
command = command,
|
command = command)
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
||||||
|
|||||||
@ -48,13 +48,11 @@ class RemoteControlHandler(
|
|||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
val logger by injectLogger<RemoteControlHandler>()
|
val logger by injectLogger<RemoteControlHandler>()
|
||||||
|
|
||||||
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
||||||
extraBufferCapacity = 16,
|
extraBufferCapacity = 16)
|
||||||
)
|
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@ -88,8 +86,7 @@ class RemoteControlHandler(
|
|||||||
deviceId = deviceId ?: "unknown",
|
deviceId = deviceId ?: "unknown",
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
sessionId = session.call.request.headers["X-Request-Id"]
|
sessionId = session.call.request.headers["X-Request-Id"]
|
||||||
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}",
|
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}")
|
||||||
)
|
|
||||||
incomingConnectionRequests.emit(request)
|
incomingConnectionRequests.emit(request)
|
||||||
val response = waitForRequestResolution(request.sessionId)
|
val response = waitForRequestResolution(request.sessionId)
|
||||||
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
||||||
@ -225,8 +222,7 @@ class RemoteControlHandler(
|
|||||||
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is RemoteControlCommand.AddTrackToQueue -> {
|
is RemoteControlCommand.AddTrackToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
||||||
@ -300,8 +296,7 @@ class RemoteControlHandler(
|
|||||||
private suspend fun handleCollectionSource(
|
private suspend fun handleCollectionSource(
|
||||||
source: String,
|
source: String,
|
||||||
action: RemoteCollectionAction,
|
action: RemoteCollectionAction,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false) {
|
||||||
) {
|
|
||||||
logger.d { "Remote collection $action for source: $source" }
|
logger.d { "Remote collection $action for source: $source" }
|
||||||
when {
|
when {
|
||||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||||
@ -359,8 +354,7 @@ class RemoteControlHandler(
|
|||||||
currentTrackTitle = current?.titleOrNull(),
|
currentTrackTitle = current?.titleOrNull(),
|
||||||
currentTrackArtists = current?.artistsOrNull(),
|
currentTrackArtists = current?.artistsOrNull(),
|
||||||
currentTrackAlbum = current?.albumOrNull(),
|
currentTrackAlbum = current?.albumOrNull(),
|
||||||
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
currentTrackCoverUrl = current?.coverUrlOrNull())
|
||||||
)
|
|
||||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||||
session.send(Frame.Text(text))
|
session.send(Frame.Text(text))
|
||||||
}
|
}
|
||||||
@ -375,8 +369,7 @@ class RemoteControlHandler(
|
|||||||
}
|
}
|
||||||
val event = RemoteControlEvent.QueueUpdated(
|
val event = RemoteControlEvent.QueueUpdated(
|
||||||
entries = queue.map { it.toRemoteQueueEntry() },
|
entries = queue.map { it.toRemoteQueueEntry() },
|
||||||
currentIndex = currentIndex,
|
currentIndex = currentIndex)
|
||||||
)
|
|
||||||
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
||||||
session.send(Frame.Text(text))
|
session.send(Frame.Text(text))
|
||||||
}
|
}
|
||||||
@ -403,8 +396,7 @@ class RemoteControlHandler(
|
|||||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||||
album = track.album?.title,
|
album = track.album?.title,
|
||||||
coverUrl = coverUrlOrNull(),
|
coverUrl = coverUrlOrNull(),
|
||||||
durationMs = track.durationMs,
|
durationMs = track.durationMs)
|
||||||
)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||||
mediaUrl = url,
|
mediaUrl = url,
|
||||||
@ -413,8 +405,7 @@ class RemoteControlHandler(
|
|||||||
artists = artists.joinToString(", "),
|
artists = artists.joinToString(", "),
|
||||||
album = album,
|
album = album,
|
||||||
coverUrl = null,
|
coverUrl = null,
|
||||||
durationMs = duration,
|
durationMs = duration)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun QueueEntry.mediaKey(): String = when (this) {
|
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||||
@ -447,8 +438,7 @@ class RemoteControlHandler(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class CommandEnvelope(
|
data class CommandEnvelope(
|
||||||
val commandId: String,
|
val commandId: String,
|
||||||
val command: RemoteControlCommand,
|
val command: RemoteControlCommand)
|
||||||
)
|
|
||||||
|
|
||||||
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
||||||
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
||||||
@ -464,8 +454,7 @@ enum class RemoteCollectionAction {
|
|||||||
data class ConnectionRequest(
|
data class ConnectionRequest(
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val deviceName: String,
|
val deviceName: String,
|
||||||
val sessionId: String,
|
val sessionId: String)
|
||||||
)
|
|
||||||
|
|
||||||
enum class ConnectionRequestResponse {
|
enum class ConnectionRequestResponse {
|
||||||
Allow,
|
Allow,
|
||||||
|
|||||||
@ -119,15 +119,13 @@ sealed class RemoteControlEvent {
|
|||||||
val currentTrackTitle: String?,
|
val currentTrackTitle: String?,
|
||||||
val currentTrackArtists: String?,
|
val currentTrackArtists: String?,
|
||||||
val currentTrackAlbum: String?,
|
val currentTrackAlbum: String?,
|
||||||
val currentTrackCoverUrl: String?,
|
val currentTrackCoverUrl: String?) : RemoteControlEvent()
|
||||||
) : RemoteControlEvent()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("queueUpdated")
|
@SerialName("queueUpdated")
|
||||||
data class QueueUpdated(
|
data class QueueUpdated(
|
||||||
val entries: List<RemoteQueueEntry>,
|
val entries: List<RemoteQueueEntry>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int) : RemoteControlEvent()
|
||||||
) : RemoteControlEvent()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("ack")
|
@SerialName("ack")
|
||||||
@ -146,5 +144,4 @@ data class RemoteQueueEntry(
|
|||||||
val artists: String,
|
val artists: String,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val durationMs: Long,
|
val durationMs: Long)
|
||||||
)
|
|
||||||
|
|||||||
@ -53,8 +53,7 @@ import kotlin.random.Random
|
|||||||
class RemoteControlService(
|
class RemoteControlService(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val discoveryService: DeviceDiscoveryService,
|
private val discoveryService: DeviceDiscoveryService,
|
||||||
private val localServer: LocalServer,
|
private val localServer: LocalServer) {
|
||||||
) {
|
|
||||||
private val log = Logger.withTag("RemoteControlService")
|
private val log = Logger.withTag("RemoteControlService")
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
@ -78,8 +77,7 @@ class RemoteControlService(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
settingsRepository.userSettings,
|
settingsRepository.userSettings,
|
||||||
localServer.port,
|
localServer.port) { settings, port -> settings to port }
|
||||||
) { settings, port -> settings to port }
|
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { (settings, port) ->
|
.collect { (settings, port) ->
|
||||||
if (settings.allowRemoteControl && port != null) {
|
if (settings.allowRemoteControl && port != null) {
|
||||||
@ -139,8 +137,7 @@ class RemoteControlService(
|
|||||||
val service = discoveryService.createService(
|
val service = discoveryService.createService(
|
||||||
name = serviceName,
|
name = serviceName,
|
||||||
port = port,
|
port = port,
|
||||||
deviceId = deviceId,
|
deviceId = deviceId)
|
||||||
)
|
|
||||||
pendingService = service
|
pendingService = service
|
||||||
try {
|
try {
|
||||||
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
||||||
|
|||||||
@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest {
|
|||||||
val type: RemoteCollectionType,
|
val type: RemoteCollectionType,
|
||||||
val id: String,
|
val id: String,
|
||||||
val startTrack: MetadataTrack? = null,
|
val startTrack: MetadataTrack? = null,
|
||||||
val shuffle: Boolean = false,
|
val shuffle: Boolean = false) : PlaybackDestinationRequest
|
||||||
) : PlaybackDestinationRequest
|
|
||||||
|
|
||||||
data class Track(
|
data class Track(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
override val action: PlaybackDestinationAction,
|
override val action: PlaybackDestinationAction,
|
||||||
val track: MetadataTrack,
|
val track: MetadataTrack) : PlaybackDestinationRequest
|
||||||
) : PlaybackDestinationRequest
|
|
||||||
|
|
||||||
data class Tracks(
|
data class Tracks(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
override val action: PlaybackDestinationAction,
|
override val action: PlaybackDestinationAction,
|
||||||
val tracks: List<MetadataTrack>,
|
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
|
||||||
) : PlaybackDestinationRequest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,8 +87,7 @@ class RemotePlaybackController(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val jamRoomService: JamRoomService,
|
private val jamRoomService: JamRoomService) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
private val logger = Logger.withTag("RemotePlaybackController")
|
private val logger = Logger.withTag("RemotePlaybackController")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@ -113,8 +109,7 @@ class RemotePlaybackController(
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
startTrack: MetadataTrack? = null,
|
startTrack: MetadataTrack? = null,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false) {
|
||||||
) {
|
|
||||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,8 +200,7 @@ class RemotePlaybackController(
|
|||||||
QueueEntry.StreamingTrack(
|
QueueEntry.StreamingTrack(
|
||||||
track = track,
|
track = track,
|
||||||
url = "",
|
url = "",
|
||||||
addedBy = jamRoomService.participantClientId,
|
addedBy = jamRoomService.participantClientId)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -291,8 +285,7 @@ class RemotePlaybackController(
|
|||||||
entries = listOf(entry),
|
entries = listOf(entry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDestinationAction.AddToQueue -> {
|
PlaybackDestinationAction.AddToQueue -> {
|
||||||
@ -319,8 +312,7 @@ class RemotePlaybackController(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDestinationAction.AddToQueue -> {
|
PlaybackDestinationAction.AddToQueue -> {
|
||||||
|
|||||||
@ -52,8 +52,7 @@ class ScrobbleManager(
|
|||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val scrobblePluginSource: ScrobblePluginSource,
|
private val scrobblePluginSource: ScrobblePluginSource,
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<ScrobbleManager>()
|
private val logger by injectLogger<ScrobbleManager>()
|
||||||
|
|
||||||
@ -69,14 +68,12 @@ class ScrobbleManager(
|
|||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow,
|
||||||
audioPlayer.positionFlow,
|
audioPlayer.positionFlow,
|
||||||
audioPlayer.durationFlow,
|
audioPlayer.durationFlow) { entry, playerState, position, duration ->
|
||||||
) { entry, playerState, position, duration ->
|
|
||||||
PlaybackSnapshot(
|
PlaybackSnapshot(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
positionMs = position.inWholeMilliseconds,
|
positionMs = position.inWholeMilliseconds,
|
||||||
playerDurationMs = duration.inWholeMilliseconds,
|
playerDurationMs = duration.inWholeMilliseconds)
|
||||||
)
|
|
||||||
}.collect(::onPlaybackSnapshot)
|
}.collect(::onPlaybackSnapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,8 +134,7 @@ class ScrobbleManager(
|
|||||||
scrobbled = true
|
scrobbled = true
|
||||||
val track = entry.toScrobbleTrack(
|
val track = entry.toScrobbleTrack(
|
||||||
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
scope.launch { submitScrobble(track) }
|
scope.launch { submitScrobble(track) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,8 +193,7 @@ class ScrobbleManager(
|
|||||||
artistName = track.artists.joinToString(", ") { it.name },
|
artistName = track.artists.joinToString(", ") { it.name },
|
||||||
albumName = track.album?.title,
|
albumName = track.album?.title,
|
||||||
streamingProvider = resolveStreamingProvider(track),
|
streamingProvider = resolveStreamingProvider(track),
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@ -209,8 +204,7 @@ class ScrobbleManager(
|
|||||||
artistName = artists.joinToString(", "),
|
artistName = artists.joinToString(", "),
|
||||||
albumName = album,
|
albumName = album,
|
||||||
streamingProvider = LOCAL_PROVIDER,
|
streamingProvider = LOCAL_PROVIDER,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,8 +239,7 @@ class ScrobbleManager(
|
|||||||
val entry: QueueEntry?,
|
val entry: QueueEntry?,
|
||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
val playerDurationMs: Long,
|
val playerDurationMs: Long)
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
||||||
|
|||||||
@ -29,8 +29,7 @@ class AlternativeTracksRepository(
|
|||||||
private val matchedTracksRepository: TrackSourceRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||||
|
|
||||||
|
|||||||
@ -51,13 +51,11 @@ data class CacheEntry(
|
|||||||
val filename: String,
|
val filename: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
val createdAtMs: Long,
|
val createdAtMs: Long,
|
||||||
val contentType: String = "application/octet-stream",
|
val contentType: String = "application/octet-stream")
|
||||||
)
|
|
||||||
|
|
||||||
class CacheManager(
|
class CacheManager(
|
||||||
private val paths: Paths,
|
private val paths: Paths,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository) {
|
||||||
) {
|
|
||||||
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
||||||
private val cacheMutex: Mutex = Mutex()
|
private val cacheMutex: Mutex = Mutex()
|
||||||
private val json: Json = Json { ignoreUnknownKeys = true }
|
private val json: Json = Json { ignoreUnknownKeys = true }
|
||||||
@ -160,15 +158,13 @@ class CacheManager(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
allBytes: ByteArray,
|
allBytes: ByteArray,
|
||||||
contentType: String,
|
contentType: String) {
|
||||||
) {
|
|
||||||
val entry = CacheEntry(
|
val entry = CacheEntry(
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
sizeBytes = allBytes.size.toLong(),
|
sizeBytes = allBytes.size.toLong(),
|
||||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||||
contentType = contentType,
|
contentType = contentType)
|
||||||
)
|
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
val index = readCacheIndex()
|
val index = readCacheIndex()
|
||||||
writeCacheIndex(CacheIndex(
|
writeCacheIndex(CacheIndex(
|
||||||
|
|||||||
@ -60,8 +60,7 @@ class LocalServer(
|
|||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
private val remoteControlHandler: RemoteControlHandler,
|
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
val logger by injectLogger<LocalServer>()
|
val logger by injectLogger<LocalServer>()
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
@ -99,8 +98,7 @@ class LocalServer(
|
|||||||
audioPlayerQueue = audioPlayerQueue,
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
isCachingEnabled = { cachedCacheEnabled.value },
|
isCachingEnabled = { cachedCacheEnabled.value },
|
||||||
activePort = { activePort.value },
|
activePort = { activePort.value },
|
||||||
scope = scope,
|
scope = scope)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class PluginTaggedAudioSource(
|
data class PluginTaggedAudioSource(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val source: AudioSource.Basic,
|
val source: AudioSource.Basic)
|
||||||
)
|
|
||||||
|
|
||||||
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
||||||
interface TrackSourceRepository {
|
interface TrackSourceRepository {
|
||||||
|
|||||||
@ -62,8 +62,7 @@ internal class StreamProxy(
|
|||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val isCachingEnabled: () -> Boolean,
|
private val isCachingEnabled: () -> Boolean,
|
||||||
private val activePort: () -> Int?,
|
private val activePort: () -> Int?,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope): KoinComponent {
|
||||||
): KoinComponent {
|
|
||||||
val logger by injectLogger<StreamProxy>()
|
val logger by injectLogger<StreamProxy>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -414,8 +413,7 @@ internal class StreamProxy(
|
|||||||
trackId = trackIdForCache,
|
trackId = trackIdForCache,
|
||||||
filename = cacheFilename,
|
filename = cacheFilename,
|
||||||
allBytes = allBytes,
|
allBytes = allBytes,
|
||||||
contentType = cacheContentType,
|
contentType = cacheContentType)
|
||||||
)
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
cacheManager.evictIfNeeded()
|
cacheManager.evictIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,28 +48,24 @@ data class CachedStreamUrl(
|
|||||||
// The id of the audio plugin that produced this entry. Guards against a stale entry
|
// The id of the audio plugin that produced this entry. Guards against a stale entry
|
||||||
// (e.g. a YouTube video URL) being served after the user switches to a different
|
// (e.g. a YouTube video URL) being served after the user switches to a different
|
||||||
// audio plugin, which would otherwise play back completely unrelated content.
|
// audio plugin, which would otherwise play back completely unrelated content.
|
||||||
val pluginId: String,
|
val pluginId: String)
|
||||||
)
|
|
||||||
|
|
||||||
private data class CachedAlternatives(
|
private data class CachedAlternatives(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val sources: List<AudioSource>,
|
val sources: List<AudioSource>)
|
||||||
)
|
|
||||||
|
|
||||||
data class StreamInfo(
|
data class StreamInfo(
|
||||||
val url: String,
|
val url: String,
|
||||||
val protocol: StreamProtocol,
|
val protocol: StreamProtocol,
|
||||||
val codec: String,
|
val codec: String,
|
||||||
val container: String,
|
val container: String)
|
||||||
)
|
|
||||||
|
|
||||||
class StreamingUrlRepository(
|
class StreamingUrlRepository(
|
||||||
private val pluginManager: AudioPluginSource,
|
private val pluginManager: AudioPluginSource,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: TrackSourceRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val settingsRepository: UserSettingsSource,
|
private val settingsRepository: UserSettingsSource,
|
||||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<StreamingUrlRepository>()
|
private val logger by injectLogger<StreamingUrlRepository>()
|
||||||
private val streamUrlCacheMutex = Mutex()
|
private val streamUrlCacheMutex = Mutex()
|
||||||
@ -133,8 +129,7 @@ class StreamingUrlRepository(
|
|||||||
val resolvedStream = selectPreferredAudioStream(
|
val resolvedStream = selectPreferredAudioStream(
|
||||||
stream.streams,
|
stream.streams,
|
||||||
preferredFormat,
|
preferredFormat,
|
||||||
preferredQuality,
|
preferredQuality)
|
||||||
)
|
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
@ -193,8 +188,7 @@ class StreamingUrlRepository(
|
|||||||
val resolvedStream = selectPreferredAudioStream(
|
val resolvedStream = selectPreferredAudioStream(
|
||||||
stream.streams,
|
stream.streams,
|
||||||
preferredFormat,
|
preferredFormat,
|
||||||
preferredQuality,
|
preferredQuality)
|
||||||
)
|
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
@ -207,15 +201,13 @@ class StreamingUrlRepository(
|
|||||||
url = normalizeManifestUrl(url, protocol),
|
url = normalizeManifestUrl(url, protocol),
|
||||||
protocol = protocol,
|
protocol = protocol,
|
||||||
codec = codec,
|
codec = codec,
|
||||||
container = container,
|
container = container)
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun getCachedStreamUrl(
|
private suspend fun getCachedStreamUrl(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
pluginId: String,
|
pluginId: String): StreamInfo? {
|
||||||
): StreamInfo? {
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
return streamUrlCacheMutex.withLock {
|
return streamUrlCacheMutex.withLock {
|
||||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||||
@ -241,8 +233,7 @@ class StreamingUrlRepository(
|
|||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
source: AudioSource.Streamed,
|
source: AudioSource.Streamed,
|
||||||
selectedStream: AudioStream,
|
selectedStream: AudioStream,
|
||||||
pluginId: String,
|
pluginId: String) {
|
||||||
) {
|
|
||||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||||
streamUrlCacheMutex.withLock {
|
streamUrlCacheMutex.withLock {
|
||||||
streamUrlCache[trackId] = CachedStreamUrl(
|
streamUrlCache[trackId] = CachedStreamUrl(
|
||||||
@ -255,8 +246,7 @@ class StreamingUrlRepository(
|
|||||||
selectedStream = selectedStream,
|
selectedStream = selectedStream,
|
||||||
preferredFormat = preferredFormat,
|
preferredFormat = preferredFormat,
|
||||||
preferredQuality = preferredQuality,
|
preferredQuality = preferredQuality,
|
||||||
pluginId = pluginId,
|
pluginId = pluginId)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
|||||||
internal fun selectPreferredAudioStream(
|
internal fun selectPreferredAudioStream(
|
||||||
streams: List<AudioStream>,
|
streams: List<AudioStream>,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||||
): AudioStream? = streams.minWithOrNull(
|
|
||||||
compareBy<AudioStream> { stream ->
|
compareBy<AudioStream> { stream ->
|
||||||
formatMismatchCount(stream, preferredFormat)
|
formatMismatchCount(stream, preferredFormat)
|
||||||
}.thenBy { stream ->
|
}.thenBy { stream ->
|
||||||
|
|||||||
@ -1,569 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.focus.FocusRequester
|
|
||||||
import androidx.compose.ui.focus.focusRequester
|
|
||||||
import androidx.compose.ui.focus.onFocusChanged
|
|
||||||
import androidx.compose.ui.input.key.Key
|
|
||||||
import androidx.compose.ui.input.key.KeyEvent
|
|
||||||
import androidx.compose.ui.input.key.key
|
|
||||||
import androidx.compose.ui.input.key.onPreviewKeyEvent
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.IntSize
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Popup
|
|
||||||
import androidx.compose.ui.window.PopupProperties
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
|
||||||
|
|
||||||
private val AutocompleteTextFieldMinHeight = 44.dp
|
|
||||||
private val AutocompleteMenuDefaultMaxHeight = 300.dp
|
|
||||||
private val AutocompleteMenuDefaultOffset = 4.dp
|
|
||||||
|
|
||||||
private data class ResolvedAutoCompleteTextFieldState(
|
|
||||||
val background: Brush,
|
|
||||||
val highlight: Color,
|
|
||||||
val shape: Shape,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val foreground: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveTextFieldState(
|
|
||||||
theme: BaseUITheme.TextFieldTheme,
|
|
||||||
isFocused: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedAutoCompleteTextFieldState {
|
|
||||||
return when {
|
|
||||||
isFocused -> ResolvedAutoCompleteTextFieldState(
|
|
||||||
theme.background.focused,
|
|
||||||
theme.highlight.focused,
|
|
||||||
theme.shape.focused,
|
|
||||||
theme.border.focused,
|
|
||||||
theme.shadow.focused,
|
|
||||||
theme.foreground.focused
|
|
||||||
)
|
|
||||||
|
|
||||||
isHovered -> ResolvedAutoCompleteTextFieldState(
|
|
||||||
theme.background.hovered,
|
|
||||||
theme.highlight.hovered,
|
|
||||||
theme.shape.hovered,
|
|
||||||
theme.border.hovered,
|
|
||||||
theme.shadow.hovered,
|
|
||||||
theme.foreground.hovered
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> ResolvedAutoCompleteTextFieldState(
|
|
||||||
theme.background.normal,
|
|
||||||
theme.highlight.normal,
|
|
||||||
theme.shape.normal,
|
|
||||||
theme.border.normal,
|
|
||||||
theme.shadow.normal,
|
|
||||||
theme.foreground.normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun <T> AutocompleteTextField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
items: List<T>,
|
|
||||||
itemContent: @Composable (item: T, isSelected: Boolean) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
onItemSelected: (T) -> Unit = {},
|
|
||||||
placeholder: @Composable (() -> Unit)? = null,
|
|
||||||
leadingIcon: @Composable (() -> Unit)? = null,
|
|
||||||
trailingIcon: @Composable (() -> Unit)? = null,
|
|
||||||
label: @Composable (() -> Unit)? = null,
|
|
||||||
singleLine: Boolean = true,
|
|
||||||
maxLines: Int = if (singleLine) 1 else Int.MAX_VALUE,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
readOnly: Boolean = false,
|
|
||||||
isError: Boolean = false,
|
|
||||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
|
||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
|
||||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
textStyle: TextStyle = TextStyle.Default,
|
|
||||||
cursorBrush: Color? = null,
|
|
||||||
onKeyEvent: ((KeyEvent) -> Boolean)? = null,
|
|
||||||
menuMaxHeight: Dp = AutocompleteMenuDefaultMaxHeight,
|
|
||||||
menuOffset: Dp = AutocompleteMenuDefaultOffset,
|
|
||||||
textFieldTheme: BaseUITheme.TextFieldTheme? = null,
|
|
||||||
menuTheme: BaseUITheme.AutoCompleteMenuTheme? = null,
|
|
||||||
) {
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val baseTextFieldTheme = LocalBaseUITheme.current.textField
|
|
||||||
val baseMenuTheme = LocalBaseUITheme.current.autoCompleteMenuTheme
|
|
||||||
val resolvedTextFieldTheme = textFieldTheme ?: baseTextFieldTheme
|
|
||||||
val resolvedMenuTheme = menuTheme ?: baseMenuTheme
|
|
||||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
var isMenuOpen by remember { mutableStateOf(false) }
|
|
||||||
var selectedIndex by remember { mutableIntStateOf(-1) }
|
|
||||||
var textFieldSize by remember { mutableStateOf(IntSize.Zero) }
|
|
||||||
val menuListState = rememberLazyListState()
|
|
||||||
val textFieldFocusRequester = remember { FocusRequester() }
|
|
||||||
|
|
||||||
val menuExpanded = isMenuOpen && items.isNotEmpty()
|
|
||||||
|
|
||||||
LaunchedEffect(isFocused) {
|
|
||||||
if (isFocused) {
|
|
||||||
isMenuOpen = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(value) {
|
|
||||||
if (selectedIndex != -1) selectedIndex = -1
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(selectedIndex) {
|
|
||||||
if (selectedIndex >= 0 && menuExpanded) {
|
|
||||||
menuListState.scrollToItem(selectedIndex)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
val wrappedKeyboardActions = remember(keyboardActions, menuExpanded, selectedIndex, items) {
|
|
||||||
KeyboardActions(
|
|
||||||
onSearch = {
|
|
||||||
if (menuExpanded && selectedIndex in items.indices) {
|
|
||||||
onItemSelected(items[selectedIndex])
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
} else {
|
|
||||||
keyboardActions.onSearch?.invoke(this)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
onDone = keyboardActions.onDone,
|
|
||||||
onGo = keyboardActions.onGo,
|
|
||||||
onNext = keyboardActions.onNext,
|
|
||||||
onPrevious = keyboardActions.onPrevious,
|
|
||||||
onSend = keyboardActions.onSend,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val fieldState = resolveTextFieldState(resolvedTextFieldTheme, isFocused, isHovered)
|
|
||||||
|
|
||||||
val resolvedBorder = if (isError) {
|
|
||||||
BaseUITheme.Border(MaterialTheme.colorScheme.error, fieldState.border.width)
|
|
||||||
} else {
|
|
||||||
fieldState.border
|
|
||||||
}
|
|
||||||
|
|
||||||
val contentColor = when {
|
|
||||||
!enabled -> fieldState.foreground.copy(alpha = 0.38f)
|
|
||||||
else -> fieldState.foreground
|
|
||||||
}
|
|
||||||
|
|
||||||
val cursor = cursorBrush?.let { SolidColor(it) } ?: resolvedTextFieldTheme.cursor
|
|
||||||
|
|
||||||
fun navigate(delta: Int) {
|
|
||||||
if (items.isEmpty()) return
|
|
||||||
selectedIndex = ((selectedIndex + delta) % items.size + items.size) % items.size
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
|
||||||
Column {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = label != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
label?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.onSizeChanged { textFieldSize = it }
|
|
||||||
.defaultMinSize(minHeight = AutocompleteTextFieldMinHeight)
|
|
||||||
.then(
|
|
||||||
if (fieldState.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = fieldState.shadow.elevation,
|
|
||||||
shape = fieldState.shape,
|
|
||||||
ambientColor = fieldState.shadow.ambientColor,
|
|
||||||
spotColor = fieldState.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(fieldState.shape)
|
|
||||||
.background(fieldState.background, fieldState.shape)
|
|
||||||
.border(
|
|
||||||
BorderStroke(resolvedBorder.width, resolvedBorder.color),
|
|
||||||
fieldState.shape
|
|
||||||
)
|
|
||||||
.highlight(fieldState.highlight)
|
|
||||||
.padding(resolvedTextFieldTheme.padding),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
if (leadingIcon != null) {
|
|
||||||
leadingIcon()
|
|
||||||
}
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.hoverable(
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
enabled = enabled,
|
|
||||||
)
|
|
||||||
.focusRequester(textFieldFocusRequester)
|
|
||||||
.onFocusChanged { state ->
|
|
||||||
if (!state.isFocused) {
|
|
||||||
isMenuOpen = false
|
|
||||||
selectedIndex = -1
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.onPreviewKeyEvent { event ->
|
|
||||||
if (onKeyEvent?.invoke(event) == true) {
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
if (!menuExpanded) return@onPreviewKeyEvent false
|
|
||||||
when (event.key) {
|
|
||||||
Key.DirectionDown -> {
|
|
||||||
navigate(1)
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.DirectionUp -> {
|
|
||||||
if (selectedIndex >= 0) {
|
|
||||||
navigate(-1)
|
|
||||||
true
|
|
||||||
} else false
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.Enter -> {
|
|
||||||
if (selectedIndex in items.indices) {
|
|
||||||
onItemSelected(items[selectedIndex])
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
return@onPreviewKeyEvent true
|
|
||||||
}
|
|
||||||
false
|
|
||||||
}
|
|
||||||
|
|
||||||
Key.Escape -> {
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> false
|
|
||||||
}
|
|
||||||
},
|
|
||||||
enabled = enabled,
|
|
||||||
readOnly = readOnly,
|
|
||||||
textStyle = textStyle.copy(color = contentColor),
|
|
||||||
cursorBrush = cursor,
|
|
||||||
keyboardOptions = keyboardOptions,
|
|
||||||
keyboardActions = wrappedKeyboardActions,
|
|
||||||
singleLine = singleLine,
|
|
||||||
maxLines = maxLines,
|
|
||||||
visualTransformation = visualTransformation,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
if (value.isEmpty() && placeholder != null && !isFocused) {
|
|
||||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
|
||||||
placeholder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trailingIcon != null) {
|
|
||||||
trailingIcon()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if (menuExpanded && textFieldSize.width > 0) {
|
|
||||||
val textFieldHeightPx = textFieldSize.height
|
|
||||||
val menuOffsetPx = with(density) { menuOffset.roundToPx() }
|
|
||||||
val textFieldWidthDp = with(density) { textFieldSize.width.toDp() }
|
|
||||||
|
|
||||||
Popup(
|
|
||||||
alignment = Alignment.TopStart,
|
|
||||||
offset = IntOffset(0, textFieldHeightPx + menuOffsetPx),
|
|
||||||
properties = PopupProperties(focusable = false),
|
|
||||||
onDismissRequest = {
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
AutocompleteMenuContainer(
|
|
||||||
theme = resolvedMenuTheme,
|
|
||||||
modifier = Modifier
|
|
||||||
.width(textFieldWidthDp)
|
|
||||||
.heightIn(max = menuMaxHeight),
|
|
||||||
) {
|
|
||||||
LazyColumn(
|
|
||||||
state = menuListState,
|
|
||||||
modifier = Modifier
|
|
||||||
.heightIn(max = menuMaxHeight),
|
|
||||||
) {
|
|
||||||
items(items.size) { index ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clickable {
|
|
||||||
onItemSelected(items[index])
|
|
||||||
selectedIndex = -1
|
|
||||||
isMenuOpen = false
|
|
||||||
textFieldFocusRequester.requestFocus()
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
itemContent(items[index], selectedIndex == index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The visual container of the autocomplete suggestion menu, without the Popup window.
|
|
||||||
*
|
|
||||||
* Extracted so the menu can be rendered in previews, since popups are hosted in a
|
|
||||||
* separate window the preview canvas cannot show.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun AutocompleteMenuContainer(
|
|
||||||
theme: BaseUITheme.AutoCompleteMenuTheme,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.shadow(theme.shadowElevation, theme.shape)
|
|
||||||
.background(theme.background, theme.shape)
|
|
||||||
.clip(theme.shape),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private val AutocompletePreviewArtists = listOf(
|
|
||||||
"Twenty One Pilots",
|
|
||||||
"The Beatles",
|
|
||||||
"Adele",
|
|
||||||
"Drake",
|
|
||||||
"Taylor Swift",
|
|
||||||
"Arctic Monkeys",
|
|
||||||
"Billie Eilish",
|
|
||||||
)
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun AutocompleteTextFieldPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var value by remember { mutableStateOf("") }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Field states",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
AutocompleteTextField(
|
|
||||||
value = "",
|
|
||||||
onValueChange = {},
|
|
||||||
items = emptyList<String>(),
|
|
||||||
itemContent = { _, _ -> },
|
|
||||||
placeholder = { Text("Search artists...") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxSearchBroken,
|
|
||||||
contentDescription = "Search",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
)
|
|
||||||
AutocompleteTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = { value = it },
|
|
||||||
items = AutocompletePreviewArtists.filter {
|
|
||||||
it.contains(value, ignoreCase = true)
|
|
||||||
},
|
|
||||||
onItemSelected = { value = it },
|
|
||||||
placeholder = { Text("Search artists...") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxSearchBroken,
|
|
||||||
contentDescription = "Search",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxTrash,
|
|
||||||
contentDescription = "Clear",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
itemContent = { item, isSelected ->
|
|
||||||
AutocompletePreviewItem(item, isSelected)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun AutocompleteMenuPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var selectedIndex by remember { mutableStateOf(1) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Suggestion menu",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
AutocompleteMenuContainer(
|
|
||||||
theme = LocalBaseUITheme.current.autoCompleteMenuTheme,
|
|
||||||
modifier = Modifier.width(320.dp),
|
|
||||||
) {
|
|
||||||
Column {
|
|
||||||
AutocompletePreviewArtists.take(5).forEachIndexed { index, artist ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.clickable { selectedIndex = index },
|
|
||||||
) {
|
|
||||||
AutocompletePreviewItem(artist, isSelected = selectedIndex == index)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = "Selected: ${AutocompletePreviewArtists[selectedIndex]}",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun AutocompletePreviewItem(text: String, isSelected: Boolean) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.background(
|
|
||||||
color = if (isSelected) {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.08f)
|
|
||||||
} else {
|
|
||||||
Color.Transparent
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.padding(horizontal = 14.dp, vertical = 10.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxSearchBroken,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,381 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
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.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's own colour system, independent of [MaterialTheme].
|
|
||||||
*
|
|
||||||
* Each [SurfaceStyle] is a complete visual theme with its own palette (backgrounds,
|
|
||||||
* surfaces, text and outlines) rather than Material's role colours. The user's chosen
|
|
||||||
* accent is threaded through as `primary`, but everything else comes from the surface
|
|
||||||
* theme, so the app never has to rely on Material's colour scheme.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class BaseUIColorScheme(
|
|
||||||
val surfaceTheme: SurfaceStyle,
|
|
||||||
val isLight: Boolean,
|
|
||||||
/** Full-bleed page background for the current surface theme. */
|
|
||||||
val background: Brush,
|
|
||||||
val surface: Color,
|
|
||||||
val surfaceVariant: Color,
|
|
||||||
val surfaceContainer: Color,
|
|
||||||
val surfaceContainerHigh: Color,
|
|
||||||
val surfaceContainerHighest: Color,
|
|
||||||
val onBackground: Color,
|
|
||||||
val onSurface: Color,
|
|
||||||
val onSurfaceVariant: Color,
|
|
||||||
val primary: Color,
|
|
||||||
val onPrimary: Color,
|
|
||||||
val primaryContainer: Color,
|
|
||||||
val onPrimaryContainer: Color,
|
|
||||||
val secondaryContainer: Color,
|
|
||||||
val onSecondaryContainer: Color,
|
|
||||||
val backgroundPrimary: Color,
|
|
||||||
val onBackgroundPrimary: Color,
|
|
||||||
val backgroundSecondary: Color,
|
|
||||||
val onBackgroundSecondary: Color,
|
|
||||||
val accentPrimary: Color,
|
|
||||||
val onAccentPrimary: Color,
|
|
||||||
val accentSecondary: Color,
|
|
||||||
val onAccentSecondary: Color,
|
|
||||||
val backgroundInverse: Color,
|
|
||||||
val onBackgroundInverse: Color,
|
|
||||||
val actionInverse: Color,
|
|
||||||
val onActionInverse: Color,
|
|
||||||
val outline: Color,
|
|
||||||
val outlineVariant: Color,
|
|
||||||
val error: Color,
|
|
||||||
val onError: Color,
|
|
||||||
val scrim: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
val LocalBaseUIColors = staticCompositionLocalOf<BaseUIColorScheme> {
|
|
||||||
error("No BaseUIColorScheme provided. Wrap your UI in a BaseUITheme provider.")
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Builds the [BaseUIColorScheme] for the selected [surfaceTheme] and accent.
|
|
||||||
*
|
|
||||||
* Light/dark is read from the surrounding [MaterialTheme] (which the app already drives
|
|
||||||
* from the user's theme setting), and the accent comes from the user's chosen accent
|
|
||||||
* colour. Everything else is derived from the surface theme palette.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun rememberBaseUIColorScheme(
|
|
||||||
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
|
|
||||||
accentColor: Color? = null,
|
|
||||||
): BaseUIColorScheme {
|
|
||||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
|
||||||
val accent = accentColor ?: surfaceTheme.accentColor(isLight)
|
|
||||||
return remember(surfaceTheme, isLight, accent) {
|
|
||||||
baseUIColorScheme(surfaceTheme, isLight, accent)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The default accent colour that characterises a surface theme. Every theme has its own,
|
|
||||||
* so a Watery app is aqua and a Chrome app is steel, unless an accent is passed explicitly.
|
|
||||||
*/
|
|
||||||
fun SurfaceStyle.accentColor(isLight: Boolean): Color = when (this) {
|
|
||||||
SurfaceStyle.Watery -> if (isLight) Color(0xFF0E8FD6) else Color(0xFF4FC3F7)
|
|
||||||
SurfaceStyle.Icy -> if (isLight) Color(0xFF3FA9E0) else Color(0xFF7FD8FF)
|
|
||||||
SurfaceStyle.Chrome -> if (isLight) Color(0xFF5B6B7C) else Color(0xFF9AA5B4)
|
|
||||||
SurfaceStyle.Acrylic -> if (isLight) Color(0xFF5C6BC0) else Color(0xFF8C9EFF)
|
|
||||||
SurfaceStyle.Glass -> if (isLight) Color(0xFF7C4DFF) else Color(0xFFB388FF)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The secondary accent is derived from the primary accent as a complementary hue, so it always
|
|
||||||
* shifts with — and harmonises with — the chosen accent instead of being fixed per style.
|
|
||||||
*/
|
|
||||||
private fun Color.complementaryAccent(hueShift: Float = 180f): Color {
|
|
||||||
val (hue, saturation, value) = toHsv()
|
|
||||||
return Color.hsv(
|
|
||||||
(hue + hueShift + 360f) % 360f,
|
|
||||||
saturation.coerceIn(0.45f, 0.9f),
|
|
||||||
value.coerceIn(0.45f, 0.95f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Color.toHsv(): Triple<Float, Float, Float> {
|
|
||||||
val max = maxOf(red, green, blue)
|
|
||||||
val min = minOf(red, green, blue)
|
|
||||||
val delta = max - min
|
|
||||||
val rawHue = when {
|
|
||||||
delta == 0f -> 0f
|
|
||||||
max == red -> 60f * (((green - blue) / delta) % 6f)
|
|
||||||
max == green -> 60f * (((blue - red) / delta) + 2f)
|
|
||||||
else -> 60f * (((red - green) / delta) + 4f)
|
|
||||||
}
|
|
||||||
val hue = if (rawHue < 0f) rawHue + 360f else rawHue
|
|
||||||
val saturation = if (max == 0f) 0f else delta / max
|
|
||||||
return Triple(hue, saturation, max)
|
|
||||||
}
|
|
||||||
|
|
||||||
fun baseUIColorScheme(
|
|
||||||
surfaceTheme: SurfaceStyle,
|
|
||||||
isLight: Boolean,
|
|
||||||
accent: Color,
|
|
||||||
): BaseUIColorScheme {
|
|
||||||
val palette = surfacePalette(surfaceTheme, isLight)
|
|
||||||
val onPrimary = if (accent.luminance() > 0.5f) Color(0xFF10161C) else Color.White
|
|
||||||
val primaryContainer = lerp(palette.surface, accent, if (isLight) 0.16f else 0.32f)
|
|
||||||
val onPrimaryContainer = if (isLight) {
|
|
||||||
lerp(accent, Color.Black, 0.55f)
|
|
||||||
} else {
|
|
||||||
lerp(accent, Color.White, 0.7f)
|
|
||||||
}
|
|
||||||
val secondaryContainer = lerp(palette.surface, accent, if (isLight) 0.22f else 0.34f)
|
|
||||||
val inversePalette = surfacePalette(surfaceTheme, !isLight)
|
|
||||||
val secondaryAccent = accent.complementaryAccent()
|
|
||||||
val onSecondaryAccent = if (secondaryAccent.luminance() > 0.5f) Color(0xFF10161C) else Color.White
|
|
||||||
val secondaryBackground = if (isLight) {
|
|
||||||
lerp(palette.surface, secondaryAccent, 0.14f)
|
|
||||||
} else {
|
|
||||||
lerp(palette.surface, secondaryAccent, 0.22f)
|
|
||||||
}
|
|
||||||
val onSecondaryBackground = if (secondaryBackground.luminance() > 0.5f) {
|
|
||||||
lerp(secondaryAccent, Color.Black, 0.6f)
|
|
||||||
} else {
|
|
||||||
lerp(secondaryAccent, Color.White, 0.82f)
|
|
||||||
}
|
|
||||||
val actionInverse = if (isLight) lerp(accent, Color.Black, 0.45f) else lerp(accent, Color.White, 0.5f)
|
|
||||||
val onActionInverse = if (actionInverse.luminance() > 0.5f) Color(0xFF10161C) else Color.White
|
|
||||||
|
|
||||||
return BaseUIColorScheme(
|
|
||||||
surfaceTheme = surfaceTheme,
|
|
||||||
isLight = isLight,
|
|
||||||
background = Brush.verticalGradient(
|
|
||||||
listOf(palette.backgroundTop, palette.backgroundBottom),
|
|
||||||
),
|
|
||||||
surface = palette.surface,
|
|
||||||
surfaceVariant = palette.surfaceVariant,
|
|
||||||
surfaceContainer = palette.surfaceContainer,
|
|
||||||
surfaceContainerHigh = palette.surfaceContainerHigh,
|
|
||||||
surfaceContainerHighest = palette.surfaceContainerHighest,
|
|
||||||
onBackground = palette.onBackground,
|
|
||||||
onSurface = palette.onSurface,
|
|
||||||
onSurfaceVariant = palette.onSurfaceVariant,
|
|
||||||
primary = accent,
|
|
||||||
onPrimary = onPrimary,
|
|
||||||
primaryContainer = primaryContainer,
|
|
||||||
onPrimaryContainer = onPrimaryContainer,
|
|
||||||
secondaryContainer = secondaryContainer,
|
|
||||||
onSecondaryContainer = palette.onSurface,
|
|
||||||
backgroundPrimary = palette.surface,
|
|
||||||
onBackgroundPrimary = palette.onSurface,
|
|
||||||
backgroundSecondary = secondaryBackground,
|
|
||||||
onBackgroundSecondary = onSecondaryBackground,
|
|
||||||
accentPrimary = accent,
|
|
||||||
onAccentPrimary = onPrimary,
|
|
||||||
accentSecondary = secondaryAccent,
|
|
||||||
onAccentSecondary = onSecondaryAccent,
|
|
||||||
backgroundInverse = inversePalette.surface,
|
|
||||||
onBackgroundInverse = inversePalette.onSurface,
|
|
||||||
actionInverse = actionInverse,
|
|
||||||
onActionInverse = onActionInverse,
|
|
||||||
outline = palette.outline,
|
|
||||||
outlineVariant = palette.outlineVariant,
|
|
||||||
error = if (isLight) Color(0xFFC0392B) else Color(0xFFE57373),
|
|
||||||
onError = Color.White,
|
|
||||||
scrim = Color.Black.copy(alpha = 0.42f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
internal class SurfacePalette(
|
|
||||||
val backgroundTop: Color,
|
|
||||||
val backgroundBottom: Color,
|
|
||||||
val surface: Color,
|
|
||||||
val surfaceVariant: Color,
|
|
||||||
val surfaceContainer: Color,
|
|
||||||
val surfaceContainerHigh: Color,
|
|
||||||
val surfaceContainerHighest: Color,
|
|
||||||
val onBackground: Color,
|
|
||||||
val onSurface: Color,
|
|
||||||
val onSurfaceVariant: Color,
|
|
||||||
val outline: Color,
|
|
||||||
val outlineVariant: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
internal fun surfacePalette(style: SurfaceStyle, isLight: Boolean): SurfacePalette {
|
|
||||||
return if (isLight) {
|
|
||||||
when (style) {
|
|
||||||
SurfaceStyle.Glass -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFFEAF0F7),
|
|
||||||
backgroundBottom = Color(0xFFD8E3F1),
|
|
||||||
surface = Color(0xFFF8FBFF),
|
|
||||||
surfaceVariant = Color(0xFFE4ECF5),
|
|
||||||
surfaceContainer = Color(0xFFEEF3F9),
|
|
||||||
surfaceContainerHigh = Color(0xFFF3F7FC),
|
|
||||||
surfaceContainerHighest = Color(0xFFE1E9F3),
|
|
||||||
onBackground = Color(0xFF1B2733),
|
|
||||||
onSurface = Color(0xFF1B2733),
|
|
||||||
onSurfaceVariant = Color(0xFF56677A),
|
|
||||||
outline = Color(0xFFA9B8C8),
|
|
||||||
outlineVariant = Color(0xFFD3DDE8),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Acrylic -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFFE6EAF0),
|
|
||||||
backgroundBottom = Color(0xFFD6DCE5),
|
|
||||||
surface = Color(0xFFF4F7FA),
|
|
||||||
surfaceVariant = Color(0xFFE1E7EE),
|
|
||||||
surfaceContainer = Color(0xFFEAEEF4),
|
|
||||||
surfaceContainerHigh = Color(0xFFF1F4F8),
|
|
||||||
surfaceContainerHighest = Color(0xFFDCE2E9),
|
|
||||||
onBackground = Color(0xFF1C222B),
|
|
||||||
onSurface = Color(0xFF1C222B),
|
|
||||||
onSurfaceVariant = Color(0xFF545E6C),
|
|
||||||
outline = Color(0xFFA6AEBC),
|
|
||||||
outlineVariant = Color(0xFFD2D8E0),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Watery -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFFEAF7FE),
|
|
||||||
backgroundBottom = Color(0xFFD8EFFB),
|
|
||||||
surface = Color(0xFFF0FAFF),
|
|
||||||
surfaceVariant = Color(0xFFE2F2FB),
|
|
||||||
surfaceContainer = Color(0xFFEAF6FD),
|
|
||||||
surfaceContainerHigh = Color(0xFFF2FAFE),
|
|
||||||
surfaceContainerHighest = Color(0xFFD8ECF8),
|
|
||||||
onBackground = Color(0xFF08324A),
|
|
||||||
onSurface = Color(0xFF08324A),
|
|
||||||
onSurfaceVariant = Color(0xFF3A6884),
|
|
||||||
outline = Color(0xFF9EC4DA),
|
|
||||||
outlineVariant = Color(0xFFD0E7F4),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Icy -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFFE8F7FF),
|
|
||||||
backgroundBottom = Color(0xFFC9E8FB),
|
|
||||||
surface = Color(0xFFF6FCFF),
|
|
||||||
surfaceVariant = Color(0xFFE0F1FC),
|
|
||||||
surfaceContainer = Color(0xFFEBF7FE),
|
|
||||||
surfaceContainerHigh = Color(0xFFF3FBFF),
|
|
||||||
surfaceContainerHighest = Color(0xFFD4EBFA),
|
|
||||||
onBackground = Color(0xFF0B3A55),
|
|
||||||
onSurface = Color(0xFF0B3A55),
|
|
||||||
onSurfaceVariant = Color(0xFF3D6C88),
|
|
||||||
outline = Color(0xFF93C0D9),
|
|
||||||
outlineVariant = Color(0xFFC9E5F4),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Chrome -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFFE4E8ED),
|
|
||||||
backgroundBottom = Color(0xFFD0D6DE),
|
|
||||||
surface = Color(0xFFF4F7FA),
|
|
||||||
surfaceVariant = Color(0xFFE0E5EB),
|
|
||||||
surfaceContainer = Color(0xFFE9EDF2),
|
|
||||||
surfaceContainerHigh = Color(0xFFF1F4F7),
|
|
||||||
surfaceContainerHighest = Color(0xFFDBE0E7),
|
|
||||||
onBackground = Color(0xFF1B2733),
|
|
||||||
onSurface = Color(0xFF1B2733),
|
|
||||||
onSurfaceVariant = Color(0xFF525C6A),
|
|
||||||
outline = Color(0xFFA2AAB6),
|
|
||||||
outlineVariant = Color(0xFFD0D5DC),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
when (style) {
|
|
||||||
SurfaceStyle.Glass -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFF0E1217),
|
|
||||||
backgroundBottom = Color(0xFF161C24),
|
|
||||||
surface = Color(0xFF1E242C),
|
|
||||||
surfaceVariant = Color(0xFF262E38),
|
|
||||||
surfaceContainer = Color(0xFF1A2027),
|
|
||||||
surfaceContainerHigh = Color(0xFF232A33),
|
|
||||||
surfaceContainerHighest = Color(0xFF2C343E),
|
|
||||||
onBackground = Color(0xFFE6EDF5),
|
|
||||||
onSurface = Color(0xFFE6EDF5),
|
|
||||||
onSurfaceVariant = Color(0xFF9AA7B6),
|
|
||||||
outline = Color(0xFF46525F),
|
|
||||||
outlineVariant = Color(0xFF2C343E),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Acrylic -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFF101317),
|
|
||||||
backgroundBottom = Color(0xFF181C22),
|
|
||||||
surface = Color(0xFF20262E),
|
|
||||||
surfaceVariant = Color(0xFF282F39),
|
|
||||||
surfaceContainer = Color(0xFF1C2229),
|
|
||||||
surfaceContainerHigh = Color(0xFF252C35),
|
|
||||||
surfaceContainerHighest = Color(0xFF2E3640),
|
|
||||||
onBackground = Color(0xFFE4EAF1),
|
|
||||||
onSurface = Color(0xFFE4EAF1),
|
|
||||||
onSurfaceVariant = Color(0xFF98A2B0),
|
|
||||||
outline = Color(0xFF48525E),
|
|
||||||
outlineVariant = Color(0xFF2E3640),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Watery -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFF0A1622),
|
|
||||||
backgroundBottom = Color(0xFF0E1E2D),
|
|
||||||
surface = Color(0xFF0E2233),
|
|
||||||
surfaceVariant = Color(0xFF153148),
|
|
||||||
surfaceContainer = Color(0xFF0B1D2C),
|
|
||||||
surfaceContainerHigh = Color(0xFF12293C),
|
|
||||||
surfaceContainerHighest = Color(0xFF183449),
|
|
||||||
onBackground = Color(0xFFD6EAF7),
|
|
||||||
onSurface = Color(0xFFD6EAF7),
|
|
||||||
onSurfaceVariant = Color(0xFF8FB0C6),
|
|
||||||
outline = Color(0xFF3C5A72),
|
|
||||||
outlineVariant = Color(0xFF183449),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Icy -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFF0C1620),
|
|
||||||
backgroundBottom = Color(0xFF132331),
|
|
||||||
surface = Color(0xFF16283A),
|
|
||||||
surfaceVariant = Color(0xFF1E3448),
|
|
||||||
surfaceContainer = Color(0xFF122130),
|
|
||||||
surfaceContainerHigh = Color(0xFF1A2E42),
|
|
||||||
surfaceContainerHighest = Color(0xFF213A50),
|
|
||||||
onBackground = Color(0xFFDCEBF7),
|
|
||||||
onSurface = Color(0xFFDCEBF7),
|
|
||||||
onSurfaceVariant = Color(0xFF92B3CA),
|
|
||||||
outline = Color(0xFF40607A),
|
|
||||||
outlineVariant = Color(0xFF213A50),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Chrome -> SurfacePalette(
|
|
||||||
backgroundTop = Color(0xFF101317),
|
|
||||||
backgroundBottom = Color(0xFF181C22),
|
|
||||||
surface = Color(0xFF232830),
|
|
||||||
surfaceVariant = Color(0xFF2B313A),
|
|
||||||
surfaceContainer = Color(0xFF1E232A),
|
|
||||||
surfaceContainerHigh = Color(0xFF272D36),
|
|
||||||
surfaceContainerHighest = Color(0xFF303742),
|
|
||||||
onBackground = Color(0xFFE7ECF2),
|
|
||||||
onSurface = Color(0xFFE7ECF2),
|
|
||||||
onSurfaceVariant = Color(0xFF9BA5B2),
|
|
||||||
outline = Color(0xFF4B5561),
|
|
||||||
outlineVariant = Color(0xFF303742),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,94 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.darkColorScheme
|
|
||||||
import androidx.compose.material3.lightColorScheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.tooling.preview.AndroidUiModes.UI_MODE_NIGHT_YES
|
|
||||||
import androidx.compose.ui.tooling.preview.Devices
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Shared scaffold for every preview of a base UI component.
|
|
||||||
*
|
|
||||||
* It mirrors the runtime environment the components expect:
|
|
||||||
* - a [MaterialTheme] whose light/dark mode follows the preview's `uiMode`, so the
|
|
||||||
* `@PreviewLightDark`-style annotations actually change the rendered colors,
|
|
||||||
* - a [LocalBaseUITheme] built from that color scheme via [rememberBaseUITheme],
|
|
||||||
* - a [Surface] matching the real app background, so layered highlights and shadows
|
|
||||||
* render against the color they will actually sit on.
|
|
||||||
*
|
|
||||||
* Always render base components inside [BaseUIPreview]; several of them read
|
|
||||||
* [LocalBaseUITheme] directly and would otherwise throw at composition time.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun BaseUIPreview(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = if (isSystemInDarkTheme()) darkColorScheme() else lightColorScheme(),
|
|
||||||
) {
|
|
||||||
val theme = rememberBaseUITheme(surfaceTheme = surfaceTheme)
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalBaseUITheme provides theme,
|
|
||||||
LocalBaseUIColors provides theme.colors,
|
|
||||||
) {
|
|
||||||
Surface(
|
|
||||||
modifier = modifier.fillMaxSize(),
|
|
||||||
color = theme.colors.surface,
|
|
||||||
) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize(), content = content)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a base UI component on a phone-sized canvas in both light and dark themes.
|
|
||||||
*/
|
|
||||||
@Preview(name = "Phone · Light", group = "Base UI devices", device = Devices.PHONE)
|
|
||||||
@Preview(
|
|
||||||
name = "Phone · Dark",
|
|
||||||
group = "Base UI devices",
|
|
||||||
device = Devices.PHONE,
|
|
||||||
uiMode = UI_MODE_NIGHT_YES,
|
|
||||||
)
|
|
||||||
internal annotation class BaseUIPhonePreview
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Renders a base UI component on a desktop-sized canvas in both light and dark themes.
|
|
||||||
*/
|
|
||||||
@Preview(name = "Desktop · Light", group = "Base UI devices", device = Devices.DESKTOP)
|
|
||||||
@Preview(
|
|
||||||
name = "Desktop · Dark",
|
|
||||||
group = "Base UI devices",
|
|
||||||
device = Devices.DESKTOP,
|
|
||||||
uiMode = UI_MODE_NIGHT_YES,
|
|
||||||
)
|
|
||||||
internal annotation class BaseUIDesktopPreview
|
|
||||||
@ -1,908 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.alpha
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxArrowSquareUp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentText
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMagic
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxNext
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
|
||||||
|
|
||||||
private val BadgeShape = RoundedCornerShape(11.dp)
|
|
||||||
private const val DisabledContentAlpha = 0.38f
|
|
||||||
|
|
||||||
private val ButtonMinHeight = 40.dp
|
|
||||||
private val SquareButtonSize = 40.dp
|
|
||||||
|
|
||||||
private data class ResolvedButtonState(
|
|
||||||
val colors: BaseUITheme.ButtonColors,
|
|
||||||
val shape: Shape,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveButtonState(
|
|
||||||
style: BaseUITheme.ButtonStyle,
|
|
||||||
isPressed: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedButtonState {
|
|
||||||
return when {
|
|
||||||
isPressed -> ResolvedButtonState(
|
|
||||||
style.colors.pressed,
|
|
||||||
style.shape.pressed,
|
|
||||||
style.shadow.pressed,
|
|
||||||
style.border.pressed,
|
|
||||||
style.padding.pressed
|
|
||||||
)
|
|
||||||
|
|
||||||
isHovered -> ResolvedButtonState(
|
|
||||||
style.colors.hovered,
|
|
||||||
style.shape.hovered,
|
|
||||||
style.shadow.hovered,
|
|
||||||
style.border.hovered,
|
|
||||||
style.padding.hovered
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> ResolvedButtonState(
|
|
||||||
style.colors.normal,
|
|
||||||
style.shape.normal,
|
|
||||||
style.shadow.normal,
|
|
||||||
style.border.normal,
|
|
||||||
style.padding.normal
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Modifier.applyShadow(
|
|
||||||
shadow: BaseUITheme.Shadow,
|
|
||||||
shape: Shape,
|
|
||||||
): Modifier {
|
|
||||||
return if (shadow.elevation > 0.dp) {
|
|
||||||
this.shadow(
|
|
||||||
elevation = shadow.elevation,
|
|
||||||
shape = shape,
|
|
||||||
ambientColor = shadow.ambientColor,
|
|
||||||
spotColor = shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overlays the ambient [SurfaceStyle] material for a colour's [BaseUITheme.ButtonColors.surfaceRole],
|
|
||||||
* so an actionable item looks exactly like the matching [BaseSurface] role.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun Modifier.applySurfaceMaterial(colors: BaseUITheme.ButtonColors): Modifier =
|
|
||||||
applySurfaceMaterial(colors.surfaceRole)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
internal fun Modifier.applySurfaceMaterial(role: SurfaceRole?): Modifier {
|
|
||||||
val scheme = LocalBaseUIColors.current
|
|
||||||
return role?.let { baseSurfaceTexture(scheme.surfaceTheme, it, scheme.isLight) } ?: this
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The generic button gloss. Textured items get their sheen from the surface material, so it
|
|
||||||
* is only applied to colours without a [BaseUITheme.ButtonColors.surfaceRole].
|
|
||||||
*/
|
|
||||||
internal fun Modifier.applyHighlight(colors: BaseUITheme.ButtonColors): Modifier =
|
|
||||||
if (colors.surfaceRole == null) highlight(colors.highlight) else this
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun OutlineButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
hoverOnly: Boolean = false,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.outline
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val state = resolveButtonState(style, isPressed, isHovered)
|
|
||||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.graphicsLayer { translationY = lift.toPx() }
|
|
||||||
.then(
|
|
||||||
if (hoverOnly && !isHovered) Modifier else Modifier.applyShadow(
|
|
||||||
state.shadow,
|
|
||||||
state.shape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
.clip(state.shape)
|
|
||||||
.then(
|
|
||||||
if (hoverOnly && !isHovered) Modifier else Modifier.background(
|
|
||||||
state.colors.background,
|
|
||||||
state.shape
|
|
||||||
)
|
|
||||||
.applySurfaceMaterial(state.colors)
|
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
|
||||||
)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = ripple(),
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
.then(if (hoverOnly && !isHovered) Modifier else Modifier.applyHighlight(state.colors))
|
|
||||||
.padding(state.padding),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
content = {
|
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PrimaryButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.primary
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val state = resolveButtonState(style, isPressed, isHovered)
|
|
||||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.graphicsLayer { translationY = lift.toPx() }
|
|
||||||
.applyShadow(state.shadow, state.shape)
|
|
||||||
.clip(state.shape)
|
|
||||||
.background(state.colors.background, state.shape)
|
|
||||||
.applySurfaceMaterial(state.colors)
|
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = ripple(),
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
.applyHighlight(state.colors)
|
|
||||||
.padding(state.padding),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SecondaryButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.secondary
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val state = resolveButtonState(style, isPressed, isHovered)
|
|
||||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.graphicsLayer { translationY = lift.toPx() }
|
|
||||||
.applyShadow(state.shadow, state.shape)
|
|
||||||
.clip(state.shape)
|
|
||||||
.background(state.colors.background, state.shape)
|
|
||||||
.applySurfaceMaterial(state.colors)
|
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = ripple(),
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
.applyHighlight(state.colors)
|
|
||||||
.padding(state.padding),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(LocalContentColor provides state.colors.foreground) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun IconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.iconButtons.outline
|
|
||||||
OutlineButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = style,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GhostButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.ghost
|
|
||||||
OutlineButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
hoverOnly = true,
|
|
||||||
theme = style,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GhostIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.iconButtons.ghost
|
|
||||||
OutlineButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
hoverOnly = true,
|
|
||||||
theme = style,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun PrimaryIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.iconButtons.primary
|
|
||||||
PrimaryButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = style,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun SecondaryIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.iconButtons.secondary
|
|
||||||
SecondaryButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = style,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ButtonBadge(
|
|
||||||
count: Int,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.outline
|
|
||||||
val state = resolveButtonState(style, isPressed = false, isHovered = false)
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.heightIn(min = 22.dp)
|
|
||||||
.defaultMinSize(minWidth = 22.dp)
|
|
||||||
.background(state.colors.background, BadgeShape)
|
|
||||||
.border(1.dp, state.border.color, BadgeShape)
|
|
||||||
.padding(horizontal = 7.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = count.toString(),
|
|
||||||
fontSize = 12.sp,
|
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
color = state.colors.foreground.copy(alpha = 0.7f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.outline
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val state = resolveButtonState(style, isPressed, isHovered)
|
|
||||||
val overlay = when {
|
|
||||||
isPressed -> state.colors.background
|
|
||||||
isHovered -> state.colors.background
|
|
||||||
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
|
|
||||||
}
|
|
||||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
|
||||||
.graphicsLayer { translationY = lift.toPx() }
|
|
||||||
.background(overlay)
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = ripple(),
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
.padding(state.padding),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun GroupIconButton(
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.outline
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val state = resolveButtonState(style, isPressed, isHovered)
|
|
||||||
val overlay = when {
|
|
||||||
isPressed -> state.colors.background
|
|
||||||
isHovered -> state.colors.background
|
|
||||||
else -> Brush.verticalGradient(listOf(Color.Transparent, Color.Transparent))
|
|
||||||
}
|
|
||||||
val lift = if (isHovered && !isPressed) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(SquareButtonSize)
|
|
||||||
.graphicsLayer { translationY = lift.toPx() }
|
|
||||||
.background(overlay)
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = ripple(),
|
|
||||||
onClick = onClick,
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.alpha(if (enabled) 1f else DisabledContentAlpha),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ButtonGroup(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.outline
|
|
||||||
val state = resolveButtonState(style, isPressed = false, isHovered = false)
|
|
||||||
val groupShape = state.shape
|
|
||||||
Surface(
|
|
||||||
modifier = modifier
|
|
||||||
.defaultMinSize(minHeight = ButtonMinHeight)
|
|
||||||
.applyShadow(state.shadow, groupShape),
|
|
||||||
shape = groupShape,
|
|
||||||
color = Color.Transparent,
|
|
||||||
border = BorderStroke(state.border.width, state.border.color),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.background(state.colors.background, groupShape),
|
|
||||||
) {
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ButtonGroupDivider(
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current
|
|
||||||
val style = theme ?: baseTheme.buttons.outline
|
|
||||||
val state = resolveButtonState(style, isPressed = false, isHovered = false)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.width(1.dp)
|
|
||||||
.heightIn(min = 20.dp)
|
|
||||||
.background(state.border.color),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@BaseUIDesktopPreview
|
|
||||||
@Composable
|
|
||||||
private fun ButtonsPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var liked by remember { mutableStateOf(false) }
|
|
||||||
var likeCount by remember { mutableIntStateOf(128) }
|
|
||||||
var following by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
OutlineButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxShare,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Text("Share", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
SecondaryButton(onClick = {}) {
|
|
||||||
Text("Secondary", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
PrimaryButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
"Sign up",
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GhostButton(onClick = {}) {
|
|
||||||
Text("Ghost", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
OutlineButton(onClick = {}, enabled = false) {
|
|
||||||
Text("Disabled", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
SecondaryButton(onClick = {}, enabled = false) {
|
|
||||||
Text("Disabled", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
PrimaryButton(onClick = {}, enabled = false) {
|
|
||||||
Text("Disabled", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
OutlineButton(onClick = {}) {
|
|
||||||
Text("Forward", maxLines = 1, softWrap = false)
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxArrowSquareUp,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
OutlineButton(
|
|
||||||
onClick = {
|
|
||||||
liked = !liked
|
|
||||||
likeCount += if (liked) 1 else -1
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxHeart,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Text(if (liked) "Liked" else "Like", maxLines = 1, softWrap = false)
|
|
||||||
ButtonBadge(count = likeCount)
|
|
||||||
}
|
|
||||||
PrimaryButton(onClick = { following = !following }) {
|
|
||||||
Text(
|
|
||||||
if (following) "Following" else "Follow",
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun IconButtonsPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Icon buttons",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
IconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxMagic,
|
|
||||||
contentDescription = "Magic",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
GhostIconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxHeart,
|
|
||||||
contentDescription = "Favorite",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PrimaryIconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
|
||||||
contentDescription = "Add",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SecondaryIconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxShare,
|
|
||||||
contentDescription = "Share",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Variable icon button",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
VariableIconButton(
|
|
||||||
variant = VariableIconButtonVariant.Outline,
|
|
||||||
onClick = {},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxDocumentText,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
VariableIconButton(
|
|
||||||
variant = VariableIconButtonVariant.Ghost,
|
|
||||||
onClick = {},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
VariableIconButton(
|
|
||||||
variant = VariableIconButtonVariant.Primary,
|
|
||||||
onClick = {},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxMagic,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
VariableIconButton(
|
|
||||||
variant = VariableIconButtonVariant.Secondary,
|
|
||||||
onClick = {},
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.User,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIDesktopPreview
|
|
||||||
@Composable
|
|
||||||
private fun ButtonGroupPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "Button groups",
|
|
||||||
style = MaterialTheme.typography.labelLarge,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
ButtonGroup {
|
|
||||||
GroupButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxDocumentText,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Text("Documents", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
ButtonGroupDivider()
|
|
||||||
GroupButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxShare,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
Text("Export", maxLines = 1, softWrap = false)
|
|
||||||
}
|
|
||||||
ButtonGroupDivider()
|
|
||||||
GroupIconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ButtonGroup {
|
|
||||||
GroupIconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.ArrowLeft3,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
ButtonGroupDivider()
|
|
||||||
GroupIconButton(onClick = {}) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxNext,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
ButtonBadge(count = 8)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
enum class VariableIconButtonVariant {
|
|
||||||
Outline,
|
|
||||||
Ghost,
|
|
||||||
Primary,
|
|
||||||
Secondary,
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun VariableIconButton(
|
|
||||||
variant: VariableIconButtonVariant = VariableIconButtonVariant.Outline,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ButtonStyle? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
when (variant) {
|
|
||||||
VariableIconButtonVariant.Outline -> IconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = theme,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
|
|
||||||
VariableIconButtonVariant.Ghost -> GhostIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = theme,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
|
|
||||||
VariableIconButtonVariant.Primary -> PrimaryIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = theme,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
|
|
||||||
VariableIconButtonVariant.Secondary -> SecondaryIconButton(
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = theme,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,174 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Card(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
theme: BaseUITheme.CardTheme? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val cardTheme = theme ?: LocalBaseUITheme.current.card
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.then(
|
|
||||||
if (cardTheme.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = cardTheme.shadow.elevation,
|
|
||||||
shape = cardTheme.shape,
|
|
||||||
ambientColor = cardTheme.shadow.ambientColor,
|
|
||||||
spotColor = cardTheme.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(cardTheme.shape)
|
|
||||||
.background(cardTheme.background, cardTheme.shape)
|
|
||||||
.applySurfaceMaterial(cardTheme.surfaceRole)
|
|
||||||
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
|
|
||||||
.padding(cardTheme.padding),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun CardPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var connected by remember { mutableStateOf(false) }
|
|
||||||
var playing by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Card {
|
|
||||||
Column(modifier = Modifier.padding(16.dp)) {
|
|
||||||
Text(
|
|
||||||
text = if (connected) "Spotify connected" else "Connect a music source",
|
|
||||||
style = MaterialTheme.typography.titleMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(6.dp))
|
|
||||||
Text(
|
|
||||||
text = if (connected) {
|
|
||||||
"Streaming, downloads and scrobbling are ready."
|
|
||||||
} else {
|
|
||||||
"Sign in to stream, download and scrobble from your account."
|
|
||||||
},
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
OutlineButton(onClick = { connected = false }) {
|
|
||||||
Text("Disconnect")
|
|
||||||
}
|
|
||||||
PrimaryButton(onClick = { connected = !connected }) {
|
|
||||||
Text(if (connected) "Connected" else "Connect")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Card {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxMusic,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(20.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = "Midnight City",
|
|
||||||
style = MaterialTheme.typography.titleSmall,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = "M83 · Hurry Up, We're Dreaming",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 1,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
PrimaryIconButton(onClick = { playing = !playing }) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (playing) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
|
||||||
contentDescription = if (playing) "Pause" else "Play",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,281 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Path
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.graphics.StrokeJoin
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
enum class CheckBoxState {
|
|
||||||
SELECTED,
|
|
||||||
UNSELECTED,
|
|
||||||
INDETERMINATE,
|
|
||||||
@Deprecated("Use INDETERMINATE", ReplaceWith("CheckBoxState.INDETERMINATE"))
|
|
||||||
CLEAR;
|
|
||||||
}
|
|
||||||
|
|
||||||
private val CheckBoxSize = 22.dp
|
|
||||||
|
|
||||||
private data class ResolvedCheckBoxState(
|
|
||||||
val colors: BaseUITheme.ButtonColors,
|
|
||||||
val shape: Shape,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveCheckBoxState(
|
|
||||||
style: BaseUITheme.ButtonStyle,
|
|
||||||
isPressed: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedCheckBoxState {
|
|
||||||
return when {
|
|
||||||
isPressed -> ResolvedCheckBoxState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
|
|
||||||
isHovered -> ResolvedCheckBoxState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
|
|
||||||
else -> ResolvedCheckBoxState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun CheckBox(
|
|
||||||
state: CheckBoxState = CheckBoxState.UNSELECTED,
|
|
||||||
onClick: (() -> Unit)?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
interactionSource: MutableInteractionSource? = null,
|
|
||||||
theme: BaseUITheme.CheckBoxTheme? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.checkBox
|
|
||||||
val checkBoxTheme = theme ?: baseTheme
|
|
||||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
|
||||||
val isPressed by source.collectIsPressedAsState()
|
|
||||||
val isHovered by source.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
val isSelected = state == CheckBoxState.SELECTED
|
|
||||||
val isIndeterminate = state == CheckBoxState.INDETERMINATE
|
|
||||||
val isFilled = isSelected || isIndeterminate
|
|
||||||
|
|
||||||
val style = if (isFilled) checkBoxTheme.selected else checkBoxTheme.unselected
|
|
||||||
val resolved = resolveCheckBoxState(style, isPressed, isHovered)
|
|
||||||
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
val borderColor = when {
|
|
||||||
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
|
|
||||||
else -> resolved.border.color
|
|
||||||
}
|
|
||||||
|
|
||||||
val checkProgress by animateFloatAsState(
|
|
||||||
targetValue = if (isSelected) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 180),
|
|
||||||
label = "checkProgress",
|
|
||||||
)
|
|
||||||
val dashProgress by animateFloatAsState(
|
|
||||||
targetValue = if (isIndeterminate) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 150),
|
|
||||||
label = "dashProgress",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(CheckBoxSize)
|
|
||||||
.graphicsLayer { translationY = lift.toPx() }
|
|
||||||
.then(
|
|
||||||
if (resolved.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = resolved.shadow.elevation,
|
|
||||||
shape = resolved.shape,
|
|
||||||
ambientColor = resolved.shadow.ambientColor,
|
|
||||||
spotColor = resolved.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(resolved.shape)
|
|
||||||
.background(resolved.colors.background, resolved.shape)
|
|
||||||
.applySurfaceMaterial(resolved.colors)
|
|
||||||
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
|
|
||||||
.applyHighlight(resolved.colors)
|
|
||||||
.then(
|
|
||||||
if (onClick != null) {
|
|
||||||
Modifier
|
|
||||||
.hoverable(interactionSource = source, enabled = enabled)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = source,
|
|
||||||
indication = ripple(),
|
|
||||||
enabled = enabled,
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
if (isSelected) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(14.dp)
|
|
||||||
.graphicsLayer { alpha = checkProgress },
|
|
||||||
) {
|
|
||||||
androidx.compose.foundation.Canvas(modifier = Modifier.size(14.dp)) {
|
|
||||||
val stroke = 2.dp.toPx()
|
|
||||||
val path = Path().apply {
|
|
||||||
val w = size.width
|
|
||||||
val h = size.height
|
|
||||||
moveTo(w * 0.2f, h * 0.52f)
|
|
||||||
lineTo(w * 0.42f, h * 0.74f)
|
|
||||||
lineTo(w * 0.82f, h * 0.28f)
|
|
||||||
}
|
|
||||||
drawPath(
|
|
||||||
path = path,
|
|
||||||
color = checkBoxTheme.checkmarkColor,
|
|
||||||
style = Stroke(
|
|
||||||
width = stroke,
|
|
||||||
cap = StrokeCap.Round,
|
|
||||||
join = StrokeJoin.Round,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else if (isIndeterminate) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(width = 10.dp, height = 2.dp)
|
|
||||||
.graphicsLayer { alpha = dashProgress }
|
|
||||||
.clip(RoundedCornerShape(1.dp))
|
|
||||||
.background(
|
|
||||||
brush = Brush.horizontalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
checkBoxTheme.checkmarkColor,
|
|
||||||
checkBoxTheme.checkmarkColor.copy(alpha = 0.92f),
|
|
||||||
)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun CheckBoxPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var wifi by remember { mutableStateOf(true) }
|
|
||||||
var bluetooth by remember { mutableStateOf(false) }
|
|
||||||
var downloads by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
val allEnabled = wifi && bluetooth && downloads
|
|
||||||
val anyEnabled = wifi || bluetooth || downloads
|
|
||||||
val allState = when {
|
|
||||||
allEnabled -> CheckBoxState.SELECTED
|
|
||||||
anyEnabled -> CheckBoxState.INDETERMINATE
|
|
||||||
else -> CheckBoxState.UNSELECTED
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
CheckBoxPreviewRow(
|
|
||||||
state = allState,
|
|
||||||
label = "All playback sources",
|
|
||||||
onClick = {
|
|
||||||
val target = !allEnabled
|
|
||||||
wifi = target
|
|
||||||
bluetooth = target
|
|
||||||
downloads = target
|
|
||||||
},
|
|
||||||
)
|
|
||||||
Spacer(Modifier.height(4.dp))
|
|
||||||
CheckBoxPreviewRow(
|
|
||||||
state = if (wifi) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
label = "Wi-Fi",
|
|
||||||
onClick = { wifi = !wifi },
|
|
||||||
)
|
|
||||||
CheckBoxPreviewRow(
|
|
||||||
state = if (bluetooth) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
label = "Bluetooth",
|
|
||||||
onClick = { bluetooth = !bluetooth },
|
|
||||||
)
|
|
||||||
CheckBoxPreviewRow(
|
|
||||||
state = if (downloads) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
label = "Download over cellular",
|
|
||||||
onClick = { downloads = !downloads },
|
|
||||||
)
|
|
||||||
CheckBoxPreviewRow(CheckBoxState.SELECTED, "Disabled selected", enabled = false)
|
|
||||||
CheckBoxPreviewRow(CheckBoxState.UNSELECTED, "Disabled unselected", enabled = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun CheckBoxPreviewRow(
|
|
||||||
state: CheckBoxState,
|
|
||||||
label: String,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onClick: () -> Unit = {},
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
CheckBox(state = state, onClick = onClick, enabled = enabled)
|
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,240 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.RowScope
|
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDocumentDownload
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusicLibrary
|
|
||||||
|
|
||||||
private val ChipTabMinHeight = 36.dp
|
|
||||||
|
|
||||||
private data class ResolvedChipState(
|
|
||||||
val colors: BaseUITheme.ButtonColors,
|
|
||||||
val shape: Shape,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
val padding: androidx.compose.foundation.layout.PaddingValues,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveChipState(
|
|
||||||
style: BaseUITheme.ButtonStyle,
|
|
||||||
isPressed: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedChipState {
|
|
||||||
return when {
|
|
||||||
isPressed -> ResolvedChipState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed, style.padding.pressed)
|
|
||||||
isHovered -> ResolvedChipState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered, style.padding.hovered)
|
|
||||||
else -> ResolvedChipState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal, style.padding.normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun Modifier.applyChipShadow(
|
|
||||||
shadow: BaseUITheme.Shadow,
|
|
||||||
shape: Shape,
|
|
||||||
): Modifier {
|
|
||||||
return if (shadow.elevation > 0.dp) {
|
|
||||||
this.shadow(
|
|
||||||
elevation = shadow.elevation,
|
|
||||||
shape = shape,
|
|
||||||
ambientColor = shadow.ambientColor,
|
|
||||||
spotColor = shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
this
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChipTab(
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.ChipTabTheme? = null,
|
|
||||||
content: @Composable RowScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.chipTab
|
|
||||||
val chipTheme = theme ?: baseTheme
|
|
||||||
val style = if (selected) chipTheme.selected else chipTheme.unselected
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val state = resolveChipState(style, isPressed, isHovered)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.defaultMinSize(minHeight = ChipTabMinHeight)
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.applyChipShadow(state.shadow, state.shape)
|
|
||||||
.clip(state.shape)
|
|
||||||
.background(state.colors.background, state.shape)
|
|
||||||
.applySurfaceMaterial(state.colors)
|
|
||||||
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = ripple(),
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
.applyHighlight(state.colors)
|
|
||||||
.padding(state.padding),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
CompositionLocalProvider(
|
|
||||||
LocalContentColor provides state.colors.foreground,
|
|
||||||
LocalTextStyle provides LocalTextStyle.current.copy(
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
fontSize = 14.sp,
|
|
||||||
)
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ChipTab(
|
|
||||||
text: String,
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
leadingIcon: @Composable (() -> Unit)? = null,
|
|
||||||
theme: BaseUITheme.ChipTabTheme? = null,
|
|
||||||
) {
|
|
||||||
ChipTab(
|
|
||||||
selected = selected,
|
|
||||||
onClick = onClick,
|
|
||||||
modifier = modifier,
|
|
||||||
enabled = enabled,
|
|
||||||
theme = theme,
|
|
||||||
) {
|
|
||||||
if (leadingIcon != null) {
|
|
||||||
leadingIcon()
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun ChipTabsPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
val tabs = listOf("All", "Songs", "Albums", "Playlists")
|
|
||||||
var selected by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
tabs.forEachIndexed { index, tab ->
|
|
||||||
ChipTab(
|
|
||||||
text = tab,
|
|
||||||
selected = selected == index,
|
|
||||||
onClick = { selected = index },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
|
||||||
ChipTab(
|
|
||||||
text = "Library",
|
|
||||||
selected = true,
|
|
||||||
onClick = {},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxMusicLibrary,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ChipTab(
|
|
||||||
text = "Downloads",
|
|
||||||
selected = false,
|
|
||||||
onClick = {},
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxDocumentDownload,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
ChipTab(
|
|
||||||
text = "Disabled",
|
|
||||||
selected = false,
|
|
||||||
onClick = {},
|
|
||||||
enabled = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,237 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.rememberScrollState
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.foundation.verticalScroll
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.window.Dialog
|
|
||||||
import androidx.compose.ui.window.DialogProperties
|
|
||||||
|
|
||||||
private val DefaultDialogMaxWidth: Dp = 440.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ThemedDialog(
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
title: @Composable (() -> Unit)? = null,
|
|
||||||
actions: @Composable (() -> Unit)? = null,
|
|
||||||
theme: BaseUITheme.DialogTheme? = null,
|
|
||||||
properties: DialogProperties = DialogProperties(usePlatformDefaultWidth = false),
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Dialog(
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
properties = properties,
|
|
||||||
) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = true,
|
|
||||||
enter = scaleIn(
|
|
||||||
initialScale = 0.92f,
|
|
||||||
animationSpec = tween(280),
|
|
||||||
) + fadeIn(animationSpec = tween(200)),
|
|
||||||
exit = scaleOut(
|
|
||||||
targetScale = 0.92f,
|
|
||||||
animationSpec = tween(180),
|
|
||||||
) + fadeOut(animationSpec = tween(150)),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(24.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
ThemedDialogSurface(
|
|
||||||
theme = theme,
|
|
||||||
title = title,
|
|
||||||
actions = actions,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The visual chrome of [ThemedDialog] without the platform [Dialog] window.
|
|
||||||
*
|
|
||||||
* Extracted so the dialog can be rendered inside previews (and reused elsewhere),
|
|
||||||
* since the platform dialog lives in a separate window the preview canvas cannot show.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
internal fun ThemedDialogSurface(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
theme: BaseUITheme.DialogTheme? = null,
|
|
||||||
title: @Composable (() -> Unit)? = null,
|
|
||||||
actions: @Composable (() -> Unit)? = null,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.widthIn(max = DefaultDialogMaxWidth)
|
|
||||||
.shadow(
|
|
||||||
elevation = dialogTheme.shadow.elevation,
|
|
||||||
shape = dialogTheme.shadow.let { if (it.clip) dialogTheme.shape else RoundedCornerShape(0.dp) },
|
|
||||||
ambientColor = dialogTheme.shadow.ambientColor,
|
|
||||||
spotColor = dialogTheme.shadow.spotColor,
|
|
||||||
)
|
|
||||||
.clip(dialogTheme.shape)
|
|
||||||
.background(dialogTheme.background, dialogTheme.shape)
|
|
||||||
.border(
|
|
||||||
BorderStroke(dialogTheme.border.width, dialogTheme.border.color),
|
|
||||||
dialogTheme.shape
|
|
||||||
)
|
|
||||||
.highlight(Color.White.copy(alpha = 0.08f)),
|
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.widthIn(max = DefaultDialogMaxWidth)) {
|
|
||||||
if (title != null) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(start = 24.dp, end = 24.dp, top = 24.dp),
|
|
||||||
) {
|
|
||||||
title()
|
|
||||||
}
|
|
||||||
Spacer(Modifier.height(8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f, fill = false)
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(
|
|
||||||
start = 24.dp,
|
|
||||||
end = 24.dp,
|
|
||||||
top = if (title == null) 24.dp else 0.dp,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
|
|
||||||
if (actions != null) {
|
|
||||||
Spacer(Modifier.height(16.dp))
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
|
||||||
) {
|
|
||||||
actions()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Spacer(Modifier.height(24.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun ThemedDialogPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var visible by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
|
||||||
PrimaryButton(
|
|
||||||
onClick = { visible = true },
|
|
||||||
modifier = Modifier
|
|
||||||
.align(Alignment.TopStart)
|
|
||||||
.padding(24.dp),
|
|
||||||
) {
|
|
||||||
Text("Open dialog")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (visible) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(LocalBaseUITheme.current.dialog.scrim)
|
|
||||||
.clickable { visible = false },
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
ThemedDialogSurface(
|
|
||||||
modifier = Modifier.clickable(
|
|
||||||
interactionSource = remember { MutableInteractionSource() },
|
|
||||||
indication = null,
|
|
||||||
onClick = {},
|
|
||||||
),
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Sign out of Spotube?",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
OutlineButton(onClick = { visible = false }) { Text("Cancel") }
|
|
||||||
PrimaryButton(onClick = { visible = false }) { Text("Sign out") }
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = "You can sign back in at any time. Your downloaded tracks stay on this device.",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,277 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.ColumnScope
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.widthIn
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.DpOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DropdownMenu(
|
|
||||||
expanded: Boolean,
|
|
||||||
onDismissRequest: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
offset: DpOffset = DpOffset(0.dp, 4.dp),
|
|
||||||
theme: BaseUITheme.DropdownMenuTheme? = null,
|
|
||||||
content: @Composable ColumnScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
|
|
||||||
|
|
||||||
androidx.compose.material3.DropdownMenu(
|
|
||||||
expanded = expanded,
|
|
||||||
onDismissRequest = onDismissRequest,
|
|
||||||
offset = offset,
|
|
||||||
modifier = modifier.dropdownMenuContainer(menuTheme),
|
|
||||||
) {
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The container styling of [DropdownMenu], split out so it can be reused by the
|
|
||||||
* material3 popup and by previews, which cannot render the popup window itself.
|
|
||||||
*/
|
|
||||||
internal fun Modifier.dropdownMenuContainer(theme: BaseUITheme.DropdownMenuTheme): Modifier =
|
|
||||||
this
|
|
||||||
.widthIn(min = 180.dp)
|
|
||||||
.shadow(
|
|
||||||
elevation = theme.shadow.elevation,
|
|
||||||
shape = theme.shape,
|
|
||||||
ambientColor = theme.shadow.ambientColor,
|
|
||||||
spotColor = theme.shadow.spotColor,
|
|
||||||
)
|
|
||||||
.clip(theme.shape)
|
|
||||||
.background(theme.background, theme.shape)
|
|
||||||
.border(
|
|
||||||
BorderStroke(theme.border.width, theme.border.color),
|
|
||||||
theme.shape,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DropdownMenuItem(
|
|
||||||
text: String,
|
|
||||||
onClick: () -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
leadingIcon: ImageVector? = null,
|
|
||||||
leadingContent: (@Composable () -> Unit)? = null,
|
|
||||||
subtitle: String? = null,
|
|
||||||
selected: Boolean = false,
|
|
||||||
theme: BaseUITheme.DropdownMenuTheme? = null,
|
|
||||||
) {
|
|
||||||
val menuTheme = theme ?: LocalBaseUITheme.current.dropdownMenu
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.clip(menuTheme.shape)
|
|
||||||
.hoverable(interactionSource)
|
|
||||||
.then(
|
|
||||||
if (isHovered && enabled) {
|
|
||||||
Modifier.background(menuTheme.itemHoverBackground)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.then(
|
|
||||||
if (isHovered && enabled) {
|
|
||||||
Modifier.highlight(menuTheme.itemHighlight)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = null,
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
.padding(menuTheme.itemPadding),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
when {
|
|
||||||
leadingContent != null -> leadingContent()
|
|
||||||
leadingIcon != null -> Icon(
|
|
||||||
imageVector = leadingIcon,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = if (enabled) {
|
|
||||||
menuTheme.itemForeground
|
|
||||||
} else {
|
|
||||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = if (enabled) {
|
|
||||||
menuTheme.itemForeground
|
|
||||||
} else {
|
|
||||||
menuTheme.itemForeground.copy(alpha = 0.38f)
|
|
||||||
},
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
)
|
|
||||||
if (subtitle != null) {
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = menuTheme.itemForeground.copy(alpha = if (enabled) 0.6f else 0.38f),
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun DropdownMenuDivider(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 8.dp, vertical = 4.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(0.5.dp)
|
|
||||||
.background(MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun DropdownMenuPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var expanded by remember { mutableStateOf(true) }
|
|
||||||
var lastAction by remember { mutableStateOf("None") }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
OutlineButton(onClick = { expanded = !expanded }) {
|
|
||||||
Text(if (expanded) "Hide menu" else "Show menu")
|
|
||||||
}
|
|
||||||
|
|
||||||
if (expanded) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.dropdownMenuContainer(
|
|
||||||
LocalBaseUITheme.current.dropdownMenu,
|
|
||||||
),
|
|
||||||
) {
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = "Play next",
|
|
||||||
leadingIcon = Iconsax.IconsaxPlay,
|
|
||||||
onClick = {
|
|
||||||
lastAction = "Play next"
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = "Add to queue",
|
|
||||||
leadingIcon = Iconsax.IconsaxAddSquare,
|
|
||||||
onClick = {
|
|
||||||
lastAction = "Add to queue"
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = "Add to favorites",
|
|
||||||
leadingIcon = Iconsax.IconsaxHeart,
|
|
||||||
selected = lastAction == "Add to favorites",
|
|
||||||
onClick = {
|
|
||||||
lastAction = "Add to favorites"
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuDivider()
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = "Share",
|
|
||||||
leadingIcon = Iconsax.IconsaxShare,
|
|
||||||
onClick = {
|
|
||||||
lastAction = "Share"
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
)
|
|
||||||
DropdownMenuItem(
|
|
||||||
text = "Remove from playlist",
|
|
||||||
leadingIcon = Iconsax.IconsaxTrash,
|
|
||||||
enabled = false,
|
|
||||||
onClick = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Text(
|
|
||||||
text = "Last action: $lastAction",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,240 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxMusic
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun ListRowTile(
|
|
||||||
selected: Boolean = false,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onClick: (() -> Unit)? = null,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
leading: @Composable (() -> Unit)? = null,
|
|
||||||
title: @Composable (() -> Unit)? = null,
|
|
||||||
subtitle: @Composable (() -> Unit)? = null,
|
|
||||||
trailing: @Composable (() -> Unit)? = null,
|
|
||||||
) {
|
|
||||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
|
||||||
|
|
||||||
val background = when {
|
|
||||||
isPressed -> rowTheme.background.pressed
|
|
||||||
isHovered -> rowTheme.background.hovered
|
|
||||||
isFocused -> rowTheme.background.focused
|
|
||||||
selected -> rowTheme.background.selected
|
|
||||||
else -> rowTheme.background.normal
|
|
||||||
}
|
|
||||||
val shape = when {
|
|
||||||
isPressed -> rowTheme.shape.pressed
|
|
||||||
isHovered -> rowTheme.shape.hovered
|
|
||||||
isFocused -> rowTheme.shape.focused
|
|
||||||
selected -> rowTheme.shape.selected
|
|
||||||
else -> rowTheme.shape.normal
|
|
||||||
}
|
|
||||||
val borderDef = when {
|
|
||||||
isPressed -> rowTheme.border.pressed
|
|
||||||
isHovered -> rowTheme.border.hovered
|
|
||||||
isFocused -> rowTheme.border.focused
|
|
||||||
selected -> rowTheme.border.selected
|
|
||||||
else -> rowTheme.border.normal
|
|
||||||
}
|
|
||||||
val shadowDef = when {
|
|
||||||
isPressed -> rowTheme.shadow.pressed
|
|
||||||
isHovered -> rowTheme.shadow.hovered
|
|
||||||
isFocused -> rowTheme.shadow.focused
|
|
||||||
selected -> rowTheme.shadow.selected
|
|
||||||
else -> rowTheme.shadow.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
val mod = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.shadow(
|
|
||||||
shadowDef.elevation,
|
|
||||||
shape,
|
|
||||||
shadowDef.clip,
|
|
||||||
shadowDef.ambientColor,
|
|
||||||
shadowDef.spotColor
|
|
||||||
)
|
|
||||||
.clip(shape)
|
|
||||||
.border(borderDef.width, borderDef.color, shape)
|
|
||||||
.background(background, shape)
|
|
||||||
.then(
|
|
||||||
if (onClick != null) {
|
|
||||||
Modifier.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
indication = null,
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.padding(rowTheme.padding)
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = mod,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
if (leading != null) {
|
|
||||||
leading()
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
}
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.weight(1f),
|
|
||||||
verticalArrangement = Arrangement.Center,
|
|
||||||
) {
|
|
||||||
if (title != null) {
|
|
||||||
title()
|
|
||||||
}
|
|
||||||
if (subtitle != null) {
|
|
||||||
Spacer(Modifier.width(2.dp))
|
|
||||||
subtitle()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (trailing != null) {
|
|
||||||
Spacer(Modifier.width(12.dp))
|
|
||||||
trailing()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class ListRowPreviewTrack(
|
|
||||||
val title: String,
|
|
||||||
val subtitle: String,
|
|
||||||
val duration: String,
|
|
||||||
)
|
|
||||||
|
|
||||||
private val ListRowPreviewTracks = listOf(
|
|
||||||
ListRowPreviewTrack("Midnight City", "M83 · Hurry Up, We're Dreaming", "4:03"),
|
|
||||||
ListRowPreviewTrack("Odoriko", "Vaundy · strobo", "3:37"),
|
|
||||||
ListRowPreviewTrack("Instant Crush", "Daft Punk · Random Access Memories", "5:37"),
|
|
||||||
)
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun ListRowTilePreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var selectedIndex by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(4.dp),
|
|
||||||
) {
|
|
||||||
ListRowPreviewTracks.forEachIndexed { index, track ->
|
|
||||||
ListRowTile(
|
|
||||||
selected = selectedIndex == index,
|
|
||||||
onClick = { selectedIndex = index },
|
|
||||||
leading = { ListRowTilePreviewArtwork() },
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = track.title,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
subtitle = {
|
|
||||||
Text(
|
|
||||||
text = track.subtitle,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Text(
|
|
||||||
text = track.duration,
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
ListRowTile(
|
|
||||||
enabled = false,
|
|
||||||
title = {
|
|
||||||
Text(
|
|
||||||
text = "Add to queue",
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
subtitle = {
|
|
||||||
Text(
|
|
||||||
text = "Unavailable offline",
|
|
||||||
style = MaterialTheme.typography.bodySmall,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.38f),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun ListRowTilePreviewArtwork() {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(40.dp)
|
|
||||||
.clip(RoundedCornerShape(10.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxMusic,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,183 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.ripple
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
private val RadioSize = 22.dp
|
|
||||||
private val RadioDotSize = 10.dp
|
|
||||||
|
|
||||||
private data class ResolvedRadioState(
|
|
||||||
val colors: BaseUITheme.ButtonColors,
|
|
||||||
val shape: Shape,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveRadioState(
|
|
||||||
style: BaseUITheme.ButtonStyle,
|
|
||||||
isPressed: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedRadioState {
|
|
||||||
return when {
|
|
||||||
isPressed -> ResolvedRadioState(style.colors.pressed, style.shape.pressed, style.shadow.pressed, style.border.pressed)
|
|
||||||
isHovered -> ResolvedRadioState(style.colors.hovered, style.shape.hovered, style.shadow.hovered, style.border.hovered)
|
|
||||||
else -> ResolvedRadioState(style.colors.normal, style.shape.normal, style.shadow.normal, style.border.normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Radio(
|
|
||||||
selected: Boolean,
|
|
||||||
onClick: (() -> Unit)?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
theme: BaseUITheme.CheckBoxTheme? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.checkBox
|
|
||||||
val radioTheme = theme ?: baseTheme
|
|
||||||
val source = remember { MutableInteractionSource() }
|
|
||||||
val isPressed by source.collectIsPressedAsState()
|
|
||||||
val isHovered by source.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
val style = if (selected) radioTheme.selected else radioTheme.unselected
|
|
||||||
val resolved = resolveRadioState(style, isPressed, isHovered)
|
|
||||||
val lift = if (isHovered && !isPressed && onClick != null) (-1).dp else 0.dp
|
|
||||||
|
|
||||||
val dotProgress by animateFloatAsState(
|
|
||||||
targetValue = if (selected) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 180),
|
|
||||||
label = "radioDot",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.size(RadioSize)
|
|
||||||
.graphicsLayer {
|
|
||||||
translationY = lift.toPx()
|
|
||||||
shape = CircleShape
|
|
||||||
clip = true
|
|
||||||
}
|
|
||||||
.then(
|
|
||||||
if (resolved.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = resolved.shadow.elevation,
|
|
||||||
shape = CircleShape,
|
|
||||||
ambientColor = resolved.shadow.ambientColor,
|
|
||||||
spotColor = resolved.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(resolved.colors.background, CircleShape)
|
|
||||||
.border(BorderStroke(0.5.dp, resolved.border.color), CircleShape)
|
|
||||||
.highlight(resolved.colors.highlight)
|
|
||||||
.then(
|
|
||||||
if (onClick != null) {
|
|
||||||
Modifier
|
|
||||||
.hoverable(interactionSource = source, enabled = enabled)
|
|
||||||
.clickable(
|
|
||||||
interactionSource = source,
|
|
||||||
indication = ripple(bounded = true, radius = RadioSize / 2),
|
|
||||||
enabled = enabled,
|
|
||||||
onClick = onClick,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(RadioDotSize * dotProgress)
|
|
||||||
.clip(CircleShape)
|
|
||||||
.background(radioTheme.checkmarkColor, CircleShape),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun RadioPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
val options = listOf("Automatic", "High", "Lossless", "Data saver")
|
|
||||||
var selected by remember { mutableStateOf(0) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
) {
|
|
||||||
options.forEachIndexed { index, label ->
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Radio(
|
|
||||||
selected = selected == index,
|
|
||||||
onClick = { selected = index },
|
|
||||||
)
|
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Row(
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Radio(selected = true, onClick = {}, enabled = false)
|
|
||||||
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,121 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.ScaffoldDefaults
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A theme-aware replacement for [Scaffold].
|
|
||||||
*
|
|
||||||
* A scaffold is always the current theme's **Primary Background** — there is no option to
|
|
||||||
* switch its surface. Material's [Scaffold] would paint Material's own surface colour and
|
|
||||||
* hide the selected [SurfaceStyle] (Watery/Icy/Chrome/Acrylic/Glass), so [BaseScaffold]
|
|
||||||
* paints the theme's primary background material instead.
|
|
||||||
*
|
|
||||||
* [containerColor] is a narrow escape hatch for the rare screen that paints its own
|
|
||||||
* backdrop (e.g. onboarding) and needs the scaffold to stay out of the way; it is not a
|
|
||||||
* surface-style switch.
|
|
||||||
*
|
|
||||||
* It keeps the same slot structure as [Scaffold] (top/bottom bars, snackbar host,
|
|
||||||
* floating action button) and hands the resolved [PaddingValues] to [content].
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BaseScaffold(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
topBar: @Composable () -> Unit = {},
|
|
||||||
bottomBar: @Composable () -> Unit = {},
|
|
||||||
snackbarHost: @Composable () -> Unit = {},
|
|
||||||
floatingActionButton: @Composable () -> Unit = {},
|
|
||||||
containerColor: Color? = null,
|
|
||||||
contentWindowInsets: WindowInsets = ScaffoldDefaults.contentWindowInsets,
|
|
||||||
content: @Composable (PaddingValues) -> Unit,
|
|
||||||
) {
|
|
||||||
Scaffold(
|
|
||||||
modifier = modifier,
|
|
||||||
topBar = topBar,
|
|
||||||
bottomBar = bottomBar,
|
|
||||||
snackbarHost = snackbarHost,
|
|
||||||
floatingActionButton = floatingActionButton,
|
|
||||||
containerColor = Color.Transparent,
|
|
||||||
contentWindowInsets = contentWindowInsets,
|
|
||||||
) { innerPadding ->
|
|
||||||
val colors = LocalBaseUIColors.current
|
|
||||||
val backgroundModifier = containerColor?.let { Modifier.background(it) }
|
|
||||||
?: Modifier.baseSurfaceBackdrop(colors.surfaceTheme, colors.isLight)
|
|
||||||
Box(modifier = Modifier.fillMaxSize().then(backgroundModifier)) {
|
|
||||||
content(innerPadding)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@BaseUIDesktopPreview
|
|
||||||
@Composable
|
|
||||||
private fun BaseScaffoldPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
BaseScaffold(
|
|
||||||
topBar = {
|
|
||||||
Text(
|
|
||||||
text = "Title",
|
|
||||||
style = MaterialTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
bottomBar = {
|
|
||||||
Text(
|
|
||||||
text = "Bottom bar",
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(16.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
) { innerPadding ->
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(16.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
|
||||||
) {
|
|
||||||
Text("Content")
|
|
||||||
Text("Sits on the selected surface theme.", style = MaterialTheme.typography.bodySmall)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,369 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.heightIn
|
|
||||||
import androidx.compose.ui.layout.onSizeChanged
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableFloatStateOf
|
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
|
||||||
import androidx.compose.ui.geometry.CornerRadius
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.input.pointer.pointerInput
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val SliderTrackHeight = 7.dp
|
|
||||||
private val SliderThumbSize = 24.dp
|
|
||||||
private val SliderMinHeight = 32.dp
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Slider(
|
|
||||||
value: Float,
|
|
||||||
onValueChange: (Float) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
valueRange: ClosedFloatingPointRange<Float> = 0f..1f,
|
|
||||||
steps: Int = 0,
|
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
onValueChangeFinished: () -> Unit = {},
|
|
||||||
theme: BaseUITheme.SliderTheme? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.slider
|
|
||||||
val sliderTheme = theme ?: baseTheme
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val density = LocalDensity.current
|
|
||||||
var sliderWidth by remember { mutableIntStateOf(0) }
|
|
||||||
|
|
||||||
val range = valueRange.endInclusive - valueRange.start
|
|
||||||
val fraction = if (range > 0f) ((value - valueRange.start) / range).coerceIn(0f, 1f) else 0f
|
|
||||||
|
|
||||||
val stateColor = when {
|
|
||||||
isPressed -> sliderTheme.trackActiveColor.pressed
|
|
||||||
isHovered -> sliderTheme.trackActiveColor.hovered
|
|
||||||
else -> sliderTheme.trackActiveColor.normal
|
|
||||||
}
|
|
||||||
val inactiveColor = when {
|
|
||||||
isPressed -> sliderTheme.trackInactiveColor.pressed
|
|
||||||
isHovered -> sliderTheme.trackInactiveColor.hovered
|
|
||||||
else -> sliderTheme.trackInactiveColor.normal
|
|
||||||
}
|
|
||||||
val thumbColor = when {
|
|
||||||
isPressed -> sliderTheme.thumbColor.pressed
|
|
||||||
isHovered -> sliderTheme.thumbColor.hovered
|
|
||||||
else -> sliderTheme.thumbColor.normal
|
|
||||||
}
|
|
||||||
val thumbShadowState = when {
|
|
||||||
isPressed -> sliderTheme.thumbShadow.pressed
|
|
||||||
isHovered -> sliderTheme.thumbShadow.hovered
|
|
||||||
else -> sliderTheme.thumbShadow.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
val trackActiveColor = if (enabled) stateColor else stateColor.copy(alpha = 0.38f)
|
|
||||||
val trackInactiveColor = if (enabled) inactiveColor else inactiveColor.copy(alpha = 0.38f)
|
|
||||||
val resolvedThumbColor = if (enabled) thumbColor else thumbColor.copy(alpha = 0.38f)
|
|
||||||
|
|
||||||
val thumbScale by animateFloatAsState(
|
|
||||||
targetValue = when {
|
|
||||||
isPressed -> 1.15f
|
|
||||||
isHovered -> 1.08f
|
|
||||||
else -> 1f
|
|
||||||
},
|
|
||||||
animationSpec = tween(durationMillis = 150),
|
|
||||||
label = "thumbScale",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.heightIn(min = SliderMinHeight)
|
|
||||||
.onSizeChanged { sliderWidth = it.width }
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled)
|
|
||||||
.pointerInput(enabled, valueRange, steps) {
|
|
||||||
if (!enabled) return@pointerInput
|
|
||||||
|
|
||||||
val stepSize = if (steps > 0) range / (steps + 1) else 0f
|
|
||||||
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
|
|
||||||
|
|
||||||
fun valueFromX(x: Float): Float {
|
|
||||||
val width = size.width.toFloat()
|
|
||||||
val trackWidth = width - 2 * thumbHalfPx
|
|
||||||
if (trackWidth <= 0f) return valueRange.start
|
|
||||||
val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start
|
|
||||||
val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive)
|
|
||||||
return if (steps > 0) {
|
|
||||||
val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt()
|
|
||||||
(valueRange.start + stepIndex * stepSize).coerceIn(
|
|
||||||
valueRange.start,
|
|
||||||
valueRange.endInclusive,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
coerced
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detectTapGestures { offset ->
|
|
||||||
onValueChange(valueFromX(offset.x))
|
|
||||||
onValueChangeFinished()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
.pointerInput(enabled, valueRange, steps) {
|
|
||||||
if (!enabled) return@pointerInput
|
|
||||||
|
|
||||||
val stepSize = if (steps > 0) range / (steps + 1) else 0f
|
|
||||||
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
|
|
||||||
|
|
||||||
fun valueFromX(x: Float): Float {
|
|
||||||
val width = size.width.toFloat()
|
|
||||||
val trackWidth = width - 2 * thumbHalfPx
|
|
||||||
if (trackWidth <= 0f) return valueRange.start
|
|
||||||
val raw = ((x - thumbHalfPx) / trackWidth) * range + valueRange.start
|
|
||||||
val coerced = raw.coerceIn(valueRange.start, valueRange.endInclusive)
|
|
||||||
return if (steps > 0) {
|
|
||||||
val stepIndex = ((coerced - valueRange.start) / stepSize).roundToInt()
|
|
||||||
(valueRange.start + stepIndex * stepSize).coerceIn(
|
|
||||||
valueRange.start,
|
|
||||||
valueRange.endInclusive,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
coerced
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
detectHorizontalDragGestures(
|
|
||||||
onDragEnd = { onValueChangeFinished() },
|
|
||||||
) { change, _ ->
|
|
||||||
change.consume()
|
|
||||||
onValueChange(valueFromX(change.position.x))
|
|
||||||
}
|
|
||||||
},
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
Canvas(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(SliderTrackHeight)
|
|
||||||
.align(Alignment.CenterStart),
|
|
||||||
) {
|
|
||||||
val thumbHalfPx = SliderThumbSize.toPx() / 2f
|
|
||||||
val trackY = size.height / 2f
|
|
||||||
val trackStart = thumbHalfPx
|
|
||||||
val trackEnd = size.width - thumbHalfPx
|
|
||||||
val trackWidth = trackEnd - trackStart
|
|
||||||
|
|
||||||
if (trackWidth > 0f) {
|
|
||||||
val trackRadius = CornerRadius(size.height / 2f)
|
|
||||||
|
|
||||||
drawRoundRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
trackInactiveColor.copy(alpha = 0.45f),
|
|
||||||
trackInactiveColor,
|
|
||||||
trackInactiveColor.copy(alpha = 0.7f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
topLeft = Offset(trackStart, 0f),
|
|
||||||
size = Size(trackWidth, size.height),
|
|
||||||
cornerRadius = trackRadius,
|
|
||||||
)
|
|
||||||
drawLine(
|
|
||||||
color = Color.White.copy(alpha = 0.15f),
|
|
||||||
start = Offset(trackStart + size.height / 2f, 1.dp.toPx()),
|
|
||||||
end = Offset(trackEnd - size.height / 2f, 1.dp.toPx()),
|
|
||||||
strokeWidth = 1.dp.toPx(),
|
|
||||||
cap = StrokeCap.Round,
|
|
||||||
)
|
|
||||||
|
|
||||||
val activeEnd = trackStart + trackWidth * fraction
|
|
||||||
drawRoundRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
trackActiveColor.copy(alpha = 0.75f),
|
|
||||||
trackActiveColor,
|
|
||||||
trackActiveColor.copy(alpha = 0.9f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
topLeft = Offset(trackStart, 0f),
|
|
||||||
size = Size((activeEnd - trackStart).coerceAtLeast(0f), size.height),
|
|
||||||
cornerRadius = trackRadius,
|
|
||||||
)
|
|
||||||
drawLine(
|
|
||||||
color = Color.White.copy(alpha = 0.22f),
|
|
||||||
start = Offset(trackStart + size.height / 2f, 1.dp.toPx()),
|
|
||||||
end = Offset(activeEnd - size.height / 2f, 1.dp.toPx()),
|
|
||||||
strokeWidth = 1.dp.toPx(),
|
|
||||||
cap = StrokeCap.Round,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (steps > 0 && enabled) {
|
|
||||||
val stepSpacing = trackWidth / (steps + 1)
|
|
||||||
for (i in 1 downTo steps) {
|
|
||||||
val x = trackStart + stepSpacing * i
|
|
||||||
drawCircle(
|
|
||||||
color = if (x <= activeEnd) {
|
|
||||||
sliderTheme.stepActiveColor
|
|
||||||
} else {
|
|
||||||
sliderTheme.stepInactiveColor
|
|
||||||
},
|
|
||||||
radius = 2.dp.toPx(),
|
|
||||||
center = Offset(x, trackY),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(SliderThumbSize)
|
|
||||||
.offset {
|
|
||||||
val thumbHalfPx = with(density) { SliderThumbSize.toPx() / 2f }
|
|
||||||
val trackWidth = sliderWidth.toFloat() - 2 * thumbHalfPx
|
|
||||||
val x = thumbHalfPx + trackWidth * fraction - thumbHalfPx
|
|
||||||
IntOffset(x.roundToInt(), 0)
|
|
||||||
}
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = thumbScale
|
|
||||||
scaleY = thumbScale
|
|
||||||
}
|
|
||||||
.shadow(
|
|
||||||
elevation = thumbShadowState.elevation,
|
|
||||||
shape = CircleShape,
|
|
||||||
ambientColor = thumbShadowState.ambientColor,
|
|
||||||
spotColor = thumbShadowState.spotColor,
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
brush = Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = 0.6f),
|
|
||||||
resolvedThumbColor.copy(alpha = 0.95f),
|
|
||||||
resolvedThumbColor,
|
|
||||||
),
|
|
||||||
center = Offset(0f, -0.55f),
|
|
||||||
radius = 1.1f,
|
|
||||||
),
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
width = 0.8.dp,
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = 0.55f),
|
|
||||||
Color.White.copy(alpha = 0.08f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shape = CircleShape,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun SliderPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var volume by remember { mutableFloatStateOf(0.65f) }
|
|
||||||
var progress by remember { mutableFloatStateOf(0.3f) }
|
|
||||||
var quality by remember { mutableFloatStateOf(2f) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(28.dp),
|
|
||||||
) {
|
|
||||||
SliderPreviewField(title = "Volume", value = "${(volume * 100).toInt()}%") {
|
|
||||||
Slider(value = volume, onValueChange = { volume = it })
|
|
||||||
}
|
|
||||||
SliderPreviewField(title = "Progress", value = "${(progress * 100).toInt()}%") {
|
|
||||||
Slider(value = progress, onValueChange = { progress = it })
|
|
||||||
}
|
|
||||||
SliderPreviewField(title = "Quality", value = "${quality.toInt()} / 4") {
|
|
||||||
Slider(
|
|
||||||
value = quality,
|
|
||||||
onValueChange = { quality = it },
|
|
||||||
valueRange = 0f..4f,
|
|
||||||
steps = 3,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
SliderPreviewField(title = "Disabled", value = "40%") {
|
|
||||||
Slider(value = 0.4f, onValueChange = {}, enabled = false)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun SliderPreviewField(
|
|
||||||
title: String,
|
|
||||||
value: String,
|
|
||||||
content: @Composable () -> Unit,
|
|
||||||
) {
|
|
||||||
Column(verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(title, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Text(
|
|
||||||
text = value,
|
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
content()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,699 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
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.material3.LocalContentColor
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
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.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.geometry.Size
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Path
|
|
||||||
import androidx.compose.ui.graphics.RectangleShape
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.drawscope.DrawScope
|
|
||||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import kotlin.random.Random
|
|
||||||
import kotlinx.serialization.Serializable
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The Frutiger-Aero material families available to [BaseSurface].
|
|
||||||
*
|
|
||||||
* - [Watery]: deep aqua water with caustic light and bubbles.
|
|
||||||
* - [Icy]: pale frosted ice with crystalline facets and frost.
|
|
||||||
* - [Chrome]: brushed metal with a hard specular reflection.
|
|
||||||
* - [Acrylic]: translucent tinted panel with fine grain.
|
|
||||||
* - [Glass]: clear, glossy glass with a strong sheen.
|
|
||||||
*
|
|
||||||
* Each style doubles as a full app theme (see [BaseUIColorScheme]).
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
enum class SurfaceStyle {
|
|
||||||
Watery,
|
|
||||||
Icy,
|
|
||||||
Chrome,
|
|
||||||
Acrylic,
|
|
||||||
Glass,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The role a surface plays within a theme. Each style produces a distinct material for
|
|
||||||
* every role, so a theme is composed from them:
|
|
||||||
*
|
|
||||||
* - [PrimaryBackground] — the subtle main background (pages, panels).
|
|
||||||
* - [SecondaryBackground] — a distinct, complementary background hue that pairs with the
|
|
||||||
* primary (grouped/alternate sections).
|
|
||||||
* - [PrimaryAccent] — the primary accent surface for interactive elements (buttons,
|
|
||||||
* toggles, active chips).
|
|
||||||
* - [SecondaryAccent] — a complementary accent surface (secondary actions, highlights).
|
|
||||||
* - [InverseBackground] — the inverted-brightness background (tooltips, snackbars).
|
|
||||||
* - [InverseAction] — the inverted-brightness action surface.
|
|
||||||
*/
|
|
||||||
@Serializable
|
|
||||||
enum class SurfaceRole {
|
|
||||||
PrimaryBackground,
|
|
||||||
SecondaryBackground,
|
|
||||||
PrimaryAccent,
|
|
||||||
SecondaryAccent,
|
|
||||||
InverseBackground,
|
|
||||||
InverseAction,
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* A themed surface panel rendered in one of the Frutiger-Aero [SurfaceStyle] materials,
|
|
||||||
* in the given [SurfaceRole].
|
|
||||||
*
|
|
||||||
* Distinct from [androidx.compose.material3.Surface] — this draws decorative material
|
|
||||||
* backgrounds (gradients, gloss, grain, bubbles, facets) and provides a matching
|
|
||||||
* [LocalContentColor] so text/content inside stays legible.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun BaseSurface(
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
|
|
||||||
role: SurfaceRole = SurfaceRole.PrimaryBackground,
|
|
||||||
shape: Shape = RectangleShape,
|
|
||||||
elevation: Dp? = null,
|
|
||||||
contentColor: Color? = null,
|
|
||||||
contentPadding: PaddingValues = PaddingValues.Zero,
|
|
||||||
contentAlignment: Alignment = Alignment.TopStart,
|
|
||||||
content: @Composable BoxScope.() -> Unit,
|
|
||||||
) {
|
|
||||||
val ambientScheme = LocalBaseUIColors.current
|
|
||||||
val isLight = ambientScheme.isLight
|
|
||||||
// A surface is rendered in its own style's colour scheme, so previewing a style shows
|
|
||||||
// that style's real accent instead of the ambient theme's. When the requested style is
|
|
||||||
// the ambient theme, the ambient scheme is used so a user-chosen accent still applies.
|
|
||||||
val ownScheme = remember(style, isLight) {
|
|
||||||
baseUIColorScheme(style, isLight, style.accentColor(isLight))
|
|
||||||
}
|
|
||||||
val scheme = if (ambientScheme.surfaceTheme == style) ambientScheme else ownScheme
|
|
||||||
val palette = surfacePalette(style, isLight)
|
|
||||||
|
|
||||||
val baseElevation = when (style) {
|
|
||||||
SurfaceStyle.Watery -> 14.dp
|
|
||||||
SurfaceStyle.Icy -> 12.dp
|
|
||||||
SurfaceStyle.Chrome -> 10.dp
|
|
||||||
SurfaceStyle.Acrylic -> 16.dp
|
|
||||||
SurfaceStyle.Glass -> 12.dp
|
|
||||||
}
|
|
||||||
val resolvedElevation = elevation ?: when (role) {
|
|
||||||
SurfaceRole.PrimaryBackground -> baseElevation
|
|
||||||
SurfaceRole.SecondaryBackground -> baseElevation + 2.dp
|
|
||||||
SurfaceRole.PrimaryAccent, SurfaceRole.SecondaryAccent -> baseElevation + 6.dp
|
|
||||||
SurfaceRole.InverseBackground -> baseElevation
|
|
||||||
SurfaceRole.InverseAction -> baseElevation + 6.dp
|
|
||||||
}
|
|
||||||
val resolvedContentColor = contentColor ?: when (role) {
|
|
||||||
SurfaceRole.PrimaryBackground -> scheme.onBackgroundPrimary
|
|
||||||
SurfaceRole.SecondaryBackground -> scheme.onBackgroundSecondary
|
|
||||||
SurfaceRole.PrimaryAccent -> scheme.onAccentPrimary
|
|
||||||
SurfaceRole.SecondaryAccent -> scheme.onAccentSecondary
|
|
||||||
SurfaceRole.InverseBackground -> scheme.onBackgroundInverse
|
|
||||||
SurfaceRole.InverseAction -> scheme.onActionInverse
|
|
||||||
}
|
|
||||||
val ambient = when (role) {
|
|
||||||
SurfaceRole.PrimaryAccent -> scheme.accentPrimary.copy(alpha = 0.42f)
|
|
||||||
SurfaceRole.SecondaryAccent -> scheme.accentSecondary.copy(alpha = 0.42f)
|
|
||||||
SurfaceRole.InverseAction -> scheme.actionInverse.copy(alpha = 0.42f)
|
|
||||||
SurfaceRole.InverseBackground -> Color.Black.copy(alpha = 0.3f)
|
|
||||||
else -> palette.onSurface.copy(alpha = 0.2f)
|
|
||||||
}
|
|
||||||
val spot = ambient.copy(alpha = (ambient.alpha + 0.07f).coerceAtMost(1f))
|
|
||||||
val borderBrush = roleBorderBrush(style, role, isLight, scheme)
|
|
||||||
|
|
||||||
// Translucent materials (Glass, Acrylic) must not use a drop shadow: it is drawn
|
|
||||||
// behind the node and, because the panel is see-through, shows up as a dark inset
|
|
||||||
// shape. Accent and secondary/inverse roles are filled, so they can cast one.
|
|
||||||
val translucent = role == SurfaceRole.PrimaryBackground &&
|
|
||||||
(style == SurfaceStyle.Glass || style == SurfaceStyle.Acrylic)
|
|
||||||
|
|
||||||
CompositionLocalProvider(LocalContentColor provides resolvedContentColor) {
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.then(
|
|
||||||
if (!translucent) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = resolvedElevation,
|
|
||||||
shape = shape,
|
|
||||||
ambientColor = ambient,
|
|
||||||
spotColor = spot,
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clip(shape)
|
|
||||||
.drawWithCache {
|
|
||||||
onDrawBehind { drawSurfaceMaterial(style, role, isLight, scheme) }
|
|
||||||
}
|
|
||||||
.border(BorderStroke(1.dp, borderBrush), shape)
|
|
||||||
.padding(contentPadding),
|
|
||||||
contentAlignment = contentAlignment,
|
|
||||||
content = content,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paints the full-bleed material of [style] as a page background: the primary material
|
|
||||||
* body plus its sheen and reflections, but no panel bevel (a backdrop has no edges).
|
|
||||||
*/
|
|
||||||
fun Modifier.baseSurfaceBackdrop(style: SurfaceStyle, isLight: Boolean): Modifier =
|
|
||||||
drawWithCache {
|
|
||||||
onDrawBehind {
|
|
||||||
drawStyleBase(style, isLight)
|
|
||||||
drawStyleDecorations(style, isLight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Overlays a [SurfaceStyle]'s material character (gloss, bubbles, facets, specular band,
|
|
||||||
* grain) and its inner bevel on a node that already carries a base fill. Compact components
|
|
||||||
* — buttons, toggles, chips — use this to share the exact look of a [BaseSurface] in [role]
|
|
||||||
* without redrawing the fill (which comes from their own interaction-state brush).
|
|
||||||
*/
|
|
||||||
fun Modifier.baseSurfaceTexture(
|
|
||||||
style: SurfaceStyle,
|
|
||||||
role: SurfaceRole,
|
|
||||||
isLight: Boolean,
|
|
||||||
): Modifier = drawWithCache {
|
|
||||||
onDrawBehind {
|
|
||||||
val effectiveLight = when (role) {
|
|
||||||
SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight
|
|
||||||
else -> isLight
|
|
||||||
}
|
|
||||||
drawStyleDecorations(style, effectiveLight)
|
|
||||||
drawInnerBevel(effectiveLight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Ambient-scheme overload of [baseSurfaceTexture] for a single role. */
|
|
||||||
@Composable
|
|
||||||
fun Modifier.baseSurfaceTexture(role: SurfaceRole): Modifier {
|
|
||||||
val scheme = LocalBaseUIColors.current
|
|
||||||
return baseSurfaceTexture(scheme.surfaceTheme, role, scheme.isLight)
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Paints the full [SurfaceStyle] material for [role] — base fill, material character and inner
|
|
||||||
* bevel — using the ambient [BaseUIColorScheme]. Use it to make a chrome panel (a sidebar, a
|
|
||||||
* player bar) a first-class surface rather than a flat Material surface.
|
|
||||||
*/
|
|
||||||
@Composable
|
|
||||||
fun Modifier.baseSurfaceMaterial(
|
|
||||||
role: SurfaceRole = SurfaceRole.PrimaryBackground,
|
|
||||||
style: SurfaceStyle = LocalBaseUIColors.current.surfaceTheme,
|
|
||||||
): Modifier {
|
|
||||||
val scheme = LocalBaseUIColors.current
|
|
||||||
return drawWithCache {
|
|
||||||
onDrawBehind { drawSurfaceMaterial(style, role, scheme.isLight, scheme) }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawSurfaceMaterial(
|
|
||||||
style: SurfaceStyle,
|
|
||||||
role: SurfaceRole,
|
|
||||||
isLight: Boolean,
|
|
||||||
scheme: BaseUIColorScheme,
|
|
||||||
) {
|
|
||||||
// Role base fill.
|
|
||||||
when (role) {
|
|
||||||
SurfaceRole.PrimaryBackground -> drawStyleBase(style, isLight)
|
|
||||||
|
|
||||||
// A distinct, complementary background hue (keeps the style's texture below).
|
|
||||||
SurfaceRole.SecondaryBackground -> drawRect(color = scheme.backgroundSecondary)
|
|
||||||
|
|
||||||
SurfaceRole.PrimaryAccent -> drawAccentBase(scheme.accentPrimary)
|
|
||||||
SurfaceRole.SecondaryAccent -> drawAccentBase(scheme.accentSecondary)
|
|
||||||
|
|
||||||
// Opaque inverted background so translucent styles still read as a solid surface.
|
|
||||||
SurfaceRole.InverseBackground -> drawRect(color = scheme.backgroundInverse)
|
|
||||||
|
|
||||||
SurfaceRole.InverseAction -> drawAccentBase(scheme.actionInverse)
|
|
||||||
}
|
|
||||||
|
|
||||||
// The material's own character (bubbles, facets, grain, specular band, sheen).
|
|
||||||
val effectiveLight = when (role) {
|
|
||||||
SurfaceRole.InverseBackground, SurfaceRole.InverseAction -> !isLight
|
|
||||||
else -> isLight
|
|
||||||
}
|
|
||||||
drawStyleDecorations(style, effectiveLight)
|
|
||||||
drawInnerBevel(effectiveLight)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Material base gradients (one per style) ---------------------------------
|
|
||||||
|
|
||||||
private fun DrawScope.drawStyleBase(style: SurfaceStyle, isLight: Boolean) {
|
|
||||||
when (style) {
|
|
||||||
SurfaceStyle.Glass -> {
|
|
||||||
val tint = if (isLight) Color(0xFFCFE0F2) else Color(0xFF9FB8D6)
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
tint.copy(alpha = if (isLight) 0.4f else 0.2f),
|
|
||||||
tint.copy(alpha = if (isLight) 0.24f else 0.1f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Acrylic -> {
|
|
||||||
val tint = if (isLight) Color(0xFFCFD9E8) else Color(0xFF8FA2B8)
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
tint.copy(alpha = if (isLight) 0.45f else 0.22f),
|
|
||||||
tint.copy(alpha = if (isLight) 0.28f else 0.12f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Chrome -> {
|
|
||||||
if (isLight) {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
0f to Color(0xFFF8FBFF),
|
|
||||||
0.16f to Color(0xFFD6DEE7),
|
|
||||||
0.4f to Color(0xFF8E99A8),
|
|
||||||
0.5f to Color(0xFFEDF2F7),
|
|
||||||
0.62f to Color(0xFF9AA5B4),
|
|
||||||
1f to Color(0xFF6C7787),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
0f to Color(0xFF3B424C),
|
|
||||||
0.16f to Color(0xFF2A303A),
|
|
||||||
0.4f to Color(0xFF1B2028),
|
|
||||||
0.5f to Color(0xFF343B45),
|
|
||||||
0.62f to Color(0xFF232830),
|
|
||||||
1f to Color(0xFF141820),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Icy -> {
|
|
||||||
val colors = if (isLight) {
|
|
||||||
listOf(Color(0xFFF4FCFF), Color(0xFFCBE9FB), Color(0xFF9BD3F0))
|
|
||||||
} else {
|
|
||||||
listOf(Color(0xFF2A3B4D), Color(0xFF1B2A39), Color(0xFF0F1A26))
|
|
||||||
}
|
|
||||||
drawRect(brush = Brush.verticalGradient(colors))
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Watery -> {
|
|
||||||
// A clear-but-soft aqua column: unmistakably water, without a heavy flat blue.
|
|
||||||
val colors = if (isLight) {
|
|
||||||
listOf(Color(0xFFD8F0FD), Color(0xFFB0DEF7), Color(0xFF83C8EE))
|
|
||||||
} else {
|
|
||||||
listOf(Color(0xFF123249), Color(0xFF0B2135), Color(0xFF06141F))
|
|
||||||
}
|
|
||||||
drawRect(brush = Brush.verticalGradient(colors))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** The accent-filled base used by [SurfaceRole.Action]. */
|
|
||||||
private fun DrawScope.drawAccentBase(accent: Color) {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
lerp(accent, Color.White, 0.45f),
|
|
||||||
accent,
|
|
||||||
lerp(accent, Color.Black, 0.16f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// --- Material decorations (one per style) ------------------------------------
|
|
||||||
|
|
||||||
private fun DrawScope.drawStyleDecorations(style: SurfaceStyle, isLight: Boolean) {
|
|
||||||
when (style) {
|
|
||||||
SurfaceStyle.Glass -> {
|
|
||||||
drawGloss(if (isLight) 0.55f else 0.16f)
|
|
||||||
drawBottomReflection(if (isLight) 0.4f else 0.1f)
|
|
||||||
drawBubbles(seed = 11, count = 3, alpha = if (isLight) 0.4f else 0.14f)
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Acrylic -> {
|
|
||||||
// Fine grain so the panel reads as a translucent acrylic sheet.
|
|
||||||
val random = Random(23)
|
|
||||||
repeat(180) {
|
|
||||||
val light = random.nextBoolean()
|
|
||||||
drawCircle(
|
|
||||||
color = if (light) {
|
|
||||||
Color.White.copy(alpha = if (isLight) 0.16f else 0.06f)
|
|
||||||
} else {
|
|
||||||
Color.Black.copy(alpha = if (isLight) 0.06f else 0.08f)
|
|
||||||
},
|
|
||||||
radius = 0.7f,
|
|
||||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
drawGloss(if (isLight) 0.4f else 0.12f, heightFraction = 0.45f)
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Chrome -> {
|
|
||||||
// Hard specular reflection near the top.
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = if (isLight) 0.85f else 0.18f),
|
|
||||||
Color.Transparent,
|
|
||||||
),
|
|
||||||
startY = size.height * 0.05f,
|
|
||||||
endY = size.height * 0.22f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
// Darkened lower edge for the metal bevel.
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = 0.25f)),
|
|
||||||
startY = size.height * 0.78f,
|
|
||||||
endY = size.height,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Icy -> {
|
|
||||||
val frost = if (isLight) Color.White else Color(0xFFB8CCE0)
|
|
||||||
|
|
||||||
// Crystalline facets (kept inset so they are never clipped by the shape).
|
|
||||||
val random = Random(37)
|
|
||||||
repeat(4) {
|
|
||||||
val s = size.minDimension * (0.2f + random.nextFloat() * 0.3f)
|
|
||||||
val x = random.nextFloat() * (size.width - s).coerceAtLeast(0f)
|
|
||||||
val y = random.nextFloat() * (size.height - s).coerceAtLeast(0f)
|
|
||||||
val path = Path().apply {
|
|
||||||
moveTo(x, y)
|
|
||||||
lineTo(x + s, y + s * 0.4f)
|
|
||||||
lineTo(x + s * 0.4f, y + s)
|
|
||||||
close()
|
|
||||||
}
|
|
||||||
drawPath(path, frost.copy(alpha = if (isLight) 0.25f else 0.12f))
|
|
||||||
}
|
|
||||||
|
|
||||||
// Frost speckles.
|
|
||||||
repeat(90) {
|
|
||||||
drawCircle(
|
|
||||||
color = frost.copy(alpha = if (isLight) 0.5f else 0.28f),
|
|
||||||
radius = 0.8f,
|
|
||||||
center = Offset(random.nextFloat() * size.width, random.nextFloat() * size.height),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
drawGloss(if (isLight) 0.6f else 0.12f, heightFraction = 0.5f)
|
|
||||||
}
|
|
||||||
|
|
||||||
SurfaceStyle.Watery -> {
|
|
||||||
val sheen = if (isLight) Color.White else Color(0xFFAFD3F2)
|
|
||||||
|
|
||||||
// Caustic light pooling from the surface.
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
sheen.copy(alpha = if (isLight) 0.6f else 0.2f),
|
|
||||||
Color.Transparent,
|
|
||||||
),
|
|
||||||
startY = 0f,
|
|
||||||
endY = size.height * 0.55f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val random = Random(53)
|
|
||||||
// Refracted caustic blobs — the bright, rippling patterns of moving water.
|
|
||||||
repeat(9) {
|
|
||||||
val rx = size.minDimension * (0.14f + random.nextFloat() * 0.26f)
|
|
||||||
val cx = rx + random.nextFloat() * (size.width - 2 * rx).coerceAtLeast(0f)
|
|
||||||
val cy = rx + random.nextFloat() * (size.height - 2 * rx).coerceAtLeast(0f)
|
|
||||||
drawOval(
|
|
||||||
brush = Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
sheen.copy(alpha = if (isLight) 0.5f else 0.22f),
|
|
||||||
Color.Transparent,
|
|
||||||
),
|
|
||||||
center = Offset(cx, cy),
|
|
||||||
radius = rx,
|
|
||||||
),
|
|
||||||
topLeft = Offset(cx - rx, cy - rx * 0.7f),
|
|
||||||
size = Size(rx * 2f, rx * 1.4f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Ripple crests: thin horizontal highlights that read as surface waves.
|
|
||||||
repeat(3) {
|
|
||||||
val y = size.height * (0.18f + random.nextFloat() * 0.64f)
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.Transparent,
|
|
||||||
sheen.copy(alpha = if (isLight) 0.4f else 0.16f),
|
|
||||||
Color.Transparent,
|
|
||||||
),
|
|
||||||
startY = y - size.height * 0.035f,
|
|
||||||
endY = y + size.height * 0.035f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
drawBubbles(seed = 71, count = 11, alpha = if (isLight) 0.6f else 0.3f, rim = true)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawGloss(alpha: Float, heightFraction: Float = 0.5f) {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.White.copy(alpha = alpha), Color.Transparent),
|
|
||||||
startY = 0f,
|
|
||||||
endY = size.height * heightFraction,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawBottomReflection(alpha: Float) {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.Transparent, Color.White.copy(alpha = alpha)),
|
|
||||||
startY = size.height * 0.7f,
|
|
||||||
endY = size.height,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
/** A bright inner top edge plus a shaded inner bottom edge, giving the pane a glass bevel. */
|
|
||||||
private fun DrawScope.drawInnerBevel(isLight: Boolean) {
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.White.copy(alpha = if (isLight) 0.85f else 0.22f), Color.Transparent),
|
|
||||||
startY = 0f,
|
|
||||||
endY = size.height * 0.1f,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
drawRect(
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.Transparent, Color.Black.copy(alpha = if (isLight) 0.08f else 0.28f)),
|
|
||||||
startY = size.height * 0.9f,
|
|
||||||
endY = size.height,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun DrawScope.drawBubbles(seed: Int, count: Int, alpha: Float, rim: Boolean = false) {
|
|
||||||
val random = Random(seed)
|
|
||||||
repeat(count) {
|
|
||||||
val radius = size.minDimension * (0.05f + random.nextFloat() * 0.14f)
|
|
||||||
// Keep bubbles inset so they are never clipped into hard arcs at the edges.
|
|
||||||
val cx = radius + random.nextFloat() * (size.width - 2 * radius).coerceAtLeast(0f)
|
|
||||||
val cy = radius + random.nextFloat() * (size.height - 2 * radius).coerceAtLeast(0f)
|
|
||||||
val center = Offset(cx, cy)
|
|
||||||
drawCircle(
|
|
||||||
brush = Brush.radialGradient(
|
|
||||||
colors = listOf(Color.White.copy(alpha = alpha), Color.White.copy(alpha = 0f)),
|
|
||||||
center = center,
|
|
||||||
radius = radius,
|
|
||||||
),
|
|
||||||
radius = radius,
|
|
||||||
center = center,
|
|
||||||
)
|
|
||||||
if (rim) {
|
|
||||||
drawCircle(
|
|
||||||
color = Color.White.copy(alpha = alpha * 0.7f),
|
|
||||||
radius = radius,
|
|
||||||
center = center,
|
|
||||||
style = Stroke(width = (radius * 0.14f).coerceAtLeast(0.8f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun styleBorderBrush(style: SurfaceStyle): Brush = when (style) {
|
|
||||||
SurfaceStyle.Watery -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.7f), Color.White.copy(alpha = 0.12f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Icy -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.95f), Color.White.copy(alpha = 0.35f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Chrome -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.95f), Color.Black.copy(alpha = 0.28f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Acrylic -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.12f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceStyle.Glass -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.9f), Color.White.copy(alpha = 0.3f)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun roleBorderBrush(
|
|
||||||
style: SurfaceStyle,
|
|
||||||
role: SurfaceRole,
|
|
||||||
isLight: Boolean,
|
|
||||||
scheme: BaseUIColorScheme,
|
|
||||||
): Brush = when (role) {
|
|
||||||
SurfaceRole.PrimaryBackground -> styleBorderBrush(style)
|
|
||||||
|
|
||||||
SurfaceRole.SecondaryBackground -> Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
lerp(scheme.backgroundSecondary, Color.White, 0.5f)
|
|
||||||
.copy(alpha = if (isLight) 0.85f else 0.6f),
|
|
||||||
Color.White.copy(alpha = 0.12f),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceRole.PrimaryAccent -> accentEdgeBrush(scheme.accentPrimary)
|
|
||||||
SurfaceRole.SecondaryAccent -> accentEdgeBrush(scheme.accentSecondary)
|
|
||||||
|
|
||||||
SurfaceRole.InverseBackground -> Brush.verticalGradient(
|
|
||||||
listOf(Color.White.copy(alpha = 0.5f), Color.White.copy(alpha = 0.1f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
SurfaceRole.InverseAction -> accentEdgeBrush(scheme.actionInverse)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun accentEdgeBrush(accent: Color): Brush = Brush.verticalGradient(
|
|
||||||
listOf(lerp(accent, Color.White, 0.6f), lerp(accent, Color.Black, 0.2f)),
|
|
||||||
)
|
|
||||||
|
|
||||||
/**
|
|
||||||
* One preview showing everything about surfaces: every [SurfaceStyle] crossed with every
|
|
||||||
* [SurfaceRole], plus the full-bleed backdrops — no need to scroll through many previews.
|
|
||||||
*/
|
|
||||||
@BaseUIDesktopPreview
|
|
||||||
@Composable
|
|
||||||
private fun SurfacePreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
val colors = LocalBaseUIColors.current
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(20.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
SurfaceStyle.entries.forEach { style ->
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
SurfaceRole.entries.forEach { role ->
|
|
||||||
BaseSurface(
|
|
||||||
style = style,
|
|
||||||
role = role,
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(84.dp),
|
|
||||||
contentPadding = PaddingValues(10.dp),
|
|
||||||
) {
|
|
||||||
Column(
|
|
||||||
modifier = Modifier.fillMaxSize(),
|
|
||||||
verticalArrangement = Arrangement.SpaceBetween,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = style.name,
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
fontWeight = FontWeight.SemiBold,
|
|
||||||
)
|
|
||||||
Text(
|
|
||||||
text = role.name,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Full-bleed backdrops (the page background material, without the panel bevel).
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
SurfaceStyle.entries.forEach { style ->
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.weight(1f)
|
|
||||||
.height(60.dp)
|
|
||||||
.clip(RoundedCornerShape(12.dp))
|
|
||||||
.baseSurfaceBackdrop(style, colors.isLight),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Text(
|
|
||||||
text = style.name,
|
|
||||||
style = MaterialTheme.typography.labelSmall,
|
|
||||||
color = colors.onSurface,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,291 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.Spacer
|
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
|
||||||
import androidx.compose.foundation.text.KeyboardActions
|
|
||||||
import androidx.compose.foundation.text.KeyboardOptions
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.input.VisualTransformation
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSearchBroken
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
|
||||||
|
|
||||||
private val TextFieldMinHeight = 44.dp
|
|
||||||
|
|
||||||
private data class ResolvedTextFieldState(
|
|
||||||
val background: Brush,
|
|
||||||
val highlight: Color,
|
|
||||||
val shape: Shape,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val foreground: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveTextFieldState(
|
|
||||||
theme: BaseUITheme.TextFieldTheme,
|
|
||||||
isFocused: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedTextFieldState {
|
|
||||||
return when {
|
|
||||||
isFocused -> ResolvedTextFieldState(theme.background.focused, theme.highlight.focused, theme.shape.focused, theme.border.focused, theme.shadow.focused, theme.foreground.focused)
|
|
||||||
isHovered -> ResolvedTextFieldState(theme.background.hovered, theme.highlight.hovered, theme.shape.hovered, theme.border.hovered, theme.shadow.hovered, theme.foreground.hovered)
|
|
||||||
else -> ResolvedTextFieldState(theme.background.normal, theme.highlight.normal, theme.shape.normal, theme.border.normal, theme.shadow.normal, theme.foreground.normal)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun TextField(
|
|
||||||
value: String,
|
|
||||||
onValueChange: (String) -> Unit,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
placeholder: @Composable (() -> Unit)? = null,
|
|
||||||
leadingIcon: @Composable (() -> Unit)? = null,
|
|
||||||
trailingIcon: @Composable (() -> Unit)? = null,
|
|
||||||
label: @Composable (() -> Unit)? = null,
|
|
||||||
singleLine: Boolean = false,
|
|
||||||
maxLines: Int = Int.MAX_VALUE,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
readOnly: Boolean = false,
|
|
||||||
isError: Boolean = false,
|
|
||||||
keyboardOptions: KeyboardOptions = KeyboardOptions.Default,
|
|
||||||
keyboardActions: KeyboardActions = KeyboardActions.Default,
|
|
||||||
visualTransformation: VisualTransformation = VisualTransformation.None,
|
|
||||||
interactionSource: MutableInteractionSource = remember { MutableInteractionSource() },
|
|
||||||
textStyle: TextStyle = TextStyle.Default,
|
|
||||||
cursorBrush: Color? = null,
|
|
||||||
theme: BaseUITheme.TextFieldTheme? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.textField
|
|
||||||
val textFieldTheme = theme ?: baseTheme
|
|
||||||
val isFocused by interactionSource.collectIsFocusedAsState()
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
val state = resolveTextFieldState(textFieldTheme, isFocused, isHovered)
|
|
||||||
|
|
||||||
val resolvedBorder = if (isError) {
|
|
||||||
BaseUITheme.Border(MaterialTheme.colorScheme.error, state.border.width)
|
|
||||||
} else {
|
|
||||||
state.border
|
|
||||||
}
|
|
||||||
|
|
||||||
val contentColor = when {
|
|
||||||
!enabled -> state.foreground.copy(alpha = 0.38f)
|
|
||||||
else -> state.foreground
|
|
||||||
}
|
|
||||||
|
|
||||||
val cursor = cursorBrush?.let { SolidColor(it) } ?: textFieldTheme.cursor
|
|
||||||
|
|
||||||
Column(modifier = modifier) {
|
|
||||||
AnimatedVisibility(
|
|
||||||
visible = label != null,
|
|
||||||
enter = fadeIn(),
|
|
||||||
exit = fadeOut(),
|
|
||||||
) {
|
|
||||||
label?.invoke()
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.defaultMinSize(minHeight = TextFieldMinHeight)
|
|
||||||
.then(
|
|
||||||
if (state.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = state.shadow.elevation,
|
|
||||||
shape = state.shape,
|
|
||||||
ambientColor = state.shadow.ambientColor,
|
|
||||||
spotColor = state.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(state.shape)
|
|
||||||
.background(state.background, state.shape)
|
|
||||||
.border(BorderStroke(resolvedBorder.width, resolvedBorder.color), state.shape)
|
|
||||||
.highlight(state.highlight)
|
|
||||||
.padding(textFieldTheme.padding),
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
if (leadingIcon != null) {
|
|
||||||
leadingIcon()
|
|
||||||
}
|
|
||||||
Box(modifier = Modifier.weight(1f)) {
|
|
||||||
BasicTextField(
|
|
||||||
value = value,
|
|
||||||
onValueChange = onValueChange,
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.hoverable(interactionSource = interactionSource, enabled = enabled),
|
|
||||||
enabled = enabled,
|
|
||||||
readOnly = readOnly,
|
|
||||||
textStyle = textStyle.copy(color = contentColor),
|
|
||||||
cursorBrush = cursor,
|
|
||||||
keyboardOptions = keyboardOptions,
|
|
||||||
keyboardActions = keyboardActions,
|
|
||||||
singleLine = singleLine,
|
|
||||||
maxLines = maxLines,
|
|
||||||
visualTransformation = visualTransformation,
|
|
||||||
interactionSource = interactionSource,
|
|
||||||
decorationBox = { innerTextField ->
|
|
||||||
if (value.isEmpty() && placeholder != null && !isFocused) {
|
|
||||||
CompositionLocalProvider(LocalTextStyle provides textStyle) {
|
|
||||||
Box(contentAlignment = Alignment.CenterStart) {
|
|
||||||
placeholder()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
innerTextField()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (trailingIcon != null) {
|
|
||||||
trailingIcon()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun TextFieldPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var email by remember { mutableStateOf("") }
|
|
||||||
var query by remember { mutableStateOf("Twenty One Pilots") }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
|
||||||
TextField(
|
|
||||||
value = email,
|
|
||||||
onValueChange = { email = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = { Text("you@example.com") },
|
|
||||||
label = { TextFieldPreviewLabel("Email address") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.User,
|
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
value = query,
|
|
||||||
onValueChange = { query = it },
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
placeholder = { Text("Search songs, artists, albums...") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxSearchBroken,
|
|
||||||
contentDescription = "Search",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
trailingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxTrash,
|
|
||||||
contentDescription = "Clear",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
value = "not-an-email",
|
|
||||||
onValueChange = {},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { TextFieldPreviewLabel("Error state") },
|
|
||||||
isError = true,
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
|
|
||||||
TextField(
|
|
||||||
value = "Read only value",
|
|
||||||
onValueChange = {},
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
label = { TextFieldPreviewLabel("Disabled") },
|
|
||||||
enabled = false,
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TextFieldPreviewLabel(text: String) {
|
|
||||||
Text(
|
|
||||||
text = text,
|
|
||||||
modifier = Modifier.padding(bottom = 6.dp),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,962 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.Stable
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.SolidColor
|
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.compose.ui.graphics.lerp
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.unit.Dp
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.AutoCompleteMenuTheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.CheckBoxTheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.ChipTabTheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.SliderTheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme.TextFieldTheme
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class BaseUITheme(
|
|
||||||
val colors: BaseUIColorScheme,
|
|
||||||
val surfaceTheme: SurfaceStyle,
|
|
||||||
val buttons: ButtonVariants,
|
|
||||||
val iconButtons: ButtonVariants,
|
|
||||||
val textField: TextFieldTheme,
|
|
||||||
val autoCompleteMenuTheme: AutoCompleteMenuTheme,
|
|
||||||
val slider: SliderTheme,
|
|
||||||
val checkBox: CheckBoxTheme,
|
|
||||||
val chipTab: ChipTabTheme,
|
|
||||||
val toggle: ToggleTheme,
|
|
||||||
val card: CardTheme,
|
|
||||||
val listRowTile: ListRowTheme,
|
|
||||||
val dialog: DialogTheme,
|
|
||||||
val dropdownMenu: DropdownMenuTheme,
|
|
||||||
) {
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class InteractionState<T>(
|
|
||||||
val normal: T,
|
|
||||||
val hovered: T,
|
|
||||||
val pressed: T,
|
|
||||||
val focused: T,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun <T> fromSingleValue(value: T): InteractionState<T> {
|
|
||||||
return InteractionState(value, value, value, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class AdvancedInteractionState<T>(
|
|
||||||
val normal: T,
|
|
||||||
val hovered: T,
|
|
||||||
val pressed: T,
|
|
||||||
val focused: T,
|
|
||||||
val selected: T,
|
|
||||||
val disabled: T,
|
|
||||||
) {
|
|
||||||
companion object {
|
|
||||||
fun <T> fromSingleValue(value: T): AdvancedInteractionState<T> {
|
|
||||||
return AdvancedInteractionState(value, value, value, value, value, value)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class Border(
|
|
||||||
val color: Color,
|
|
||||||
val width: Dp,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class Shadow(
|
|
||||||
val elevation: Dp,
|
|
||||||
val clip: Boolean = elevation > 0.dp,
|
|
||||||
val ambientColor: Color,
|
|
||||||
val spotColor: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class ButtonVariants(
|
|
||||||
val primary: ButtonStyle,
|
|
||||||
val secondary: ButtonStyle,
|
|
||||||
val outline: ButtonStyle,
|
|
||||||
val ghost: ButtonStyle,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class ButtonStyle(
|
|
||||||
val colors: InteractionState<ButtonColors>,
|
|
||||||
val shape: InteractionState<Shape>,
|
|
||||||
val shadow: InteractionState<Shadow>,
|
|
||||||
val border: InteractionState<Border>,
|
|
||||||
val padding: InteractionState<PaddingValues>,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Stable
|
|
||||||
data class ButtonColors(
|
|
||||||
val background: Brush,
|
|
||||||
val foreground: Color,
|
|
||||||
val highlight: Color,
|
|
||||||
/**
|
|
||||||
* The [SurfaceRole] this colour represents. When set, components overlay the
|
|
||||||
* matching [SurfaceStyle] material (gloss, bubbles, facets, specular band, grain)
|
|
||||||
* on top of [background], so buttons and other actionable items share the exact
|
|
||||||
* look of a [BaseSurface]. `null` means no material (e.g. ghost buttons).
|
|
||||||
*/
|
|
||||||
val surfaceRole: SurfaceRole? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CardTheme(
|
|
||||||
val background: Brush,
|
|
||||||
val shape: Shape,
|
|
||||||
val border: Border,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
val shadow: Shadow,
|
|
||||||
/** The [SurfaceRole] the card renders, so it can overlay the matching material. */
|
|
||||||
val surfaceRole: SurfaceRole? = null,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ListRowTheme(
|
|
||||||
val background: AdvancedInteractionState<Brush>,
|
|
||||||
val shape: AdvancedInteractionState<Shape>,
|
|
||||||
val border: AdvancedInteractionState<Border>,
|
|
||||||
val shadow: AdvancedInteractionState<Shadow>,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
val textStyle: AdvancedInteractionState<TextStyle>,
|
|
||||||
val foreground: AdvancedInteractionState<Color>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class TextFieldTheme(
|
|
||||||
val background: InteractionState<Brush>,
|
|
||||||
val highlight: InteractionState<Color>,
|
|
||||||
val shape: InteractionState<Shape>,
|
|
||||||
val border: InteractionState<Border>,
|
|
||||||
val shadow: InteractionState<Shadow>,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
val textStyle: TextStyle,
|
|
||||||
val cursor: Brush,
|
|
||||||
val foreground: InteractionState<Color>,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class AutoCompleteMenuTheme(
|
|
||||||
val background: Brush,
|
|
||||||
val shape: Shape,
|
|
||||||
val border: Border,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
val shadowElevation: Dp,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DialogTheme(
|
|
||||||
val background: Brush,
|
|
||||||
val scrim: Color,
|
|
||||||
val shape: Shape,
|
|
||||||
val border: Border,
|
|
||||||
val shadow: Shadow,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class DropdownMenuTheme(
|
|
||||||
val background: Brush,
|
|
||||||
val shape: Shape,
|
|
||||||
val border: Border,
|
|
||||||
val shadow: Shadow,
|
|
||||||
val itemHoverBackground: Brush,
|
|
||||||
val itemForeground: Color,
|
|
||||||
val itemHighlight: Color,
|
|
||||||
val padding: PaddingValues,
|
|
||||||
val itemPadding: PaddingValues,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class SliderTheme(
|
|
||||||
val trackActiveColor: InteractionState<Color>,
|
|
||||||
val trackInactiveColor: InteractionState<Color>,
|
|
||||||
val thumbColor: InteractionState<Color>,
|
|
||||||
val thumbShadow: InteractionState<Shadow>,
|
|
||||||
val stepActiveColor: Color,
|
|
||||||
val stepInactiveColor: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class CheckBoxTheme(
|
|
||||||
val selected: ButtonStyle,
|
|
||||||
val unselected: ButtonStyle,
|
|
||||||
val checkmarkColor: Color,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ChipTabTheme(
|
|
||||||
val selected: ButtonStyle,
|
|
||||||
val unselected: ButtonStyle,
|
|
||||||
)
|
|
||||||
|
|
||||||
data class ToggleTheme(
|
|
||||||
val checked: ButtonStyle,
|
|
||||||
val unchecked: ButtonStyle,
|
|
||||||
val thumb: InteractionState<Color>,
|
|
||||||
val thumbShadow: InteractionState<Shadow>,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
val LocalBaseUITheme = staticCompositionLocalOf<BaseUITheme> {
|
|
||||||
throw IllegalStateException("No BaseUITheme provided")
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun rememberBaseUITheme(
|
|
||||||
surfaceTheme: SurfaceStyle = SurfaceStyle.Glass,
|
|
||||||
accentColor: Color? = null,
|
|
||||||
): BaseUITheme {
|
|
||||||
val colors = rememberBaseUIColorScheme(surfaceTheme, accentColor)
|
|
||||||
val isLight = colors.isLight
|
|
||||||
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Frutiger-Aero glass palette.
|
|
||||||
//
|
|
||||||
// Surfaces are glossy, top-lit gradients with a strong white specular
|
|
||||||
// highlight and translucent "glass" edges, lifted by soft, coloured
|
|
||||||
// shadows — the aqua/glass language of Frutiger-Aero.
|
|
||||||
// ------------------------------------------------------------------
|
|
||||||
// Primary surface (subtle) with the style's palette, and the secondary surface
|
|
||||||
// (slightly accented) used by cards and grouped content.
|
|
||||||
val panelTop = lerp(colors.backgroundPrimary, Color.White, if (isLight) 0.45f else 0.06f)
|
|
||||||
val panelBottom = colors.surfaceVariant
|
|
||||||
val secondaryTop = lerp(colors.backgroundSecondary, Color.White, if (isLight) 0.35f else 0.05f)
|
|
||||||
val secondaryBottom = colors.backgroundSecondary
|
|
||||||
val glassPressed = if (isLight) {
|
|
||||||
lerp(colors.surfaceContainerHighest, Color.White, 0.06f)
|
|
||||||
} else {
|
|
||||||
lerp(colors.surfaceContainerHighest, Color.Black, 0.08f)
|
|
||||||
}
|
|
||||||
|
|
||||||
val gloss = if (isLight) Color.White.copy(alpha = 0.75f) else Color.White.copy(alpha = 0.18f)
|
|
||||||
val glossSoft = if (isLight) Color.White.copy(alpha = 0.5f) else Color.White.copy(alpha = 0.12f)
|
|
||||||
val glassEdge = if (isLight) Color.White.copy(alpha = 0.95f) else Color.White.copy(alpha = 0.22f)
|
|
||||||
val glassBorder = if (isLight) {
|
|
||||||
lerp(colors.outlineVariant, Color.White, 0.55f)
|
|
||||||
} else {
|
|
||||||
colors.outlineVariant.copy(alpha = 0.5f)
|
|
||||||
}
|
|
||||||
val shadowColor = colors.onSurface
|
|
||||||
|
|
||||||
fun glassBrush(top: Color, bottom: Color): Brush =
|
|
||||||
Brush.verticalGradient(listOf(top, bottom))
|
|
||||||
|
|
||||||
fun aqua(accent: Color): Brush = Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
lerp(accent, Color.White, 0.45f),
|
|
||||||
accent,
|
|
||||||
lerp(accent, Color.Black, 0.14f),
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
val defaultShape = RoundedCornerShape(16.dp)
|
|
||||||
val defaultShapeState =
|
|
||||||
BaseUITheme.InteractionState<Shape>(defaultShape, defaultShape, defaultShape, defaultShape)
|
|
||||||
val noBorder = BaseUITheme.Border(Color.Transparent, 0.dp)
|
|
||||||
val noBorderState = BaseUITheme.InteractionState(noBorder, noBorder, noBorder, noBorder)
|
|
||||||
val noShadow = BaseUITheme.Shadow(0.dp, false, Color.Transparent, Color.Transparent)
|
|
||||||
val noShadowState = BaseUITheme.InteractionState(noShadow, noShadow, noShadow, noShadow)
|
|
||||||
val defaultButtonPadding = BaseUITheme.InteractionState(
|
|
||||||
PaddingValues(horizontal = 22.dp, vertical = 10.dp),
|
|
||||||
PaddingValues(horizontal = 22.dp, vertical = 10.dp),
|
|
||||||
PaddingValues(horizontal = 22.dp, vertical = 10.dp),
|
|
||||||
PaddingValues(horizontal = 22.dp, vertical = 10.dp),
|
|
||||||
)
|
|
||||||
val iconButtonPadding = BaseUITheme.InteractionState(
|
|
||||||
PaddingValues(9.dp),
|
|
||||||
PaddingValues(9.dp),
|
|
||||||
PaddingValues(9.dp),
|
|
||||||
PaddingValues(9.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Glass (outline) -------------------------------------------------
|
|
||||||
val outlineColors = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(panelTop, panelBottom),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = gloss,
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(panelTop, panelBottom),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = Color.White.copy(alpha = if (isLight) 0.9f else 0.26f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(glassPressed, glassPressed),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = glossSoft,
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(panelTop, panelBottom),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = gloss,
|
|
||||||
),
|
|
||||||
).withRole(SurfaceRole.PrimaryBackground)
|
|
||||||
val glassShadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
8.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.16f),
|
|
||||||
shadowColor.copy(alpha = 0.2f),
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
14.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.22f),
|
|
||||||
shadowColor.copy(alpha = 0.26f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
2.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.1f),
|
|
||||||
shadowColor.copy(alpha = 0.12f),
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
8.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.16f),
|
|
||||||
shadowColor.copy(alpha = 0.2f),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val glassBorderState = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
hovered = BaseUITheme.Border(glassEdge, 1.dp),
|
|
||||||
pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp),
|
|
||||||
focused = BaseUITheme.Border(glassEdge, 1.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Aqua accent (primary) -------------------------------------------
|
|
||||||
val primaryEdge = lerp(colors.primary, Color.White, 0.5f)
|
|
||||||
val primaryColors = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss),
|
|
||||||
hovered = BaseUITheme.ButtonColors(
|
|
||||||
aqua(colors.accentPrimary),
|
|
||||||
colors.onAccentPrimary,
|
|
||||||
Color.White.copy(alpha = if (isLight) 0.9f else 0.3f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.ButtonColors(
|
|
||||||
glassBrush(
|
|
||||||
lerp(colors.primary, Color.Black, 0.16f),
|
|
||||||
lerp(colors.primary, Color.Black, 0.24f),
|
|
||||||
),
|
|
||||||
colors.onAccentPrimary,
|
|
||||||
glossSoft,
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.ButtonColors(aqua(colors.accentPrimary), colors.onAccentPrimary, gloss),
|
|
||||||
).withRole(SurfaceRole.PrimaryAccent)
|
|
||||||
val primaryShadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
10.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.35f),
|
|
||||||
colors.primary.copy(alpha = 0.42f),
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
16.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.45f),
|
|
||||||
colors.primary.copy(alpha = 0.5f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
3.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.22f),
|
|
||||||
colors.primary.copy(alpha = 0.28f),
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
10.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.35f),
|
|
||||||
colors.primary.copy(alpha = 0.42f),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val primaryBorder = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Border(primaryEdge, 1.dp),
|
|
||||||
hovered = BaseUITheme.Border(primaryEdge, 1.dp),
|
|
||||||
pressed = BaseUITheme.Border(primaryEdge.copy(alpha = 0.8f), 1.dp),
|
|
||||||
focused = BaseUITheme.Border(primaryEdge, 1.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Secondary (soft aqua glass) -------------------------------------
|
|
||||||
val secondaryBase = colors.backgroundSecondary
|
|
||||||
val secondaryColors = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.ButtonColors(
|
|
||||||
glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase),
|
|
||||||
colors.onSecondaryContainer,
|
|
||||||
gloss,
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.ButtonColors(
|
|
||||||
glassBrush(lerp(secondaryBase, Color.White, 0.55f), secondaryBase),
|
|
||||||
colors.onSecondaryContainer,
|
|
||||||
Color.White.copy(alpha = if (isLight) 0.85f else 0.24f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.ButtonColors(
|
|
||||||
glassBrush(
|
|
||||||
lerp(secondaryBase, Color.Black, 0.08f),
|
|
||||||
lerp(secondaryBase, Color.Black, 0.14f),
|
|
||||||
),
|
|
||||||
colors.onSecondaryContainer,
|
|
||||||
glossSoft,
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.ButtonColors(
|
|
||||||
glassBrush(lerp(secondaryBase, Color.White, 0.5f), secondaryBase),
|
|
||||||
colors.onSecondaryContainer,
|
|
||||||
gloss,
|
|
||||||
),
|
|
||||||
).withRole(SurfaceRole.SecondaryBackground)
|
|
||||||
val secondaryShadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
8.dp,
|
|
||||||
true,
|
|
||||||
secondaryBase.copy(alpha = 0.4f),
|
|
||||||
secondaryBase.copy(alpha = 0.5f),
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
14.dp,
|
|
||||||
true,
|
|
||||||
secondaryBase.copy(alpha = 0.5f),
|
|
||||||
secondaryBase.copy(alpha = 0.6f),
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
2.dp,
|
|
||||||
true,
|
|
||||||
secondaryBase.copy(alpha = 0.3f),
|
|
||||||
secondaryBase.copy(alpha = 0.35f),
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
8.dp,
|
|
||||||
true,
|
|
||||||
secondaryBase.copy(alpha = 0.4f),
|
|
||||||
secondaryBase.copy(alpha = 0.5f),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
val secondaryBorder = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp),
|
|
||||||
hovered = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.6f), 1.dp),
|
|
||||||
pressed = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.4f), 1.dp),
|
|
||||||
focused = BaseUITheme.Border(lerp(secondaryBase, Color.White, 0.5f), 1.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
// --- Ghost -----------------------------------------------------------
|
|
||||||
val ghostColors = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(Color.Transparent, Color.Transparent),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = Color.Transparent,
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(
|
|
||||||
lerp(colors.surfaceContainerHigh, Color.White, 0.1f),
|
|
||||||
colors.surfaceContainer,
|
|
||||||
),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = glossSoft,
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(glassPressed, glassPressed),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = Color.Transparent,
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.ButtonColors(
|
|
||||||
background = glassBrush(Color.Transparent, Color.Transparent),
|
|
||||||
foreground = colors.onSurface,
|
|
||||||
highlight = Color.Transparent,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val outlineStyle = BaseUITheme.ButtonStyle(
|
|
||||||
outlineColors,
|
|
||||||
defaultShapeState,
|
|
||||||
glassShadow,
|
|
||||||
glassBorderState,
|
|
||||||
defaultButtonPadding
|
|
||||||
)
|
|
||||||
val primaryStyle = BaseUITheme.ButtonStyle(
|
|
||||||
primaryColors,
|
|
||||||
defaultShapeState,
|
|
||||||
primaryShadow,
|
|
||||||
primaryBorder,
|
|
||||||
defaultButtonPadding
|
|
||||||
)
|
|
||||||
val secondaryStyle = BaseUITheme.ButtonStyle(
|
|
||||||
secondaryColors,
|
|
||||||
defaultShapeState,
|
|
||||||
secondaryShadow,
|
|
||||||
secondaryBorder,
|
|
||||||
defaultButtonPadding
|
|
||||||
)
|
|
||||||
val ghostStyle = BaseUITheme.ButtonStyle(
|
|
||||||
ghostColors,
|
|
||||||
defaultShapeState,
|
|
||||||
noShadowState,
|
|
||||||
noBorderState,
|
|
||||||
defaultButtonPadding
|
|
||||||
)
|
|
||||||
|
|
||||||
val outlineIconStyle = BaseUITheme.ButtonStyle(
|
|
||||||
outlineColors,
|
|
||||||
defaultShapeState,
|
|
||||||
glassShadow,
|
|
||||||
glassBorderState,
|
|
||||||
iconButtonPadding
|
|
||||||
)
|
|
||||||
val primaryIconStyle = BaseUITheme.ButtonStyle(
|
|
||||||
primaryColors,
|
|
||||||
defaultShapeState,
|
|
||||||
primaryShadow,
|
|
||||||
primaryBorder,
|
|
||||||
iconButtonPadding
|
|
||||||
)
|
|
||||||
val secondaryIconStyle = BaseUITheme.ButtonStyle(
|
|
||||||
secondaryColors,
|
|
||||||
defaultShapeState,
|
|
||||||
secondaryShadow,
|
|
||||||
secondaryBorder,
|
|
||||||
iconButtonPadding
|
|
||||||
)
|
|
||||||
val ghostIconStyle = BaseUITheme.ButtonStyle(
|
|
||||||
ghostColors,
|
|
||||||
defaultShapeState,
|
|
||||||
noShadowState,
|
|
||||||
noBorderState,
|
|
||||||
iconButtonPadding
|
|
||||||
)
|
|
||||||
|
|
||||||
val textFieldForeground = BaseUITheme.InteractionState(
|
|
||||||
normal = colors.onSurface,
|
|
||||||
hovered = colors.onSurface,
|
|
||||||
pressed = colors.onSurface,
|
|
||||||
focused = colors.onSurface,
|
|
||||||
)
|
|
||||||
|
|
||||||
val checkBoxShape = RoundedCornerShape(7.dp)
|
|
||||||
val checkBoxShapeState =
|
|
||||||
BaseUITheme.InteractionState<Shape>(checkBoxShape, checkBoxShape, checkBoxShape, checkBoxShape)
|
|
||||||
val noPadding =
|
|
||||||
BaseUITheme.InteractionState(PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp), PaddingValues(0.dp))
|
|
||||||
|
|
||||||
val chipTabShape = RoundedCornerShape(999.dp)
|
|
||||||
val chipTabShapeState =
|
|
||||||
BaseUITheme.InteractionState<Shape>(chipTabShape, chipTabShape, chipTabShape, chipTabShape)
|
|
||||||
val chipTabPadding = BaseUITheme.InteractionState(
|
|
||||||
PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
PaddingValues(horizontal = 16.dp, vertical = 6.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
val toggleShape = RoundedCornerShape(50)
|
|
||||||
val toggleShapeState =
|
|
||||||
BaseUITheme.InteractionState<Shape>(toggleShape, toggleShape, toggleShape, toggleShape)
|
|
||||||
val togglePadding = BaseUITheme.InteractionState(
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
PaddingValues(2.dp),
|
|
||||||
)
|
|
||||||
|
|
||||||
return BaseUITheme(
|
|
||||||
colors = colors,
|
|
||||||
surfaceTheme = surfaceTheme,
|
|
||||||
buttons = BaseUITheme.ButtonVariants(
|
|
||||||
primary = primaryStyle,
|
|
||||||
secondary = secondaryStyle,
|
|
||||||
outline = outlineStyle,
|
|
||||||
ghost = ghostStyle,
|
|
||||||
),
|
|
||||||
iconButtons = BaseUITheme.ButtonVariants(
|
|
||||||
primary = primaryIconStyle,
|
|
||||||
secondary = secondaryIconStyle,
|
|
||||||
outline = outlineIconStyle,
|
|
||||||
ghost = ghostIconStyle,
|
|
||||||
),
|
|
||||||
textField = TextFieldTheme(
|
|
||||||
background = BaseUITheme.InteractionState(
|
|
||||||
normal = glassBrush(panelTop, panelBottom),
|
|
||||||
hovered = glassBrush(panelTop, panelBottom),
|
|
||||||
pressed = glassBrush(glassPressed, glassPressed),
|
|
||||||
focused = glassBrush(panelTop, panelBottom),
|
|
||||||
),
|
|
||||||
highlight = BaseUITheme.InteractionState(
|
|
||||||
normal = gloss,
|
|
||||||
hovered = gloss,
|
|
||||||
pressed = glossSoft,
|
|
||||||
focused = Color.White.copy(alpha = if (isLight) 0.85f else 0.22f),
|
|
||||||
),
|
|
||||||
shape = BaseUITheme.InteractionState(
|
|
||||||
normal = defaultShape,
|
|
||||||
hovered = defaultShape,
|
|
||||||
pressed = defaultShape,
|
|
||||||
focused = defaultShape,
|
|
||||||
),
|
|
||||||
border = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
hovered = BaseUITheme.Border(glassEdge, 1.dp),
|
|
||||||
pressed = BaseUITheme.Border(glassBorder.copy(alpha = 0.7f), 1.dp),
|
|
||||||
focused = BaseUITheme.Border(primaryEdge, 1.5.dp),
|
|
||||||
),
|
|
||||||
shadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
8.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.14f),
|
|
||||||
shadowColor.copy(alpha = 0.18f)
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
10.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.18f),
|
|
||||||
shadowColor.copy(alpha = 0.22f)
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.1f),
|
|
||||||
shadowColor.copy(alpha = 0.12f)
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
10.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.22f),
|
|
||||||
colors.primary.copy(alpha = 0.28f)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
padding = PaddingValues(horizontal = 16.dp, vertical = 12.dp),
|
|
||||||
textStyle = TextStyle.Default,
|
|
||||||
cursor = SolidColor(colors.primary),
|
|
||||||
foreground = textFieldForeground,
|
|
||||||
),
|
|
||||||
autoCompleteMenuTheme = AutoCompleteMenuTheme(
|
|
||||||
background = glassBrush(panelTop, panelBottom),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
border = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
padding = PaddingValues(0.dp),
|
|
||||||
shadowElevation = 16.dp,
|
|
||||||
),
|
|
||||||
slider = SliderTheme(
|
|
||||||
trackActiveColor = BaseUITheme.InteractionState(
|
|
||||||
normal = colors.primary,
|
|
||||||
hovered = colors.primary,
|
|
||||||
pressed = colors.primary,
|
|
||||||
focused = colors.primary,
|
|
||||||
),
|
|
||||||
trackInactiveColor = BaseUITheme.InteractionState(
|
|
||||||
normal = glassBorder,
|
|
||||||
hovered = glassBorder,
|
|
||||||
pressed = glassBorder,
|
|
||||||
focused = glassBorder,
|
|
||||||
),
|
|
||||||
thumbColor = BaseUITheme.InteractionState(
|
|
||||||
normal = colors.primary,
|
|
||||||
hovered = colors.primary,
|
|
||||||
pressed = colors.primary,
|
|
||||||
focused = colors.primary,
|
|
||||||
),
|
|
||||||
thumbShadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
5.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.4f),
|
|
||||||
colors.primary.copy(alpha = 0.45f)
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
8.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.45f),
|
|
||||||
colors.primary.copy(alpha = 0.5f)
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
2.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.25f),
|
|
||||||
colors.primary.copy(alpha = 0.3f)
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
5.dp,
|
|
||||||
true,
|
|
||||||
colors.primary.copy(alpha = 0.4f),
|
|
||||||
colors.primary.copy(alpha = 0.45f)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
stepActiveColor = Color.White.copy(alpha = 0.7f),
|
|
||||||
stepInactiveColor = colors.onSurface.copy(alpha = 0.25f),
|
|
||||||
),
|
|
||||||
checkBox = CheckBoxTheme(
|
|
||||||
selected = BaseUITheme.ButtonStyle(
|
|
||||||
colors = primaryColors,
|
|
||||||
shape = checkBoxShapeState,
|
|
||||||
shadow = primaryShadow,
|
|
||||||
border = primaryBorder,
|
|
||||||
padding = noPadding,
|
|
||||||
),
|
|
||||||
unselected = BaseUITheme.ButtonStyle(
|
|
||||||
colors = outlineColors,
|
|
||||||
shape = checkBoxShapeState,
|
|
||||||
shadow = glassShadow,
|
|
||||||
border = glassBorderState,
|
|
||||||
padding = noPadding,
|
|
||||||
),
|
|
||||||
checkmarkColor = colors.onAccentPrimary,
|
|
||||||
),
|
|
||||||
chipTab = ChipTabTheme(
|
|
||||||
selected = BaseUITheme.ButtonStyle(
|
|
||||||
colors = primaryColors,
|
|
||||||
shape = chipTabShapeState,
|
|
||||||
shadow = primaryShadow,
|
|
||||||
border = primaryBorder,
|
|
||||||
padding = chipTabPadding,
|
|
||||||
),
|
|
||||||
unselected = BaseUITheme.ButtonStyle(
|
|
||||||
colors = outlineColors,
|
|
||||||
shape = chipTabShapeState,
|
|
||||||
shadow = glassShadow,
|
|
||||||
border = glassBorderState,
|
|
||||||
padding = chipTabPadding,
|
|
||||||
),
|
|
||||||
),
|
|
||||||
toggle = BaseUITheme.ToggleTheme(
|
|
||||||
checked = BaseUITheme.ButtonStyle(
|
|
||||||
colors = primaryColors,
|
|
||||||
shape = toggleShapeState,
|
|
||||||
shadow = primaryShadow,
|
|
||||||
border = primaryBorder,
|
|
||||||
padding = togglePadding,
|
|
||||||
),
|
|
||||||
unchecked = BaseUITheme.ButtonStyle(
|
|
||||||
colors = outlineColors,
|
|
||||||
shape = toggleShapeState,
|
|
||||||
shadow = glassShadow,
|
|
||||||
border = glassBorderState,
|
|
||||||
padding = togglePadding,
|
|
||||||
),
|
|
||||||
thumb = BaseUITheme.InteractionState(
|
|
||||||
normal = Color.White,
|
|
||||||
hovered = Color.White,
|
|
||||||
pressed = Color.White,
|
|
||||||
focused = Color.White,
|
|
||||||
),
|
|
||||||
thumbShadow = BaseUITheme.InteractionState(
|
|
||||||
normal = BaseUITheme.Shadow(
|
|
||||||
3.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.2f),
|
|
||||||
shadowColor.copy(alpha = 0.25f)
|
|
||||||
),
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
4.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.28f),
|
|
||||||
shadowColor.copy(alpha = 0.32f)
|
|
||||||
),
|
|
||||||
pressed = BaseUITheme.Shadow(
|
|
||||||
1.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.12f),
|
|
||||||
shadowColor.copy(alpha = 0.15f)
|
|
||||||
),
|
|
||||||
focused = BaseUITheme.Shadow(
|
|
||||||
3.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.2f),
|
|
||||||
shadowColor.copy(alpha = 0.25f)
|
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
card = BaseUITheme.CardTheme(
|
|
||||||
background = glassBrush(secondaryTop, secondaryBottom),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
border = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
padding = PaddingValues(0.dp),
|
|
||||||
shadow = BaseUITheme.Shadow(
|
|
||||||
12.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.16f),
|
|
||||||
shadowColor.copy(alpha = 0.2f)
|
|
||||||
),
|
|
||||||
surfaceRole = SurfaceRole.SecondaryBackground,
|
|
||||||
),
|
|
||||||
listRowTile = BaseUITheme.ListRowTheme(
|
|
||||||
background = BaseUITheme.AdvancedInteractionState(
|
|
||||||
normal = glassBrush(Color.Transparent, Color.Transparent),
|
|
||||||
hovered = glassBrush(panelTop, panelBottom),
|
|
||||||
pressed = glassBrush(glassPressed, glassPressed),
|
|
||||||
focused = glassBrush(panelTop, panelBottom),
|
|
||||||
selected = glassBrush(secondaryTop, secondaryBottom),
|
|
||||||
disabled = glassBrush(
|
|
||||||
panelTop.copy(alpha = 0.5f),
|
|
||||||
panelBottom.copy(alpha = 0.5f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shape = BaseUITheme.AdvancedInteractionState(
|
|
||||||
normal = RoundedCornerShape(10.dp),
|
|
||||||
hovered = RoundedCornerShape(10.dp),
|
|
||||||
pressed = RoundedCornerShape(10.dp),
|
|
||||||
focused = RoundedCornerShape(10.dp),
|
|
||||||
selected = RoundedCornerShape(10.dp),
|
|
||||||
disabled = RoundedCornerShape(10.dp),
|
|
||||||
),
|
|
||||||
border = BaseUITheme.AdvancedInteractionState(
|
|
||||||
normal = BaseUITheme.Border(Color.Transparent, 0.dp),
|
|
||||||
hovered = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
pressed = BaseUITheme.Border(Color.Transparent, 0.dp),
|
|
||||||
focused = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
selected = BaseUITheme.Border(Color.Transparent, 0.dp),
|
|
||||||
disabled = BaseUITheme.Border(Color.Transparent, 0.dp),
|
|
||||||
),
|
|
||||||
shadow = BaseUITheme.AdvancedInteractionState(
|
|
||||||
normal = noShadow,
|
|
||||||
hovered = BaseUITheme.Shadow(
|
|
||||||
6.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.12f),
|
|
||||||
shadowColor.copy(alpha = 0.16f)
|
|
||||||
),
|
|
||||||
pressed = noShadow,
|
|
||||||
focused = noShadow,
|
|
||||||
selected = noShadow,
|
|
||||||
disabled = noShadow,
|
|
||||||
),
|
|
||||||
padding = PaddingValues(horizontal = 10.dp, vertical = 8.dp),
|
|
||||||
textStyle = BaseUITheme.AdvancedInteractionState(
|
|
||||||
normal = TextStyle.Default,
|
|
||||||
hovered = TextStyle.Default,
|
|
||||||
pressed = TextStyle.Default,
|
|
||||||
focused = TextStyle.Default,
|
|
||||||
selected = TextStyle.Default,
|
|
||||||
disabled = TextStyle.Default,
|
|
||||||
),
|
|
||||||
foreground = BaseUITheme.AdvancedInteractionState(
|
|
||||||
normal = colors.onSurface,
|
|
||||||
hovered = colors.onSurface,
|
|
||||||
pressed = colors.onSurface,
|
|
||||||
focused = colors.onSurface,
|
|
||||||
selected = colors.onSurface,
|
|
||||||
disabled = colors.onSurface.copy(alpha = 0.38f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
dialog = BaseUITheme.DialogTheme(
|
|
||||||
background = glassBrush(panelTop, panelBottom),
|
|
||||||
scrim = Color.Black.copy(alpha = 0.42f),
|
|
||||||
shape = RoundedCornerShape(24.dp),
|
|
||||||
border = BaseUITheme.Border(glassEdge, 1.dp),
|
|
||||||
shadow = BaseUITheme.Shadow(
|
|
||||||
28.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.3f),
|
|
||||||
shadowColor.copy(alpha = 0.4f)
|
|
||||||
),
|
|
||||||
padding = PaddingValues(24.dp),
|
|
||||||
),
|
|
||||||
dropdownMenu = BaseUITheme.DropdownMenuTheme(
|
|
||||||
background = glassBrush(panelTop, panelBottom),
|
|
||||||
shape = RoundedCornerShape(16.dp),
|
|
||||||
border = BaseUITheme.Border(glassBorder, 1.dp),
|
|
||||||
shadow = BaseUITheme.Shadow(
|
|
||||||
16.dp,
|
|
||||||
true,
|
|
||||||
shadowColor.copy(alpha = 0.22f),
|
|
||||||
shadowColor.copy(alpha = 0.28f)
|
|
||||||
),
|
|
||||||
itemHoverBackground = glassBrush(panelTop, panelBottom),
|
|
||||||
itemForeground = colors.onSurface,
|
|
||||||
itemHighlight = gloss,
|
|
||||||
padding = PaddingValues(6.dp),
|
|
||||||
itemPadding = PaddingValues(horizontal = 14.dp, vertical = 11.dp),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Helper functions for easier theme overrides
|
|
||||||
|
|
||||||
fun BaseUITheme.ButtonStyle.copyShape(shape: Shape): BaseUITheme.ButtonStyle =
|
|
||||||
copy(shape = BaseUITheme.InteractionState.fromSingleValue(shape))
|
|
||||||
|
|
||||||
fun BaseUITheme.ButtonStyle.copyShadow(shadow: BaseUITheme.Shadow): BaseUITheme.ButtonStyle =
|
|
||||||
copy(shadow = BaseUITheme.InteractionState.fromSingleValue(shadow))
|
|
||||||
|
|
||||||
fun BaseUITheme.ButtonStyle.copyBorder(border: BaseUITheme.Border): BaseUITheme.ButtonStyle =
|
|
||||||
copy(border = BaseUITheme.InteractionState.fromSingleValue(border))
|
|
||||||
|
|
||||||
fun BaseUITheme.ButtonStyle.copyPadding(padding: PaddingValues): BaseUITheme.ButtonStyle =
|
|
||||||
copy(padding = BaseUITheme.InteractionState.fromSingleValue(padding))
|
|
||||||
|
|
||||||
fun BaseUITheme.ButtonStyle.copyColors(colors: BaseUITheme.ButtonColors): BaseUITheme.ButtonStyle =
|
|
||||||
copy(colors = BaseUITheme.InteractionState.fromSingleValue(colors))
|
|
||||||
|
|
||||||
/** Tags every interaction state of a button colour set with the surface role it renders. */
|
|
||||||
private fun BaseUITheme.InteractionState<BaseUITheme.ButtonColors>.withRole(
|
|
||||||
role: SurfaceRole,
|
|
||||||
): BaseUITheme.InteractionState<BaseUITheme.ButtonColors> = BaseUITheme.InteractionState(
|
|
||||||
normal = normal.copy(surfaceRole = role),
|
|
||||||
hovered = hovered.copy(surfaceRole = role),
|
|
||||||
pressed = pressed.copy(surfaceRole = role),
|
|
||||||
focused = focused.copy(surfaceRole = role),
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun invertedButtonStyle(): BaseUITheme.ButtonStyle {
|
|
||||||
val scheme = MaterialTheme.colorScheme
|
|
||||||
val isLight = scheme.surface.luminance() > 0.5f
|
|
||||||
val shadowColor = scheme.onSurface
|
|
||||||
|
|
||||||
val invertedColors = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(listOf(scheme.onSurface, scheme.onSurface)),
|
|
||||||
foreground = scheme.surface,
|
|
||||||
highlight = if (isLight) Color.White.copy(alpha = 0.15f) else Color.White.copy(alpha = 0.08f),
|
|
||||||
)
|
|
||||||
val pressedColors = BaseUITheme.ButtonColors(
|
|
||||||
background = Brush.verticalGradient(
|
|
||||||
listOf(
|
|
||||||
scheme.onSurfaceVariant,
|
|
||||||
scheme.onSurfaceVariant
|
|
||||||
)
|
|
||||||
),
|
|
||||||
foreground = scheme.surface,
|
|
||||||
highlight = if (isLight) Color.White.copy(alpha = 0.1f) else Color.White.copy(alpha = 0.05f),
|
|
||||||
)
|
|
||||||
val border = BaseUITheme.Border(scheme.onSurfaceVariant, 0.5.dp)
|
|
||||||
val shadow = BaseUITheme.Shadow(
|
|
||||||
elevation = 6.dp,
|
|
||||||
clip = true,
|
|
||||||
ambientColor = shadowColor.copy(alpha = 0.2f),
|
|
||||||
spotColor = shadowColor.copy(alpha = 0.25f),
|
|
||||||
)
|
|
||||||
|
|
||||||
return BaseUITheme.ButtonStyle(
|
|
||||||
colors = BaseUITheme.InteractionState(
|
|
||||||
normal = invertedColors,
|
|
||||||
hovered = invertedColors,
|
|
||||||
pressed = pressedColors,
|
|
||||||
focused = invertedColors,
|
|
||||||
),
|
|
||||||
shape = BaseUITheme.InteractionState.fromSingleValue(RoundedCornerShape(14.dp)),
|
|
||||||
shadow = BaseUITheme.InteractionState.fromSingleValue(shadow),
|
|
||||||
border = BaseUITheme.InteractionState.fromSingleValue(border),
|
|
||||||
padding = BaseUITheme.InteractionState.fromSingleValue(PaddingValues(8.dp)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@ -1,298 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.height
|
|
||||||
import androidx.compose.foundation.layout.offset
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.layout.width
|
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.shadow
|
|
||||||
import androidx.compose.ui.geometry.Offset
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.graphicsLayer
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
|
||||||
import androidx.compose.ui.unit.IntOffset
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.max
|
|
||||||
import kotlin.math.roundToInt
|
|
||||||
|
|
||||||
private val ToggleWidth = 44.dp
|
|
||||||
private val ToggleHeight = 24.dp
|
|
||||||
private val ThumbSize = 20.dp
|
|
||||||
|
|
||||||
private data class ResolvedToggleState(
|
|
||||||
val colors: BaseUITheme.ButtonColors,
|
|
||||||
val shape: Shape,
|
|
||||||
val shadow: BaseUITheme.Shadow,
|
|
||||||
val border: BaseUITheme.Border,
|
|
||||||
)
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun resolveToggleState(
|
|
||||||
style: BaseUITheme.ButtonStyle,
|
|
||||||
isPressed: Boolean,
|
|
||||||
isHovered: Boolean,
|
|
||||||
): ResolvedToggleState {
|
|
||||||
return when {
|
|
||||||
isPressed -> ResolvedToggleState(
|
|
||||||
style.colors.pressed,
|
|
||||||
style.shape.pressed,
|
|
||||||
style.shadow.pressed,
|
|
||||||
style.border.pressed,
|
|
||||||
)
|
|
||||||
|
|
||||||
isHovered -> ResolvedToggleState(
|
|
||||||
style.colors.hovered,
|
|
||||||
style.shape.hovered,
|
|
||||||
style.shadow.hovered,
|
|
||||||
style.border.hovered,
|
|
||||||
)
|
|
||||||
|
|
||||||
else -> ResolvedToggleState(
|
|
||||||
style.colors.normal,
|
|
||||||
style.shape.normal,
|
|
||||||
style.shadow.normal,
|
|
||||||
style.border.normal,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
fun Toggle(
|
|
||||||
checked: Boolean,
|
|
||||||
onCheckedChange: ((Boolean) -> Unit)?,
|
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
interactionSource: MutableInteractionSource? = null,
|
|
||||||
theme: BaseUITheme.ToggleTheme? = null,
|
|
||||||
) {
|
|
||||||
val baseTheme = LocalBaseUITheme.current.toggle
|
|
||||||
val toggleTheme = theme ?: baseTheme
|
|
||||||
val source = interactionSource ?: remember { MutableInteractionSource() }
|
|
||||||
val isPressed by source.collectIsPressedAsState()
|
|
||||||
val isHovered by source.collectIsHoveredAsState()
|
|
||||||
|
|
||||||
val style = if (checked) toggleTheme.checked else toggleTheme.unchecked
|
|
||||||
val resolved = resolveToggleState(style, isPressed, isHovered)
|
|
||||||
|
|
||||||
val thumbColor = when {
|
|
||||||
isPressed -> toggleTheme.thumb.pressed
|
|
||||||
isHovered -> toggleTheme.thumb.hovered
|
|
||||||
else -> toggleTheme.thumb.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
val thumbShadowState = when {
|
|
||||||
isPressed -> toggleTheme.thumbShadow.pressed
|
|
||||||
isHovered -> toggleTheme.thumbShadow.hovered
|
|
||||||
else -> toggleTheme.thumbShadow.normal
|
|
||||||
}
|
|
||||||
|
|
||||||
val borderColor = when {
|
|
||||||
!enabled -> resolved.colors.foreground.copy(alpha = 0.38f)
|
|
||||||
else -> resolved.border.color
|
|
||||||
}
|
|
||||||
|
|
||||||
val thumbProgress by animateFloatAsState(
|
|
||||||
targetValue = if (checked) 1f else 0f,
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
|
||||||
label = "thumbProgress",
|
|
||||||
)
|
|
||||||
|
|
||||||
val thumbScale by animateFloatAsState(
|
|
||||||
targetValue = when {
|
|
||||||
isPressed -> 0.92f
|
|
||||||
isHovered -> 1.05f
|
|
||||||
else -> 1f
|
|
||||||
},
|
|
||||||
animationSpec = tween(durationMillis = 150),
|
|
||||||
label = "thumbScale",
|
|
||||||
)
|
|
||||||
|
|
||||||
val lift = if (isHovered && !isPressed && onCheckedChange != null) (-1).dp else 0.dp
|
|
||||||
val density = LocalDensity.current
|
|
||||||
val thumbOffsetXPx by animateDpAsState(
|
|
||||||
targetValue = with(density) {
|
|
||||||
val trackWidth = ToggleWidth - ThumbSize
|
|
||||||
max((thumbProgress * trackWidth.toPx()).toDp() - 4.dp, 2.dp)
|
|
||||||
},
|
|
||||||
animationSpec = tween(durationMillis = 200),
|
|
||||||
label = "thumbOffsetX",
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = modifier
|
|
||||||
.width(ToggleWidth)
|
|
||||||
.height(ToggleHeight)
|
|
||||||
.graphicsLayer {
|
|
||||||
translationY = lift.toPx()
|
|
||||||
}
|
|
||||||
.then(
|
|
||||||
if (resolved.shadow.elevation > 0.dp) {
|
|
||||||
Modifier.shadow(
|
|
||||||
elevation = resolved.shadow.elevation,
|
|
||||||
shape = resolved.shape,
|
|
||||||
ambientColor = resolved.shadow.ambientColor,
|
|
||||||
spotColor = resolved.shadow.spotColor,
|
|
||||||
)
|
|
||||||
} else Modifier
|
|
||||||
)
|
|
||||||
.clip(resolved.shape)
|
|
||||||
.background(resolved.colors.background, resolved.shape)
|
|
||||||
.applySurfaceMaterial(resolved.colors)
|
|
||||||
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
|
|
||||||
.applyHighlight(resolved.colors)
|
|
||||||
.then(
|
|
||||||
if (onCheckedChange != null) {
|
|
||||||
Modifier
|
|
||||||
.hoverable(interactionSource = source, enabled = enabled)
|
|
||||||
.clickable(
|
|
||||||
enabled = enabled,
|
|
||||||
interactionSource = source,
|
|
||||||
indication = null,
|
|
||||||
onClick = { onCheckedChange(!checked) },
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
),
|
|
||||||
contentAlignment = Alignment.CenterStart,
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.offset(x = thumbOffsetXPx)
|
|
||||||
.size(ThumbSize)
|
|
||||||
.graphicsLayer {
|
|
||||||
scaleX = thumbScale
|
|
||||||
scaleY = thumbScale
|
|
||||||
}
|
|
||||||
.shadow(
|
|
||||||
elevation = thumbShadowState.elevation,
|
|
||||||
shape = CircleShape,
|
|
||||||
ambientColor = thumbShadowState.ambientColor,
|
|
||||||
spotColor = thumbShadowState.spotColor,
|
|
||||||
)
|
|
||||||
.background(
|
|
||||||
brush = Brush.radialGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = 0.6f),
|
|
||||||
thumbColor.copy(alpha = 0.95f),
|
|
||||||
thumbColor,
|
|
||||||
),
|
|
||||||
center = Offset(0f, -0.55f),
|
|
||||||
radius = 1.1f,
|
|
||||||
),
|
|
||||||
shape = CircleShape,
|
|
||||||
)
|
|
||||||
.border(
|
|
||||||
width = 0.8.dp,
|
|
||||||
brush = Brush.verticalGradient(
|
|
||||||
colors = listOf(
|
|
||||||
Color.White.copy(alpha = 0.55f),
|
|
||||||
Color.White.copy(alpha = 0.08f),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
shape = CircleShape,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun TogglePreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var scrobbling by remember { mutableStateOf(true) }
|
|
||||||
var offlineMode by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
Column(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
|
||||||
) {
|
|
||||||
TogglePreviewRow(
|
|
||||||
label = "Scrobble listened tracks",
|
|
||||||
checked = scrobbling,
|
|
||||||
onCheckedChange = { scrobbling = it },
|
|
||||||
)
|
|
||||||
TogglePreviewRow(
|
|
||||||
label = "Offline mode",
|
|
||||||
checked = offlineMode,
|
|
||||||
onCheckedChange = { offlineMode = it },
|
|
||||||
)
|
|
||||||
TogglePreviewRow(
|
|
||||||
label = "Discord rich presence",
|
|
||||||
checked = true,
|
|
||||||
enabled = false,
|
|
||||||
onCheckedChange = {},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
private fun TogglePreviewRow(
|
|
||||||
label: String,
|
|
||||||
checked: Boolean,
|
|
||||||
enabled: Boolean = true,
|
|
||||||
onCheckedChange: (Boolean) -> Unit,
|
|
||||||
) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
) {
|
|
||||||
Text(label, style = MaterialTheme.typography.bodyMedium)
|
|
||||||
Toggle(
|
|
||||||
checked = checked,
|
|
||||||
onCheckedChange = onCheckedChange,
|
|
||||||
enabled = enabled,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,88 +0,0 @@
|
|||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
|
||||||
import androidx.compose.foundation.layout.padding
|
|
||||||
import androidx.compose.foundation.layout.size
|
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.getValue
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
import androidx.compose.ui.Modifier
|
|
||||||
import androidx.compose.ui.draw.clip
|
|
||||||
import androidx.compose.ui.draw.drawWithCache
|
|
||||||
import androidx.compose.ui.graphics.Brush
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
|
|
||||||
fun Modifier.highlight(color: Color): Modifier =
|
|
||||||
drawWithCache {
|
|
||||||
// Frutiger-Aero glass: a bright specular sheen across the top half plus a
|
|
||||||
// faint reflection rising from the bottom edge.
|
|
||||||
val topGloss = Brush.verticalGradient(
|
|
||||||
colors = listOf(color, color.copy(alpha = color.alpha * 0.25f), Color.Transparent),
|
|
||||||
startY = 0f,
|
|
||||||
endY = size.height * 0.55f,
|
|
||||||
)
|
|
||||||
val bottomReflection = Brush.verticalGradient(
|
|
||||||
colors = listOf(Color.Transparent, color.copy(alpha = color.alpha * 0.3f)),
|
|
||||||
startY = size.height * 0.72f,
|
|
||||||
endY = size.height,
|
|
||||||
)
|
|
||||||
onDrawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawRect(
|
|
||||||
brush = topGloss,
|
|
||||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
|
||||||
size = size,
|
|
||||||
)
|
|
||||||
drawRect(
|
|
||||||
brush = bottomReflection,
|
|
||||||
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
|
||||||
size = size,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@BaseUIPhonePreview
|
|
||||||
@Composable
|
|
||||||
private fun HighlightModifierPreview() {
|
|
||||||
BaseUIPreview {
|
|
||||||
var highlighted by remember { mutableStateOf(true) }
|
|
||||||
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.padding(24.dp),
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
|
||||||
) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(96.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer)
|
|
||||||
.then(
|
|
||||||
if (highlighted) {
|
|
||||||
Modifier.highlight(Color.White.copy(alpha = 0.45f))
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
},
|
|
||||||
)
|
|
||||||
.clickable { highlighted = !highlighted },
|
|
||||||
)
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(96.dp)
|
|
||||||
.clip(RoundedCornerShape(16.dp))
|
|
||||||
.background(MaterialTheme.colorScheme.primaryContainer),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumDialog
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -26,22 +33,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
|||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Icon
|
import com.composeunstyled.SheetDetent
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
|
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun AdaptiveDialogBottomSheet(
|
fun AdaptiveDialogBottomSheet(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
@ -49,51 +53,37 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
breakpointDp: Float = 600f,
|
breakpointDp: Float = 600f,
|
||||||
actions: @Composable (() -> Unit)? = null,
|
actions: @Composable (() -> Unit)? = null,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit) {
|
||||||
) {
|
|
||||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||||
|
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
ThemedDialog(
|
MillenniumDialog(
|
||||||
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = if (title != null) {
|
title = "",
|
||||||
{
|
modifier = modifier) {
|
||||||
Row(
|
title?.let { it() }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(Modifier.weight(1f)) { title() }
|
|
||||||
IconButton(onClick = onDismiss) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.FluentDismiss,
|
|
||||||
contentDescription = "Close dialog",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
actions = actions,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
|
actions?.let {
|
||||||
|
MillenniumSeparator()
|
||||||
|
it()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ModalBottomSheet(
|
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||||
onDismissRequest = onDismiss,
|
LaunchedEffect(sheetState.currentDetent) {
|
||||||
dragHandle = null,
|
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||||
modifier = modifier,
|
onDismiss()
|
||||||
) {
|
}
|
||||||
|
}
|
||||||
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 16.dp),
|
.padding(top = 16.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start) {
|
||||||
) {
|
|
||||||
title?.let {
|
title?.let {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@ -101,11 +91,12 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(Modifier.weight(1f)) { it() }
|
Box(Modifier.weight(1f)) { it() }
|
||||||
IconButton(onClick = onDismiss) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onDismiss,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
Iconsax.FluentDismiss,
|
Iconsax.FluentDismiss,
|
||||||
contentDescription = "Close dialog",
|
contentDescription = "Close dialog")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -113,7 +104,7 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
content()
|
content()
|
||||||
actions?.let {
|
actions?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
MillenniumSeparator()
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
it()
|
it()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
|
import org.spotube.millennium.composables.MillenniumTextField
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.border
|
||||||
@ -24,6 +31,7 @@ import androidx.compose.foundation.clickable
|
|||||||
import androidx.compose.foundation.hoverable
|
import androidx.compose.foundation.hoverable
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
import androidx.compose.foundation.interaction.MutableInteractionSource
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
||||||
|
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -38,16 +46,22 @@ import androidx.compose.foundation.layout.widthIn
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Icon
|
import com.composeunstyled.DropdownMenuPanel
|
||||||
import androidx.compose.material3.IconButton
|
import com.composeunstyled.SheetDetent
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
import com.composeunstyled.UnstyledDropdownMenu
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
import com.composeunstyled.UnstyledDropdownMenuItem
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo
|
||||||
|
import org.spotube.millennium.theme.MillenniumTheme
|
||||||
|
import org.spotube.millennium.theme.gelBrush
|
||||||
|
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||||
|
import org.spotube.millennium.theme.millenniumSurface
|
||||||
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -64,10 +78,6 @@ import androidx.compose.ui.unit.DpOffset
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import compose.icons.FeatherIcons
|
import compose.icons.FeatherIcons
|
||||||
import compose.icons.feathericons.Check
|
import compose.icons.feathericons.Check
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenu
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||||
@ -83,8 +93,7 @@ data class AdaptiveMenuItem(
|
|||||||
val selected: Boolean = false,
|
val selected: Boolean = false,
|
||||||
val dividerBefore: Boolean = false,
|
val dividerBefore: Boolean = false,
|
||||||
val subtitle: String? = null,
|
val subtitle: String? = null,
|
||||||
val leadingContent: (@Composable () -> Unit)? = null,
|
val leadingContent: (@Composable () -> Unit)? = null)
|
||||||
)
|
|
||||||
|
|
||||||
enum class HeaderDisplayMode {
|
enum class HeaderDisplayMode {
|
||||||
Always,
|
Always,
|
||||||
@ -101,69 +110,127 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
menuMinWidth: Dp = 200.dp,
|
menuMinWidth: Dp = 200.dp,
|
||||||
header: @Composable (() -> Unit)? = null,
|
header: @Composable (() -> Unit)? = null,
|
||||||
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
|
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
|
||||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||||
) {
|
|
||||||
var expanded by remember { mutableStateOf(false) }
|
var expanded by remember { mutableStateOf(false) }
|
||||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
trigger { expanded = true }
|
|
||||||
|
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
DropdownMenu(
|
UnstyledDropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = { expanded = false },
|
onExpandedChange = { expanded = it },
|
||||||
) {
|
panel = {
|
||||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
DropdownMenuPanel(
|
||||||
header()
|
Modifier
|
||||||
}
|
.width(menuMinWidth)
|
||||||
|
.millenniumSurface(
|
||||||
var query by remember { mutableStateOf("") }
|
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||||
if (filter != null) {
|
shape = AppTheme.shapes.medium,
|
||||||
TextField(
|
borderColor = AppTheme.colors.outlineVariant,
|
||||||
value = query,
|
borderWidth = 1.dp)
|
||||||
onValueChange = {
|
.padding(4.dp)) {
|
||||||
query = it
|
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||||
},
|
header()
|
||||||
placeholder = { Text("Search...") },
|
|
||||||
singleLine = true,
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Search",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach { item ->
|
|
||||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
|
||||||
return@forEach
|
|
||||||
}
|
}
|
||||||
if (item.dividerBefore) {
|
|
||||||
DropdownMenuDivider()
|
val dropdownQueryState = rememberTextFieldState()
|
||||||
|
val query = dropdownQueryState.text.toString()
|
||||||
|
if (filter != null) {
|
||||||
|
MillenniumTextField(
|
||||||
|
state = dropdownQueryState,
|
||||||
|
label = "",
|
||||||
|
placeholder = "Search...",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach { item ->
|
||||||
|
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (item.dividerBefore) {
|
||||||
|
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
}
|
||||||
|
val itemInteraction = remember { MutableInteractionSource() }
|
||||||
|
val itemHovered by itemInteraction.collectIsHoveredAsState()
|
||||||
|
val itemPressed by itemInteraction.collectIsPressedAsState()
|
||||||
|
val itemEnabled = item.enabled
|
||||||
|
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
|
||||||
|
val itemForeground = when {
|
||||||
|
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
|
itemHighlighted -> MillenniumTheme.colors.onAccent
|
||||||
|
else -> AppTheme.colors.onSurface
|
||||||
|
}
|
||||||
|
UnstyledDropdownMenuItem(
|
||||||
|
onClick = { item.onClick() },
|
||||||
|
enabled = itemEnabled,
|
||||||
|
indication = null,
|
||||||
|
interactionSource = itemInteraction) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (itemHighlighted) {
|
||||||
|
Modifier.millenniumSurface(
|
||||||
|
background = gelBrush(
|
||||||
|
MillenniumTheme.colors.accentTop,
|
||||||
|
MillenniumTheme.colors.accentBottom),
|
||||||
|
shape = RoundedCornerShape(8.dp))
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
})
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
val itemIcon = item.icon
|
||||||
|
when {
|
||||||
|
item.leadingContent != null -> item.leadingContent.invoke()
|
||||||
|
itemIcon != null -> MillenniumIcon(
|
||||||
|
imageVector = itemIcon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = itemForeground,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
|
||||||
|
item.selected -> MillenniumIcon(
|
||||||
|
imageVector = FeatherIcons.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = itemForeground,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.label,
|
||||||
|
style = AppTheme.typography.bodyMedium,
|
||||||
|
color = itemForeground)
|
||||||
|
item.subtitle?.let { subtitle ->
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = if (itemHighlighted) {
|
||||||
|
itemForeground.copy(alpha = 0.85f)
|
||||||
|
} else {
|
||||||
|
AppTheme.colors.onSurfaceVariant
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = FeatherIcons.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = itemForeground,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
|
||||||
text = item.label,
|
|
||||||
onClick = {
|
|
||||||
item.onClick()
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
enabled = item.enabled,
|
|
||||||
selected = item.selected,
|
|
||||||
leadingIcon = item.icon,
|
|
||||||
leadingContent = item.leadingContent,
|
|
||||||
subtitle = item.subtitle,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
anchor = { trigger { expanded = true } },
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
|
trigger { expanded = true }
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
AdaptiveBottomSheetContent(
|
AdaptiveBottomSheetContent(
|
||||||
onDismiss = { expanded = false },
|
onDismiss = { expanded = false },
|
||||||
@ -173,119 +240,97 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
filter = filter,
|
filter = filter)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AdaptiveBottomSheetContent(
|
private fun AdaptiveBottomSheetContent(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
items: List<AdaptiveMenuItem>,
|
items: List<AdaptiveMenuItem>,
|
||||||
header: @Composable (() -> Unit)?,
|
header: @Composable (() -> Unit)?,
|
||||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||||
) {
|
|
||||||
val hasSelection = items.any { it.selected }
|
val hasSelection = items.any { it.selected }
|
||||||
var query by remember { mutableStateOf("") }
|
val queryState = rememberTextFieldState()
|
||||||
|
val query = queryState.text.toString()
|
||||||
val filteredItems = if (filter != null && query.isNotBlank()) {
|
val filteredItems = if (filter != null && query.isNotBlank()) {
|
||||||
items.filter { filter(it, query) }
|
items.filter { filter(it, query) }
|
||||||
} else {
|
} else {
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
ModalBottomSheet(
|
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||||
onDismissRequest = onDismiss,
|
LaunchedEffect(sheetState.currentDetent) {
|
||||||
dragHandle = null,
|
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||||
) {
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
) {
|
|
||||||
header?.invoke()
|
header?.invoke()
|
||||||
|
|
||||||
if (filter != null) {
|
if (filter != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TextField(
|
MillenniumTextField(
|
||||||
value = query,
|
state = queryState,
|
||||||
onValueChange = {
|
label = "",
|
||||||
query = it
|
placeholder = "Search...",
|
||||||
},
|
|
||||||
placeholder = { Text("Search...") },
|
|
||||||
singleLine = true,
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Search",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
items(
|
items(
|
||||||
count = filteredItems.size,
|
count = filteredItems.size) { index ->
|
||||||
) { index ->
|
|
||||||
val item = filteredItems[index]
|
val item = filteredItems[index]
|
||||||
|
|
||||||
if (item.dividerBefore) {
|
if (item.dividerBefore) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(AppTheme.shapes.medium)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = item.enabled,
|
enabled = item.enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
item.onClick()
|
item.onClick()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.background(
|
.background(
|
||||||
color = if (item.selected) {
|
color = if (item.selected) {
|
||||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
|
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
},
|
},
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = AppTheme.shapes.medium)
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
when {
|
when {
|
||||||
item.leadingContent != null -> item.leadingContent.invoke()
|
item.leadingContent != null -> item.leadingContent.invoke()
|
||||||
item.icon != null -> Icon(
|
item.icon != null -> MillenniumIcon(
|
||||||
imageVector = item.icon,
|
imageVector = item.icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = if (item.enabled) {
|
tint = if (item.enabled) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
AppTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
|
|
||||||
item.selected -> Icon(
|
item.selected -> MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Check,
|
imageVector = FeatherIcons.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = "Selected",
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
|
|
||||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||||
}
|
}
|
||||||
@ -293,29 +338,26 @@ private fun AdaptiveBottomSheetContent(
|
|||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge,
|
||||||
color = if (item.enabled) {
|
color = if (item.enabled) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
AppTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
item.subtitle?.let { subtitle ->
|
item.subtitle?.let { subtitle ->
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Check,
|
imageVector = FeatherIcons.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = "Selected",
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -327,44 +369,37 @@ private fun AdaptiveBottomSheetContent(
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||||
BaseScaffold {
|
Box(modifier = Modifier.background(AppTheme.colors.background)) {
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = listOf(
|
items = listOf(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxHeart,
|
icon = Iconsax.IconsaxHeart,
|
||||||
label = "Favorite",
|
label = "Favorite",
|
||||||
onClick = {},
|
onClick = {}),
|
||||||
),
|
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxShare,
|
icon = Iconsax.IconsaxShare,
|
||||||
label = "Share",
|
label = "Share",
|
||||||
onClick = {},
|
onClick = {}),
|
||||||
),
|
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxTrash,
|
icon = Iconsax.IconsaxTrash,
|
||||||
label = "Delete",
|
label = "Delete",
|
||||||
onClick = {},
|
onClick = {},
|
||||||
enabled = false,
|
enabled = false)),
|
||||||
),
|
|
||||||
),
|
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.background(
|
.background(
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
color = AppTheme.colors.primary.copy(alpha = 0.1f),
|
||||||
shape = CircleShape,
|
shape = CircleShape)
|
||||||
)
|
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = AppTheme.colors.primary)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,8 +40,7 @@ fun AlbumCard(
|
|||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject(),
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||||
) {
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
@ -57,17 +56,14 @@ fun AlbumCard(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.Album,
|
RemoteCollectionType.Album,
|
||||||
album.id,
|
album.id,
|
||||||
album.title,
|
album.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.Album,
|
RemoteCollectionType.Album,
|
||||||
album.id,
|
album.id,
|
||||||
album.title,
|
album.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = modifier.width(160.dp),
|
modifier = modifier.width(160.dp),
|
||||||
sharedElementKey = "album_art_${album.id}",
|
sharedElementKey = "album_art_${album.id}")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,9 +17,12 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||||
@ -30,12 +33,12 @@ import org.koin.compose.koinInject
|
|||||||
fun ApplicationBackButton() {
|
fun ApplicationBackButton() {
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
|
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigationCommands.pop()
|
navigationCommands.pop()
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.ArrowLeft3,
|
imageVector = Iconsax.ArrowLeft3,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
@ -48,5 +51,4 @@ expect fun ApplicationMainBar(
|
|||||||
subtitle: @Composable () -> Unit = {},
|
subtitle: @Composable () -> Unit = {},
|
||||||
actions: @Composable (RowScope.() -> Unit) = {},
|
actions: @Composable (RowScope.() -> Unit) = {},
|
||||||
backButton: Boolean = true,
|
backButton: Boolean = true,
|
||||||
transparent: Boolean = false,
|
transparent: Boolean = false)
|
||||||
)
|
|
||||||
@ -29,9 +29,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -52,8 +52,7 @@ import org.koin.compose.koinInject
|
|||||||
fun ArtistCard(
|
fun ArtistCard(
|
||||||
artist: MetadataArtist.Basic,
|
artist: MetadataArtist.Basic,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isBlacklisted: Boolean = false,
|
isBlacklisted: Boolean = false) {
|
||||||
) {
|
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
|
|
||||||
AvatarCard(
|
AvatarCard(
|
||||||
@ -64,7 +63,6 @@ fun ArtistCard(
|
|||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
isBlacklisted = isBlacklisted,
|
isBlacklisted = isBlacklisted)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,13 +17,23 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
@ -31,9 +41,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -42,12 +52,6 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
@ -62,7 +66,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
|
|||||||
import org.jetbrains.compose.resources.DrawableResource
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionDetails(
|
fun CollectionDetails(
|
||||||
@ -85,8 +88,7 @@ fun CollectionDetails(
|
|||||||
onFollowClick: () -> Unit = { },
|
onFollowClick: () -> Unit = { },
|
||||||
showFollowButton: Boolean = true,
|
showFollowButton: Boolean = true,
|
||||||
onEdit: (() -> Unit)? = null,
|
onEdit: (() -> Unit)? = null,
|
||||||
sharedElementKey: String? = null,
|
sharedElementKey: String? = null) {
|
||||||
) {
|
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||||
val isCompact = maxWidth < 600.dp
|
val isCompact = maxWidth < 600.dp
|
||||||
|
|
||||||
@ -98,95 +100,86 @@ fun CollectionDetails(
|
|||||||
if (onAddToJam != null) {
|
if (onAddToJam != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
) {
|
MillenniumButton(
|
||||||
PrimaryButton(
|
|
||||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||||
onClick = onAddToJam!!,
|
onClick = onAddToJam!!) {
|
||||||
) {
|
MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to Jam",
|
contentDescription = "Add to Jam")
|
||||||
)
|
TextWithShimmer(text = "Add to Jam")
|
||||||
TextWithShimmer(
|
|
||||||
text = "Add to Jam",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
) {
|
|
||||||
val modifier = if (isCompact) {
|
val modifier = if (isCompact) {
|
||||||
Modifier.weight(1f)
|
Modifier.weight(1f)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
||||||
PrimaryButton(
|
MillenniumButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onPlay,
|
onClick = onPlay) {
|
||||||
) {
|
MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||||
)
|
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
|
||||||
TextWithShimmer(
|
|
||||||
text = if (isPlaying) "Pause" else "Play",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
OutlineButton(
|
MillenniumButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onShufflePlay
|
onClick = onShufflePlay,
|
||||||
|
style = MillenniumButtonStyle.Secondary
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||||
TextWithShimmer(
|
TextWithShimmer(text = "Shuffle")
|
||||||
text = "Shuffle",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val actions = @Composable {
|
val actions = @Composable {
|
||||||
ButtonGroup {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
GroupIconButton(onClick = onAddToQueue) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onAddToQueue,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue"
|
contentDescription = "Add to queue"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ButtonGroupDivider()
|
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||||
if (showFollowButton) {
|
if (showFollowButton) {
|
||||||
if (isFollowing) {
|
if (isFollowing) {
|
||||||
GroupIconButton(onClick = onFollowClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onFollowClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxHeart2,
|
imageVector = Iconsax.IconsaxHeart2,
|
||||||
contentDescription = "Unfollow",
|
contentDescription = "Unfollow")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
GroupIconButton(onClick = onFollowClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onFollowClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxHeart,
|
imageVector = Iconsax.IconsaxHeart,
|
||||||
contentDescription = "Follow",
|
contentDescription = "Follow",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (onEdit != null) {
|
if (onEdit != null) {
|
||||||
ButtonGroupDivider()
|
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||||
GroupIconButton(onClick = onEdit) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onEdit,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxEdit,
|
imageVector = Iconsax.IconsaxEdit,
|
||||||
contentDescription = "Edit playlist",
|
contentDescription = "Edit playlist")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,8 +188,7 @@ fun CollectionDetails(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.clickable(onClick = onOwnerClick),
|
modifier = Modifier.clickable(onClick = onOwnerClick),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
) {
|
|
||||||
if (ownerImageURL != null) {
|
if (ownerImageURL != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ownerImageURL,
|
model = ownerImageURL,
|
||||||
@ -205,27 +197,24 @@ fun CollectionDetails(
|
|||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.User,
|
imageVector = Iconsax.User,
|
||||||
contentDescription = ownerName,
|
contentDescription = ownerName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.primaryContainer,
|
AppTheme.colors.primaryContainer,
|
||||||
CircleShape
|
CircleShape
|
||||||
)
|
)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = AppTheme.colors.onPrimaryContainer)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "By $ownerName",
|
text = "By $ownerName",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = AppTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,79 +226,68 @@ fun CollectionDetails(
|
|||||||
.sharedElementOrNone(
|
.sharedElementOrNone(
|
||||||
key = sharedElementKey,
|
key = sharedElementKey,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope)
|
||||||
)
|
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
if (imageURL.isNotBlank()) {
|
if (imageURL.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else if (imageResource != null) {
|
} else if (imageResource != null) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
painter = painterResource(imageResource),
|
painter = painterResource(imageResource),
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = AppTheme.typography.displaySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val metadata = @Composable {
|
val metadata = @Composable {
|
||||||
Column {
|
Column {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge,
|
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
|
|
||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = description,
|
text = description,
|
||||||
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
|
style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(14.dp),
|
.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
artwork()
|
artwork()
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
) {
|
|
||||||
metadata()
|
metadata()
|
||||||
ownerInfo()
|
ownerInfo()
|
||||||
actions()
|
actions()
|
||||||
@ -323,14 +301,12 @@ fun CollectionDetails(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top) {
|
||||||
) {
|
|
||||||
artwork()
|
artwork()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
) {
|
|
||||||
metadata()
|
metadata()
|
||||||
ownerInfo()
|
ownerInfo()
|
||||||
actions()
|
actions()
|
||||||
@ -354,6 +330,5 @@ fun CollectionDetailsPreview() {
|
|||||||
onOwnerClick = {},
|
onOwnerClick = {},
|
||||||
onPlay = {},
|
onPlay = {},
|
||||||
onShufflePlay = {},
|
onShufflePlay = {},
|
||||||
onAddToQueue = {},
|
onAddToQueue = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,18 +17,22 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -93,8 +97,7 @@ fun CollectionView(
|
|||||||
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
footerContent: (@Composable () -> Unit)? = null,
|
footerContent: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: @Composable () -> Unit = {},
|
trailingContent: @Composable () -> Unit = {}) {
|
||||||
) {
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isCollapsed by remember {
|
val isCollapsed by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@ -103,45 +106,42 @@ fun CollectionView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
BaseScaffold(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
topBar = {
|
ApplicationMainBar(
|
||||||
ApplicationMainBar(
|
backButton = true,
|
||||||
backButton = true,
|
title = if (isCollapsed) {
|
||||||
title = if (isCollapsed) {
|
{
|
||||||
{
|
CollapsedCollectionTitle(
|
||||||
CollapsedCollectionTitle(
|
title = title,
|
||||||
title = title,
|
imageURL = imageURL,
|
||||||
imageURL = imageURL,
|
imageResource = imageResource)
|
||||||
imageResource = imageResource,
|
}
|
||||||
)
|
} else {
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
actions = if (isCollapsed && !isJamGuest) {
|
||||||
|
{
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onPlay,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
{}
|
} else {
|
||||||
},
|
{}
|
||||||
actions = if (isCollapsed && !isJamGuest) {
|
})
|
||||||
{
|
|
||||||
IconButton(onClick = onPlay) {
|
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||||
Icon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
{}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
if (error != null && tracks.isEmpty() && !isLoading) {
|
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||||
ErrorDisplay(
|
ErrorDisplay(
|
||||||
errorMessage = error,
|
errorMessage = error,
|
||||||
onRetry = onRetry ?: {},
|
onRetry = onRetry ?: {},
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.fillMaxSize())
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
TrackList(
|
TrackList(
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.fillMaxSize(),
|
||||||
state = listState,
|
state = listState,
|
||||||
headerContent = {
|
headerContent = {
|
||||||
if (isLoading && tracks.isEmpty()) {
|
if (isLoading && tracks.isEmpty()) {
|
||||||
@ -164,8 +164,7 @@ fun CollectionView(
|
|||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
onAddToJam = { onAddToJam(tracks) },
|
onAddToJam = { onAddToJam(tracks) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CollectionDetails(
|
CollectionDetails(
|
||||||
@ -186,8 +185,7 @@ fun CollectionView(
|
|||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
onAddToJam = { onAddToJam(tracks) },
|
onAddToJam = { onAddToJam(tracks) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
footerContent = footerContent,
|
footerContent = footerContent,
|
||||||
@ -211,11 +209,11 @@ fun CollectionView(
|
|||||||
onBulkAddToJam = onBulkAddToJam,
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
trackOptionsState = trackOptionsState,
|
trackOptionsState = trackOptionsState)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trailingContent()
|
trailingContent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,46 +221,39 @@ fun CollectionView(
|
|||||||
private fun CollapsedCollectionTitle(
|
private fun CollapsedCollectionTitle(
|
||||||
title: String,
|
title: String,
|
||||||
imageURL: String,
|
imageURL: String,
|
||||||
imageResource: DrawableResource? = null,
|
imageResource: DrawableResource? = null) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
if (imageURL.isNotBlank()) {
|
if (imageURL.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else if (imageResource != null) {
|
} else if (imageResource != null) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
painter = painterResource(imageResource),
|
painter = painterResource(imageResource),
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = AppTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(start = 10.dp),
|
modifier = Modifier.padding(start = 10.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,30 +17,33 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
|
import org.spotube.millennium.composables.MillenniumDialog
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TextButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw
|
|||||||
fun ErrorDisplay(
|
fun ErrorDisplay(
|
||||||
errorMessage: String,
|
errorMessage: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
var showFullLog by remember { mutableStateOf(false) }
|
var showFullLog by remember { mutableStateOf(false) }
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
@ -77,117 +79,99 @@ fun ErrorDisplay(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
) {
|
MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = FeatherIcons.AlertCircle,
|
imageVector = FeatherIcons.AlertCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(64.dp),
|
modifier = Modifier.size(64.dp),
|
||||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
tint = AppTheme.colors.error.copy(alpha = 0.7f))
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "We're Truly Sorry",
|
text = "We're Truly Sorry",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = AppTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = AppTheme.colors.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center)
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
|
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
|
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
|
||||||
)
|
|
||||||
|
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Error Details",
|
text = "Error Details",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = AppTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
) {
|
MillenniumIconButton(
|
||||||
IconButton(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Copy,
|
imageVector = FeatherIcons.Copy,
|
||||||
contentDescription = "Copy to clipboard",
|
contentDescription = "Copy to clipboard",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = { showFullLog = true },
|
onClick = { showFullLog = true },
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Maximize2,
|
imageVector = FeatherIcons.Maximize2,
|
||||||
contentDescription = "View full log",
|
contentDescription = "View full log",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
MillenniumCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
) {
|
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = errorMessage,
|
text = errorMessage,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = AppTheme.typography.bodySmall.copy(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace),
|
||||||
),
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 5,
|
maxLines = 5,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
MillenniumButton(
|
||||||
Button(
|
|
||||||
onClick = onRetry,
|
onClick = onRetry,
|
||||||
modifier = Modifier.height(40.dp),
|
modifier = Modifier.height(40.dp)) {
|
||||||
) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.RefreshCw,
|
imageVector = FeatherIcons.RefreshCw,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
modifier = Modifier.padding(end = 8.dp).size(18.dp))
|
||||||
)
|
Text("Retry")
|
||||||
Text("Retry")
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,93 +179,85 @@ fun ErrorDisplay(
|
|||||||
if (showFullLog) {
|
if (showFullLog) {
|
||||||
ErrorLogDialog(
|
ErrorLogDialog(
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
onDismiss = { showFullLog = false },
|
onDismiss = { showFullLog = false })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorLogDialog(
|
private fun ErrorLogDialog(
|
||||||
errorMessage: String,
|
errorMessage: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit) {
|
||||||
) {
|
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
AlertDialog(
|
MillenniumDialog(
|
||||||
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = "") {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
Text(
|
||||||
Text(
|
text = "Full Error Log",
|
||||||
text = "Full Error Log",
|
style = AppTheme.typography.titleLarge,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
fontWeight = FontWeight.Bold)
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Copy,
|
imageVector = FeatherIcons.Copy,
|
||||||
contentDescription = "Copy to clipboard",
|
contentDescription = "Copy to clipboard")
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
text = {
|
Box(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.height(400.dp)
|
||||||
.height(400.dp)
|
.clip(AppTheme.shapes.small)
|
||||||
.clip(MaterialTheme.shapes.small)
|
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
|
SelectionContainer {
|
||||||
) {
|
Text(
|
||||||
SelectionContainer {
|
text = errorMessage,
|
||||||
Text(
|
style = AppTheme.typography.bodySmall.copy(
|
||||||
text = errorMessage,
|
fontFamily = FontFamily.Monospace),
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
fontFamily = FontFamily.Monospace,
|
modifier = Modifier
|
||||||
),
|
.fillMaxSize()
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.verticalScroll(rememberScrollState())
|
||||||
modifier = Modifier
|
.padding(12.dp))
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
confirmButton = {
|
Row(
|
||||||
TextButton(onClick = onDismiss) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End) {
|
||||||
|
MillenniumButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorDisplayPreview() {
|
private fun ErrorDisplayPreview() {
|
||||||
Surface {
|
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||||
ErrorDisplay(
|
ErrorDisplay(
|
||||||
errorMessage = "This is an error message",
|
errorMessage = "This is an error message",
|
||||||
onRetry = {},
|
onRetry = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorLogDialogPreview() {
|
private fun ErrorLogDialogPreview() {
|
||||||
BaseScaffold {
|
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||||
ErrorLogDialog(
|
ErrorLogDialog(
|
||||||
errorMessage = "This is an error message",
|
errorMessage = "This is an error message",
|
||||||
onDismiss = {},
|
onDismiss = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@ -34,11 +38,10 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Text
|
import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@ -83,8 +86,7 @@ import org.koin.compose.koinInject
|
|||||||
@Composable
|
@Composable
|
||||||
fun FeaturedCarousel(
|
fun FeaturedCarousel(
|
||||||
items: List<MetadataBrowseItem>,
|
items: List<MetadataBrowseItem>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val filteredItems = remember(items) {
|
val filteredItems = remember(items) {
|
||||||
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
||||||
}
|
}
|
||||||
@ -92,15 +94,13 @@ fun FeaturedCarousel(
|
|||||||
if (filteredItems.isEmpty()) return
|
if (filteredItems.isEmpty()) return
|
||||||
|
|
||||||
FeaturedCarouselContent(
|
FeaturedCarouselContent(
|
||||||
items = filteredItems,
|
items = filteredItems)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun FeaturedCarouselContent(
|
private fun FeaturedCarouselContent(
|
||||||
items: List<MetadataBrowseItem>,
|
items: List<MetadataBrowseItem>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val centerIndex = items.size / 2
|
val centerIndex = items.size / 2
|
||||||
var selectedIndex by remember { mutableIntStateOf(centerIndex) }
|
var selectedIndex by remember { mutableIntStateOf(centerIndex) }
|
||||||
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
|
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
|
||||||
@ -113,8 +113,7 @@ private fun FeaturedCarouselContent(
|
|||||||
.height(
|
.height(
|
||||||
if (isSmallScreen) 200.dp else 300.dp
|
if (isSmallScreen) 200.dp else 300.dp
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
Coverflow(
|
Coverflow(
|
||||||
state = state,
|
state = state,
|
||||||
params = CoverflowParams(
|
params = CoverflowParams(
|
||||||
@ -123,21 +122,17 @@ private fun FeaturedCarouselContent(
|
|||||||
angle = 12f,
|
angle = 12f,
|
||||||
shift = 0.255f,
|
shift = 0.255f,
|
||||||
zoom = 0.7864f,
|
zoom = 0.7864f,
|
||||||
mirror = false,
|
mirror = false),
|
||||||
),
|
modifier = modifier) {
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
items(
|
items(
|
||||||
items = items,
|
items = items,
|
||||||
key = { index: Int -> items[index].hashCode() },
|
key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem ->
|
||||||
) { item: MetadataBrowseItem ->
|
|
||||||
FeaturedCarouselCard(
|
FeaturedCarouselCard(
|
||||||
item = item,
|
item = item,
|
||||||
isCentered = selectedIndex == items.indexOf(item),
|
isCentered = selectedIndex == items.indexOf(item),
|
||||||
onScrollToCenter = {
|
onScrollToCenter = {
|
||||||
state.scrollToItem(items.indexOf(item))
|
state.scrollToItem(items.indexOf(item))
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,8 +143,7 @@ private fun FeaturedCarouselCard(
|
|||||||
item: MetadataBrowseItem,
|
item: MetadataBrowseItem,
|
||||||
isCentered: Boolean,
|
isCentered: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onScrollToCenter: () -> Unit = { },
|
onScrollToCenter: () -> Unit = { }) {
|
||||||
) {
|
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
||||||
|
|
||||||
@ -204,25 +198,21 @@ private fun FeaturedCarouselCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.hazeSource(hazeState),
|
.hazeSource(hazeState)) {
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = thumbnailUrl,
|
model = thumbnailUrl,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = dimAlpha)),
|
.background(Color.Black.copy(alpha = dimAlpha)))
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -231,43 +221,35 @@ private fun FeaturedCarouselCard(
|
|||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Black.copy(alpha = 0.9f),
|
Color.Black.copy(alpha = 0.9f)))))
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp)) {
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = AppTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = Color.White.copy(alpha = 0.85f),
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val animatedAlpha by animateFloatAsState(
|
val animatedAlpha by animateFloatAsState(
|
||||||
@ -306,8 +288,7 @@ private fun FeaturedPlayButton(
|
|||||||
navigationCommands: NavigationCommands,
|
navigationCommands: NavigationCommands,
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -320,7 +301,8 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
||||||
@ -330,7 +312,7 @@ private fun FeaturedPlayButton(
|
|||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
@ -347,7 +329,8 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
||||||
@ -357,7 +340,7 @@ private fun FeaturedPlayButton(
|
|||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
@ -377,20 +360,20 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = listOf(queueEntry),
|
entries = listOf(queueEntry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@ -35,11 +39,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Icon
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -76,8 +78,7 @@ fun LikedTracksCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject(),
|
navigationCommands: NavigationCommands = koinInject()) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
@ -92,8 +93,7 @@ fun LikedTracksCard(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
||||||
.width(160.dp)
|
.width(160.dp)
|
||||||
.hoverable(interactionSource = interactionSource),
|
.hoverable(interactionSource = interactionSource)) {
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
@ -106,10 +106,8 @@ fun LikedTracksCard(
|
|||||||
.sharedElementOrNone(
|
.sharedElementOrNone(
|
||||||
key = "saved_tracks_art",
|
key = "saved_tracks_art",
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope),
|
||||||
),
|
contentScale = ContentScale.Crop)
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||||
@ -120,22 +118,24 @@ fun LikedTracksCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp)
|
||||||
) {
|
) {
|
||||||
FilledTonalIconButton(
|
MillenniumIconButton(
|
||||||
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||||
modifier = Modifier.size(30.dp)
|
modifier = Modifier.size(30.dp),
|
||||||
|
style = MillenniumIconButtonStyle.Secondary
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue",
|
contentDescription = "Add to queue",
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
FilledIconButton(
|
MillenniumIconButton(
|
||||||
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||||
modifier = Modifier.size(30.dp)
|
modifier = Modifier.size(30.dp),
|
||||||
|
style = MillenniumIconButtonStyle.Primary
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) {
|
imageVector = if (isPlaying) {
|
||||||
Iconsax.IconsaxPauseCircle
|
Iconsax.IconsaxPauseCircle
|
||||||
} else Iconsax.Play,
|
} else Iconsax.Play,
|
||||||
@ -152,18 +152,16 @@ fun LikedTracksCard(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Liked Tracks",
|
text = "Liked Tracks",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Your saved songs",
|
text = "Your saved songs",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,8 +40,7 @@ fun PlaylistCard(
|
|||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject(),
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -58,17 +57,14 @@ fun PlaylistCard(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.Playlist,
|
RemoteCollectionType.Playlist,
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title,
|
playlist.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.Playlist,
|
RemoteCollectionType.Playlist,
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title,
|
playlist.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
sharedElementKey = "playlist_art_${playlist.id}",
|
sharedElementKey = "playlist_art_${playlist.id}")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
|
|||||||
fun Modifier.sharedElementOrNone(
|
fun Modifier.sharedElementOrNone(
|
||||||
key: String?,
|
key: String?,
|
||||||
sharedTransitionScope: SharedTransitionScope?,
|
sharedTransitionScope: SharedTransitionScope?,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||||
): Modifier {
|
|
||||||
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
||||||
with(sharedTransitionScope) {
|
with(sharedTransitionScope) {
|
||||||
return this@sharedElementOrNone.sharedElement(
|
return this@sharedElementOrNone.sharedElement(
|
||||||
sharedContentState = rememberSharedContentState(key = key),
|
sharedContentState = rememberSharedContentState(key = key),
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,33 +17,33 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxScope
|
import androidx.compose.foundation.layout.BoxScope
|
||||||
import androidx.compose.foundation.layout.PaddingValues
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.foundation.layout.Spacer
|
import androidx.compose.foundation.layout.Spacer
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import com.composeunstyled.LocalTextStyle
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyPadding
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.copyShape
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The shared navigation item used by every sidebar in the app (the main app sidebar
|
* The shared navigation item used by every sidebar in the app (the main app sidebar
|
||||||
@ -60,64 +60,57 @@ fun SidebarItem(
|
|||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
expanded: Boolean = true,
|
expanded: Boolean = true,
|
||||||
badge: (@Composable BoxScope.() -> Unit)? = null,
|
badge: (@Composable BoxScope.() -> Unit)? = null) {
|
||||||
) {
|
val itemContent: @Composable () -> Unit = {
|
||||||
val itemContent: @Composable RowScope.() -> Unit = {
|
Row(
|
||||||
Box(modifier = Modifier.size(16.dp)) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Icon(
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
imageVector = icon,
|
Box(modifier = Modifier.size(16.dp)) {
|
||||||
contentDescription = label,
|
MillenniumIcon(
|
||||||
tint = if (selected) {
|
imageVector = icon,
|
||||||
MaterialTheme.colorScheme.primary
|
contentDescription = label,
|
||||||
} else {
|
tint = if (selected) {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
AppTheme.colors.primary
|
||||||
},
|
|
||||||
)
|
|
||||||
badge?.invoke(this)
|
|
||||||
}
|
|
||||||
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
|
||||||
Text(
|
|
||||||
text = label,
|
|
||||||
maxLines = 1,
|
|
||||||
softWrap = false,
|
|
||||||
style = LocalTextStyle.current.copy(
|
|
||||||
fontSize = MaterialTheme.typography.labelMedium.fontSize,
|
|
||||||
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
|
||||||
color = if (selected) {
|
|
||||||
MaterialTheme.colorScheme.onSecondaryContainer
|
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurfaceVariant
|
AppTheme.colors.onSurfaceVariant
|
||||||
}
|
})
|
||||||
|
badge?.invoke(this)
|
||||||
|
}
|
||||||
|
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
|
||||||
|
Text(
|
||||||
|
text = label,
|
||||||
|
singleLine = true,
|
||||||
|
style = LocalTextStyle.current.copy(
|
||||||
|
fontSize = AppTheme.typography.labelMedium.fontSize,
|
||||||
|
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
|
||||||
|
color = if (selected) {
|
||||||
|
AppTheme.colors.onSecondaryContainer
|
||||||
|
} else {
|
||||||
|
AppTheme.colors.onSurfaceVariant
|
||||||
|
}
|
||||||
|
)
|
||||||
)
|
)
|
||||||
)
|
}
|
||||||
|
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
||||||
}
|
}
|
||||||
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val buttonModifier = modifier
|
val buttonModifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 7.dp, vertical = 2.dp)
|
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||||
val contentPadding = PaddingValues(horizontal = 5.dp)
|
|
||||||
|
|
||||||
if (selected) {
|
if (selected) {
|
||||||
SecondaryButton(
|
MillenniumButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = buttonModifier,
|
modifier = buttonModifier,
|
||||||
theme = LocalBaseUITheme.current.buttons.secondary
|
style = MillenniumButtonStyle.Secondary,
|
||||||
.copyPadding(contentPadding)
|
content = itemContent)
|
||||||
.copyShape(RoundedCornerShape(5.dp)),
|
|
||||||
content = itemContent,
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
OutlineButton(
|
MillenniumButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = buttonModifier,
|
modifier = buttonModifier,
|
||||||
theme = LocalBaseUITheme.current.buttons.outline
|
style = MillenniumButtonStyle.Quiet,
|
||||||
.copyPadding(contentPadding)
|
content = itemContent)
|
||||||
.copyShape(RoundedCornerShape(5.dp)),
|
|
||||||
hoverOnly = true,
|
|
||||||
content = itemContent,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -127,16 +120,14 @@ fun SidebarItem(
|
|||||||
@Composable
|
@Composable
|
||||||
fun SidebarSectionLabel(
|
fun SidebarSectionLabel(
|
||||||
text: String,
|
text: String,
|
||||||
expanded: Boolean = true,
|
expanded: Boolean = true) {
|
||||||
) {
|
|
||||||
AnimatedVisibility(visible = expanded) {
|
AnimatedVisibility(visible = expanded) {
|
||||||
Text(
|
Text(
|
||||||
text = text.uppercase(),
|
text = text.uppercase(),
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = AppTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 24.dp, vertical = 8.dp),
|
.padding(horizontal = 24.dp, vertical = 8.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -54,8 +51,7 @@ import org.koin.compose.koinInject
|
|||||||
fun TrackCard(
|
fun TrackCard(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject()) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
|
|
||||||
@ -69,8 +65,7 @@ fun TrackCard(
|
|||||||
entries = listOf(queueEntry),
|
entries = listOf(queueEntry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
|
|||||||
@ -17,6 +17,7 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@ -36,6 +37,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -44,12 +46,20 @@ import androidx.compose.foundation.layout.widthIn
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.ui.state.ToggleableState
|
||||||
import androidx.compose.material3.Icon
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
import androidx.compose.material3.MaterialTheme
|
import org.spotube.millennium.composables.MillenniumCheckbox
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import androidx.compose.material3.Text
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumListTile
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
|
import org.spotube.millennium.composables.MillenniumTextField
|
||||||
|
import org.spotube.millennium.composables.MillenniumTriStateCheckbox
|
||||||
|
|
||||||
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
@ -83,16 +93,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.CheckBox
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.CheckBoxState
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.highlight
|
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
@ -122,7 +122,7 @@ enum class TrackSortOption(val label: String) {
|
|||||||
Duration("Duration"),
|
Duration("Duration"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TrackList(
|
fun TrackList(
|
||||||
tracks: List<MetadataTrack> = emptyList(),
|
tracks: List<MetadataTrack> = emptyList(),
|
||||||
@ -153,9 +153,9 @@ fun TrackList(
|
|||||||
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
||||||
simplified: Boolean = false,
|
simplified: Boolean = false,
|
||||||
scrollable: Boolean = true,
|
scrollable: Boolean = true,
|
||||||
state: LazyListState? = null,
|
state: LazyListState? = null) {
|
||||||
) {
|
val filterState = rememberTextFieldState()
|
||||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
val filterQuery = filterState.text.toString()
|
||||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||||
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
||||||
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
||||||
@ -216,11 +216,11 @@ fun TrackList(
|
|||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
val trackCardContent: @Composable () -> Unit = {
|
val trackCardContent: @Composable () -> Unit = {
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -228,9 +228,7 @@ fun TrackList(
|
|||||||
) {
|
) {
|
||||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||||
if (displayedIndex > 0) {
|
if (displayedIndex > 0) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator()
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TrackListRow(
|
TrackListRow(
|
||||||
index = displayedIndex + 1,
|
index = displayedIndex + 1,
|
||||||
@ -284,38 +282,31 @@ fun TrackList(
|
|||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && tracks.isEmpty()) {
|
if (isLoading && tracks.isEmpty()) {
|
||||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShimmerTrackListRow(
|
ShimmerTrackListRow(
|
||||||
index = shimmerIndex + 1,
|
index = shimmerIndex + 1,
|
||||||
showIndex = showIndex,
|
showIndex = showIndex,
|
||||||
showAlbum = showAlbum,
|
showAlbum = showAlbum,
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
useDropdownForOptions = useDropdownForOptions)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TrackListFeedbackRow(
|
TrackListFeedbackRow(
|
||||||
message = error,
|
message = error,
|
||||||
isError = true,
|
isError = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||||
@ -324,17 +315,14 @@ fun TrackList(
|
|||||||
|
|
||||||
if (isLoadingNextPage) {
|
if (isLoadingNextPage) {
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShimmerTrackListRow(
|
ShimmerTrackListRow(
|
||||||
index = visibleTracks.size + 1,
|
index = visibleTracks.size + 1,
|
||||||
showIndex = showIndex,
|
showIndex = showIndex,
|
||||||
showAlbum = showAlbum,
|
showAlbum = showAlbum,
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
useDropdownForOptions = useDropdownForOptions)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -345,19 +333,18 @@ fun TrackList(
|
|||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||||
.heightIn(max = 60.dp),
|
.heightIn(max = 60.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) {
|
||||||
) {
|
|
||||||
if (showIndex || isSelectionMode) {
|
if (showIndex || isSelectionMode) {
|
||||||
val allSelected =
|
val allSelected =
|
||||||
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
||||||
val anySelected = selectedTrackIds.isNotEmpty()
|
val anySelected = selectedTrackIds.isNotEmpty()
|
||||||
val headerState = when {
|
val headerState = when {
|
||||||
allSelected -> CheckBoxState.SELECTED
|
allSelected -> ToggleableState.On
|
||||||
anySelected -> CheckBoxState.INDETERMINATE
|
anySelected -> ToggleableState.Indeterminate
|
||||||
else -> CheckBoxState.UNSELECTED
|
else -> ToggleableState.Off
|
||||||
}
|
}
|
||||||
CheckBox(
|
MillenniumTriStateCheckbox(
|
||||||
state = headerState,
|
value = headerState,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
@ -367,58 +354,45 @@ fun TrackList(
|
|||||||
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
label = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
TextField(
|
MillenniumTextField(
|
||||||
value = filterQuery,
|
state = filterState,
|
||||||
onValueChange = { filterQuery = it },
|
label = "",
|
||||||
|
placeholder = "Filter",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 400.dp)
|
.widthIn(max = 400.dp)
|
||||||
.align(Alignment.CenterEnd),
|
.align(Alignment.CenterEnd))
|
||||||
placeholder = { TextWithShimmer("Filter") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Filter",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ButtonGroup {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = TrackSortOption.entries.map { option ->
|
items = TrackSortOption.entries.map { option ->
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
label = option.label,
|
label = option.label,
|
||||||
onClick = { sortBy = option },
|
onClick = { sortBy = option },
|
||||||
selected = sortBy == option,
|
selected = sortBy == option)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GroupIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxSort,
|
imageVector = Iconsax.IconsaxSort,
|
||||||
contentDescription = "Sort",
|
contentDescription = "Sort")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ButtonGroupDivider()
|
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
||||||
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
||||||
} else {
|
} else {
|
||||||
@ -433,54 +407,42 @@ fun TrackList(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
label = if (isAll) "Download All" else "Download $trackCount",
|
label = if (isAll) "Download All" else "Download $trackCount",
|
||||||
onClick = { onBulkDownload(targetTracks) },
|
onClick = { onBulkDownload(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!isJamGuest) {
|
if (!isJamGuest) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||||
onClick = { onBulkAddToQueue(targetTracks) },
|
onClick = { onBulkAddToQueue(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||||
onClick = { onBulkPlayNext(targetTracks) },
|
onClick = { onBulkPlayNext(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicPlaylist,
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
onClick = { onBulkAddToPlaylist(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} + if (isInJam) {
|
} + if (isInJam) {
|
||||||
listOf(
|
listOf(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||||
onClick = { onBulkAddToJam(targetTracks) },
|
onClick = { onBulkAddToJam(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
},
|
},
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GroupIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = "Bulk actions",
|
contentDescription = "Bulk actions")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -495,8 +457,7 @@ fun TrackList(
|
|||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
if (headerContent != null) {
|
if (headerContent != null) {
|
||||||
item {
|
item {
|
||||||
headerContent()
|
headerContent()
|
||||||
@ -525,8 +486,7 @@ fun TrackList(
|
|||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||||
@ -536,8 +496,7 @@ fun TrackList(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
if (headerContent != null) {
|
if (headerContent != null) {
|
||||||
headerContent()
|
headerContent()
|
||||||
}
|
}
|
||||||
@ -560,8 +519,7 @@ fun TrackList(
|
|||||||
onTrackOptionsAction(track, action)
|
onTrackOptionsAction(track, action)
|
||||||
selectedTrackForOptions = null
|
selectedTrackForOptions = null
|
||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -590,235 +548,198 @@ private fun TrackListRow(
|
|||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: () -> Unit,
|
onArtistsOverflowClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
|
||||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
|
||||||
val artworkInteractionSource = remember { MutableInteractionSource() }
|
val artworkInteractionSource = remember { MutableInteractionSource() }
|
||||||
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
||||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||||
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
||||||
val rowBackgroundColor = when {
|
val rowBackgroundColor = when {
|
||||||
isSelected -> rowTheme.background.selected
|
isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
|
||||||
isCurrentTrack -> rowTheme.background.focused
|
isCurrentTrack -> AppTheme.colors.surfaceVariant
|
||||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
|
||||||
else -> rowTheme.background.normal
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
val highlightColor = Color.White.copy(
|
|
||||||
alpha = if (isLight) 0.9f else 0.06f
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
MillenniumListTile(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.fillMaxWidth()
|
selected = isSelected || isCurrentTrack,
|
||||||
.hoverable(rowInteractionSource)
|
onClick = onTrackClick,
|
||||||
.combinedClickable(
|
title = {
|
||||||
onClick = onTrackClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
)
|
|
||||||
.background(rowBackgroundColor)
|
|
||||||
.then(
|
|
||||||
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
|
|
||||||
Modifier.highlight(highlightColor)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
.padding(end = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
CheckBox(
|
|
||||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
onClick = { onSelectionToggle(!isSelected) },
|
|
||||||
modifier = Modifier.shimmerApply()
|
|
||||||
)
|
|
||||||
} else if (showIndex) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.width(30.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = isRowHovered,
|
|
||||||
transitionSpec = {
|
|
||||||
fadeIn() togetherWith fadeOut()
|
|
||||||
},
|
|
||||||
label = "index-checkbox",
|
|
||||||
) { hovered ->
|
|
||||||
if (hovered) {
|
|
||||||
CheckBox(
|
|
||||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
onClick = { onSelectionToggle(!isSelected) },
|
|
||||||
modifier = Modifier.shimmerApply()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
TextWithShimmer(
|
|
||||||
text = index.toString(),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.hoverable(interactionSource = artworkInteractionSource),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
|
||||||
val platformContext = LocalPlatformContext.current
|
|
||||||
val imageRequest = remember(imageUrl) {
|
|
||||||
ImageRequest.Builder(platformContext)
|
|
||||||
.data(imageUrl)
|
|
||||||
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
|
|
||||||
.crossfade(false) // Crucial for scroll performance
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncImage(
|
|
||||||
model = imageRequest,
|
|
||||||
contentDescription = track.title,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize().shimmerApply(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isArtworkHovered || isCurrentTrack) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.10f)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
|
||||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(18.dp).shimmerApply(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1.2f)) {
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
},
|
||||||
|
subtitle = {
|
||||||
ArtistChips(
|
ArtistChips(
|
||||||
artists = track.artists,
|
artists = track.artists,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick)
|
||||||
)
|
},
|
||||||
}
|
leading = {
|
||||||
|
|
||||||
if (showAlbum && track.album != null) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
TextWithShimmer(
|
if (isSelectionMode) {
|
||||||
text = track.album!!.title,
|
MillenniumCheckbox(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
checked = isSelected,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
onCheckedChange = { onSelectionToggle(it) },
|
||||||
maxLines = 1,
|
label = "",
|
||||||
overflow = TextOverflow.Ellipsis,
|
modifier = Modifier.shimmerApply())
|
||||||
modifier = Modifier
|
} else if (showIndex) {
|
||||||
.hoverable(interactionSource)
|
Box(
|
||||||
.clickable(
|
modifier = Modifier.width(30.dp),
|
||||||
indication = null,
|
contentAlignment = Alignment.Center) {
|
||||||
interactionSource = interactionSource
|
AnimatedContent(
|
||||||
) { onAlbumClick(track.album!!) }
|
targetState = isRowHovered,
|
||||||
// Hide ripple and show underline on hover/click
|
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||||
.then(
|
label = "index-checkbox") { hovered ->
|
||||||
if (isHovered || isPressed) {
|
if (hovered) {
|
||||||
Modifier.drawWithCache {
|
MillenniumCheckbox(
|
||||||
val underlineHeight = 1.dp.toPx()
|
checked = isSelected,
|
||||||
onDrawWithContent {
|
onCheckedChange = { onSelectionToggle(it) },
|
||||||
drawContent()
|
label = "",
|
||||||
drawLine(
|
modifier = Modifier.shimmerApply())
|
||||||
color = colorScheme.primary,
|
|
||||||
start = Offset(0f, size.height - underlineHeight),
|
|
||||||
end = Offset(size.width, size.height - underlineHeight),
|
|
||||||
strokeWidth = underlineHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
TextWithShimmer(
|
||||||
|
text = index.toString(),
|
||||||
|
style = AppTheme.typography.labelMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TextWithShimmer(
|
Box(
|
||||||
text = track.durationMs.toDurationString(),
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.size(44.dp)
|
||||||
textAlign = TextAlign.End,
|
.clip(AppTheme.shapes.small)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.hoverable(interactionSource = artworkInteractionSource),
|
||||||
modifier = Modifier.width(56.dp),
|
contentAlignment = Alignment.Center) {
|
||||||
)
|
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||||
|
val platformContext = LocalPlatformContext.current
|
||||||
|
val imageRequest = remember(imageUrl) {
|
||||||
|
ImageRequest.Builder(platformContext)
|
||||||
|
.data(imageUrl)
|
||||||
|
.size(128)
|
||||||
|
.crossfade(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
if (useDropdownForOptions) {
|
AsyncImage(
|
||||||
TrackOptions(
|
model = imageRequest,
|
||||||
track = track,
|
contentDescription = track.title,
|
||||||
state = trackOptionsState,
|
contentScale = ContentScale.Crop,
|
||||||
onAction = onTrackOptionsAction,
|
modifier = Modifier.fillMaxSize().shimmerApply())
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
|
||||||
isInJam = isInJam,
|
if (isArtworkHovered || isCurrentTrack) {
|
||||||
isJamGuest = isJamGuest,
|
Box(
|
||||||
)
|
modifier = Modifier
|
||||||
} else {
|
.fillMaxSize()
|
||||||
GhostIconButton(onClick = onShowOptionsClick) {
|
.background(Color.Black.copy(alpha = 0.10f)),
|
||||||
Icon(
|
contentAlignment = Alignment.Center) {
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
MillenniumIcon(
|
||||||
contentDescription = "Track options",
|
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
)
|
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp).shimmerApply())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
trailing = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
if (showAlbum && track.album != null) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val colorScheme = AppTheme.colors
|
||||||
|
|
||||||
|
TextWithShimmer(
|
||||||
|
text = track.album!!.title,
|
||||||
|
style = AppTheme.typography.bodyMedium,
|
||||||
|
color = AppTheme.colors.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 220.dp)
|
||||||
|
.hoverable(interactionSource)
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
|
||||||
|
.then(
|
||||||
|
if (isHovered || isPressed) {
|
||||||
|
Modifier.drawWithCache {
|
||||||
|
val underlineHeight = 1.dp.toPx()
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawLine(
|
||||||
|
color = colorScheme.primary,
|
||||||
|
start = Offset(0f, size.height - underlineHeight),
|
||||||
|
end = Offset(size.width, size.height - underlineHeight),
|
||||||
|
strokeWidth = underlineHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextWithShimmer(
|
||||||
|
text = track.durationMs.toDurationString(),
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(56.dp))
|
||||||
|
|
||||||
|
if (useDropdownForOptions) {
|
||||||
|
TrackOptions(
|
||||||
|
track = track,
|
||||||
|
state = trackOptionsState,
|
||||||
|
onAction = onTrackOptionsAction,
|
||||||
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest)
|
||||||
|
} else {
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onShowOptionsClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
|
contentDescription = "Track options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistChips(
|
private fun ArtistChips(
|
||||||
artists: List<MetadataArtist.Basic>,
|
artists: List<MetadataArtist.Basic>,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onArtistsOverflowClick: () -> Unit,
|
onArtistsOverflowClick: () -> Unit) {
|
||||||
) {
|
|
||||||
val visibleArtists = artists.take(2)
|
val visibleArtists = artists.take(2)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
) {
|
|
||||||
visibleArtists.forEachIndexed { index, artist ->
|
visibleArtists.forEachIndexed { index, artist ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = artist.name,
|
text = artist.name,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = AppTheme.colors.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
||||||
@ -827,13 +748,12 @@ private fun ArtistChips(
|
|||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = interactionSource
|
interactionSource = interactionSource
|
||||||
) { onArtistClick(artist) }
|
) { onArtistClick(artist) }
|
||||||
,
|
)
|
||||||
)
|
|
||||||
if (index < visibleArtists.size - 1)
|
if (index < visibleArtists.size - 1)
|
||||||
Text(
|
Text(
|
||||||
",",
|
",",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = AppTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -842,12 +762,11 @@ private fun ArtistChips(
|
|||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "+$remaining more",
|
text = "+$remaining more",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = AppTheme.colors.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
|
modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -857,8 +776,7 @@ private fun ShimmerTrackListRow(
|
|||||||
index: Int,
|
index: Int,
|
||||||
showIndex: Boolean,
|
showIndex: Boolean,
|
||||||
showAlbum: Boolean,
|
showAlbum: Boolean,
|
||||||
useDropdownForOptions: Boolean,
|
useDropdownForOptions: Boolean) {
|
||||||
) {
|
|
||||||
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
||||||
SkeletonTree(true) {
|
SkeletonTree(true) {
|
||||||
TrackListRow(
|
TrackListRow(
|
||||||
@ -881,8 +799,7 @@ private fun ShimmerTrackListRow(
|
|||||||
onShowOptionsClick = {},
|
onShowOptionsClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistsOverflowClick = {},
|
onArtistsOverflowClick = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -891,8 +808,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
|||||||
id = "shimmer_artist_$index",
|
id = "shimmer_artist_$index",
|
||||||
name = "Artist Name",
|
name = "Artist Name",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null)
|
||||||
)
|
|
||||||
return MetadataTrack(
|
return MetadataTrack(
|
||||||
id = "shimmer_track_$index",
|
id = "shimmer_track_$index",
|
||||||
title = "Track Title Here",
|
title = "Track Title Here",
|
||||||
@ -913,29 +829,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
|||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
albumType = MetadataAlbumType.Album,
|
albumType = MetadataAlbumType.Album,
|
||||||
artists = listOf(artist),
|
artists = listOf(artist),
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
thumbnails = null,
|
thumbnails = null,
|
||||||
explicit = false,
|
explicit = false,
|
||||||
popularity = 0,
|
popularity = 0,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackListFeedbackRow(
|
private fun TrackListFeedbackRow(
|
||||||
message: String,
|
message: String,
|
||||||
isError: Boolean = false,
|
isError: Boolean = false) {
|
||||||
) {
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = message,
|
text = message,
|
||||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
.padding(horizontal = 8.dp, vertical = 12.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Long.toDurationString(): String {
|
private fun Long.toDurationString(): String {
|
||||||
@ -951,21 +863,17 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
id = "artist_1",
|
id = "artist_1",
|
||||||
name = "Aria Vale",
|
name = "Aria Vale",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
MetadataArtist.Basic(
|
MetadataArtist.Basic(
|
||||||
id = "artist_2",
|
id = "artist_2",
|
||||||
name = "Neon Harbor",
|
name = "Neon Harbor",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
MetadataArtist.Basic(
|
MetadataArtist.Basic(
|
||||||
id = "artist_3",
|
id = "artist_3",
|
||||||
name = "Echo Drift",
|
name = "Echo Drift",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val album = MetadataAlbum.Detailed(
|
val album = MetadataAlbum.Detailed(
|
||||||
releaseDate = null,
|
releaseDate = null,
|
||||||
@ -978,13 +886,11 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
Thumbnail(
|
Thumbnail(
|
||||||
url = "https://picsum.photos/120",
|
url = "https://picsum.photos/120",
|
||||||
width = 120,
|
width = 120,
|
||||||
height = 120,
|
height = 120)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
albumType = MetadataAlbumType.Album,
|
albumType = MetadataAlbumType.Album,
|
||||||
artists = artists.take(2),
|
artists = artists.take(2),
|
||||||
externalUri = null,
|
externalUri = null)
|
||||||
)
|
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
MetadataTrack(
|
MetadataTrack(
|
||||||
@ -999,8 +905,7 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
explicit = false,
|
explicit = false,
|
||||||
popularity = 92,
|
popularity = 92,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
MetadataTrack(
|
MetadataTrack(
|
||||||
id = "track_2",
|
id = "track_2",
|
||||||
title = "Static Horizon",
|
title = "Static Horizon",
|
||||||
@ -1013,8 +918,7 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
popularity = 80,
|
popularity = 80,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null,
|
||||||
thumbnails = null,
|
thumbnails = null),
|
||||||
),
|
|
||||||
MetadataTrack(
|
MetadataTrack(
|
||||||
id = "track_3",
|
id = "track_3",
|
||||||
title = "Last Train Echo",
|
title = "Last Train Echo",
|
||||||
@ -1027,19 +931,16 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
popularity = 77,
|
popularity = 77,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null,
|
||||||
thumbnails = null,
|
thumbnails = null))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
private fun TrackListPreview() {
|
private fun TrackListPreview() {
|
||||||
BaseScaffold {
|
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||||
TrackList(
|
TrackList(
|
||||||
tracks = previewTracks(),
|
tracks = previewTracks(),
|
||||||
hasMore = true,
|
hasMore = true,
|
||||||
isLoadingNextPage = true,
|
isLoadingNextPage = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||||
|
import com.composeunstyled.SheetDetent
|
||||||
|
import com.composeunstyled.rememberModalBottomSheetState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -24,12 +31,11 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
@ -72,8 +76,7 @@ data class TrackOptionsState(
|
|||||||
val isInQueue: Boolean = false,
|
val isInQueue: Boolean = false,
|
||||||
val isCurrentlyPlaying: Boolean = false,
|
val isCurrentlyPlaying: Boolean = false,
|
||||||
val isFavorite: Boolean = false,
|
val isFavorite: Boolean = false,
|
||||||
val isBlacklisted: Boolean = false,
|
val isBlacklisted: Boolean = false)
|
||||||
)
|
|
||||||
|
|
||||||
data class TrackOptionsContext(
|
data class TrackOptionsContext(
|
||||||
val currentTrackId: String? = null,
|
val currentTrackId: String? = null,
|
||||||
@ -81,15 +84,13 @@ data class TrackOptionsContext(
|
|||||||
val savedTrackIds: Set<String> = emptySet(),
|
val savedTrackIds: Set<String> = emptySet(),
|
||||||
val blacklistedTrackIds: Set<String> = emptySet(),
|
val blacklistedTrackIds: Set<String> = emptySet(),
|
||||||
val blacklistedArtistIds: Set<String> = emptySet(),
|
val blacklistedArtistIds: Set<String> = emptySet(),
|
||||||
val forceFavorite: Boolean = false,
|
val forceFavorite: Boolean = false) {
|
||||||
) {
|
|
||||||
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
||||||
isInQueue = track.id in queueTrackIds,
|
isInQueue = track.id in queueTrackIds,
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = forceFavorite || track.id in savedTrackIds,
|
isFavorite = forceFavorite || track.id in savedTrackIds,
|
||||||
isBlacklisted = track.id in blacklistedTrackIds ||
|
isBlacklisted = track.id in blacklistedTrackIds ||
|
||||||
track.artists.any { it.id in blacklistedArtistIds },
|
track.artists.any { it.id in blacklistedArtistIds })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -100,8 +101,7 @@ fun TrackOptions(
|
|||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false,
|
isJamGuest: Boolean = false) {
|
||||||
) {
|
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = buildTrackMenuItems(
|
items = buildTrackMenuItems(
|
||||||
track = track,
|
track = track,
|
||||||
@ -109,11 +109,12 @@ fun TrackOptions(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest),
|
||||||
),
|
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GhostIconButton(onClick = onClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = "Track options",
|
contentDescription = "Track options",
|
||||||
modifier = Modifier.shimmerApply()
|
modifier = Modifier.shimmerApply()
|
||||||
@ -122,11 +123,9 @@ fun TrackOptions(
|
|||||||
},
|
},
|
||||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||||
header = { TrackOptionsSheetHeader(track = track) },
|
header = { TrackOptionsSheetHeader(track = track) },
|
||||||
modifier = modifier,
|
modifier = modifier)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrackOptionsBottomSheet(
|
fun TrackOptionsBottomSheet(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false,
|
isJamGuest: Boolean = false) {
|
||||||
) {
|
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
LaunchedEffect(sheetState.currentDetent) {
|
||||||
|
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
TrackOptionsSheetHeader(track = track)
|
TrackOptionsSheetHeader(track = track)
|
||||||
|
|
||||||
@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
buildTrackMenuItems(
|
buildTrackMenuItems(
|
||||||
track = track,
|
track = track,
|
||||||
state = state,
|
state = state,
|
||||||
@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest).forEach { item ->
|
||||||
).forEach { item ->
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = item.enabled,
|
enabled = item.enabled,
|
||||||
onClick = item.onClick,
|
onClick = item.onClick)
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
item.icon?.let { icon ->
|
item.icon?.let { icon ->
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = if (item.enabled) {
|
tint = if (item.enabled) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
AppTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge,
|
||||||
color = if (item.enabled) {
|
color = if (item.enabled) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
AppTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
.clip(MaterialTheme.shapes.small),
|
.clip(AppTheme.shapes.small))
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = AppTheme.typography.titleMedium,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = track.artists.joinToString(", ") { it.name },
|
text = track.artists.joinToString(", ") { it.name },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
track.album?.let { album ->
|
track.album?.let { album ->
|
||||||
Text(
|
Text(
|
||||||
text = album.title,
|
text = album.title,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,25 +244,20 @@ private fun buildTrackMenuItems(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false,
|
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
|
||||||
): List<AdaptiveMenuItem> = buildList {
|
|
||||||
if (isInJam) {
|
if (isInJam) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = "Add to Jam",
|
label = "Add to Jam",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
onClick = { onAction(TrackOptionsAction.AddToJam) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicCircle,
|
icon = Iconsax.IconsaxMusicCircle,
|
||||||
label = "Start radio",
|
label = "Start radio",
|
||||||
onClick = { onAction(TrackOptionsAction.StartRadio) },
|
onClick = { onAction(TrackOptionsAction.StartRadio) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// A guest's queue is the shared jam queue — mutating it locally is not
|
// A guest's queue is the shared jam queue — mutating it locally is not
|
||||||
// allowed, so queue actions are replaced by "Add to Jam".
|
// allowed, so queue actions are replaced by "Add to Jam".
|
||||||
@ -279,17 +267,13 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = "Play next",
|
label = "Play next",
|
||||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = "Move to next",
|
label = "Move to next",
|
||||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isInQueue) {
|
if (!state.isInQueue) {
|
||||||
@ -297,17 +281,13 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = "Add to queue",
|
label = "Add to queue",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
onClick = { onAction(TrackOptionsAction.AddToQueue) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||||
label = "Remove from queue",
|
label = "Remove from queue",
|
||||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,42 +295,32 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
||||||
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
||||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) },
|
onClick = { onAction(TrackOptionsAction.ToggleFavorite) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicPlaylist,
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
label = "Add to playlist",
|
label = "Add to playlist",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
|
onClick = { onAction(TrackOptionsAction.AddToPlaylist) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
label = "Download",
|
label = "Download",
|
||||||
onClick = { onAction(TrackOptionsAction.Download) },
|
onClick = { onAction(TrackOptionsAction.Download) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!track.externalUri.isNullOrBlank()) {
|
if (!track.externalUri.isNullOrBlank()) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxShare,
|
icon = Iconsax.IconsaxShare,
|
||||||
label = "Share",
|
label = "Share",
|
||||||
onClick = { onAction(TrackOptionsAction.Share) },
|
onClick = { onAction(TrackOptionsAction.Share) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.album != null) {
|
if (track.album != null) {
|
||||||
@ -358,8 +328,6 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxCd,
|
icon = Iconsax.IconsaxCd,
|
||||||
label = "Go to album",
|
label = "Go to album",
|
||||||
onClick = onAlbumClick,
|
onClick = onAlbumClick))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
|||||||
@Composable
|
@Composable
|
||||||
fun UserCard(
|
fun UserCard(
|
||||||
user: MetadataUser,
|
user: MetadataUser,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
AvatarCard(
|
AvatarCard(
|
||||||
title = user.displayName ?: user.username,
|
title = user.displayName ?: user.username,
|
||||||
subtitle = "User",
|
subtitle = "User",
|
||||||
@ -53,7 +52,6 @@ fun UserCard(
|
|||||||
onClick = {
|
onClick = {
|
||||||
// TODO: Route to user details screen.
|
// TODO: Route to user details screen.
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop
|
|||||||
@Composable
|
@Composable
|
||||||
expect fun VerticalScrollbar(
|
expect fun VerticalScrollbar(
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier)
|
||||||
)
|
|
||||||
|
|
||||||
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
||||||
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
||||||
|
|||||||
@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -58,10 +58,9 @@ fun AvatarCard(
|
|||||||
imageURL: String? = null,
|
imageURL: String? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isBlacklisted: Boolean = false,
|
isBlacklisted: Boolean = false) {
|
||||||
) {
|
|
||||||
val backgroundColor = if (isBlacklisted) {
|
val backgroundColor = if (isBlacklisted) {
|
||||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
AppTheme.colors.error.copy(alpha = 0.1f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
}
|
}
|
||||||
@ -71,20 +70,17 @@ fun AvatarCard(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||||
.width(160.dp),
|
.width(160.dp)) {
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
@ -93,27 +89,24 @@ fun AvatarCard(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth())
|
||||||
)
|
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component.cards
|
package dev.krtirtho.spotube.core.ui.component.cards
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@ -36,9 +40,8 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -50,10 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||||
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
||||||
@ -75,8 +74,7 @@ fun PlayableCard(
|
|||||||
onPlay: (() -> Unit)? = null,
|
onPlay: (() -> Unit)? = null,
|
||||||
onAddToQueue: (() -> Unit)? = null,
|
onAddToQueue: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sharedElementKey: String? = null,
|
sharedElementKey: String? = null) {
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
@ -86,8 +84,7 @@ fun PlayableCard(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
||||||
.width(160.dp)
|
.width(160.dp)
|
||||||
.hoverable(interactionSource = interactionSource),
|
.hoverable(interactionSource = interactionSource)) {
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@ -100,10 +97,8 @@ fun PlayableCard(
|
|||||||
.sharedElementOrNone(
|
.sharedElementOrNone(
|
||||||
key = sharedElementKey,
|
key = sharedElementKey,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope),
|
||||||
),
|
contentScale = ContentScale.Crop)
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (onPlay != null || onAddToQueue != null)
|
if (onPlay != null || onAddToQueue != null)
|
||||||
Box(
|
Box(
|
||||||
@ -114,46 +109,35 @@ fun PlayableCard(
|
|||||||
this@Column.AnimatedVisibility(
|
this@Column.AnimatedVisibility(
|
||||||
visible = isHovered,
|
visible = isHovered,
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut()) {
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp)
|
||||||
) {
|
) {
|
||||||
onAddToQueue?.let {
|
onAddToQueue?.let {
|
||||||
SecondaryIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onAddToQueue,
|
onClick = onAddToQueue,
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(34.dp),
|
||||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
style = MillenniumIconButtonStyle.Secondary
|
||||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue",
|
contentDescription = "Add to queue")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
onPlay?.let {
|
onPlay?.let {
|
||||||
PrimaryIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(34.dp),
|
||||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
style = MillenniumIconButtonStyle.Primary
|
||||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) {
|
imageVector = if (isPlaying) {
|
||||||
Iconsax.IconsaxPause
|
Iconsax.IconsaxPause
|
||||||
} else Iconsax.IconsaxPlay,
|
} else Iconsax.IconsaxPlay,
|
||||||
contentDescription = "Play",
|
contentDescription = "Play")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,19 +149,17 @@ fun PlayableCard(
|
|||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,11 +167,10 @@ fun PlayableCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PreviewLightDark
|
@PreviewLightDark
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayableCardPreview() {
|
private fun PlayableCardPreview() {
|
||||||
BaseScaffold {
|
Box {
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
title = "Sample Title",
|
title = "Sample Title",
|
||||||
subtitle = "Sample Subtitle",
|
subtitle = "Sample Subtitle",
|
||||||
|
|||||||
@ -45,8 +45,7 @@ fun Coverflow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
state: CoverflowState = rememberCoverflowState(),
|
state: CoverflowState = rememberCoverflowState(),
|
||||||
params: CoverflowParams = CoverflowParams(),
|
params: CoverflowParams = CoverflowParams(),
|
||||||
content: CoverflowScope.() -> Unit,
|
content: CoverflowScope.() -> Unit) {
|
||||||
) {
|
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
||||||
|
|
||||||
@ -55,8 +54,7 @@ fun Coverflow(
|
|||||||
val g = geometry ?: return@LaunchedEffect
|
val g = geometry ?: return@LaunchedEffect
|
||||||
state.lazyListState.scrollToItem(
|
state.lazyListState.scrollToItem(
|
||||||
state.selectedIndex + 1,
|
state.selectedIndex + 1,
|
||||||
-g.spacerWidth,
|
-g.spacerWidth)
|
||||||
)
|
|
||||||
state.initialScrollDone = true
|
state.initialScrollDone = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,8 +67,7 @@ fun Coverflow(
|
|||||||
},
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
state = state.lazyListState,
|
state = state.lazyListState,
|
||||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState),
|
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) {
|
||||||
) {
|
|
||||||
if (size != IntSize.Zero) {
|
if (size != IntSize.Zero) {
|
||||||
val g = Geometry(params, size)
|
val g = Geometry(params, size)
|
||||||
geometry = g
|
geometry = g
|
||||||
@ -79,8 +76,7 @@ fun Coverflow(
|
|||||||
val coverflowScope = CoverflowScopeImpl(
|
val coverflowScope = CoverflowScopeImpl(
|
||||||
geometry = g,
|
geometry = g,
|
||||||
lazyListScope = this,
|
lazyListScope = this,
|
||||||
coverflowState = state,
|
coverflowState = state)
|
||||||
)
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
||||||
@ -102,15 +98,13 @@ fun Coverflow(
|
|||||||
if (state.selectedIndex > 0) {
|
if (state.selectedIndex > 0) {
|
||||||
state.scrollToItem(state.selectedIndex - 1)
|
state.scrollToItem(state.selectedIndex - 1)
|
||||||
}
|
}
|
||||||
},
|
}) {}
|
||||||
) {}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.clickable {
|
.clickable {
|
||||||
state.scrollToItem(state.selectedIndex + 1)
|
state.scrollToItem(state.selectedIndex + 1)
|
||||||
},
|
}) {}
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,7 @@ import kotlin.math.min
|
|||||||
@Stable
|
@Stable
|
||||||
internal class Geometry(
|
internal class Geometry(
|
||||||
internal val params: CoverflowParams = CoverflowParams(),
|
internal val params: CoverflowParams = CoverflowParams(),
|
||||||
private val size: IntSize = IntSize.Zero,
|
private val size: IntSize = IntSize.Zero) {
|
||||||
) {
|
|
||||||
@Stable
|
@Stable
|
||||||
private val shortEdge
|
private val shortEdge
|
||||||
get() = min(size.width, size.height)
|
get() = min(size.width, size.height)
|
||||||
|
|||||||
@ -41,8 +41,7 @@ internal fun CoverflowItem(
|
|||||||
geometry: Geometry,
|
geometry: Geometry,
|
||||||
onClickHandler: () -> Unit = {},
|
onClickHandler: () -> Unit = {},
|
||||||
onSelectedHandler: (Boolean) -> Unit,
|
onSelectedHandler: (Boolean) -> Unit,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit) {
|
||||||
) {
|
|
||||||
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
||||||
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
||||||
|
|
||||||
@ -53,8 +52,7 @@ internal fun CoverflowItem(
|
|||||||
.onGloballyPositioned { coordinates ->
|
.onGloballyPositioned { coordinates ->
|
||||||
horizontalPosition = coordinates.positionInParent().x
|
horizontalPosition = coordinates.positionInParent().x
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
if (horizontalPosition != null) {
|
if (horizontalPosition != null) {
|
||||||
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
||||||
|
|
||||||
@ -65,20 +63,16 @@ internal fun CoverflowItem(
|
|||||||
rotationY = geometry.rotation(distanceToCenter),
|
rotationY = geometry.rotation(distanceToCenter),
|
||||||
translationX = geometry.translationX(distanceToCenter),
|
translationX = geometry.translationX(distanceToCenter),
|
||||||
scaleY = geometry.scale(distanceToCenter),
|
scaleY = geometry.scale(distanceToCenter),
|
||||||
scaleX = geometry.scale(distanceToCenter),
|
scaleX = geometry.scale(distanceToCenter)),
|
||||||
),
|
propagateMinConstraints = true) {
|
||||||
propagateMinConstraints = true,
|
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
if (geometry.params.mirror) {
|
if (geometry.params.mirror) {
|
||||||
Mirror(
|
Mirror(
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = null,
|
indication = null,
|
||||||
onClick = onClickHandler,
|
onClick = onClickHandler),
|
||||||
),
|
coverSize = geometry.coverSize) {
|
||||||
coverSize = geometry.coverSize,
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -86,9 +80,7 @@ internal fun CoverflowItem(
|
|||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = null,
|
indication = null,
|
||||||
onClick = onClickHandler,
|
onClick = onClickHandler)) {
|
||||||
),
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,5 +26,4 @@ data class CoverflowParams(
|
|||||||
val angle: Float = 45f,
|
val angle: Float = 45f,
|
||||||
val shift: Float = 0.4f,
|
val shift: Float = 0.4f,
|
||||||
val zoom: Float = 0.8f,
|
val zoom: Float = 0.8f,
|
||||||
val mirror: Boolean = false,
|
val mirror: Boolean = false)
|
||||||
)
|
|
||||||
|
|||||||
@ -26,37 +26,32 @@ interface CoverflowScope {
|
|||||||
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
|
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
|
||||||
key: ((index: Int) -> Any)? = null,
|
key: ((index: Int) -> Any)? = null,
|
||||||
contentType: (index: Int) -> Any? = { null },
|
contentType: (index: Int) -> Any? = { null },
|
||||||
itemContent: @Composable ((item: T) -> Unit),
|
itemContent: @Composable ((item: T) -> Unit))
|
||||||
)
|
|
||||||
|
|
||||||
fun items(
|
fun items(
|
||||||
count: Int,
|
count: Int,
|
||||||
onSelectHandler: (index: Int) -> Unit = {},
|
onSelectHandler: (index: Int) -> Unit = {},
|
||||||
key: ((index: Int) -> Any)? = null,
|
key: ((index: Int) -> Any)? = null,
|
||||||
contentType: (index: Int) -> Any? = { null },
|
contentType: (index: Int) -> Any? = { null },
|
||||||
itemContent: @Composable ((index: Int) -> Unit),
|
itemContent: @Composable ((index: Int) -> Unit))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class CoverflowScopeImpl(
|
internal class CoverflowScopeImpl(
|
||||||
private val geometry: Geometry,
|
private val geometry: Geometry,
|
||||||
private val lazyListScope: LazyListScope,
|
private val lazyListScope: LazyListScope,
|
||||||
private val coverflowState: CoverflowState,
|
private val coverflowState: CoverflowState) : CoverflowScope {
|
||||||
) : CoverflowScope {
|
|
||||||
override fun <T> items(
|
override fun <T> items(
|
||||||
items: List<T>,
|
items: List<T>,
|
||||||
onSelectHandler: (item: T, index: Int) -> Unit,
|
onSelectHandler: (item: T, index: Int) -> Unit,
|
||||||
key: ((index: Int) -> Any)?,
|
key: ((index: Int) -> Any)?,
|
||||||
contentType: (index: Int) -> Any?,
|
contentType: (index: Int) -> Any?,
|
||||||
itemContent: @Composable (item: T) -> Unit,
|
itemContent: @Composable (item: T) -> Unit) = items(
|
||||||
) = items(
|
|
||||||
count = items.size,
|
count = items.size,
|
||||||
onSelectHandler = {
|
onSelectHandler = {
|
||||||
onSelectHandler(items[it], it)
|
onSelectHandler(items[it], it)
|
||||||
},
|
},
|
||||||
key = key,
|
key = key,
|
||||||
contentType = contentType,
|
contentType = contentType) {
|
||||||
) {
|
|
||||||
itemContent(items[it])
|
itemContent(items[it])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,12 +60,10 @@ internal class CoverflowScopeImpl(
|
|||||||
onSelectHandler: (index: Int) -> Unit,
|
onSelectHandler: (index: Int) -> Unit,
|
||||||
key: ((index: Int) -> Any)?,
|
key: ((index: Int) -> Any)?,
|
||||||
contentType: (index: Int) -> Any?,
|
contentType: (index: Int) -> Any?,
|
||||||
itemContent: @Composable (index: Int) -> Unit,
|
itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items(
|
||||||
) = lazyListScope.items(
|
|
||||||
count,
|
count,
|
||||||
key,
|
key,
|
||||||
contentType,
|
contentType) {
|
||||||
) {
|
|
||||||
CoverflowItem(
|
CoverflowItem(
|
||||||
onClickHandler = { coverflowState.scrollToItem(it) },
|
onClickHandler = { coverflowState.scrollToItem(it) },
|
||||||
onSelectedHandler = { isSelected: Boolean ->
|
onSelectedHandler = { isSelected: Boolean ->
|
||||||
@ -78,16 +71,14 @@ internal class CoverflowScopeImpl(
|
|||||||
onSelectHandler(it)
|
onSelectHandler(it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
geometry = geometry,
|
geometry = geometry) {
|
||||||
) {
|
|
||||||
itemContent(it)
|
itemContent(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectHandler(
|
private fun selectHandler(
|
||||||
index: Int,
|
index: Int,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean): Boolean {
|
||||||
): Boolean {
|
|
||||||
if (!isSelected && index == coverflowState.selectedIndex) {
|
if (!isSelected && index == coverflowState.selectedIndex) {
|
||||||
coverflowState.selectedIndex = -1
|
coverflowState.selectedIndex = -1
|
||||||
} else if (isSelected && index != coverflowState.selectedIndex) {
|
} else if (isSelected && index != coverflowState.selectedIndex) {
|
||||||
|
|||||||
@ -28,19 +28,16 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberCoverflowState(
|
fun rememberCoverflowState(
|
||||||
initialIndex: Int = 0,
|
initialIndex: Int = 0,
|
||||||
onSelectHandler: (Int) -> Unit = {},
|
onSelectHandler: (Int) -> Unit = {}): CoverflowState {
|
||||||
): CoverflowState {
|
|
||||||
val lazyListState: LazyListState = rememberLazyListState(
|
val lazyListState: LazyListState = rememberLazyListState(
|
||||||
initialFirstVisibleItemIndex = initialIndex + 1,
|
initialFirstVisibleItemIndex = initialIndex + 1)
|
||||||
)
|
|
||||||
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||||
return remember {
|
return remember {
|
||||||
CoverflowState(
|
CoverflowState(
|
||||||
lazyListState,
|
lazyListState,
|
||||||
coroutineScope,
|
coroutineScope,
|
||||||
onSelectHandler,
|
onSelectHandler,
|
||||||
initialIndex,
|
initialIndex)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +45,7 @@ class CoverflowState internal constructor(
|
|||||||
internal val lazyListState: LazyListState,
|
internal val lazyListState: LazyListState,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val onSelectHandler: (Int) -> Unit = {},
|
private val onSelectHandler: (Int) -> Unit = {},
|
||||||
initialIndex: Int = 0,
|
initialIndex: Int = 0) {
|
||||||
) {
|
|
||||||
internal var geometry: Geometry? = null
|
internal var geometry: Geometry? = null
|
||||||
internal var initialScrollDone: Boolean = false
|
internal var initialScrollDone: Boolean = false
|
||||||
|
|
||||||
@ -65,8 +61,7 @@ class CoverflowState internal constructor(
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
lazyListState.animateScrollToItem(
|
lazyListState.animateScrollToItem(
|
||||||
index + 1,
|
index + 1,
|
||||||
-geometryCopy.spacerWidth,
|
-geometryCopy.spacerWidth)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times
|
|||||||
internal fun Mirror(
|
internal fun Mirror(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
coverSize: Float,
|
coverSize: Float,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit) {
|
||||||
) {
|
|
||||||
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(top = coverSizeDp)
|
.padding(top = coverSizeDp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.requiredHeight(2 * coverSizeDp),
|
.requiredHeight(2 * coverSizeDp)) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
propagateMinConstraints = true,
|
propagateMinConstraints = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(coverSizeDp),
|
.height(coverSizeDp)) {
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@ -75,34 +72,27 @@ internal fun Mirror(
|
|||||||
.height(coverSizeDp)
|
.height(coverSizeDp)
|
||||||
.graphicsLayer(
|
.graphicsLayer(
|
||||||
compositingStrategy = CompositingStrategy.Offscreen,
|
compositingStrategy = CompositingStrategy.Offscreen,
|
||||||
rotationX = 180f,
|
rotationX = 180f)
|
||||||
)
|
|
||||||
.clip(HalfSizeShape)
|
.clip(HalfSizeShape)
|
||||||
.drawWithContent {
|
.drawWithContent {
|
||||||
val colors = listOf(
|
val colors = listOf(
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Black,
|
Color.Black)
|
||||||
)
|
|
||||||
drawContent()
|
drawContent()
|
||||||
drawRect(
|
drawRect(
|
||||||
topLeft = Offset(
|
topLeft = Offset(
|
||||||
0f,
|
0f,
|
||||||
this.size.height / 2,
|
this.size.height / 2),
|
||||||
),
|
|
||||||
size = Size(
|
size = Size(
|
||||||
this.size.width,
|
this.size.width,
|
||||||
this.size.height / 2,
|
this.size.height / 2),
|
||||||
),
|
|
||||||
brush = Brush.verticalGradient(colors),
|
brush = Brush.verticalGradient(colors),
|
||||||
blendMode = BlendMode.DstIn,
|
blendMode = BlendMode.DstIn)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.blur(
|
.blur(
|
||||||
radiusX = 1.dp,
|
radiusX = 1.dp,
|
||||||
radiusY = 3.dp,
|
radiusY = 3.dp)) {
|
||||||
),
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,17 +102,12 @@ private object HalfSizeShape : Shape {
|
|||||||
override fun createOutline(
|
override fun createOutline(
|
||||||
size: Size,
|
size: Size,
|
||||||
layoutDirection: LayoutDirection,
|
layoutDirection: LayoutDirection,
|
||||||
density: Density,
|
density: Density): Outline = Outline.Rectangle(
|
||||||
): Outline = Outline.Rectangle(
|
|
||||||
Rect(
|
Rect(
|
||||||
Offset(
|
Offset(
|
||||||
0f,
|
0f,
|
||||||
size.height / 2,
|
size.height / 2),
|
||||||
),
|
|
||||||
Size(
|
Size(
|
||||||
size.width,
|
size.width,
|
||||||
size.height,
|
size.height)))
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,10 +17,11 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.misc
|
package dev.krtirtho.spotube.core.ui.misc
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.text.TextAutoSize
|
import androidx.compose.foundation.text.TextAutoSize
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import com.composeunstyled.LocalTextStyle
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
@ -38,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import com.eygraber.compose.placeholder.material3.placeholder
|
|
||||||
import com.valentinilk.shimmer.ShimmerBounds
|
import com.valentinilk.shimmer.ShimmerBounds
|
||||||
import com.valentinilk.shimmer.rememberShimmer
|
import com.valentinilk.shimmer.rememberShimmer
|
||||||
import com.valentinilk.shimmer.shimmer
|
import com.valentinilk.shimmer.shimmer
|
||||||
@ -84,11 +84,7 @@ fun Modifier.shimmerApply(
|
|||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
val isTreeLoading = LocalSkeletonLoading.current
|
val isTreeLoading = LocalSkeletonLoading.current
|
||||||
|
|
||||||
this.placeholder(
|
if (isTreeLoading) this.background(color) else this
|
||||||
visible = isTreeLoading,
|
|
||||||
color = color,
|
|
||||||
shape = null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -110,23 +106,20 @@ fun TextWithShimmer(
|
|||||||
maxLines: Int = Int.MAX_VALUE,
|
maxLines: Int = Int.MAX_VALUE,
|
||||||
minLines: Int = 1,
|
minLines: Int = 1,
|
||||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||||
style: TextStyle = LocalTextStyle.current,
|
style: TextStyle = LocalTextStyle.current) {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
color = color,
|
color = color,
|
||||||
modifier = Modifier.shimmerApply().then(modifier),
|
modifier = Modifier.shimmerApply().then(modifier),
|
||||||
autoSize = autoSize,
|
autoSize = autoSize,
|
||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
fontStyle = fontStyle,
|
|
||||||
fontWeight = fontWeight,
|
fontWeight = fontWeight,
|
||||||
fontFamily = fontFamily,
|
fontFamily = fontFamily,
|
||||||
letterSpacing = letterSpacing,
|
letterSpacing = letterSpacing,
|
||||||
textDecoration = textDecoration,
|
textDecoration = textDecoration,
|
||||||
textAlign = textAlign,
|
textAlign = textAlign ?: TextAlign.Start,
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
softWrap = softWrap,
|
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
onTextLayout = onTextLayout,
|
onTextLayout = onTextLayout,
|
||||||
|
|||||||
@ -18,93 +18,188 @@
|
|||||||
package dev.krtirtho.spotube.core.ui.theming
|
package dev.krtirtho.spotube.core.ui.theming
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.ColorScheme
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import com.materialkolor.rememberDynamicColorScheme
|
import androidx.compose.runtime.staticCompositionLocalOf
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme
|
import androidx.compose.ui.graphics.Color
|
||||||
import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.luminance
|
||||||
|
import androidx.compose.ui.text.TextStyle
|
||||||
|
import androidx.compose.ui.text.font.FontFamily
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import androidx.compose.ui.unit.sp
|
||||||
|
|
||||||
import dev.krtirtho.spotube.modules.settings.Theme
|
import dev.krtirtho.spotube.modules.settings.Theme
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
|
import org.spotube.millennium.theme.MillenniumColors
|
||||||
|
import org.spotube.millennium.theme.MillenniumPalette
|
||||||
|
import org.spotube.millennium.theme.MillenniumTheme
|
||||||
|
import org.spotube.millennium.theme.MillenniumThemeConfig
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Semantic color roles used across the app, resolved from the active Millennium palette.
|
||||||
|
*
|
||||||
|
* This is not a second palette: every role is derived from [MillenniumColors], so the
|
||||||
|
* whole app follows the selected Millennium accent family and light/dark appearance.
|
||||||
|
*/
|
||||||
|
@Immutable
|
||||||
|
data class AppColors(
|
||||||
|
val primary: Color,
|
||||||
|
val onPrimary: Color,
|
||||||
|
val primaryContainer: Color,
|
||||||
|
val onPrimaryContainer: Color,
|
||||||
|
val secondary: Color,
|
||||||
|
val onSecondary: Color,
|
||||||
|
val secondaryContainer: Color,
|
||||||
|
val onSecondaryContainer: Color,
|
||||||
|
val tertiaryContainer: Color,
|
||||||
|
val onTertiaryContainer: Color,
|
||||||
|
val background: Color,
|
||||||
|
val onBackground: Color,
|
||||||
|
val surface: Color,
|
||||||
|
val onSurface: Color,
|
||||||
|
val surfaceVariant: Color,
|
||||||
|
val onSurfaceVariant: Color,
|
||||||
|
val surfaceContainer: Color,
|
||||||
|
val surfaceContainerHigh: Color,
|
||||||
|
val surfaceContainerHighest: Color,
|
||||||
|
val inverseSurface: Color,
|
||||||
|
val inverseOnSurface: Color,
|
||||||
|
val outline: Color,
|
||||||
|
val outlineVariant: Color,
|
||||||
|
val error: Color,
|
||||||
|
val onError: Color,
|
||||||
|
val errorContainer: Color,
|
||||||
|
val onErrorContainer: Color,
|
||||||
|
val scrim: Color)
|
||||||
|
|
||||||
|
/** Maps Millennium's semantic palette onto the app's role names. */
|
||||||
|
fun appColorsFrom(m: MillenniumColors): AppColors = AppColors(
|
||||||
|
primary = m.accent,
|
||||||
|
onPrimary = m.onAccent,
|
||||||
|
primaryContainer = m.raised,
|
||||||
|
onPrimaryContainer = m.text,
|
||||||
|
secondary = m.accent,
|
||||||
|
onSecondary = m.onAccent,
|
||||||
|
secondaryContainer = m.raised,
|
||||||
|
onSecondaryContainer = m.text,
|
||||||
|
tertiaryContainer = m.raised,
|
||||||
|
onTertiaryContainer = m.text,
|
||||||
|
background = m.background,
|
||||||
|
onBackground = m.text,
|
||||||
|
surface = m.surface,
|
||||||
|
onSurface = m.text,
|
||||||
|
surfaceVariant = m.inset,
|
||||||
|
onSurfaceVariant = m.muted,
|
||||||
|
surfaceContainer = m.surface,
|
||||||
|
surfaceContainerHigh = m.raised,
|
||||||
|
surfaceContainerHighest = m.raised,
|
||||||
|
inverseSurface = m.text,
|
||||||
|
inverseOnSurface = m.background,
|
||||||
|
outline = m.border,
|
||||||
|
outlineVariant = m.border,
|
||||||
|
error = m.danger,
|
||||||
|
onError = m.background,
|
||||||
|
errorContainer = m.danger.copy(alpha = 0.16f),
|
||||||
|
onErrorContainer = m.danger,
|
||||||
|
scrim = m.scrim)
|
||||||
|
|
||||||
|
/** Named text styles used by the app, independent of Material typography. */
|
||||||
|
@Immutable
|
||||||
|
data class AppTypography(
|
||||||
|
val displaySmall: TextStyle,
|
||||||
|
val headlineLarge: TextStyle,
|
||||||
|
val headlineMedium: TextStyle,
|
||||||
|
val headlineSmall: TextStyle,
|
||||||
|
val titleLarge: TextStyle,
|
||||||
|
val titleMedium: TextStyle,
|
||||||
|
val titleSmall: TextStyle,
|
||||||
|
val bodyLarge: TextStyle,
|
||||||
|
val bodyMedium: TextStyle,
|
||||||
|
val bodySmall: TextStyle,
|
||||||
|
val labelLarge: TextStyle,
|
||||||
|
val labelMedium: TextStyle,
|
||||||
|
val labelSmall: TextStyle)
|
||||||
|
|
||||||
|
private fun style(
|
||||||
|
size: Int,
|
||||||
|
lineHeight: Int,
|
||||||
|
weight: FontWeight = FontWeight.Normal): TextStyle = TextStyle(
|
||||||
|
fontFamily = FontFamily.SansSerif,
|
||||||
|
fontSize = size.sp,
|
||||||
|
lineHeight = lineHeight.sp,
|
||||||
|
fontWeight = weight)
|
||||||
|
|
||||||
|
val AppDefaultTypography = AppTypography(
|
||||||
|
displaySmall = style(36, 44),
|
||||||
|
headlineLarge = style(32, 40),
|
||||||
|
headlineMedium = style(28, 36),
|
||||||
|
headlineSmall = style(24, 32),
|
||||||
|
titleLarge = style(22, 28, FontWeight.Medium),
|
||||||
|
titleMedium = style(16, 24, FontWeight.Medium),
|
||||||
|
titleSmall = style(14, 20, FontWeight.Medium),
|
||||||
|
bodyLarge = style(16, 24),
|
||||||
|
bodyMedium = style(14, 20),
|
||||||
|
bodySmall = style(12, 16),
|
||||||
|
labelLarge = style(14, 20, FontWeight.Medium),
|
||||||
|
labelMedium = style(12, 16, FontWeight.Medium),
|
||||||
|
labelSmall = style(11, 16, FontWeight.Medium))
|
||||||
|
|
||||||
|
/** Corner shapes used by the app. */
|
||||||
|
@Immutable
|
||||||
|
data class AppShapes(
|
||||||
|
val small: Shape,
|
||||||
|
val medium: Shape,
|
||||||
|
val large: Shape)
|
||||||
|
|
||||||
|
val AppDefaultShapes = AppShapes(
|
||||||
|
small = RoundedCornerShape(8.dp),
|
||||||
|
medium = RoundedCornerShape(12.dp),
|
||||||
|
large = RoundedCornerShape(16.dp))
|
||||||
|
|
||||||
|
val LocalAppColors = staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) }
|
||||||
|
val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography }
|
||||||
|
val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes }
|
||||||
|
|
||||||
|
/** Reads the active app theme tokens, all backed by Millennium. */
|
||||||
|
object AppTheme {
|
||||||
|
val colors: AppColors @Composable get() = LocalAppColors.current
|
||||||
|
val typography: AppTypography @Composable get() = LocalAppTypography.current
|
||||||
|
val shapes: AppShapes @Composable get() = LocalAppShapes.current
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The app's single source of truth for colour.
|
* The app's single source of truth for colour.
|
||||||
*
|
*
|
||||||
* The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a
|
* Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark
|
||||||
* [BaseUIColorScheme], and every Material role is then overridden from it. That way the
|
* [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and
|
||||||
* whole app — Material components included — renders in the chosen surface theme's colours
|
* [AppShapes] tokens from the active Millennium theme.
|
||||||
* and accent, instead of Material's dynamic palette drifting away from the base UI.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
fun SpotubeTheme(
|
fun SpotubeTheme(
|
||||||
settings: UserSettings,
|
settings: UserSettings,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit) {
|
||||||
) {
|
|
||||||
val isDarkTheme = when (settings.theme) {
|
val isDarkTheme = when (settings.theme) {
|
||||||
Theme.LIGHT -> false
|
Theme.LIGHT -> false
|
||||||
Theme.DARK -> true
|
Theme.DARK -> true
|
||||||
Theme.SYSTEM -> isSystemInDarkTheme()
|
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||||
}
|
}
|
||||||
val isLight = !isDarkTheme
|
val palette: MillenniumPalette = settings.palette
|
||||||
|
|
||||||
val accent = if (isDarkTheme) {
|
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
||||||
settings.accentColor.toDarkColor()
|
val colors = appColorsFrom(MillenniumTheme.colors)
|
||||||
} else {
|
CompositionLocalProvider(
|
||||||
settings.accentColor.toLightColor()
|
LocalAppColors provides colors,
|
||||||
}
|
LocalAppTypography provides AppDefaultTypography,
|
||||||
|
LocalAppShapes provides AppDefaultShapes,
|
||||||
val baseColors = remember(settings.surfaceTheme, isLight, accent) {
|
content = content)
|
||||||
baseUIColorScheme(settings.surfaceTheme, isLight, accent)
|
|
||||||
}
|
|
||||||
|
|
||||||
val dynamicScheme = rememberDynamicColorScheme(
|
|
||||||
seedColor = accent,
|
|
||||||
isDark = isDarkTheme,
|
|
||||||
)
|
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = dynamicScheme.mergeWithBaseColors(baseColors),
|
|
||||||
) {
|
|
||||||
Surface(content = content)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** Whether the active Millennium appearance is light. */
|
||||||
* Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the
|
@Composable
|
||||||
* base UI palette, so Material components share the exact same colours as the base
|
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
|
||||||
* components and the surface theme's primary/secondary backgrounds.
|
|
||||||
*/
|
|
||||||
private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy(
|
|
||||||
primary = base.accentPrimary,
|
|
||||||
onPrimary = base.onAccentPrimary,
|
|
||||||
primaryContainer = base.primaryContainer,
|
|
||||||
onPrimaryContainer = base.onPrimaryContainer,
|
|
||||||
inversePrimary = base.actionInverse,
|
|
||||||
secondary = base.accentSecondary,
|
|
||||||
onSecondary = base.onAccentSecondary,
|
|
||||||
secondaryContainer = base.secondaryContainer,
|
|
||||||
onSecondaryContainer = base.onSecondaryContainer,
|
|
||||||
background = base.surface,
|
|
||||||
onBackground = base.onBackground,
|
|
||||||
surface = base.surface,
|
|
||||||
onSurface = base.onSurface,
|
|
||||||
surfaceVariant = base.surfaceVariant,
|
|
||||||
onSurfaceVariant = base.onSurfaceVariant,
|
|
||||||
surfaceTint = base.accentPrimary,
|
|
||||||
surfaceBright = base.surfaceContainerHigh,
|
|
||||||
surfaceDim = base.surfaceContainerHighest,
|
|
||||||
surfaceContainerLowest = base.surface,
|
|
||||||
surfaceContainerLow = base.surfaceContainer,
|
|
||||||
surfaceContainer = base.surfaceContainer,
|
|
||||||
surfaceContainerHigh = base.surfaceContainerHigh,
|
|
||||||
surfaceContainerHighest = base.surfaceContainerHighest,
|
|
||||||
inverseSurface = base.backgroundInverse,
|
|
||||||
inverseOnSurface = base.onBackgroundInverse,
|
|
||||||
outline = base.outline,
|
|
||||||
outlineVariant = base.outlineVariant,
|
|
||||||
error = base.error,
|
|
||||||
onError = base.onError,
|
|
||||||
scrim = base.scrim,
|
|
||||||
)
|
|
||||||
|
|||||||
@ -1,221 +1,230 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import androidx.compose.foundation.layout.Column
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
import androidx.compose.foundation.layout.Row
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.statusBars
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.IconButton
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.remember
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.Alignment
|
||||||
import compose.icons.FeatherIcons
|
import androidx.compose.ui.Modifier
|
||||||
import compose.icons.feathericons.ChevronLeft
|
import androidx.compose.ui.graphics.Color
|
||||||
import compose.icons.feathericons.ChevronRight
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import compose.icons.feathericons.X
|
import androidx.compose.ui.unit.dp
|
||||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
import compose.icons.FeatherIcons
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import compose.icons.feathericons.ChevronLeft
|
||||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
import compose.icons.feathericons.ChevronRight
|
||||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
import compose.icons.feathericons.X
|
||||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||||
import dev.nucleusframework.webview.web.WebView
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||||
import dev.nucleusframework.webview.web.WebViewState
|
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||||
|
import dev.nucleusframework.webview.web.WebView
|
||||||
class PostMessageHandler(
|
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||||
private val onMessageReceived: (String) -> Unit = {}
|
import dev.nucleusframework.webview.web.WebViewState
|
||||||
) : IJsMessageHandler {
|
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||||
override fun methodName(): String {
|
|
||||||
return "sendMessage"
|
class PostMessageHandler(
|
||||||
}
|
private val onMessageReceived: (String) -> Unit = {}
|
||||||
|
) : IJsMessageHandler {
|
||||||
override fun handle(
|
override fun methodName(): String {
|
||||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
return "sendMessage"
|
||||||
) {
|
}
|
||||||
onMessageReceived(message.params)
|
|
||||||
callback(message.params)
|
override fun handle(
|
||||||
}
|
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||||
}
|
) {
|
||||||
|
onMessageReceived(message.params)
|
||||||
@Composable
|
callback(message.params)
|
||||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
}
|
||||||
if (webViewController.getContent() == null) {
|
}
|
||||||
// This should never happen, but just in case
|
|
||||||
Text("No URL to load")
|
@Composable
|
||||||
return
|
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||||
}
|
if (webViewController.getContent() == null) {
|
||||||
|
// This should never happen, but just in case
|
||||||
val state = remember {
|
Text("No URL to load")
|
||||||
WebViewState(
|
return
|
||||||
webViewController.getWebContent(
|
}
|
||||||
additionalHttpHeaders = mapOf(
|
|
||||||
"User-Agent" to UserAgents.random()
|
val state = remember {
|
||||||
)
|
WebViewState(
|
||||||
)
|
webViewController.getWebContent(
|
||||||
)
|
additionalHttpHeaders = mapOf(
|
||||||
}.apply {
|
"User-Agent" to UserAgents.random()
|
||||||
this.content = webViewController.getWebContent()
|
)
|
||||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
)
|
||||||
}
|
)
|
||||||
|
}.apply {
|
||||||
val navigator = rememberWebViewNavigator()
|
this.content = webViewController.getWebContent()
|
||||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||||
|
}
|
||||||
val bridgeBootstrapScript = remember {
|
|
||||||
"""
|
val navigator = rememberWebViewNavigator()
|
||||||
(function() {
|
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||||
if (typeof window.sendMessage !== "function") {
|
|
||||||
window.sendMessage = function(message) {
|
val bridgeBootstrapScript = remember {
|
||||||
if (typeof message !== "string") {
|
"""
|
||||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
(function() {
|
||||||
}
|
if (typeof window.sendMessage !== "function") {
|
||||||
window.kmpJsBridge.callNative("sendMessage", message);
|
window.sendMessage = function(message) {
|
||||||
};
|
if (typeof message !== "string") {
|
||||||
}
|
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||||
|
}
|
||||||
if (!window.bridgeReady) {
|
window.kmpJsBridge.callNative("sendMessage", message);
|
||||||
const event = new CustomEvent("onBridgeReady");
|
};
|
||||||
window.dispatchEvent(event);
|
}
|
||||||
window.bridgeReady = true;
|
|
||||||
}
|
if (!window.bridgeReady) {
|
||||||
})();
|
const event = new CustomEvent("onBridgeReady");
|
||||||
""".trimIndent()
|
window.dispatchEvent(event);
|
||||||
}
|
window.bridgeReady = true;
|
||||||
|
}
|
||||||
LaunchedEffect(state) {
|
})();
|
||||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
""".trimIndent()
|
||||||
if (url != null) {
|
}
|
||||||
webViewController.emitUrlChange(url)
|
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
LaunchedEffect(state) {
|
||||||
webViewController.emitWebViewCreated()
|
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||||
}
|
if (url != null) {
|
||||||
}
|
webViewController.emitUrlChange(url)
|
||||||
}
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
|
webViewController.emitWebViewCreated()
|
||||||
LaunchedEffect(state.cookieManager, navigator) {
|
}
|
||||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
}
|
||||||
webViewController.webViewNavigator = navigator
|
}
|
||||||
}
|
|
||||||
|
LaunchedEffect(state.cookieManager, navigator) {
|
||||||
LaunchedEffect(webViewBridge) {
|
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||||
webViewBridge.register(PostMessageHandler { message ->
|
webViewController.webViewNavigator = navigator
|
||||||
webViewController.emitPostMessage(message)
|
}
|
||||||
})
|
|
||||||
}
|
LaunchedEffect(webViewBridge) {
|
||||||
|
webViewBridge.register(PostMessageHandler { message ->
|
||||||
DisposableEffect(Unit) {
|
webViewController.emitPostMessage(message)
|
||||||
onDispose {
|
})
|
||||||
webViewController.dispose()
|
}
|
||||||
}
|
|
||||||
}
|
DisposableEffect(Unit) {
|
||||||
|
onDispose {
|
||||||
BaseScaffold(
|
webViewController.dispose()
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
}
|
||||||
topBar = {
|
}
|
||||||
ApplicationMainBar(
|
|
||||||
title = {
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
Row(
|
ApplicationMainBar(
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
title = {
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||||
) {
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
Row(
|
verticalAlignment = Alignment.CenterVertically
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
) {
|
||||||
horizontalArrangement = Arrangement.Start,
|
Row(
|
||||||
modifier = Modifier.height(56.dp)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
horizontalArrangement = Arrangement.Start,
|
||||||
IconButton(
|
modifier = Modifier.height(56.dp)
|
||||||
onClick = {
|
) {
|
||||||
navigator.navigateBack()
|
MillenniumIconButton(
|
||||||
}, enabled = navigator.canGoBack
|
onClick = {
|
||||||
) {
|
navigator.navigateBack()
|
||||||
Icon(
|
},
|
||||||
FeatherIcons.ChevronLeft,
|
enabled = navigator.canGoBack,
|
||||||
contentDescription = "Go back to browser history"
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
)
|
) {
|
||||||
}
|
MillenniumIcon(
|
||||||
IconButton(
|
FeatherIcons.ChevronLeft,
|
||||||
onClick = {
|
contentDescription = "Go back to browser history"
|
||||||
navigator.navigateForward()
|
)
|
||||||
}, enabled = navigator.canGoForward
|
}
|
||||||
) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = {
|
||||||
FeatherIcons.ChevronRight,
|
navigator.navigateForward()
|
||||||
contentDescription = "Go forward to browser history"
|
},
|
||||||
)
|
enabled = navigator.canGoForward,
|
||||||
}
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
}
|
) {
|
||||||
Surface(
|
MillenniumIcon(
|
||||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
FeatherIcons.ChevronRight,
|
||||||
shape = RoundedCornerShape(18.dp),
|
contentDescription = "Go forward to browser history"
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
)
|
||||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
}
|
||||||
) {
|
}
|
||||||
BasicTextField(
|
Box(
|
||||||
value = state.lastLoadedUrl ?: "",
|
modifier = Modifier
|
||||||
onValueChange = {}, // Read-only
|
.weight(1f)
|
||||||
readOnly = true,
|
.height(36.dp)
|
||||||
singleLine = true,
|
.padding(horizontal = 4.dp)
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
.clip(RoundedCornerShape(18.dp))
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
|
||||||
textAlign = TextAlign.Start
|
.border(
|
||||||
),
|
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
RoundedCornerShape(18.dp)),
|
||||||
.wrapContentHeight(Alignment.CenterVertically)
|
contentAlignment = Alignment.CenterStart
|
||||||
)
|
) {
|
||||||
}
|
BasicTextField(
|
||||||
}
|
value = state.lastLoadedUrl ?: "",
|
||||||
}
|
onValueChange = {}, // Read-only
|
||||||
)
|
readOnly = true,
|
||||||
})
|
singleLine = true,
|
||||||
{ innerPadding ->
|
textStyle = AppTheme.typography.bodyMedium.copy(
|
||||||
WebView(
|
color = AppTheme.colors.onSurface,
|
||||||
state = state,
|
textAlign = TextAlign.Start
|
||||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
),
|
||||||
navigator = navigator,
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||||
webViewJsBridge = webViewBridge,
|
.wrapContentHeight(Alignment.CenterVertically)
|
||||||
onCreated = { webView ->
|
)
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
)
|
||||||
|
WebView(
|
||||||
|
state = state,
|
||||||
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
|
navigator = navigator,
|
||||||
|
webViewJsBridge = webViewBridge,
|
||||||
|
onCreated = { webView ->
|
||||||
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
|
})
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent
|
|||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class BuiltInPluginService(
|
class BuiltInPluginService(
|
||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||||
) : PluginService, KoinComponent {
|
|
||||||
|
|
||||||
override val pluginId: String = pluginInfo.id
|
override val pluginId: String = pluginInfo.id
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
@ -58,7 +57,6 @@ class BuiltInPluginService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun start() {
|
override suspend fun start() {
|
||||||
when (pluginInfo) {
|
when (pluginInfo) {
|
||||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
||||||
|
|||||||
@ -1,347 +1,344 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline
|
package dev.krtirtho.spotube.core.zipline
|
||||||
|
|
||||||
import app.cash.zipline.Zipline
|
import app.cash.zipline.Zipline
|
||||||
import app.cash.zipline.ZiplineService
|
import app.cash.zipline.ZiplineService
|
||||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||||
import app.cash.zipline.loader.LoadResult
|
import app.cash.zipline.loader.LoadResult
|
||||||
import app.cash.zipline.loader.ManifestVerifier
|
import app.cash.zipline.loader.ManifestVerifier
|
||||||
import app.cash.zipline.loader.ZiplineLoader
|
import app.cash.zipline.loader.ZiplineLoader
|
||||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||||
import io.ktor.http.URLBuilder
|
import io.ktor.http.URLBuilder
|
||||||
import io.ktor.http.decodeURLQueryComponent
|
import io.ktor.http.decodeURLQueryComponent
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
/**
|
||||||
/**
|
* Manages the lifecycle of a Zipline plugin.
|
||||||
* Manages the lifecycle of a Zipline plugin.
|
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
* Anything it provides, must be called within that dispatcher context.
|
||||||
* Anything it provides, must be called within that dispatcher context.
|
* The [use] function must be used.
|
||||||
* The [use] function must be used.
|
*
|
||||||
*
|
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
*
|
||||||
*
|
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
*/
|
||||||
*/
|
open class ZiplinePluginService(
|
||||||
open class ZiplinePluginService(
|
val applicationName: String,
|
||||||
val applicationName: String,
|
private val manifestUrl: String,
|
||||||
private val manifestUrl: String,
|
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||||
private val pluginInfo: PluginEntry,
|
|
||||||
) : PluginService, KoinComponent {
|
override val pluginId: String = pluginInfo.id
|
||||||
|
|
||||||
override val pluginId: String = pluginInfo.id
|
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||||
|
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
// instance is created, before any modules are loaded.
|
||||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
private val ziplineDispatcher = createZiplineDispatcher()
|
||||||
// instance is created, before any modules are loaded.
|
|
||||||
private val ziplineDispatcher = createZiplineDispatcher()
|
private fun trace(event: String) {
|
||||||
|
logger.d { "[$applicationName] $event" }
|
||||||
private fun trace(event: String) {
|
}
|
||||||
logger.d { "[$applicationName] $event" }
|
|
||||||
}
|
private val logger by injectLogger<ZiplinePluginService>()
|
||||||
|
private val webViewController: WebViewController by inject()
|
||||||
private val logger by injectLogger<ZiplinePluginService>()
|
|
||||||
private val webViewController: WebViewController by inject()
|
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||||
|
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
logger.e(throwable) { "Zipline Engine Error" }
|
||||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
}
|
||||||
logger.e(throwable) { "Zipline Engine Error" }
|
private val lifecycleMutex = Mutex()
|
||||||
}
|
private var ziplineLoader: ZiplineLoader
|
||||||
private val lifecycleMutex = Mutex()
|
private var ziplineInstance: Zipline? = null
|
||||||
private var ziplineLoader: ZiplineLoader
|
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||||
private var ziplineInstance: Zipline? = null
|
|
||||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
init {
|
||||||
|
val manifestPath = URLBuilder(manifestUrl)
|
||||||
init {
|
val baseDir =
|
||||||
val manifestPath = URLBuilder(manifestUrl)
|
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||||
val baseDir =
|
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
ziplineLoader = ZiplineLoader(
|
||||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
dispatcher = ziplineDispatcher.dispatcher,
|
||||||
ziplineLoader = ZiplineLoader(
|
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||||
dispatcher = ziplineDispatcher.dispatcher,
|
httpClient = FileSystemHTTPClient(baseDir)
|
||||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
)
|
||||||
httpClient = FileSystemHTTPClient(baseDir)
|
}
|
||||||
)
|
|
||||||
}
|
private val realHttpClientAPI = RealHttpClientAPI()
|
||||||
|
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||||
private val realHttpClientAPI = RealHttpClientAPI()
|
|
||||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||||
|
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
private val systemInformationAPI = RealSystemInformationAPI()
|
||||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
|
||||||
private val systemInformationAPI = RealSystemInformationAPI()
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
|
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
|
||||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
private fun bindHostServices(zipline: Zipline) {
|
||||||
|
trace("initializer(): binding host APIs")
|
||||||
private fun bindHostServices(zipline: Zipline) {
|
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||||
trace("initializer(): binding host APIs")
|
try {
|
||||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
// Basic APIs
|
||||||
try {
|
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||||
// Basic APIs
|
zipline.bind<SystemInformationAPI>(
|
||||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
SystemInformationAPI_SERVICE_NAME,
|
||||||
zipline.bind<SystemInformationAPI>(
|
systemInformationAPI
|
||||||
SystemInformationAPI_SERVICE_NAME,
|
)
|
||||||
systemInformationAPI
|
|
||||||
)
|
// Conditional APIs based on plugin capabilities
|
||||||
|
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||||
// Conditional APIs based on plugin capabilities
|
zipline.bind<HttpClientAPI>(
|
||||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
HttpClientAPI_SERVICE_NAME,
|
||||||
zipline.bind<HttpClientAPI>(
|
realHttpClientAPI
|
||||||
HttpClientAPI_SERVICE_NAME,
|
)
|
||||||
realHttpClientAPI
|
}
|
||||||
)
|
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||||
}
|
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
}
|
||||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||||
}
|
zipline.bind<PersistedStorageAPI>(
|
||||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
PersistedStorageAPI_SERVICE_NAME,
|
||||||
zipline.bind<PersistedStorageAPI>(
|
persistedStorageAPI
|
||||||
PersistedStorageAPI_SERVICE_NAME,
|
)
|
||||||
persistedStorageAPI
|
}
|
||||||
)
|
} catch (e: Exception) {
|
||||||
}
|
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||||
} catch (e: Exception) {
|
throw e
|
||||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
}
|
||||||
throw e
|
}
|
||||||
}
|
|
||||||
}
|
private fun consumePluginServices(result: LoadResult.Success) {
|
||||||
|
trace("start(): loadOnce success")
|
||||||
private fun consumePluginServices(result: LoadResult.Success) {
|
val apiMap =
|
||||||
trace("start(): loadOnce success")
|
buildMap<KClass<*>, ZiplineService> {
|
||||||
val apiMap =
|
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||||
buildMap<KClass<*>, ZiplineService> {
|
|
||||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||||
|
put(
|
||||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
MetadataUserAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataUserAPI>(
|
||||||
MetadataUserAPI::class,
|
MetadataUserAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataUserAPI>(
|
)
|
||||||
MetadataUserAPI_SERVICE_NAME
|
)
|
||||||
)
|
put(
|
||||||
)
|
MetadataTrackAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataTrackAPI>(
|
||||||
MetadataTrackAPI::class,
|
MetadataTrackAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataTrackAPI>(
|
)
|
||||||
MetadataTrackAPI_SERVICE_NAME
|
)
|
||||||
)
|
put(
|
||||||
)
|
MetadataAlbumAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataAlbumAPI>(
|
||||||
MetadataAlbumAPI::class,
|
MetadataAlbumAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataAlbumAPI>(
|
)
|
||||||
MetadataAlbumAPI_SERVICE_NAME
|
)
|
||||||
)
|
put(
|
||||||
)
|
MetadataArtistAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataArtistAPI>(
|
||||||
MetadataArtistAPI::class,
|
MetadataArtistAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataArtistAPI>(
|
)
|
||||||
MetadataArtistAPI_SERVICE_NAME
|
)
|
||||||
)
|
put(
|
||||||
)
|
MetadataPlaylistAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataPlaylistAPI>(
|
||||||
MetadataPlaylistAPI::class,
|
MetadataPlaylistAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataPlaylistAPI>(
|
)
|
||||||
MetadataPlaylistAPI_SERVICE_NAME
|
)
|
||||||
)
|
put(
|
||||||
)
|
MetadataBrowseAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataBrowseAPI>(
|
||||||
MetadataBrowseAPI::class,
|
MetadataBrowseAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataBrowseAPI>(
|
)
|
||||||
MetadataBrowseAPI_SERVICE_NAME
|
)
|
||||||
)
|
put(
|
||||||
)
|
MetadataSearchAPI::class,
|
||||||
put(
|
result.zipline.take<MetadataSearchAPI>(
|
||||||
MetadataSearchAPI::class,
|
MetadataSearchAPI_SERVICE_NAME
|
||||||
result.zipline.take<MetadataSearchAPI>(
|
)
|
||||||
MetadataSearchAPI_SERVICE_NAME
|
)
|
||||||
)
|
}
|
||||||
)
|
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||||
}
|
put(
|
||||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
AudioAPI::class,
|
||||||
put(
|
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||||
AudioAPI::class,
|
)
|
||||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
}
|
||||||
)
|
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||||
}
|
put(
|
||||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
LyricsAPI::class,
|
||||||
put(
|
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||||
LyricsAPI::class,
|
)
|
||||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
}
|
||||||
)
|
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||||
}
|
put(
|
||||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
ScrobbleAPI::class,
|
||||||
put(
|
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||||
ScrobbleAPI::class,
|
)
|
||||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
}
|
||||||
)
|
}
|
||||||
}
|
|
||||||
}
|
serviceRegistry.putAll(apiMap)
|
||||||
|
trace("start(): API ready")
|
||||||
serviceRegistry.putAll(apiMap)
|
}
|
||||||
trace("start(): API ready")
|
|
||||||
}
|
private fun runLogInFlowObservers() = scope.launch {
|
||||||
|
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||||
private fun runLogInFlowObservers() = scope.launch {
|
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
loggedInStateFlow.value = isLoggedIn
|
||||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
}
|
||||||
loggedInStateFlow.value = isLoggedIn
|
}
|
||||||
}
|
|
||||||
}
|
override suspend fun start() {
|
||||||
|
lifecycleMutex.withLock {
|
||||||
override suspend fun start() {
|
trace("start(): entered")
|
||||||
lifecycleMutex.withLock {
|
if (serviceRegistry.isNotEmpty()) {
|
||||||
trace("start(): entered")
|
trace("start(): already started, skipping")
|
||||||
if (serviceRegistry.isNotEmpty()) {
|
return
|
||||||
trace("start(): already started, skipping")
|
}
|
||||||
return
|
|
||||||
}
|
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||||
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
trace("start(): inside zipline dispatcher before loadOnce")
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
val result = ziplineLoader.loadOnce(
|
||||||
trace("start(): inside zipline dispatcher before loadOnce")
|
applicationName = applicationName,
|
||||||
val result = ziplineLoader.loadOnce(
|
manifestUrl = manifestUrl,
|
||||||
applicationName = applicationName,
|
freshnessChecker = DefaultFreshnessCheckerNotFresh)
|
||||||
manifestUrl = manifestUrl,
|
when (result) {
|
||||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
is LoadResult.Success -> {
|
||||||
)
|
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||||
when (result) {
|
ziplineInstance = result.zipline
|
||||||
is LoadResult.Success -> {
|
// Now we consume the initializer
|
||||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||||
ziplineInstance = result.zipline
|
// Bind host services before initialization, so plugins can use them in their initializer
|
||||||
// Now we consume the initializer
|
bindHostServices(result.zipline)
|
||||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
trace("start(): calling initializer.initialize()")
|
||||||
// Bind host services before initialization, so plugins can use them in their initializer
|
runCatching { initializer.initialize() }
|
||||||
bindHostServices(result.zipline)
|
.onSuccess {
|
||||||
trace("start(): calling initializer.initialize()")
|
consumePluginServices(result)
|
||||||
runCatching { initializer.initialize() }
|
runLogInFlowObservers()
|
||||||
.onSuccess {
|
}
|
||||||
consumePluginServices(result)
|
.onFailure { e ->
|
||||||
runLogInFlowObservers()
|
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||||
}
|
throw e
|
||||||
.onFailure { e ->
|
}
|
||||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
}
|
||||||
throw e
|
|
||||||
}
|
is LoadResult.Failure -> {
|
||||||
}
|
trace("start(): loadOnce failure: ${result.exception}")
|
||||||
|
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||||
is LoadResult.Failure -> {
|
throw result.exception
|
||||||
trace("start(): loadOnce failure: ${result.exception}")
|
}
|
||||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
}
|
||||||
throw result.exception
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
override suspend fun stop() {
|
||||||
}
|
lifecycleMutex.withLock {
|
||||||
|
trace("stop(): entered")
|
||||||
override suspend fun stop() {
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
lifecycleMutex.withLock {
|
ziplineInstance?.close()
|
||||||
trace("stop(): entered")
|
ziplineInstance = null
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
for (service in serviceRegistry.values) {
|
||||||
ziplineInstance?.close()
|
try {
|
||||||
ziplineInstance = null
|
trace("stop(): closing service ${service::class.simpleName}")
|
||||||
for (service in serviceRegistry.values) {
|
service.close()
|
||||||
try {
|
} catch (_: Exception) {
|
||||||
trace("stop(): closing service ${service::class.simpleName}")
|
trace("stop(): error closing service ${service::class.simpleName}")
|
||||||
service.close()
|
}
|
||||||
} catch (_: Exception) {
|
}
|
||||||
trace("stop(): error closing service ${service::class.simpleName}")
|
}
|
||||||
}
|
serviceRegistry.clear()
|
||||||
}
|
scope.cancel()
|
||||||
}
|
loggedInStateFlow.value = false
|
||||||
serviceRegistry.clear()
|
ziplineDispatcher.close()
|
||||||
scope.cancel()
|
trace("stop(): completed")
|
||||||
loggedInStateFlow.value = false
|
}
|
||||||
ziplineDispatcher.close()
|
}
|
||||||
trace("stop(): completed")
|
|
||||||
}
|
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||||
}
|
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||||
|
// Create the scope with the current registry
|
||||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
val scope = PluginServiceScope(serviceRegistry)
|
||||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
// Execute the block with 'scope' as 'this'
|
||||||
// Create the scope with the current registry
|
scope.block()
|
||||||
val scope = PluginServiceScope(serviceRegistry)
|
}
|
||||||
// Execute the block with 'scope' as 'this'
|
}
|
||||||
scope.block()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||||
private val provider = CryptographyProvider.Default
|
private val provider = CryptographyProvider.Default
|
||||||
|
|
||||||
@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
return CryptographyRandom.nextBytes(size)
|
return CryptographyRandom.nextBytes(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
||||||
val hasher = provider.get(algorithm.algorithm()).hasher()
|
val hasher = provider.get(algorithm.algorithm()).hasher()
|
||||||
return hasher.hash(data)
|
return hasher.hash(data)
|
||||||
@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
||||||
algorithm.keySizeBits.bits,
|
algorithm.keySizeBits.bits,
|
||||||
algorithm.hashAlgorithm.algorithm(),
|
algorithm.hashAlgorithm.algorithm(),
|
||||||
algorithm.publicExponent.toBigInt(),
|
algorithm.publicExponent.toBigInt()).generateKey()
|
||||||
).generateKey()
|
|
||||||
|
|
||||||
Pair(
|
Pair(
|
||||||
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
||||||
@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.ECDSA ->
|
is SignAlgorithms.ECDSA ->
|
||||||
provider.get(ECDSA).privateKeyDecoder(
|
provider.get(ECDSA).privateKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.privateKeyFormat(),
|
algorithm.format.privateKeyFormat(),
|
||||||
privateKey
|
privateKey
|
||||||
).signatureGenerator(
|
).signatureGenerator(
|
||||||
@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.EdDSA ->
|
is SignAlgorithms.EdDSA ->
|
||||||
provider.get(EdDSA).privateKeyDecoder(
|
provider.get(EdDSA).privateKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.privateKeyFormat(),
|
algorithm.format.privateKeyFormat(),
|
||||||
privateKey
|
privateKey
|
||||||
).signatureGenerator().generateSignature(data)
|
).signatureGenerator().generateSignature(data)
|
||||||
@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.ECDSA ->
|
is SignAlgorithms.ECDSA ->
|
||||||
provider.get(ECDSA).publicKeyDecoder(
|
provider.get(ECDSA).publicKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.publicKeyFormat(),
|
algorithm.format.publicKeyFormat(),
|
||||||
publicKey
|
publicKey
|
||||||
).signatureVerifier(
|
).signatureVerifier(
|
||||||
@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.EdDSA ->
|
is SignAlgorithms.EdDSA ->
|
||||||
provider.get(EdDSA).publicKeyDecoder(
|
provider.get(EdDSA).publicKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.publicKeyFormat(),
|
algorithm.format.publicKeyFormat(),
|
||||||
publicKey
|
publicKey
|
||||||
).signatureVerifier().verifySignature(
|
).signatureVerifier().verifySignature(
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText
|
|||||||
import io.ktor.http.takeFrom
|
import io.ktor.http.takeFrom
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
|
|
||||||
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
||||||
val logger by injectLogger<RealHttpClientAPI>()
|
val logger by injectLogger<RealHttpClientAPI>()
|
||||||
val httpClient = HttpClient()
|
val httpClient = HttpClient()
|
||||||
|
|||||||
@ -1,74 +1,73 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class RealWebViewAPI(
|
class RealWebViewAPI(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val webViewController: WebViewController,
|
private val webViewController: WebViewController,
|
||||||
private val pluginId: String,
|
private val pluginId: String) : WebViewAPI {
|
||||||
) : WebViewAPI {
|
override fun navigateTo(url: String) {
|
||||||
override fun navigateTo(url: String) {
|
scope.launch(Dispatchers.Main) {
|
||||||
scope.launch(Dispatchers.Main) {
|
webViewController.navigateTo(url, pluginId)
|
||||||
webViewController.navigateTo(url, pluginId)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun navigateToHTML(html: String) {
|
||||||
override fun navigateToHTML(html: String) {
|
scope.launch {
|
||||||
scope.launch {
|
webViewController.navigateToHTML(html, pluginId)
|
||||||
webViewController.navigateToHTML(html, pluginId)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override suspend fun getCookies(url: String): List<Cookie> {
|
||||||
override suspend fun getCookies(url: String): List<Cookie> {
|
return withContext(Dispatchers.Main) {
|
||||||
return withContext(Dispatchers.Main) {
|
webViewController.getCookies(url)
|
||||||
webViewController.getCookies(url)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override suspend fun evaluateJavaScript(script: String): String? {
|
||||||
override suspend fun evaluateJavaScript(script: String): String? {
|
return withContext(Dispatchers.Main) {
|
||||||
return withContext(Dispatchers.Main) {
|
webViewController.evaluateJavascript(script)
|
||||||
webViewController.evaluateJavascript(script)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
override fun urlChangeFlow(): Flow<String> {
|
||||||
override fun urlChangeFlow(): Flow<String> {
|
return webViewController.urlChangedFlow
|
||||||
return webViewController.urlChangedFlow
|
}
|
||||||
}
|
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
return webViewController.webviewCreatedFlow
|
||||||
return webViewController.webviewCreatedFlow
|
}
|
||||||
}
|
override fun postMessagesFlow(): Flow<String> {
|
||||||
override fun postMessagesFlow(): Flow<String> {
|
return webViewController.postMessagesFlow
|
||||||
return webViewController.postMessagesFlow
|
}
|
||||||
}
|
|
||||||
|
override fun exitWebView() {
|
||||||
override fun exitWebView() {
|
scope.launch(Dispatchers.Main) {
|
||||||
scope.launch(Dispatchers.Main) {
|
webViewController.closeWebview()
|
||||||
webViewController.closeWebview()
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -45,8 +45,7 @@ data class LRCLibResponse(
|
|||||||
val artistName: String,
|
val artistName: String,
|
||||||
val albumName: String,
|
val albumName: String,
|
||||||
val plainLyrics: String?,
|
val plainLyrics: String?,
|
||||||
val syncedLyrics: String?,
|
val syncedLyrics: String?)
|
||||||
)
|
|
||||||
|
|
||||||
class RealLRCLibLyricsAPI : LyricsAPI {
|
class RealLRCLibLyricsAPI : LyricsAPI {
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI {
|
|||||||
val text = line.substring(match.range.last + 1).trim()
|
val text = line.substring(match.range.last + 1).trim()
|
||||||
LyricsLine(time = timeMs, text = text)
|
LyricsLine(time = timeMs, text = text)
|
||||||
},
|
},
|
||||||
plainLyrics = response.plainLyrics,
|
plainLyrics = response.plainLyrics)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,16 +124,14 @@ data class MusicGatewayResponse(
|
|||||||
val externalIds: ExternalIds,
|
val externalIds: ExternalIds,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val popularity: Int,
|
val popularity: Int) {
|
||||||
) {
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Album(
|
data class Album(
|
||||||
@SerialName("album_type")
|
@SerialName("album_type")
|
||||||
val albumType: String,
|
val albumType: String,
|
||||||
val artists: List<Artist>,
|
val artists: List<Artist>,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String)
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Artist(
|
data class Artist(
|
||||||
|
|||||||
@ -57,7 +57,6 @@ class ISRCProviders {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private var ifpiSessionToken: String? = null
|
private var ifpiSessionToken: String? = null
|
||||||
private val ifpiDefaultHeaders = mapOf(
|
private val ifpiDefaultHeaders = mapOf(
|
||||||
"Content-Type" to "application/json",
|
"Content-Type" to "application/json",
|
||||||
@ -76,7 +75,6 @@ class ISRCProviders {
|
|||||||
"User-Agent" to UserAgents.random()
|
"User-Agent" to UserAgents.random()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
|
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
|
||||||
if (ifpiSessionToken != null) return@withContext ifpiSessionToken
|
if (ifpiSessionToken != null) return@withContext ifpiSessionToken
|
||||||
|
|
||||||
|
|||||||
@ -65,12 +65,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
Thumbnail(
|
Thumbnail(
|
||||||
url = video.thumbnailUrl,
|
url = video.thumbnailUrl,
|
||||||
width = 300,
|
width = 300,
|
||||||
height = 300,
|
height = 300)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
externalUri = "https://www.youtube.com/watch?v=${video.id}",
|
externalUri = "https://www.youtube.com/watch?v=${video.id}",
|
||||||
confidence = confidence,
|
confidence = confidence)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override val supportedQualities: List<AudioFormat> = listOf(
|
override val supportedQualities: List<AudioFormat> = listOf(
|
||||||
@ -79,15 +77,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
AudioQuality.Lossy(bitrate = 44_000),
|
AudioQuality.Lossy(bitrate = 44_000),
|
||||||
AudioQuality.Lossy(bitrate = 96_000),
|
AudioQuality.Lossy(bitrate = 96_000),
|
||||||
AudioQuality.Lossy(bitrate = 128_000),
|
AudioQuality.Lossy(bitrate = 128_000),
|
||||||
AudioQuality.Lossy(bitrate = 256_000),
|
AudioQuality.Lossy(bitrate = 256_000))
|
||||||
)
|
|
||||||
), AudioFormat(
|
), AudioFormat(
|
||||||
codec = "aac", container = "mp4", qualities = listOf(
|
codec = "aac", container = "mp4", qualities = listOf(
|
||||||
AudioQuality.Lossy(bitrate = 44_000),
|
AudioQuality.Lossy(bitrate = 44_000),
|
||||||
AudioQuality.Lossy(bitrate = 96_000),
|
AudioQuality.Lossy(bitrate = 96_000),
|
||||||
AudioQuality.Lossy(bitrate = 128_000),
|
AudioQuality.Lossy(bitrate = 128_000),
|
||||||
AudioQuality.Lossy(bitrate = 256_000),
|
AudioQuality.Lossy(bitrate = 256_000))
|
||||||
)
|
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -98,7 +94,6 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
))
|
))
|
||||||
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
val isYouTubeID = track.id.matches(youtubeIDRegex)
|
||||||
|
|
||||||
|
|
||||||
logger.i {
|
logger.i {
|
||||||
"Searching streams for track(${track.id}): ${track.title} by ${
|
"Searching streams for track(${track.id}): ${track.title} by ${
|
||||||
track.artists.joinToString(
|
track.artists.joinToString(
|
||||||
@ -119,13 +114,11 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
Thumbnail(
|
Thumbnail(
|
||||||
url = videoInfo.thumbnailUrl,
|
url = videoInfo.thumbnailUrl,
|
||||||
width = 300,
|
width = 300,
|
||||||
height = 300,
|
height = 300)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
||||||
confidence = 1.0f,
|
confidence = 1.0f,
|
||||||
streams = videoInfo.audioStreams,
|
streams = videoInfo.audioStreams)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,8 +158,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
|||||||
Thumbnail(
|
Thumbnail(
|
||||||
url = videoInfo.thumbnailUrl,
|
url = videoInfo.thumbnailUrl,
|
||||||
width = 300,
|
width = 300,
|
||||||
height = 300,
|
height = 300)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
||||||
confidence = source.confidence,
|
confidence = source.confidence,
|
||||||
|
|||||||
@ -36,8 +36,7 @@ import kotlinx.coroutines.launch
|
|||||||
@OptIn(ExperimentalCoroutinesApi::class)
|
@OptIn(ExperimentalCoroutinesApi::class)
|
||||||
class AlbumRepository(
|
class AlbumRepository(
|
||||||
val pluginManager: PluginManager,
|
val pluginManager: PluginManager,
|
||||||
private val libraryRepository: LibraryRepository,
|
private val libraryRepository: LibraryRepository) {
|
||||||
) {
|
|
||||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||||
|
|
||||||
val plugin
|
val plugin
|
||||||
|
|||||||
@ -110,8 +110,6 @@ fun AlbumScreen(
|
|||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
onPlaylistSelected = viewModel::addTracksToPlaylist)
|
||||||
)
|
})
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -65,15 +65,13 @@ sealed interface AlbumScreenState {
|
|||||||
override val isSaved: Boolean = false,
|
override val isSaved: Boolean = false,
|
||||||
override val tracks: List<MetadataTrack> = emptyList(),
|
override val tracks: List<MetadataTrack> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null,
|
override val nextPagination: PaginationStrategy? = null,
|
||||||
override val isSaving: Boolean = false,
|
override val isSaving: Boolean = false) : Data {
|
||||||
) : Data {
|
|
||||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||||
album = album,
|
album = album,
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
tracks = tracks,
|
tracks = tracks,
|
||||||
nextPagination = nextPagination,
|
nextPagination = nextPagination,
|
||||||
isSaving = isSaving,
|
isSaving = isSaving)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class LoadingMore(
|
data class LoadingMore(
|
||||||
@ -81,8 +79,7 @@ sealed interface AlbumScreenState {
|
|||||||
override val isSaved: Boolean = false,
|
override val isSaved: Boolean = false,
|
||||||
override val tracks: List<MetadataTrack> = emptyList(),
|
override val tracks: List<MetadataTrack> = emptyList(),
|
||||||
override val nextPagination: PaginationStrategy? = null,
|
override val nextPagination: PaginationStrategy? = null,
|
||||||
override val isSaving: Boolean = false,
|
override val isSaving: Boolean = false) : Data
|
||||||
) : Data
|
|
||||||
}
|
}
|
||||||
|
|
||||||
data class Error(val message: String) : AlbumScreenState
|
data class Error(val message: String) : AlbumScreenState
|
||||||
@ -99,8 +96,7 @@ class AlbumViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
private val remotePlaybackController: RemotePlaybackController,
|
private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent {
|
||||||
) : ViewModel(), KoinComponent {
|
|
||||||
private val logger by injectLogger<AlbumViewModel>()
|
private val logger by injectLogger<AlbumViewModel>()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading)
|
private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading)
|
||||||
@ -126,8 +122,7 @@ class AlbumViewModel(
|
|||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
savedTracksRepository.savedTracksIdsFlow,
|
savedTracksRepository.savedTracksIdsFlow,
|
||||||
_blacklistedTrackIds,
|
_blacklistedTrackIds,
|
||||||
_blacklistedArtistIds,
|
_blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||||
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
|
||||||
TrackOptionsContext(
|
TrackOptionsContext(
|
||||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
queueTrackIds = queue.mapNotNull { entry ->
|
queueTrackIds = queue.mapNotNull { entry ->
|
||||||
@ -135,8 +130,7 @@ class AlbumViewModel(
|
|||||||
}.toSet(),
|
}.toSet(),
|
||||||
savedTrackIds = savedIds,
|
savedTrackIds = savedIds,
|
||||||
blacklistedTrackIds = blacklistedTracks,
|
blacklistedTrackIds = blacklistedTracks,
|
||||||
blacklistedArtistIds = blacklistedArtists,
|
blacklistedArtistIds = blacklistedArtists)
|
||||||
)
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -175,8 +169,7 @@ class AlbumViewModel(
|
|||||||
album = albumInfo?.first,
|
album = albumInfo?.first,
|
||||||
isSaved = albumInfo?.second ?: false,
|
isSaved = albumInfo?.second ?: false,
|
||||||
tracks = tracksResult?.items ?: emptyList(),
|
tracks = tracksResult?.items ?: emptyList(),
|
||||||
nextPagination = tracksResult?.nextPagination,
|
nextPagination = tracksResult?.nextPagination)
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load album" }
|
logger.e(e) { "Failed to load album" }
|
||||||
_state.value = AlbumScreenState.Error(e.message ?: "Unknown error")
|
_state.value = AlbumScreenState.Error(e.message ?: "Unknown error")
|
||||||
@ -193,8 +186,7 @@ class AlbumViewModel(
|
|||||||
isSaved = currentState.isSaved,
|
isSaved = currentState.isSaved,
|
||||||
tracks = currentState.tracks + (result?.items ?: emptyList()),
|
tracks = currentState.tracks + (result?.items ?: emptyList()),
|
||||||
nextPagination = result?.nextPagination,
|
nextPagination = result?.nextPagination,
|
||||||
isSaving = currentState.isSaving,
|
isSaving = currentState.isSaving)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more tracks" }
|
logger.e(e) { "Failed to load more tracks" }
|
||||||
@ -245,8 +237,7 @@ class AlbumViewModel(
|
|||||||
type = RemoteCollectionType.Album,
|
type = RemoteCollectionType.Album,
|
||||||
id = albumId,
|
id = albumId,
|
||||||
title = title,
|
title = title,
|
||||||
startTrack = track,
|
startTrack = track)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun refresh() {
|
fun refresh() {
|
||||||
@ -319,8 +310,7 @@ class AlbumViewModel(
|
|||||||
runCatching {
|
runCatching {
|
||||||
libraryRepository.addTracksToPlaylist(
|
libraryRepository.addTracksToPlaylist(
|
||||||
playlistId,
|
playlistId,
|
||||||
_tracksToAddToPlaylist.value.map { it.id },
|
_tracksToAddToPlaylist.value.map { it.id })
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to add tracks to playlist" }
|
logger.e(e) { "Failed to add tracks to playlist" }
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
|
|||||||
@ -17,6 +17,15 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.modules.artist
|
package dev.krtirtho.spotube.modules.artist
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
|
import org.spotube.millennium.composables.MillenniumTooltip
|
||||||
|
import org.spotube.millennium.theme.MillenniumTheme
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
@ -35,19 +44,9 @@ import androidx.compose.foundation.lazy.LazyRow
|
|||||||
import androidx.compose.foundation.lazy.items
|
import androidx.compose.foundation.lazy.items
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
|
||||||
import androidx.compose.material3.Card
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.CardDefaults
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.PlainTooltip
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.material3.TooltipAnchorPosition
|
|
||||||
import androidx.compose.material3.TooltipBox
|
|
||||||
import androidx.compose.material3.TooltipDefaults
|
|
||||||
import androidx.compose.material3.rememberTooltipState
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@ -74,10 +73,6 @@ import dev.krtirtho.spotube.core.jam.JamRoomService
|
|||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
import dev.krtirtho.spotube.core.navigation.Routes
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
import dev.krtirtho.spotube.core.ui.component.AlbumCard
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||||
@ -118,17 +113,19 @@ fun ArtistScreen(
|
|||||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
BaseScaffold(
|
Column(
|
||||||
topBar = { ApplicationMainBar() }
|
modifier = Modifier
|
||||||
) { innerPadding ->
|
.fillMaxSize()
|
||||||
|
.background(MillenniumTheme.colors.background)) {
|
||||||
|
ApplicationMainBar()
|
||||||
|
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||||
when (val currentState = state) {
|
when (val currentState = state) {
|
||||||
is ArtistScreenState.Loading -> {
|
is ArtistScreenState.Loading -> {
|
||||||
ArtistLoadingContent(innerPadding)
|
ArtistLoadingContent()
|
||||||
}
|
}
|
||||||
|
|
||||||
is ArtistScreenState.Error -> {
|
is ArtistScreenState.Error -> {
|
||||||
ArtistErrorContent(
|
ArtistErrorContent(
|
||||||
innerPadding = innerPadding,
|
|
||||||
message = currentState.message,
|
message = currentState.message,
|
||||||
onRetry = { viewModel.refresh() }
|
onRetry = { viewModel.refresh() }
|
||||||
)
|
)
|
||||||
@ -141,25 +138,21 @@ fun ArtistScreen(
|
|||||||
state = listState,
|
state = listState,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.padding(innerPadding)
|
|
||||||
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
|
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||||
) {
|
|
||||||
item {
|
item {
|
||||||
ArtistHeaderCard(
|
ArtistHeaderCard(
|
||||||
artist = currentState.artist,
|
artist = currentState.artist,
|
||||||
isSaved = savedArtistIds.contains(currentState.artist.id),
|
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||||
onFollowClick = viewModel::toggleSavedArtist,
|
onFollowClick = viewModel::toggleSavedArtist,
|
||||||
isBlacklisted = currentState.artist.id in blacklistedArtistIds,
|
isBlacklisted = currentState.artist.id in blacklistedArtistIds,
|
||||||
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) },
|
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
TopTracksHeader(
|
TopTracksHeader(
|
||||||
onPlay = viewModel::playTopTracks,
|
onPlay = viewModel::playTopTracks,
|
||||||
onAddToQueue = viewModel::addTopTracksToQueue,
|
onAddToQueue = viewModel::addTopTracksToQueue)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
item {
|
item {
|
||||||
@ -197,8 +190,7 @@ fun ArtistScreen(
|
|||||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||||
onBulkAddToJam = viewModel::addTracksToJam,
|
onBulkAddToJam = viewModel::addTracksToJam,
|
||||||
isInJam = jamActive,
|
isInJam = jamActive,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) {
|
if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) {
|
||||||
@ -212,8 +204,7 @@ fun ArtistScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLoadMore = { viewModel.loadMoreAlbums() },
|
onLoadMore = { viewModel.loadMoreAlbums() })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -228,8 +219,7 @@ fun ArtistScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLoadMore = { viewModel.loadMoreRelatedArtists() },
|
onLoadMore = { viewModel.loadMoreRelatedArtists() })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -244,73 +234,62 @@ fun ArtistScreen(
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() },
|
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AddToPlaylistPicker(
|
AddToPlaylistPicker(
|
||||||
visible = showAddToPlaylistPicker,
|
visible = showAddToPlaylistPicker,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
onPlaylistSelected = viewModel::addTracksToPlaylist)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistLoadingContent(innerPadding: PaddingValues) {
|
private fun ArtistLoadingContent() {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize(),
|
||||||
.padding(innerPadding),
|
contentAlignment = Alignment.Center) {
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
SkeletonTree(true) {
|
SkeletonTree(true) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp)) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(200.dp)
|
.size(200.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
.background(AppTheme.colors.surfaceContainerHighest))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp)) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
LazyRow(
|
LazyRow(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||||
) {
|
|
||||||
items(4) {
|
items(4) {
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
title = "Item Title",
|
title = "Item Title",
|
||||||
subtitle = "Subtitle",
|
subtitle = "Subtitle",
|
||||||
imageURL = "https://placehold.co/600x400",
|
imageURL = "https://placehold.co/600x400")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -321,26 +300,20 @@ private fun ArtistLoadingContent(innerPadding: PaddingValues) {
|
|||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistErrorContent(
|
private fun ArtistErrorContent(
|
||||||
innerPadding: PaddingValues,
|
|
||||||
message: String,
|
message: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize(),
|
||||||
.padding(innerPadding),
|
contentAlignment = Alignment.Center) {
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = message,
|
text = message,
|
||||||
color = MaterialTheme.colorScheme.error,
|
color = AppTheme.colors.error,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge)
|
||||||
)
|
MillenniumButton(onClick = onRetry) {
|
||||||
PrimaryButton(onClick = onRetry) {
|
|
||||||
Text("Retry")
|
Text("Retry")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -353,44 +326,34 @@ private fun ArtistHeaderCard(
|
|||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
isBlacklisted: Boolean,
|
isBlacklisted: Boolean,
|
||||||
onBlacklistClick: () -> Unit,
|
onBlacklistClick: () -> Unit) {
|
||||||
) {
|
|
||||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||||
val isCompact = maxWidth < 600.dp
|
val isCompact = maxWidth < 600.dp
|
||||||
|
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||||
colors = CardDefaults.cardColors(
|
|
||||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
|
||||||
),
|
|
||||||
shape = RoundedCornerShape(20.dp),
|
|
||||||
) {
|
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
) {
|
|
||||||
ArtistAvatar(
|
ArtistAvatar(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
size = 180.dp,
|
size = 180.dp)
|
||||||
)
|
|
||||||
|
|
||||||
ArtistMeta(
|
ArtistMeta(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
isCompact = true,
|
isCompact = true)
|
||||||
)
|
|
||||||
|
|
||||||
ArtistHeaderActions(
|
ArtistHeaderActions(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
onFollowClick = onFollowClick,
|
onFollowClick = onFollowClick,
|
||||||
isBlacklisted = isBlacklisted,
|
isBlacklisted = isBlacklisted,
|
||||||
onBlacklistClick = onBlacklistClick,
|
onBlacklistClick = onBlacklistClick)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(
|
Row(
|
||||||
@ -398,28 +361,23 @@ private fun ArtistHeaderCard(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(20.dp),
|
.padding(20.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top) {
|
||||||
) {
|
|
||||||
ArtistAvatar(
|
ArtistAvatar(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
size = 220.dp,
|
size = 220.dp)
|
||||||
)
|
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||||
) {
|
|
||||||
ArtistMeta(
|
ArtistMeta(
|
||||||
artist = artist,
|
artist = artist,
|
||||||
isCompact = false,
|
isCompact = false)
|
||||||
)
|
|
||||||
|
|
||||||
ArtistHeaderActions(
|
ArtistHeaderActions(
|
||||||
isSaved = isSaved,
|
isSaved = isSaved,
|
||||||
onFollowClick = onFollowClick,
|
onFollowClick = onFollowClick,
|
||||||
isBlacklisted = isBlacklisted,
|
isBlacklisted = isBlacklisted,
|
||||||
onBlacklistClick = onBlacklistClick,
|
onBlacklistClick = onBlacklistClick)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -430,30 +388,26 @@ private fun ArtistHeaderCard(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistAvatar(
|
private fun ArtistAvatar(
|
||||||
artist: MetadataArtist.Detailed,
|
artist: MetadataArtist.Detailed,
|
||||||
size: androidx.compose.ui.unit.Dp,
|
size: androidx.compose.ui.unit.Dp) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(size)
|
.size(size)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
.background(AppTheme.colors.surfaceContainerHighest),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
|
val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
|
||||||
if (imageUrl.isNotBlank()) {
|
if (imageUrl.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageUrl,
|
model = imageUrl,
|
||||||
contentDescription = artist.name,
|
contentDescription = artist.name,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.User,
|
imageVector = Iconsax.User,
|
||||||
contentDescription = artist.name,
|
contentDescription = artist.name,
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = AppTheme.colors.onSurfaceVariant,
|
||||||
modifier = Modifier.size(size * 0.4f),
|
modifier = Modifier.size(size * 0.4f))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -461,21 +415,18 @@ private fun ArtistAvatar(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ArtistMeta(
|
private fun ArtistMeta(
|
||||||
artist: MetadataArtist.Detailed,
|
artist: MetadataArtist.Detailed,
|
||||||
isCompact: Boolean,
|
isCompact: Boolean) {
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
|
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
|
||||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = artist.name,
|
text = artist.name,
|
||||||
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge,
|
style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = buildString {
|
text = buildString {
|
||||||
@ -485,74 +436,66 @@ private fun ArtistMeta(
|
|||||||
append(artist.genres.joinToString(", "))
|
append(artist.genres.joinToString(", "))
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start)
|
||||||
)
|
|
||||||
|
|
||||||
artist.biography?.takeIf { it.isNotBlank() }?.let {
|
artist.biography?.takeIf { it.isNotBlank() }?.let {
|
||||||
Text(
|
Text(
|
||||||
text = it,
|
text = it,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = if (isCompact) 4 else 6,
|
maxLines = if (isCompact) 4 else 6,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistHeaderActions(
|
private fun ArtistHeaderActions(
|
||||||
isSaved: Boolean,
|
isSaved: Boolean,
|
||||||
onFollowClick: () -> Unit,
|
onFollowClick: () -> Unit,
|
||||||
isBlacklisted: Boolean,
|
isBlacklisted: Boolean,
|
||||||
onBlacklistClick: () -> Unit,
|
onBlacklistClick: () -> Unit) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
if (isSaved) {
|
if (isSaved) {
|
||||||
SecondaryButton(onClick = onFollowClick) {
|
MillenniumButton(
|
||||||
|
onClick = onFollowClick,
|
||||||
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
Text(
|
Text(
|
||||||
"Following",
|
"Following",
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
PrimaryButton(onClick = onFollowClick) {
|
MillenniumButton(onClick = onFollowClick) {
|
||||||
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
TooltipBox(
|
MillenniumTooltip(
|
||||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
text = if (isBlacklisted) "Remove from blacklist" else "Add to blacklist"
|
||||||
tooltip = {
|
|
||||||
PlainTooltip {
|
|
||||||
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
|
|
||||||
}
|
|
||||||
},
|
|
||||||
state = rememberTooltipState()
|
|
||||||
) {
|
) {
|
||||||
if (isBlacklisted) {
|
if (isBlacklisted) {
|
||||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onBlacklistClick,
|
||||||
|
style = MillenniumIconButtonStyle.Secondary) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
imageVector = Iconsax.IconsaxUserRemove,
|
||||||
contentDescription = "Remove from blacklist",
|
contentDescription = "Remove from blacklist",
|
||||||
tint = MaterialTheme.colorScheme.error,
|
tint = AppTheme.colors.error)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onBlacklistClick,
|
||||||
|
style = MillenniumIconButtonStyle.Secondary) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxUserRemove,
|
imageVector = Iconsax.IconsaxUserRemove,
|
||||||
contentDescription = "Add to blacklist",
|
contentDescription = "Add to blacklist")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -562,33 +505,32 @@ private fun ArtistHeaderActions(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun TopTracksHeader(
|
private fun TopTracksHeader(
|
||||||
onPlay: () -> Unit,
|
onPlay: () -> Unit,
|
||||||
onAddToQueue: () -> Unit,
|
onAddToQueue: () -> Unit) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp),
|
.padding(horizontal = 16.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Top Tracks",
|
text = "Top Tracks",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = AppTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold)
|
||||||
)
|
|
||||||
|
|
||||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
PrimaryIconButton(onClick = onPlay) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onPlay,
|
||||||
|
style = MillenniumIconButtonStyle.Primary) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxPlay,
|
imageVector = Iconsax.IconsaxPlay,
|
||||||
contentDescription = "Play top tracks",
|
contentDescription = "Play top tracks")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
SecondaryIconButton(onClick = onAddToQueue) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onAddToQueue,
|
||||||
|
style = MillenniumIconButtonStyle.Secondary) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add top tracks to queue",
|
contentDescription = "Add top tracks to queue")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -598,8 +540,7 @@ private fun TopTracksHeader(
|
|||||||
private fun AlbumsSection(
|
private fun AlbumsSection(
|
||||||
albums: List<MetadataAlbum.Detailed>,
|
albums: List<MetadataAlbum.Detailed>,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit) {
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
val rowState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(rowState) {
|
LaunchedEffect(rowState) {
|
||||||
@ -624,8 +565,7 @@ private fun AlbumsSection(
|
|||||||
modifier = Modifier.dragScrollable(rowState),
|
modifier = Modifier.dragScrollable(rowState),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top) {
|
||||||
) {
|
|
||||||
items(albums, key = { it.id }) { album ->
|
items(albums, key = { it.id }) { album ->
|
||||||
AlbumCard(album = album)
|
AlbumCard(album = album)
|
||||||
}
|
}
|
||||||
@ -636,8 +576,7 @@ private fun AlbumsSection(
|
|||||||
private fun RelatedArtistsSection(
|
private fun RelatedArtistsSection(
|
||||||
artists: List<MetadataArtist.Basic>,
|
artists: List<MetadataArtist.Basic>,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit) {
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
val rowState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(rowState) {
|
LaunchedEffect(rowState) {
|
||||||
@ -662,8 +601,7 @@ private fun RelatedArtistsSection(
|
|||||||
modifier = Modifier.dragScrollable(rowState),
|
modifier = Modifier.dragScrollable(rowState),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top) {
|
||||||
) {
|
|
||||||
items(artists, key = { it.id }) { artist ->
|
items(artists, key = { it.id }) { artist ->
|
||||||
ArtistCard(artist = artist)
|
ArtistCard(artist = artist)
|
||||||
}
|
}
|
||||||
@ -674,8 +612,7 @@ private fun RelatedArtistsSection(
|
|||||||
private fun FeaturedPlaylistsSection(
|
private fun FeaturedPlaylistsSection(
|
||||||
playlists: List<MetadataPlaylist>,
|
playlists: List<MetadataPlaylist>,
|
||||||
onPlaylistClick: (MetadataPlaylist) -> Unit,
|
onPlaylistClick: (MetadataPlaylist) -> Unit,
|
||||||
onLoadMore: () -> Unit,
|
onLoadMore: () -> Unit) {
|
||||||
) {
|
|
||||||
val rowState = rememberLazyListState()
|
val rowState = rememberLazyListState()
|
||||||
|
|
||||||
LaunchedEffect(rowState) {
|
LaunchedEffect(rowState) {
|
||||||
@ -700,8 +637,7 @@ private fun FeaturedPlaylistsSection(
|
|||||||
modifier = Modifier.dragScrollable(rowState),
|
modifier = Modifier.dragScrollable(rowState),
|
||||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top) {
|
||||||
) {
|
|
||||||
items(playlists, key = { it.id }) { playlist ->
|
items(playlists, key = { it.id }) { playlist ->
|
||||||
PlaylistCard(playlist = playlist)
|
PlaylistCard(playlist = playlist)
|
||||||
}
|
}
|
||||||
@ -712,10 +648,9 @@ private fun FeaturedPlaylistsSection(
|
|||||||
private fun SectionHeader(title: String) {
|
private fun SectionHeader(title: String) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = AppTheme.typography.titleLarge,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
modifier = Modifier.padding(horizontal = 16.dp),
|
modifier = Modifier.padding(horizontal = 16.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun formatFollowers(count: Int?): String {
|
private fun formatFollowers(count: Int?): String {
|
||||||
|
|||||||
@ -59,8 +59,7 @@ sealed interface ArtistScreenState {
|
|||||||
val relatedArtists: List<MetadataArtist.Basic>,
|
val relatedArtists: List<MetadataArtist.Basic>,
|
||||||
val relatedArtistsNextPagination: PaginationStrategy?,
|
val relatedArtistsNextPagination: PaginationStrategy?,
|
||||||
val featuredPlaylists: List<MetadataPlaylist>,
|
val featuredPlaylists: List<MetadataPlaylist>,
|
||||||
val featuredPlaylistsNextPagination: PaginationStrategy?,
|
val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState
|
||||||
) : ArtistScreenState
|
|
||||||
|
|
||||||
data class Error(val message: String) : ArtistScreenState
|
data class Error(val message: String) : ArtistScreenState
|
||||||
}
|
}
|
||||||
@ -75,8 +74,7 @@ class ArtistViewModel(
|
|||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val shareService: ShareService,
|
private val shareService: ShareService,
|
||||||
private val downloadManager: DownloadManager,
|
private val downloadManager: DownloadManager,
|
||||||
private val remotePlaybackController: RemotePlaybackController,
|
private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent {
|
||||||
) : ViewModel(), KoinComponent {
|
|
||||||
private val logger by injectLogger<ArtistViewModel>()
|
private val logger by injectLogger<ArtistViewModel>()
|
||||||
|
|
||||||
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
|
||||||
@ -106,8 +104,7 @@ class ArtistViewModel(
|
|||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
savedTracksRepository.savedTracksIdsFlow,
|
savedTracksRepository.savedTracksIdsFlow,
|
||||||
_blacklistedTrackIds,
|
_blacklistedTrackIds,
|
||||||
_blacklistedArtistIds,
|
_blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||||
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
|
||||||
TrackOptionsContext(
|
TrackOptionsContext(
|
||||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||||
queueTrackIds = queue.mapNotNull { entry ->
|
queueTrackIds = queue.mapNotNull { entry ->
|
||||||
@ -115,8 +112,7 @@ class ArtistViewModel(
|
|||||||
}.toSet(),
|
}.toSet(),
|
||||||
savedTrackIds = savedIds,
|
savedTrackIds = savedIds,
|
||||||
blacklistedTrackIds = blacklistedTracks,
|
blacklistedTrackIds = blacklistedTracks,
|
||||||
blacklistedArtistIds = blacklistedArtists,
|
blacklistedArtistIds = blacklistedArtists)
|
||||||
)
|
|
||||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
||||||
|
|
||||||
init {
|
init {
|
||||||
@ -198,8 +194,7 @@ class ArtistViewModel(
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
_state.value = currentState.copy(
|
_state.value = currentState.copy(
|
||||||
albums = currentState.albums + result.items,
|
albums = currentState.albums + result.items,
|
||||||
albumsNextPagination = result.nextPagination,
|
albumsNextPagination = result.nextPagination)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more albums" }
|
logger.e(e) { "Failed to load more albums" }
|
||||||
@ -219,8 +214,7 @@ class ArtistViewModel(
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
_state.value = currentState.copy(
|
_state.value = currentState.copy(
|
||||||
relatedArtists = currentState.relatedArtists + result.items,
|
relatedArtists = currentState.relatedArtists + result.items,
|
||||||
relatedArtistsNextPagination = result.nextPagination,
|
relatedArtistsNextPagination = result.nextPagination)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more related artists" }
|
logger.e(e) { "Failed to load more related artists" }
|
||||||
@ -240,8 +234,7 @@ class ArtistViewModel(
|
|||||||
if (result != null) {
|
if (result != null) {
|
||||||
_state.value = currentState.copy(
|
_state.value = currentState.copy(
|
||||||
featuredPlaylists = currentState.featuredPlaylists + result.items,
|
featuredPlaylists = currentState.featuredPlaylists + result.items,
|
||||||
featuredPlaylistsNextPagination = result.nextPagination,
|
featuredPlaylistsNextPagination = result.nextPagination)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to load more featured playlists" }
|
logger.e(e) { "Failed to load more featured playlists" }
|
||||||
@ -254,8 +247,7 @@ class ArtistViewModel(
|
|||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.ArtistTopTracks,
|
RemoteCollectionType.ArtistTopTracks,
|
||||||
artistId,
|
artistId,
|
||||||
artistName,
|
artistName)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTopTracks() {
|
fun playTopTracks() {
|
||||||
@ -263,8 +255,7 @@ class ArtistViewModel(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.ArtistTopTracks,
|
RemoteCollectionType.ArtistTopTracks,
|
||||||
artistId,
|
artistId,
|
||||||
artistName,
|
artistName)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun playTopTracksFromTrack(track: MetadataTrack) {
|
fun playTopTracksFromTrack(track: MetadataTrack) {
|
||||||
@ -273,8 +264,7 @@ class ArtistViewModel(
|
|||||||
type = RemoteCollectionType.ArtistTopTracks,
|
type = RemoteCollectionType.ArtistTopTracks,
|
||||||
id = artistId,
|
id = artistId,
|
||||||
title = artistName,
|
title = artistName,
|
||||||
startTrack = track,
|
startTrack = track)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
fun addTracksToJam(tracks: List<MetadataTrack>) {
|
||||||
remotePlaybackController.addTracksToJam(tracks)
|
remotePlaybackController.addTracksToJam(tracks)
|
||||||
@ -349,8 +339,7 @@ class ArtistViewModel(
|
|||||||
runCatching {
|
runCatching {
|
||||||
libraryRepository.addTracksToPlaylist(
|
libraryRepository.addTracksToPlaylist(
|
||||||
playlistId,
|
playlistId,
|
||||||
_tracksToAddToPlaylist.value.map { it.id },
|
_tracksToAddToPlaylist.value.map { it.id })
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
logger.e(e) { "Failed to add tracks to playlist" }
|
logger.e(e) { "Failed to add tracks to playlist" }
|
||||||
}.onSuccess {
|
}.onSuccess {
|
||||||
@ -389,8 +378,7 @@ class ArtistViewModel(
|
|||||||
relatedArtists = overview.relatedArtists.items,
|
relatedArtists = overview.relatedArtists.items,
|
||||||
relatedArtistsNextPagination = overview.relatedArtists.nextPagination,
|
relatedArtistsNextPagination = overview.relatedArtists.nextPagination,
|
||||||
featuredPlaylists = overview.featuredPlaylists.items,
|
featuredPlaylists = overview.featuredPlaylists.items,
|
||||||
featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination,
|
featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
_state.value = ArtistScreenState.Error("Failed to load artist overview")
|
_state.value = ArtistScreenState.Error("Failed to load artist overview")
|
||||||
}
|
}
|
||||||
|
|||||||
@ -29,8 +29,7 @@ import kotlinx.serialization.json.Json
|
|||||||
import kotlinx.serialization.encodeToString
|
import kotlinx.serialization.encodeToString
|
||||||
|
|
||||||
open class BlacklistRepository(
|
open class BlacklistRepository(
|
||||||
private val database: Database,
|
private val database: Database) {
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks")
|
private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks")
|
||||||
private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists")
|
private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists")
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user