Compare commits

...

6 Commits

Author SHA1 Message Date
Jonatan Nyberg
76c472cdbf
Merge b0327fd224 into caad76ba95 2026-10-11 08:18:35 -07:00
Kingkor Roy Tirtho
caad76ba95 refactor: replace currentWindowAdaptiveInfo with currentWindowWidthBreakpoint for adaptive layouts 2026-10-11 21:08:25 +06:00
Kingkor Roy Tirtho
bb9f225fd7
Merge pull request #3136 from henrik242/kmp-zip-1-0-0
Upgrade kmp-zip to 1.0.0
2026-10-11 19:51:34 +06:00
Kingkor Roy Tirtho
cfd472016e refactor: integrate millennium UI 2026-10-11 17:17:34 +06:00
Henrik Brautaset Aronsen
0def3e2e49
Upgrade kmp-zip to 1.0.0
https://github.com/henrik242/kmp-zip/blob/main/CHANGELOG.md
2026-09-30 10:06:42 +02:00
Jonatan Nyberg
b0327fd224 translation: add Swedish resource translations
Signed-off-by: Jonatan Nyberg <84130654+NickWick13@users.noreply.github.com>
2026-09-20 14:17:16 +03:00
231 changed files with 6592 additions and 15001 deletions

View File

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

View File

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

View File

@ -45,7 +45,6 @@ actual val platformModules = module {
collectionPlaybackHelper = get(),
audioPlayerQueue = get(),
settingsRepository = get(),
blacklistRepository = get(),
)
blacklistRepository = get())
}
}

View File

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

View File

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

View File

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

View File

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

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

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

View File

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

View File

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

View File

@ -21,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
interface AudioPlayerInterface {
// // Playback
suspend fun play()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -49,5 +49,4 @@ data class MediaStream(
val container: String,
val bitrate: Int,
val isAudio: Boolean,
val isVideo: Boolean,
)
val isVideo: Boolean)

View File

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

View File

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

View File

@ -162,8 +162,7 @@ class RemoteControlClient {
val envelope = CommandEnvelope(
commandId = randomShortId(),
command = command,
)
command = command)
try {
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)

View File

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

View File

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

View File

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

View File

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

View File

@ -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). */

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -110,8 +110,6 @@ fun AlbumScreen(
visible = showAddToPlaylistPicker,
currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist,
)
},
)
onPlaylistSelected = viewModel::addTracksToPlaylist)
})
}

View File

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

View File

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