mirror of
https://github.com/KRTirtho/spotube.git
synced 2026-11-04 10:13:59 +00:00
Compare commits
6 Commits
b37bdaef5f
...
76c472cdbf
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
76c472cdbf | ||
|
|
caad76ba95 | ||
|
|
bb9f225fd7 | ||
|
|
cfd472016e | ||
|
|
0def3e2e49 | ||
|
|
b0327fd224 |
@ -119,7 +119,6 @@ kotlin {
|
||||
// Jetpack Compose dependencies
|
||||
implementation(libs.compose.runtime)
|
||||
implementation(libs.compose.foundation)
|
||||
implementation(libs.compose.material3)
|
||||
implementation(libs.compose.ui)
|
||||
implementation(libs.coil.compose)
|
||||
implementation(libs.coil.network.ktor3)
|
||||
@ -132,6 +131,9 @@ kotlin {
|
||||
implementation(libs.kotlinx.datetime)
|
||||
implementation(libs.dns.sd.kt)
|
||||
|
||||
// Millennium UI
|
||||
implementation(libs.millennium)
|
||||
|
||||
// Navigation
|
||||
implementation(libs.jetbrains.navigation3.ui)
|
||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||
@ -164,12 +166,6 @@ kotlin {
|
||||
// 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
|
||||
implementation(libs.androidx.datastore)
|
||||
implementation(libs.androidx.datastore.preferences)
|
||||
@ -184,8 +180,6 @@ kotlin {
|
||||
|
||||
implementation(libs.semver)
|
||||
|
||||
implementation(libs.material.kolor)
|
||||
|
||||
// crypto
|
||||
implementation(libs.cryptography.core)
|
||||
implementation(libs.cryptography.provider.optimal)
|
||||
@ -206,7 +200,6 @@ kotlin {
|
||||
|
||||
// Shimmer effect
|
||||
implementation(libs.compose.shimmer)
|
||||
implementation(libs.compose.placeholder.material3)
|
||||
|
||||
// DLNA
|
||||
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.stream.StreamInfoItem
|
||||
|
||||
|
||||
actual class NewPipeService {
|
||||
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
||||
return withContext(Dispatchers.IO) {
|
||||
@ -47,8 +46,7 @@ actual class NewPipeService {
|
||||
url = item.url,
|
||||
uploader = item.uploaderName,
|
||||
durationMs = item.duration,
|
||||
thumbnailUrl = item.thumbnails.first().url,
|
||||
)
|
||||
thumbnailUrl = item.thumbnails.first().url)
|
||||
} else {
|
||||
null
|
||||
}
|
||||
@ -67,8 +65,7 @@ actual class NewPipeService {
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
container = stream.format!!.suffix,
|
||||
bitrate = stream.bitrate,
|
||||
)
|
||||
bitrate = stream.bitrate)
|
||||
} + buildList {
|
||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||
AudioStream.Lossy(
|
||||
@ -90,7 +87,6 @@ actual class NewPipeService {
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
val videoStreams = extractor.videoStreams
|
||||
.filter { stream -> stream.isUrl && stream.format != null }
|
||||
.map { stream ->
|
||||
@ -98,8 +94,7 @@ actual class NewPipeService {
|
||||
url = stream.content,
|
||||
codec = stream.codec,
|
||||
bitrate = stream.bitrate,
|
||||
container = stream.format!!.suffix,
|
||||
)
|
||||
container = stream.format!!.suffix)
|
||||
}
|
||||
VideoInfo(
|
||||
id = id,
|
||||
|
||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
||||
collectionPlaybackHelper = get(),
|
||||
audioPlayerQueue = get(),
|
||||
settingsRepository = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
blacklistRepository = get())
|
||||
}
|
||||
}
|
||||
|
||||
@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat
|
||||
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
||||
val context = LocalContext.current
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
ActivityResultContracts.RequestPermission(),
|
||||
) { /* result is picked up by discovery/advertising retry loops */ }
|
||||
ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ }
|
||||
|
||||
return {
|
||||
val granted = ContextCompat.checkSelfPermission(
|
||||
context,
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||
) == PackageManager.PERMISSION_GRANTED
|
||||
Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
|
||||
if (!granted) {
|
||||
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||
}
|
||||
|
||||
@ -53,8 +53,7 @@ class MediaBrowseHelper(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
private val blacklistRepository: BlacklistRepository) {
|
||||
|
||||
companion object {
|
||||
const val MEDIA_ID_ROOT = "root"
|
||||
@ -253,8 +252,7 @@ class MediaBrowseHelper(
|
||||
.setIsPlayable(false)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||
@ -390,8 +388,7 @@ class MediaBrowseHelper(
|
||||
.setIsBrowsable(true)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||
@ -475,8 +472,7 @@ class MediaBrowseHelper(
|
||||
.setIsPlayable(false)
|
||||
.build()
|
||||
)
|
||||
.build(),
|
||||
)
|
||||
.build())
|
||||
}
|
||||
|
||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||
|
||||
@ -32,8 +32,7 @@ import java.io.File
|
||||
import java.util.Locale
|
||||
|
||||
class AndroidLocalMediaDiscoveryService(
|
||||
private val context: Context,
|
||||
) : LocalMediaDiscoveryService, KoinComponent {
|
||||
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||
|
||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
||||
@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
MediaStore.Audio.Media.ARTIST,
|
||||
MediaStore.Audio.Media.ALBUM,
|
||||
MediaStore.Audio.Media.DURATION,
|
||||
MediaStore.Audio.Media.MIME_TYPE,
|
||||
) + if (Build.VERSION.SDK_INT >= 29) {
|
||||
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
|
||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||
} else {
|
||||
emptyArray()
|
||||
@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
artists = listOfNotNull(artist),
|
||||
durationMs = durationMs,
|
||||
album = album,
|
||||
coverBytes = null,
|
||||
)
|
||||
coverBytes = null)
|
||||
|
||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||
acceptedRows += 1
|
||||
@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = 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()) }
|
||||
|
||||
@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
|
||||
override fun observeChanges(
|
||||
roots: List<String>,
|
||||
onChanged: LocalMediaChangeCallback,
|
||||
): LocalMediaObservation? {
|
||||
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
||||
logger.i { "observeChanges: start roots=${roots.size}" }
|
||||
if (!hasReadPermission()) {
|
||||
logger.w { "observeChanges: missing media permission, observer not registered" }
|
||||
@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
context.contentResolver.registerContentObserver(
|
||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||
true,
|
||||
observer,
|
||||
)
|
||||
observer)
|
||||
|
||||
logger.i { "observeChanges: observer registered" }
|
||||
|
||||
@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
artists = emptyList(),
|
||||
durationMs = 0L,
|
||||
album = null,
|
||||
coverBytes = null,
|
||||
)
|
||||
coverBytes = null)
|
||||
|
||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||
}
|
||||
@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = 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()) }
|
||||
|
||||
@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
||||
)
|
||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
|
||||
|
||||
return (configured + defaults)
|
||||
.map { it.absoluteFile }
|
||||
@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
|
||||
private fun mergeFolders(
|
||||
mediaStoreFolders: List<LocalMediaFolder>,
|
||||
fallbackFolders: List<LocalMediaFolder>,
|
||||
): List<LocalMediaFolder> {
|
||||
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
||||
|
||||
@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService(
|
||||
LocalMediaFolder(
|
||||
path = 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()) }
|
||||
}
|
||||
@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService(
|
||||
"wav",
|
||||
"ogg",
|
||||
"opus",
|
||||
"wma",
|
||||
)
|
||||
"wma")
|
||||
}
|
||||
}
|
||||
|
||||
@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
|
||||
|
||||
var isGranted by remember {
|
||||
mutableStateOf(
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
|
||||
)
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||
}
|
||||
|
||||
val launcher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestPermission(),
|
||||
) { granted ->
|
||||
contract = ActivityResultContracts.RequestPermission()) { granted ->
|
||||
isGranted = granted ||
|
||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
return LocalMediaPermissionState(
|
||||
isGranted = isGranted,
|
||||
requestPermission = { launcher.launch(permission) },
|
||||
)
|
||||
requestPermission = { launcher.launch(permission) })
|
||||
}
|
||||
|
||||
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal file
22
composeApp/src/androidMain/res/values-sv/strings.xml
Normal file
@ -0,0 +1,22 @@
|
||||
<!--
|
||||
~ 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/>.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="app_name">Spotube</string>
|
||||
<string name="now_playing">Spelas nu</string>
|
||||
<string name="car_connect_hint">Spela musik från telefonen för att börja lyssna i bilen</string>
|
||||
</resources>
|
||||
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal file
169
composeApp/src/commonMain/composeResources/values-sv/strings.xml
Normal file
@ -0,0 +1,169 @@
|
||||
<!--
|
||||
~ 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/>.
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="settings_screen_title">Inställningar</string>
|
||||
|
||||
<string name="settings_section_plugins">Tillägg</string>
|
||||
<string name="settings_section_language_region">Språk och region</string>
|
||||
<string name="settings_section_appearance">Utseende</string>
|
||||
<string name="settings_section_playback">Uppspelning</string>
|
||||
<string name="settings_section_downloads">Nedladdningar</string>
|
||||
<string name="settings_section_caching">Cachelagring</string>
|
||||
<string name="settings_section_desktop">Dator</string>
|
||||
<string name="settings_section_updates">Uppdateringar</string>
|
||||
|
||||
<string name="settings_action_close">Stäng</string>
|
||||
<string name="settings_action_save">Spara</string>
|
||||
<string name="settings_action_cancel">Avbryt</string>
|
||||
|
||||
<string name="settings_language_title">Språk</string>
|
||||
<string name="settings_language_subtitle_current">Språk för appens gränssnitt. Nuvarande: %1$s</string>
|
||||
<string name="settings_country_title">Land</string>
|
||||
<string name="settings_country_subtitle_current">Land som styr innehåll och tillgänglighet. Nuvarande: %1$s</string>
|
||||
<string name="settings_option_name_and_code">%1$s (%2$s)</string>
|
||||
|
||||
<string name="settings_theme_title">Tema</string>
|
||||
<string name="settings_theme_subtitle_current">Välj hur Spotube anpassar sig till systemets utseende. Nuvarande: %1$s</string>
|
||||
<string name="settings_theme_light">Ljust</string>
|
||||
<string name="settings_theme_dark">Mörkt</string>
|
||||
<string name="settings_theme_system">System</string>
|
||||
|
||||
<string name="settings_accent_title">Accentfärg</string>
|
||||
<string name="settings_accent_subtitle_current">Nuvarande färgpalett: %1$s</string>
|
||||
<string name="settings_accent_dialog_title">Accentfärg</string>
|
||||
<string name="settings_accent_dialog_description">Välj en accentpalett och förhandsgranska den i både ljust och mörkt tema.</string>
|
||||
<string name="settings_preview_light">Ljust</string>
|
||||
<string name="settings_preview_dark">Mörkt</string>
|
||||
<string name="settings_preview_label">Förhandsgranskning</string>
|
||||
<string name="settings_preview_button">Knapp</string>
|
||||
<string name="settings_accent_green_goblin">Gröna trollet</string>
|
||||
<string name="settings_accent_electric_violet">Elektriskt violett</string>
|
||||
<string name="settings_accent_oceanic_cyan">Havscyan</string>
|
||||
<string name="settings_accent_sunset_orange">Solnedgångsorange</string>
|
||||
<string name="settings_accent_rose_garden">Rosenträdgård</string>
|
||||
<string name="settings_accent_midnight_blue">Midnattsblått</string>
|
||||
<string name="settings_accent_metallic_slate">Metalliskt skiffergrått</string>
|
||||
|
||||
<string name="settings_download_folder_title">Egen nedladdningsmapp</string>
|
||||
<string name="settings_download_folder_current">Nuvarande mapp: %1$s</string>
|
||||
<string name="settings_download_folder_default">Nuvarande mapp: standardmappen för musik</string>
|
||||
<string name="settings_download_folder_description">Lämna fältet tomt för att använda standardmappen för musik.</string>
|
||||
<string name="settings_download_folder_placeholder">C:\Music\Spotube</string>
|
||||
<string name="settings_local_media_folders_title">Lokala mediemappar</string>
|
||||
<string name="settings_local_media_folders_subtitle_current">Egna mappar att skanna på datorn. Tillagda: %1$d</string>
|
||||
<string name="settings_local_media_folders_subtitle_empty">Ingen egen mapp har lagts till ännu</string>
|
||||
<string name="settings_local_media_folders_description">Lägg till mappar med mappväljaren. Den egna nedladdningsmappen tas med automatiskt.</string>
|
||||
<string name="settings_local_media_folders_manage">Hantera</string>
|
||||
<string name="settings_local_media_folders_add_action">Lägg till mapp</string>
|
||||
<string name="settings_local_media_folders_remove_action">Ta bort mapp</string>
|
||||
<string name="settings_local_media_folders_none_added">Inga egna mappar har lagts till.</string>
|
||||
<string name="settings_download_format_title">Musikformat för nedladdning</string>
|
||||
<string name="settings_download_format_subtitle_current">Önskad kodek och önskat behållarformat för nedladdningar. Nuvarande: %1$s</string>
|
||||
<string name="settings_download_quality_title">Musikkvalitet för nedladdning</string>
|
||||
|
||||
<string name="settings_streaming_format_title">Musikformat för strömning</string>
|
||||
<string name="settings_streaming_format_subtitle_current">Önskad kodek och önskat behållarformat för musikströmning. Nuvarande: %1$s</string>
|
||||
<string name="settings_streaming_quality_title">Musikkvalitet för strömning</string>
|
||||
<string name="settings_subtitle_current">Nuvarande: %1$s</string>
|
||||
<string name="settings_enable_music_caching_title">Aktivera cachelagring av musik</string>
|
||||
<string name="settings_enable_music_caching_subtitle">Lagra strömmat ljud lokalt så att det går snabbare att spela upp igen.</string>
|
||||
<string name="settings_cache_folder_title">Cachemapp</string>
|
||||
<string name="settings_cache_folder_current">Nuvarande: %1$s</string>
|
||||
<string name="settings_cache_folder_default">Nuvarande: appens standardcache</string>
|
||||
<string name="settings_cache_size_limit_title">Maximal cachestorlek</string>
|
||||
<string name="settings_cache_size_limit_unlimited">Ingen gräns</string>
|
||||
<string name="settings_cache_size_limit_current">Nuvarande gräns: %1$d MB</string>
|
||||
<string name="settings_cache_size_limit_description">Ange den maximala cachestorleken i MB. Ange 0 eller lämna tomt för obegränsad storlek.</string>
|
||||
<string name="settings_cache_size_limit_placeholder">500</string>
|
||||
<string name="settings_error_cache_size_range">Storleken måste vara 0 eller ett positivt tal.</string>
|
||||
<string name="settings_enable_endless_playback_title">Fortsätt spela automatiskt</string>
|
||||
<string name="settings_enable_endless_playback_subtitle">Fortsätt spela rekommenderade låtar när kön tar slut.</string>
|
||||
<string name="settings_enable_connect_title">Aktivera Connect</string>
|
||||
<string name="settings_enable_connect_subtitle">Gör det möjligt att fjärrstyra uppspelningen via Spotube Connect.</string>
|
||||
<string name="settings_playback_port_title">Port för uppspelningens proxyserver</string>
|
||||
<string name="settings_playback_port_subtitle_current">Port som uppspelningens proxyserver använder. Nuvarande: %1$d</string>
|
||||
<string name="settings_playback_port_description">Välj en port mellan 1 och 65535.</string>
|
||||
<string name="settings_playback_port_placeholder">14769</string>
|
||||
<string name="settings_error_whole_number">Ange ett giltigt heltal.</string>
|
||||
<string name="settings_error_port_range">Porten måste vara mellan 1 och 65535.</string>
|
||||
|
||||
<string name="settings_blacklist_title">Spärrlista</string>
|
||||
<string name="settings_blacklist_subtitle">Hantera spärrade låtar och artister</string>
|
||||
|
||||
<string name="settings_desktop_minimize_title">Minimera till meddelandefältet</string>
|
||||
<string name="settings_desktop_minimize_subtitle">Låt Spotube fortsätta köras i meddelandefältet när fönstret minimeras.</string>
|
||||
<string name="settings_desktop_discord_title">Discord Rich Presence</string>
|
||||
<string name="settings_desktop_discord_subtitle">Visa din aktuella uppspelningsstatus i Discord på datorn.</string>
|
||||
|
||||
<string name="settings_updates_auto_check_title">Sök automatiskt efter uppdateringar</string>
|
||||
<string name="settings_updates_auto_check_subtitle">Sök efter nya versioner av Spotube i bakgrunden.</string>
|
||||
|
||||
<string name="settings_plugins_manage_title">Hantera tillägg</string>
|
||||
<string name="settings_plugins_manage_subtitle">Hantera dina tillägg</string>
|
||||
<string name="settings_plugins_default_ability_title">Standardtillägg: %1$s</string>
|
||||
<string name="settings_plugins_ability_metadata">Metadata</string>
|
||||
<string name="settings_plugins_ability_audio">Ljud</string>
|
||||
<string name="settings_plugins_ability_lyrics">Låttexter</string>
|
||||
<string name="settings_plugins_ability_scrobble">Skrobbling</string>
|
||||
<string name="settings_plugins_no_selection">Inget tillägg valt</string>
|
||||
<string name="settings_plugins_action_change">Ändra</string>
|
||||
<string name="settings_plugins_action_select">Välj</string>
|
||||
<string name="settings_plugins_selected">Valt</string>
|
||||
<string name="settings_plugins_clear">Rensa val</string>
|
||||
<string name="settings_plugins_no_plugins">Inga tillägg är tillgängliga</string>
|
||||
<string name="settings_plugins_plugin_content_description">Tillägget %1$s</string>
|
||||
<string name="settings_plugins_clear_content_description">Rensa</string>
|
||||
|
||||
<string name="plugin_configure_title">Konfigurera</string>
|
||||
<string name="plugin_screen_title">Tilläggshanterare</string>
|
||||
<string name="plugin_error_enter_url">Ange en URL</string>
|
||||
<string name="plugin_error_url_scheme">URL:en måste börja med http:// eller https://</string>
|
||||
<string name="plugin_error_download_failed">Det gick inte att ladda ner tillägget</string>
|
||||
<string name="plugin_empty_title">Inga tillägg har installerats ännu</string>
|
||||
<string name="plugin_empty_subtitle">Klistra in en nedladdningsadress eller välj en fil ovan.</string>
|
||||
<string name="plugin_installed_count">Installerade %2$s: %1$d</string>
|
||||
<string name="plugin_installed_singular">tillägg</string>
|
||||
<string name="plugin_installed_plural">tillägg</string>
|
||||
<string name="plugin_install_section_title">Installera ett tillägg</string>
|
||||
<string name="plugin_url_placeholder">https://example.com/plugin.smplug</string>
|
||||
<string name="plugin_action_download">Ladda ner</string>
|
||||
<string name="plugin_action_install_from_file">Installera från fil (.smplug)</string>
|
||||
<string name="plugin_state_active">Aktivt</string>
|
||||
<string name="plugin_version_label">v%1$s</string>
|
||||
<string name="plugin_state_builtin">Inbyggt</string>
|
||||
<string name="plugin_action_remove">Ta bort tillägg</string>
|
||||
<string name="plugin_action_login">Logga in</string>
|
||||
<string name="plugin_action_logout">Logga ut</string>
|
||||
<string name="plugin_section_url_title">Ladda ner från URL</string>
|
||||
<string name="plugin_section_file_title">Installera från fil</string>
|
||||
<string name="plugin_section_install">Installera</string>
|
||||
|
||||
<string name="plugin_permissions_author_version">• %1$s</string>
|
||||
<string name="plugin_permissions_requested_title">Begärda behörigheter</string>
|
||||
<string name="plugin_permissions_none">Det här tillägget kräver inga särskilda behörigheter.</string>
|
||||
<string name="plugin_permissions_compare_title">Installerat och tillhandahållet</string>
|
||||
<string name="plugin_permissions_installed_label">Installerat</string>
|
||||
<string name="plugin_permissions_supplied_label">Tillhandahållet</string>
|
||||
<string name="plugin_permissions_api_diff">API %1$s → %2$s</string>
|
||||
<string name="plugin_permissions_capability_storage_title">Beständig lagring</string>
|
||||
<string name="plugin_permissions_capability_storage_desc">Läsa och skriva data på disken</string>
|
||||
<string name="plugin_permissions_capability_network_title">Nätverksförfrågningar</string>
|
||||
<string name="plugin_permissions_capability_network_desc">Skicka och ta emot data via internet</string>
|
||||
<string name="plugin_permissions_capability_webview_title">WebView</string>
|
||||
<string name="plugin_permissions_capability_webview_desc">Visa webbinnehåll i appen</string>
|
||||
</resources>
|
||||
@ -16,13 +16,18 @@
|
||||
-->
|
||||
|
||||
<resources>
|
||||
<string name="queue_title">Queue</string>
|
||||
<string name="queue_clear_queue">Clear Queue</string>
|
||||
<string name="queue_filter_queue">Filter queue...</string>
|
||||
<string name="queue_no_queue_entries">No queue entries</string>
|
||||
|
||||
<string name="settings_screen_title">Settings</string>
|
||||
|
||||
<string name="settings_section_plugins">Plugins</string>
|
||||
<string name="settings_section_language_region">Language and Region</string>
|
||||
<string name="settings_section_appearance">Appearance</string>
|
||||
<string name="settings_section_playback">Playback</string>
|
||||
<string name="settings_section_downloads">Downloads</string>
|
||||
<string name="settings_section_downloads">Downloads</string>f
|
||||
<string name="settings_section_caching">Caching</string>
|
||||
<string name="settings_section_desktop">Desktop</string>
|
||||
<string name="settings_section_updates">Updates</string>
|
||||
@ -76,6 +81,16 @@
|
||||
<string name="settings_appearance_accent_label">Accent</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_current">Current folder: %1$s</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.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||
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.rememberNavigationState
|
||||
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.theming.SpotubeTheme
|
||||
import dev.krtirtho.spotube.modules.library.LibraryTab
|
||||
@ -81,7 +76,7 @@ interface NavigationItem {
|
||||
|
||||
private val tabs = listOf<NavigationItem>(
|
||||
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(
|
||||
@ -95,7 +90,7 @@ val sidebarTabs = tabs + listOf(
|
||||
LibraryTab.Playlists
|
||||
),
|
||||
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(
|
||||
@ -112,27 +107,23 @@ val sidebarTabs = tabs + listOf(
|
||||
Iconsax.IconsaxDirectboxReceive,
|
||||
Routes.Library,
|
||||
LibraryTab.Downloads
|
||||
),
|
||||
)
|
||||
)
|
||||
),
|
||||
NavigationItem.Group(
|
||||
"Connect",
|
||||
items = listOf(
|
||||
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(
|
||||
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
|
||||
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
|
||||
)
|
||||
|
||||
@OptIn(
|
||||
KoinExperimentalAPI::class,
|
||||
ExperimentalCoroutinesApi::class,
|
||||
ExperimentalSharedTransitionApi::class
|
||||
)
|
||||
@OptIn(KoinExperimentalAPI::class)
|
||||
@Composable
|
||||
fun App(
|
||||
content: @Composable () -> Unit = {}
|
||||
@ -159,69 +150,52 @@ fun App(
|
||||
}
|
||||
|
||||
SpotubeTheme(settings = userSettings) {
|
||||
val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
|
||||
val accent = if (isDark) {
|
||||
userSettings.accentColor.toDarkColor()
|
||||
} 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 = Modifier.fillMaxSize()) {
|
||||
val currentRoute =
|
||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = navigator::pop,
|
||||
entries = navigationState.toEntries(koinEntryProvider())
|
||||
)
|
||||
}
|
||||
Box(Modifier.fillMaxSize()) {
|
||||
AppShell(navigator, navigationState) {
|
||||
SharedTransitionLayout {
|
||||
CompositionLocalProvider(
|
||||
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
||||
) {
|
||||
Column {
|
||||
NavDisplay(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
onBack = navigator::pop,
|
||||
entries = navigationState.toEntries(koinEntryProvider())
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
when (currentRoute) {
|
||||
Routes.WebView -> WebViewScreen(koinInject())
|
||||
else -> {}
|
||||
}
|
||||
}
|
||||
content()
|
||||
availableAppUpdate?.let { update ->
|
||||
AppUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = appUpdateService::dismissUpdate,
|
||||
onIgnore = appUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
appUpdateService.dismissUpdate()
|
||||
},
|
||||
)
|
||||
}
|
||||
availablePluginUpdate?.let { update ->
|
||||
PluginUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = pluginUpdateService::dismissUpdate,
|
||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||
pluginUpdateService.dismissUpdate()
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
content()
|
||||
availableAppUpdate?.let { update ->
|
||||
AppUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = appUpdateService::dismissUpdate,
|
||||
onIgnore = appUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||
appUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
availablePluginUpdate?.let { update ->
|
||||
PluginUpdateDialog(
|
||||
update = update,
|
||||
onDismiss = pluginUpdateService::dismissUpdate,
|
||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
||||
onUpdate = {
|
||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||
pluginUpdateService.dismissUpdate()
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlin.time.Duration
|
||||
|
||||
|
||||
interface AudioPlayerInterface {
|
||||
// // Playback
|
||||
suspend fun play()
|
||||
|
||||
@ -30,8 +30,7 @@ data class MediaItem(
|
||||
val duration: Duration,
|
||||
val coverURL: String,
|
||||
val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
)
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
|
||||
|
||||
enum class LoopState {
|
||||
NONE, ONE, ALL;
|
||||
@ -60,8 +59,7 @@ sealed interface QueueEntry {
|
||||
val track: MetadataTrack,
|
||||
override val url: String,
|
||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry
|
||||
override val addedBy: String = "") : QueueEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("local")
|
||||
@ -72,8 +70,7 @@ sealed interface QueueEntry {
|
||||
val album: String?,
|
||||
val coverBytes: ByteArray?,
|
||||
override val url: String,
|
||||
override val addedBy: String = "",
|
||||
) : QueueEntry {
|
||||
override val addedBy: String = "") : QueueEntry {
|
||||
override fun equals(other: Any?): Boolean {
|
||||
if (this === other) return true
|
||||
if (other == null || this::class != other::class) return false
|
||||
@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry {
|
||||
@Serializable
|
||||
@SerialName("playlist")
|
||||
data class Playlist(
|
||||
override val id: String,
|
||||
) : QueueCollectionEntry
|
||||
override val id: String) : QueueCollectionEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("album")
|
||||
data class Album(
|
||||
override val id: String,
|
||||
) : QueueCollectionEntry
|
||||
override val id: String) : QueueCollectionEntry
|
||||
|
||||
@Serializable
|
||||
@SerialName("saved_tracks")
|
||||
@ -132,5 +127,4 @@ data class PersistedQueueState(
|
||||
val entries: List<QueueEntry>,
|
||||
val currentIndex: Int,
|
||||
val currentCollectionEntry: QueueCollectionEntry? = null,
|
||||
val collectionHistory: List<QueueCollectionEntry> = emptyList(),
|
||||
)
|
||||
val collectionHistory: List<QueueCollectionEntry> = emptyList())
|
||||
|
||||
@ -29,8 +29,7 @@ interface AudioPlayerQueue {
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean = true,
|
||||
startPosition: Int = 0,
|
||||
collectionEntry: QueueCollectionEntry? = null,
|
||||
)
|
||||
collectionEntry: QueueCollectionEntry? = null)
|
||||
suspend fun addToQueue(entry: QueueEntry)
|
||||
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
||||
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
||||
|
||||
@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue(
|
||||
private val settingsProvider: SettingsProvider,
|
||||
private val repository: QueueStateRepository,
|
||||
private val pluginProvider: PluginProvider,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) : AudioPlayerQueue, KoinComponent {
|
||||
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||
|
||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||
|
||||
@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue(
|
||||
|
||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||
audioPlayer.playlistFlow,
|
||||
entryByMediaUrl,
|
||||
) { playlist, knownEntries ->
|
||||
entryByMediaUrl) { playlist, knownEntries ->
|
||||
playlist.map { mediaItem ->
|
||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||
}
|
||||
@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue(
|
||||
|
||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
entryByMediaUrl,
|
||||
) { mediaItem, knownEntries ->
|
||||
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||
|
||||
@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue(
|
||||
combine(
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.playlistFlow,
|
||||
audioPlayer.loopStateFlow,
|
||||
) { currentMedia, playlist, loopState ->
|
||||
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
|
||||
Triple(currentMedia, playlist, loopState)
|
||||
}.collect { (currentMedia, playlist, loopState) ->
|
||||
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||
@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue(
|
||||
entries: List<QueueEntry>,
|
||||
autoPlay: Boolean,
|
||||
startPosition: Int,
|
||||
collectionEntry: QueueCollectionEntry?,
|
||||
) {
|
||||
collectionEntry: QueueCollectionEntry?) {
|
||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||
if (mediaItems.isEmpty()) {
|
||||
@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue(
|
||||
entries = queueEntries,
|
||||
currentIndex = normalizedCurrentIndex,
|
||||
currentCollectionEntry = currentCollectionEntryState.value,
|
||||
collectionHistory = collectionHistoryState.value,
|
||||
)
|
||||
collectionHistory = collectionHistoryState.value)
|
||||
)
|
||||
}
|
||||
|
||||
@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = track.durationMs.milliseconds,
|
||||
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
||||
url = resolvedUrl,
|
||||
protocol = protocol,
|
||||
)
|
||||
protocol = protocol)
|
||||
}
|
||||
|
||||
is QueueEntry.LocalTrack -> {
|
||||
@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue(
|
||||
album = album.orEmpty(),
|
||||
duration = duration.milliseconds,
|
||||
coverURL = "",
|
||||
url = url,
|
||||
)
|
||||
url = url)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue(
|
||||
duration = duration.inWholeMilliseconds,
|
||||
album = album.ifBlank { null },
|
||||
coverBytes = null,
|
||||
url = url,
|
||||
)
|
||||
url = url)
|
||||
}
|
||||
|
||||
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_local_media.preferences_pb",
|
||||
"spotube_blacklist.preferences_pb",
|
||||
"spotube_cookies.preferences_pb",
|
||||
)
|
||||
"spotube_cookies.preferences_pb")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -168,8 +168,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Saved Tracks
|
||||
@ -183,8 +182,7 @@ val sharedModules = module {
|
||||
libraryRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Artist
|
||||
@ -199,8 +197,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Blacklist
|
||||
@ -212,8 +209,7 @@ val sharedModules = module {
|
||||
JamViewModel(
|
||||
jamRoomService = get(),
|
||||
shareService = get(),
|
||||
settingsProvider = get(),
|
||||
)
|
||||
settingsProvider = get())
|
||||
}
|
||||
|
||||
// Album
|
||||
@ -229,8 +225,7 @@ val sharedModules = module {
|
||||
blacklistRepository = get(),
|
||||
shareService = get(),
|
||||
downloadManager = get(),
|
||||
remotePlaybackController = get(),
|
||||
)
|
||||
remotePlaybackController = get())
|
||||
}
|
||||
|
||||
// Lyrics
|
||||
@ -244,8 +239,7 @@ val sharedModules = module {
|
||||
savedTracksRepository = get(),
|
||||
artistRepository = get(),
|
||||
audioPlayerQueue = get(),
|
||||
blacklistRepository = get(),
|
||||
)
|
||||
blacklistRepository = get())
|
||||
}
|
||||
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||
single {
|
||||
@ -253,8 +247,7 @@ val sharedModules = module {
|
||||
pluginManager = get(),
|
||||
audioPlayerQueue = get(),
|
||||
matchedTracksRepository = get(),
|
||||
settingsRepository = get(),
|
||||
)
|
||||
settingsRepository = get())
|
||||
}
|
||||
singleOf(::CacheManager)
|
||||
singleOf(::AlternativeTracksRepository)
|
||||
|
||||
@ -34,8 +34,7 @@ import kotlin.time.Clock
|
||||
|
||||
class DiscordRpcService(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val settingsProvider: SettingsProvider,
|
||||
) : KoinComponent, AutoCloseable {
|
||||
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
|
||||
|
||||
private val logger by injectLogger<DiscordRpcService>()
|
||||
|
||||
@ -61,8 +60,7 @@ class DiscordRpcService(
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.currentMediaItemFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow,
|
||||
) { settings, playerState, mediaItem, position, duration ->
|
||||
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
|
||||
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
||||
}.collect { state ->
|
||||
handleStateChange(state)
|
||||
@ -86,8 +84,7 @@ class DiscordRpcService(
|
||||
album = mediaItem.album,
|
||||
coverUrl = mediaItem.coverURL,
|
||||
positionMs = state.position.inWholeMilliseconds,
|
||||
durationMs = state.duration.inWholeMilliseconds,
|
||||
)
|
||||
durationMs = state.duration.inWholeMilliseconds)
|
||||
}
|
||||
PlayerState.PAUSED -> {
|
||||
val mediaItem = state.mediaItem ?: return
|
||||
@ -98,8 +95,7 @@ class DiscordRpcService(
|
||||
album = mediaItem.album,
|
||||
coverUrl = mediaItem.coverURL,
|
||||
positionMs = state.position.inWholeMilliseconds,
|
||||
durationMs = state.duration.inWholeMilliseconds,
|
||||
)
|
||||
durationMs = state.duration.inWholeMilliseconds)
|
||||
}
|
||||
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
||||
clearPresence()
|
||||
@ -158,8 +154,7 @@ class DiscordRpcService(
|
||||
album: String,
|
||||
coverUrl: String,
|
||||
positionMs: Long,
|
||||
durationMs: Long,
|
||||
) {
|
||||
durationMs: Long) {
|
||||
val client = rpcClient ?: return
|
||||
try {
|
||||
client.updatePresence(
|
||||
@ -168,8 +163,7 @@ class DiscordRpcService(
|
||||
album = album,
|
||||
coverUrl = coverUrl,
|
||||
positionMs = positionMs,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
} catch (e: Exception) {
|
||||
rpcClient = null
|
||||
connectionFailed = true
|
||||
@ -210,6 +204,5 @@ class DiscordRpcService(
|
||||
val playerState: PlayerState,
|
||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||
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 host: String,
|
||||
val port: Int,
|
||||
val deviceId: String,
|
||||
) {
|
||||
val deviceId: String) {
|
||||
val key: String get() = "$name$type".replace(".", "")
|
||||
}
|
||||
|
||||
@ -55,8 +54,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
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)
|
||||
}
|
||||
|
||||
@ -66,8 +64,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
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)
|
||||
}
|
||||
|
||||
@ -77,8 +74,7 @@ class DeviceDiscoveryService {
|
||||
type = event.service.type,
|
||||
host = event.service.host,
|
||||
port = event.service.port,
|
||||
deviceId = "",
|
||||
)
|
||||
deviceId = "")
|
||||
DiscoveryState.Removed(device = device)
|
||||
}
|
||||
}
|
||||
@ -88,8 +84,7 @@ class DeviceDiscoveryService {
|
||||
name: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
registerTimeoutMs: Long = 5_000,
|
||||
): NetService {
|
||||
registerTimeoutMs: Long = 5_000): NetService {
|
||||
val service = createService(name, port, deviceId)
|
||||
service.register(timeoutInMs = registerTimeoutMs)
|
||||
return service
|
||||
@ -98,11 +93,9 @@ class DeviceDiscoveryService {
|
||||
fun createService(
|
||||
name: String,
|
||||
port: Int,
|
||||
deviceId: String,
|
||||
): NetService = createNetService(
|
||||
deviceId: String): NetService = createNetService(
|
||||
type = SERVICE_TYPE,
|
||||
name = name,
|
||||
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,
|
||||
/** Host's live play state — late-joining guests start with it. */
|
||||
val isPlaying: Boolean = false,
|
||||
) : JamMessage()
|
||||
val isPlaying: Boolean = false) : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("playbackCommand")
|
||||
@ -57,22 +56,19 @@ sealed class JamMessage {
|
||||
@SerialName("suggestTrack")
|
||||
data class SuggestTrack(
|
||||
val mediaItem: JamMediaItem,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("suggestPlaylist")
|
||||
data class SuggestPlaylist(
|
||||
val tracks: List<JamMediaItem>,
|
||||
val addedBy: String = "",
|
||||
) : JamMessage()
|
||||
val addedBy: String = "") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("kick")
|
||||
data class Kick(
|
||||
val participantId: String,
|
||||
val reason: String = "kicked",
|
||||
) : JamMessage()
|
||||
val reason: String = "kicked") : JamMessage()
|
||||
|
||||
@Serializable
|
||||
@SerialName("leave")
|
||||
@ -104,8 +100,7 @@ data class JamPresence(
|
||||
val clientId: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
val left: Boolean = false,
|
||||
)
|
||||
val left: Boolean = false)
|
||||
|
||||
@Serializable
|
||||
data class JamMediaItem(
|
||||
@ -117,8 +112,7 @@ data class JamMediaItem(
|
||||
val durationMs: Long,
|
||||
val coverUrl: String,
|
||||
val protocol: String,
|
||||
val addedBy: String = "",
|
||||
) {
|
||||
val addedBy: String = "") {
|
||||
companion object {
|
||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||
@ -132,8 +126,7 @@ data class JamMediaItem(
|
||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = entry.protocol.name,
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
addedBy = entry.addedBy)
|
||||
|
||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||
url = entry.url,
|
||||
@ -144,8 +137,7 @@ data class JamMediaItem(
|
||||
durationMs = entry.duration,
|
||||
coverUrl = "",
|
||||
protocol = "PROGRESSIVE",
|
||||
addedBy = entry.addedBy,
|
||||
)
|
||||
addedBy = entry.addedBy)
|
||||
}
|
||||
|
||||
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
||||
@ -158,8 +150,7 @@ data class JamMediaItem(
|
||||
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||
.orEmpty(),
|
||||
protocol = "PROGRESSIVE",
|
||||
)
|
||||
protocol = "PROGRESSIVE")
|
||||
|
||||
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||
url = item.url,
|
||||
@ -168,8 +159,7 @@ data class JamMediaItem(
|
||||
album = item.album,
|
||||
durationMs = item.duration.inWholeMilliseconds,
|
||||
coverUrl = item.coverURL,
|
||||
protocol = item.protocol.name,
|
||||
)
|
||||
protocol = item.protocol.name)
|
||||
|
||||
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
||||
title = item.title,
|
||||
@ -179,8 +169,7 @@ data class JamMediaItem(
|
||||
coverURL = item.coverUrl,
|
||||
url = item.url,
|
||||
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(
|
||||
val id: String,
|
||||
val displayName: String,
|
||||
val isHost: Boolean,
|
||||
)
|
||||
val isHost: Boolean)
|
||||
|
||||
@Serializable
|
||||
enum class JamRole {
|
||||
|
||||
@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName
|
||||
import com.ditchoom.mqtt.controlpacket.WillConfig
|
||||
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
||||
import dev.krtirtho.spotube.modules.settings.JamBroker
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
@ -47,6 +45,7 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.koin.core.component.KoinComponent
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
/**
|
||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||
@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent {
|
||||
code: String,
|
||||
clientId: String,
|
||||
displayName: String,
|
||||
isHost: Boolean,
|
||||
): Result<Unit> {
|
||||
isHost: Boolean): Result<Unit> {
|
||||
disconnect()
|
||||
if (broker.host.isBlank()) {
|
||||
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
||||
@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent {
|
||||
clientId = clientId,
|
||||
displayName = displayName,
|
||||
isHost = isHost,
|
||||
left = false,
|
||||
)
|
||||
left = false)
|
||||
|
||||
val mqtt = startClient(broker, clientId)
|
||||
client = mqtt
|
||||
@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent {
|
||||
publishJson(
|
||||
presenceTopic(code, presence.clientId),
|
||||
json.encodeToString(JamPresence.serializer(), presence),
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
}
|
||||
|
||||
/** Marks us as the host in presence (host takeover). */
|
||||
@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent {
|
||||
publishJson(
|
||||
presenceTopic(code, presence.clientId),
|
||||
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent {
|
||||
host = broker.host,
|
||||
port = broker.port,
|
||||
tlsEnabled = broker.useTls,
|
||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
||||
)
|
||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds)
|
||||
val code = roomCode ?: "unset"
|
||||
val will = WillConfig.Enabled(
|
||||
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
||||
payload = json.encodeToString(
|
||||
JamPresence.serializer(),
|
||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
||||
).toReadBuffer(Charset.UTF8),
|
||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
retain = true,
|
||||
)
|
||||
retain = true)
|
||||
val request = ConnectionRequest(
|
||||
clientId = clientId,
|
||||
keepAliveSeconds = broker.keepAliveSeconds,
|
||||
cleanStart = true,
|
||||
userName = broker.username,
|
||||
password = broker.password,
|
||||
will = will,
|
||||
)
|
||||
will = will)
|
||||
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
||||
val brokerRef = persistence.addBroker(connection, request)
|
||||
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
||||
@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent {
|
||||
val operation = mqtt.subscribe(
|
||||
roomFilter(code),
|
||||
OpaquePublishPayloadCodec,
|
||||
QualityOfService.AT_LEAST_ONCE,
|
||||
) { publish, payload ->
|
||||
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
||||
route(publish.topic.toString(), payload)
|
||||
}
|
||||
runCatching { operation.subAck.await() }
|
||||
@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent {
|
||||
topicName = topic,
|
||||
qos = QualityOfService.AT_LEAST_ONCE,
|
||||
payload = payload.toReadBuffer(Charset.UTF8),
|
||||
retain = retain,
|
||||
)
|
||||
retain = retain)
|
||||
}.onFailure { e ->
|
||||
log.w(e) { "Failed to publish to $topic" }
|
||||
}
|
||||
|
||||
@ -59,15 +59,13 @@ private data class PlaybackBroadcast(
|
||||
val queue: List<QueueEntry>,
|
||||
val current: QueueEntry?,
|
||||
val shuffle: Boolean,
|
||||
val playerState: PlayerState,
|
||||
)
|
||||
val playerState: PlayerState)
|
||||
|
||||
class JamRoomService(
|
||||
private val jamClient: JamRoomClient,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val settingsRepository: SettingsRepository) {
|
||||
private val log = Logger.withTag("JamRoomService")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@ -171,8 +169,7 @@ class JamRoomService(
|
||||
code = code,
|
||||
clientId = localClientId,
|
||||
displayName = name,
|
||||
isHost = true,
|
||||
).map {
|
||||
isHost = true).map {
|
||||
_role.value = JamRole.Host
|
||||
_roomCode.value = code
|
||||
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
||||
@ -206,8 +203,7 @@ class JamRoomService(
|
||||
code = normalized,
|
||||
clientId = localClientId,
|
||||
displayName = name,
|
||||
isHost = false,
|
||||
).map {
|
||||
isHost = false).map {
|
||||
_role.value = JamRole.Guest
|
||||
_roomCode.value = normalized
|
||||
_participants.value = emptyList()
|
||||
@ -261,8 +257,7 @@ class JamRoomService(
|
||||
jamClient.publishCommand(
|
||||
JamMessage.SuggestTrack(
|
||||
mediaItem = JamMediaItem.fromTrack(track),
|
||||
addedBy = localClientId,
|
||||
)
|
||||
addedBy = localClientId)
|
||||
)
|
||||
}
|
||||
|
||||
@ -271,8 +266,7 @@ class JamRoomService(
|
||||
jamClient.publishCommand(
|
||||
JamMessage.SuggestPlaylist(
|
||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||
addedBy = localClientId,
|
||||
)
|
||||
addedBy = localClientId)
|
||||
)
|
||||
}
|
||||
|
||||
@ -296,8 +290,7 @@ class JamRoomService(
|
||||
audioPlayerQueue.queueFlow,
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.shuffleModeFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
) { queue, current, shuffle, playerState ->
|
||||
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||
}
|
||||
.onEach { broadcast ->
|
||||
@ -314,8 +307,7 @@ class JamRoomService(
|
||||
currentIndex = index.coerceAtLeast(0),
|
||||
shuffleEnabled = broadcast.shuffle,
|
||||
follow = now() > autoAdvanceDeadlineMs,
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
||||
)
|
||||
isPlaying = broadcast.playerState == PlayerState.PLAYING)
|
||||
)
|
||||
}
|
||||
.launchIn(this)
|
||||
@ -374,8 +366,7 @@ class JamRoomService(
|
||||
audioPlayerQueue.load(
|
||||
entries = items.map { it.toQueueEntry() },
|
||||
autoPlay = wasPlaying,
|
||||
startPosition = startIndex,
|
||||
)
|
||||
startPosition = startIndex)
|
||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||
return
|
||||
}
|
||||
@ -524,13 +515,11 @@ class JamRoomService(
|
||||
explicit = null,
|
||||
popularity = null,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
url = "",
|
||||
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||
addedBy = addedBy,
|
||||
)
|
||||
addedBy = addedBy)
|
||||
|
||||
else -> QueueEntry.LocalTrack(
|
||||
name = title,
|
||||
@ -539,8 +528,7 @@ class JamRoomService(
|
||||
album = album.ifBlank { null },
|
||||
coverBytes = null,
|
||||
url = url,
|
||||
addedBy = addedBy,
|
||||
)
|
||||
addedBy = addedBy)
|
||||
}
|
||||
|
||||
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
||||
|
||||
@ -125,8 +125,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Artist> {
|
||||
ArtistScreen(
|
||||
@ -136,8 +135,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Album> {
|
||||
AlbumScreen(
|
||||
@ -148,8 +146,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Lyrics> {
|
||||
LyricsScreen(viewModel = koinViewModel())
|
||||
@ -162,8 +159,7 @@ val navigationModule = module {
|
||||
),
|
||||
audioPlayerQueue = get(),
|
||||
audioPlayer = get(),
|
||||
navigationCommands = get(),
|
||||
)
|
||||
navigationCommands = get())
|
||||
}
|
||||
navigation<Routes.Blacklist> {
|
||||
BlacklistScreen()
|
||||
|
||||
@ -46,8 +46,7 @@ val TOP_LEVEL_ROUTES = setOf(
|
||||
Routes.Library,
|
||||
Routes.Settings,
|
||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
||||
Routes.Welcome,
|
||||
)
|
||||
Routes.Welcome)
|
||||
|
||||
val serializersConfig = SavedStateConfiguration {
|
||||
serializersModule = SerializersModule {
|
||||
@ -123,8 +122,7 @@ fun NavigationState.toEntries(
|
||||
): SnapshotStateList<NavEntry<NavKey>> {
|
||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||
val decorators = listOf(
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
||||
)
|
||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>())
|
||||
rememberDecoratedNavEntries(
|
||||
backStack = stack,
|
||||
entryDecorators = decorators,
|
||||
|
||||
@ -49,5 +49,4 @@ data class MediaStream(
|
||||
val container: String,
|
||||
val bitrate: Int,
|
||||
val isAudio: Boolean,
|
||||
val isVideo: Boolean,
|
||||
)
|
||||
val isVideo: Boolean)
|
||||
@ -34,8 +34,7 @@ class CollectionPlaybackHelper(
|
||||
private val savedTracksRepository: SavedTracksRepository,
|
||||
private val artistRepository: ArtistRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
) {
|
||||
private val blacklistRepository: BlacklistRepository) {
|
||||
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
||||
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
||||
@ -44,8 +43,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -54,8 +52,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -87,8 +84,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||
}
|
||||
|
||||
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
||||
@ -99,8 +95,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -109,8 +104,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
}
|
||||
|
||||
@ -142,8 +136,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||
}
|
||||
|
||||
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
||||
@ -154,8 +147,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -166,8 +158,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
}
|
||||
|
||||
@ -190,8 +181,7 @@ class CollectionPlaybackHelper(
|
||||
if (entries.isNotEmpty()) {
|
||||
audioPlayerQueue.addAllToQueue(
|
||||
entries,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
}
|
||||
|
||||
@ -216,8 +206,7 @@ class CollectionPlaybackHelper(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = startPosition,
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||
)
|
||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||
}
|
||||
|
||||
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
||||
@ -313,8 +302,7 @@ class CollectionPlaybackHelper(
|
||||
private fun isTrackBlacklisted(
|
||||
track: MetadataTrack,
|
||||
blacklistedTrackIds: Set<String>,
|
||||
blacklistedArtistIds: Set<String>,
|
||||
): Boolean {
|
||||
blacklistedArtistIds: Set<String>): Boolean {
|
||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||
}
|
||||
|
||||
|
||||
@ -17,17 +17,24 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.remote
|
||||
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Row
|
||||
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.LaunchedEffect
|
||||
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.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
|
||||
|
||||
@Composable
|
||||
@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() {
|
||||
}
|
||||
|
||||
pendingRequest?.let { request ->
|
||||
AdaptiveDialogBottomSheet(
|
||||
onDismiss = {
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||
pendingRequest = null
|
||||
},
|
||||
title = {
|
||||
Text(
|
||||
text = "Remote Control Request",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
)
|
||||
},
|
||||
content = {
|
||||
Text(
|
||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Start,
|
||||
)
|
||||
},
|
||||
actions = {
|
||||
Button(
|
||||
title = "Remote Control Request") {
|
||||
Text(
|
||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
textAlign = TextAlign.Start)
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text("Deny")
|
||||
}
|
||||
Button(
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
}) {
|
||||
Text("Allow")
|
||||
}
|
||||
Button(
|
||||
MillenniumButton(
|
||||
onClick = {
|
||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||
pendingRequest = null
|
||||
},
|
||||
) {
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Text("Allow Always")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -162,8 +162,7 @@ class RemoteControlClient {
|
||||
|
||||
val envelope = CommandEnvelope(
|
||||
commandId = randomShortId(),
|
||||
command = command,
|
||||
)
|
||||
command = command)
|
||||
|
||||
try {
|
||||
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
||||
|
||||
@ -48,13 +48,11 @@ class RemoteControlHandler(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
) : KoinComponent {
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent {
|
||||
val logger by injectLogger<RemoteControlHandler>()
|
||||
|
||||
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
||||
extraBufferCapacity = 16,
|
||||
)
|
||||
extraBufferCapacity = 16)
|
||||
|
||||
private val json = Json {
|
||||
ignoreUnknownKeys = true
|
||||
@ -88,8 +86,7 @@ class RemoteControlHandler(
|
||||
deviceId = deviceId ?: "unknown",
|
||||
deviceName = deviceName,
|
||||
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)
|
||||
val response = waitForRequestResolution(request.sessionId)
|
||||
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
||||
@ -225,8 +222,7 @@ class RemoteControlHandler(
|
||||
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
is RemoteControlCommand.AddTrackToQueue -> {
|
||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
||||
@ -300,8 +296,7 @@ class RemoteControlHandler(
|
||||
private suspend fun handleCollectionSource(
|
||||
source: String,
|
||||
action: RemoteCollectionAction,
|
||||
shuffle: Boolean = false,
|
||||
) {
|
||||
shuffle: Boolean = false) {
|
||||
logger.d { "Remote collection $action for source: $source" }
|
||||
when {
|
||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||
@ -359,8 +354,7 @@ class RemoteControlHandler(
|
||||
currentTrackTitle = current?.titleOrNull(),
|
||||
currentTrackArtists = current?.artistsOrNull(),
|
||||
currentTrackAlbum = current?.albumOrNull(),
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
||||
)
|
||||
currentTrackCoverUrl = current?.coverUrlOrNull())
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
@ -375,8 +369,7 @@ class RemoteControlHandler(
|
||||
}
|
||||
val event = RemoteControlEvent.QueueUpdated(
|
||||
entries = queue.map { it.toRemoteQueueEntry() },
|
||||
currentIndex = currentIndex,
|
||||
)
|
||||
currentIndex = currentIndex)
|
||||
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
||||
session.send(Frame.Text(text))
|
||||
}
|
||||
@ -403,8 +396,7 @@ class RemoteControlHandler(
|
||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||
album = track.album?.title,
|
||||
coverUrl = coverUrlOrNull(),
|
||||
durationMs = track.durationMs,
|
||||
)
|
||||
durationMs = track.durationMs)
|
||||
|
||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||
mediaUrl = url,
|
||||
@ -413,8 +405,7 @@ class RemoteControlHandler(
|
||||
artists = artists.joinToString(", "),
|
||||
album = album,
|
||||
coverUrl = null,
|
||||
durationMs = duration,
|
||||
)
|
||||
durationMs = duration)
|
||||
}
|
||||
|
||||
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||
@ -447,8 +438,7 @@ class RemoteControlHandler(
|
||||
@Serializable
|
||||
data class CommandEnvelope(
|
||||
val commandId: String,
|
||||
val command: RemoteControlCommand,
|
||||
)
|
||||
val command: RemoteControlCommand)
|
||||
|
||||
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
||||
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
||||
@ -464,8 +454,7 @@ enum class RemoteCollectionAction {
|
||||
data class ConnectionRequest(
|
||||
val deviceId: String,
|
||||
val deviceName: String,
|
||||
val sessionId: String,
|
||||
)
|
||||
val sessionId: String)
|
||||
|
||||
enum class ConnectionRequestResponse {
|
||||
Allow,
|
||||
|
||||
@ -119,15 +119,13 @@ sealed class RemoteControlEvent {
|
||||
val currentTrackTitle: String?,
|
||||
val currentTrackArtists: String?,
|
||||
val currentTrackAlbum: String?,
|
||||
val currentTrackCoverUrl: String?,
|
||||
) : RemoteControlEvent()
|
||||
val currentTrackCoverUrl: String?) : RemoteControlEvent()
|
||||
|
||||
@Serializable
|
||||
@SerialName("queueUpdated")
|
||||
data class QueueUpdated(
|
||||
val entries: List<RemoteQueueEntry>,
|
||||
val currentIndex: Int,
|
||||
) : RemoteControlEvent()
|
||||
val currentIndex: Int) : RemoteControlEvent()
|
||||
|
||||
@Serializable
|
||||
@SerialName("ack")
|
||||
@ -146,5 +144,4 @@ data class RemoteQueueEntry(
|
||||
val artists: String,
|
||||
val album: String?,
|
||||
val coverUrl: String?,
|
||||
val durationMs: Long,
|
||||
)
|
||||
val durationMs: Long)
|
||||
|
||||
@ -53,8 +53,7 @@ import kotlin.random.Random
|
||||
class RemoteControlService(
|
||||
private val settingsRepository: SettingsRepository,
|
||||
private val discoveryService: DeviceDiscoveryService,
|
||||
private val localServer: LocalServer,
|
||||
) {
|
||||
private val localServer: LocalServer) {
|
||||
private val log = Logger.withTag("RemoteControlService")
|
||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||
|
||||
@ -78,8 +77,7 @@ class RemoteControlService(
|
||||
scope.launch {
|
||||
combine(
|
||||
settingsRepository.userSettings,
|
||||
localServer.port,
|
||||
) { settings, port -> settings to port }
|
||||
localServer.port) { settings, port -> settings to port }
|
||||
.distinctUntilChanged()
|
||||
.collect { (settings, port) ->
|
||||
if (settings.allowRemoteControl && port != null) {
|
||||
@ -139,8 +137,7 @@ class RemoteControlService(
|
||||
val service = discoveryService.createService(
|
||||
name = serviceName,
|
||||
port = port,
|
||||
deviceId = deviceId,
|
||||
)
|
||||
deviceId = deviceId)
|
||||
pendingService = service
|
||||
try {
|
||||
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
||||
|
||||
@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest {
|
||||
val type: RemoteCollectionType,
|
||||
val id: String,
|
||||
val startTrack: MetadataTrack? = null,
|
||||
val shuffle: Boolean = false,
|
||||
) : PlaybackDestinationRequest
|
||||
val shuffle: Boolean = false) : PlaybackDestinationRequest
|
||||
|
||||
data class Track(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val track: MetadataTrack,
|
||||
) : PlaybackDestinationRequest
|
||||
val track: MetadataTrack) : PlaybackDestinationRequest
|
||||
|
||||
data class Tracks(
|
||||
override val title: String,
|
||||
override val action: PlaybackDestinationAction,
|
||||
val tracks: List<MetadataTrack>,
|
||||
) : PlaybackDestinationRequest
|
||||
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
|
||||
}
|
||||
|
||||
/**
|
||||
@ -90,8 +87,7 @@ class RemotePlaybackController(
|
||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val jamRoomService: JamRoomService,
|
||||
) : KoinComponent {
|
||||
private val jamRoomService: JamRoomService) : KoinComponent {
|
||||
private val logger = Logger.withTag("RemotePlaybackController")
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||
|
||||
@ -113,8 +109,7 @@ class RemotePlaybackController(
|
||||
id: String,
|
||||
title: String,
|
||||
startTrack: MetadataTrack? = null,
|
||||
shuffle: Boolean = false,
|
||||
) {
|
||||
shuffle: Boolean = false) {
|
||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
||||
}
|
||||
|
||||
@ -205,8 +200,7 @@ class RemotePlaybackController(
|
||||
QueueEntry.StreamingTrack(
|
||||
track = track,
|
||||
url = "",
|
||||
addedBy = jamRoomService.participantClientId,
|
||||
)
|
||||
addedBy = jamRoomService.participantClientId)
|
||||
}
|
||||
)
|
||||
|
||||
@ -291,8 +285,7 @@ class RemotePlaybackController(
|
||||
entries = listOf(entry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
@ -319,8 +312,7 @@ class RemotePlaybackController(
|
||||
entries = entries,
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
|
||||
PlaybackDestinationAction.AddToQueue -> {
|
||||
|
||||
@ -52,8 +52,7 @@ class ScrobbleManager(
|
||||
private val audioPlayer: AudioPlayerInterface,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val scrobblePluginSource: ScrobblePluginSource,
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
||||
) : KoinComponent {
|
||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<ScrobbleManager>()
|
||||
|
||||
@ -69,14 +68,12 @@ class ScrobbleManager(
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
audioPlayer.playerStateFlow,
|
||||
audioPlayer.positionFlow,
|
||||
audioPlayer.durationFlow,
|
||||
) { entry, playerState, position, duration ->
|
||||
audioPlayer.durationFlow) { entry, playerState, position, duration ->
|
||||
PlaybackSnapshot(
|
||||
entry = entry,
|
||||
playerState = playerState,
|
||||
positionMs = position.inWholeMilliseconds,
|
||||
playerDurationMs = duration.inWholeMilliseconds,
|
||||
)
|
||||
playerDurationMs = duration.inWholeMilliseconds)
|
||||
}.collect(::onPlaybackSnapshot)
|
||||
}
|
||||
}
|
||||
@ -137,8 +134,7 @@ class ScrobbleManager(
|
||||
scrobbled = true
|
||||
val track = entry.toScrobbleTrack(
|
||||
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
scope.launch { submitScrobble(track) }
|
||||
}
|
||||
|
||||
@ -197,8 +193,7 @@ class ScrobbleManager(
|
||||
artistName = track.artists.joinToString(", ") { it.name },
|
||||
albumName = track.album?.title,
|
||||
streamingProvider = resolveStreamingProvider(track),
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
|
||||
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
||||
timestamp = timestamp,
|
||||
@ -209,8 +204,7 @@ class ScrobbleManager(
|
||||
artistName = artists.joinToString(", "),
|
||||
albumName = album,
|
||||
streamingProvider = LOCAL_PROVIDER,
|
||||
durationMs = durationMs,
|
||||
)
|
||||
durationMs = durationMs)
|
||||
}
|
||||
}
|
||||
|
||||
@ -245,8 +239,7 @@ class ScrobbleManager(
|
||||
val entry: QueueEntry?,
|
||||
val playerState: PlayerState,
|
||||
val positionMs: Long,
|
||||
val playerDurationMs: Long,
|
||||
)
|
||||
val playerDurationMs: Long)
|
||||
|
||||
companion object {
|
||||
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
||||
|
||||
@ -29,8 +29,7 @@ class AlternativeTracksRepository(
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
) : KoinComponent {
|
||||
private val cacheManager: CacheManager) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||
|
||||
|
||||
@ -51,13 +51,11 @@ data class CacheEntry(
|
||||
val filename: String,
|
||||
val sizeBytes: Long,
|
||||
val createdAtMs: Long,
|
||||
val contentType: String = "application/octet-stream",
|
||||
)
|
||||
val contentType: String = "application/octet-stream")
|
||||
|
||||
class CacheManager(
|
||||
private val paths: Paths,
|
||||
private val settingsRepository: SettingsRepository,
|
||||
) {
|
||||
private val settingsRepository: SettingsRepository) {
|
||||
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
||||
private val cacheMutex: Mutex = Mutex()
|
||||
private val json: Json = Json { ignoreUnknownKeys = true }
|
||||
@ -160,15 +158,13 @@ class CacheManager(
|
||||
trackId: String,
|
||||
filename: String,
|
||||
allBytes: ByteArray,
|
||||
contentType: String,
|
||||
) {
|
||||
contentType: String) {
|
||||
val entry = CacheEntry(
|
||||
trackId = trackId,
|
||||
filename = filename,
|
||||
sizeBytes = allBytes.size.toLong(),
|
||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||
contentType = contentType,
|
||||
)
|
||||
contentType = contentType)
|
||||
cacheMutex.withLock {
|
||||
val index = readCacheIndex()
|
||||
writeCacheIndex(CacheIndex(
|
||||
|
||||
@ -60,8 +60,7 @@ class LocalServer(
|
||||
private val streamingUrlRepository: StreamingUrlRepository,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val cacheManager: CacheManager,
|
||||
private val remoteControlHandler: RemoteControlHandler,
|
||||
) : KoinComponent {
|
||||
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
|
||||
|
||||
val logger by injectLogger<LocalServer>()
|
||||
private val httpClient = HttpClient {
|
||||
@ -99,8 +98,7 @@ class LocalServer(
|
||||
audioPlayerQueue = audioPlayerQueue,
|
||||
isCachingEnabled = { cachedCacheEnabled.value },
|
||||
activePort = { activePort.value },
|
||||
scope = scope,
|
||||
)
|
||||
scope = scope)
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
||||
@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json
|
||||
@Serializable
|
||||
data class PluginTaggedAudioSource(
|
||||
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. */
|
||||
interface TrackSourceRepository {
|
||||
|
||||
@ -62,8 +62,7 @@ internal class StreamProxy(
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val isCachingEnabled: () -> Boolean,
|
||||
private val activePort: () -> Int?,
|
||||
private val scope: CoroutineScope,
|
||||
): KoinComponent {
|
||||
private val scope: CoroutineScope): KoinComponent {
|
||||
val logger by injectLogger<StreamProxy>()
|
||||
|
||||
companion object {
|
||||
@ -414,8 +413,7 @@ internal class StreamProxy(
|
||||
trackId = trackIdForCache,
|
||||
filename = cacheFilename,
|
||||
allBytes = allBytes,
|
||||
contentType = cacheContentType,
|
||||
)
|
||||
contentType = cacheContentType)
|
||||
scope.launch {
|
||||
cacheManager.evictIfNeeded()
|
||||
}
|
||||
|
||||
@ -48,28 +48,24 @@ data class CachedStreamUrl(
|
||||
// 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
|
||||
// audio plugin, which would otherwise play back completely unrelated content.
|
||||
val pluginId: String,
|
||||
)
|
||||
val pluginId: String)
|
||||
|
||||
private data class CachedAlternatives(
|
||||
val pluginId: String,
|
||||
val sources: List<AudioSource>,
|
||||
)
|
||||
val sources: List<AudioSource>)
|
||||
|
||||
data class StreamInfo(
|
||||
val url: String,
|
||||
val protocol: StreamProtocol,
|
||||
val codec: String,
|
||||
val container: String,
|
||||
)
|
||||
val container: String)
|
||||
|
||||
class StreamingUrlRepository(
|
||||
private val pluginManager: AudioPluginSource,
|
||||
private val audioPlayerQueue: AudioPlayerQueue,
|
||||
private val matchedTracksRepository: TrackSourceRepository,
|
||||
private val settingsRepository: UserSettingsSource,
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||
) : KoinComponent {
|
||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||
|
||||
private val logger by injectLogger<StreamingUrlRepository>()
|
||||
private val streamUrlCacheMutex = Mutex()
|
||||
@ -133,8 +129,7 @@ class StreamingUrlRepository(
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
preferredQuality)
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
@ -193,8 +188,7 @@ class StreamingUrlRepository(
|
||||
val resolvedStream = selectPreferredAudioStream(
|
||||
stream.streams,
|
||||
preferredFormat,
|
||||
preferredQuality,
|
||||
)
|
||||
preferredQuality)
|
||||
if (resolvedStream != null) {
|
||||
val info = resolvedStream.toStreamInfo()
|
||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||
@ -207,15 +201,13 @@ class StreamingUrlRepository(
|
||||
url = normalizeManifestUrl(url, protocol),
|
||||
protocol = protocol,
|
||||
codec = codec,
|
||||
container = container,
|
||||
)
|
||||
container = container)
|
||||
|
||||
private suspend fun getCachedStreamUrl(
|
||||
trackId: String,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
pluginId: String,
|
||||
): StreamInfo? {
|
||||
pluginId: String): StreamInfo? {
|
||||
val now = Clock.System.now().toEpochMilliseconds()
|
||||
return streamUrlCacheMutex.withLock {
|
||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||
@ -241,8 +233,7 @@ class StreamingUrlRepository(
|
||||
preferredQuality: AudioQuality,
|
||||
source: AudioSource.Streamed,
|
||||
selectedStream: AudioStream,
|
||||
pluginId: String,
|
||||
) {
|
||||
pluginId: String) {
|
||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||
streamUrlCacheMutex.withLock {
|
||||
streamUrlCache[trackId] = CachedStreamUrl(
|
||||
@ -255,8 +246,7 @@ class StreamingUrlRepository(
|
||||
selectedStream = selectedStream,
|
||||
preferredFormat = preferredFormat,
|
||||
preferredQuality = preferredQuality,
|
||||
pluginId = pluginId,
|
||||
)
|
||||
pluginId = pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
||||
internal fun selectPreferredAudioStream(
|
||||
streams: List<AudioStream>,
|
||||
preferredFormat: AudioFormat,
|
||||
preferredQuality: AudioQuality,
|
||||
): AudioStream? = streams.minWithOrNull(
|
||||
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||
compareBy<AudioStream> { stream ->
|
||||
formatMismatchCount(stream, preferredFormat)
|
||||
}.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
|
||||
|
||||
|
||||
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.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
@ -25,75 +32,54 @@ import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
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.Iconsax
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun AdaptiveDialogBottomSheet(
|
||||
onDismiss: () -> Unit,
|
||||
title: @Composable (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
breakpointDp: Float = 600f,
|
||||
actions: @Composable (() -> Unit)? = null,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
content: @Composable () -> Unit) {
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
|
||||
if (isLargeScreen) {
|
||||
ThemedDialog(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = if (title != null) {
|
||||
{
|
||||
Row(
|
||||
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,
|
||||
) {
|
||||
title = "",
|
||||
modifier = modifier) {
|
||||
title?.let { it() }
|
||||
content()
|
||||
actions?.let {
|
||||
MillenniumSeparator()
|
||||
it()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
modifier = modifier,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp)
|
||||
.padding(top = 16.dp),
|
||||
horizontalAlignment = Alignment.Start,
|
||||
) {
|
||||
horizontalAlignment = Alignment.Start) {
|
||||
title?.let {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
@ -101,11 +87,12 @@ fun AdaptiveDialogBottomSheet(
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Box(Modifier.weight(1f)) { it() }
|
||||
IconButton(onClick = onDismiss) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
Iconsax.FluentDismiss,
|
||||
contentDescription = "Close dialog",
|
||||
)
|
||||
contentDescription = "Close dialog")
|
||||
}
|
||||
}
|
||||
Spacer(Modifier.height(12.dp))
|
||||
@ -113,7 +100,7 @@ fun AdaptiveDialogBottomSheet(
|
||||
content()
|
||||
actions?.let {
|
||||
Spacer(Modifier.height(8.dp))
|
||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||
MillenniumSeparator()
|
||||
Spacer(Modifier.height(8.dp))
|
||||
it()
|
||||
}
|
||||
|
||||
@ -17,13 +17,12 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
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
|
||||
@ -34,20 +33,12 @@ 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.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@ -55,25 +46,37 @@ 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.graphics.vector.ImageVector
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.unit.Dp
|
||||
import androidx.compose.ui.unit.DpOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.composeunstyled.DropdownMenuPanel
|
||||
import com.composeunstyled.SheetDetent
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.UnstyledDropdownMenu
|
||||
import com.composeunstyled.UnstyledDropdownMenuItem
|
||||
import com.composeunstyled.rememberModalBottomSheetState
|
||||
import compose.icons.FeatherIcons
|
||||
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.core.ui.theming.AppTheme
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||
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 org.spotube.millennium.theme.MillenniumTheme
|
||||
import org.spotube.millennium.theme.gelBrush
|
||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
||||
import org.spotube.millennium.theme.millenniumSurface
|
||||
|
||||
data class AdaptiveMenuItem(
|
||||
val icon: ImageVector? = null,
|
||||
@ -83,8 +86,7 @@ data class AdaptiveMenuItem(
|
||||
val selected: Boolean = false,
|
||||
val dividerBefore: Boolean = false,
|
||||
val subtitle: String? = null,
|
||||
val leadingContent: (@Composable () -> Unit)? = null,
|
||||
)
|
||||
val leadingContent: (@Composable () -> Unit)? = null)
|
||||
|
||||
enum class HeaderDisplayMode {
|
||||
Always,
|
||||
@ -97,73 +99,129 @@ fun AdaptiveDropdownBottomSheet(
|
||||
items: List<AdaptiveMenuItem>,
|
||||
trigger: @Composable (onClick: () -> Unit) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
breakpointDp: Float = 600f,
|
||||
menuMinWidth: Dp = 200.dp,
|
||||
header: @Composable (() -> Unit)? = null,
|
||||
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
||||
) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
var expanded by remember { mutableStateOf(false) }
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||
|
||||
Box(modifier = modifier) {
|
||||
trigger { expanded = true }
|
||||
|
||||
if (isLargeScreen) {
|
||||
DropdownMenu(
|
||||
UnstyledDropdownMenu(
|
||||
expanded = expanded,
|
||||
onDismissRequest = { expanded = false },
|
||||
) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
|
||||
var query by remember { mutableStateOf("") }
|
||||
if (filter != null) {
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
},
|
||||
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
|
||||
onExpandedChange = { expanded = it },
|
||||
panel = {
|
||||
DropdownMenuPanel(
|
||||
Modifier
|
||||
.width(menuMinWidth)
|
||||
.millenniumSurface(
|
||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||
shape = AppTheme.shapes.medium,
|
||||
borderColor = AppTheme.colors.outlineVariant,
|
||||
borderWidth = 1.dp)
|
||||
.padding(4.dp)) {
|
||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||
header()
|
||||
}
|
||||
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 {
|
||||
trigger { expanded = true }
|
||||
if (expanded) {
|
||||
AdaptiveBottomSheetContent(
|
||||
onDismiss = { expanded = false },
|
||||
@ -173,119 +231,97 @@ fun AdaptiveDropdownBottomSheet(
|
||||
} else {
|
||||
null
|
||||
},
|
||||
filter = filter,
|
||||
)
|
||||
filter = filter)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun AdaptiveBottomSheetContent(
|
||||
onDismiss: () -> Unit,
|
||||
items: List<AdaptiveMenuItem>,
|
||||
header: @Composable (() -> Unit)?,
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
||||
) {
|
||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||
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()) {
|
||||
items.filter { filter(it, query) }
|
||||
} else {
|
||||
items
|
||||
}
|
||||
ModalBottomSheet(
|
||||
onDismissRequest = onDismiss,
|
||||
dragHandle = null,
|
||||
) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
header?.invoke()
|
||||
|
||||
if (filter != null) {
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
TextField(
|
||||
value = query,
|
||||
onValueChange = {
|
||||
query = it
|
||||
},
|
||||
placeholder = { Text("Search...") },
|
||||
singleLine = true,
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Search",
|
||||
modifier = Modifier.size(18.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
},
|
||||
MillenniumTextField(
|
||||
state = queryState,
|
||||
label = "",
|
||||
placeholder = "Search...",
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||
)
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||
}
|
||||
|
||||
LazyColumn(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
items(
|
||||
count = filteredItems.size,
|
||||
) { index ->
|
||||
count = filteredItems.size) { index ->
|
||||
val item = filteredItems[index]
|
||||
|
||||
if (item.dividerBefore) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(vertical = 4.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(MaterialTheme.shapes.medium)
|
||||
.clip(AppTheme.shapes.medium)
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = {
|
||||
item.onClick()
|
||||
onDismiss()
|
||||
},
|
||||
)
|
||||
})
|
||||
.background(
|
||||
color = if (item.selected) {
|
||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
|
||||
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
},
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
)
|
||||
shape = AppTheme.shapes.medium)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
when {
|
||||
item.leadingContent != null -> item.leadingContent.invoke()
|
||||
item.icon != null -> Icon(
|
||||
item.icon != null -> MillenniumIcon(
|
||||
imageVector = item.icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
|
||||
item.selected -> Icon(
|
||||
item.selected -> MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||
}
|
||||
@ -293,29 +329,26 @@ private fun AdaptiveBottomSheetContent(
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
item.subtitle?.let { subtitle ->
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Check,
|
||||
contentDescription = "Selected",
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -327,44 +360,37 @@ private fun AdaptiveBottomSheetContent(
|
||||
@Preview
|
||||
@Composable
|
||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||
BaseScaffold {
|
||||
Box(modifier = Modifier.background(AppTheme.colors.background)) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxHeart,
|
||||
label = "Favorite",
|
||||
onClick = {},
|
||||
),
|
||||
onClick = {}),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxShare,
|
||||
label = "Share",
|
||||
onClick = {},
|
||||
),
|
||||
onClick = {}),
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxTrash,
|
||||
label = "Delete",
|
||||
onClick = {},
|
||||
enabled = false,
|
||||
),
|
||||
),
|
||||
enabled = false)),
|
||||
trigger = { onClick ->
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
modifier = Modifier
|
||||
.size(32.dp)
|
||||
.background(
|
||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape,
|
||||
)
|
||||
color = AppTheme.colors.primary.copy(alpha = 0.1f),
|
||||
shape = CircleShape)
|
||||
.clip(CircleShape),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = null,
|
||||
tint = MaterialTheme.colorScheme.primary,
|
||||
)
|
||||
tint = AppTheme.colors.primary)
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,8 +40,7 @@ fun AlbumCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
PlayableCard(
|
||||
@ -57,17 +56,14 @@ fun AlbumCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
album.title)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Album,
|
||||
album.id,
|
||||
album.title,
|
||||
)
|
||||
album.title)
|
||||
},
|
||||
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
|
||||
|
||||
|
||||
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.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||
@ -30,12 +33,12 @@ import org.koin.compose.koinInject
|
||||
fun ApplicationBackButton() {
|
||||
val navigationCommands: NavigationCommands = koinInject()
|
||||
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigationCommands.pop()
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.ArrowLeft3,
|
||||
contentDescription = "Back"
|
||||
)
|
||||
@ -48,5 +51,4 @@ expect fun ApplicationMainBar(
|
||||
subtitle: @Composable () -> Unit = {},
|
||||
actions: @Composable (RowScope.() -> Unit) = {},
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -52,8 +52,7 @@ import org.koin.compose.koinInject
|
||||
fun ArtistCard(
|
||||
artist: MetadataArtist.Basic,
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
isBlacklisted: Boolean = false) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
|
||||
AvatarCard(
|
||||
@ -64,7 +63,6 @@ fun ArtistCard(
|
||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||
},
|
||||
modifier = modifier,
|
||||
isBlacklisted = isBlacklisted,
|
||||
)
|
||||
isBlacklisted = isBlacklisted)
|
||||
}
|
||||
|
||||
|
||||
@ -17,13 +17,24 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
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.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
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
|
||||
@ -31,9 +42,9 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -42,12 +53,6 @@ import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
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.shimmerApply
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||
@ -62,7 +67,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
|
||||
import org.jetbrains.compose.resources.DrawableResource
|
||||
import org.jetbrains.compose.resources.painterResource
|
||||
|
||||
|
||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||
@Composable
|
||||
fun CollectionDetails(
|
||||
@ -85,10 +89,9 @@ fun CollectionDetails(
|
||||
onFollowClick: () -> Unit = { },
|
||||
showFollowButton: Boolean = true,
|
||||
onEdit: (() -> Unit)? = null,
|
||||
sharedElementKey: String? = null,
|
||||
) {
|
||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||
val isCompact = maxWidth < 600.dp
|
||||
sharedElementKey: String? = null) {
|
||||
Box(modifier = modifier.fillMaxWidth()) {
|
||||
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
@ -98,95 +101,86 @@ fun CollectionDetails(
|
||||
if (onAddToJam != null) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
PrimaryButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
MillenniumButton(
|
||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||
onClick = onAddToJam!!,
|
||||
) {
|
||||
Icon(
|
||||
onClick = onAddToJam!!) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to Jam",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = "Add to Jam",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
contentDescription = "Add to Jam")
|
||||
TextWithShimmer(text = "Add to Jam")
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
val modifier = if (isCompact) {
|
||||
Modifier.weight(1f)
|
||||
} else {
|
||||
Modifier
|
||||
}
|
||||
|
||||
PrimaryButton(
|
||||
MillenniumButton(
|
||||
modifier = modifier,
|
||||
onClick = onPlay,
|
||||
) {
|
||||
Icon(
|
||||
onClick = onPlay) {
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
TextWithShimmer(
|
||||
text = if (isPlaying) "Pause" else "Play",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
|
||||
}
|
||||
OutlineButton(
|
||||
MillenniumButton(
|
||||
modifier = modifier,
|
||||
onClick = onShufflePlay
|
||||
onClick = onShufflePlay,
|
||||
style = MillenniumButtonStyle.Secondary
|
||||
) {
|
||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(
|
||||
text = "Shuffle",
|
||||
modifier = Modifier.padding(start = 6.dp),
|
||||
)
|
||||
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||
TextWithShimmer(text = "Shuffle")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
val actions = @Composable {
|
||||
ButtonGroup {
|
||||
GroupIconButton(onClick = onAddToQueue) {
|
||||
Icon(
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue"
|
||||
)
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
if (showFollowButton) {
|
||||
if (isFollowing) {
|
||||
GroupIconButton(onClick = onFollowClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart2,
|
||||
contentDescription = "Unfollow",
|
||||
)
|
||||
contentDescription = "Unfollow")
|
||||
}
|
||||
} else {
|
||||
GroupIconButton(onClick = onFollowClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxHeart,
|
||||
contentDescription = "Follow",
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
if (onEdit != null) {
|
||||
ButtonGroupDivider()
|
||||
GroupIconButton(onClick = onEdit) {
|
||||
Icon(
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
MillenniumIconButton(
|
||||
onClick = onEdit,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxEdit,
|
||||
contentDescription = "Edit playlist",
|
||||
)
|
||||
contentDescription = "Edit playlist")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,8 +189,7 @@ fun CollectionDetails(
|
||||
Row(
|
||||
modifier = Modifier.clickable(onClick = onOwnerClick),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (ownerImageURL != null) {
|
||||
AsyncImage(
|
||||
model = ownerImageURL,
|
||||
@ -205,27 +198,24 @@ fun CollectionDetails(
|
||||
.size(16.dp)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = ownerName,
|
||||
modifier = Modifier
|
||||
.size(16.dp)
|
||||
.background(
|
||||
MaterialTheme.colorScheme.primaryContainer,
|
||||
AppTheme.colors.primaryContainer,
|
||||
CircleShape
|
||||
)
|
||||
.padding(6.dp),
|
||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||
)
|
||||
tint = AppTheme.colors.onPrimaryContainer)
|
||||
}
|
||||
TextWithShimmer(
|
||||
text = "By $ownerName",
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.labelSmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
|
||||
@ -237,79 +227,68 @@ fun CollectionDetails(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
.shimmerApply(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (imageURL.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else if (imageResource != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(imageResource),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Text(
|
||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||
style = MaterialTheme.typography.displaySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.displaySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
val metadata = @Composable {
|
||||
Column {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge,
|
||||
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
|
||||
if (description.isNotBlank()) {
|
||||
TextWithShimmer(
|
||||
text = description,
|
||||
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
if (isCompact) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(14.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
artwork()
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||
metadata()
|
||||
ownerInfo()
|
||||
actions()
|
||||
@ -323,14 +302,12 @@ fun CollectionDetails(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
artwork()
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
metadata()
|
||||
ownerInfo()
|
||||
actions()
|
||||
@ -354,6 +331,5 @@ fun CollectionDetailsPreview() {
|
||||
onOwnerClick = {},
|
||||
onPlay = {},
|
||||
onShufflePlay = {},
|
||||
onAddToQueue = {},
|
||||
)
|
||||
onAddToQueue = {})
|
||||
}
|
||||
|
||||
@ -17,18 +17,22 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
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 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.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -93,8 +97,7 @@ fun CollectionView(
|
||||
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||
footerContent: (@Composable () -> Unit)? = null,
|
||||
trailingContent: @Composable () -> Unit = {},
|
||||
) {
|
||||
trailingContent: @Composable () -> Unit = {}) {
|
||||
val listState = rememberLazyListState()
|
||||
val isCollapsed by remember {
|
||||
derivedStateOf {
|
||||
@ -103,45 +106,42 @@ fun CollectionView(
|
||||
}
|
||||
}
|
||||
|
||||
BaseScaffold(
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = if (isCollapsed) {
|
||||
{
|
||||
CollapsedCollectionTitle(
|
||||
title = title,
|
||||
imageURL = imageURL,
|
||||
imageResource = imageResource,
|
||||
)
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
backButton = true,
|
||||
title = if (isCollapsed) {
|
||||
{
|
||||
CollapsedCollectionTitle(
|
||||
title = title,
|
||||
imageURL = imageURL,
|
||||
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 {
|
||||
{}
|
||||
},
|
||||
actions = if (isCollapsed && !isJamGuest) {
|
||||
{
|
||||
IconButton(onClick = onPlay) {
|
||||
Icon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||
)
|
||||
}
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
},
|
||||
)
|
||||
}
|
||||
) { innerPadding ->
|
||||
}
|
||||
} else {
|
||||
{}
|
||||
})
|
||||
|
||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||
ErrorDisplay(
|
||||
errorMessage = error,
|
||||
onRetry = onRetry ?: {},
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
)
|
||||
modifier = Modifier.fillMaxSize())
|
||||
} else {
|
||||
TrackList(
|
||||
modifier = Modifier.padding(innerPadding),
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
state = listState,
|
||||
headerContent = {
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
@ -164,8 +164,7 @@ fun CollectionView(
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
onAddToJam = { onAddToJam(tracks) })
|
||||
}
|
||||
} else {
|
||||
CollectionDetails(
|
||||
@ -186,8 +185,7 @@ fun CollectionView(
|
||||
onEdit = onEdit,
|
||||
sharedElementKey = sharedElementKey,
|
||||
isJamGuest = isJamGuest,
|
||||
onAddToJam = { onAddToJam(tracks) },
|
||||
)
|
||||
onAddToJam = { onAddToJam(tracks) })
|
||||
}
|
||||
},
|
||||
footerContent = footerContent,
|
||||
@ -211,11 +209,11 @@ fun CollectionView(
|
||||
onBulkAddToJam = onBulkAddToJam,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
trackOptionsState = trackOptionsState,
|
||||
)
|
||||
trackOptionsState = trackOptionsState)
|
||||
}
|
||||
|
||||
trailingContent()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -223,46 +221,39 @@ fun CollectionView(
|
||||
private fun CollapsedCollectionTitle(
|
||||
title: String,
|
||||
imageURL: String,
|
||||
imageResource: DrawableResource? = null,
|
||||
) {
|
||||
imageResource: DrawableResource? = null) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(36.dp)
|
||||
.clip(RoundedCornerShape(6.dp))
|
||||
.shimmerApply(),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (imageURL.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else if (imageResource != null) {
|
||||
androidx.compose.foundation.Image(
|
||||
painter = painterResource(imageResource),
|
||||
contentDescription = title,
|
||||
modifier = Modifier.size(36.dp),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Text(
|
||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
style = AppTheme.typography.labelMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
}
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
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
|
||||
|
||||
|
||||
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.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 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.rememberScrollState
|
||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw
|
||||
fun ErrorDisplay(
|
||||
errorMessage: String,
|
||||
onRetry: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
var showFullLog by remember { mutableStateOf(false) }
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
@ -77,117 +79,99 @@ fun ErrorDisplay(
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
||||
) {
|
||||
Icon(
|
||||
verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.AlertCircle,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(64.dp),
|
||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||
)
|
||||
tint = AppTheme.colors.error.copy(alpha = 0.7f))
|
||||
|
||||
Text(
|
||||
text = "We're Truly Sorry",
|
||||
style = MaterialTheme.typography.headlineSmall,
|
||||
style = AppTheme.typography.headlineSmall,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
color = AppTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Center)
|
||||
|
||||
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.",
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = TextAlign.Center,
|
||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
|
||||
)
|
||||
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
|
||||
),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Error Details",
|
||||
style = MaterialTheme.typography.labelMedium,
|
||||
style = AppTheme.typography.labelMedium,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
||||
) {
|
||||
IconButton(
|
||||
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { showFullLog = true },
|
||||
modifier = Modifier.size(32.dp),
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Maximize2,
|
||||
contentDescription = "View full log",
|
||||
modifier = Modifier.size(16.dp),
|
||||
)
|
||||
modifier = Modifier.size(16.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Surface(
|
||||
MillenniumCard(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 5,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.padding(12.dp),
|
||||
)
|
||||
modifier = Modifier.padding(12.dp))
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Button(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
MillenniumButton(
|
||||
onClick = onRetry,
|
||||
modifier = Modifier.height(40.dp),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.RefreshCw,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
||||
)
|
||||
Text("Retry")
|
||||
modifier = Modifier.height(40.dp)) {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.RefreshCw,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.padding(end = 8.dp).size(18.dp))
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -195,93 +179,85 @@ fun ErrorDisplay(
|
||||
if (showFullLog) {
|
||||
ErrorLogDialog(
|
||||
errorMessage = errorMessage,
|
||||
onDismiss = { showFullLog = false },
|
||||
)
|
||||
onDismiss = { showFullLog = false })
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ErrorLogDialog(
|
||||
errorMessage: String,
|
||||
onDismiss: () -> Unit,
|
||||
) {
|
||||
onDismiss: () -> Unit) {
|
||||
val clipboardManager = LocalClipboardManager.current
|
||||
|
||||
AlertDialog(
|
||||
MillenniumDialog(
|
||||
visible = true,
|
||||
onDismissRequest = onDismiss,
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
Text(
|
||||
text = "Full Error Log",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
title = "") {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
Text(
|
||||
text = "Full Error Log",
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.Bold)
|
||||
|
||||
IconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
) {
|
||||
Icon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard",
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||
},
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = FeatherIcons.Copy,
|
||||
contentDescription = "Copy to clipboard")
|
||||
}
|
||||
},
|
||||
text = {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.clip(MaterialTheme.shapes.small)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
|
||||
) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = MaterialTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace,
|
||||
),
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.height(400.dp)
|
||||
.clip(AppTheme.shapes.small)
|
||||
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
||||
SelectionContainer {
|
||||
Text(
|
||||
text = errorMessage,
|
||||
style = AppTheme.typography.bodySmall.copy(
|
||||
fontFamily = FontFamily.Monospace),
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(12.dp))
|
||||
}
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = onDismiss) {
|
||||
}
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.End) {
|
||||
MillenniumButton(
|
||||
onClick = onDismiss,
|
||||
style = MillenniumButtonStyle.Quiet) {
|
||||
Text("Close")
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorDisplayPreview() {
|
||||
Surface {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
ErrorDisplay(
|
||||
errorMessage = "This is an error message",
|
||||
onRetry = {},
|
||||
)
|
||||
onRetry = {})
|
||||
}
|
||||
}
|
||||
|
||||
@Preview
|
||||
@Composable
|
||||
private fun ErrorLogDialogPreview() {
|
||||
BaseScaffold {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
ErrorLogDialog(
|
||||
errorMessage = "This is an error message",
|
||||
onDismiss = {},
|
||||
)
|
||||
onDismiss = {})
|
||||
}
|
||||
}
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.tween
|
||||
import androidx.compose.foundation.background
|
||||
@ -34,11 +38,11 @@ import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
@ -83,8 +87,7 @@ import org.koin.compose.koinInject
|
||||
@Composable
|
||||
fun FeaturedCarousel(
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val filteredItems = remember(items) {
|
||||
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
||||
}
|
||||
@ -92,20 +95,17 @@ fun FeaturedCarousel(
|
||||
if (filteredItems.isEmpty()) return
|
||||
|
||||
FeaturedCarouselContent(
|
||||
items = filteredItems,
|
||||
)
|
||||
items = filteredItems)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun FeaturedCarouselContent(
|
||||
items: List<MetadataBrowseItem>,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val centerIndex = items.size / 2
|
||||
var selectedIndex by remember { mutableIntStateOf(centerIndex) }
|
||||
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
|
||||
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||
val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600
|
||||
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
@ -113,8 +113,7 @@ private fun FeaturedCarouselContent(
|
||||
.height(
|
||||
if (isSmallScreen) 200.dp else 300.dp
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
Coverflow(
|
||||
state = state,
|
||||
params = CoverflowParams(
|
||||
@ -123,21 +122,17 @@ private fun FeaturedCarouselContent(
|
||||
angle = 12f,
|
||||
shift = 0.255f,
|
||||
zoom = 0.7864f,
|
||||
mirror = false,
|
||||
),
|
||||
modifier = modifier,
|
||||
) {
|
||||
mirror = false),
|
||||
modifier = modifier) {
|
||||
items(
|
||||
items = items,
|
||||
key = { index: Int -> items[index].hashCode() },
|
||||
) { item: MetadataBrowseItem ->
|
||||
key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem ->
|
||||
FeaturedCarouselCard(
|
||||
item = item,
|
||||
isCentered = selectedIndex == items.indexOf(item),
|
||||
onScrollToCenter = {
|
||||
state.scrollToItem(items.indexOf(item))
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -148,8 +143,7 @@ private fun FeaturedCarouselCard(
|
||||
item: MetadataBrowseItem,
|
||||
isCentered: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
onScrollToCenter: () -> Unit = { },
|
||||
) {
|
||||
onScrollToCenter: () -> Unit = { }) {
|
||||
val navigationCommands = koinInject<NavigationCommands>()
|
||||
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
||||
|
||||
@ -204,25 +198,21 @@ private fun FeaturedCarouselCard(
|
||||
}
|
||||
}
|
||||
.aspectRatio(1f)
|
||||
.hazeSource(hazeState),
|
||||
) {
|
||||
.hazeSource(hazeState)) {
|
||||
AsyncImage(
|
||||
model = thumbnailUrl,
|
||||
contentDescription = title,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black.copy(alpha = dimAlpha)),
|
||||
)
|
||||
.background(Color.Black.copy(alpha = dimAlpha)))
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -231,43 +221,35 @@ private fun FeaturedCarouselCard(
|
||||
brush = Brush.verticalGradient(
|
||||
colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Black.copy(alpha = 0.9f),
|
||||
),
|
||||
),
|
||||
),
|
||||
)
|
||||
Color.Black.copy(alpha = 0.9f)))))
|
||||
}
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.BottomCenter,
|
||||
) {
|
||||
contentAlignment = Alignment.BottomCenter) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
) {
|
||||
.padding(16.dp)) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
fontWeight = FontWeight.Bold,
|
||||
color = Color.White,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = Color.White.copy(alpha = 0.85f),
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
|
||||
val animatedAlpha by animateFloatAsState(
|
||||
@ -306,8 +288,7 @@ private fun FeaturedPlayButton(
|
||||
navigationCommands: NavigationCommands,
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
enabled: Boolean,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -320,7 +301,8 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
||||
@ -330,7 +312,7 @@ private fun FeaturedPlayButton(
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
@ -347,7 +329,8 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
if (isPlaying) {
|
||||
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
||||
@ -357,7 +340,7 @@ private fun FeaturedPlayButton(
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
@ -377,20 +360,20 @@ private fun FeaturedPlayButton(
|
||||
modifier = modifier.size(44.dp),
|
||||
contentAlignment = Alignment.BottomEnd
|
||||
) {
|
||||
IconButton(
|
||||
MillenniumIconButton(
|
||||
style = MillenniumIconButtonStyle.Quiet,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
audioPlayerQueue.load(
|
||||
entries = listOf(queueEntry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
},
|
||||
enabled = enabled
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||
contentDescription = null,
|
||||
tint = Color.White
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.ExperimentalSharedTransitionApi
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.remember
|
||||
@ -76,8 +78,7 @@ fun LikedTracksCard(
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
) {
|
||||
navigationCommands: NavigationCommands = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
@ -92,8 +93,7 @@ fun LikedTracksCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
androidx.compose.foundation.Image(
|
||||
@ -106,10 +106,8 @@ fun LikedTracksCard(
|
||||
.sharedElementOrNone(
|
||||
key = "saved_tracks_art",
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
Box(
|
||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||
@ -120,22 +118,24 @@ fun LikedTracksCard(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
FilledTonalIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||
modifier = Modifier.size(30.dp)
|
||||
modifier = Modifier.size(30.dp),
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue",
|
||||
modifier = Modifier.size(16.dp)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
FilledIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||
modifier = Modifier.size(30.dp)
|
||||
modifier = Modifier.size(30.dp),
|
||||
style = MillenniumIconButtonStyle.Primary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPauseCircle
|
||||
} else Iconsax.Play,
|
||||
@ -152,18 +152,16 @@ fun LikedTracksCard(
|
||||
) {
|
||||
Text(
|
||||
text = "Liked Tracks",
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
Text(
|
||||
text = "Your saved songs",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -40,8 +40,7 @@ fun PlaylistCard(
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||
navigationCommands: NavigationCommands = koinInject(),
|
||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
||||
) {
|
||||
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||
|
||||
@ -58,17 +57,14 @@ fun PlaylistCard(
|
||||
remotePlaybackController.requestCollectionPlay(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
playlist.title)
|
||||
},
|
||||
onAddToQueue = {
|
||||
remotePlaybackController.requestCollectionAddToQueue(
|
||||
RemoteCollectionType.Playlist,
|
||||
playlist.id,
|
||||
playlist.title,
|
||||
)
|
||||
playlist.title)
|
||||
},
|
||||
modifier = modifier,
|
||||
sharedElementKey = "playlist_art_${playlist.id}",
|
||||
)
|
||||
sharedElementKey = "playlist_art_${playlist.id}")
|
||||
}
|
||||
|
||||
@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
|
||||
fun Modifier.sharedElementOrNone(
|
||||
key: String?,
|
||||
sharedTransitionScope: SharedTransitionScope?,
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||
): Modifier {
|
||||
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
||||
with(sharedTransitionScope) {
|
||||
return this@sharedElementOrNone.sharedElement(
|
||||
sharedContentState = rememberSharedContentState(key = key),
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope)
|
||||
}
|
||||
}
|
||||
|
||||
@ -1,142 +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.component
|
||||
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxScope
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.RowScope
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
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.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.vector.ImageVector
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
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
|
||||
* and the settings sidebar), so they stay visually identical.
|
||||
*
|
||||
* @param icon leading icon of the item.
|
||||
* @param badge optional overlay drawn on the icon (for example a download indicator).
|
||||
*/
|
||||
@Composable
|
||||
fun SidebarItem(
|
||||
label: String,
|
||||
icon: ImageVector,
|
||||
selected: Boolean,
|
||||
onClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
expanded: Boolean = true,
|
||||
badge: (@Composable BoxScope.() -> Unit)? = null,
|
||||
) {
|
||||
val itemContent: @Composable RowScope.() -> Unit = {
|
||||
Box(modifier = Modifier.size(16.dp)) {
|
||||
Icon(
|
||||
imageVector = icon,
|
||||
contentDescription = label,
|
||||
tint = if (selected) {
|
||||
MaterialTheme.colorScheme.primary
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
},
|
||||
)
|
||||
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 {
|
||||
MaterialTheme.colorScheme.onSurfaceVariant
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
|
||||
}
|
||||
|
||||
val buttonModifier = modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 7.dp, vertical = 2.dp)
|
||||
val contentPadding = PaddingValues(horizontal = 5.dp)
|
||||
|
||||
if (selected) {
|
||||
SecondaryButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.secondary
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
content = itemContent,
|
||||
)
|
||||
} else {
|
||||
OutlineButton(
|
||||
onClick = onClick,
|
||||
modifier = buttonModifier,
|
||||
theme = LocalBaseUITheme.current.buttons.outline
|
||||
.copyPadding(contentPadding)
|
||||
.copyShape(RoundedCornerShape(5.dp)),
|
||||
hoverOnly = true,
|
||||
content = itemContent,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* The shared section heading used above groups of [SidebarItem]s.
|
||||
*/
|
||||
@Composable
|
||||
fun SidebarSectionLabel(
|
||||
text: String,
|
||||
expanded: Boolean = true,
|
||||
) {
|
||||
AnimatedVisibility(visible = expanded) {
|
||||
Text(
|
||||
text = text.uppercase(),
|
||||
style = MaterialTheme.typography.labelSmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.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.size
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.FilledIconButton
|
||||
import androidx.compose.material3.FilledTonalIconButton
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
@ -54,8 +51,7 @@ import org.koin.compose.koinInject
|
||||
fun TrackCard(
|
||||
track: MetadataTrack,
|
||||
modifier: Modifier = Modifier,
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||
) {
|
||||
audioPlayerQueue: AudioPlayerQueue = koinInject()) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
||||
|
||||
@ -69,8 +65,7 @@ fun TrackCard(
|
||||
entries = listOf(queueEntry),
|
||||
autoPlay = true,
|
||||
startPosition = 0,
|
||||
collectionEntry = null,
|
||||
)
|
||||
collectionEntry = null)
|
||||
}
|
||||
},
|
||||
onAddToQueue = {
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.component
|
||||
|
||||
|
||||
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.animation.AnimatedContent
|
||||
import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
@ -36,6 +40,7 @@ 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.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
@ -44,12 +49,20 @@ import androidx.compose.foundation.layout.widthIn
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||
import androidx.compose.ui.state.ToggleableState
|
||||
import org.spotube.millennium.composables.MillenniumCard
|
||||
import org.spotube.millennium.composables.MillenniumCheckbox
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
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.LaunchedEffect
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
@ -68,7 +81,6 @@ import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.graphics.luminance
|
||||
import androidx.compose.ui.layout.ContentScale
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.platform.LocalWindowInfo
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.text.style.TextDecoration
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@ -83,16 +95,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.common.Thumbnail
|
||||
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.TextWithShimmer
|
||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||
@ -122,7 +124,7 @@ enum class TrackSortOption(val label: String) {
|
||||
Duration("Duration"),
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun TrackList(
|
||||
tracks: List<MetadataTrack> = emptyList(),
|
||||
@ -153,9 +155,9 @@ fun TrackList(
|
||||
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
||||
simplified: Boolean = false,
|
||||
scrollable: Boolean = true,
|
||||
state: LazyListState? = null,
|
||||
) {
|
||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||
state: LazyListState? = null) {
|
||||
val filterState = rememberTextFieldState()
|
||||
val filterQuery = filterState.text.toString()
|
||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
||||
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
||||
@ -207,20 +209,19 @@ fun TrackList(
|
||||
}
|
||||
}
|
||||
|
||||
val windowInfo = LocalWindowInfo.current
|
||||
val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
|
||||
val isCompact = maxWidth < CompactTrackListBreakpoint
|
||||
val widthBreakpoint = currentWindowWidthBreakpoint()
|
||||
val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium
|
||||
val isDesktop = remember { getPlatform().isDesktop() }
|
||||
val showIndex = maxWidth >= LargeTrackListBreakpoint
|
||||
val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded
|
||||
val showAlbum = !isCompact
|
||||
val useDropdownForOptions = isDesktop && !isCompact
|
||||
|
||||
val trackCardContent: @Composable () -> Unit = {
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(0.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
@ -228,9 +229,7 @@ fun TrackList(
|
||||
) {
|
||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||
if (displayedIndex > 0) {
|
||||
HorizontalDivider(
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator()
|
||||
}
|
||||
TrackListRow(
|
||||
index = displayedIndex + 1,
|
||||
@ -284,38 +283,31 @@ fun TrackList(
|
||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||
onArtistClick = onArtistClick,
|
||||
onAlbumClick = onAlbumClick,
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
||||
)
|
||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||
}
|
||||
|
||||
if (isLoading && tracks.isEmpty()) {
|
||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = shimmerIndex + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
)
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
|
||||
if (error != null) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
TrackListFeedbackRow(
|
||||
message = error,
|
||||
isError = true,
|
||||
)
|
||||
isError = true)
|
||||
}
|
||||
|
||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||
@ -324,17 +316,14 @@ fun TrackList(
|
||||
|
||||
if (isLoadingNextPage) {
|
||||
if (visibleTracks.isNotEmpty()) {
|
||||
HorizontalDivider(
|
||||
modifier = Modifier.padding(horizontal = 8.dp),
|
||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||
)
|
||||
MillenniumSeparator(
|
||||
modifier = Modifier.padding(horizontal = 8.dp))
|
||||
}
|
||||
ShimmerTrackListRow(
|
||||
index = visibleTracks.size + 1,
|
||||
showIndex = showIndex,
|
||||
showAlbum = showAlbum,
|
||||
useDropdownForOptions = useDropdownForOptions,
|
||||
)
|
||||
useDropdownForOptions = useDropdownForOptions)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -345,19 +334,18 @@ fun TrackList(
|
||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||
.heightIn(max = 60.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) {
|
||||
if (showIndex || isSelectionMode) {
|
||||
val allSelected =
|
||||
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
||||
val anySelected = selectedTrackIds.isNotEmpty()
|
||||
val headerState = when {
|
||||
allSelected -> CheckBoxState.SELECTED
|
||||
anySelected -> CheckBoxState.INDETERMINATE
|
||||
else -> CheckBoxState.UNSELECTED
|
||||
allSelected -> ToggleableState.On
|
||||
anySelected -> ToggleableState.Indeterminate
|
||||
else -> ToggleableState.Off
|
||||
}
|
||||
CheckBox(
|
||||
state = headerState,
|
||||
MillenniumTriStateCheckbox(
|
||||
value = headerState,
|
||||
onClick = {
|
||||
if (allSelected) {
|
||||
isSelectionMode = false
|
||||
@ -367,58 +355,45 @@ fun TrackList(
|
||||
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
||||
}
|
||||
},
|
||||
)
|
||||
label = "")
|
||||
}
|
||||
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
) {
|
||||
TextField(
|
||||
value = filterQuery,
|
||||
onValueChange = { filterQuery = it },
|
||||
MillenniumTextField(
|
||||
state = filterState,
|
||||
label = "",
|
||||
placeholder = "Filter",
|
||||
modifier = Modifier
|
||||
.widthIn(max = 400.dp)
|
||||
.align(Alignment.CenterEnd),
|
||||
placeholder = { TextWithShimmer("Filter") },
|
||||
leadingIcon = {
|
||||
Icon(
|
||||
imageVector = Iconsax.IconsaxFilterSearch,
|
||||
contentDescription = "Filter",
|
||||
)
|
||||
},
|
||||
singleLine = true,
|
||||
)
|
||||
.align(Alignment.CenterEnd))
|
||||
}
|
||||
|
||||
ButtonGroup {
|
||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = TrackSortOption.entries.map { option ->
|
||||
AdaptiveMenuItem(
|
||||
label = option.label,
|
||||
onClick = { sortBy = option },
|
||||
selected = sortBy == option,
|
||||
)
|
||||
selected = sortBy == option)
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxSort,
|
||||
contentDescription = "Sort",
|
||||
)
|
||||
contentDescription = "Sort")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
ButtonGroupDivider()
|
||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||
Box(
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
||||
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
||||
} else {
|
||||
@ -433,54 +408,42 @@ fun TrackList(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = if (isAll) "Download All" else "Download $trackCount",
|
||||
onClick = { onBulkDownload(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkDownload(targetTracks) }))
|
||||
if (!isJamGuest) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||
onClick = { onBulkAddToQueue(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToQueue(targetTracks) }))
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||
onClick = { onBulkPlayNext(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkPlayNext(targetTracks) }))
|
||||
}
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToPlaylist(targetTracks) }))
|
||||
} + if (isInJam) {
|
||||
listOf(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||
onClick = { onBulkAddToJam(targetTracks) },
|
||||
),
|
||||
)
|
||||
onClick = { onBulkAddToJam(targetTracks) }))
|
||||
} else {
|
||||
emptyList()
|
||||
},
|
||||
trigger = { onClick ->
|
||||
GroupIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
) {
|
||||
Icon(
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Bulk actions",
|
||||
)
|
||||
contentDescription = "Bulk actions")
|
||||
}
|
||||
},
|
||||
)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -495,8 +458,7 @@ fun TrackList(
|
||||
.align(Alignment.TopCenter)
|
||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||
contentPadding = contentPadding,
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
if (headerContent != null) {
|
||||
item {
|
||||
headerContent()
|
||||
@ -525,8 +487,7 @@ fun TrackList(
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
isJamGuest = isJamGuest)
|
||||
}
|
||||
}
|
||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||
@ -536,8 +497,7 @@ fun TrackList(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.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) {
|
||||
headerContent()
|
||||
}
|
||||
@ -560,8 +520,7 @@ fun TrackList(
|
||||
onTrackOptionsAction(track, action)
|
||||
selectedTrackForOptions = null
|
||||
},
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
)
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } })
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -590,235 +549,198 @@ private fun TrackListRow(
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onArtistsOverflowClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
||||
modifier: Modifier = Modifier) {
|
||||
val artworkInteractionSource = remember { MutableInteractionSource() }
|
||||
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
||||
val rowBackgroundColor = when {
|
||||
isSelected -> rowTheme.background.selected
|
||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
||||
isCurrentTrack -> rowTheme.background.focused
|
||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
||||
else -> rowTheme.background.normal
|
||||
isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
|
||||
isCurrentTrack -> AppTheme.colors.surfaceVariant
|
||||
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
else -> Color.Transparent
|
||||
}
|
||||
val highlightColor = Color.White.copy(
|
||||
alpha = if (isLight) 0.9f else 0.06f
|
||||
)
|
||||
|
||||
Row(
|
||||
modifier = modifier
|
||||
.fillMaxWidth()
|
||||
.hoverable(rowInteractionSource)
|
||||
.combinedClickable(
|
||||
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)) {
|
||||
MillenniumListTile(
|
||||
modifier = modifier,
|
||||
selected = isSelected || isCurrentTrack,
|
||||
onClick = onTrackClick,
|
||||
title = {
|
||||
TextWithShimmer(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
},
|
||||
subtitle = {
|
||||
ArtistChips(
|
||||
artists = track.artists,
|
||||
onArtistClick = onArtistClick,
|
||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||
)
|
||||
}
|
||||
|
||||
if (showAlbum && track.album != null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
val colorScheme = MaterialTheme.colorScheme
|
||||
|
||||
onArtistsOverflowClick = onArtistsOverflowClick)
|
||||
},
|
||||
leading = {
|
||||
Row(
|
||||
modifier = Modifier.weight(1f)
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = track.album!!.title,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier
|
||||
.hoverable(interactionSource)
|
||||
.clickable(
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
) { onAlbumClick(track.album!!) }
|
||||
// Hide ripple and show underline on hover/click
|
||||
.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,
|
||||
)
|
||||
}
|
||||
}
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
if (isSelectionMode) {
|
||||
MillenniumCheckbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle(it) },
|
||||
label = "",
|
||||
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) {
|
||||
MillenniumCheckbox(
|
||||
checked = isSelected,
|
||||
onCheckedChange = { onSelectionToggle(it) },
|
||||
label = "",
|
||||
modifier = Modifier.shimmerApply())
|
||||
} else {
|
||||
Modifier
|
||||
TextWithShimmer(
|
||||
text = index.toString(),
|
||||
style = AppTheme.typography.labelMedium,
|
||||
textAlign = TextAlign.Center,
|
||||
color = AppTheme.colors.onSurfaceVariant)
|
||||
}
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
TextWithShimmer(
|
||||
text = track.durationMs.toDurationString(),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
textAlign = TextAlign.End,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.width(56.dp),
|
||||
)
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(44.dp)
|
||||
.clip(AppTheme.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)
|
||||
.crossfade(false)
|
||||
.build()
|
||||
}
|
||||
|
||||
if (useDropdownForOptions) {
|
||||
TrackOptions(
|
||||
track = track,
|
||||
state = trackOptionsState,
|
||||
onAction = onTrackOptionsAction,
|
||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
} else {
|
||||
GhostIconButton(onClick = onShowOptionsClick) {
|
||||
Icon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options",
|
||||
)
|
||||
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) {
|
||||
MillenniumIcon(
|
||||
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
|
||||
private fun ArtistChips(
|
||||
artists: List<MetadataArtist.Basic>,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onArtistsOverflowClick: () -> Unit,
|
||||
) {
|
||||
onArtistsOverflowClick: () -> Unit) {
|
||||
val visibleArtists = artists.take(2)
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
) {
|
||||
modifier = Modifier.fillMaxWidth()) {
|
||||
visibleArtists.forEachIndexed { index, artist ->
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||
val isPressed by interactionSource.collectIsPressedAsState()
|
||||
|
||||
TextWithShimmer(
|
||||
text = artist.name,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
||||
@ -827,13 +749,12 @@ private fun ArtistChips(
|
||||
indication = null,
|
||||
interactionSource = interactionSource
|
||||
) { onArtistClick(artist) }
|
||||
,
|
||||
)
|
||||
)
|
||||
if (index < visibleArtists.size - 1)
|
||||
Text(
|
||||
",",
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodySmall
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -842,12 +763,11 @@ private fun ArtistChips(
|
||||
if (remaining > 0) {
|
||||
TextWithShimmer(
|
||||
text = "+$remaining more",
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.primary,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.primary,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
|
||||
)
|
||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -857,8 +777,7 @@ private fun ShimmerTrackListRow(
|
||||
index: Int,
|
||||
showIndex: Boolean,
|
||||
showAlbum: Boolean,
|
||||
useDropdownForOptions: Boolean,
|
||||
) {
|
||||
useDropdownForOptions: Boolean) {
|
||||
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
||||
SkeletonTree(true) {
|
||||
TrackListRow(
|
||||
@ -881,8 +800,7 @@ private fun ShimmerTrackListRow(
|
||||
onShowOptionsClick = {},
|
||||
onArtistClick = {},
|
||||
onAlbumClick = {},
|
||||
onArtistsOverflowClick = {},
|
||||
)
|
||||
onArtistsOverflowClick = {})
|
||||
}
|
||||
}
|
||||
|
||||
@ -891,8 +809,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
||||
id = "shimmer_artist_$index",
|
||||
name = "Artist Name",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
return MetadataTrack(
|
||||
id = "shimmer_track_$index",
|
||||
title = "Track Title Here",
|
||||
@ -913,29 +830,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
||||
thumbnails = emptyList(),
|
||||
albumType = MetadataAlbumType.Album,
|
||||
artists = listOf(artist),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
thumbnails = null,
|
||||
explicit = false,
|
||||
popularity = 0,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun TrackListFeedbackRow(
|
||||
message: String,
|
||||
isError: Boolean = false,
|
||||
) {
|
||||
isError: Boolean = false) {
|
||||
TextWithShimmer(
|
||||
text = message,
|
||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
||||
)
|
||||
.padding(horizontal = 8.dp, vertical = 12.dp))
|
||||
}
|
||||
|
||||
private fun Long.toDurationString(): String {
|
||||
@ -951,21 +864,17 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
id = "artist_1",
|
||||
name = "Aria Vale",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataArtist.Basic(
|
||||
id = "artist_2",
|
||||
name = "Neon Harbor",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataArtist.Basic(
|
||||
id = "artist_3",
|
||||
name = "Echo Drift",
|
||||
thumbnails = emptyList(),
|
||||
externalUri = null,
|
||||
),
|
||||
)
|
||||
externalUri = null))
|
||||
|
||||
val album = MetadataAlbum.Detailed(
|
||||
releaseDate = null,
|
||||
@ -978,13 +887,11 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
Thumbnail(
|
||||
url = "https://picsum.photos/120",
|
||||
width = 120,
|
||||
height = 120,
|
||||
)
|
||||
height = 120)
|
||||
),
|
||||
albumType = MetadataAlbumType.Album,
|
||||
artists = artists.take(2),
|
||||
externalUri = null,
|
||||
)
|
||||
externalUri = null)
|
||||
|
||||
return listOf(
|
||||
MetadataTrack(
|
||||
@ -999,8 +906,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
explicit = false,
|
||||
popularity = 92,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
),
|
||||
externalUri = null),
|
||||
MetadataTrack(
|
||||
id = "track_2",
|
||||
title = "Static Horizon",
|
||||
@ -1013,8 +919,7 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
popularity = 80,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
thumbnails = null,
|
||||
),
|
||||
thumbnails = null),
|
||||
MetadataTrack(
|
||||
id = "track_3",
|
||||
title = "Last Train Echo",
|
||||
@ -1027,19 +932,16 @@ fun previewTracks(): List<MetadataTrack> {
|
||||
popularity = 77,
|
||||
isrcCode = null,
|
||||
externalUri = null,
|
||||
thumbnails = null,
|
||||
),
|
||||
)
|
||||
thumbnails = null))
|
||||
}
|
||||
|
||||
@Composable
|
||||
@Preview
|
||||
private fun TrackListPreview() {
|
||||
BaseScaffold {
|
||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||
TrackList(
|
||||
tracks = previewTracks(),
|
||||
hasMore = true,
|
||||
isLoadingNextPage = true,
|
||||
)
|
||||
isLoadingNextPage = true)
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,13 @@
|
||||
|
||||
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.layout.Arrangement
|
||||
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.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.ModalBottomSheet
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
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.resources.iconsax.Iconsax
|
||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||
@ -72,8 +76,7 @@ data class TrackOptionsState(
|
||||
val isInQueue: Boolean = false,
|
||||
val isCurrentlyPlaying: Boolean = false,
|
||||
val isFavorite: Boolean = false,
|
||||
val isBlacklisted: Boolean = false,
|
||||
)
|
||||
val isBlacklisted: Boolean = false)
|
||||
|
||||
data class TrackOptionsContext(
|
||||
val currentTrackId: String? = null,
|
||||
@ -81,15 +84,13 @@ data class TrackOptionsContext(
|
||||
val savedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedTrackIds: Set<String> = emptySet(),
|
||||
val blacklistedArtistIds: Set<String> = emptySet(),
|
||||
val forceFavorite: Boolean = false,
|
||||
) {
|
||||
val forceFavorite: Boolean = false) {
|
||||
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
||||
isInQueue = track.id in queueTrackIds,
|
||||
isCurrentlyPlaying = track.id == currentTrackId,
|
||||
isFavorite = forceFavorite || track.id in savedTrackIds,
|
||||
isBlacklisted = track.id in blacklistedTrackIds ||
|
||||
track.artists.any { it.id in blacklistedArtistIds },
|
||||
)
|
||||
track.artists.any { it.id in blacklistedArtistIds })
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -100,8 +101,7 @@ fun TrackOptions(
|
||||
onAlbumClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
isJamGuest: Boolean = false) {
|
||||
AdaptiveDropdownBottomSheet(
|
||||
items = buildTrackMenuItems(
|
||||
track = track,
|
||||
@ -109,11 +109,12 @@ fun TrackOptions(
|
||||
onAction = onAction,
|
||||
onAlbumClick = onAlbumClick,
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
),
|
||||
isJamGuest = isJamGuest),
|
||||
trigger = { onClick ->
|
||||
GhostIconButton(onClick = onClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onClick,
|
||||
style = MillenniumIconButtonStyle.Quiet) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.Iconsax3DotsMore,
|
||||
contentDescription = "Track options",
|
||||
modifier = Modifier.shimmerApply()
|
||||
@ -122,11 +123,9 @@ fun TrackOptions(
|
||||
},
|
||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||
header = { TrackOptionsSheetHeader(track = track) },
|
||||
modifier = modifier,
|
||||
)
|
||||
modifier = modifier)
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun TrackOptionsBottomSheet(
|
||||
track: MetadataTrack,
|
||||
@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
) {
|
||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||
isJamGuest: Boolean = false) {
|
||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||
LaunchedEffect(sheetState.currentDetent) {
|
||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||
onDismiss()
|
||||
}
|
||||
}
|
||||
MillenniumModalBottomSheet(state = sheetState) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
TrackOptionsSheetHeader(track = track)
|
||||
|
||||
@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||
buildTrackMenuItems(
|
||||
track = track,
|
||||
state = state,
|
||||
@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet(
|
||||
onDismiss()
|
||||
},
|
||||
isInJam = isInJam,
|
||||
isJamGuest = isJamGuest,
|
||||
).forEach { item ->
|
||||
isJamGuest = isJamGuest).forEach { item ->
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable(
|
||||
enabled = item.enabled,
|
||||
onClick = item.onClick,
|
||||
)
|
||||
onClick = item.onClick)
|
||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
item.icon?.let { icon ->
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = icon,
|
||||
contentDescription = null,
|
||||
modifier = Modifier.size(22.dp),
|
||||
tint = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
}
|
||||
Text(
|
||||
text = item.label,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
style = AppTheme.typography.bodyLarge,
|
||||
color = if (item.enabled) {
|
||||
MaterialTheme.colorScheme.onSurface
|
||||
AppTheme.colors.onSurface
|
||||
} else {
|
||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||
},
|
||||
)
|
||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||
AsyncImage(
|
||||
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
||||
contentDescription = null,
|
||||
contentScale = ContentScale.Crop,
|
||||
modifier = Modifier
|
||||
.size(56.dp)
|
||||
.clip(MaterialTheme.shapes.small),
|
||||
)
|
||||
.clip(AppTheme.shapes.small))
|
||||
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
text = track.title,
|
||||
style = MaterialTheme.typography.titleMedium,
|
||||
style = AppTheme.typography.titleMedium,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Text(
|
||||
text = track.artists.joinToString(", ") { it.name },
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
track.album?.let { album ->
|
||||
Text(
|
||||
text = album.title,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -251,25 +244,20 @@ private fun buildTrackMenuItems(
|
||||
onAction: (TrackOptionsAction) -> Unit,
|
||||
onAlbumClick: () -> Unit,
|
||||
isInJam: Boolean = false,
|
||||
isJamGuest: Boolean = false,
|
||||
): List<AdaptiveMenuItem> = buildList {
|
||||
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
|
||||
if (isInJam) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to Jam",
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToJam) }))
|
||||
}
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicCircle,
|
||||
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
|
||||
// allowed, so queue actions are replaced by "Add to Jam".
|
||||
@ -279,17 +267,13 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Play next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxNext,
|
||||
label = "Move to next",
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||
}
|
||||
|
||||
if (!state.isInQueue) {
|
||||
@ -297,17 +281,13 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxAddSquare,
|
||||
label = "Add to queue",
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToQueue) }))
|
||||
} else {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||
label = "Remove from queue",
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }))
|
||||
}
|
||||
}
|
||||
|
||||
@ -315,42 +295,32 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
||||
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicPlaylist,
|
||||
label = "Add to playlist",
|
||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxDirectboxReceive,
|
||||
label = "Download",
|
||||
onClick = { onAction(TrackOptionsAction.Download) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.Download) }))
|
||||
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }))
|
||||
|
||||
if (!track.externalUri.isNullOrBlank()) {
|
||||
add(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxShare,
|
||||
label = "Share",
|
||||
onClick = { onAction(TrackOptionsAction.Share) },
|
||||
),
|
||||
)
|
||||
onClick = { onAction(TrackOptionsAction.Share) }))
|
||||
}
|
||||
|
||||
if (track.album != null) {
|
||||
@ -358,8 +328,6 @@ private fun buildTrackMenuItems(
|
||||
AdaptiveMenuItem(
|
||||
icon = Iconsax.IconsaxCd,
|
||||
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.width
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
||||
@Composable
|
||||
fun UserCard(
|
||||
user: MetadataUser,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
modifier: Modifier = Modifier) {
|
||||
AvatarCard(
|
||||
title = user.displayName ?: user.username,
|
||||
subtitle = "User",
|
||||
@ -53,7 +52,6 @@ fun UserCard(
|
||||
onClick = {
|
||||
// TODO: Route to user details screen.
|
||||
},
|
||||
modifier = modifier,
|
||||
)
|
||||
modifier = modifier)
|
||||
}
|
||||
|
||||
|
||||
@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop
|
||||
@Composable
|
||||
expect fun VerticalScrollbar(
|
||||
listState: LazyListState,
|
||||
modifier: Modifier = Modifier,
|
||||
)
|
||||
modifier: Modifier = Modifier)
|
||||
|
||||
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
@ -58,10 +58,9 @@ fun AvatarCard(
|
||||
imageURL: String? = null,
|
||||
onClick: () -> Unit = {},
|
||||
modifier: Modifier = Modifier,
|
||||
isBlacklisted: Boolean = false,
|
||||
) {
|
||||
isBlacklisted: Boolean = false) {
|
||||
val backgroundColor = if (isBlacklisted) {
|
||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||
AppTheme.colors.error.copy(alpha = 0.1f)
|
||||
} else {
|
||||
Color.Transparent
|
||||
}
|
||||
@ -71,20 +70,17 @@ fun AvatarCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.background(backgroundColor)
|
||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||
.width(160.dp),
|
||||
) {
|
||||
.width(160.dp)) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth(),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clip(CircleShape)
|
||||
.padding(12.dp),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
AsyncImage(
|
||||
model = imageURL,
|
||||
contentDescription = title,
|
||||
@ -93,27 +89,24 @@ fun AvatarCard(
|
||||
.aspectRatio(1f)
|
||||
.clip(CircleShape)
|
||||
.shimmerApply(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -17,6 +17,10 @@
|
||||
|
||||
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.ExperimentalSharedTransitionApi
|
||||
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.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
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.unit.dp
|
||||
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.LocalSharedTransitionScope
|
||||
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
||||
@ -75,8 +74,7 @@ fun PlayableCard(
|
||||
onPlay: (() -> Unit)? = null,
|
||||
onAddToQueue: (() -> Unit)? = null,
|
||||
modifier: Modifier = Modifier,
|
||||
sharedElementKey: String? = null,
|
||||
) {
|
||||
sharedElementKey: String? = null) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||
@ -86,8 +84,7 @@ fun PlayableCard(
|
||||
.clip(RoundedCornerShape(8.dp))
|
||||
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
||||
.width(160.dp)
|
||||
.hoverable(interactionSource = interactionSource),
|
||||
) {
|
||||
.hoverable(interactionSource = interactionSource)) {
|
||||
Column(modifier = Modifier.fillMaxWidth()) {
|
||||
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
||||
AsyncImage(
|
||||
@ -100,10 +97,8 @@ fun PlayableCard(
|
||||
.sharedElementOrNone(
|
||||
key = sharedElementKey,
|
||||
sharedTransitionScope = sharedTransitionScope,
|
||||
animatedVisibilityScope = animatedVisibilityScope,
|
||||
),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
animatedVisibilityScope = animatedVisibilityScope),
|
||||
contentScale = ContentScale.Crop)
|
||||
|
||||
if (onPlay != null || onAddToQueue != null)
|
||||
Box(
|
||||
@ -114,46 +109,35 @@ fun PlayableCard(
|
||||
this@Column.AnimatedVisibility(
|
||||
visible = isHovered,
|
||||
enter = fadeIn(),
|
||||
exit = fadeOut(),
|
||||
) {
|
||||
exit = fadeOut()) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
modifier = Modifier.padding(4.dp)
|
||||
) {
|
||||
onAddToQueue?.let {
|
||||
SecondaryIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
modifier = Modifier.size(30.dp),
|
||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
modifier = Modifier.size(34.dp),
|
||||
style = MillenniumIconButtonStyle.Secondary
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add to queue",
|
||||
)
|
||||
contentDescription = "Add to queue")
|
||||
}
|
||||
}
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
onPlay?.let {
|
||||
PrimaryIconButton(
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
modifier = Modifier.size(30.dp),
|
||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||
CircleShape
|
||||
)
|
||||
)
|
||||
modifier = Modifier.size(34.dp),
|
||||
style = MillenniumIconButtonStyle.Primary
|
||||
|
||||
) {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = if (isPlaying) {
|
||||
Iconsax.IconsaxPause
|
||||
} else Iconsax.IconsaxPlay,
|
||||
contentDescription = "Play",
|
||||
)
|
||||
contentDescription = "Play")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -165,19 +149,17 @@ fun PlayableCard(
|
||||
) {
|
||||
TextWithShimmer(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleSmall,
|
||||
style = AppTheme.typography.titleSmall,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
Spacer(modifier = Modifier.height(4.dp))
|
||||
subtitle?.let {
|
||||
TextWithShimmer(
|
||||
text = subtitle,
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodySmall,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -185,11 +167,10 @@ fun PlayableCard(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@PreviewLightDark
|
||||
@Composable
|
||||
private fun PlayableCardPreview() {
|
||||
BaseScaffold {
|
||||
Box {
|
||||
PlayableCard(
|
||||
title = "Sample Title",
|
||||
subtitle = "Sample Subtitle",
|
||||
|
||||
@ -45,8 +45,7 @@ fun Coverflow(
|
||||
modifier: Modifier = Modifier,
|
||||
state: CoverflowState = rememberCoverflowState(),
|
||||
params: CoverflowParams = CoverflowParams(),
|
||||
content: CoverflowScope.() -> Unit,
|
||||
) {
|
||||
content: CoverflowScope.() -> Unit) {
|
||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
||||
|
||||
@ -55,8 +54,7 @@ fun Coverflow(
|
||||
val g = geometry ?: return@LaunchedEffect
|
||||
state.lazyListState.scrollToItem(
|
||||
state.selectedIndex + 1,
|
||||
-g.spacerWidth,
|
||||
)
|
||||
-g.spacerWidth)
|
||||
state.initialScrollDone = true
|
||||
}
|
||||
|
||||
@ -69,8 +67,7 @@ fun Coverflow(
|
||||
},
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
state = state.lazyListState,
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState),
|
||||
) {
|
||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) {
|
||||
if (size != IntSize.Zero) {
|
||||
val g = Geometry(params, size)
|
||||
geometry = g
|
||||
@ -79,8 +76,7 @@ fun Coverflow(
|
||||
val coverflowScope = CoverflowScopeImpl(
|
||||
geometry = g,
|
||||
lazyListScope = this,
|
||||
coverflowState = state,
|
||||
)
|
||||
coverflowState = state)
|
||||
|
||||
item {
|
||||
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
||||
@ -102,15 +98,13 @@ fun Coverflow(
|
||||
if (state.selectedIndex > 0) {
|
||||
state.scrollToItem(state.selectedIndex - 1)
|
||||
}
|
||||
},
|
||||
) {}
|
||||
}) {}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxHeight()
|
||||
.align(Alignment.CenterEnd)
|
||||
.clickable {
|
||||
state.scrollToItem(state.selectedIndex + 1)
|
||||
},
|
||||
) {}
|
||||
}) {}
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,8 +25,7 @@ import kotlin.math.min
|
||||
@Stable
|
||||
internal class Geometry(
|
||||
internal val params: CoverflowParams = CoverflowParams(),
|
||||
private val size: IntSize = IntSize.Zero,
|
||||
) {
|
||||
private val size: IntSize = IntSize.Zero) {
|
||||
@Stable
|
||||
private val shortEdge
|
||||
get() = min(size.width, size.height)
|
||||
|
||||
@ -41,8 +41,7 @@ internal fun CoverflowItem(
|
||||
geometry: Geometry,
|
||||
onClickHandler: () -> Unit = {},
|
||||
onSelectedHandler: (Boolean) -> Unit,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
content: @Composable () -> Unit) {
|
||||
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
||||
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
||||
|
||||
@ -53,8 +52,7 @@ internal fun CoverflowItem(
|
||||
.onGloballyPositioned { coordinates ->
|
||||
horizontalPosition = coordinates.positionInParent().x
|
||||
},
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
contentAlignment = Alignment.Center) {
|
||||
if (horizontalPosition != null) {
|
||||
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
||||
|
||||
@ -65,20 +63,16 @@ internal fun CoverflowItem(
|
||||
rotationY = geometry.rotation(distanceToCenter),
|
||||
translationX = geometry.translationX(distanceToCenter),
|
||||
scaleY = geometry.scale(distanceToCenter),
|
||||
scaleX = geometry.scale(distanceToCenter),
|
||||
),
|
||||
propagateMinConstraints = true,
|
||||
) {
|
||||
scaleX = geometry.scale(distanceToCenter)),
|
||||
propagateMinConstraints = true) {
|
||||
val interactionSource = remember { MutableInteractionSource() }
|
||||
if (geometry.params.mirror) {
|
||||
Mirror(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClickHandler,
|
||||
),
|
||||
coverSize = geometry.coverSize,
|
||||
) {
|
||||
onClick = onClickHandler),
|
||||
coverSize = geometry.coverSize) {
|
||||
content()
|
||||
}
|
||||
} else {
|
||||
@ -86,9 +80,7 @@ internal fun CoverflowItem(
|
||||
modifier = Modifier.clickable(
|
||||
interactionSource = interactionSource,
|
||||
indication = null,
|
||||
onClick = onClickHandler,
|
||||
),
|
||||
) {
|
||||
onClick = onClickHandler)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
|
||||
@ -26,5 +26,4 @@ data class CoverflowParams(
|
||||
val angle: Float = 45f,
|
||||
val shift: Float = 0.4f,
|
||||
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 -> },
|
||||
key: ((index: Int) -> Any)? = null,
|
||||
contentType: (index: Int) -> Any? = { null },
|
||||
itemContent: @Composable ((item: T) -> Unit),
|
||||
)
|
||||
itemContent: @Composable ((item: T) -> Unit))
|
||||
|
||||
fun items(
|
||||
count: Int,
|
||||
onSelectHandler: (index: Int) -> Unit = {},
|
||||
key: ((index: Int) -> Any)? = null,
|
||||
contentType: (index: Int) -> Any? = { null },
|
||||
itemContent: @Composable ((index: Int) -> Unit),
|
||||
)
|
||||
itemContent: @Composable ((index: Int) -> Unit))
|
||||
}
|
||||
|
||||
internal class CoverflowScopeImpl(
|
||||
private val geometry: Geometry,
|
||||
private val lazyListScope: LazyListScope,
|
||||
private val coverflowState: CoverflowState,
|
||||
) : CoverflowScope {
|
||||
private val coverflowState: CoverflowState) : CoverflowScope {
|
||||
override fun <T> items(
|
||||
items: List<T>,
|
||||
onSelectHandler: (item: T, index: Int) -> Unit,
|
||||
key: ((index: Int) -> Any)?,
|
||||
contentType: (index: Int) -> Any?,
|
||||
itemContent: @Composable (item: T) -> Unit,
|
||||
) = items(
|
||||
itemContent: @Composable (item: T) -> Unit) = items(
|
||||
count = items.size,
|
||||
onSelectHandler = {
|
||||
onSelectHandler(items[it], it)
|
||||
},
|
||||
key = key,
|
||||
contentType = contentType,
|
||||
) {
|
||||
contentType = contentType) {
|
||||
itemContent(items[it])
|
||||
}
|
||||
|
||||
@ -65,12 +60,10 @@ internal class CoverflowScopeImpl(
|
||||
onSelectHandler: (index: Int) -> Unit,
|
||||
key: ((index: Int) -> Any)?,
|
||||
contentType: (index: Int) -> Any?,
|
||||
itemContent: @Composable (index: Int) -> Unit,
|
||||
) = lazyListScope.items(
|
||||
itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items(
|
||||
count,
|
||||
key,
|
||||
contentType,
|
||||
) {
|
||||
contentType) {
|
||||
CoverflowItem(
|
||||
onClickHandler = { coverflowState.scrollToItem(it) },
|
||||
onSelectedHandler = { isSelected: Boolean ->
|
||||
@ -78,16 +71,14 @@ internal class CoverflowScopeImpl(
|
||||
onSelectHandler(it)
|
||||
}
|
||||
},
|
||||
geometry = geometry,
|
||||
) {
|
||||
geometry = geometry) {
|
||||
itemContent(it)
|
||||
}
|
||||
}
|
||||
|
||||
private fun selectHandler(
|
||||
index: Int,
|
||||
isSelected: Boolean,
|
||||
): Boolean {
|
||||
isSelected: Boolean): Boolean {
|
||||
if (!isSelected && index == coverflowState.selectedIndex) {
|
||||
coverflowState.selectedIndex = -1
|
||||
} else if (isSelected && index != coverflowState.selectedIndex) {
|
||||
|
||||
@ -28,19 +28,16 @@ import kotlinx.coroutines.launch
|
||||
@Composable
|
||||
fun rememberCoverflowState(
|
||||
initialIndex: Int = 0,
|
||||
onSelectHandler: (Int) -> Unit = {},
|
||||
): CoverflowState {
|
||||
onSelectHandler: (Int) -> Unit = {}): CoverflowState {
|
||||
val lazyListState: LazyListState = rememberLazyListState(
|
||||
initialFirstVisibleItemIndex = initialIndex + 1,
|
||||
)
|
||||
initialFirstVisibleItemIndex = initialIndex + 1)
|
||||
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||
return remember {
|
||||
CoverflowState(
|
||||
lazyListState,
|
||||
coroutineScope,
|
||||
onSelectHandler,
|
||||
initialIndex,
|
||||
)
|
||||
initialIndex)
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,8 +45,7 @@ class CoverflowState internal constructor(
|
||||
internal val lazyListState: LazyListState,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val onSelectHandler: (Int) -> Unit = {},
|
||||
initialIndex: Int = 0,
|
||||
) {
|
||||
initialIndex: Int = 0) {
|
||||
internal var geometry: Geometry? = null
|
||||
internal var initialScrollDone: Boolean = false
|
||||
|
||||
@ -65,8 +61,7 @@ class CoverflowState internal constructor(
|
||||
coroutineScope.launch {
|
||||
lazyListState.animateScrollToItem(
|
||||
index + 1,
|
||||
-geometryCopy.spacerWidth,
|
||||
)
|
||||
-geometryCopy.spacerWidth)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times
|
||||
internal fun Mirror(
|
||||
modifier: Modifier = Modifier,
|
||||
coverSize: Float,
|
||||
content: @Composable () -> Unit,
|
||||
) {
|
||||
content: @Composable () -> Unit) {
|
||||
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
||||
|
||||
Column(
|
||||
modifier = modifier
|
||||
.padding(top = coverSizeDp)
|
||||
.fillMaxWidth()
|
||||
.requiredHeight(2 * coverSizeDp),
|
||||
) {
|
||||
.requiredHeight(2 * coverSizeDp)) {
|
||||
Box(
|
||||
propagateMinConstraints = true,
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.fillMaxWidth()
|
||||
.height(coverSizeDp),
|
||||
) {
|
||||
.height(coverSizeDp)) {
|
||||
content()
|
||||
}
|
||||
Box(
|
||||
@ -75,34 +72,27 @@ internal fun Mirror(
|
||||
.height(coverSizeDp)
|
||||
.graphicsLayer(
|
||||
compositingStrategy = CompositingStrategy.Offscreen,
|
||||
rotationX = 180f,
|
||||
)
|
||||
rotationX = 180f)
|
||||
.clip(HalfSizeShape)
|
||||
.drawWithContent {
|
||||
val colors = listOf(
|
||||
Color.Transparent,
|
||||
Color.Transparent,
|
||||
Color.Black,
|
||||
)
|
||||
Color.Black)
|
||||
drawContent()
|
||||
drawRect(
|
||||
topLeft = Offset(
|
||||
0f,
|
||||
this.size.height / 2,
|
||||
),
|
||||
this.size.height / 2),
|
||||
size = Size(
|
||||
this.size.width,
|
||||
this.size.height / 2,
|
||||
),
|
||||
this.size.height / 2),
|
||||
brush = Brush.verticalGradient(colors),
|
||||
blendMode = BlendMode.DstIn,
|
||||
)
|
||||
blendMode = BlendMode.DstIn)
|
||||
}
|
||||
.blur(
|
||||
radiusX = 1.dp,
|
||||
radiusY = 3.dp,
|
||||
),
|
||||
) {
|
||||
radiusY = 3.dp)) {
|
||||
content()
|
||||
}
|
||||
}
|
||||
@ -112,17 +102,12 @@ private object HalfSizeShape : Shape {
|
||||
override fun createOutline(
|
||||
size: Size,
|
||||
layoutDirection: LayoutDirection,
|
||||
density: Density,
|
||||
): Outline = Outline.Rectangle(
|
||||
density: Density): Outline = Outline.Rectangle(
|
||||
Rect(
|
||||
Offset(
|
||||
0f,
|
||||
size.height / 2,
|
||||
),
|
||||
size.height / 2),
|
||||
Size(
|
||||
size.width,
|
||||
size.height,
|
||||
),
|
||||
),
|
||||
)
|
||||
size.height)))
|
||||
}
|
||||
|
||||
@ -17,10 +17,11 @@
|
||||
|
||||
package dev.krtirtho.spotube.core.ui.misc
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.text.TextAutoSize
|
||||
import androidx.compose.material3.LocalTextStyle
|
||||
import androidx.compose.material3.Text
|
||||
import com.composeunstyled.LocalTextStyle
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
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.TextOverflow
|
||||
import androidx.compose.ui.unit.TextUnit
|
||||
import com.eygraber.compose.placeholder.material3.placeholder
|
||||
import com.valentinilk.shimmer.ShimmerBounds
|
||||
import com.valentinilk.shimmer.rememberShimmer
|
||||
import com.valentinilk.shimmer.shimmer
|
||||
@ -84,11 +84,7 @@ fun Modifier.shimmerApply(
|
||||
): Modifier = composed {
|
||||
val isTreeLoading = LocalSkeletonLoading.current
|
||||
|
||||
this.placeholder(
|
||||
visible = isTreeLoading,
|
||||
color = color,
|
||||
shape = null,
|
||||
)
|
||||
if (isTreeLoading) this.background(color) else this
|
||||
}
|
||||
|
||||
@Composable
|
||||
@ -110,23 +106,20 @@ fun TextWithShimmer(
|
||||
maxLines: Int = Int.MAX_VALUE,
|
||||
minLines: Int = 1,
|
||||
onTextLayout: ((TextLayoutResult) -> Unit)? = null,
|
||||
style: TextStyle = LocalTextStyle.current,
|
||||
) {
|
||||
style: TextStyle = LocalTextStyle.current) {
|
||||
Text(
|
||||
text = text,
|
||||
color = color,
|
||||
modifier = Modifier.shimmerApply().then(modifier),
|
||||
autoSize = autoSize,
|
||||
fontSize = fontSize,
|
||||
fontStyle = fontStyle,
|
||||
fontWeight = fontWeight,
|
||||
fontFamily = fontFamily,
|
||||
letterSpacing = letterSpacing,
|
||||
textDecoration = textDecoration,
|
||||
textAlign = textAlign,
|
||||
textAlign = textAlign ?: TextAlign.Start,
|
||||
lineHeight = lineHeight,
|
||||
overflow = overflow,
|
||||
softWrap = softWrap,
|
||||
maxLines = maxLines,
|
||||
minLines = minLines,
|
||||
onTextLayout = onTextLayout,
|
||||
|
||||
@ -18,93 +18,202 @@
|
||||
package dev.krtirtho.spotube.core.ui.theming
|
||||
|
||||
import androidx.compose.foundation.isSystemInDarkTheme
|
||||
import androidx.compose.material3.ColorScheme
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.Immutable
|
||||
import androidx.compose.runtime.remember
|
||||
import com.materialkolor.rememberDynamicColorScheme
|
||||
import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme
|
||||
import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import androidx.compose.ui.graphics.Color
|
||||
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.UserSettings
|
||||
import org.spotube.millennium.composables.MillenniumTooltipHost
|
||||
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 selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a
|
||||
* [BaseUIColorScheme], and every Material role is then overridden from it. That way the
|
||||
* whole app — Material components included — renders in the chosen surface theme's colours
|
||||
* and accent, instead of Material's dynamic palette drifting away from the base UI.
|
||||
* Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark
|
||||
* [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and
|
||||
* [AppShapes] tokens from the active Millennium theme.
|
||||
*/
|
||||
@Composable
|
||||
fun SpotubeTheme(
|
||||
settings: UserSettings,
|
||||
content: @Composable () -> Unit,
|
||||
content: @Composable () -> Unit
|
||||
) {
|
||||
val isDarkTheme = when (settings.theme) {
|
||||
Theme.LIGHT -> false
|
||||
Theme.DARK -> true
|
||||
Theme.SYSTEM -> isSystemInDarkTheme()
|
||||
}
|
||||
val isLight = !isDarkTheme
|
||||
val palette: MillenniumPalette = settings.palette
|
||||
|
||||
val accent = if (isDarkTheme) {
|
||||
settings.accentColor.toDarkColor()
|
||||
} else {
|
||||
settings.accentColor.toLightColor()
|
||||
}
|
||||
|
||||
val baseColors = remember(settings.surfaceTheme, isLight, accent) {
|
||||
baseUIColorScheme(settings.surfaceTheme, isLight, accent)
|
||||
}
|
||||
|
||||
val dynamicScheme = rememberDynamicColorScheme(
|
||||
seedColor = accent,
|
||||
isDark = isDarkTheme,
|
||||
)
|
||||
|
||||
MaterialTheme(
|
||||
colorScheme = dynamicScheme.mergeWithBaseColors(baseColors),
|
||||
) {
|
||||
Surface(content = content)
|
||||
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
||||
val colors = appColorsFrom(MillenniumTheme.colors)
|
||||
MillenniumTooltipHost {
|
||||
CompositionLocalProvider(
|
||||
LocalAppColors provides colors,
|
||||
LocalAppTypography provides AppDefaultTypography,
|
||||
LocalAppShapes provides AppDefaultShapes,
|
||||
content = content
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the
|
||||
* base UI palette, so Material components share the exact same colours as the base
|
||||
* 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,
|
||||
)
|
||||
/** Whether the active Millennium appearance is light. */
|
||||
@Composable
|
||||
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
|
||||
|
||||
@ -1,221 +1,230 @@
|
||||
/*
|
||||
* 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.webview
|
||||
|
||||
import androidx.compose.foundation.BorderStroke
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.WindowInsets
|
||||
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.statusBars
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
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.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
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(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
BaseScaffold(
|
||||
contentWindowInsets = WindowInsets.statusBars,
|
||||
topBar = {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
}, enabled = navigator.canGoBack
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
IconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
}, enabled = navigator.canGoForward
|
||||
) {
|
||||
Icon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Surface(
|
||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||
shape = RoundedCornerShape(18.dp),
|
||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||
color = MaterialTheme.colorScheme.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
})
|
||||
{ innerPadding ->
|
||||
WebView(
|
||||
state = state,
|
||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||
navigator = navigator,
|
||||
webViewJsBridge = webViewBridge,
|
||||
onCreated = { webView ->
|
||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||
},
|
||||
)
|
||||
}
|
||||
/*
|
||||
* 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.webview
|
||||
|
||||
|
||||
import org.spotube.millennium.composables.MillenniumIcon
|
||||
import org.spotube.millennium.composables.MillenniumIconButton
|
||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||
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.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.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.wrapContentHeight
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.foundation.text.BasicTextField
|
||||
import androidx.compose.ui.draw.clip
|
||||
|
||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.snapshotFlow
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.unit.dp
|
||||
import compose.icons.FeatherIcons
|
||||
import compose.icons.feathericons.ChevronLeft
|
||||
import compose.icons.feathericons.ChevronRight
|
||||
import compose.icons.feathericons.X
|
||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||
import dev.nucleusframework.webview.web.WebView
|
||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||
import dev.nucleusframework.webview.web.WebViewState
|
||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||
|
||||
class PostMessageHandler(
|
||||
private val onMessageReceived: (String) -> Unit = {}
|
||||
) : IJsMessageHandler {
|
||||
override fun methodName(): String {
|
||||
return "sendMessage"
|
||||
}
|
||||
|
||||
override fun handle(
|
||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||
) {
|
||||
onMessageReceived(message.params)
|
||||
callback(message.params)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||
if (webViewController.getContent() == null) {
|
||||
// This should never happen, but just in case
|
||||
Text("No URL to load")
|
||||
return
|
||||
}
|
||||
|
||||
val state = remember {
|
||||
WebViewState(
|
||||
webViewController.getWebContent(
|
||||
additionalHttpHeaders = mapOf(
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
)
|
||||
)
|
||||
}.apply {
|
||||
this.content = webViewController.getWebContent()
|
||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||
}
|
||||
|
||||
val navigator = rememberWebViewNavigator()
|
||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||
|
||||
val bridgeBootstrapScript = remember {
|
||||
"""
|
||||
(function() {
|
||||
if (typeof window.sendMessage !== "function") {
|
||||
window.sendMessage = function(message) {
|
||||
if (typeof message !== "string") {
|
||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||
}
|
||||
window.kmpJsBridge.callNative("sendMessage", message);
|
||||
};
|
||||
}
|
||||
|
||||
if (!window.bridgeReady) {
|
||||
const event = new CustomEvent("onBridgeReady");
|
||||
window.dispatchEvent(event);
|
||||
window.bridgeReady = true;
|
||||
}
|
||||
})();
|
||||
""".trimIndent()
|
||||
}
|
||||
|
||||
LaunchedEffect(state) {
|
||||
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(webViewBridge) {
|
||||
webViewBridge.register(PostMessageHandler { message ->
|
||||
webViewController.emitPostMessage(message)
|
||||
})
|
||||
}
|
||||
|
||||
DisposableEffect(Unit) {
|
||||
onDispose {
|
||||
webViewController.dispose()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = Modifier.fillMaxSize()) {
|
||||
ApplicationMainBar(
|
||||
title = {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
verticalAlignment = Alignment.CenterVertically
|
||||
) {
|
||||
Row(
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.Start,
|
||||
modifier = Modifier.height(56.dp)
|
||||
) {
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigator.navigateBack()
|
||||
},
|
||||
enabled = navigator.canGoBack,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronLeft,
|
||||
contentDescription = "Go back to browser history"
|
||||
)
|
||||
}
|
||||
MillenniumIconButton(
|
||||
onClick = {
|
||||
navigator.navigateForward()
|
||||
},
|
||||
enabled = navigator.canGoForward,
|
||||
style = MillenniumIconButtonStyle.Quiet
|
||||
) {
|
||||
MillenniumIcon(
|
||||
FeatherIcons.ChevronRight,
|
||||
contentDescription = "Go forward to browser history"
|
||||
)
|
||||
}
|
||||
}
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.weight(1f)
|
||||
.height(36.dp)
|
||||
.padding(horizontal = 4.dp)
|
||||
.clip(RoundedCornerShape(18.dp))
|
||||
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
|
||||
.border(
|
||||
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
|
||||
RoundedCornerShape(18.dp)),
|
||||
contentAlignment = Alignment.CenterStart
|
||||
) {
|
||||
BasicTextField(
|
||||
value = state.lastLoadedUrl ?: "",
|
||||
onValueChange = {}, // Read-only
|
||||
readOnly = true,
|
||||
singleLine = true,
|
||||
textStyle = AppTheme.typography.bodyMedium.copy(
|
||||
color = AppTheme.colors.onSurface,
|
||||
textAlign = TextAlign.Start
|
||||
),
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||
.wrapContentHeight(Alignment.CenterVertically)
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
)
|
||||
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
|
||||
|
||||
class BuiltInPluginService(
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||
|
||||
override val pluginId: String = pluginInfo.id
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
@ -58,7 +57,6 @@ class BuiltInPluginService(
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
override suspend fun start() {
|
||||
when (pluginInfo) {
|
||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
||||
|
||||
@ -1,347 +1,344 @@
|
||||
/*
|
||||
* 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.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
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_SERVICE_NAME
|
||||
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.PersistedStorageAPI
|
||||
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_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
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_SERVICE_NAME
|
||||
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.lyrics.LyricsAPI
|
||||
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_SERVICE_NAME
|
||||
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.browse.MetadataBrowseAPI
|
||||
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_SERVICE_NAME
|
||||
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.track.MetadataTrackAPI
|
||||
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_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
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.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* 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
|
||||
* `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.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry,
|
||||
) : PluginService, KoinComponent {
|
||||
|
||||
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
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
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 systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||
)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.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}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
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
|
||||
val scope = PluginServiceScope(serviceRegistry)
|
||||
// Execute the block with 'scope' as 'this'
|
||||
scope.block()
|
||||
}
|
||||
}
|
||||
/*
|
||||
* 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.zipline
|
||||
|
||||
import app.cash.zipline.Zipline
|
||||
import app.cash.zipline.ZiplineService
|
||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||
import app.cash.zipline.loader.LoadResult
|
||||
import app.cash.zipline.loader.ManifestVerifier
|
||||
import app.cash.zipline.loader.ZiplineLoader
|
||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||
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_SERVICE_NAME
|
||||
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.PersistedStorageAPI
|
||||
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_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
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_SERVICE_NAME
|
||||
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.lyrics.LyricsAPI
|
||||
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_SERVICE_NAME
|
||||
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.browse.MetadataBrowseAPI
|
||||
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_SERVICE_NAME
|
||||
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.track.MetadataTrackAPI
|
||||
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_SERVICE_NAME
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||
import dev.krtirtho.spotube.core.di.injectLogger
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
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.RealPersistedStorageAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||
import io.ktor.http.URLBuilder
|
||||
import io.ktor.http.decodeURLQueryComponent
|
||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import okio.Path.Companion.toPath
|
||||
import org.koin.core.component.KoinComponent
|
||||
import org.koin.core.component.inject
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
/**
|
||||
* Manages the lifecycle of a Zipline plugin.
|
||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||
* Anything it provides, must be called within that dispatcher context.
|
||||
* The [use] function must be used.
|
||||
*
|
||||
* 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
|
||||
* `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.
|
||||
*/
|
||||
open class ZiplinePluginService(
|
||||
val applicationName: String,
|
||||
private val manifestUrl: String,
|
||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||
|
||||
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
|
||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||
// instance is created, before any modules are loaded.
|
||||
private val ziplineDispatcher = createZiplineDispatcher()
|
||||
|
||||
private fun trace(event: String) {
|
||||
logger.d { "[$applicationName] $event" }
|
||||
}
|
||||
|
||||
private val logger by injectLogger<ZiplinePluginService>()
|
||||
private val webViewController: WebViewController by inject()
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||
logger.e(throwable) { "Zipline Engine Error" }
|
||||
}
|
||||
private val lifecycleMutex = Mutex()
|
||||
private var ziplineLoader: ZiplineLoader
|
||||
private var ziplineInstance: Zipline? = null
|
||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||
|
||||
init {
|
||||
val manifestPath = URLBuilder(manifestUrl)
|
||||
val baseDir =
|
||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||
ziplineLoader = ZiplineLoader(
|
||||
dispatcher = ziplineDispatcher.dispatcher,
|
||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||
httpClient = FileSystemHTTPClient(baseDir)
|
||||
)
|
||||
}
|
||||
|
||||
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 systemInformationAPI = RealSystemInformationAPI()
|
||||
|
||||
private val loggedInStateFlow = MutableStateFlow(false)
|
||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||
|
||||
private fun bindHostServices(zipline: Zipline) {
|
||||
trace("initializer(): binding host APIs")
|
||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||
try {
|
||||
// Basic APIs
|
||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||
zipline.bind<SystemInformationAPI>(
|
||||
SystemInformationAPI_SERVICE_NAME,
|
||||
systemInformationAPI
|
||||
)
|
||||
|
||||
// Conditional APIs based on plugin capabilities
|
||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||
zipline.bind<HttpClientAPI>(
|
||||
HttpClientAPI_SERVICE_NAME,
|
||||
realHttpClientAPI
|
||||
)
|
||||
}
|
||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||
}
|
||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||
zipline.bind<PersistedStorageAPI>(
|
||||
PersistedStorageAPI_SERVICE_NAME,
|
||||
persistedStorageAPI
|
||||
)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||
throw e
|
||||
}
|
||||
}
|
||||
|
||||
private fun consumePluginServices(result: LoadResult.Success) {
|
||||
trace("start(): loadOnce success")
|
||||
val apiMap =
|
||||
buildMap<KClass<*>, ZiplineService> {
|
||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||
|
||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||
put(
|
||||
MetadataUserAPI::class,
|
||||
result.zipline.take<MetadataUserAPI>(
|
||||
MetadataUserAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataTrackAPI::class,
|
||||
result.zipline.take<MetadataTrackAPI>(
|
||||
MetadataTrackAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataAlbumAPI::class,
|
||||
result.zipline.take<MetadataAlbumAPI>(
|
||||
MetadataAlbumAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataArtistAPI::class,
|
||||
result.zipline.take<MetadataArtistAPI>(
|
||||
MetadataArtistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataPlaylistAPI::class,
|
||||
result.zipline.take<MetadataPlaylistAPI>(
|
||||
MetadataPlaylistAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataBrowseAPI::class,
|
||||
result.zipline.take<MetadataBrowseAPI>(
|
||||
MetadataBrowseAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
put(
|
||||
MetadataSearchAPI::class,
|
||||
result.zipline.take<MetadataSearchAPI>(
|
||||
MetadataSearchAPI_SERVICE_NAME
|
||||
)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||
put(
|
||||
AudioAPI::class,
|
||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||
put(
|
||||
LyricsAPI::class,
|
||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||
put(
|
||||
ScrobbleAPI::class,
|
||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
serviceRegistry.putAll(apiMap)
|
||||
trace("start(): API ready")
|
||||
}
|
||||
|
||||
private fun runLogInFlowObservers() = scope.launch {
|
||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||
loggedInStateFlow.value = isLoggedIn
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun start() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("start(): entered")
|
||||
if (serviceRegistry.isNotEmpty()) {
|
||||
trace("start(): already started, skipping")
|
||||
return
|
||||
}
|
||||
|
||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
trace("start(): inside zipline dispatcher before loadOnce")
|
||||
val result = ziplineLoader.loadOnce(
|
||||
applicationName = applicationName,
|
||||
manifestUrl = manifestUrl,
|
||||
freshnessChecker = DefaultFreshnessCheckerNotFresh)
|
||||
when (result) {
|
||||
is LoadResult.Success -> {
|
||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||
ziplineInstance = result.zipline
|
||||
// Now we consume the initializer
|
||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||
// Bind host services before initialization, so plugins can use them in their initializer
|
||||
bindHostServices(result.zipline)
|
||||
trace("start(): calling initializer.initialize()")
|
||||
runCatching { initializer.initialize() }
|
||||
.onSuccess {
|
||||
consumePluginServices(result)
|
||||
runLogInFlowObservers()
|
||||
}
|
||||
.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}" }
|
||||
throw result.exception
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun stop() {
|
||||
lifecycleMutex.withLock {
|
||||
trace("stop(): entered")
|
||||
withContext(ziplineDispatcher.dispatcher) {
|
||||
ziplineInstance?.close()
|
||||
ziplineInstance = null
|
||||
for (service in serviceRegistry.values) {
|
||||
try {
|
||||
trace("stop(): closing service ${service::class.simpleName}")
|
||||
service.close()
|
||||
} catch (_: Exception) {
|
||||
trace("stop(): error closing service ${service::class.simpleName}")
|
||||
}
|
||||
}
|
||||
}
|
||||
serviceRegistry.clear()
|
||||
scope.cancel()
|
||||
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
|
||||
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 {
|
||||
private val provider = CryptographyProvider.Default
|
||||
|
||||
@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
return CryptographyRandom.nextBytes(size)
|
||||
}
|
||||
|
||||
|
||||
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
||||
val hasher = provider.get(algorithm.algorithm()).hasher()
|
||||
return hasher.hash(data)
|
||||
@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
||||
algorithm.keySizeBits.bits,
|
||||
algorithm.hashAlgorithm.algorithm(),
|
||||
algorithm.publicExponent.toBigInt(),
|
||||
).generateKey()
|
||||
algorithm.publicExponent.toBigInt()).generateKey()
|
||||
|
||||
Pair(
|
||||
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
||||
@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.ECDSA ->
|
||||
provider.get(ECDSA).privateKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.privateKeyFormat(),
|
||||
privateKey
|
||||
).signatureGenerator(
|
||||
@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.EdDSA ->
|
||||
provider.get(EdDSA).privateKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.privateKeyFormat(),
|
||||
privateKey
|
||||
).signatureGenerator().generateSignature(data)
|
||||
@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.ECDSA ->
|
||||
provider.get(ECDSA).publicKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.publicKeyFormat(),
|
||||
publicKey
|
||||
).signatureVerifier(
|
||||
@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||
|
||||
is SignAlgorithms.EdDSA ->
|
||||
provider.get(EdDSA).publicKeyDecoder(
|
||||
algorithm.curve.curve(),
|
||||
).decodeFromByteArray(
|
||||
algorithm.curve.curve()).decodeFromByteArray(
|
||||
algorithm.format.publicKeyFormat(),
|
||||
publicKey
|
||||
).signatureVerifier().verifySignature(
|
||||
|
||||
@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText
|
||||
import io.ktor.http.takeFrom
|
||||
import org.koin.core.component.KoinComponent
|
||||
|
||||
|
||||
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
||||
val logger by injectLogger<RealHttpClientAPI>()
|
||||
val httpClient = HttpClient()
|
||||
|
||||
@ -1,74 +1,73 @@
|
||||
/*
|
||||
* 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.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String,
|
||||
) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
/*
|
||||
* 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.zipline.host_apis
|
||||
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
|
||||
class RealWebViewAPI(
|
||||
private val scope: CoroutineScope,
|
||||
private val webViewController: WebViewController,
|
||||
private val pluginId: String) : WebViewAPI {
|
||||
override fun navigateTo(url: String) {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.navigateTo(url, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override fun navigateToHTML(html: String) {
|
||||
scope.launch {
|
||||
webViewController.navigateToHTML(html, pluginId)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getCookies(url: String): List<Cookie> {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.getCookies(url)
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun evaluateJavaScript(script: String): String? {
|
||||
return withContext(Dispatchers.Main) {
|
||||
webViewController.evaluateJavascript(script)
|
||||
}
|
||||
}
|
||||
|
||||
override fun urlChangeFlow(): Flow<String> {
|
||||
return webViewController.urlChangedFlow
|
||||
}
|
||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||
return webViewController.webviewCreatedFlow
|
||||
}
|
||||
override fun postMessagesFlow(): Flow<String> {
|
||||
return webViewController.postMessagesFlow
|
||||
}
|
||||
|
||||
override fun exitWebView() {
|
||||
scope.launch(Dispatchers.Main) {
|
||||
webViewController.closeWebview()
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
@ -45,8 +45,7 @@ data class LRCLibResponse(
|
||||
val artistName: String,
|
||||
val albumName: String,
|
||||
val plainLyrics: String?,
|
||||
val syncedLyrics: String?,
|
||||
)
|
||||
val syncedLyrics: String?)
|
||||
|
||||
class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
private val httpClient = HttpClient {
|
||||
@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI {
|
||||
val text = line.substring(match.range.last + 1).trim()
|
||||
LyricsLine(time = timeMs, text = text)
|
||||
},
|
||||
plainLyrics = response.plainLyrics,
|
||||
)
|
||||
plainLyrics = response.plainLyrics)
|
||||
}
|
||||
}
|
||||
@ -124,16 +124,14 @@ data class MusicGatewayResponse(
|
||||
val externalIds: ExternalIds,
|
||||
val id: String,
|
||||
val name: String,
|
||||
val popularity: Int,
|
||||
) {
|
||||
val popularity: Int) {
|
||||
@Serializable
|
||||
data class Album(
|
||||
@SerialName("album_type")
|
||||
val albumType: String,
|
||||
val artists: List<Artist>,
|
||||
val id: String,
|
||||
val name: String,
|
||||
)
|
||||
val name: String)
|
||||
|
||||
@Serializable
|
||||
data class Artist(
|
||||
|
||||
@ -57,7 +57,6 @@ class ISRCProviders {
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
private var ifpiSessionToken: String? = null
|
||||
private val ifpiDefaultHeaders = mapOf(
|
||||
"Content-Type" to "application/json",
|
||||
@ -76,7 +75,6 @@ class ISRCProviders {
|
||||
"User-Agent" to UserAgents.random()
|
||||
)
|
||||
|
||||
|
||||
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
|
||||
if (ifpiSessionToken != null) return@withContext ifpiSessionToken
|
||||
|
||||
|
||||
@ -65,12 +65,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
Thumbnail(
|
||||
url = video.thumbnailUrl,
|
||||
width = 300,
|
||||
height = 300,
|
||||
)
|
||||
height = 300)
|
||||
),
|
||||
externalUri = "https://www.youtube.com/watch?v=${video.id}",
|
||||
confidence = confidence,
|
||||
)
|
||||
confidence = confidence)
|
||||
}
|
||||
|
||||
override val supportedQualities: List<AudioFormat> = listOf(
|
||||
@ -79,15 +77,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_000),
|
||||
AudioQuality.Lossy(bitrate = 128_000),
|
||||
AudioQuality.Lossy(bitrate = 256_000),
|
||||
)
|
||||
AudioQuality.Lossy(bitrate = 256_000))
|
||||
), AudioFormat(
|
||||
codec = "aac", container = "mp4", qualities = listOf(
|
||||
AudioQuality.Lossy(bitrate = 44_000),
|
||||
AudioQuality.Lossy(bitrate = 96_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)
|
||||
|
||||
|
||||
logger.i {
|
||||
"Searching streams for track(${track.id}): ${track.title} by ${
|
||||
track.artists.joinToString(
|
||||
@ -119,13 +114,11 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
Thumbnail(
|
||||
url = videoInfo.thumbnailUrl,
|
||||
width = 300,
|
||||
height = 300,
|
||||
)
|
||||
height = 300)
|
||||
),
|
||||
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
||||
confidence = 1.0f,
|
||||
streams = videoInfo.audioStreams,
|
||||
)
|
||||
streams = videoInfo.audioStreams)
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -165,8 +158,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
|
||||
Thumbnail(
|
||||
url = videoInfo.thumbnailUrl,
|
||||
width = 300,
|
||||
height = 300,
|
||||
)
|
||||
height = 300)
|
||||
),
|
||||
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
|
||||
confidence = source.confidence,
|
||||
|
||||
@ -36,8 +36,7 @@ import kotlinx.coroutines.launch
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class AlbumRepository(
|
||||
val pluginManager: PluginManager,
|
||||
private val libraryRepository: LibraryRepository,
|
||||
) {
|
||||
private val libraryRepository: LibraryRepository) {
|
||||
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
|
||||
|
||||
val plugin
|
||||
|
||||
@ -110,8 +110,6 @@ fun AlbumScreen(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||
)
|
||||
},
|
||||
)
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist)
|
||||
})
|
||||
}
|
||||
|
||||
@ -65,15 +65,13 @@ sealed interface AlbumScreenState {
|
||||
override val isSaved: Boolean = false,
|
||||
override val tracks: List<MetadataTrack> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null,
|
||||
override val isSaving: Boolean = false,
|
||||
) : Data {
|
||||
override val isSaving: Boolean = false) : Data {
|
||||
fun toLoadingMore(): LoadingMore = LoadingMore(
|
||||
album = album,
|
||||
isSaved = isSaved,
|
||||
tracks = tracks,
|
||||
nextPagination = nextPagination,
|
||||
isSaving = isSaving,
|
||||
)
|
||||
isSaving = isSaving)
|
||||
}
|
||||
|
||||
data class LoadingMore(
|
||||
@ -81,8 +79,7 @@ sealed interface AlbumScreenState {
|
||||
override val isSaved: Boolean = false,
|
||||
override val tracks: List<MetadataTrack> = emptyList(),
|
||||
override val nextPagination: PaginationStrategy? = null,
|
||||
override val isSaving: Boolean = false,
|
||||
) : Data
|
||||
override val isSaving: Boolean = false) : Data
|
||||
}
|
||||
|
||||
data class Error(val message: String) : AlbumScreenState
|
||||
@ -99,8 +96,7 @@ class AlbumViewModel(
|
||||
private val blacklistRepository: BlacklistRepository,
|
||||
private val shareService: ShareService,
|
||||
private val downloadManager: DownloadManager,
|
||||
private val remotePlaybackController: RemotePlaybackController,
|
||||
) : ViewModel(), KoinComponent {
|
||||
private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent {
|
||||
private val logger by injectLogger<AlbumViewModel>()
|
||||
|
||||
private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading)
|
||||
@ -126,8 +122,7 @@ class AlbumViewModel(
|
||||
audioPlayerQueue.currentQueueEntryFlow,
|
||||
savedTracksRepository.savedTracksIdsFlow,
|
||||
_blacklistedTrackIds,
|
||||
_blacklistedArtistIds,
|
||||
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||
_blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
|
||||
TrackOptionsContext(
|
||||
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
|
||||
queueTrackIds = queue.mapNotNull { entry ->
|
||||
@ -135,8 +130,7 @@ class AlbumViewModel(
|
||||
}.toSet(),
|
||||
savedTrackIds = savedIds,
|
||||
blacklistedTrackIds = blacklistedTracks,
|
||||
blacklistedArtistIds = blacklistedArtists,
|
||||
)
|
||||
blacklistedArtistIds = blacklistedArtists)
|
||||
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
|
||||
|
||||
init {
|
||||
@ -175,8 +169,7 @@ class AlbumViewModel(
|
||||
album = albumInfo?.first,
|
||||
isSaved = albumInfo?.second ?: false,
|
||||
tracks = tracksResult?.items ?: emptyList(),
|
||||
nextPagination = tracksResult?.nextPagination,
|
||||
)
|
||||
nextPagination = tracksResult?.nextPagination)
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load album" }
|
||||
_state.value = AlbumScreenState.Error(e.message ?: "Unknown error")
|
||||
@ -193,8 +186,7 @@ class AlbumViewModel(
|
||||
isSaved = currentState.isSaved,
|
||||
tracks = currentState.tracks + (result?.items ?: emptyList()),
|
||||
nextPagination = result?.nextPagination,
|
||||
isSaving = currentState.isSaving,
|
||||
)
|
||||
isSaving = currentState.isSaving)
|
||||
}
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to load more tracks" }
|
||||
@ -245,8 +237,7 @@ class AlbumViewModel(
|
||||
type = RemoteCollectionType.Album,
|
||||
id = albumId,
|
||||
title = title,
|
||||
startTrack = track,
|
||||
)
|
||||
startTrack = track)
|
||||
}
|
||||
|
||||
fun refresh() {
|
||||
@ -319,8 +310,7 @@ class AlbumViewModel(
|
||||
runCatching {
|
||||
libraryRepository.addTracksToPlaylist(
|
||||
playlistId,
|
||||
_tracksToAddToPlaylist.value.map { it.id },
|
||||
)
|
||||
_tracksToAddToPlaylist.value.map { it.id })
|
||||
}.onFailure { e ->
|
||||
logger.e(e) { "Failed to add tracks to playlist" }
|
||||
}.onSuccess {
|
||||
|
||||
@ -17,10 +17,20 @@
|
||||
|
||||
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.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.PaddingValues
|
||||
import androidx.compose.foundation.layout.Row
|
||||
@ -35,19 +45,9 @@ import androidx.compose.foundation.lazy.LazyRow
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||
import com.composeunstyled.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@ -74,10 +74,6 @@ import dev.krtirtho.spotube.core.jam.JamRoomService
|
||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||
import dev.krtirtho.spotube.core.navigation.Routes
|
||||
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.ApplicationMainBar
|
||||
import dev.krtirtho.spotube.core.ui.component.ArtistCard
|
||||
@ -118,17 +114,19 @@ fun ArtistScreen(
|
||||
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
|
||||
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
|
||||
|
||||
BaseScaffold(
|
||||
topBar = { ApplicationMainBar() }
|
||||
) { innerPadding ->
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(MillenniumTheme.colors.background)) {
|
||||
ApplicationMainBar()
|
||||
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
|
||||
when (val currentState = state) {
|
||||
is ArtistScreenState.Loading -> {
|
||||
ArtistLoadingContent(innerPadding)
|
||||
ArtistLoadingContent()
|
||||
}
|
||||
|
||||
is ArtistScreenState.Error -> {
|
||||
ArtistErrorContent(
|
||||
innerPadding = innerPadding,
|
||||
message = currentState.message,
|
||||
onRetry = { viewModel.refresh() }
|
||||
)
|
||||
@ -141,25 +139,21 @@ fun ArtistScreen(
|
||||
state = listState,
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding)
|
||||
.padding(bottom = LocalAppShellBottomInset.current + 24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
item {
|
||||
ArtistHeaderCard(
|
||||
artist = currentState.artist,
|
||||
isSaved = savedArtistIds.contains(currentState.artist.id),
|
||||
onFollowClick = viewModel::toggleSavedArtist,
|
||||
isBlacklisted = currentState.artist.id in blacklistedArtistIds,
|
||||
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) },
|
||||
)
|
||||
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) })
|
||||
}
|
||||
|
||||
item {
|
||||
TopTracksHeader(
|
||||
onPlay = viewModel::playTopTracks,
|
||||
onAddToQueue = viewModel::addTopTracksToQueue,
|
||||
)
|
||||
onAddToQueue = viewModel::addTopTracksToQueue)
|
||||
}
|
||||
|
||||
item {
|
||||
@ -197,8 +191,7 @@ fun ArtistScreen(
|
||||
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
|
||||
onBulkAddToJam = viewModel::addTracksToJam,
|
||||
isInJam = jamActive,
|
||||
isJamGuest = isJamGuest,
|
||||
)
|
||||
isJamGuest = isJamGuest)
|
||||
}
|
||||
|
||||
if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) {
|
||||
@ -212,8 +205,7 @@ fun ArtistScreen(
|
||||
)
|
||||
)
|
||||
},
|
||||
onLoadMore = { viewModel.loadMoreAlbums() },
|
||||
)
|
||||
onLoadMore = { viewModel.loadMoreAlbums() })
|
||||
}
|
||||
}
|
||||
|
||||
@ -228,8 +220,7 @@ fun ArtistScreen(
|
||||
)
|
||||
)
|
||||
},
|
||||
onLoadMore = { viewModel.loadMoreRelatedArtists() },
|
||||
)
|
||||
onLoadMore = { viewModel.loadMoreRelatedArtists() })
|
||||
}
|
||||
}
|
||||
|
||||
@ -244,73 +235,62 @@ fun ArtistScreen(
|
||||
)
|
||||
)
|
||||
},
|
||||
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() },
|
||||
)
|
||||
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() })
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
AddToPlaylistPicker(
|
||||
visible = showAddToPlaylistPicker,
|
||||
currentUserId = currentUserId,
|
||||
onDismiss = viewModel::dismissAddToPlaylistPicker,
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist,
|
||||
)
|
||||
onPlaylistSelected = viewModel::addTracksToPlaylist)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun ArtistLoadingContent(innerPadding: PaddingValues) {
|
||||
private fun ArtistLoadingContent() {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
SkeletonTree(true) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
.padding(horizontal = 16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(200.dp)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
)
|
||||
.background(AppTheme.colors.surfaceContainerHighest))
|
||||
}
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
) {
|
||||
.padding(horizontal = 16.dp)) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 8.dp),
|
||||
)
|
||||
.padding(vertical = 8.dp))
|
||||
}
|
||||
LazyRow(
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
) {
|
||||
contentPadding = PaddingValues(horizontal = 16.dp)) {
|
||||
items(4) {
|
||||
PlayableCard(
|
||||
title = "Item Title",
|
||||
subtitle = "Subtitle",
|
||||
imageURL = "https://placehold.co/600x400",
|
||||
)
|
||||
imageURL = "https://placehold.co/600x400")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -321,26 +301,20 @@ private fun ArtistLoadingContent(innerPadding: PaddingValues) {
|
||||
|
||||
@Composable
|
||||
private fun ArtistErrorContent(
|
||||
innerPadding: PaddingValues,
|
||||
message: String,
|
||||
onRetry: () -> Unit,
|
||||
) {
|
||||
onRetry: () -> Unit) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.padding(innerPadding),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
.fillMaxSize(),
|
||||
contentAlignment = Alignment.Center) {
|
||||
Column(
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||
Text(
|
||||
text = message,
|
||||
color = MaterialTheme.colorScheme.error,
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
PrimaryButton(onClick = onRetry) {
|
||||
color = AppTheme.colors.error,
|
||||
style = AppTheme.typography.bodyLarge)
|
||||
MillenniumButton(onClick = onRetry) {
|
||||
Text("Retry")
|
||||
}
|
||||
}
|
||||
@ -353,44 +327,34 @@ private fun ArtistHeaderCard(
|
||||
isSaved: Boolean,
|
||||
onFollowClick: () -> Unit,
|
||||
isBlacklisted: Boolean,
|
||||
onBlacklistClick: () -> Unit,
|
||||
) {
|
||||
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
|
||||
val isCompact = maxWidth < 600.dp
|
||||
onBlacklistClick: () -> Unit) {
|
||||
Box(modifier = Modifier.fillMaxWidth()) {
|
||||
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||
|
||||
Card(
|
||||
MillenniumCard(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||
colors = CardDefaults.cardColors(
|
||||
containerColor = MaterialTheme.colorScheme.surfaceContainer,
|
||||
),
|
||||
shape = RoundedCornerShape(20.dp),
|
||||
) {
|
||||
.padding(horizontal = 12.dp, vertical = 8.dp)) {
|
||||
if (isCompact) {
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(16.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
horizontalAlignment = Alignment.CenterHorizontally,
|
||||
) {
|
||||
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||
ArtistAvatar(
|
||||
artist = artist,
|
||||
size = 180.dp,
|
||||
)
|
||||
size = 180.dp)
|
||||
|
||||
ArtistMeta(
|
||||
artist = artist,
|
||||
isCompact = true,
|
||||
)
|
||||
isCompact = true)
|
||||
|
||||
ArtistHeaderActions(
|
||||
isSaved = isSaved,
|
||||
onFollowClick = onFollowClick,
|
||||
isBlacklisted = isBlacklisted,
|
||||
onBlacklistClick = onBlacklistClick,
|
||||
)
|
||||
onBlacklistClick = onBlacklistClick)
|
||||
}
|
||||
} else {
|
||||
Row(
|
||||
@ -398,28 +362,23 @@ private fun ArtistHeaderCard(
|
||||
.fillMaxWidth()
|
||||
.padding(20.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(20.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
ArtistAvatar(
|
||||
artist = artist,
|
||||
size = 220.dp,
|
||||
)
|
||||
size = 220.dp)
|
||||
|
||||
Column(
|
||||
modifier = Modifier.weight(1f),
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(14.dp)) {
|
||||
ArtistMeta(
|
||||
artist = artist,
|
||||
isCompact = false,
|
||||
)
|
||||
isCompact = false)
|
||||
|
||||
ArtistHeaderActions(
|
||||
isSaved = isSaved,
|
||||
onFollowClick = onFollowClick,
|
||||
isBlacklisted = isBlacklisted,
|
||||
onBlacklistClick = onBlacklistClick,
|
||||
)
|
||||
onBlacklistClick = onBlacklistClick)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -430,30 +389,26 @@ private fun ArtistHeaderCard(
|
||||
@Composable
|
||||
private fun ArtistAvatar(
|
||||
artist: MetadataArtist.Detailed,
|
||||
size: androidx.compose.ui.unit.Dp,
|
||||
) {
|
||||
size: androidx.compose.ui.unit.Dp) {
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.size(size)
|
||||
.clip(CircleShape)
|
||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
.background(AppTheme.colors.surfaceContainerHighest),
|
||||
contentAlignment = Alignment.Center) {
|
||||
val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
|
||||
if (imageUrl.isNotBlank()) {
|
||||
AsyncImage(
|
||||
model = imageUrl,
|
||||
contentDescription = artist.name,
|
||||
modifier = Modifier.fillMaxSize(),
|
||||
contentScale = ContentScale.Crop,
|
||||
)
|
||||
contentScale = ContentScale.Crop)
|
||||
} else {
|
||||
Icon(
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.User,
|
||||
contentDescription = artist.name,
|
||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
modifier = Modifier.size(size * 0.4f),
|
||||
)
|
||||
tint = AppTheme.colors.onSurfaceVariant,
|
||||
modifier = Modifier.size(size * 0.4f))
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -461,21 +416,18 @@ private fun ArtistAvatar(
|
||||
@Composable
|
||||
private fun ArtistMeta(
|
||||
artist: MetadataArtist.Detailed,
|
||||
isCompact: Boolean,
|
||||
) {
|
||||
isCompact: Boolean) {
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp),
|
||||
) {
|
||||
verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(
|
||||
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,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
|
||||
Text(
|
||||
text = buildString {
|
||||
@ -485,74 +437,66 @@ private fun ArtistMeta(
|
||||
append(artist.genres.joinToString(", "))
|
||||
}
|
||||
},
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||
)
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start)
|
||||
|
||||
artist.biography?.takeIf { it.isNotBlank() }?.let {
|
||||
Text(
|
||||
text = it,
|
||||
style = MaterialTheme.typography.bodyMedium,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
style = AppTheme.typography.bodyMedium,
|
||||
color = AppTheme.colors.onSurfaceVariant,
|
||||
maxLines = if (isCompact) 4 else 6,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
|
||||
)
|
||||
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
private fun ArtistHeaderActions(
|
||||
isSaved: Boolean,
|
||||
onFollowClick: () -> Unit,
|
||||
isBlacklisted: Boolean,
|
||||
onBlacklistClick: () -> Unit,
|
||||
) {
|
||||
onBlacklistClick: () -> Unit) {
|
||||
Row(
|
||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
verticalAlignment = Alignment.CenterVertically) {
|
||||
if (isSaved) {
|
||||
SecondaryButton(onClick = onFollowClick) {
|
||||
MillenniumButton(
|
||||
onClick = onFollowClick,
|
||||
style = MillenniumButtonStyle.Secondary) {
|
||||
Text(
|
||||
"Following",
|
||||
textAlign = TextAlign.Center,
|
||||
maxLines = 1,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
overflow = TextOverflow.Ellipsis)
|
||||
}
|
||||
} else {
|
||||
PrimaryButton(onClick = onFollowClick) {
|
||||
MillenniumButton(onClick = onFollowClick) {
|
||||
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
|
||||
}
|
||||
}
|
||||
|
||||
TooltipBox(
|
||||
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above),
|
||||
tooltip = {
|
||||
PlainTooltip {
|
||||
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
|
||||
}
|
||||
},
|
||||
state = rememberTooltipState()
|
||||
MillenniumTooltip(
|
||||
text = if (isBlacklisted) "Remove from blacklist" else "Add to blacklist"
|
||||
) {
|
||||
if (isBlacklisted) {
|
||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onBlacklistClick,
|
||||
style = MillenniumIconButtonStyle.Secondary) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxUserRemove,
|
||||
contentDescription = "Remove from blacklist",
|
||||
tint = MaterialTheme.colorScheme.error,
|
||||
)
|
||||
tint = AppTheme.colors.error)
|
||||
}
|
||||
} else {
|
||||
SecondaryIconButton(onClick = onBlacklistClick) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onBlacklistClick,
|
||||
style = MillenniumIconButtonStyle.Secondary) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxUserRemove,
|
||||
contentDescription = "Add to blacklist",
|
||||
)
|
||||
contentDescription = "Add to blacklist")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -562,33 +506,32 @@ private fun ArtistHeaderActions(
|
||||
@Composable
|
||||
private fun TopTracksHeader(
|
||||
onPlay: () -> Unit,
|
||||
onAddToQueue: () -> Unit,
|
||||
) {
|
||||
onAddToQueue: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(horizontal = 16.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Text(
|
||||
text = "Top Tracks",
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold)
|
||||
|
||||
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
PrimaryIconButton(onClick = onPlay) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onPlay,
|
||||
style = MillenniumIconButtonStyle.Primary) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxPlay,
|
||||
contentDescription = "Play top tracks",
|
||||
)
|
||||
contentDescription = "Play top tracks")
|
||||
}
|
||||
SecondaryIconButton(onClick = onAddToQueue) {
|
||||
Icon(
|
||||
MillenniumIconButton(
|
||||
onClick = onAddToQueue,
|
||||
style = MillenniumIconButtonStyle.Secondary) {
|
||||
MillenniumIcon(
|
||||
imageVector = Iconsax.IconsaxAddSquare,
|
||||
contentDescription = "Add top tracks to queue",
|
||||
)
|
||||
contentDescription = "Add top tracks to queue")
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -598,8 +541,7 @@ private fun TopTracksHeader(
|
||||
private fun AlbumsSection(
|
||||
albums: List<MetadataAlbum.Detailed>,
|
||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
) {
|
||||
onLoadMore: () -> Unit) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(rowState) {
|
||||
@ -624,8 +566,7 @@ private fun AlbumsSection(
|
||||
modifier = Modifier.dragScrollable(rowState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
items(albums, key = { it.id }) { album ->
|
||||
AlbumCard(album = album)
|
||||
}
|
||||
@ -636,8 +577,7 @@ private fun AlbumsSection(
|
||||
private fun RelatedArtistsSection(
|
||||
artists: List<MetadataArtist.Basic>,
|
||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
) {
|
||||
onLoadMore: () -> Unit) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(rowState) {
|
||||
@ -662,8 +602,7 @@ private fun RelatedArtistsSection(
|
||||
modifier = Modifier.dragScrollable(rowState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
items(artists, key = { it.id }) { artist ->
|
||||
ArtistCard(artist = artist)
|
||||
}
|
||||
@ -674,8 +613,7 @@ private fun RelatedArtistsSection(
|
||||
private fun FeaturedPlaylistsSection(
|
||||
playlists: List<MetadataPlaylist>,
|
||||
onPlaylistClick: (MetadataPlaylist) -> Unit,
|
||||
onLoadMore: () -> Unit,
|
||||
) {
|
||||
onLoadMore: () -> Unit) {
|
||||
val rowState = rememberLazyListState()
|
||||
|
||||
LaunchedEffect(rowState) {
|
||||
@ -700,8 +638,7 @@ private fun FeaturedPlaylistsSection(
|
||||
modifier = Modifier.dragScrollable(rowState),
|
||||
contentPadding = PaddingValues(horizontal = 16.dp),
|
||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||
verticalAlignment = Alignment.Top,
|
||||
) {
|
||||
verticalAlignment = Alignment.Top) {
|
||||
items(playlists, key = { it.id }) { playlist ->
|
||||
PlaylistCard(playlist = playlist)
|
||||
}
|
||||
@ -712,10 +649,9 @@ private fun FeaturedPlaylistsSection(
|
||||
private fun SectionHeader(title: String) {
|
||||
Text(
|
||||
text = title,
|
||||
style = MaterialTheme.typography.titleLarge,
|
||||
style = AppTheme.typography.titleLarge,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
modifier = Modifier.padding(horizontal = 16.dp),
|
||||
)
|
||||
modifier = Modifier.padding(horizontal = 16.dp))
|
||||
}
|
||||
|
||||
private fun formatFollowers(count: Int?): String {
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Loading…
Reference in New Issue
Block a user