refactor: integrate millennium UI

This commit is contained in:
Kingkor Roy Tirtho 2026-10-11 17:17:34 +06:00
parent 963b360a81
commit cfd472016e
229 changed files with 6317 additions and 14840 deletions

View File

@ -119,7 +119,6 @@ kotlin {
// Jetpack Compose dependencies // Jetpack Compose dependencies
implementation(libs.compose.runtime) implementation(libs.compose.runtime)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
implementation(libs.compose.material3)
implementation(libs.compose.ui) implementation(libs.compose.ui)
implementation(libs.coil.compose) implementation(libs.coil.compose)
implementation(libs.coil.network.ktor3) implementation(libs.coil.network.ktor3)
@ -132,6 +131,9 @@ kotlin {
implementation(libs.kotlinx.datetime) implementation(libs.kotlinx.datetime)
implementation(libs.dns.sd.kt) implementation(libs.dns.sd.kt)
// Millennium UI
implementation(libs.millennium)
// Navigation // Navigation
implementation(libs.jetbrains.navigation3.ui) implementation(libs.jetbrains.navigation3.ui)
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3) implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
@ -164,12 +166,6 @@ kotlin {
// icons // icons
implementation(libs.feather.icons) implementation(libs.feather.icons)
// Material3 adaptive
implementation(libs.jetbrains.material3.adaptive)
implementation(libs.jetbrains.material3.adaptive.layout)
implementation(libs.jetbrains.material3.adaptive.suite)
implementation(libs.jetbrains.material3.adaptiveNavigation3)
implementation(libs.jetbrains.material3.window.size)
// androidx-datastore // androidx-datastore
implementation(libs.androidx.datastore) implementation(libs.androidx.datastore)
implementation(libs.androidx.datastore.preferences) implementation(libs.androidx.datastore.preferences)
@ -184,8 +180,6 @@ kotlin {
implementation(libs.semver) implementation(libs.semver)
implementation(libs.material.kolor)
// crypto // crypto
implementation(libs.cryptography.core) implementation(libs.cryptography.core)
implementation(libs.cryptography.provider.optimal) implementation(libs.cryptography.provider.optimal)
@ -206,7 +200,6 @@ kotlin {
// Shimmer effect // Shimmer effect
implementation(libs.compose.shimmer) implementation(libs.compose.shimmer)
implementation(libs.compose.placeholder.material3)
// DLNA // DLNA
implementation(libs.dns.sd.kt) implementation(libs.dns.sd.kt)

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.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
import org.schabi.newpipe.extractor.stream.StreamInfoItem import org.schabi.newpipe.extractor.stream.StreamInfoItem
actual class NewPipeService { actual class NewPipeService {
actual suspend fun searchVideos(query: String): List<VideoSearchResult> { actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
return withContext(Dispatchers.IO) { return withContext(Dispatchers.IO) {
@ -47,8 +46,7 @@ actual class NewPipeService {
url = item.url, url = item.url,
uploader = item.uploaderName, uploader = item.uploaderName,
durationMs = item.duration, durationMs = item.duration,
thumbnailUrl = item.thumbnails.first().url, thumbnailUrl = item.thumbnails.first().url)
)
} else { } else {
null null
} }
@ -67,8 +65,7 @@ actual class NewPipeService {
url = stream.content, url = stream.content,
codec = stream.codec, codec = stream.codec,
container = stream.format!!.suffix, container = stream.format!!.suffix,
bitrate = stream.bitrate, bitrate = stream.bitrate)
)
} + buildList { } + buildList {
if (extractor.dashMpdUrl.isNotBlank()) add( if (extractor.dashMpdUrl.isNotBlank()) add(
AudioStream.Lossy( AudioStream.Lossy(
@ -90,7 +87,6 @@ actual class NewPipeService {
) )
} }
val videoStreams = extractor.videoStreams val videoStreams = extractor.videoStreams
.filter { stream -> stream.isUrl && stream.format != null } .filter { stream -> stream.isUrl && stream.format != null }
.map { stream -> .map { stream ->
@ -98,8 +94,7 @@ actual class NewPipeService {
url = stream.content, url = stream.content,
codec = stream.codec, codec = stream.codec,
bitrate = stream.bitrate, bitrate = stream.bitrate,
container = stream.format!!.suffix, container = stream.format!!.suffix)
)
} }
VideoInfo( VideoInfo(
id = id, id = id,

View File

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

View File

@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit { actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
val context = LocalContext.current val context = LocalContext.current
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
ActivityResultContracts.RequestPermission(), ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ }
) { /* result is picked up by discovery/advertising retry loops */ }
return { return {
val granted = ContextCompat.checkSelfPermission( val granted = ContextCompat.checkSelfPermission(
context, context,
Manifest.permission.NEARBY_WIFI_DEVICES, Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
) == PackageManager.PERMISSION_GRANTED
if (!granted) { if (!granted) {
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES) launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
} }

View File

@ -53,8 +53,7 @@ class MediaBrowseHelper(
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository) {
) {
companion object { companion object {
const val MEDIA_ID_ROOT = "root" const val MEDIA_ID_ROOT = "root"
@ -253,8 +252,7 @@ class MediaBrowseHelper(
.setIsPlayable(false) .setIsPlayable(false)
.build() .build()
) )
.build(), .build())
)
} }
private suspend fun buildBrowseItems(): List<MediaItem> { private suspend fun buildBrowseItems(): List<MediaItem> {
@ -390,8 +388,7 @@ class MediaBrowseHelper(
.setIsBrowsable(true) .setIsBrowsable(true)
.build() .build()
) )
.build(), .build())
)
} }
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> { private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
@ -475,8 +472,7 @@ class MediaBrowseHelper(
.setIsPlayable(false) .setIsPlayable(false)
.build() .build()
) )
.build(), .build())
)
} }
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> { private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {

View File

@ -32,8 +32,7 @@ import java.io.File
import java.util.Locale import java.util.Locale
class AndroidLocalMediaDiscoveryService( class AndroidLocalMediaDiscoveryService(
private val context: Context, private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
) : LocalMediaDiscoveryService, KoinComponent {
val logger by injectLogger<AndroidLocalMediaDiscoveryService>() val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> { override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService(
MediaStore.Audio.Media.ARTIST, MediaStore.Audio.Media.ARTIST,
MediaStore.Audio.Media.ALBUM, MediaStore.Audio.Media.ALBUM,
MediaStore.Audio.Media.DURATION, MediaStore.Audio.Media.DURATION,
MediaStore.Audio.Media.MIME_TYPE, MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
) + if (Build.VERSION.SDK_INT >= 29) {
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH) arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
} else { } else {
emptyArray() emptyArray()
@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService(
artists = listOfNotNull(artist), artists = listOfNotNull(artist),
durationMs = durationMs, durationMs = durationMs,
album = album, album = album,
coverBytes = null, coverBytes = null)
)
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
acceptedRows += 1 acceptedRows += 1
@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService(
LocalMediaFolder( LocalMediaFolder(
path = folderPath, path = folderPath,
name = normalizeFolderName(folderPath), name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
)
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService(
override fun observeChanges( override fun observeChanges(
roots: List<String>, roots: List<String>,
onChanged: LocalMediaChangeCallback, onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
): LocalMediaObservation? {
logger.i { "observeChanges: start roots=${roots.size}" } logger.i { "observeChanges: start roots=${roots.size}" }
if (!hasReadPermission()) { if (!hasReadPermission()) {
logger.w { "observeChanges: missing media permission, observer not registered" } logger.w { "observeChanges: missing media permission, observer not registered" }
@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService(
context.contentResolver.registerContentObserver( context.contentResolver.registerContentObserver(
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI, MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
true, true,
observer, observer)
)
logger.i { "observeChanges: observer registered" } logger.i { "observeChanges: observer registered" }
@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService(
artists = emptyList(), artists = emptyList(),
durationMs = 0L, durationMs = 0L,
album = null, album = null,
coverBytes = null, coverBytes = null)
)
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track) tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
} }
@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService(
LocalMediaFolder( LocalMediaFolder(
path = folderPath, path = folderPath,
name = normalizeFolderName(folderPath), name = normalizeFolderName(folderPath),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
)
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService(
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS), Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC), context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS), context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
)
return (configured + defaults) return (configured + defaults)
.map { it.absoluteFile } .map { it.absoluteFile }
@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService(
private fun mergeFolders( private fun mergeFolders(
mediaStoreFolders: List<LocalMediaFolder>, mediaStoreFolders: List<LocalMediaFolder>,
fallbackFolders: List<LocalMediaFolder>, fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
): List<LocalMediaFolder> {
if (fallbackFolders.isEmpty()) return mediaStoreFolders if (fallbackFolders.isEmpty()) return mediaStoreFolders
if (mediaStoreFolders.isEmpty()) return fallbackFolders if (mediaStoreFolders.isEmpty()) return fallbackFolders
@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService(
LocalMediaFolder( LocalMediaFolder(
path = path, path = path,
name = normalizeFolderName(path), name = normalizeFolderName(path),
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) }, tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
)
} }
.sortedBy { it.name.lowercase(Locale.getDefault()) } .sortedBy { it.name.lowercase(Locale.getDefault()) }
} }
@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService(
"wav", "wav",
"ogg", "ogg",
"opus", "opus",
"wma", "wma")
)
} }
} }

View File

@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
var isGranted by remember { var isGranted by remember {
mutableStateOf( mutableStateOf(
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED, context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
)
} }
val launcher = rememberLauncherForActivityResult( val launcher = rememberLauncherForActivityResult(
contract = ActivityResultContracts.RequestPermission(), contract = ActivityResultContracts.RequestPermission()) { granted ->
) { granted ->
isGranted = granted || isGranted = granted ||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
} }
return LocalMediaPermissionState( return LocalMediaPermissionState(
isGranted = isGranted, isGranted = isGranted,
requestPermission = { launcher.launch(permission) }, requestPermission = { launcher.launch(permission) })
)
} }

View File

@ -76,6 +76,16 @@
<string name="settings_appearance_accent_label">Accent</string> <string name="settings_appearance_accent_label">Accent</string>
<string name="settings_preview_secondary">Secondary</string> <string name="settings_preview_secondary">Secondary</string>
<string name="settings_palette_title">Palette</string>
<string name="settings_palette_subtitle_current">Millennium accent palette. Current: %1$s</string>
<string name="settings_palette_dialog_title">Palette</string>
<string name="settings_palette_dialog_description">Choose a Millennium accent palette and preview it in light and dark.</string>
<string name="settings_palette_label">Palette</string>
<string name="settings_appearance_subtitle_palette">Millennium palette and accent. Current: %1$s</string>
<string name="settings_palette_blue">Blue</string>
<string name="settings_palette_orange">Orange</string>
<string name="settings_palette_lime">Lime</string>
<string name="settings_download_folder_title">Download folder override</string> <string name="settings_download_folder_title">Download folder override</string>
<string name="settings_download_folder_current">Current folder: %1$s</string> <string name="settings_download_folder_current">Current folder: %1$s</string>
<string name="settings_download_folder_default">Current folder: default music folder</string> <string name="settings_download_folder_default">Current folder: default music folder</string>

View File

@ -22,13 +22,11 @@ import androidx.compose.animation.SharedTransitionLayout
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
@ -37,9 +35,6 @@ import dev.krtirtho.spotube.core.navigation.Routes
import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES import dev.krtirtho.spotube.core.navigation.TOP_LEVEL_ROUTES
import dev.krtirtho.spotube.core.navigation.rememberNavigationState import dev.krtirtho.spotube.core.navigation.rememberNavigationState
import dev.krtirtho.spotube.core.navigation.toEntries import dev.krtirtho.spotube.core.navigation.toEntries
import dev.krtirtho.spotube.core.ui.base.LocalBaseUIColors
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.rememberBaseUITheme
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme import dev.krtirtho.spotube.core.ui.theming.SpotubeTheme
import dev.krtirtho.spotube.modules.library.LibraryTab import dev.krtirtho.spotube.modules.library.LibraryTab
@ -81,8 +76,7 @@ interface NavigationItem {
private val tabs = listOf<NavigationItem>( private val tabs = listOf<NavigationItem>(
NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home), NavigationItem.Tab<Any>("Home", Iconsax.IconsaxHome, Routes.Home),
NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search), NavigationItem.Tab<Any>("Search", Iconsax.IconsaxSearch, Routes.Search))
)
val sidebarTabs = tabs + listOf( val sidebarTabs = tabs + listOf(
NavigationItem.Group( NavigationItem.Group(
@ -95,8 +89,7 @@ val sidebarTabs = tabs + listOf(
LibraryTab.Playlists LibraryTab.Playlists
), ),
NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums), NavigationItem.Tab("Albums", Iconsax.IconsaxCd, Routes.Library, LibraryTab.Albums),
NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists), NavigationItem.Tab("Artists", Iconsax.User, Routes.Library, LibraryTab.Artists))
)
), ),
NavigationItem.Group( NavigationItem.Group(
"On Device", "On Device",
@ -112,17 +105,13 @@ val sidebarTabs = tabs + listOf(
Iconsax.IconsaxDirectboxReceive, Iconsax.IconsaxDirectboxReceive,
Routes.Library, Routes.Library,
LibraryTab.Downloads LibraryTab.Downloads
), ))
)
), ),
NavigationItem.Group( NavigationItem.Group(
"Connect", "Connect",
items = listOf( items = listOf(
NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices), NavigationItem.Tab("Devices", Iconsax.IconsaxMirrorScreenRegular, Routes.Devices),
NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam), NavigationItem.Tab("Group Jam", Iconsax.IconsaxSoundTwotone, Routes.Jam))))
),
),
)
val bottombarTabs = tabs + listOf( val bottombarTabs = tabs + listOf(
NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library), NavigationItem.Tab<Any>("Library", Iconsax.IconsaxMusicLibrary, Routes.Library),
NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings) NavigationItem.Tab("Settings", Iconsax.IconsaxSetting2, Routes.Settings)
@ -159,69 +148,51 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f Box(modifier = Modifier.fillMaxSize()) {
val accent = if (isDark) { val currentRoute =
userSettings.accentColor.toDarkColor() navigationState.backStacks[navigationState.topLevelRoute]?.last()
} else {
userSettings.accentColor.toLightColor()
}
val baseUITheme = rememberBaseUITheme(
surfaceTheme = userSettings.surfaceTheme,
accentColor = accent,
)
CompositionLocalProvider(
LocalBaseUITheme provides baseUITheme,
LocalBaseUIColors provides baseUITheme.colors,
) {
Box(modifier = Modifier.fillMaxSize()) {
val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last()
Box(Modifier.fillMaxSize()) { Box(Modifier.fillMaxSize()) {
AppShell(navigator, navigationState) { AppShell(navigator, navigationState) {
SharedTransitionLayout { SharedTransitionLayout {
CompositionLocalProvider( CompositionLocalProvider(
LocalSharedTransitionScope provides this@SharedTransitionLayout, LocalSharedTransitionScope provides this@SharedTransitionLayout) {
) { Column {
Column { NavDisplay(
NavDisplay( modifier = Modifier.fillMaxSize(),
modifier = Modifier.fillMaxSize(), onBack = navigator::pop,
onBack = navigator::pop, entries = navigationState.toEntries(koinEntryProvider())
entries = navigationState.toEntries(koinEntryProvider()) )
)
}
} }
} }
} }
when (currentRoute) { }
Routes.WebView -> WebViewScreen(koinInject()) when (currentRoute) {
else -> {} Routes.WebView -> WebViewScreen(koinInject())
} else -> {}
} }
} }
content() }
availableAppUpdate?.let { update -> content()
AppUpdateDialog( availableAppUpdate?.let { update ->
update = update, AppUpdateDialog(
onDismiss = appUpdateService::dismissUpdate, update = update,
onIgnore = appUpdateService::ignoreUpdate, onDismiss = appUpdateService::dismissUpdate,
onUpdate = { onIgnore = appUpdateService::ignoreUpdate,
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } onUpdate = {
appUpdateService.dismissUpdate() runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
}, appUpdateService.dismissUpdate()
) })
} }
availablePluginUpdate?.let { update -> availablePluginUpdate?.let { update ->
PluginUpdateDialog( PluginUpdateDialog(
update = update, update = update,
onDismiss = pluginUpdateService::dismissUpdate, onDismiss = pluginUpdateService::dismissUpdate,
onIgnore = pluginUpdateService::ignoreUpdate, onIgnore = pluginUpdateService::ignoreUpdate,
onUpdate = { onUpdate = {
runCatching { openUrlInBrowser(update.info.directDownloadUrl) } runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
pluginUpdateService.dismissUpdate() pluginUpdateService.dismissUpdate()
}, })
)
}
} }
} }
} }

View File

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

View File

@ -30,8 +30,7 @@ data class MediaItem(
val duration: Duration, val duration: Duration,
val coverURL: String, val coverURL: String,
val url: String, val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
)
enum class LoopState { enum class LoopState {
NONE, ONE, ALL; NONE, ONE, ALL;
@ -60,8 +59,7 @@ sealed interface QueueEntry {
val track: MetadataTrack, val track: MetadataTrack,
override val url: String, override val url: String,
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE, val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
override val addedBy: String = "", override val addedBy: String = "") : QueueEntry
) : QueueEntry
@Serializable @Serializable
@SerialName("local") @SerialName("local")
@ -72,8 +70,7 @@ sealed interface QueueEntry {
val album: String?, val album: String?,
val coverBytes: ByteArray?, val coverBytes: ByteArray?,
override val url: String, override val url: String,
override val addedBy: String = "", override val addedBy: String = "") : QueueEntry {
) : QueueEntry {
override fun equals(other: Any?): Boolean { override fun equals(other: Any?): Boolean {
if (this === other) return true if (this === other) return true
if (other == null || this::class != other::class) return false if (other == null || this::class != other::class) return false
@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry {
@Serializable @Serializable
@SerialName("playlist") @SerialName("playlist")
data class Playlist( data class Playlist(
override val id: String, override val id: String) : QueueCollectionEntry
) : QueueCollectionEntry
@Serializable @Serializable
@SerialName("album") @SerialName("album")
data class Album( data class Album(
override val id: String, override val id: String) : QueueCollectionEntry
) : QueueCollectionEntry
@Serializable @Serializable
@SerialName("saved_tracks") @SerialName("saved_tracks")
@ -132,5 +127,4 @@ data class PersistedQueueState(
val entries: List<QueueEntry>, val entries: List<QueueEntry>,
val currentIndex: Int, val currentIndex: Int,
val currentCollectionEntry: QueueCollectionEntry? = null, val currentCollectionEntry: QueueCollectionEntry? = null,
val collectionHistory: List<QueueCollectionEntry> = emptyList(), val collectionHistory: List<QueueCollectionEntry> = emptyList())
)

View File

@ -29,8 +29,7 @@ interface AudioPlayerQueue {
entries: List<QueueEntry>, entries: List<QueueEntry>,
autoPlay: Boolean = true, autoPlay: Boolean = true,
startPosition: Int = 0, startPosition: Int = 0,
collectionEntry: QueueCollectionEntry? = null, collectionEntry: QueueCollectionEntry? = null)
)
suspend fun addToQueue(entry: QueueEntry) suspend fun addToQueue(entry: QueueEntry)
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null) suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
suspend fun addAllAfterCurrent(entries: List<QueueEntry>) suspend fun addAllAfterCurrent(entries: List<QueueEntry>)

View File

@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue(
private val settingsProvider: SettingsProvider, private val settingsProvider: SettingsProvider,
private val repository: QueueStateRepository, private val repository: QueueStateRepository,
private val pluginProvider: PluginProvider, private val pluginProvider: PluginProvider,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
) : AudioPlayerQueue, KoinComponent {
private val logger by injectLogger<DeviceAudioPlayerQueue>() private val logger by injectLogger<DeviceAudioPlayerQueue>()
@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue(
override val queueFlow: StateFlow<List<QueueEntry>> = combine( override val queueFlow: StateFlow<List<QueueEntry>> = combine(
audioPlayer.playlistFlow, audioPlayer.playlistFlow,
entryByMediaUrl, entryByMediaUrl) { playlist, knownEntries ->
) { playlist, knownEntries ->
playlist.map { mediaItem -> playlist.map { mediaItem ->
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry() knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
} }
@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue(
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine( override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
entryByMediaUrl, entryByMediaUrl) { mediaItem, knownEntries ->
) { mediaItem, knownEntries ->
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() } mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null) }.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue(
combine( combine(
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
audioPlayer.playlistFlow, audioPlayer.playlistFlow,
audioPlayer.loopStateFlow, audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
) { currentMedia, playlist, loopState ->
Triple(currentMedia, playlist, loopState) Triple(currentMedia, playlist, loopState)
}.collect { (currentMedia, playlist, loopState) -> }.collect { (currentMedia, playlist, loopState) ->
if (currentMedia == null || playlist.isEmpty()) return@collect if (currentMedia == null || playlist.isEmpty()) return@collect
@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue(
entries: List<QueueEntry>, entries: List<QueueEntry>,
autoPlay: Boolean, autoPlay: Boolean,
startPosition: Int, startPosition: Int,
collectionEntry: QueueCollectionEntry?, collectionEntry: QueueCollectionEntry?) {
) {
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" } logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
val mediaItems = entries.mapNotNull { it.toMediaItem() } val mediaItems = entries.mapNotNull { it.toMediaItem() }
if (mediaItems.isEmpty()) { if (mediaItems.isEmpty()) {
@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue(
entries = queueEntries, entries = queueEntries,
currentIndex = normalizedCurrentIndex, currentIndex = normalizedCurrentIndex,
currentCollectionEntry = currentCollectionEntryState.value, currentCollectionEntry = currentCollectionEntryState.value,
collectionHistory = collectionHistoryState.value, collectionHistory = collectionHistoryState.value)
)
) )
} }
@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue(
duration = track.durationMs.milliseconds, duration = track.durationMs.milliseconds,
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(), coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
url = resolvedUrl, url = resolvedUrl,
protocol = protocol, protocol = protocol)
)
} }
is QueueEntry.LocalTrack -> { is QueueEntry.LocalTrack -> {
@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue(
album = album.orEmpty(), album = album.orEmpty(),
duration = duration.milliseconds, duration = duration.milliseconds,
coverURL = "", coverURL = "",
url = url, url = url)
)
} }
} }
} }
@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue(
duration = duration.inWholeMilliseconds, duration = duration.inWholeMilliseconds,
album = album.ifBlank { null }, album = album.ifBlank { null },
coverBytes = null, coverBytes = null,
url = url, url = url)
)
} }
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String { private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {

View File

@ -86,8 +86,7 @@ class Database(val paths: Paths) {
"spotube_audio_player_queue.preferences_pb", "spotube_audio_player_queue.preferences_pb",
"spotube_local_media.preferences_pb", "spotube_local_media.preferences_pb",
"spotube_blacklist.preferences_pb", "spotube_blacklist.preferences_pb",
"spotube_cookies.preferences_pb", "spotube_cookies.preferences_pb")
)
} }
} }

View File

@ -168,8 +168,7 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(), remotePlaybackController = get())
)
} }
// Saved Tracks // Saved Tracks
@ -183,8 +182,7 @@ val sharedModules = module {
libraryRepository = get(), libraryRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(), remotePlaybackController = get())
)
} }
// Artist // Artist
@ -199,8 +197,7 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(), remotePlaybackController = get())
)
} }
// Blacklist // Blacklist
@ -212,8 +209,7 @@ val sharedModules = module {
JamViewModel( JamViewModel(
jamRoomService = get(), jamRoomService = get(),
shareService = get(), shareService = get(),
settingsProvider = get(), settingsProvider = get())
)
} }
// Album // Album
@ -229,8 +225,7 @@ val sharedModules = module {
blacklistRepository = get(), blacklistRepository = get(),
shareService = get(), shareService = get(),
downloadManager = get(), downloadManager = get(),
remotePlaybackController = get(), remotePlaybackController = get())
)
} }
// Lyrics // Lyrics
@ -244,8 +239,7 @@ val sharedModules = module {
savedTracksRepository = get(), savedTracksRepository = get(),
artistRepository = get(), artistRepository = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
blacklistRepository = get(), blacklistRepository = get())
)
} }
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() } singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
single { single {
@ -253,8 +247,7 @@ val sharedModules = module {
pluginManager = get(), pluginManager = get(),
audioPlayerQueue = get(), audioPlayerQueue = get(),
matchedTracksRepository = get(), matchedTracksRepository = get(),
settingsRepository = get(), settingsRepository = get())
)
} }
singleOf(::CacheManager) singleOf(::CacheManager)
singleOf(::AlternativeTracksRepository) singleOf(::AlternativeTracksRepository)

View File

@ -34,8 +34,7 @@ import kotlin.time.Clock
class DiscordRpcService( class DiscordRpcService(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val settingsProvider: SettingsProvider, private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
) : KoinComponent, AutoCloseable {
private val logger by injectLogger<DiscordRpcService>() private val logger by injectLogger<DiscordRpcService>()
@ -61,8 +60,7 @@ class DiscordRpcService(
audioPlayer.playerStateFlow, audioPlayer.playerStateFlow,
audioPlayer.currentMediaItemFlow, audioPlayer.currentMediaItemFlow,
audioPlayer.positionFlow, audioPlayer.positionFlow,
audioPlayer.durationFlow, audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
) { settings, playerState, mediaItem, position, duration ->
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration) DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
}.collect { state -> }.collect { state ->
handleStateChange(state) handleStateChange(state)
@ -86,8 +84,7 @@ class DiscordRpcService(
album = mediaItem.album, album = mediaItem.album,
coverUrl = mediaItem.coverURL, coverUrl = mediaItem.coverURL,
positionMs = state.position.inWholeMilliseconds, positionMs = state.position.inWholeMilliseconds,
durationMs = state.duration.inWholeMilliseconds, durationMs = state.duration.inWholeMilliseconds)
)
} }
PlayerState.PAUSED -> { PlayerState.PAUSED -> {
val mediaItem = state.mediaItem ?: return val mediaItem = state.mediaItem ?: return
@ -98,8 +95,7 @@ class DiscordRpcService(
album = mediaItem.album, album = mediaItem.album,
coverUrl = mediaItem.coverURL, coverUrl = mediaItem.coverURL,
positionMs = state.position.inWholeMilliseconds, positionMs = state.position.inWholeMilliseconds,
durationMs = state.duration.inWholeMilliseconds, durationMs = state.duration.inWholeMilliseconds)
)
} }
PlayerState.IDLE, PlayerState.COMPLETED -> { PlayerState.IDLE, PlayerState.COMPLETED -> {
clearPresence() clearPresence()
@ -158,8 +154,7 @@ class DiscordRpcService(
album: String, album: String,
coverUrl: String, coverUrl: String,
positionMs: Long, positionMs: Long,
durationMs: Long, durationMs: Long) {
) {
val client = rpcClient ?: return val client = rpcClient ?: return
try { try {
client.updatePresence( client.updatePresence(
@ -168,8 +163,7 @@ class DiscordRpcService(
album = album, album = album,
coverUrl = coverUrl, coverUrl = coverUrl,
positionMs = positionMs, positionMs = positionMs,
durationMs = durationMs, durationMs = durationMs)
)
} catch (e: Exception) { } catch (e: Exception) {
rpcClient = null rpcClient = null
connectionFailed = true connectionFailed = true
@ -210,6 +204,5 @@ class DiscordRpcService(
val playerState: PlayerState, val playerState: PlayerState,
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?, val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
val position: kotlin.time.Duration, val position: kotlin.time.Duration,
val duration: kotlin.time.Duration, val duration: kotlin.time.Duration)
)
} }

View File

@ -30,8 +30,7 @@ data class DiscoveredDevice(
val type: String, val type: String,
val host: String, val host: String,
val port: Int, val port: Int,
val deviceId: String, val deviceId: String) {
) {
val key: String get() = "$name$type".replace(".", "") val key: String get() = "$name$type".replace(".", "")
} }
@ -55,8 +54,7 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
)
DiscoveryState.Discovered(device = device, resolve = event.resolve) DiscoveryState.Discovered(device = device, resolve = event.resolve)
} }
@ -66,8 +64,7 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(), deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
)
DiscoveryState.Resolved(device = device) DiscoveryState.Resolved(device = device)
} }
@ -77,8 +74,7 @@ class DeviceDiscoveryService {
type = event.service.type, type = event.service.type,
host = event.service.host, host = event.service.host,
port = event.service.port, port = event.service.port,
deviceId = "", deviceId = "")
)
DiscoveryState.Removed(device = device) DiscoveryState.Removed(device = device)
} }
} }
@ -88,8 +84,7 @@ class DeviceDiscoveryService {
name: String, name: String,
port: Int, port: Int,
deviceId: String, deviceId: String,
registerTimeoutMs: Long = 5_000, registerTimeoutMs: Long = 5_000): NetService {
): NetService {
val service = createService(name, port, deviceId) val service = createService(name, port, deviceId)
service.register(timeoutInMs = registerTimeoutMs) service.register(timeoutInMs = registerTimeoutMs)
return service return service
@ -98,11 +93,9 @@ class DeviceDiscoveryService {
fun createService( fun createService(
name: String, name: String,
port: Int, port: Int,
deviceId: String, deviceId: String): NetService = createNetService(
): NetService = createNetService(
type = SERVICE_TYPE, type = SERVICE_TYPE,
name = name, name = name,
port = port, port = port,
txt = mapOf(TXT_DEVICE_ID to deviceId), txt = mapOf(TXT_DEVICE_ID to deviceId))
)
} }

View File

@ -46,8 +46,7 @@ sealed class JamMessage {
*/ */
val follow: Boolean = false, val follow: Boolean = false,
/** Host's live play state — late-joining guests start with it. */ /** Host's live play state — late-joining guests start with it. */
val isPlaying: Boolean = false, val isPlaying: Boolean = false) : JamMessage()
) : JamMessage()
@Serializable @Serializable
@SerialName("playbackCommand") @SerialName("playbackCommand")
@ -57,22 +56,19 @@ sealed class JamMessage {
@SerialName("suggestTrack") @SerialName("suggestTrack")
data class SuggestTrack( data class SuggestTrack(
val mediaItem: JamMediaItem, val mediaItem: JamMediaItem,
val addedBy: String = "", val addedBy: String = "") : JamMessage()
) : JamMessage()
@Serializable @Serializable
@SerialName("suggestPlaylist") @SerialName("suggestPlaylist")
data class SuggestPlaylist( data class SuggestPlaylist(
val tracks: List<JamMediaItem>, val tracks: List<JamMediaItem>,
val addedBy: String = "", val addedBy: String = "") : JamMessage()
) : JamMessage()
@Serializable @Serializable
@SerialName("kick") @SerialName("kick")
data class Kick( data class Kick(
val participantId: String, val participantId: String,
val reason: String = "kicked", val reason: String = "kicked") : JamMessage()
) : JamMessage()
@Serializable @Serializable
@SerialName("leave") @SerialName("leave")
@ -104,8 +100,7 @@ data class JamPresence(
val clientId: String, val clientId: String,
val displayName: String, val displayName: String,
val isHost: Boolean, val isHost: Boolean,
val left: Boolean = false, val left: Boolean = false)
)
@Serializable @Serializable
data class JamMediaItem( data class JamMediaItem(
@ -117,8 +112,7 @@ data class JamMediaItem(
val durationMs: Long, val durationMs: Long,
val coverUrl: String, val coverUrl: String,
val protocol: String, val protocol: String,
val addedBy: String = "", val addedBy: String = "") {
) {
companion object { companion object {
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) { fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
is QueueEntry.StreamingTrack -> JamMediaItem( is QueueEntry.StreamingTrack -> JamMediaItem(
@ -132,8 +126,7 @@ data class JamMediaItem(
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(), .orEmpty(),
protocol = entry.protocol.name, protocol = entry.protocol.name,
addedBy = entry.addedBy, addedBy = entry.addedBy)
)
is QueueEntry.LocalTrack -> JamMediaItem( is QueueEntry.LocalTrack -> JamMediaItem(
url = entry.url, url = entry.url,
@ -144,8 +137,7 @@ data class JamMediaItem(
durationMs = entry.duration, durationMs = entry.duration,
coverUrl = "", coverUrl = "",
protocol = "PROGRESSIVE", protocol = "PROGRESSIVE",
addedBy = entry.addedBy, addedBy = entry.addedBy)
)
} }
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem( fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
@ -158,8 +150,7 @@ data class JamMediaItem(
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url ?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
.orEmpty(), .orEmpty(),
protocol = "PROGRESSIVE", protocol = "PROGRESSIVE")
)
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem( fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
url = item.url, url = item.url,
@ -168,8 +159,7 @@ data class JamMediaItem(
album = item.album, album = item.album,
durationMs = item.duration.inWholeMilliseconds, durationMs = item.duration.inWholeMilliseconds,
coverUrl = item.coverURL, coverUrl = item.coverURL,
protocol = item.protocol.name, protocol = item.protocol.name)
)
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem( fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
title = item.title, title = item.title,
@ -179,8 +169,7 @@ data class JamMediaItem(
coverURL = item.coverUrl, coverURL = item.coverUrl,
url = item.url, url = item.url,
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }), .valueOf(item.protocol.ifBlank { "PROGRESSIVE" }))
)
} }
} }
@ -188,8 +177,7 @@ data class JamMediaItem(
data class JamParticipant( data class JamParticipant(
val id: String, val id: String,
val displayName: String, val displayName: String,
val isHost: Boolean, val isHost: Boolean)
)
@Serializable @Serializable
enum class JamRole { enum class JamRole {

View File

@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName
import com.ditchoom.mqtt.controlpacket.WillConfig import com.ditchoom.mqtt.controlpacket.WillConfig
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
import dev.krtirtho.spotube.modules.settings.JamBroker import dev.krtirtho.spotube.modules.settings.JamBroker
import kotlin.time.Duration.Companion.seconds
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -47,6 +45,7 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.withTimeout import kotlinx.coroutines.withTimeout
import kotlinx.serialization.json.Json import kotlinx.serialization.json.Json
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import kotlin.time.Duration.Companion.seconds
/** /**
* Thin facade over the Ditchoom MQTT 5 client for one jam room. * Thin facade over the Ditchoom MQTT 5 client for one jam room.
@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent {
code: String, code: String,
clientId: String, clientId: String,
displayName: String, displayName: String,
isHost: Boolean, isHost: Boolean): Result<Unit> {
): Result<Unit> {
disconnect() disconnect()
if (broker.host.isBlank()) { if (broker.host.isBlank()) {
return Result.failure(IllegalArgumentException("No jam broker configured")) return Result.failure(IllegalArgumentException("No jam broker configured"))
@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent {
clientId = clientId, clientId = clientId,
displayName = displayName, displayName = displayName,
isHost = isHost, isHost = isHost,
left = false, left = false)
)
val mqtt = startClient(broker, clientId) val mqtt = startClient(broker, clientId)
client = mqtt client = mqtt
@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent {
publishJson( publishJson(
presenceTopic(code, presence.clientId), presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence), json.encodeToString(JamPresence.serializer(), presence),
retain = true, retain = true)
)
} }
/** Marks us as the host in presence (host takeover). */ /** Marks us as the host in presence (host takeover). */
@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent {
publishJson( publishJson(
presenceTopic(code, presence.clientId), presenceTopic(code, presence.clientId),
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)), json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
retain = true, retain = true)
)
} }
} }
@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent {
host = broker.host, host = broker.host,
port = broker.port, port = broker.port,
tlsEnabled = broker.useTls, tlsEnabled = broker.useTls,
connectionTimeout = broker.connectionTimeoutSeconds.seconds, connectionTimeout = broker.connectionTimeoutSeconds.seconds)
)
val code = roomCode ?: "unset" val code = roomCode ?: "unset"
val will = WillConfig.Enabled( val will = WillConfig.Enabled(
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)), topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
payload = json.encodeToString( payload = json.encodeToString(
JamPresence.serializer(), JamPresence.serializer(),
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true), JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
).toReadBuffer(Charset.UTF8),
qos = QualityOfService.AT_LEAST_ONCE, qos = QualityOfService.AT_LEAST_ONCE,
retain = true, retain = true)
)
val request = ConnectionRequest( val request = ConnectionRequest(
clientId = clientId, clientId = clientId,
keepAliveSeconds = broker.keepAliveSeconds, keepAliveSeconds = broker.keepAliveSeconds,
cleanStart = true, cleanStart = true,
userName = broker.username, userName = broker.username,
password = broker.password, password = broker.password,
will = will, will = will)
)
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true) val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
val brokerRef = persistence.addBroker(connection, request) val brokerRef = persistence.addBroker(connection, request)
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence) return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent {
val operation = mqtt.subscribe( val operation = mqtt.subscribe(
roomFilter(code), roomFilter(code),
OpaquePublishPayloadCodec, OpaquePublishPayloadCodec,
QualityOfService.AT_LEAST_ONCE, QualityOfService.AT_LEAST_ONCE) { publish, payload ->
) { publish, payload ->
route(publish.topic.toString(), payload) route(publish.topic.toString(), payload)
} }
runCatching { operation.subAck.await() } runCatching { operation.subAck.await() }
@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent {
topicName = topic, topicName = topic,
qos = QualityOfService.AT_LEAST_ONCE, qos = QualityOfService.AT_LEAST_ONCE,
payload = payload.toReadBuffer(Charset.UTF8), payload = payload.toReadBuffer(Charset.UTF8),
retain = retain, retain = retain)
)
}.onFailure { e -> }.onFailure { e ->
log.w(e) { "Failed to publish to $topic" } log.w(e) { "Failed to publish to $topic" }
} }

View File

@ -59,15 +59,13 @@ private data class PlaybackBroadcast(
val queue: List<QueueEntry>, val queue: List<QueueEntry>,
val current: QueueEntry?, val current: QueueEntry?,
val shuffle: Boolean, val shuffle: Boolean,
val playerState: PlayerState, val playerState: PlayerState)
)
class JamRoomService( class JamRoomService(
private val jamClient: JamRoomClient, private val jamClient: JamRoomClient,
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository) {
) {
private val log = Logger.withTag("JamRoomService") private val log = Logger.withTag("JamRoomService")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -171,8 +169,7 @@ class JamRoomService(
code = code, code = code,
clientId = localClientId, clientId = localClientId,
displayName = name, displayName = name,
isHost = true, isHost = true).map {
).map {
_role.value = JamRole.Host _role.value = JamRole.Host
_roomCode.value = code _roomCode.value = code
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true)) _participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
@ -206,8 +203,7 @@ class JamRoomService(
code = normalized, code = normalized,
clientId = localClientId, clientId = localClientId,
displayName = name, displayName = name,
isHost = false, isHost = false).map {
).map {
_role.value = JamRole.Guest _role.value = JamRole.Guest
_roomCode.value = normalized _roomCode.value = normalized
_participants.value = emptyList() _participants.value = emptyList()
@ -261,8 +257,7 @@ class JamRoomService(
jamClient.publishCommand( jamClient.publishCommand(
JamMessage.SuggestTrack( JamMessage.SuggestTrack(
mediaItem = JamMediaItem.fromTrack(track), mediaItem = JamMediaItem.fromTrack(track),
addedBy = localClientId, addedBy = localClientId)
)
) )
} }
@ -271,8 +266,7 @@ class JamRoomService(
jamClient.publishCommand( jamClient.publishCommand(
JamMessage.SuggestPlaylist( JamMessage.SuggestPlaylist(
tracks = tracks.map(JamMediaItem::fromTrack), tracks = tracks.map(JamMediaItem::fromTrack),
addedBy = localClientId, addedBy = localClientId)
)
) )
} }
@ -296,8 +290,7 @@ class JamRoomService(
audioPlayerQueue.queueFlow, audioPlayerQueue.queueFlow,
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.shuffleModeFlow, audioPlayer.shuffleModeFlow,
audioPlayer.playerStateFlow, audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
) { queue, current, shuffle, playerState ->
PlaybackBroadcast(queue, current, shuffle, playerState) PlaybackBroadcast(queue, current, shuffle, playerState)
} }
.onEach { broadcast -> .onEach { broadcast ->
@ -314,8 +307,7 @@ class JamRoomService(
currentIndex = index.coerceAtLeast(0), currentIndex = index.coerceAtLeast(0),
shuffleEnabled = broadcast.shuffle, shuffleEnabled = broadcast.shuffle,
follow = now() > autoAdvanceDeadlineMs, follow = now() > autoAdvanceDeadlineMs,
isPlaying = broadcast.playerState == PlayerState.PLAYING, isPlaying = broadcast.playerState == PlayerState.PLAYING)
)
) )
} }
.launchIn(this) .launchIn(this)
@ -374,8 +366,7 @@ class JamRoomService(
audioPlayerQueue.load( audioPlayerQueue.load(
entries = items.map { it.toQueueEntry() }, entries = items.map { it.toQueueEntry() },
autoPlay = wasPlaying, autoPlay = wasPlaying,
startPosition = startIndex, startPosition = startIndex)
)
}.onFailure { log.w(it) { "Failed to apply jam queue" } } }.onFailure { log.w(it) { "Failed to apply jam queue" } }
return return
} }
@ -524,13 +515,11 @@ class JamRoomService(
explicit = null, explicit = null,
popularity = null, popularity = null,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null),
),
url = "", url = "",
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) } protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
.getOrDefault(StreamProtocol.PROGRESSIVE), .getOrDefault(StreamProtocol.PROGRESSIVE),
addedBy = addedBy, addedBy = addedBy)
)
else -> QueueEntry.LocalTrack( else -> QueueEntry.LocalTrack(
name = title, name = title,
@ -539,8 +528,7 @@ class JamRoomService(
album = album.ifBlank { null }, album = album.ifBlank { null },
coverBytes = null, coverBytes = null,
url = url, url = url,
addedBy = addedBy, addedBy = addedBy)
)
} }
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean { private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {

View File

@ -125,8 +125,7 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get(), navigationCommands = get())
)
} }
navigation<Routes.Artist> { navigation<Routes.Artist> {
ArtistScreen( ArtistScreen(
@ -136,8 +135,7 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get(), navigationCommands = get())
)
} }
navigation<Routes.Album> { navigation<Routes.Album> {
AlbumScreen( AlbumScreen(
@ -148,8 +146,7 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get(), navigationCommands = get())
)
} }
navigation<Routes.Lyrics> { navigation<Routes.Lyrics> {
LyricsScreen(viewModel = koinViewModel()) LyricsScreen(viewModel = koinViewModel())
@ -162,8 +159,7 @@ val navigationModule = module {
), ),
audioPlayerQueue = get(), audioPlayerQueue = get(),
audioPlayer = get(), audioPlayer = get(),
navigationCommands = get(), navigationCommands = get())
)
} }
navigation<Routes.Blacklist> { navigation<Routes.Blacklist> {
BlacklistScreen() BlacklistScreen()

View File

@ -46,8 +46,7 @@ val TOP_LEVEL_ROUTES = setOf(
Routes.Library, Routes.Library,
Routes.Settings, Routes.Settings,
// Bootstrap destination: needs its own back stack when the app starts on it. // Bootstrap destination: needs its own back stack when the app starts on it.
Routes.Welcome, Routes.Welcome)
)
val serializersConfig = SavedStateConfiguration { val serializersConfig = SavedStateConfiguration {
serializersModule = SerializersModule { serializersModule = SerializersModule {
@ -123,8 +122,7 @@ fun NavigationState.toEntries(
): SnapshotStateList<NavEntry<NavKey>> { ): SnapshotStateList<NavEntry<NavKey>> {
val decoratedEntries = backStacks.mapValues { (_, stack) -> val decoratedEntries = backStacks.mapValues { (_, stack) ->
val decorators = listOf( val decorators = listOf(
rememberSaveableStateHolderNavEntryDecorator<NavKey>(), rememberSaveableStateHolderNavEntryDecorator<NavKey>())
)
rememberDecoratedNavEntries( rememberDecoratedNavEntries(
backStack = stack, backStack = stack,
entryDecorators = decorators, entryDecorators = decorators,

View File

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

View File

@ -34,8 +34,7 @@ class CollectionPlaybackHelper(
private val savedTracksRepository: SavedTracksRepository, private val savedTracksRepository: SavedTracksRepository,
private val artistRepository: ArtistRepository, private val artistRepository: ArtistRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository) {
) {
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) { suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it } val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
@ -44,8 +43,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = QueueCollectionEntry.Album(albumId), collectionEntry = QueueCollectionEntry.Album(albumId))
)
} }
} }
@ -54,8 +52,7 @@ class CollectionPlaybackHelper(
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue( audioPlayerQueue.addAllToQueue(
entries, entries,
collectionEntry = QueueCollectionEntry.Album(albumId), collectionEntry = QueueCollectionEntry.Album(albumId))
)
} }
} }
@ -87,8 +84,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = startPosition, startPosition = startPosition,
collectionEntry = QueueCollectionEntry.Album(albumId), collectionEntry = QueueCollectionEntry.Album(albumId))
)
} }
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) { suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
@ -99,8 +95,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = QueueCollectionEntry.Playlist(playlistId), collectionEntry = QueueCollectionEntry.Playlist(playlistId))
)
} }
} }
@ -109,8 +104,7 @@ class CollectionPlaybackHelper(
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue( audioPlayerQueue.addAllToQueue(
entries, entries,
collectionEntry = QueueCollectionEntry.Playlist(playlistId), collectionEntry = QueueCollectionEntry.Playlist(playlistId))
)
} }
} }
@ -142,8 +136,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = startPosition, startPosition = startPosition,
collectionEntry = QueueCollectionEntry.Playlist(playlistId), collectionEntry = QueueCollectionEntry.Playlist(playlistId))
)
} }
suspend fun playSavedTracks(shuffle: Boolean = false) { suspend fun playSavedTracks(shuffle: Boolean = false) {
@ -154,8 +147,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = QueueCollectionEntry.SavedTracks, collectionEntry = QueueCollectionEntry.SavedTracks)
)
} }
} }
@ -166,8 +158,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null, collectionEntry = null)
)
} }
} }
@ -190,8 +181,7 @@ class CollectionPlaybackHelper(
if (entries.isNotEmpty()) { if (entries.isNotEmpty()) {
audioPlayerQueue.addAllToQueue( audioPlayerQueue.addAllToQueue(
entries, entries,
collectionEntry = QueueCollectionEntry.SavedTracks, collectionEntry = QueueCollectionEntry.SavedTracks)
)
} }
} }
@ -216,8 +206,7 @@ class CollectionPlaybackHelper(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = startPosition, startPosition = startPosition,
collectionEntry = QueueCollectionEntry.SavedTracks, collectionEntry = QueueCollectionEntry.SavedTracks)
)
} }
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> { private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
@ -313,8 +302,7 @@ class CollectionPlaybackHelper(
private fun isTrackBlacklisted( private fun isTrackBlacklisted(
track: MetadataTrack, track: MetadataTrack,
blacklistedTrackIds: Set<String>, blacklistedTrackIds: Set<String>,
blacklistedArtistIds: Set<String>, blacklistedArtistIds: Set<String>): Boolean {
): Boolean {
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds } return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
} }

View File

@ -17,17 +17,24 @@
package dev.krtirtho.spotube.core.remote package dev.krtirtho.spotube.core.remote
import androidx.compose.material3.Button import androidx.compose.foundation.layout.Arrangement
import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.layout.Row
import androidx.compose.material3.Text import androidx.compose.foundation.layout.fillMaxWidth
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet import androidx.compose.ui.unit.dp
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumDialog
import org.koin.compose.koinInject import org.koin.compose.koinInject
@Composable @Composable
@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() {
} }
pendingRequest?.let { request -> pendingRequest?.let { request ->
AdaptiveDialogBottomSheet( MillenniumDialog(
onDismiss = { visible = true,
onDismissRequest = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
pendingRequest = null pendingRequest = null
}, },
title = { title = "Remote Control Request") {
Text( Text(
text = "Remote Control Request", text = "\"${request.deviceName}\" wants to control playback on this device.",
style = MaterialTheme.typography.titleLarge, style = AppTheme.typography.bodyMedium,
) textAlign = TextAlign.Start)
}, Row(
content = { modifier = Modifier.fillMaxWidth(),
Text( horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
text = "\"${request.deviceName}\" wants to control playback on this device.", verticalAlignment = Alignment.CenterVertically) {
style = MaterialTheme.typography.bodyMedium, MillenniumButton(
textAlign = TextAlign.Start,
)
},
actions = {
Button(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
pendingRequest = null pendingRequest = null
}, },
) { style = MillenniumButtonStyle.Secondary) {
Text("Deny") Text("Deny")
} }
Button( MillenniumButton(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
pendingRequest = null pendingRequest = null
}, }) {
) {
Text("Allow") Text("Allow")
} }
Button( MillenniumButton(
onClick = { onClick = {
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways) handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
pendingRequest = null pendingRequest = null
}, },
) { style = MillenniumButtonStyle.Quiet) {
Text("Allow Always") Text("Allow Always")
} }
}, }
) }
} }
} }

View File

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

View File

@ -48,13 +48,11 @@ class RemoteControlHandler(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent {
) : KoinComponent {
val logger by injectLogger<RemoteControlHandler>() val logger by injectLogger<RemoteControlHandler>()
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>( val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
extraBufferCapacity = 16, extraBufferCapacity = 16)
)
private val json = Json { private val json = Json {
ignoreUnknownKeys = true ignoreUnknownKeys = true
@ -88,8 +86,7 @@ class RemoteControlHandler(
deviceId = deviceId ?: "unknown", deviceId = deviceId ?: "unknown",
deviceName = deviceName, deviceName = deviceName,
sessionId = session.call.request.headers["X-Request-Id"] sessionId = session.call.request.headers["X-Request-Id"]
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}", ?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}")
)
incomingConnectionRequests.emit(request) incomingConnectionRequests.emit(request)
val response = waitForRequestResolution(request.sessionId) val response = waitForRequestResolution(request.sessionId)
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) { if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
@ -225,8 +222,7 @@ class RemoteControlHandler(
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")), entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null, collectionEntry = null)
)
} }
is RemoteControlCommand.AddTrackToQueue -> { is RemoteControlCommand.AddTrackToQueue -> {
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = "")) audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
@ -300,8 +296,7 @@ class RemoteControlHandler(
private suspend fun handleCollectionSource( private suspend fun handleCollectionSource(
source: String, source: String,
action: RemoteCollectionAction, action: RemoteCollectionAction,
shuffle: Boolean = false, shuffle: Boolean = false) {
) {
logger.d { "Remote collection $action for source: $source" } logger.d { "Remote collection $action for source: $source" }
when { when {
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> { source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
@ -359,8 +354,7 @@ class RemoteControlHandler(
currentTrackTitle = current?.titleOrNull(), currentTrackTitle = current?.titleOrNull(),
currentTrackArtists = current?.artistsOrNull(), currentTrackArtists = current?.artistsOrNull(),
currentTrackAlbum = current?.albumOrNull(), currentTrackAlbum = current?.albumOrNull(),
currentTrackCoverUrl = current?.coverUrlOrNull(), currentTrackCoverUrl = current?.coverUrlOrNull())
)
val text = json.encodeToString(RemoteControlEvent.serializer(), state) val text = json.encodeToString(RemoteControlEvent.serializer(), state)
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} }
@ -375,8 +369,7 @@ class RemoteControlHandler(
} }
val event = RemoteControlEvent.QueueUpdated( val event = RemoteControlEvent.QueueUpdated(
entries = queue.map { it.toRemoteQueueEntry() }, entries = queue.map { it.toRemoteQueueEntry() },
currentIndex = currentIndex, currentIndex = currentIndex)
)
val text = json.encodeToString(RemoteControlEvent.serializer(), event) val text = json.encodeToString(RemoteControlEvent.serializer(), event)
session.send(Frame.Text(text)) session.send(Frame.Text(text))
} }
@ -403,8 +396,7 @@ class RemoteControlHandler(
artists = track.artists.joinToString(", ") { artist -> artist.name }, artists = track.artists.joinToString(", ") { artist -> artist.name },
album = track.album?.title, album = track.album?.title,
coverUrl = coverUrlOrNull(), coverUrl = coverUrlOrNull(),
durationMs = track.durationMs, durationMs = track.durationMs)
)
is QueueEntry.LocalTrack -> RemoteQueueEntry( is QueueEntry.LocalTrack -> RemoteQueueEntry(
mediaUrl = url, mediaUrl = url,
@ -413,8 +405,7 @@ class RemoteControlHandler(
artists = artists.joinToString(", "), artists = artists.joinToString(", "),
album = album, album = album,
coverUrl = null, coverUrl = null,
durationMs = duration, durationMs = duration)
)
} }
private fun QueueEntry.mediaKey(): String = when (this) { private fun QueueEntry.mediaKey(): String = when (this) {
@ -447,8 +438,7 @@ class RemoteControlHandler(
@Serializable @Serializable
data class CommandEnvelope( data class CommandEnvelope(
val commandId: String, val commandId: String,
val command: RemoteControlCommand, val command: RemoteControlCommand)
)
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/" private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/" private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
@ -464,8 +454,7 @@ enum class RemoteCollectionAction {
data class ConnectionRequest( data class ConnectionRequest(
val deviceId: String, val deviceId: String,
val deviceName: String, val deviceName: String,
val sessionId: String, val sessionId: String)
)
enum class ConnectionRequestResponse { enum class ConnectionRequestResponse {
Allow, Allow,

View File

@ -119,15 +119,13 @@ sealed class RemoteControlEvent {
val currentTrackTitle: String?, val currentTrackTitle: String?,
val currentTrackArtists: String?, val currentTrackArtists: String?,
val currentTrackAlbum: String?, val currentTrackAlbum: String?,
val currentTrackCoverUrl: String?, val currentTrackCoverUrl: String?) : RemoteControlEvent()
) : RemoteControlEvent()
@Serializable @Serializable
@SerialName("queueUpdated") @SerialName("queueUpdated")
data class QueueUpdated( data class QueueUpdated(
val entries: List<RemoteQueueEntry>, val entries: List<RemoteQueueEntry>,
val currentIndex: Int, val currentIndex: Int) : RemoteControlEvent()
) : RemoteControlEvent()
@Serializable @Serializable
@SerialName("ack") @SerialName("ack")
@ -146,5 +144,4 @@ data class RemoteQueueEntry(
val artists: String, val artists: String,
val album: String?, val album: String?,
val coverUrl: String?, val coverUrl: String?,
val durationMs: Long, val durationMs: Long)
)

View File

@ -53,8 +53,7 @@ import kotlin.random.Random
class RemoteControlService( class RemoteControlService(
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository,
private val discoveryService: DeviceDiscoveryService, private val discoveryService: DeviceDiscoveryService,
private val localServer: LocalServer, private val localServer: LocalServer) {
) {
private val log = Logger.withTag("RemoteControlService") private val log = Logger.withTag("RemoteControlService")
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob()) private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@ -78,8 +77,7 @@ class RemoteControlService(
scope.launch { scope.launch {
combine( combine(
settingsRepository.userSettings, settingsRepository.userSettings,
localServer.port, localServer.port) { settings, port -> settings to port }
) { settings, port -> settings to port }
.distinctUntilChanged() .distinctUntilChanged()
.collect { (settings, port) -> .collect { (settings, port) ->
if (settings.allowRemoteControl && port != null) { if (settings.allowRemoteControl && port != null) {
@ -139,8 +137,7 @@ class RemoteControlService(
val service = discoveryService.createService( val service = discoveryService.createService(
name = serviceName, name = serviceName,
port = port, port = port,
deviceId = deviceId, deviceId = deviceId)
)
pendingService = service pendingService = service
try { try {
service.register(timeoutInMs = REGISTER_TIMEOUT_MS) service.register(timeoutInMs = REGISTER_TIMEOUT_MS)

View File

@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest {
val type: RemoteCollectionType, val type: RemoteCollectionType,
val id: String, val id: String,
val startTrack: MetadataTrack? = null, val startTrack: MetadataTrack? = null,
val shuffle: Boolean = false, val shuffle: Boolean = false) : PlaybackDestinationRequest
) : PlaybackDestinationRequest
data class Track( data class Track(
override val title: String, override val title: String,
override val action: PlaybackDestinationAction, override val action: PlaybackDestinationAction,
val track: MetadataTrack, val track: MetadataTrack) : PlaybackDestinationRequest
) : PlaybackDestinationRequest
data class Tracks( data class Tracks(
override val title: String, override val title: String,
override val action: PlaybackDestinationAction, override val action: PlaybackDestinationAction,
val tracks: List<MetadataTrack>, val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
) : PlaybackDestinationRequest
} }
/** /**
@ -90,8 +87,7 @@ class RemotePlaybackController(
private val collectionPlaybackHelper: CollectionPlaybackHelper, private val collectionPlaybackHelper: CollectionPlaybackHelper,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val jamRoomService: JamRoomService, private val jamRoomService: JamRoomService) : KoinComponent {
) : KoinComponent {
private val logger = Logger.withTag("RemotePlaybackController") private val logger = Logger.withTag("RemotePlaybackController")
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default) private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
@ -113,8 +109,7 @@ class RemotePlaybackController(
id: String, id: String,
title: String, title: String,
startTrack: MetadataTrack? = null, startTrack: MetadataTrack? = null,
shuffle: Boolean = false, shuffle: Boolean = false) {
) {
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle)) request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
} }
@ -205,8 +200,7 @@ class RemotePlaybackController(
QueueEntry.StreamingTrack( QueueEntry.StreamingTrack(
track = track, track = track,
url = "", url = "",
addedBy = jamRoomService.participantClientId, addedBy = jamRoomService.participantClientId)
)
} }
) )
@ -291,8 +285,7 @@ class RemotePlaybackController(
entries = listOf(entry), entries = listOf(entry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null, collectionEntry = null)
)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {
@ -319,8 +312,7 @@ class RemotePlaybackController(
entries = entries, entries = entries,
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null, collectionEntry = null)
)
} }
PlaybackDestinationAction.AddToQueue -> { PlaybackDestinationAction.AddToQueue -> {

View File

@ -52,8 +52,7 @@ class ScrobbleManager(
private val audioPlayer: AudioPlayerInterface, private val audioPlayer: AudioPlayerInterface,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val scrobblePluginSource: ScrobblePluginSource, private val scrobblePluginSource: ScrobblePluginSource,
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO), private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
) : KoinComponent {
private val logger by injectLogger<ScrobbleManager>() private val logger by injectLogger<ScrobbleManager>()
@ -69,14 +68,12 @@ class ScrobbleManager(
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
audioPlayer.playerStateFlow, audioPlayer.playerStateFlow,
audioPlayer.positionFlow, audioPlayer.positionFlow,
audioPlayer.durationFlow, audioPlayer.durationFlow) { entry, playerState, position, duration ->
) { entry, playerState, position, duration ->
PlaybackSnapshot( PlaybackSnapshot(
entry = entry, entry = entry,
playerState = playerState, playerState = playerState,
positionMs = position.inWholeMilliseconds, positionMs = position.inWholeMilliseconds,
playerDurationMs = duration.inWholeMilliseconds, playerDurationMs = duration.inWholeMilliseconds)
)
}.collect(::onPlaybackSnapshot) }.collect(::onPlaybackSnapshot)
} }
} }
@ -137,8 +134,7 @@ class ScrobbleManager(
scrobbled = true scrobbled = true
val track = entry.toScrobbleTrack( val track = entry.toScrobbleTrack(
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(), timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
durationMs = durationMs, durationMs = durationMs)
)
scope.launch { submitScrobble(track) } scope.launch { submitScrobble(track) }
} }
@ -197,8 +193,7 @@ class ScrobbleManager(
artistName = track.artists.joinToString(", ") { it.name }, artistName = track.artists.joinToString(", ") { it.name },
albumName = track.album?.title, albumName = track.album?.title,
streamingProvider = resolveStreamingProvider(track), streamingProvider = resolveStreamingProvider(track),
durationMs = durationMs, durationMs = durationMs)
)
is QueueEntry.LocalTrack -> ScrobbleTrack( is QueueEntry.LocalTrack -> ScrobbleTrack(
timestamp = timestamp, timestamp = timestamp,
@ -209,8 +204,7 @@ class ScrobbleManager(
artistName = artists.joinToString(", "), artistName = artists.joinToString(", "),
albumName = album, albumName = album,
streamingProvider = LOCAL_PROVIDER, streamingProvider = LOCAL_PROVIDER,
durationMs = durationMs, durationMs = durationMs)
)
} }
} }
@ -245,8 +239,7 @@ class ScrobbleManager(
val entry: QueueEntry?, val entry: QueueEntry?,
val playerState: PlayerState, val playerState: PlayerState,
val positionMs: Long, val positionMs: Long,
val playerDurationMs: Long, val playerDurationMs: Long)
)
companion object { companion object {
/** Tracks shorter than this are never scrobbled (Last.fm rule). */ /** Tracks shorter than this are never scrobbled (Last.fm rule). */

View File

@ -29,8 +29,7 @@ class AlternativeTracksRepository(
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager, private val cacheManager: CacheManager) : KoinComponent {
) : KoinComponent {
private val logger by injectLogger<AlternativeTracksRepository>() private val logger by injectLogger<AlternativeTracksRepository>()

View File

@ -51,13 +51,11 @@ data class CacheEntry(
val filename: String, val filename: String,
val sizeBytes: Long, val sizeBytes: Long,
val createdAtMs: Long, val createdAtMs: Long,
val contentType: String = "application/octet-stream", val contentType: String = "application/octet-stream")
)
class CacheManager( class CacheManager(
private val paths: Paths, private val paths: Paths,
private val settingsRepository: SettingsRepository, private val settingsRepository: SettingsRepository) {
) {
private val fileSystem: FileSystem = FileSystem.SYSTEM private val fileSystem: FileSystem = FileSystem.SYSTEM
private val cacheMutex: Mutex = Mutex() private val cacheMutex: Mutex = Mutex()
private val json: Json = Json { ignoreUnknownKeys = true } private val json: Json = Json { ignoreUnknownKeys = true }
@ -160,15 +158,13 @@ class CacheManager(
trackId: String, trackId: String,
filename: String, filename: String,
allBytes: ByteArray, allBytes: ByteArray,
contentType: String, contentType: String) {
) {
val entry = CacheEntry( val entry = CacheEntry(
trackId = trackId, trackId = trackId,
filename = filename, filename = filename,
sizeBytes = allBytes.size.toLong(), sizeBytes = allBytes.size.toLong(),
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(), createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
contentType = contentType, contentType = contentType)
)
cacheMutex.withLock { cacheMutex.withLock {
val index = readCacheIndex() val index = readCacheIndex()
writeCacheIndex(CacheIndex( writeCacheIndex(CacheIndex(

View File

@ -60,8 +60,7 @@ class LocalServer(
private val streamingUrlRepository: StreamingUrlRepository, private val streamingUrlRepository: StreamingUrlRepository,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val cacheManager: CacheManager, private val cacheManager: CacheManager,
private val remoteControlHandler: RemoteControlHandler, private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
) : KoinComponent {
val logger by injectLogger<LocalServer>() val logger by injectLogger<LocalServer>()
private val httpClient = HttpClient { private val httpClient = HttpClient {
@ -99,8 +98,7 @@ class LocalServer(
audioPlayerQueue = audioPlayerQueue, audioPlayerQueue = audioPlayerQueue,
isCachingEnabled = { cachedCacheEnabled.value }, isCachingEnabled = { cachedCacheEnabled.value },
activePort = { activePort.value }, activePort = { activePort.value },
scope = scope, scope = scope)
)
} }
companion object { companion object {

View File

@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json
@Serializable @Serializable
data class PluginTaggedAudioSource( data class PluginTaggedAudioSource(
val pluginId: String, val pluginId: String,
val source: AudioSource.Basic, val source: AudioSource.Basic)
)
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */ /** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
interface TrackSourceRepository { interface TrackSourceRepository {

View File

@ -62,8 +62,7 @@ internal class StreamProxy(
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val isCachingEnabled: () -> Boolean, private val isCachingEnabled: () -> Boolean,
private val activePort: () -> Int?, private val activePort: () -> Int?,
private val scope: CoroutineScope, private val scope: CoroutineScope): KoinComponent {
): KoinComponent {
val logger by injectLogger<StreamProxy>() val logger by injectLogger<StreamProxy>()
companion object { companion object {
@ -414,8 +413,7 @@ internal class StreamProxy(
trackId = trackIdForCache, trackId = trackIdForCache,
filename = cacheFilename, filename = cacheFilename,
allBytes = allBytes, allBytes = allBytes,
contentType = cacheContentType, contentType = cacheContentType)
)
scope.launch { scope.launch {
cacheManager.evictIfNeeded() cacheManager.evictIfNeeded()
} }

View File

@ -48,28 +48,24 @@ data class CachedStreamUrl(
// The id of the audio plugin that produced this entry. Guards against a stale entry // The id of the audio plugin that produced this entry. Guards against a stale entry
// (e.g. a YouTube video URL) being served after the user switches to a different // (e.g. a YouTube video URL) being served after the user switches to a different
// audio plugin, which would otherwise play back completely unrelated content. // audio plugin, which would otherwise play back completely unrelated content.
val pluginId: String, val pluginId: String)
)
private data class CachedAlternatives( private data class CachedAlternatives(
val pluginId: String, val pluginId: String,
val sources: List<AudioSource>, val sources: List<AudioSource>)
)
data class StreamInfo( data class StreamInfo(
val url: String, val url: String,
val protocol: StreamProtocol, val protocol: StreamProtocol,
val codec: String, val codec: String,
val container: String, val container: String)
)
class StreamingUrlRepository( class StreamingUrlRepository(
private val pluginManager: AudioPluginSource, private val pluginManager: AudioPluginSource,
private val audioPlayerQueue: AudioPlayerQueue, private val audioPlayerQueue: AudioPlayerQueue,
private val matchedTracksRepository: TrackSourceRepository, private val matchedTracksRepository: TrackSourceRepository,
private val settingsRepository: UserSettingsSource, private val settingsRepository: UserSettingsSource,
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds, private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
) : KoinComponent {
private val logger by injectLogger<StreamingUrlRepository>() private val logger by injectLogger<StreamingUrlRepository>()
private val streamUrlCacheMutex = Mutex() private val streamUrlCacheMutex = Mutex()
@ -133,8 +129,7 @@ class StreamingUrlRepository(
val resolvedStream = selectPreferredAudioStream( val resolvedStream = selectPreferredAudioStream(
stream.streams, stream.streams,
preferredFormat, preferredFormat,
preferredQuality, preferredQuality)
)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
@ -193,8 +188,7 @@ class StreamingUrlRepository(
val resolvedStream = selectPreferredAudioStream( val resolvedStream = selectPreferredAudioStream(
stream.streams, stream.streams,
preferredFormat, preferredFormat,
preferredQuality, preferredQuality)
)
if (resolvedStream != null) { if (resolvedStream != null) {
val info = resolvedStream.toStreamInfo() val info = resolvedStream.toStreamInfo()
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId) cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
@ -207,15 +201,13 @@ class StreamingUrlRepository(
url = normalizeManifestUrl(url, protocol), url = normalizeManifestUrl(url, protocol),
protocol = protocol, protocol = protocol,
codec = codec, codec = codec,
container = container, container = container)
)
private suspend fun getCachedStreamUrl( private suspend fun getCachedStreamUrl(
trackId: String, trackId: String,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality, preferredQuality: AudioQuality,
pluginId: String, pluginId: String): StreamInfo? {
): StreamInfo? {
val now = Clock.System.now().toEpochMilliseconds() val now = Clock.System.now().toEpochMilliseconds()
return streamUrlCacheMutex.withLock { return streamUrlCacheMutex.withLock {
val cached = streamUrlCache[trackId] ?: return@withLock null val cached = streamUrlCache[trackId] ?: return@withLock null
@ -241,8 +233,7 @@ class StreamingUrlRepository(
preferredQuality: AudioQuality, preferredQuality: AudioQuality,
source: AudioSource.Streamed, source: AudioSource.Streamed,
selectedStream: AudioStream, selectedStream: AudioStream,
pluginId: String, pluginId: String) {
) {
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
streamUrlCacheMutex.withLock { streamUrlCacheMutex.withLock {
streamUrlCache[trackId] = CachedStreamUrl( streamUrlCache[trackId] = CachedStreamUrl(
@ -255,8 +246,7 @@ class StreamingUrlRepository(
selectedStream = selectedStream, selectedStream = selectedStream,
preferredFormat = preferredFormat, preferredFormat = preferredFormat,
preferredQuality = preferredQuality, preferredQuality = preferredQuality,
pluginId = pluginId, pluginId = pluginId)
)
} }
} }
@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
internal fun selectPreferredAudioStream( internal fun selectPreferredAudioStream(
streams: List<AudioStream>, streams: List<AudioStream>,
preferredFormat: AudioFormat, preferredFormat: AudioFormat,
preferredQuality: AudioQuality, preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
): AudioStream? = streams.minWithOrNull(
compareBy<AudioStream> { stream -> compareBy<AudioStream> { stream ->
formatMismatchCount(stream, preferredFormat) formatMismatchCount(stream, preferredFormat)
}.thenBy { stream -> }.thenBy { stream ->

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 package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -26,22 +33,19 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Icon import com.composeunstyled.SheetDetent
import androidx.compose.material3.MaterialTheme import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.material3.ModalBottomSheet import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
import dev.krtirtho.spotube.resources.iconsax.FluentDismiss import dev.krtirtho.spotube.resources.iconsax.FluentDismiss
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AdaptiveDialogBottomSheet( fun AdaptiveDialogBottomSheet(
onDismiss: () -> Unit, onDismiss: () -> Unit,
@ -49,51 +53,37 @@ fun AdaptiveDialogBottomSheet(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
breakpointDp: Float = 600f, breakpointDp: Float = 600f,
actions: @Composable (() -> Unit)? = null, actions: @Composable (() -> Unit)? = null,
content: @Composable () -> Unit, content: @Composable () -> Unit) {
) {
val adaptiveInfo = currentWindowAdaptiveInfo() val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
if (isLargeScreen) { if (isLargeScreen) {
ThemedDialog( MillenniumDialog(
visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = if (title != null) { title = "",
{ modifier = modifier) {
Row( title?.let { it() }
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically
) {
Box(Modifier.weight(1f)) { title() }
IconButton(onClick = onDismiss) {
Icon(
Iconsax.FluentDismiss,
contentDescription = "Close dialog",
)
}
}
}
} else {
null
},
actions = actions,
modifier = modifier,
) {
content() content()
actions?.let {
MillenniumSeparator()
it()
}
} }
} else { } else {
ModalBottomSheet( val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
onDismissRequest = onDismiss, LaunchedEffect(sheetState.currentDetent) {
dragHandle = null, if (sheetState.currentDetent == SheetDetent.Hidden) {
modifier = modifier, onDismiss()
) { }
}
MillenniumModalBottomSheet(state = sheetState) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp) .padding(horizontal = 16.dp)
.padding(top = 16.dp), .padding(top = 16.dp),
horizontalAlignment = Alignment.Start, horizontalAlignment = Alignment.Start) {
) {
title?.let { title?.let {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
@ -101,11 +91,12 @@ fun AdaptiveDialogBottomSheet(
verticalAlignment = Alignment.CenterVertically verticalAlignment = Alignment.CenterVertically
) { ) {
Box(Modifier.weight(1f)) { it() } Box(Modifier.weight(1f)) { it() }
IconButton(onClick = onDismiss) { MillenniumIconButton(
Icon( onClick = onDismiss,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
Iconsax.FluentDismiss, Iconsax.FluentDismiss,
contentDescription = "Close dialog", contentDescription = "Close dialog")
)
} }
} }
Spacer(Modifier.height(12.dp)) Spacer(Modifier.height(12.dp))
@ -113,7 +104,7 @@ fun AdaptiveDialogBottomSheet(
content() content()
actions?.let { actions?.let {
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f)) MillenniumSeparator()
Spacer(Modifier.height(8.dp)) Spacer(Modifier.height(8.dp))
it() it()
} }

View File

@ -17,6 +17,13 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumTextField
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.border import androidx.compose.foundation.border
@ -24,6 +31,7 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.hoverable import androidx.compose.foundation.hoverable
import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.interaction.collectIsHoveredAsState import androidx.compose.foundation.interaction.collectIsHoveredAsState
import androidx.compose.foundation.interaction.collectIsPressedAsState
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -38,16 +46,22 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Icon import com.composeunstyled.DropdownMenuPanel
import androidx.compose.material3.IconButton import com.composeunstyled.SheetDetent
import androidx.compose.material3.MaterialTheme import com.composeunstyled.Text
import androidx.compose.material3.ModalBottomSheet import com.composeunstyled.UnstyledDropdownMenu
import dev.krtirtho.spotube.core.ui.base.BaseScaffold import com.composeunstyled.UnstyledDropdownMenuItem
import androidx.compose.material3.Text import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.gelBrush
import org.spotube.millennium.theme.millenniumPanelBrush
import org.spotube.millennium.theme.millenniumSurface
import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -64,10 +78,6 @@ import androidx.compose.ui.unit.DpOffset
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import compose.icons.FeatherIcons import compose.icons.FeatherIcons
import compose.icons.feathericons.Check import compose.icons.feathericons.Check
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.DropdownMenu
import dev.krtirtho.spotube.core.ui.base.DropdownMenuItem
import dev.krtirtho.spotube.core.ui.base.DropdownMenuDivider
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch import dev.krtirtho.spotube.resources.iconsax.IconsaxFilterSearch
@ -83,8 +93,7 @@ data class AdaptiveMenuItem(
val selected: Boolean = false, val selected: Boolean = false,
val dividerBefore: Boolean = false, val dividerBefore: Boolean = false,
val subtitle: String? = null, val subtitle: String? = null,
val leadingContent: (@Composable () -> Unit)? = null, val leadingContent: (@Composable () -> Unit)? = null)
)
enum class HeaderDisplayMode { enum class HeaderDisplayMode {
Always, Always,
@ -101,69 +110,127 @@ fun AdaptiveDropdownBottomSheet(
menuMinWidth: Dp = 200.dp, menuMinWidth: Dp = 200.dp,
header: @Composable (() -> Unit)? = null, header: @Composable (() -> Unit)? = null,
headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always, headerDisplayMode: HeaderDisplayMode = HeaderDisplayMode.Always,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
) {
var expanded by remember { mutableStateOf(false) } var expanded by remember { mutableStateOf(false) }
val adaptiveInfo = currentWindowAdaptiveInfo() val adaptiveInfo = currentWindowAdaptiveInfo()
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
Box(modifier = modifier) { Box(modifier = modifier) {
trigger { expanded = true }
if (isLargeScreen) { if (isLargeScreen) {
DropdownMenu( UnstyledDropdownMenu(
expanded = expanded, expanded = expanded,
onDismissRequest = { expanded = false }, onExpandedChange = { expanded = it },
) { panel = {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { DropdownMenuPanel(
header() Modifier
} .width(menuMinWidth)
.millenniumSurface(
var query by remember { mutableStateOf("") } background = millenniumPanelBrush(MillenniumTheme.colors),
if (filter != null) { shape = AppTheme.shapes.medium,
TextField( borderColor = AppTheme.colors.outlineVariant,
value = query, borderWidth = 1.dp)
onValueChange = { .padding(4.dp)) {
query = it if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
}, header()
placeholder = { Text("Search...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
modifier = Modifier.size(16.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp)
)
}
items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach
} }
if (item.dividerBefore) {
DropdownMenuDivider() val dropdownQueryState = rememberTextFieldState()
val query = dropdownQueryState.text.toString()
if (filter != null) {
MillenniumTextField(
state = dropdownQueryState,
label = "",
placeholder = "Search...",
modifier = Modifier
.fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp))
}
items.forEach { item ->
if (query.isNotBlank() && filter != null && !filter(item, query)) {
return@forEach
}
if (item.dividerBefore) {
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
}
val itemInteraction = remember { MutableInteractionSource() }
val itemHovered by itemInteraction.collectIsHoveredAsState()
val itemPressed by itemInteraction.collectIsPressedAsState()
val itemEnabled = item.enabled
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
val itemForeground = when {
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
itemHighlighted -> MillenniumTheme.colors.onAccent
else -> AppTheme.colors.onSurface
}
UnstyledDropdownMenuItem(
onClick = { item.onClick() },
enabled = itemEnabled,
indication = null,
interactionSource = itemInteraction) {
Row(
modifier = Modifier
.fillMaxWidth()
.then(
if (itemHighlighted) {
Modifier.millenniumSurface(
background = gelBrush(
MillenniumTheme.colors.accentTop,
MillenniumTheme.colors.accentBottom),
shape = RoundedCornerShape(8.dp))
} else {
Modifier
})
.padding(horizontal = 12.dp, vertical = 8.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
val itemIcon = item.icon
when {
item.leadingContent != null -> item.leadingContent.invoke()
itemIcon != null -> MillenniumIcon(
imageVector = itemIcon,
contentDescription = null,
tint = itemForeground,
modifier = Modifier.size(20.dp))
item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
Column(modifier = Modifier.weight(1f)) {
Text(
text = item.label,
style = AppTheme.typography.bodyMedium,
color = itemForeground)
item.subtitle?.let { subtitle ->
Text(
text = subtitle,
style = AppTheme.typography.bodySmall,
color = if (itemHighlighted) {
itemForeground.copy(alpha = 0.85f)
} else {
AppTheme.colors.onSurfaceVariant
})
}
}
if (item.selected && (item.icon != null || item.leadingContent != null)) {
MillenniumIcon(
imageVector = FeatherIcons.Check,
contentDescription = "Selected",
tint = itemForeground,
modifier = Modifier.size(20.dp))
}
}
}
} }
DropdownMenuItem(
text = item.label,
onClick = {
item.onClick()
expanded = false
},
enabled = item.enabled,
selected = item.selected,
leadingIcon = item.icon,
leadingContent = item.leadingContent,
subtitle = item.subtitle,
)
} }
} },
anchor = { trigger { expanded = true } },
)
} else { } else {
trigger { expanded = true }
if (expanded) { if (expanded) {
AdaptiveBottomSheetContent( AdaptiveBottomSheetContent(
onDismiss = { expanded = false }, onDismiss = { expanded = false },
@ -173,119 +240,97 @@ fun AdaptiveDropdownBottomSheet(
} else { } else {
null null
}, },
filter = filter, filter = filter)
)
} }
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun AdaptiveBottomSheetContent( private fun AdaptiveBottomSheetContent(
onDismiss: () -> Unit, onDismiss: () -> Unit,
items: List<AdaptiveMenuItem>, items: List<AdaptiveMenuItem>,
header: @Composable (() -> Unit)?, header: @Composable (() -> Unit)?,
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null, filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
) {
val hasSelection = items.any { it.selected } val hasSelection = items.any { it.selected }
var query by remember { mutableStateOf("") } val queryState = rememberTextFieldState()
val query = queryState.text.toString()
val filteredItems = if (filter != null && query.isNotBlank()) { val filteredItems = if (filter != null && query.isNotBlank()) {
items.filter { filter(it, query) } items.filter { filter(it, query) }
} else { } else {
items items
} }
ModalBottomSheet( val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
onDismissRequest = onDismiss, LaunchedEffect(sheetState.currentDetent) {
dragHandle = null, if (sheetState.currentDetent == SheetDetent.Hidden) {
) { onDismiss()
}
}
MillenniumModalBottomSheet(state = sheetState) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth()) {
) {
header?.invoke() header?.invoke()
if (filter != null) { if (filter != null) {
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
TextField( MillenniumTextField(
value = query, state = queryState,
onValueChange = { label = "",
query = it placeholder = "Search...",
},
placeholder = { Text("Search...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant,
)
},
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp) .padding(horizontal = 12.dp, vertical = 8.dp))
)
} }
LazyColumn( LazyColumn(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
) {
items( items(
count = filteredItems.size, count = filteredItems.size) { index ->
) { index ->
val item = filteredItems[index] val item = filteredItems[index]
if (item.dividerBefore) { if (item.dividerBefore) {
HorizontalDivider( MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
modifier = Modifier.padding(vertical = 4.dp),
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(MaterialTheme.shapes.medium) .clip(AppTheme.shapes.medium)
.clickable( .clickable(
enabled = item.enabled, enabled = item.enabled,
onClick = { onClick = {
item.onClick() item.onClick()
onDismiss() onDismiss()
}, })
)
.background( .background(
color = if (item.selected) { color = if (item.selected) {
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f) AppTheme.colors.secondary.copy(alpha = 0.12f)
} else { } else {
Color.Transparent Color.Transparent
}, },
shape = MaterialTheme.shapes.medium, shape = AppTheme.shapes.medium)
)
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) {
when { when {
item.leadingContent != null -> item.leadingContent.invoke() item.leadingContent != null -> item.leadingContent.invoke()
item.icon != null -> Icon( item.icon != null -> MillenniumIcon(
imageVector = item.icon, imageVector = item.icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = if (item.enabled) { tint = if (item.enabled) {
MaterialTheme.colorScheme.onSurface AppTheme.colors.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) AppTheme.colors.onSurface.copy(alpha = 0.38f)
}, })
)
item.selected -> Icon( item.selected -> MillenniumIcon(
imageVector = FeatherIcons.Check, imageVector = FeatherIcons.Check,
contentDescription = "Selected", contentDescription = "Selected",
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = AppTheme.colors.onSurfaceVariant)
)
hasSelection -> Spacer(modifier = Modifier.size(22.dp)) hasSelection -> Spacer(modifier = Modifier.size(22.dp))
} }
@ -293,29 +338,26 @@ private fun AdaptiveBottomSheetContent(
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = if (item.enabled) { color = if (item.enabled) {
MaterialTheme.colorScheme.onSurface AppTheme.colors.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) AppTheme.colors.onSurface.copy(alpha = 0.38f)
}, })
)
item.subtitle?.let { subtitle -> item.subtitle?.let { subtitle ->
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant)
)
} }
} }
if (item.selected && (item.icon != null || item.leadingContent != null)) { if (item.selected && (item.icon != null || item.leadingContent != null)) {
Icon( MillenniumIcon(
imageVector = FeatherIcons.Check, imageVector = FeatherIcons.Check,
contentDescription = "Selected", contentDescription = "Selected",
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = AppTheme.colors.onSurfaceVariant)
)
} }
} }
} }
@ -327,44 +369,37 @@ private fun AdaptiveBottomSheetContent(
@Preview @Preview
@Composable @Composable
private fun AdaptiveDropdownBottomSheetPreview() { private fun AdaptiveDropdownBottomSheetPreview() {
BaseScaffold { Box(modifier = Modifier.background(AppTheme.colors.background)) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = listOf( items = listOf(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxHeart, icon = Iconsax.IconsaxHeart,
label = "Favorite", label = "Favorite",
onClick = {}, onClick = {}),
),
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxShare, icon = Iconsax.IconsaxShare,
label = "Share", label = "Share",
onClick = {}, onClick = {}),
),
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxTrash, icon = Iconsax.IconsaxTrash,
label = "Delete", label = "Delete",
onClick = {}, onClick = {},
enabled = false, enabled = false)),
),
),
trigger = { onClick -> trigger = { onClick ->
IconButton( MillenniumIconButton(
onClick = onClick, onClick = onClick,
modifier = Modifier modifier = Modifier
.size(32.dp) .size(32.dp)
.background( .background(
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f), color = AppTheme.colors.primary.copy(alpha = 0.1f),
shape = CircleShape, shape = CircleShape)
)
.clip(CircleShape), .clip(CircleShape),
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = null, contentDescription = null,
tint = MaterialTheme.colorScheme.primary, tint = AppTheme.colors.primary)
)
} }
}, })
)
} }
} }

View File

@ -40,8 +40,7 @@ fun AlbumCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject(), remotePlaybackController: RemotePlaybackController = koinInject()) {
) {
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
PlayableCard( PlayableCard(
@ -57,17 +56,14 @@ fun AlbumCard(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Album, RemoteCollectionType.Album,
album.id, album.id,
album.title, album.title)
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Album, RemoteCollectionType.Album,
album.id, album.id,
album.title, album.title)
)
}, },
modifier = modifier.width(160.dp), modifier = modifier.width(160.dp),
sharedElementKey = "album_art_${album.id}", sharedElementKey = "album_art_${album.id}")
)
} }

View File

@ -17,9 +17,12 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3 import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
@ -30,12 +33,12 @@ import org.koin.compose.koinInject
fun ApplicationBackButton() { fun ApplicationBackButton() {
val navigationCommands: NavigationCommands = koinInject() val navigationCommands: NavigationCommands = koinInject()
IconButton( MillenniumIconButton(
onClick = { onClick = {
navigationCommands.pop() navigationCommands.pop()
}, },
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = Iconsax.ArrowLeft3, imageVector = Iconsax.ArrowLeft3,
contentDescription = "Back" contentDescription = "Back"
) )
@ -48,5 +51,4 @@ expect fun ApplicationMainBar(
subtitle: @Composable () -> Unit = {}, subtitle: @Composable () -> Unit = {},
actions: @Composable (RowScope.() -> Unit) = {}, actions: @Composable (RowScope.() -> Unit) = {},
backButton: Boolean = true, backButton: Boolean = true,
transparent: Boolean = false, transparent: Boolean = false)
)

View File

@ -29,9 +29,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -52,8 +52,7 @@ import org.koin.compose.koinInject
fun ArtistCard( fun ArtistCard(
artist: MetadataArtist.Basic, artist: MetadataArtist.Basic,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false, isBlacklisted: Boolean = false) {
) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
AvatarCard( AvatarCard(
@ -64,7 +63,6 @@ fun ArtistCard(
navigationCommands.navigateTo(Routes.Artist(artist.id)) navigationCommands.navigateTo(Routes.Artist(artist.id))
}, },
modifier = modifier, modifier = modifier,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted)
)
} }

View File

@ -17,13 +17,23 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.foundation.layout.height
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumSeparator
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxWithConstraints import androidx.compose.foundation.layout.BoxWithConstraints
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
@ -31,9 +41,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -42,12 +52,6 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
@ -62,7 +66,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
import org.jetbrains.compose.resources.DrawableResource import org.jetbrains.compose.resources.DrawableResource
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
@OptIn(ExperimentalSharedTransitionApi::class) @OptIn(ExperimentalSharedTransitionApi::class)
@Composable @Composable
fun CollectionDetails( fun CollectionDetails(
@ -85,8 +88,7 @@ fun CollectionDetails(
onFollowClick: () -> Unit = { }, onFollowClick: () -> Unit = { },
showFollowButton: Boolean = true, showFollowButton: Boolean = true,
onEdit: (() -> Unit)? = null, onEdit: (() -> Unit)? = null,
sharedElementKey: String? = null, sharedElementKey: String? = null) {
) {
BoxWithConstraints(modifier = modifier.fillMaxWidth()) { BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp val isCompact = maxWidth < 600.dp
@ -98,95 +100,86 @@ fun CollectionDetails(
if (onAddToJam != null) { if (onAddToJam != null) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
) { MillenniumButton(
PrimaryButton(
modifier = if (isCompact) Modifier.weight(1f) else Modifier, modifier = if (isCompact) Modifier.weight(1f) else Modifier,
onClick = onAddToJam!!, onClick = onAddToJam!!) {
) { MillenniumIcon(
Icon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to Jam", contentDescription = "Add to Jam")
) TextWithShimmer(text = "Add to Jam")
TextWithShimmer(
text = "Add to Jam",
modifier = Modifier.padding(start = 6.dp),
)
} }
} }
} }
} else { } else {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp)) {
) {
val modifier = if (isCompact) { val modifier = if (isCompact) {
Modifier.weight(1f) Modifier.weight(1f)
} else { } else {
Modifier Modifier
} }
PrimaryButton( MillenniumButton(
modifier = modifier, modifier = modifier,
onClick = onPlay, onClick = onPlay) {
) { MillenniumIcon(
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2, imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play", contentDescription = if (isPlaying) "Pause" else "Play")
) TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
TextWithShimmer(
text = if (isPlaying) "Pause" else "Play",
modifier = Modifier.padding(start = 6.dp),
)
} }
OutlineButton( MillenniumButton(
modifier = modifier, modifier = modifier,
onClick = onShufflePlay onClick = onShufflePlay,
style = MillenniumButtonStyle.Secondary
) { ) {
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play") MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
TextWithShimmer( TextWithShimmer(text = "Shuffle")
text = "Shuffle",
modifier = Modifier.padding(start = 6.dp),
)
} }
} }
} }
} }
val actions = @Composable { val actions = @Composable {
ButtonGroup { Row(verticalAlignment = Alignment.CenterVertically) {
GroupIconButton(onClick = onAddToQueue) { MillenniumIconButton(
Icon( onClick = onAddToQueue,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue" contentDescription = "Add to queue"
) )
} }
ButtonGroupDivider() MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
if (showFollowButton) { if (showFollowButton) {
if (isFollowing) { if (isFollowing) {
GroupIconButton(onClick = onFollowClick) { MillenniumIconButton(
Icon( onClick = onFollowClick,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxHeart2, imageVector = Iconsax.IconsaxHeart2,
contentDescription = "Unfollow", contentDescription = "Unfollow")
)
} }
} else { } else {
GroupIconButton(onClick = onFollowClick) { MillenniumIconButton(
Icon( onClick = onFollowClick,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxHeart, imageVector = Iconsax.IconsaxHeart,
contentDescription = "Follow", contentDescription = "Follow",
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = AppTheme.colors.onSurfaceVariant)
)
} }
} }
} }
if (onEdit != null) { if (onEdit != null) {
ButtonGroupDivider() MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
GroupIconButton(onClick = onEdit) { MillenniumIconButton(
Icon( onClick = onEdit,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.IconsaxEdit, imageVector = Iconsax.IconsaxEdit,
contentDescription = "Edit playlist", contentDescription = "Edit playlist")
)
} }
} }
} }
@ -195,8 +188,7 @@ fun CollectionDetails(
Row( Row(
modifier = Modifier.clickable(onClick = onOwnerClick), modifier = Modifier.clickable(onClick = onOwnerClick),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
) {
if (ownerImageURL != null) { if (ownerImageURL != null) {
AsyncImage( AsyncImage(
model = ownerImageURL, model = ownerImageURL,
@ -205,27 +197,24 @@ fun CollectionDetails(
.size(16.dp) .size(16.dp)
.clip(CircleShape) .clip(CircleShape)
.shimmerApply(), .shimmerApply(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} else { } else {
Icon( MillenniumIcon(
imageVector = Iconsax.User, imageVector = Iconsax.User,
contentDescription = ownerName, contentDescription = ownerName,
modifier = Modifier modifier = Modifier
.size(16.dp) .size(16.dp)
.background( .background(
MaterialTheme.colorScheme.primaryContainer, AppTheme.colors.primaryContainer,
CircleShape CircleShape
) )
.padding(6.dp), .padding(6.dp),
tint = MaterialTheme.colorScheme.onPrimaryContainer, tint = AppTheme.colors.onPrimaryContainer)
)
} }
TextWithShimmer( TextWithShimmer(
text = "By $ownerName", text = "By $ownerName",
style = MaterialTheme.typography.labelSmall, style = AppTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant)
)
} }
} }
@ -237,79 +226,68 @@ fun CollectionDetails(
.sharedElementOrNone( .sharedElementOrNone(
key = sharedElementKey, key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope)
)
.shimmerApply(), .shimmerApply(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
if (imageURL.isNotBlank()) { if (imageURL.isNotBlank()) {
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} else if (imageResource != null) { } else if (imageResource != null) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(imageResource), painter = painterResource(imageResource),
contentDescription = title, contentDescription = title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} else { } else {
Text( Text(
text = title.take(1).ifBlank { "?" }.uppercase(), text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.displaySmall, style = AppTheme.typography.displaySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant)
)
} }
} }
} }
val metadata = @Composable { val metadata = @Composable {
Column { Column {
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge, style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
if (description.isNotBlank()) { if (description.isNotBlank()) {
TextWithShimmer( TextWithShimmer(
text = description, text = description,
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium, style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
} }
} }
} }
Card( MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { contentPadding = PaddingValues(0.dp)) {
if (isCompact) { if (isCompact) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(14.dp), .padding(14.dp),
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp)) {
) {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically) {
) {
artwork() artwork()
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(6.dp), verticalArrangement = Arrangement.spacedBy(6.dp)) {
) {
metadata() metadata()
ownerInfo() ownerInfo()
actions() actions()
@ -323,14 +301,12 @@ fun CollectionDetails(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalArrangement = Arrangement.spacedBy(16.dp), horizontalArrangement = Arrangement.spacedBy(16.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top) {
) {
artwork() artwork()
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
) {
metadata() metadata()
ownerInfo() ownerInfo()
actions() actions()
@ -354,6 +330,5 @@ fun CollectionDetailsPreview() {
onOwnerClick = {}, onOwnerClick = {},
onPlay = {}, onPlay = {},
onShufflePlay = {}, onShufflePlay = {},
onAddToQueue = {}, onAddToQueue = {})
)
} }

View File

@ -17,18 +17,22 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.MaterialTheme import com.composeunstyled.Text
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -93,8 +97,7 @@ fun CollectionView(
onAddToJam: (List<MetadataTrack>) -> Unit = {}, onAddToJam: (List<MetadataTrack>) -> Unit = {},
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() }, trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
footerContent: (@Composable () -> Unit)? = null, footerContent: (@Composable () -> Unit)? = null,
trailingContent: @Composable () -> Unit = {}, trailingContent: @Composable () -> Unit = {}) {
) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val isCollapsed by remember { val isCollapsed by remember {
derivedStateOf { derivedStateOf {
@ -103,45 +106,42 @@ fun CollectionView(
} }
} }
BaseScaffold( Column(modifier = Modifier.fillMaxSize()) {
topBar = { ApplicationMainBar(
ApplicationMainBar( backButton = true,
backButton = true, title = if (isCollapsed) {
title = if (isCollapsed) { {
{ CollapsedCollectionTitle(
CollapsedCollectionTitle( title = title,
title = title, imageURL = imageURL,
imageURL = imageURL, imageResource = imageResource)
imageResource = imageResource, }
) } else {
{}
},
actions = if (isCollapsed && !isJamGuest) {
{
MillenniumIconButton(
onClick = onPlay,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play")
} }
} else { }
{} } else {
}, {}
actions = if (isCollapsed && !isJamGuest) { })
{
IconButton(onClick = onPlay) { Box(modifier = Modifier.weight(1f).fillMaxSize()) {
Icon(
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
contentDescription = if (isPlaying) "Pause" else "Play",
)
}
}
} else {
{}
},
)
}
) { innerPadding ->
if (error != null && tracks.isEmpty() && !isLoading) { if (error != null && tracks.isEmpty() && !isLoading) {
ErrorDisplay( ErrorDisplay(
errorMessage = error, errorMessage = error,
onRetry = onRetry ?: {}, onRetry = onRetry ?: {},
modifier = Modifier.padding(innerPadding), modifier = Modifier.fillMaxSize())
)
} else { } else {
TrackList( TrackList(
modifier = Modifier.padding(innerPadding), modifier = Modifier.fillMaxSize(),
state = listState, state = listState,
headerContent = { headerContent = {
if (isLoading && tracks.isEmpty()) { if (isLoading && tracks.isEmpty()) {
@ -164,8 +164,7 @@ fun CollectionView(
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) }, onAddToJam = { onAddToJam(tracks) })
)
} }
} else { } else {
CollectionDetails( CollectionDetails(
@ -186,8 +185,7 @@ fun CollectionView(
onEdit = onEdit, onEdit = onEdit,
sharedElementKey = sharedElementKey, sharedElementKey = sharedElementKey,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
onAddToJam = { onAddToJam(tracks) }, onAddToJam = { onAddToJam(tracks) })
)
} }
}, },
footerContent = footerContent, footerContent = footerContent,
@ -211,11 +209,11 @@ fun CollectionView(
onBulkAddToJam = onBulkAddToJam, onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
trackOptionsState = trackOptionsState, trackOptionsState = trackOptionsState)
)
} }
trailingContent() trailingContent()
}
} }
} }
@ -223,46 +221,39 @@ fun CollectionView(
private fun CollapsedCollectionTitle( private fun CollapsedCollectionTitle(
title: String, title: String,
imageURL: String, imageURL: String,
imageResource: DrawableResource? = null, imageResource: DrawableResource? = null) {
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(36.dp) .size(36.dp)
.clip(RoundedCornerShape(6.dp)) .clip(RoundedCornerShape(6.dp))
.shimmerApply(), .shimmerApply(),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
if (imageURL.isNotBlank()) { if (imageURL.isNotBlank()) {
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} else if (imageResource != null) { } else if (imageResource != null) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
painter = painterResource(imageResource), painter = painterResource(imageResource),
contentDescription = title, contentDescription = title,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} else { } else {
Text( Text(
text = title.take(1).ifBlank { "?" }.uppercase(), text = title.take(1).ifBlank { "?" }.uppercase(),
style = MaterialTheme.typography.labelMedium, style = AppTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant)
)
} }
} }
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = MaterialTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(start = 10.dp), modifier = Modifier.padding(start = 10.dp))
)
} }
} }

View File

@ -17,30 +17,33 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumDialog
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.text.selection.SelectionContainer import androidx.compose.foundation.text.selection.SelectionContainer
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults import com.composeunstyled.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Surface
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw
fun ErrorDisplay( fun ErrorDisplay(
errorMessage: String, errorMessage: String,
onRetry: () -> Unit, onRetry: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
) {
var showFullLog by remember { mutableStateOf(false) } var showFullLog by remember { mutableStateOf(false) }
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
@ -77,117 +79,99 @@ fun ErrorDisplay(
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(20.dp), verticalArrangement = Arrangement.spacedBy(20.dp)) {
) { MillenniumIcon(
Icon(
imageVector = FeatherIcons.AlertCircle, imageVector = FeatherIcons.AlertCircle,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(64.dp), modifier = Modifier.size(64.dp),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), tint = AppTheme.colors.error.copy(alpha = 0.7f))
)
Text( Text(
text = "We're Truly Sorry", text = "We're Truly Sorry",
style = MaterialTheme.typography.headlineSmall, style = AppTheme.typography.headlineSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface, color = AppTheme.colors.onSurface,
textAlign = TextAlign.Center, textAlign = TextAlign.Center)
)
Text( Text(
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.", text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2, lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
)
Card( MillenniumCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
colors = CardDefaults.cardColors( contentPadding = PaddingValues(0.dp)) {
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
),
shape = MaterialTheme.shapes.medium,
) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth()) {
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically) {
) {
Text( Text(
text = "Error Details", text = "Error Details",
style = MaterialTheme.typography.labelMedium, style = AppTheme.typography.labelMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant)
)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(4.dp), horizontalArrangement = Arrangement.spacedBy(4.dp)) {
) { MillenniumIconButton(
IconButton(
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(errorMessage)) clipboardManager.setText(AnnotatedString(errorMessage))
}, },
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = FeatherIcons.Copy, imageVector = FeatherIcons.Copy,
contentDescription = "Copy to clipboard", contentDescription = "Copy to clipboard",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp))
)
} }
IconButton( MillenniumIconButton(
onClick = { showFullLog = true }, onClick = { showFullLog = true },
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = FeatherIcons.Maximize2, imageVector = FeatherIcons.Maximize2,
contentDescription = "View full log", contentDescription = "View full log",
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp))
)
} }
} }
} }
Surface( MillenniumCard(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f), contentPadding = PaddingValues(0.dp)) {
) {
SelectionContainer { SelectionContainer {
Text( Text(
text = errorMessage, text = errorMessage,
style = MaterialTheme.typography.bodySmall.copy( style = AppTheme.typography.bodySmall.copy(
fontFamily = FontFamily.Monospace, fontFamily = FontFamily.Monospace),
), color = AppTheme.colors.onSurfaceVariant,
color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 5, maxLines = 5,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(12.dp), modifier = Modifier.padding(12.dp))
)
} }
} }
} }
} }
Row( Row(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) { MillenniumButton(
Button(
onClick = onRetry, onClick = onRetry,
modifier = Modifier.height(40.dp), modifier = Modifier.height(40.dp)) {
) { Row(verticalAlignment = Alignment.CenterVertically) {
Icon( MillenniumIcon(
imageVector = FeatherIcons.RefreshCw, imageVector = FeatherIcons.RefreshCw,
contentDescription = null, contentDescription = null,
modifier = Modifier.padding(end = 8.dp).size(18.dp), modifier = Modifier.padding(end = 8.dp).size(18.dp))
) Text("Retry")
Text("Retry") }
} }
} }
} }
@ -195,93 +179,85 @@ fun ErrorDisplay(
if (showFullLog) { if (showFullLog) {
ErrorLogDialog( ErrorLogDialog(
errorMessage = errorMessage, errorMessage = errorMessage,
onDismiss = { showFullLog = false }, onDismiss = { showFullLog = false })
)
} }
} }
@Composable @Composable
private fun ErrorLogDialog( private fun ErrorLogDialog(
errorMessage: String, errorMessage: String,
onDismiss: () -> Unit, onDismiss: () -> Unit) {
) {
val clipboardManager = LocalClipboardManager.current val clipboardManager = LocalClipboardManager.current
AlertDialog( MillenniumDialog(
visible = true,
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { title = "") {
Row( Row(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically) {
) { Text(
Text( text = "Full Error Log",
text = "Full Error Log", style = AppTheme.typography.titleLarge,
style = MaterialTheme.typography.titleLarge, fontWeight = FontWeight.Bold)
fontWeight = FontWeight.Bold,
)
IconButton( MillenniumIconButton(
onClick = { onClick = {
clipboardManager.setText(AnnotatedString(errorMessage)) clipboardManager.setText(AnnotatedString(errorMessage))
}, },
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = FeatherIcons.Copy, imageVector = FeatherIcons.Copy,
contentDescription = "Copy to clipboard", contentDescription = "Copy to clipboard")
)
}
} }
}, }
text = { Box(
Box( modifier = Modifier
modifier = Modifier .fillMaxWidth()
.fillMaxWidth() .height(400.dp)
.height(400.dp) .clip(AppTheme.shapes.small)
.clip(MaterialTheme.shapes.small) .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)), SelectionContainer {
) { Text(
SelectionContainer { text = errorMessage,
Text( style = AppTheme.typography.bodySmall.copy(
text = errorMessage, fontFamily = FontFamily.Monospace),
style = MaterialTheme.typography.bodySmall.copy( color = AppTheme.colors.onSurfaceVariant,
fontFamily = FontFamily.Monospace, modifier = Modifier
), .fillMaxSize()
color = MaterialTheme.colorScheme.onSurfaceVariant, .verticalScroll(rememberScrollState())
modifier = Modifier .padding(12.dp))
.fillMaxSize()
.verticalScroll(rememberScrollState())
.padding(12.dp),
)
}
} }
}, }
confirmButton = { Row(
TextButton(onClick = onDismiss) { modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.End) {
MillenniumButton(
onClick = onDismiss,
style = MillenniumButtonStyle.Quiet) {
Text("Close") Text("Close")
} }
}, }
) }
} }
@Preview @Preview
@Composable @Composable
private fun ErrorDisplayPreview() { private fun ErrorDisplayPreview() {
Surface { Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
ErrorDisplay( ErrorDisplay(
errorMessage = "This is an error message", errorMessage = "This is an error message",
onRetry = {}, onRetry = {})
)
} }
} }
@Preview @Preview
@Composable @Composable
private fun ErrorLogDialogPreview() { private fun ErrorLogDialogPreview() {
BaseScaffold { Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
ErrorLogDialog( ErrorLogDialog(
errorMessage = "This is an error message", errorMessage = "This is an error message",
onDismiss = {}, onDismiss = {})
)
} }
} }

View File

@ -17,6 +17,10 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.core.animateFloatAsState import androidx.compose.animation.core.animateFloatAsState
import androidx.compose.animation.core.tween import androidx.compose.animation.core.tween
import androidx.compose.foundation.background import androidx.compose.foundation.background
@ -34,11 +38,10 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.MaterialTheme import com.composeunstyled.Text
import androidx.compose.material3.Text import org.spotube.millennium.adaptive.currentWindowAdaptiveInfo
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableIntStateOf
@ -83,8 +86,7 @@ import org.koin.compose.koinInject
@Composable @Composable
fun FeaturedCarousel( fun FeaturedCarousel(
items: List<MetadataBrowseItem>, items: List<MetadataBrowseItem>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
) {
val filteredItems = remember(items) { val filteredItems = remember(items) {
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track } items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
} }
@ -92,15 +94,13 @@ fun FeaturedCarousel(
if (filteredItems.isEmpty()) return if (filteredItems.isEmpty()) return
FeaturedCarouselContent( FeaturedCarouselContent(
items = filteredItems, items = filteredItems)
)
} }
@Composable @Composable
private fun FeaturedCarouselContent( private fun FeaturedCarouselContent(
items: List<MetadataBrowseItem>, items: List<MetadataBrowseItem>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
) {
val centerIndex = items.size / 2 val centerIndex = items.size / 2
var selectedIndex by remember { mutableIntStateOf(centerIndex) } var selectedIndex by remember { mutableIntStateOf(centerIndex) }
val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index } val state = rememberCoverflowState(centerIndex) { index -> selectedIndex = index }
@ -113,8 +113,7 @@ private fun FeaturedCarouselContent(
.height( .height(
if (isSmallScreen) 200.dp else 300.dp if (isSmallScreen) 200.dp else 300.dp
), ),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
Coverflow( Coverflow(
state = state, state = state,
params = CoverflowParams( params = CoverflowParams(
@ -123,21 +122,17 @@ private fun FeaturedCarouselContent(
angle = 12f, angle = 12f,
shift = 0.255f, shift = 0.255f,
zoom = 0.7864f, zoom = 0.7864f,
mirror = false, mirror = false),
), modifier = modifier) {
modifier = modifier,
) {
items( items(
items = items, items = items,
key = { index: Int -> items[index].hashCode() }, key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem ->
) { item: MetadataBrowseItem ->
FeaturedCarouselCard( FeaturedCarouselCard(
item = item, item = item,
isCentered = selectedIndex == items.indexOf(item), isCentered = selectedIndex == items.indexOf(item),
onScrollToCenter = { onScrollToCenter = {
state.scrollToItem(items.indexOf(item)) state.scrollToItem(items.indexOf(item))
}, })
)
} }
} }
} }
@ -148,8 +143,7 @@ private fun FeaturedCarouselCard(
item: MetadataBrowseItem, item: MetadataBrowseItem,
isCentered: Boolean, isCentered: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onScrollToCenter: () -> Unit = { }, onScrollToCenter: () -> Unit = { }) {
) {
val navigationCommands = koinInject<NavigationCommands>() val navigationCommands = koinInject<NavigationCommands>()
val audioPlayerQueue = koinInject<AudioPlayerQueue>() val audioPlayerQueue = koinInject<AudioPlayerQueue>()
@ -204,25 +198,21 @@ private fun FeaturedCarouselCard(
} }
} }
.aspectRatio(1f) .aspectRatio(1f)
.hazeSource(hazeState), .hazeSource(hazeState)) {
) {
AsyncImage( AsyncImage(
model = thumbnailUrl, model = thumbnailUrl,
contentDescription = title, contentDescription = title,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = dimAlpha)), .background(Color.Black.copy(alpha = dimAlpha)))
)
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -231,43 +221,35 @@ private fun FeaturedCarouselCard(
brush = Brush.verticalGradient( brush = Brush.verticalGradient(
colors = listOf( colors = listOf(
Color.Transparent, Color.Transparent,
Color.Black.copy(alpha = 0.9f), Color.Black.copy(alpha = 0.9f)))))
),
),
),
)
} }
} }
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize(), .fillMaxSize(),
contentAlignment = Alignment.BottomCenter, contentAlignment = Alignment.BottomCenter) {
) {
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp)) {
) {
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleMedium, style = AppTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = Color.White, color = Color.White,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = Color.White.copy(alpha = 0.85f), color = Color.White.copy(alpha = 0.85f),
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
} }
val animatedAlpha by animateFloatAsState( val animatedAlpha by animateFloatAsState(
@ -306,8 +288,7 @@ private fun FeaturedPlayButton(
navigationCommands: NavigationCommands, navigationCommands: NavigationCommands,
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
enabled: Boolean, enabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -320,7 +301,8 @@ private fun FeaturedPlayButton(
modifier = modifier.size(44.dp), modifier = modifier.size(44.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
IconButton( MillenniumIconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = { onClick = {
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Album(item.data.id)) navigationCommands.navigateTo(Routes.Album(item.data.id))
@ -330,7 +312,7 @@ private fun FeaturedPlayButton(
}, },
enabled = enabled enabled = enabled
) { ) {
Icon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = null, contentDescription = null,
tint = Color.White tint = Color.White
@ -347,7 +329,8 @@ private fun FeaturedPlayButton(
modifier = modifier.size(44.dp), modifier = modifier.size(44.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
IconButton( MillenniumIconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = { onClick = {
if (isPlaying) { if (isPlaying) {
navigationCommands.navigateTo(Routes.Playlist(item.data.id)) navigationCommands.navigateTo(Routes.Playlist(item.data.id))
@ -357,7 +340,7 @@ private fun FeaturedPlayButton(
}, },
enabled = enabled enabled = enabled
) { ) {
Icon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = null, contentDescription = null,
tint = Color.White tint = Color.White
@ -377,20 +360,20 @@ private fun FeaturedPlayButton(
modifier = modifier.size(44.dp), modifier = modifier.size(44.dp),
contentAlignment = Alignment.BottomEnd contentAlignment = Alignment.BottomEnd
) { ) {
IconButton( MillenniumIconButton(
style = MillenniumIconButtonStyle.Quiet,
onClick = { onClick = {
scope.launch { scope.launch {
audioPlayerQueue.load( audioPlayerQueue.load(
entries = listOf(queueEntry), entries = listOf(queueEntry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null, collectionEntry = null)
)
} }
}, },
enabled = enabled enabled = enabled
) { ) {
Icon( MillenniumIcon(
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = null, contentDescription = null,
tint = Color.White tint = Color.White

View File

@ -17,6 +17,10 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -35,11 +39,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.FilledIconButton
import androidx.compose.material3.FilledTonalIconButton import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Icon import com.composeunstyled.Text
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -76,8 +78,7 @@ fun LikedTracksCard(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject()) {
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
@ -92,8 +93,7 @@ fun LikedTracksCard(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) }) .clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
.width(160.dp) .width(160.dp)
.hoverable(interactionSource = interactionSource), .hoverable(interactionSource = interactionSource)) {
) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth()) { Box(modifier = Modifier.fillMaxWidth()) {
androidx.compose.foundation.Image( androidx.compose.foundation.Image(
@ -106,10 +106,8 @@ fun LikedTracksCard(
.sharedElementOrNone( .sharedElementOrNone(
key = "saved_tracks_art", key = "saved_tracks_art",
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope),
), contentScale = ContentScale.Crop)
contentScale = ContentScale.Crop,
)
Box( Box(
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp), modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
@ -120,22 +118,24 @@ fun LikedTracksCard(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
FilledTonalIconButton( MillenniumIconButton(
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } }, onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
modifier = Modifier.size(30.dp) modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Secondary
) { ) {
Icon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue", contentDescription = "Add to queue",
modifier = Modifier.size(16.dp) modifier = Modifier.size(16.dp)
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
FilledIconButton( MillenniumIconButton(
onClick = { scope.launch { playbackHelper.playSavedTracks() } }, onClick = { scope.launch { playbackHelper.playSavedTracks() } },
modifier = Modifier.size(30.dp) modifier = Modifier.size(30.dp),
style = MillenniumIconButtonStyle.Primary
) { ) {
Icon( MillenniumIcon(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPauseCircle Iconsax.IconsaxPauseCircle
} else Iconsax.Play, } else Iconsax.Play,
@ -152,18 +152,16 @@ fun LikedTracksCard(
) { ) {
Text( Text(
text = "Liked Tracks", text = "Liked Tracks",
style = MaterialTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Your saved songs", text = "Your saved songs",
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
} }
} }
} }

View File

@ -40,8 +40,7 @@ fun PlaylistCard(
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject(),
playbackHelper: CollectionPlaybackHelper = koinInject(), playbackHelper: CollectionPlaybackHelper = koinInject(),
navigationCommands: NavigationCommands = koinInject(), navigationCommands: NavigationCommands = koinInject(),
remotePlaybackController: RemotePlaybackController = koinInject(), remotePlaybackController: RemotePlaybackController = koinInject()) {
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle() val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
@ -58,17 +57,14 @@ fun PlaylistCard(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.Playlist, RemoteCollectionType.Playlist,
playlist.id, playlist.id,
playlist.title, playlist.title)
)
}, },
onAddToQueue = { onAddToQueue = {
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.Playlist, RemoteCollectionType.Playlist,
playlist.id, playlist.id,
playlist.title, playlist.title)
)
}, },
modifier = modifier, modifier = modifier,
sharedElementKey = "playlist_art_${playlist.id}", sharedElementKey = "playlist_art_${playlist.id}")
)
} }

View File

@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
fun Modifier.sharedElementOrNone( fun Modifier.sharedElementOrNone(
key: String?, key: String?,
sharedTransitionScope: SharedTransitionScope?, sharedTransitionScope: SharedTransitionScope?,
animatedVisibilityScope: AnimatedVisibilityScope?, animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
): Modifier {
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
with(sharedTransitionScope) { with(sharedTransitionScope) {
return this@sharedElementOrNone.sharedElement( return this@sharedElementOrNone.sharedElement(
sharedContentState = rememberSharedContentState(key = key), sharedContentState = rememberSharedContentState(key = key),
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope)
)
} }
} }

View File

@ -17,33 +17,33 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIcon
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope import androidx.compose.foundation.layout.BoxScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.RowScope
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.LocalTextStyle import com.composeunstyled.LocalTextStyle
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.OutlineButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.copyPadding
import dev.krtirtho.spotube.core.ui.base.copyShape
/** /**
* The shared navigation item used by every sidebar in the app (the main app sidebar * The shared navigation item used by every sidebar in the app (the main app sidebar
@ -60,64 +60,57 @@ fun SidebarItem(
onClick: () -> Unit, onClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
expanded: Boolean = true, expanded: Boolean = true,
badge: (@Composable BoxScope.() -> Unit)? = null, badge: (@Composable BoxScope.() -> Unit)? = null) {
) { val itemContent: @Composable () -> Unit = {
val itemContent: @Composable RowScope.() -> Unit = { Row(
Box(modifier = Modifier.size(16.dp)) { modifier = Modifier.fillMaxWidth(),
Icon( verticalAlignment = Alignment.CenterVertically) {
imageVector = icon, Box(modifier = Modifier.size(16.dp)) {
contentDescription = label, MillenniumIcon(
tint = if (selected) { imageVector = icon,
MaterialTheme.colorScheme.primary contentDescription = label,
} else { tint = if (selected) {
MaterialTheme.colorScheme.onSurfaceVariant AppTheme.colors.primary
},
)
badge?.invoke(this)
}
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = label,
maxLines = 1,
softWrap = false,
style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelMedium.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant AppTheme.colors.onSurfaceVariant
} })
badge?.invoke(this)
}
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text(
text = label,
singleLine = true,
style = LocalTextStyle.current.copy(
fontSize = AppTheme.typography.labelMedium.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) {
AppTheme.colors.onSecondaryContainer
} else {
AppTheme.colors.onSurfaceVariant
}
)
) )
) }
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
} }
Spacer(modifier = if (expanded) Modifier.weight(1f) else Modifier)
} }
val buttonModifier = modifier val buttonModifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 7.dp, vertical = 2.dp) .padding(horizontal = 7.dp, vertical = 2.dp)
val contentPadding = PaddingValues(horizontal = 5.dp)
if (selected) { if (selected) {
SecondaryButton( MillenniumButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.secondary style = MillenniumButtonStyle.Secondary,
.copyPadding(contentPadding) content = itemContent)
.copyShape(RoundedCornerShape(5.dp)),
content = itemContent,
)
} else { } else {
OutlineButton( MillenniumButton(
onClick = onClick, onClick = onClick,
modifier = buttonModifier, modifier = buttonModifier,
theme = LocalBaseUITheme.current.buttons.outline style = MillenniumButtonStyle.Quiet,
.copyPadding(contentPadding) content = itemContent)
.copyShape(RoundedCornerShape(5.dp)),
hoverOnly = true,
content = itemContent,
)
} }
} }
@ -127,16 +120,14 @@ fun SidebarItem(
@Composable @Composable
fun SidebarSectionLabel( fun SidebarSectionLabel(
text: String, text: String,
expanded: Boolean = true, expanded: Boolean = true) {
) {
AnimatedVisibility(visible = expanded) { AnimatedVisibility(visible = expanded) {
Text( Text(
text = text.uppercase(), text = text.uppercase(),
style = MaterialTheme.typography.labelSmall, style = AppTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 24.dp, vertical = 8.dp), .padding(horizontal = 24.dp, vertical = 8.dp))
)
} }
} }

View File

@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.material3.Card
import androidx.compose.material3.FilledIconButton import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.FilledTonalIconButton import com.composeunstyled.Text
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -54,8 +51,7 @@ import org.koin.compose.koinInject
fun TrackCard( fun TrackCard(
track: MetadataTrack, track: MetadataTrack,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
audioPlayerQueue: AudioPlayerQueue = koinInject(), audioPlayerQueue: AudioPlayerQueue = koinInject()) {
) {
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "") val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
@ -69,8 +65,7 @@ fun TrackCard(
entries = listOf(queueEntry), entries = listOf(queueEntry),
autoPlay = true, autoPlay = true,
startPosition = 0, startPosition = 0,
collectionEntry = null, collectionEntry = null)
)
} }
}, },
onAddToQueue = { onAddToQueue = {

View File

@ -17,6 +17,7 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import androidx.compose.animation.AnimatedContent import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut import androidx.compose.animation.fadeOut
@ -36,6 +37,7 @@ import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
@ -44,12 +46,20 @@ import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.foundation.text.input.rememberTextFieldState
import androidx.compose.material3.HorizontalDivider import androidx.compose.ui.state.ToggleableState
import androidx.compose.material3.Icon import org.spotube.millennium.composables.MillenniumCard
import androidx.compose.material3.MaterialTheme import org.spotube.millennium.composables.MillenniumCheckbox
import dev.krtirtho.spotube.core.ui.base.BaseScaffold import org.spotube.millennium.composables.MillenniumIcon
import androidx.compose.material3.Text import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumListTile
import org.spotube.millennium.composables.MillenniumSeparator
import org.spotube.millennium.composables.MillenniumTextField
import org.spotube.millennium.composables.MillenniumTriStateCheckbox
import dev.krtirtho.spotube.core.ui.theming.AppTheme
import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
@ -83,16 +93,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
import dev.krtirtho.spotube.core.ui.base.Card
import dev.krtirtho.spotube.core.ui.base.CheckBox
import dev.krtirtho.spotube.core.ui.base.CheckBoxState
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.TextField
import dev.krtirtho.spotube.core.ui.base.highlight
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
@ -122,7 +122,7 @@ enum class TrackSortOption(val label: String) {
Duration("Duration"), Duration("Duration"),
} }
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun TrackList( fun TrackList(
tracks: List<MetadataTrack> = emptyList(), tracks: List<MetadataTrack> = emptyList(),
@ -153,9 +153,9 @@ fun TrackList(
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current), contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
simplified: Boolean = false, simplified: Boolean = false,
scrollable: Boolean = true, scrollable: Boolean = true,
state: LazyListState? = null, state: LazyListState? = null) {
) { val filterState = rememberTextFieldState()
var filterQuery by rememberSaveable { mutableStateOf("") } val filterQuery = filterState.text.toString()
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) } var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) } var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
var isSelectionMode by rememberSaveable { mutableStateOf(false) } var isSelectionMode by rememberSaveable { mutableStateOf(false) }
@ -216,11 +216,11 @@ fun TrackList(
val useDropdownForOptions = isDesktop && !isCompact val useDropdownForOptions = isDesktop && !isCompact
val trackCardContent: @Composable () -> Unit = { val trackCardContent: @Composable () -> Unit = {
Card( MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp),
) { contentPadding = PaddingValues(0.dp)) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
@ -228,9 +228,7 @@ fun TrackList(
) { ) {
visibleTracks.forEachIndexed { displayedIndex, track -> visibleTracks.forEachIndexed { displayedIndex, track ->
if (displayedIndex > 0) { if (displayedIndex > 0) {
HorizontalDivider( MillenniumSeparator()
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
TrackListRow( TrackListRow(
index = displayedIndex + 1, index = displayedIndex + 1,
@ -284,38 +282,31 @@ fun TrackList(
onShowOptionsClick = { selectedTrackForOptions = track }, onShowOptionsClick = { selectedTrackForOptions = track },
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
onArtistsOverflowClick = { onArtistsOverflowClick(track) }, onArtistsOverflowClick = { onArtistsOverflowClick(track) })
)
} }
if (isLoading && tracks.isEmpty()) { if (isLoading && tracks.isEmpty()) {
repeat(ShimmerRowCount) { shimmerIndex -> repeat(ShimmerRowCount) { shimmerIndex ->
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) { if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
HorizontalDivider( MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp))
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
ShimmerTrackListRow( ShimmerTrackListRow(
index = shimmerIndex + 1, index = shimmerIndex + 1,
showIndex = showIndex, showIndex = showIndex,
showAlbum = showAlbum, showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions, useDropdownForOptions = useDropdownForOptions)
)
} }
} }
if (error != null) { if (error != null) {
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
HorizontalDivider( MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp))
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
TrackListFeedbackRow( TrackListFeedbackRow(
message = error, message = error,
isError = true, isError = true)
)
} }
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) { if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
@ -324,17 +315,14 @@ fun TrackList(
if (isLoadingNextPage) { if (isLoadingNextPage) {
if (visibleTracks.isNotEmpty()) { if (visibleTracks.isNotEmpty()) {
HorizontalDivider( MillenniumSeparator(
modifier = Modifier.padding(horizontal = 8.dp), modifier = Modifier.padding(horizontal = 8.dp))
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
)
} }
ShimmerTrackListRow( ShimmerTrackListRow(
index = visibleTracks.size + 1, index = visibleTracks.size + 1,
showIndex = showIndex, showIndex = showIndex,
showAlbum = showAlbum, showAlbum = showAlbum,
useDropdownForOptions = useDropdownForOptions, useDropdownForOptions = useDropdownForOptions)
)
} }
} }
} }
@ -345,19 +333,18 @@ fun TrackList(
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp) modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
.heightIn(max = 60.dp), .heightIn(max = 60.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End), horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) {
) {
if (showIndex || isSelectionMode) { if (showIndex || isSelectionMode) {
val allSelected = val allSelected =
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
val anySelected = selectedTrackIds.isNotEmpty() val anySelected = selectedTrackIds.isNotEmpty()
val headerState = when { val headerState = when {
allSelected -> CheckBoxState.SELECTED allSelected -> ToggleableState.On
anySelected -> CheckBoxState.INDETERMINATE anySelected -> ToggleableState.Indeterminate
else -> CheckBoxState.UNSELECTED else -> ToggleableState.Off
} }
CheckBox( MillenniumTriStateCheckbox(
state = headerState, value = headerState,
onClick = { onClick = {
if (allSelected) { if (allSelected) {
isSelectionMode = false isSelectionMode = false
@ -367,58 +354,45 @@ fun TrackList(
selectedTrackIds = visibleTracks.map { it.id }.toSet() selectedTrackIds = visibleTracks.map { it.id }.toSet()
} }
}, },
) label = "")
} }
Box( Box(
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
) { ) {
TextField( MillenniumTextField(
value = filterQuery, state = filterState,
onValueChange = { filterQuery = it }, label = "",
placeholder = "Filter",
modifier = Modifier modifier = Modifier
.widthIn(max = 400.dp) .widthIn(max = 400.dp)
.align(Alignment.CenterEnd), .align(Alignment.CenterEnd))
placeholder = { TextWithShimmer("Filter") },
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Filter",
)
},
singleLine = true,
)
} }
ButtonGroup { Row(verticalAlignment = Alignment.CenterVertically) {
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = TrackSortOption.entries.map { option -> items = TrackSortOption.entries.map { option ->
AdaptiveMenuItem( AdaptiveMenuItem(
label = option.label, label = option.label,
onClick = { sortBy = option }, onClick = { sortBy = option },
selected = sortBy == option, selected = sortBy == option)
)
}, },
trigger = { onClick -> trigger = { onClick ->
GroupIconButton( MillenniumIconButton(
onClick = onClick, onClick = onClick,
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = Iconsax.IconsaxSort, imageVector = Iconsax.IconsaxSort,
contentDescription = "Sort", contentDescription = "Sort")
)
} }
}, })
)
} }
ButtonGroupDivider() MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
Box( Box(
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
val targetTracks = if (selectedTrackIds.isNotEmpty()) { val targetTracks = if (selectedTrackIds.isNotEmpty()) {
visibleTracks.filter { selectedTrackIds.contains(it.id) } visibleTracks.filter { selectedTrackIds.contains(it.id) }
} else { } else {
@ -433,54 +407,42 @@ fun TrackList(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,
label = if (isAll) "Download All" else "Download $trackCount", label = if (isAll) "Download All" else "Download $trackCount",
onClick = { onBulkDownload(targetTracks) }, onClick = { onBulkDownload(targetTracks) }))
),
)
if (!isJamGuest) { if (!isJamGuest) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue", label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
onClick = { onBulkAddToQueue(targetTracks) }, onClick = { onBulkAddToQueue(targetTracks) }))
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = if (isAll) "Play All Next" else "Play $trackCount Next", label = if (isAll) "Play All Next" else "Play $trackCount Next",
onClick = { onBulkPlayNext(targetTracks) }, onClick = { onBulkPlayNext(targetTracks) }))
),
)
} }
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist, icon = Iconsax.IconsaxMusicPlaylist,
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist", label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
onClick = { onBulkAddToPlaylist(targetTracks) }, onClick = { onBulkAddToPlaylist(targetTracks) }))
),
)
} + if (isInJam) { } + if (isInJam) {
listOf( listOf(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam", label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
onClick = { onBulkAddToJam(targetTracks) }, onClick = { onBulkAddToJam(targetTracks) }))
),
)
} else { } else {
emptyList() emptyList()
}, },
trigger = { onClick -> trigger = { onClick ->
GroupIconButton( MillenniumIconButton(
onClick = onClick, onClick = onClick,
) { style = MillenniumIconButtonStyle.Quiet) {
Icon( MillenniumIcon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Bulk actions", contentDescription = "Bulk actions")
)
} }
}, })
)
} }
} }
} }
@ -495,8 +457,7 @@ fun TrackList(
.align(Alignment.TopCenter) .align(Alignment.TopCenter)
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
contentPadding = contentPadding, contentPadding = contentPadding,
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
) {
if (headerContent != null) { if (headerContent != null) {
item { item {
headerContent() headerContent()
@ -525,8 +486,7 @@ fun TrackList(
}, },
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest)
)
} }
} }
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd)) VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
@ -536,8 +496,7 @@ fun TrackList(
modifier = modifier modifier = modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp), .padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
) {
if (headerContent != null) { if (headerContent != null) {
headerContent() headerContent()
} }
@ -560,8 +519,7 @@ fun TrackList(
onTrackOptionsAction(track, action) onTrackOptionsAction(track, action)
selectedTrackForOptions = null selectedTrackForOptions = null
}, },
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } })
)
} }
} }
} }
@ -590,235 +548,198 @@ private fun TrackListRow(
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onArtistsOverflowClick: () -> Unit, onArtistsOverflowClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
) {
val rowTheme = LocalBaseUITheme.current.listRowTile
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
val artworkInteractionSource = remember { MutableInteractionSource() } val artworkInteractionSource = remember { MutableInteractionSource() }
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState() val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
val rowInteractionSource = remember { MutableInteractionSource() } val rowInteractionSource = remember { MutableInteractionSource() }
val isRowHovered by rowInteractionSource.collectIsHoveredAsState() val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
val rowBackgroundColor = when { val rowBackgroundColor = when {
isSelected -> rowTheme.background.selected isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f)
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
isCurrentTrack -> rowTheme.background.focused isCurrentTrack -> AppTheme.colors.surfaceVariant
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f)) trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
else -> rowTheme.background.normal else -> Color.Transparent
} }
val highlightColor = Color.White.copy(
alpha = if (isLight) 0.9f else 0.06f
)
Row( MillenniumListTile(
modifier = modifier modifier = modifier,
.fillMaxWidth() selected = isSelected || isCurrentTrack,
.hoverable(rowInteractionSource) onClick = onTrackClick,
.combinedClickable( title = {
onClick = onTrackClick,
onLongClick = onLongClick,
)
.background(rowBackgroundColor)
.then(
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
Modifier.highlight(highlightColor)
} else {
Modifier
}
)
.clip(MaterialTheme.shapes.small)
.padding(horizontal = 8.dp, vertical = 6.dp)
.padding(end = 6.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp),
) {
if (isSelectionMode) {
CheckBox(
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
onClick = { onSelectionToggle(!isSelected) },
modifier = Modifier.shimmerApply()
)
} else if (showIndex) {
Box(
modifier = Modifier.width(30.dp),
contentAlignment = Alignment.Center,
) {
AnimatedContent(
targetState = isRowHovered,
transitionSpec = {
fadeIn() togetherWith fadeOut()
},
label = "index-checkbox",
) { hovered ->
if (hovered) {
CheckBox(
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
onClick = { onSelectionToggle(!isSelected) },
modifier = Modifier.shimmerApply()
)
} else {
TextWithShimmer(
text = index.toString(),
style = MaterialTheme.typography.labelMedium,
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant,
)
}
}
}
}
Box(
modifier = Modifier
.size(44.dp)
.clip(MaterialTheme.shapes.small)
.hoverable(interactionSource = artworkInteractionSource),
contentAlignment = Alignment.Center,
) {
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
val platformContext = LocalPlatformContext.current
val imageRequest = remember(imageUrl) {
ImageRequest.Builder(platformContext)
.data(imageUrl)
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
.crossfade(false) // Crucial for scroll performance
.build()
}
AsyncImage(
model = imageRequest,
contentDescription = track.title,
contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().shimmerApply(),
)
if (isArtworkHovered || isCurrentTrack) {
Box(
modifier = Modifier
.fillMaxSize()
.background(Color.Black.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center,
) {
Icon(
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(18.dp).shimmerApply(),
)
}
}
}
Column(modifier = Modifier.weight(1.2f)) {
TextWithShimmer( TextWithShimmer(
text = track.title, text = track.title,
style = MaterialTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
) },
subtitle = {
ArtistChips( ArtistChips(
artists = track.artists, artists = track.artists,
onArtistClick = onArtistClick, onArtistClick = onArtistClick,
onArtistsOverflowClick = onArtistsOverflowClick, onArtistsOverflowClick = onArtistsOverflowClick)
) },
} leading = {
if (showAlbum && track.album != null) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val colorScheme = MaterialTheme.colorScheme
Row( Row(
modifier = Modifier.weight(1f) verticalAlignment = Alignment.CenterVertically,
) { horizontalArrangement = Arrangement.spacedBy(10.dp)) {
TextWithShimmer( if (isSelectionMode) {
text = track.album!!.title, MillenniumCheckbox(
style = MaterialTheme.typography.bodyMedium, checked = isSelected,
color = MaterialTheme.colorScheme.primary, onCheckedChange = { onSelectionToggle(it) },
maxLines = 1, label = "",
overflow = TextOverflow.Ellipsis, modifier = Modifier.shimmerApply())
modifier = Modifier } else if (showIndex) {
.hoverable(interactionSource) Box(
.clickable( modifier = Modifier.width(30.dp),
indication = null, contentAlignment = Alignment.Center) {
interactionSource = interactionSource AnimatedContent(
) { onAlbumClick(track.album!!) } targetState = isRowHovered,
// Hide ripple and show underline on hover/click transitionSpec = { fadeIn() togetherWith fadeOut() },
.then( label = "index-checkbox") { hovered ->
if (isHovered || isPressed) { if (hovered) {
Modifier.drawWithCache { MillenniumCheckbox(
val underlineHeight = 1.dp.toPx() checked = isSelected,
onDrawWithContent { onCheckedChange = { onSelectionToggle(it) },
drawContent() label = "",
drawLine( modifier = Modifier.shimmerApply())
color = colorScheme.primary,
start = Offset(0f, size.height - underlineHeight),
end = Offset(size.width, size.height - underlineHeight),
strokeWidth = underlineHeight,
)
}
}
} else { } else {
Modifier TextWithShimmer(
text = index.toString(),
style = AppTheme.typography.labelMedium,
textAlign = TextAlign.Center,
color = AppTheme.colors.onSurfaceVariant)
} }
) }
) }
} }
}
TextWithShimmer( Box(
text = track.durationMs.toDurationString(), modifier = Modifier
style = MaterialTheme.typography.bodySmall, .size(44.dp)
textAlign = TextAlign.End, .clip(AppTheme.shapes.small)
color = MaterialTheme.colorScheme.onSurfaceVariant, .hoverable(interactionSource = artworkInteractionSource),
modifier = Modifier.width(56.dp), contentAlignment = Alignment.Center) {
) val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
val platformContext = LocalPlatformContext.current
val imageRequest = remember(imageUrl) {
ImageRequest.Builder(platformContext)
.data(imageUrl)
.size(128)
.crossfade(false)
.build()
}
if (useDropdownForOptions) { AsyncImage(
TrackOptions( model = imageRequest,
track = track, contentDescription = track.title,
state = trackOptionsState, contentScale = ContentScale.Crop,
onAction = onTrackOptionsAction, modifier = Modifier.fillMaxSize().shimmerApply())
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam, if (isArtworkHovered || isCurrentTrack) {
isJamGuest = isJamGuest, Box(
) modifier = Modifier
} else { .fillMaxSize()
GhostIconButton(onClick = onShowOptionsClick) { .background(Color.Black.copy(alpha = 0.10f)),
Icon( contentAlignment = Alignment.Center) {
imageVector = Iconsax.Iconsax3DotsMore, MillenniumIcon(
contentDescription = "Track options", imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
) contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
tint = Color.White,
modifier = Modifier.size(18.dp).shimmerApply())
}
}
}
} }
} },
} trailing = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (showAlbum && track.album != null) {
val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState()
val colorScheme = AppTheme.colors
TextWithShimmer(
text = track.album!!.title,
style = AppTheme.typography.bodyMedium,
color = AppTheme.colors.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier
.widthIn(max = 220.dp)
.hoverable(interactionSource)
.clickable(
indication = null,
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
.then(
if (isHovered || isPressed) {
Modifier.drawWithCache {
val underlineHeight = 1.dp.toPx()
onDrawWithContent {
drawContent()
drawLine(
color = colorScheme.primary,
start = Offset(0f, size.height - underlineHeight),
end = Offset(size.width, size.height - underlineHeight),
strokeWidth = underlineHeight)
}
}
} else {
Modifier
}))
}
TextWithShimmer(
text = track.durationMs.toDurationString(),
style = AppTheme.typography.bodySmall,
textAlign = TextAlign.End,
color = AppTheme.colors.onSurfaceVariant,
modifier = Modifier.width(56.dp))
if (useDropdownForOptions) {
TrackOptions(
track = track,
state = trackOptionsState,
onAction = onTrackOptionsAction,
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam,
isJamGuest = isJamGuest)
} else {
MillenniumIconButton(
onClick = onShowOptionsClick,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options")
}
}
}
})
} }
@Composable @Composable
private fun ArtistChips( private fun ArtistChips(
artists: List<MetadataArtist.Basic>, artists: List<MetadataArtist.Basic>,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onArtistsOverflowClick: () -> Unit, onArtistsOverflowClick: () -> Unit) {
) {
val visibleArtists = artists.take(2) val visibleArtists = artists.take(2)
Row( Row(
horizontalArrangement = Arrangement.spacedBy(6.dp), horizontalArrangement = Arrangement.spacedBy(6.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth()) {
) {
visibleArtists.forEachIndexed { index, artist -> visibleArtists.forEachIndexed { index, artist ->
Row( Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically) {
) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val isHovered by interactionSource.collectIsHoveredAsState() val isHovered by interactionSource.collectIsHoveredAsState()
val isPressed by interactionSource.collectIsPressedAsState() val isPressed by interactionSource.collectIsPressedAsState()
TextWithShimmer( TextWithShimmer(
text = artist.name, text = artist.name,
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary, color = AppTheme.colors.primary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null, textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
@ -827,13 +748,12 @@ private fun ArtistChips(
indication = null, indication = null,
interactionSource = interactionSource interactionSource = interactionSource
) { onArtistClick(artist) } ) { onArtistClick(artist) }
, )
)
if (index < visibleArtists.size - 1) if (index < visibleArtists.size - 1)
Text( Text(
",", ",",
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
style = MaterialTheme.typography.bodySmall style = AppTheme.typography.bodySmall
) )
} }
} }
@ -842,12 +762,11 @@ private fun ArtistChips(
if (remaining > 0) { if (remaining > 0) {
TextWithShimmer( TextWithShimmer(
text = "+$remaining more", text = "+$remaining more",
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.primary, color = AppTheme.colors.primary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.clickable(onClick = onArtistsOverflowClick), modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
)
} }
} }
} }
@ -857,8 +776,7 @@ private fun ShimmerTrackListRow(
index: Int, index: Int,
showIndex: Boolean, showIndex: Boolean,
showAlbum: Boolean, showAlbum: Boolean,
useDropdownForOptions: Boolean, useDropdownForOptions: Boolean) {
) {
val dummyTrack = remember(index) { dummyTrackForShimmer(index) } val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
SkeletonTree(true) { SkeletonTree(true) {
TrackListRow( TrackListRow(
@ -881,8 +799,7 @@ private fun ShimmerTrackListRow(
onShowOptionsClick = {}, onShowOptionsClick = {},
onArtistClick = {}, onArtistClick = {},
onAlbumClick = {}, onAlbumClick = {},
onArtistsOverflowClick = {}, onArtistsOverflowClick = {})
)
} }
} }
@ -891,8 +808,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
id = "shimmer_artist_$index", id = "shimmer_artist_$index",
name = "Artist Name", name = "Artist Name",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null, externalUri = null)
)
return MetadataTrack( return MetadataTrack(
id = "shimmer_track_$index", id = "shimmer_track_$index",
title = "Track Title Here", title = "Track Title Here",
@ -913,29 +829,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
thumbnails = emptyList(), thumbnails = emptyList(),
albumType = MetadataAlbumType.Album, albumType = MetadataAlbumType.Album,
artists = listOf(artist), artists = listOf(artist),
externalUri = null, externalUri = null),
),
thumbnails = null, thumbnails = null,
explicit = false, explicit = false,
popularity = 0, popularity = 0,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null)
)
} }
@Composable @Composable
private fun TrackListFeedbackRow( private fun TrackListFeedbackRow(
message: String, message: String,
isError: Boolean = false, isError: Boolean = false) {
) {
TextWithShimmer( TextWithShimmer(
text = message, text = message,
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant, color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 12.dp), .padding(horizontal = 8.dp, vertical = 12.dp))
)
} }
private fun Long.toDurationString(): String { private fun Long.toDurationString(): String {
@ -951,21 +863,17 @@ fun previewTracks(): List<MetadataTrack> {
id = "artist_1", id = "artist_1",
name = "Aria Vale", name = "Aria Vale",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null, externalUri = null),
),
MetadataArtist.Basic( MetadataArtist.Basic(
id = "artist_2", id = "artist_2",
name = "Neon Harbor", name = "Neon Harbor",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null, externalUri = null),
),
MetadataArtist.Basic( MetadataArtist.Basic(
id = "artist_3", id = "artist_3",
name = "Echo Drift", name = "Echo Drift",
thumbnails = emptyList(), thumbnails = emptyList(),
externalUri = null, externalUri = null))
),
)
val album = MetadataAlbum.Detailed( val album = MetadataAlbum.Detailed(
releaseDate = null, releaseDate = null,
@ -978,13 +886,11 @@ fun previewTracks(): List<MetadataTrack> {
Thumbnail( Thumbnail(
url = "https://picsum.photos/120", url = "https://picsum.photos/120",
width = 120, width = 120,
height = 120, height = 120)
)
), ),
albumType = MetadataAlbumType.Album, albumType = MetadataAlbumType.Album,
artists = artists.take(2), artists = artists.take(2),
externalUri = null, externalUri = null)
)
return listOf( return listOf(
MetadataTrack( MetadataTrack(
@ -999,8 +905,7 @@ fun previewTracks(): List<MetadataTrack> {
explicit = false, explicit = false,
popularity = 92, popularity = 92,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null),
),
MetadataTrack( MetadataTrack(
id = "track_2", id = "track_2",
title = "Static Horizon", title = "Static Horizon",
@ -1013,8 +918,7 @@ fun previewTracks(): List<MetadataTrack> {
popularity = 80, popularity = 80,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null,
thumbnails = null, thumbnails = null),
),
MetadataTrack( MetadataTrack(
id = "track_3", id = "track_3",
title = "Last Train Echo", title = "Last Train Echo",
@ -1027,19 +931,16 @@ fun previewTracks(): List<MetadataTrack> {
popularity = 77, popularity = 77,
isrcCode = null, isrcCode = null,
externalUri = null, externalUri = null,
thumbnails = null, thumbnails = null))
),
)
} }
@Composable @Composable
@Preview @Preview
private fun TrackListPreview() { private fun TrackListPreview() {
BaseScaffold { Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
TrackList( TrackList(
tracks = previewTracks(), tracks = previewTracks(),
hasMore = true, hasMore = true,
isLoadingNextPage = true, isLoadingNextPage = true)
)
} }
} }

View File

@ -17,6 +17,13 @@
package dev.krtirtho.spotube.core.ui.component package dev.krtirtho.spotube.core.ui.component
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumModalBottomSheet
import com.composeunstyled.SheetDetent
import com.composeunstyled.rememberModalBottomSheetState
import androidx.compose.foundation.clickable import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@ -24,12 +31,11 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.MaterialTheme import com.composeunstyled.Text
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
import dev.krtirtho.spotube.core.ui.base.IconButton
import dev.krtirtho.spotube.core.ui.misc.shimmerApply import dev.krtirtho.spotube.core.ui.misc.shimmerApply
import dev.krtirtho.spotube.resources.iconsax.Iconsax import dev.krtirtho.spotube.resources.iconsax.Iconsax
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
@ -72,8 +76,7 @@ data class TrackOptionsState(
val isInQueue: Boolean = false, val isInQueue: Boolean = false,
val isCurrentlyPlaying: Boolean = false, val isCurrentlyPlaying: Boolean = false,
val isFavorite: Boolean = false, val isFavorite: Boolean = false,
val isBlacklisted: Boolean = false, val isBlacklisted: Boolean = false)
)
data class TrackOptionsContext( data class TrackOptionsContext(
val currentTrackId: String? = null, val currentTrackId: String? = null,
@ -81,15 +84,13 @@ data class TrackOptionsContext(
val savedTrackIds: Set<String> = emptySet(), val savedTrackIds: Set<String> = emptySet(),
val blacklistedTrackIds: Set<String> = emptySet(), val blacklistedTrackIds: Set<String> = emptySet(),
val blacklistedArtistIds: Set<String> = emptySet(), val blacklistedArtistIds: Set<String> = emptySet(),
val forceFavorite: Boolean = false, val forceFavorite: Boolean = false) {
) {
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState( fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
isInQueue = track.id in queueTrackIds, isInQueue = track.id in queueTrackIds,
isCurrentlyPlaying = track.id == currentTrackId, isCurrentlyPlaying = track.id == currentTrackId,
isFavorite = forceFavorite || track.id in savedTrackIds, isFavorite = forceFavorite || track.id in savedTrackIds,
isBlacklisted = track.id in blacklistedTrackIds || isBlacklisted = track.id in blacklistedTrackIds ||
track.artists.any { it.id in blacklistedArtistIds }, track.artists.any { it.id in blacklistedArtistIds })
)
} }
@Composable @Composable
@ -100,8 +101,7 @@ fun TrackOptions(
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false, isJamGuest: Boolean = false) {
) {
AdaptiveDropdownBottomSheet( AdaptiveDropdownBottomSheet(
items = buildTrackMenuItems( items = buildTrackMenuItems(
track = track, track = track,
@ -109,11 +109,12 @@ fun TrackOptions(
onAction = onAction, onAction = onAction,
onAlbumClick = onAlbumClick, onAlbumClick = onAlbumClick,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest),
),
trigger = { onClick -> trigger = { onClick ->
GhostIconButton(onClick = onClick) { MillenniumIconButton(
Icon( onClick = onClick,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options", contentDescription = "Track options",
modifier = Modifier.shimmerApply() modifier = Modifier.shimmerApply()
@ -122,11 +123,9 @@ fun TrackOptions(
}, },
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet, headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
header = { TrackOptionsSheetHeader(track = track) }, header = { TrackOptionsSheetHeader(track = track) },
modifier = modifier, modifier = modifier)
)
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun TrackOptionsBottomSheet( fun TrackOptionsBottomSheet(
track: MetadataTrack, track: MetadataTrack,
@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false, isJamGuest: Boolean = false) {
) { val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
ModalBottomSheet(onDismissRequest = onDismiss) { LaunchedEffect(sheetState.currentDetent) {
if (sheetState.currentDetent == SheetDetent.Hidden) {
onDismiss()
}
}
MillenniumModalBottomSheet(state = sheetState) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
TrackOptionsSheetHeader(track = track) TrackOptionsSheetHeader(track = track)
@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 8.dp, vertical = 8.dp), .padding(horizontal = 8.dp, vertical = 8.dp),
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp)) {
) {
buildTrackMenuItems( buildTrackMenuItems(
track = track, track = track,
state = state, state = state,
@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet(
onDismiss() onDismiss()
}, },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest).forEach { item ->
).forEach { item ->
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clickable( .clickable(
enabled = item.enabled, enabled = item.enabled,
onClick = item.onClick, onClick = item.onClick)
)
.padding(horizontal = 12.dp, vertical = 14.dp), .padding(horizontal = 12.dp, vertical = 14.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) {
item.icon?.let { icon -> item.icon?.let { icon ->
Icon( MillenniumIcon(
imageVector = icon, imageVector = icon,
contentDescription = null, contentDescription = null,
modifier = Modifier.size(22.dp), modifier = Modifier.size(22.dp),
tint = if (item.enabled) { tint = if (item.enabled) {
MaterialTheme.colorScheme.onSurface AppTheme.colors.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) AppTheme.colors.onSurface.copy(alpha = 0.38f)
}, })
)
} }
Text( Text(
text = item.label, text = item.label,
style = MaterialTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge,
color = if (item.enabled) { color = if (item.enabled) {
MaterialTheme.colorScheme.onSurface AppTheme.colors.onSurface
} else { } else {
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f) AppTheme.colors.onSurface.copy(alpha = 0.38f)
}, })
)
} }
} }
} }
@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp), .padding(horizontal = 16.dp, vertical = 12.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp)) {
) {
AsyncImage( AsyncImage(
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url, model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
contentDescription = null, contentDescription = null,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier modifier = Modifier
.size(56.dp) .size(56.dp)
.clip(MaterialTheme.shapes.small), .clip(AppTheme.shapes.small))
)
Column(modifier = Modifier.weight(1f)) { Column(modifier = Modifier.weight(1f)) {
Text( Text(
text = track.title, text = track.title,
style = MaterialTheme.typography.titleMedium, style = AppTheme.typography.titleMedium,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
Text( Text(
text = track.artists.joinToString(", ") { it.name }, text = track.artists.joinToString(", ") { it.name },
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
track.album?.let { album -> track.album?.let { album ->
Text( Text(
text = album.title, text = album.title,
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
} }
} }
} }
@ -251,25 +244,20 @@ private fun buildTrackMenuItems(
onAction: (TrackOptionsAction) -> Unit, onAction: (TrackOptionsAction) -> Unit,
onAlbumClick: () -> Unit, onAlbumClick: () -> Unit,
isInJam: Boolean = false, isInJam: Boolean = false,
isJamGuest: Boolean = false, isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
): List<AdaptiveMenuItem> = buildList {
if (isInJam) { if (isInJam) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = "Add to Jam", label = "Add to Jam",
onClick = { onAction(TrackOptionsAction.AddToJam) }, onClick = { onAction(TrackOptionsAction.AddToJam) }))
),
)
} }
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicCircle, icon = Iconsax.IconsaxMusicCircle,
label = "Start radio", label = "Start radio",
onClick = { onAction(TrackOptionsAction.StartRadio) }, onClick = { onAction(TrackOptionsAction.StartRadio) }))
),
)
// A guest's queue is the shared jam queue — mutating it locally is not // A guest's queue is the shared jam queue — mutating it locally is not
// allowed, so queue actions are replaced by "Add to Jam". // allowed, so queue actions are replaced by "Add to Jam".
@ -279,17 +267,13 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = "Play next", label = "Play next",
onClick = { onAction(TrackOptionsAction.PlayNext) }, onClick = { onAction(TrackOptionsAction.PlayNext) }))
),
)
} else if (state.isInQueue && !state.isCurrentlyPlaying) { } else if (state.isInQueue && !state.isCurrentlyPlaying) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxNext, icon = Iconsax.IconsaxNext,
label = "Move to next", label = "Move to next",
onClick = { onAction(TrackOptionsAction.PlayNext) }, onClick = { onAction(TrackOptionsAction.PlayNext) }))
),
)
} }
if (!state.isInQueue) { if (!state.isInQueue) {
@ -297,17 +281,13 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxAddSquare, icon = Iconsax.IconsaxAddSquare,
label = "Add to queue", label = "Add to queue",
onClick = { onAction(TrackOptionsAction.AddToQueue) }, onClick = { onAction(TrackOptionsAction.AddToQueue) }))
),
)
} else { } else {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemove, icon = Iconsax.IconsaxMusicSquareRemove,
label = "Remove from queue", label = "Remove from queue",
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }, onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }))
),
)
} }
} }
@ -315,42 +295,32 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart, icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite", label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
onClick = { onAction(TrackOptionsAction.ToggleFavorite) }, onClick = { onAction(TrackOptionsAction.ToggleFavorite) }))
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicPlaylist, icon = Iconsax.IconsaxMusicPlaylist,
label = "Add to playlist", label = "Add to playlist",
onClick = { onAction(TrackOptionsAction.AddToPlaylist) }, onClick = { onAction(TrackOptionsAction.AddToPlaylist) }))
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxDirectboxReceive, icon = Iconsax.IconsaxDirectboxReceive,
label = "Download", label = "Download",
onClick = { onAction(TrackOptionsAction.Download) }, onClick = { onAction(TrackOptionsAction.Download) }))
),
)
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxMusicSquareRemoveFilled, icon = Iconsax.IconsaxMusicSquareRemoveFilled,
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist", label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }, onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }))
),
)
if (!track.externalUri.isNullOrBlank()) { if (!track.externalUri.isNullOrBlank()) {
add( add(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxShare, icon = Iconsax.IconsaxShare,
label = "Share", label = "Share",
onClick = { onAction(TrackOptionsAction.Share) }, onClick = { onAction(TrackOptionsAction.Share) }))
),
)
} }
if (track.album != null) { if (track.album != null) {
@ -358,8 +328,6 @@ private fun buildTrackMenuItems(
AdaptiveMenuItem( AdaptiveMenuItem(
icon = Iconsax.IconsaxCd, icon = Iconsax.IconsaxCd,
label = "Go to album", label = "Go to album",
onClick = onAlbumClick, onClick = onAlbumClick))
),
)
} }
} }

View File

@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
@Composable @Composable
fun UserCard( fun UserCard(
user: MetadataUser, user: MetadataUser,
modifier: Modifier = Modifier, modifier: Modifier = Modifier) {
) {
AvatarCard( AvatarCard(
title = user.displayName ?: user.username, title = user.displayName ?: user.username,
subtitle = "User", subtitle = "User",
@ -53,7 +52,6 @@ fun UserCard(
onClick = { onClick = {
// TODO: Route to user details screen. // TODO: Route to user details screen.
}, },
modifier = modifier, modifier = modifier)
)
} }

View File

@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop
@Composable @Composable
expect fun VerticalScrollbar( expect fun VerticalScrollbar(
listState: LazyListState, listState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier)
)
fun Modifier.dragScrollable(rowState: LazyListState): Modifier = fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
if (getPlatform().isDesktop()) pointerInput(rowState) { if (getPlatform().isDesktop()) pointerInput(rowState) {

View File

@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -58,10 +58,9 @@ fun AvatarCard(
imageURL: String? = null, imageURL: String? = null,
onClick: () -> Unit = {}, onClick: () -> Unit = {},
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
isBlacklisted: Boolean = false, isBlacklisted: Boolean = false) {
) {
val backgroundColor = if (isBlacklisted) { val backgroundColor = if (isBlacklisted) {
MaterialTheme.colorScheme.error.copy(alpha = 0.1f) AppTheme.colors.error.copy(alpha = 0.1f)
} else { } else {
Color.Transparent Color.Transparent
} }
@ -71,20 +70,17 @@ fun AvatarCard(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.background(backgroundColor) .background(backgroundColor)
.clickable(onClick = { onClick.invoke() }, enabled = true) .clickable(onClick = { onClick.invoke() }, enabled = true)
.width(160.dp), .width(160.dp)) {
) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth(), .fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.clip(CircleShape) .clip(CircleShape)
.padding(12.dp), .padding(12.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
AsyncImage( AsyncImage(
model = imageURL, model = imageURL,
contentDescription = title, contentDescription = title,
@ -93,27 +89,24 @@ fun AvatarCard(
.aspectRatio(1f) .aspectRatio(1f)
.clip(CircleShape) .clip(CircleShape)
.shimmerApply(), .shimmerApply(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} }
Column( Column(
modifier = Modifier.fillMaxWidth().padding(12.dp) modifier = Modifier.fillMaxWidth().padding(12.dp)
) { ) {
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = MaterialTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth())
)
subtitle?.let { subtitle?.let {
TextWithShimmer( TextWithShimmer(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth())
)
} }
} }
} }

View File

@ -17,6 +17,10 @@
package dev.krtirtho.spotube.core.ui.component.cards package dev.krtirtho.spotube.core.ui.component.cards
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.animation.AnimatedVisibility import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.ExperimentalSharedTransitionApi import androidx.compose.animation.ExperimentalSharedTransitionApi
import androidx.compose.animation.fadeIn import androidx.compose.animation.fadeIn
@ -36,9 +40,8 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width import androidx.compose.foundation.layout.width
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
@ -50,10 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import coil3.compose.AsyncImage import coil3.compose.AsyncImage
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
@ -75,8 +74,7 @@ fun PlayableCard(
onPlay: (() -> Unit)? = null, onPlay: (() -> Unit)? = null,
onAddToQueue: (() -> Unit)? = null, onAddToQueue: (() -> Unit)? = null,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
sharedElementKey: String? = null, sharedElementKey: String? = null) {
) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
val sharedTransitionScope = LocalSharedTransitionScope.current val sharedTransitionScope = LocalSharedTransitionScope.current
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
@ -86,8 +84,7 @@ fun PlayableCard(
.clip(RoundedCornerShape(8.dp)) .clip(RoundedCornerShape(8.dp))
.clickable(onClick = { onClick?.invoke() }, enabled = true) .clickable(onClick = { onClick?.invoke() }, enabled = true)
.width(160.dp) .width(160.dp)
.hoverable(interactionSource = interactionSource), .hoverable(interactionSource = interactionSource)) {
) {
Column(modifier = Modifier.fillMaxWidth()) { Column(modifier = Modifier.fillMaxWidth()) {
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) { Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
AsyncImage( AsyncImage(
@ -100,10 +97,8 @@ fun PlayableCard(
.sharedElementOrNone( .sharedElementOrNone(
key = sharedElementKey, key = sharedElementKey,
sharedTransitionScope = sharedTransitionScope, sharedTransitionScope = sharedTransitionScope,
animatedVisibilityScope = animatedVisibilityScope, animatedVisibilityScope = animatedVisibilityScope),
), contentScale = ContentScale.Crop)
contentScale = ContentScale.Crop,
)
if (onPlay != null || onAddToQueue != null) if (onPlay != null || onAddToQueue != null)
Box( Box(
@ -114,46 +109,35 @@ fun PlayableCard(
this@Column.AnimatedVisibility( this@Column.AnimatedVisibility(
visible = isHovered, visible = isHovered,
enter = fadeIn(), enter = fadeIn(),
exit = fadeOut(), exit = fadeOut()) {
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
modifier = Modifier.padding(4.dp) modifier = Modifier.padding(4.dp)
) { ) {
onAddToQueue?.let { onAddToQueue?.let {
SecondaryIconButton( MillenniumIconButton(
onClick = onAddToQueue, onClick = onAddToQueue,
modifier = Modifier.size(30.dp), modifier = Modifier.size(34.dp),
theme = LocalBaseUITheme.current.iconButtons.secondary.copy( style = MillenniumIconButtonStyle.Secondary
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
Icon( MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add to queue", contentDescription = "Add to queue")
)
} }
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
onPlay?.let { onPlay?.let {
PrimaryIconButton( MillenniumIconButton(
onClick = onPlay, onClick = onPlay,
modifier = Modifier.size(30.dp), modifier = Modifier.size(34.dp),
theme = LocalBaseUITheme.current.iconButtons.secondary.copy( style = MillenniumIconButtonStyle.Primary
shape = BaseUITheme.InteractionState.fromSingleValue(
CircleShape
)
)
) { ) {
Icon( MillenniumIcon(
imageVector = if (isPlaying) { imageVector = if (isPlaying) {
Iconsax.IconsaxPause Iconsax.IconsaxPause
} else Iconsax.IconsaxPlay, } else Iconsax.IconsaxPlay,
contentDescription = "Play", contentDescription = "Play")
)
} }
} }
} }
@ -165,19 +149,17 @@ fun PlayableCard(
) { ) {
TextWithShimmer( TextWithShimmer(
text = title, text = title,
style = MaterialTheme.typography.titleSmall, style = AppTheme.typography.titleSmall,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
subtitle?.let { subtitle?.let {
TextWithShimmer( TextWithShimmer(
text = subtitle, text = subtitle,
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
} }
} }
} }
@ -185,11 +167,10 @@ fun PlayableCard(
} }
} }
@PreviewLightDark @PreviewLightDark
@Composable @Composable
private fun PlayableCardPreview() { private fun PlayableCardPreview() {
BaseScaffold { Box {
PlayableCard( PlayableCard(
title = "Sample Title", title = "Sample Title",
subtitle = "Sample Subtitle", subtitle = "Sample Subtitle",

View File

@ -45,8 +45,7 @@ fun Coverflow(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
state: CoverflowState = rememberCoverflowState(), state: CoverflowState = rememberCoverflowState(),
params: CoverflowParams = CoverflowParams(), params: CoverflowParams = CoverflowParams(),
content: CoverflowScope.() -> Unit, content: CoverflowScope.() -> Unit) {
) {
var size by remember { mutableStateOf(IntSize.Zero) } var size by remember { mutableStateOf(IntSize.Zero) }
var geometry by remember { mutableStateOf<Geometry?>(null) } var geometry by remember { mutableStateOf<Geometry?>(null) }
@ -55,8 +54,7 @@ fun Coverflow(
val g = geometry ?: return@LaunchedEffect val g = geometry ?: return@LaunchedEffect
state.lazyListState.scrollToItem( state.lazyListState.scrollToItem(
state.selectedIndex + 1, state.selectedIndex + 1,
-g.spacerWidth, -g.spacerWidth)
)
state.initialScrollDone = true state.initialScrollDone = true
} }
@ -69,8 +67,7 @@ fun Coverflow(
}, },
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
state = state.lazyListState, state = state.lazyListState,
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState), flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) {
) {
if (size != IntSize.Zero) { if (size != IntSize.Zero) {
val g = Geometry(params, size) val g = Geometry(params, size)
geometry = g geometry = g
@ -79,8 +76,7 @@ fun Coverflow(
val coverflowScope = CoverflowScopeImpl( val coverflowScope = CoverflowScopeImpl(
geometry = g, geometry = g,
lazyListScope = this, lazyListScope = this,
coverflowState = state, coverflowState = state)
)
item { item {
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() })) Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
@ -102,15 +98,13 @@ fun Coverflow(
if (state.selectedIndex > 0) { if (state.selectedIndex > 0) {
state.scrollToItem(state.selectedIndex - 1) state.scrollToItem(state.selectedIndex - 1)
} }
}, }) {}
) {}
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxHeight() .fillMaxHeight()
.align(Alignment.CenterEnd) .align(Alignment.CenterEnd)
.clickable { .clickable {
state.scrollToItem(state.selectedIndex + 1) state.scrollToItem(state.selectedIndex + 1)
}, }) {}
) {}
} }
} }

View File

@ -25,8 +25,7 @@ import kotlin.math.min
@Stable @Stable
internal class Geometry( internal class Geometry(
internal val params: CoverflowParams = CoverflowParams(), internal val params: CoverflowParams = CoverflowParams(),
private val size: IntSize = IntSize.Zero, private val size: IntSize = IntSize.Zero) {
) {
@Stable @Stable
private val shortEdge private val shortEdge
get() = min(size.width, size.height) get() = min(size.width, size.height)

View File

@ -41,8 +41,7 @@ internal fun CoverflowItem(
geometry: Geometry, geometry: Geometry,
onClickHandler: () -> Unit = {}, onClickHandler: () -> Unit = {},
onSelectedHandler: (Boolean) -> Unit, onSelectedHandler: (Boolean) -> Unit,
content: @Composable () -> Unit, content: @Composable () -> Unit) {
) {
var horizontalPosition by remember { mutableStateOf<Float?>(null) } var horizontalPosition by remember { mutableStateOf<Float?>(null) }
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f) val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
@ -53,8 +52,7 @@ internal fun CoverflowItem(
.onGloballyPositioned { coordinates -> .onGloballyPositioned { coordinates ->
horizontalPosition = coordinates.positionInParent().x horizontalPosition = coordinates.positionInParent().x
}, },
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
if (horizontalPosition != null) { if (horizontalPosition != null) {
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f)) onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
@ -65,20 +63,16 @@ internal fun CoverflowItem(
rotationY = geometry.rotation(distanceToCenter), rotationY = geometry.rotation(distanceToCenter),
translationX = geometry.translationX(distanceToCenter), translationX = geometry.translationX(distanceToCenter),
scaleY = geometry.scale(distanceToCenter), scaleY = geometry.scale(distanceToCenter),
scaleX = geometry.scale(distanceToCenter), scaleX = geometry.scale(distanceToCenter)),
), propagateMinConstraints = true) {
propagateMinConstraints = true,
) {
val interactionSource = remember { MutableInteractionSource() } val interactionSource = remember { MutableInteractionSource() }
if (geometry.params.mirror) { if (geometry.params.mirror) {
Mirror( Mirror(
modifier = Modifier.clickable( modifier = Modifier.clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = onClickHandler, onClick = onClickHandler),
), coverSize = geometry.coverSize) {
coverSize = geometry.coverSize,
) {
content() content()
} }
} else { } else {
@ -86,9 +80,7 @@ internal fun CoverflowItem(
modifier = Modifier.clickable( modifier = Modifier.clickable(
interactionSource = interactionSource, interactionSource = interactionSource,
indication = null, indication = null,
onClick = onClickHandler, onClick = onClickHandler)) {
),
) {
content() content()
} }
} }

View File

@ -26,5 +26,4 @@ data class CoverflowParams(
val angle: Float = 45f, val angle: Float = 45f,
val shift: Float = 0.4f, val shift: Float = 0.4f,
val zoom: Float = 0.8f, val zoom: Float = 0.8f,
val mirror: Boolean = false, val mirror: Boolean = false)
)

View File

@ -26,37 +26,32 @@ interface CoverflowScope {
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> }, onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
key: ((index: Int) -> Any)? = null, key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null }, contentType: (index: Int) -> Any? = { null },
itemContent: @Composable ((item: T) -> Unit), itemContent: @Composable ((item: T) -> Unit))
)
fun items( fun items(
count: Int, count: Int,
onSelectHandler: (index: Int) -> Unit = {}, onSelectHandler: (index: Int) -> Unit = {},
key: ((index: Int) -> Any)? = null, key: ((index: Int) -> Any)? = null,
contentType: (index: Int) -> Any? = { null }, contentType: (index: Int) -> Any? = { null },
itemContent: @Composable ((index: Int) -> Unit), itemContent: @Composable ((index: Int) -> Unit))
)
} }
internal class CoverflowScopeImpl( internal class CoverflowScopeImpl(
private val geometry: Geometry, private val geometry: Geometry,
private val lazyListScope: LazyListScope, private val lazyListScope: LazyListScope,
private val coverflowState: CoverflowState, private val coverflowState: CoverflowState) : CoverflowScope {
) : CoverflowScope {
override fun <T> items( override fun <T> items(
items: List<T>, items: List<T>,
onSelectHandler: (item: T, index: Int) -> Unit, onSelectHandler: (item: T, index: Int) -> Unit,
key: ((index: Int) -> Any)?, key: ((index: Int) -> Any)?,
contentType: (index: Int) -> Any?, contentType: (index: Int) -> Any?,
itemContent: @Composable (item: T) -> Unit, itemContent: @Composable (item: T) -> Unit) = items(
) = items(
count = items.size, count = items.size,
onSelectHandler = { onSelectHandler = {
onSelectHandler(items[it], it) onSelectHandler(items[it], it)
}, },
key = key, key = key,
contentType = contentType, contentType = contentType) {
) {
itemContent(items[it]) itemContent(items[it])
} }
@ -65,12 +60,10 @@ internal class CoverflowScopeImpl(
onSelectHandler: (index: Int) -> Unit, onSelectHandler: (index: Int) -> Unit,
key: ((index: Int) -> Any)?, key: ((index: Int) -> Any)?,
contentType: (index: Int) -> Any?, contentType: (index: Int) -> Any?,
itemContent: @Composable (index: Int) -> Unit, itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items(
) = lazyListScope.items(
count, count,
key, key,
contentType, contentType) {
) {
CoverflowItem( CoverflowItem(
onClickHandler = { coverflowState.scrollToItem(it) }, onClickHandler = { coverflowState.scrollToItem(it) },
onSelectedHandler = { isSelected: Boolean -> onSelectedHandler = { isSelected: Boolean ->
@ -78,16 +71,14 @@ internal class CoverflowScopeImpl(
onSelectHandler(it) onSelectHandler(it)
} }
}, },
geometry = geometry, geometry = geometry) {
) {
itemContent(it) itemContent(it)
} }
} }
private fun selectHandler( private fun selectHandler(
index: Int, index: Int,
isSelected: Boolean, isSelected: Boolean): Boolean {
): Boolean {
if (!isSelected && index == coverflowState.selectedIndex) { if (!isSelected && index == coverflowState.selectedIndex) {
coverflowState.selectedIndex = -1 coverflowState.selectedIndex = -1
} else if (isSelected && index != coverflowState.selectedIndex) { } else if (isSelected && index != coverflowState.selectedIndex) {

View File

@ -28,19 +28,16 @@ import kotlinx.coroutines.launch
@Composable @Composable
fun rememberCoverflowState( fun rememberCoverflowState(
initialIndex: Int = 0, initialIndex: Int = 0,
onSelectHandler: (Int) -> Unit = {}, onSelectHandler: (Int) -> Unit = {}): CoverflowState {
): CoverflowState {
val lazyListState: LazyListState = rememberLazyListState( val lazyListState: LazyListState = rememberLazyListState(
initialFirstVisibleItemIndex = initialIndex + 1, initialFirstVisibleItemIndex = initialIndex + 1)
)
val coroutineScope: CoroutineScope = rememberCoroutineScope() val coroutineScope: CoroutineScope = rememberCoroutineScope()
return remember { return remember {
CoverflowState( CoverflowState(
lazyListState, lazyListState,
coroutineScope, coroutineScope,
onSelectHandler, onSelectHandler,
initialIndex, initialIndex)
)
} }
} }
@ -48,8 +45,7 @@ class CoverflowState internal constructor(
internal val lazyListState: LazyListState, internal val lazyListState: LazyListState,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val onSelectHandler: (Int) -> Unit = {}, private val onSelectHandler: (Int) -> Unit = {},
initialIndex: Int = 0, initialIndex: Int = 0) {
) {
internal var geometry: Geometry? = null internal var geometry: Geometry? = null
internal var initialScrollDone: Boolean = false internal var initialScrollDone: Boolean = false
@ -65,8 +61,7 @@ class CoverflowState internal constructor(
coroutineScope.launch { coroutineScope.launch {
lazyListState.animateScrollToItem( lazyListState.animateScrollToItem(
index + 1, index + 1,
-geometryCopy.spacerWidth, -geometryCopy.spacerWidth)
)
} }
} }
} }

View File

@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times
internal fun Mirror( internal fun Mirror(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
coverSize: Float, coverSize: Float,
content: @Composable () -> Unit, content: @Composable () -> Unit) {
) {
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() } val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
Column( Column(
modifier = modifier modifier = modifier
.padding(top = coverSizeDp) .padding(top = coverSizeDp)
.fillMaxWidth() .fillMaxWidth()
.requiredHeight(2 * coverSizeDp), .requiredHeight(2 * coverSizeDp)) {
) {
Box( Box(
propagateMinConstraints = true, propagateMinConstraints = true,
modifier = Modifier modifier = Modifier
.weight(1f) .weight(1f)
.fillMaxWidth() .fillMaxWidth()
.height(coverSizeDp), .height(coverSizeDp)) {
) {
content() content()
} }
Box( Box(
@ -75,34 +72,27 @@ internal fun Mirror(
.height(coverSizeDp) .height(coverSizeDp)
.graphicsLayer( .graphicsLayer(
compositingStrategy = CompositingStrategy.Offscreen, compositingStrategy = CompositingStrategy.Offscreen,
rotationX = 180f, rotationX = 180f)
)
.clip(HalfSizeShape) .clip(HalfSizeShape)
.drawWithContent { .drawWithContent {
val colors = listOf( val colors = listOf(
Color.Transparent, Color.Transparent,
Color.Transparent, Color.Transparent,
Color.Black, Color.Black)
)
drawContent() drawContent()
drawRect( drawRect(
topLeft = Offset( topLeft = Offset(
0f, 0f,
this.size.height / 2, this.size.height / 2),
),
size = Size( size = Size(
this.size.width, this.size.width,
this.size.height / 2, this.size.height / 2),
),
brush = Brush.verticalGradient(colors), brush = Brush.verticalGradient(colors),
blendMode = BlendMode.DstIn, blendMode = BlendMode.DstIn)
)
} }
.blur( .blur(
radiusX = 1.dp, radiusX = 1.dp,
radiusY = 3.dp, radiusY = 3.dp)) {
),
) {
content() content()
} }
} }
@ -112,17 +102,12 @@ private object HalfSizeShape : Shape {
override fun createOutline( override fun createOutline(
size: Size, size: Size,
layoutDirection: LayoutDirection, layoutDirection: LayoutDirection,
density: Density, density: Density): Outline = Outline.Rectangle(
): Outline = Outline.Rectangle(
Rect( Rect(
Offset( Offset(
0f, 0f,
size.height / 2, size.height / 2),
),
Size( Size(
size.width, size.width,
size.height, size.height)))
),
),
)
} }

View File

@ -17,10 +17,11 @@
package dev.krtirtho.spotube.core.ui.misc package dev.krtirtho.spotube.core.ui.misc
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.text.TextAutoSize import androidx.compose.foundation.text.TextAutoSize
import androidx.compose.material3.LocalTextStyle import com.composeunstyled.LocalTextStyle
import androidx.compose.material3.Text import com.composeunstyled.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.compositionLocalOf import androidx.compose.runtime.compositionLocalOf
@ -38,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.TextUnit import androidx.compose.ui.unit.TextUnit
import com.eygraber.compose.placeholder.material3.placeholder
import com.valentinilk.shimmer.ShimmerBounds import com.valentinilk.shimmer.ShimmerBounds
import com.valentinilk.shimmer.rememberShimmer import com.valentinilk.shimmer.rememberShimmer
import com.valentinilk.shimmer.shimmer import com.valentinilk.shimmer.shimmer
@ -84,11 +84,7 @@ fun Modifier.shimmerApply(
): Modifier = composed { ): Modifier = composed {
val isTreeLoading = LocalSkeletonLoading.current val isTreeLoading = LocalSkeletonLoading.current
this.placeholder( if (isTreeLoading) this.background(color) else this
visible = isTreeLoading,
color = color,
shape = null,
)
} }
@Composable @Composable
@ -110,23 +106,20 @@ fun TextWithShimmer(
maxLines: Int = Int.MAX_VALUE, maxLines: Int = Int.MAX_VALUE,
minLines: Int = 1, minLines: Int = 1,
onTextLayout: ((TextLayoutResult) -> Unit)? = null, onTextLayout: ((TextLayoutResult) -> Unit)? = null,
style: TextStyle = LocalTextStyle.current, style: TextStyle = LocalTextStyle.current) {
) {
Text( Text(
text = text, text = text,
color = color, color = color,
modifier = Modifier.shimmerApply().then(modifier), modifier = Modifier.shimmerApply().then(modifier),
autoSize = autoSize, autoSize = autoSize,
fontSize = fontSize, fontSize = fontSize,
fontStyle = fontStyle,
fontWeight = fontWeight, fontWeight = fontWeight,
fontFamily = fontFamily, fontFamily = fontFamily,
letterSpacing = letterSpacing, letterSpacing = letterSpacing,
textDecoration = textDecoration, textDecoration = textDecoration,
textAlign = textAlign, textAlign = textAlign ?: TextAlign.Start,
lineHeight = lineHeight, lineHeight = lineHeight,
overflow = overflow, overflow = overflow,
softWrap = softWrap,
maxLines = maxLines, maxLines = maxLines,
minLines = minLines, minLines = minLines,
onTextLayout = onTextLayout, onTextLayout = onTextLayout,

View File

@ -18,93 +18,188 @@
package dev.krtirtho.spotube.core.ui.theming package dev.krtirtho.spotube.core.ui.theming
import androidx.compose.foundation.isSystemInDarkTheme import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.material3.ColorScheme import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Surface
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import com.materialkolor.rememberDynamicColorScheme import androidx.compose.runtime.staticCompositionLocalOf
import dev.krtirtho.spotube.core.ui.base.BaseUIColorScheme import androidx.compose.ui.graphics.Color
import dev.krtirtho.spotube.core.ui.base.baseUIColorScheme import androidx.compose.ui.graphics.Shape
import androidx.compose.ui.graphics.luminance
import androidx.compose.ui.text.TextStyle
import androidx.compose.ui.text.font.FontFamily
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import dev.krtirtho.spotube.modules.settings.Theme import dev.krtirtho.spotube.modules.settings.Theme
import dev.krtirtho.spotube.modules.settings.UserSettings import dev.krtirtho.spotube.modules.settings.UserSettings
import org.spotube.millennium.theme.MillenniumColors
import org.spotube.millennium.theme.MillenniumPalette
import org.spotube.millennium.theme.MillenniumTheme
import org.spotube.millennium.theme.MillenniumThemeConfig
/**
* Semantic color roles used across the app, resolved from the active Millennium palette.
*
* This is not a second palette: every role is derived from [MillenniumColors], so the
* whole app follows the selected Millennium accent family and light/dark appearance.
*/
@Immutable
data class AppColors(
val primary: Color,
val onPrimary: Color,
val primaryContainer: Color,
val onPrimaryContainer: Color,
val secondary: Color,
val onSecondary: Color,
val secondaryContainer: Color,
val onSecondaryContainer: Color,
val tertiaryContainer: Color,
val onTertiaryContainer: Color,
val background: Color,
val onBackground: Color,
val surface: Color,
val onSurface: Color,
val surfaceVariant: Color,
val onSurfaceVariant: Color,
val surfaceContainer: Color,
val surfaceContainerHigh: Color,
val surfaceContainerHighest: Color,
val inverseSurface: Color,
val inverseOnSurface: Color,
val outline: Color,
val outlineVariant: Color,
val error: Color,
val onError: Color,
val errorContainer: Color,
val onErrorContainer: Color,
val scrim: Color)
/** Maps Millennium's semantic palette onto the app's role names. */
fun appColorsFrom(m: MillenniumColors): AppColors = AppColors(
primary = m.accent,
onPrimary = m.onAccent,
primaryContainer = m.raised,
onPrimaryContainer = m.text,
secondary = m.accent,
onSecondary = m.onAccent,
secondaryContainer = m.raised,
onSecondaryContainer = m.text,
tertiaryContainer = m.raised,
onTertiaryContainer = m.text,
background = m.background,
onBackground = m.text,
surface = m.surface,
onSurface = m.text,
surfaceVariant = m.inset,
onSurfaceVariant = m.muted,
surfaceContainer = m.surface,
surfaceContainerHigh = m.raised,
surfaceContainerHighest = m.raised,
inverseSurface = m.text,
inverseOnSurface = m.background,
outline = m.border,
outlineVariant = m.border,
error = m.danger,
onError = m.background,
errorContainer = m.danger.copy(alpha = 0.16f),
onErrorContainer = m.danger,
scrim = m.scrim)
/** Named text styles used by the app, independent of Material typography. */
@Immutable
data class AppTypography(
val displaySmall: TextStyle,
val headlineLarge: TextStyle,
val headlineMedium: TextStyle,
val headlineSmall: TextStyle,
val titleLarge: TextStyle,
val titleMedium: TextStyle,
val titleSmall: TextStyle,
val bodyLarge: TextStyle,
val bodyMedium: TextStyle,
val bodySmall: TextStyle,
val labelLarge: TextStyle,
val labelMedium: TextStyle,
val labelSmall: TextStyle)
private fun style(
size: Int,
lineHeight: Int,
weight: FontWeight = FontWeight.Normal): TextStyle = TextStyle(
fontFamily = FontFamily.SansSerif,
fontSize = size.sp,
lineHeight = lineHeight.sp,
fontWeight = weight)
val AppDefaultTypography = AppTypography(
displaySmall = style(36, 44),
headlineLarge = style(32, 40),
headlineMedium = style(28, 36),
headlineSmall = style(24, 32),
titleLarge = style(22, 28, FontWeight.Medium),
titleMedium = style(16, 24, FontWeight.Medium),
titleSmall = style(14, 20, FontWeight.Medium),
bodyLarge = style(16, 24),
bodyMedium = style(14, 20),
bodySmall = style(12, 16),
labelLarge = style(14, 20, FontWeight.Medium),
labelMedium = style(12, 16, FontWeight.Medium),
labelSmall = style(11, 16, FontWeight.Medium))
/** Corner shapes used by the app. */
@Immutable
data class AppShapes(
val small: Shape,
val medium: Shape,
val large: Shape)
val AppDefaultShapes = AppShapes(
small = RoundedCornerShape(8.dp),
medium = RoundedCornerShape(12.dp),
large = RoundedCornerShape(16.dp))
val LocalAppColors = staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) }
val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography }
val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes }
/** Reads the active app theme tokens, all backed by Millennium. */
object AppTheme {
val colors: AppColors @Composable get() = LocalAppColors.current
val typography: AppTypography @Composable get() = LocalAppTypography.current
val shapes: AppShapes @Composable get() = LocalAppShapes.current
}
/** /**
* The app's single source of truth for colour. * The app's single source of truth for colour.
* *
* The selected [UserSettings.surfaceTheme] and [UserSettings.accentColor] produce a * Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark
* [BaseUIColorScheme], and every Material role is then overridden from it. That way the * [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and
* whole app — Material components included — renders in the chosen surface theme's colours * [AppShapes] tokens from the active Millennium theme.
* and accent, instead of Material's dynamic palette drifting away from the base UI.
*/ */
@Composable @Composable
fun SpotubeTheme( fun SpotubeTheme(
settings: UserSettings, settings: UserSettings,
content: @Composable () -> Unit, content: @Composable () -> Unit) {
) {
val isDarkTheme = when (settings.theme) { val isDarkTheme = when (settings.theme) {
Theme.LIGHT -> false Theme.LIGHT -> false
Theme.DARK -> true Theme.DARK -> true
Theme.SYSTEM -> isSystemInDarkTheme() Theme.SYSTEM -> isSystemInDarkTheme()
} }
val isLight = !isDarkTheme val palette: MillenniumPalette = settings.palette
val accent = if (isDarkTheme) { MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
settings.accentColor.toDarkColor() val colors = appColorsFrom(MillenniumTheme.colors)
} else { CompositionLocalProvider(
settings.accentColor.toLightColor() LocalAppColors provides colors,
} LocalAppTypography provides AppDefaultTypography,
LocalAppShapes provides AppDefaultShapes,
val baseColors = remember(settings.surfaceTheme, isLight, accent) { content = content)
baseUIColorScheme(settings.surfaceTheme, isLight, accent)
}
val dynamicScheme = rememberDynamicColorScheme(
seedColor = accent,
isDark = isDarkTheme,
)
MaterialTheme(
colorScheme = dynamicScheme.mergeWithBaseColors(baseColors),
) {
Surface(content = content)
} }
} }
/** /** Whether the active Millennium appearance is light. */
* Overrides the accent/surface roles of a dynamically generated [ColorScheme] with the @Composable
* base UI palette, so Material components share the exact same colours as the base fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
* components and the surface theme's primary/secondary backgrounds.
*/
private fun ColorScheme.mergeWithBaseColors(base: BaseUIColorScheme): ColorScheme = copy(
primary = base.accentPrimary,
onPrimary = base.onAccentPrimary,
primaryContainer = base.primaryContainer,
onPrimaryContainer = base.onPrimaryContainer,
inversePrimary = base.actionInverse,
secondary = base.accentSecondary,
onSecondary = base.onAccentSecondary,
secondaryContainer = base.secondaryContainer,
onSecondaryContainer = base.onSecondaryContainer,
background = base.surface,
onBackground = base.onBackground,
surface = base.surface,
onSurface = base.onSurface,
surfaceVariant = base.surfaceVariant,
onSurfaceVariant = base.onSurfaceVariant,
surfaceTint = base.accentPrimary,
surfaceBright = base.surfaceContainerHigh,
surfaceDim = base.surfaceContainerHighest,
surfaceContainerLowest = base.surface,
surfaceContainerLow = base.surfaceContainer,
surfaceContainer = base.surfaceContainer,
surfaceContainerHigh = base.surfaceContainerHigh,
surfaceContainerHighest = base.surfaceContainerHighest,
inverseSurface = base.backgroundInverse,
inverseOnSurface = base.onBackgroundInverse,
outline = base.outline,
outlineVariant = base.outlineVariant,
error = base.error,
onError = base.onError,
scrim = base.scrim,
)

View File

@ -1,221 +1,230 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.Arrangement import org.spotube.millennium.composables.MillenniumIcon
import androidx.compose.foundation.layout.Column import org.spotube.millennium.composables.MillenniumIconButton
import androidx.compose.foundation.layout.Row import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.layout.WindowInsets import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.background
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.border
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.statusBars import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.layout.height
import androidx.compose.material3.Icon import androidx.compose.foundation.layout.padding
import androidx.compose.material3.IconButton import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.material3.MaterialTheme import androidx.compose.foundation.layout.wrapContentHeight
import dev.krtirtho.spotube.core.ui.base.BaseScaffold import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Surface import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Text import androidx.compose.ui.draw.clip
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember import com.composeunstyled.Text
import androidx.compose.runtime.snapshotFlow import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.runtime.DisposableEffect
import androidx.compose.ui.Modifier import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.graphics.Color import androidx.compose.runtime.remember
import androidx.compose.ui.text.style.TextAlign import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.unit.dp import androidx.compose.ui.Alignment
import compose.icons.FeatherIcons import androidx.compose.ui.Modifier
import compose.icons.feathericons.ChevronLeft import androidx.compose.ui.graphics.Color
import compose.icons.feathericons.ChevronRight import androidx.compose.ui.text.style.TextAlign
import compose.icons.feathericons.X import androidx.compose.ui.unit.dp
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents import compose.icons.FeatherIcons
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import compose.icons.feathericons.ChevronLeft
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler import compose.icons.feathericons.ChevronRight
import dev.nucleusframework.webview.jsbridge.JsMessage import compose.icons.feathericons.X
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
import dev.nucleusframework.webview.web.WebView import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.nucleusframework.webview.web.WebViewNavigator import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
import dev.nucleusframework.webview.web.WebViewState import dev.nucleusframework.webview.jsbridge.JsMessage
import dev.nucleusframework.webview.web.rememberWebViewNavigator import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
import dev.nucleusframework.webview.web.WebView
class PostMessageHandler( import dev.nucleusframework.webview.web.WebViewNavigator
private val onMessageReceived: (String) -> Unit = {} import dev.nucleusframework.webview.web.WebViewState
) : IJsMessageHandler { import dev.nucleusframework.webview.web.rememberWebViewNavigator
override fun methodName(): String {
return "sendMessage" class PostMessageHandler(
} private val onMessageReceived: (String) -> Unit = {}
) : IJsMessageHandler {
override fun handle( override fun methodName(): String {
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit return "sendMessage"
) { }
onMessageReceived(message.params)
callback(message.params) override fun handle(
} message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
} ) {
onMessageReceived(message.params)
@Composable callback(message.params)
fun PlatformWebViewScreen(webViewController: WebViewController) { }
if (webViewController.getContent() == null) { }
// This should never happen, but just in case
Text("No URL to load") @Composable
return fun PlatformWebViewScreen(webViewController: WebViewController) {
} if (webViewController.getContent() == null) {
// This should never happen, but just in case
val state = remember { Text("No URL to load")
WebViewState( return
webViewController.getWebContent( }
additionalHttpHeaders = mapOf(
"User-Agent" to UserAgents.random() val state = remember {
) WebViewState(
) webViewController.getWebContent(
) additionalHttpHeaders = mapOf(
}.apply { "User-Agent" to UserAgents.random()
this.content = webViewController.getWebContent() )
platformWebviewConfig(this, webViewController.currentPluginId) )
} )
}.apply {
val navigator = rememberWebViewNavigator() this.content = webViewController.getWebContent()
val webViewBridge = rememberWebViewJsBridge(navigator) platformWebviewConfig(this, webViewController.currentPluginId)
}
val bridgeBootstrapScript = remember {
""" val navigator = rememberWebViewNavigator()
(function() { val webViewBridge = rememberWebViewJsBridge(navigator)
if (typeof window.sendMessage !== "function") {
window.sendMessage = function(message) { val bridgeBootstrapScript = remember {
if (typeof message !== "string") { """
throw new TypeError("[window.sendMessage] Message must be a string"); (function() {
} if (typeof window.sendMessage !== "function") {
window.kmpJsBridge.callNative("sendMessage", message); window.sendMessage = function(message) {
}; if (typeof message !== "string") {
} throw new TypeError("[window.sendMessage] Message must be a string");
}
if (!window.bridgeReady) { window.kmpJsBridge.callNative("sendMessage", message);
const event = new CustomEvent("onBridgeReady"); };
window.dispatchEvent(event); }
window.bridgeReady = true;
} if (!window.bridgeReady) {
})(); const event = new CustomEvent("onBridgeReady");
""".trimIndent() window.dispatchEvent(event);
} window.bridgeReady = true;
}
LaunchedEffect(state) { })();
snapshotFlow { state.lastLoadedUrl }.collect { url -> """.trimIndent()
if (url != null) { }
webViewController.emitUrlChange(url)
navigator.evaluateJavaScript(bridgeBootstrapScript) LaunchedEffect(state) {
webViewController.emitWebViewCreated() snapshotFlow { state.lastLoadedUrl }.collect { url ->
} if (url != null) {
} webViewController.emitUrlChange(url)
} navigator.evaluateJavaScript(bridgeBootstrapScript)
webViewController.emitWebViewCreated()
LaunchedEffect(state.cookieManager, navigator) { }
webViewController.setCookieManager(cookieManager = state.cookieManager) }
webViewController.webViewNavigator = navigator }
}
LaunchedEffect(state.cookieManager, navigator) {
LaunchedEffect(webViewBridge) { webViewController.setCookieManager(cookieManager = state.cookieManager)
webViewBridge.register(PostMessageHandler { message -> webViewController.webViewNavigator = navigator
webViewController.emitPostMessage(message) }
})
} LaunchedEffect(webViewBridge) {
webViewBridge.register(PostMessageHandler { message ->
DisposableEffect(Unit) { webViewController.emitPostMessage(message)
onDispose { })
webViewController.dispose() }
}
} DisposableEffect(Unit) {
onDispose {
BaseScaffold( webViewController.dispose()
contentWindowInsets = WindowInsets.statusBars, }
topBar = { }
ApplicationMainBar(
title = { Column(modifier = Modifier.fillMaxSize()) {
Row( ApplicationMainBar(
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp), title = {
horizontalArrangement = Arrangement.SpaceBetween, Row(
verticalAlignment = Alignment.CenterVertically modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
) { horizontalArrangement = Arrangement.SpaceBetween,
Row( verticalAlignment = Alignment.CenterVertically
verticalAlignment = Alignment.CenterVertically, ) {
horizontalArrangement = Arrangement.Start, Row(
modifier = Modifier.height(56.dp) verticalAlignment = Alignment.CenterVertically,
) { horizontalArrangement = Arrangement.Start,
IconButton( modifier = Modifier.height(56.dp)
onClick = { ) {
navigator.navigateBack() MillenniumIconButton(
}, enabled = navigator.canGoBack onClick = {
) { navigator.navigateBack()
Icon( },
FeatherIcons.ChevronLeft, enabled = navigator.canGoBack,
contentDescription = "Go back to browser history" style = MillenniumIconButtonStyle.Quiet
) ) {
} MillenniumIcon(
IconButton( FeatherIcons.ChevronLeft,
onClick = { contentDescription = "Go back to browser history"
navigator.navigateForward() )
}, enabled = navigator.canGoForward }
) { MillenniumIconButton(
Icon( onClick = {
FeatherIcons.ChevronRight, navigator.navigateForward()
contentDescription = "Go forward to browser history" },
) enabled = navigator.canGoForward,
} style = MillenniumIconButtonStyle.Quiet
} ) {
Surface( MillenniumIcon(
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), FeatherIcons.ChevronRight,
shape = RoundedCornerShape(18.dp), contentDescription = "Go forward to browser history"
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), )
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)) }
) { }
BasicTextField( Box(
value = state.lastLoadedUrl ?: "", modifier = Modifier
onValueChange = {}, // Read-only .weight(1f)
readOnly = true, .height(36.dp)
singleLine = true, .padding(horizontal = 4.dp)
textStyle = MaterialTheme.typography.bodyMedium.copy( .clip(RoundedCornerShape(18.dp))
color = MaterialTheme.colorScheme.onSurface, .background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
textAlign = TextAlign.Start .border(
), BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) RoundedCornerShape(18.dp)),
.wrapContentHeight(Alignment.CenterVertically) contentAlignment = Alignment.CenterStart
) ) {
} BasicTextField(
} value = state.lastLoadedUrl ?: "",
} onValueChange = {}, // Read-only
) readOnly = true,
}) singleLine = true,
{ innerPadding -> textStyle = AppTheme.typography.bodyMedium.copy(
WebView( color = AppTheme.colors.onSurface,
state = state, textAlign = TextAlign.Start
modifier = Modifier.padding(innerPadding).fillMaxSize(), ),
navigator = navigator, modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
webViewJsBridge = webViewBridge, .wrapContentHeight(Alignment.CenterVertically)
onCreated = { webView -> )
navigator.evaluateJavaScript(bridgeBootstrapScript) }
}, }
) }
} )
WebView(
state = state,
modifier = Modifier.weight(1f).fillMaxWidth(),
navigator = navigator,
webViewJsBridge = webViewBridge,
onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript)
})
}
} }

View File

@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent
import kotlin.reflect.KClass import kotlin.reflect.KClass
class BuiltInPluginService( class BuiltInPluginService(
private val pluginInfo: PluginEntry, private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id override val pluginId: String = pluginInfo.id
private val loggedInStateFlow = MutableStateFlow(false) private val loggedInStateFlow = MutableStateFlow(false)
@ -58,7 +57,6 @@ class BuiltInPluginService(
} }
} }
override suspend fun start() { override suspend fun start() {
when (pluginInfo) { when (pluginInfo) {
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> { NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {

View File

@ -1,347 +1,344 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.zipline package dev.krtirtho.spotube.core.zipline
import app.cash.zipline.Zipline import app.cash.zipline.Zipline
import app.cash.zipline.ZiplineService import app.cash.zipline.ZiplineService
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
import app.cash.zipline.loader.LoadResult import app.cash.zipline.loader.LoadResult
import app.cash.zipline.loader.ManifestVerifier import app.cash.zipline.loader.ManifestVerifier
import app.cash.zipline.loader.ZiplineLoader import app.cash.zipline.loader.ZiplineLoader
import dev.krtirtho.plugin_interfaces.core.Initializer import dev.krtirtho.plugin_interfaces.core.Initializer
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
import dev.krtirtho.spotube.core.di.injectLogger import dev.krtirtho.spotube.core.di.injectLogger
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
import dev.krtirtho.spotube.modules.plugin.PluginAbility import dev.krtirtho.spotube.modules.plugin.PluginAbility
import dev.krtirtho.spotube.modules.plugin.PluginCapability import dev.krtirtho.spotube.modules.plugin.PluginCapability
import dev.krtirtho.spotube.modules.plugin.PluginEntry import dev.krtirtho.spotube.modules.plugin.PluginEntry
import io.ktor.http.URLBuilder import io.ktor.http.URLBuilder
import io.ktor.http.decodeURLQueryComponent import io.ktor.http.decodeURLQueryComponent
import kotlinx.coroutines.CoroutineExceptionHandler import kotlinx.coroutines.CoroutineExceptionHandler
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel import kotlinx.coroutines.cancel
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import okio.Path.Companion.toPath import okio.Path.Companion.toPath
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
import org.koin.core.component.inject import org.koin.core.component.inject
import kotlin.reflect.KClass import kotlin.reflect.KClass
/**
/** * Manages the lifecycle of a Zipline plugin.
* Manages the lifecycle of a Zipline plugin. * It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements. * Anything it provides, must be called within that dispatcher context.
* Anything it provides, must be called within that dispatcher context. * The [use] function must be used.
* The [use] function must be used. *
* * The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well, * so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in * `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows. *
* * The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called. */
*/ open class ZiplinePluginService(
open class ZiplinePluginService( val applicationName: String,
val applicationName: String, private val manifestUrl: String,
private val manifestUrl: String, private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
private val pluginInfo: PluginEntry,
) : PluginService, KoinComponent { override val pluginId: String = pluginInfo.id
override val pluginId: String = pluginInfo.id // QuickJS compile() uses deep C-level recursion on the native thread stack.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
// QuickJS compile() uses deep C-level recursion on the native thread stack. // (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules // We use a custom EventListener to increase maxStackSize right after the Zipline
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing. // instance is created, before any modules are loaded.
// We use a custom EventListener to increase maxStackSize right after the Zipline private val ziplineDispatcher = createZiplineDispatcher()
// instance is created, before any modules are loaded.
private val ziplineDispatcher = createZiplineDispatcher() private fun trace(event: String) {
logger.d { "[$applicationName] $event" }
private fun trace(event: String) { }
logger.d { "[$applicationName] $event" }
} private val logger by injectLogger<ZiplinePluginService>()
private val webViewController: WebViewController by inject()
private val logger by injectLogger<ZiplinePluginService>()
private val webViewController: WebViewController by inject() private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher) logger.e(throwable) { "Zipline Engine Error" }
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable -> }
logger.e(throwable) { "Zipline Engine Error" } private val lifecycleMutex = Mutex()
} private var ziplineLoader: ZiplineLoader
private val lifecycleMutex = Mutex() private var ziplineInstance: Zipline? = null
private var ziplineLoader: ZiplineLoader private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
private var ziplineInstance: Zipline? = null
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>() init {
val manifestPath = URLBuilder(manifestUrl)
init { val baseDir =
val manifestPath = URLBuilder(manifestUrl) manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
val baseDir = ?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent ziplineLoader = ZiplineLoader(
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.") dispatcher = ziplineDispatcher.dispatcher,
ziplineLoader = ZiplineLoader( manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
dispatcher = ziplineDispatcher.dispatcher, httpClient = FileSystemHTTPClient(baseDir)
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS, )
httpClient = FileSystemHTTPClient(baseDir) }
)
} private val realHttpClientAPI = RealHttpClientAPI()
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
private val realHttpClientAPI = RealHttpClientAPI()
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id) private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo) private val systemInformationAPI = RealSystemInformationAPI()
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
private val systemInformationAPI = RealSystemInformationAPI() private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
private val loggedInStateFlow = MutableStateFlow(false)
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow() private fun bindHostServices(zipline: Zipline) {
trace("initializer(): binding host APIs")
private fun bindHostServices(zipline: Zipline) { logger.d { "[$applicationName] Binding host APIs in initializer" }
trace("initializer(): binding host APIs") try {
logger.d { "[$applicationName] Binding host APIs in initializer" } // Basic APIs
try { zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
// Basic APIs zipline.bind<SystemInformationAPI>(
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI) SystemInformationAPI_SERVICE_NAME,
zipline.bind<SystemInformationAPI>( systemInformationAPI
SystemInformationAPI_SERVICE_NAME, )
systemInformationAPI
) // Conditional APIs based on plugin capabilities
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
// Conditional APIs based on plugin capabilities zipline.bind<HttpClientAPI>(
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) { HttpClientAPI_SERVICE_NAME,
zipline.bind<HttpClientAPI>( realHttpClientAPI
HttpClientAPI_SERVICE_NAME, )
realHttpClientAPI }
) if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
} zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) { }
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI) if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
} zipline.bind<PersistedStorageAPI>(
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) { PersistedStorageAPI_SERVICE_NAME,
zipline.bind<PersistedStorageAPI>( persistedStorageAPI
PersistedStorageAPI_SERVICE_NAME, )
persistedStorageAPI }
) } catch (e: Exception) {
} logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
} catch (e: Exception) { throw e
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" } }
throw e }
}
} private fun consumePluginServices(result: LoadResult.Success) {
trace("start(): loadOnce success")
private fun consumePluginServices(result: LoadResult.Success) { val apiMap =
trace("start(): loadOnce success") buildMap<KClass<*>, ZiplineService> {
val apiMap = put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
buildMap<KClass<*>, ZiplineService> {
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME)) if (PluginAbility.METADATA in pluginInfo.abilities) {
put(
if (PluginAbility.METADATA in pluginInfo.abilities) { MetadataUserAPI::class,
put( result.zipline.take<MetadataUserAPI>(
MetadataUserAPI::class, MetadataUserAPI_SERVICE_NAME
result.zipline.take<MetadataUserAPI>( )
MetadataUserAPI_SERVICE_NAME )
) put(
) MetadataTrackAPI::class,
put( result.zipline.take<MetadataTrackAPI>(
MetadataTrackAPI::class, MetadataTrackAPI_SERVICE_NAME
result.zipline.take<MetadataTrackAPI>( )
MetadataTrackAPI_SERVICE_NAME )
) put(
) MetadataAlbumAPI::class,
put( result.zipline.take<MetadataAlbumAPI>(
MetadataAlbumAPI::class, MetadataAlbumAPI_SERVICE_NAME
result.zipline.take<MetadataAlbumAPI>( )
MetadataAlbumAPI_SERVICE_NAME )
) put(
) MetadataArtistAPI::class,
put( result.zipline.take<MetadataArtistAPI>(
MetadataArtistAPI::class, MetadataArtistAPI_SERVICE_NAME
result.zipline.take<MetadataArtistAPI>( )
MetadataArtistAPI_SERVICE_NAME )
) put(
) MetadataPlaylistAPI::class,
put( result.zipline.take<MetadataPlaylistAPI>(
MetadataPlaylistAPI::class, MetadataPlaylistAPI_SERVICE_NAME
result.zipline.take<MetadataPlaylistAPI>( )
MetadataPlaylistAPI_SERVICE_NAME )
) put(
) MetadataBrowseAPI::class,
put( result.zipline.take<MetadataBrowseAPI>(
MetadataBrowseAPI::class, MetadataBrowseAPI_SERVICE_NAME
result.zipline.take<MetadataBrowseAPI>( )
MetadataBrowseAPI_SERVICE_NAME )
) put(
) MetadataSearchAPI::class,
put( result.zipline.take<MetadataSearchAPI>(
MetadataSearchAPI::class, MetadataSearchAPI_SERVICE_NAME
result.zipline.take<MetadataSearchAPI>( )
MetadataSearchAPI_SERVICE_NAME )
) }
) if (PluginAbility.AUDIO in pluginInfo.abilities) {
} put(
if (PluginAbility.AUDIO in pluginInfo.abilities) { AudioAPI::class,
put( result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
AudioAPI::class, )
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME) }
) if (PluginAbility.LYRICS in pluginInfo.abilities) {
} put(
if (PluginAbility.LYRICS in pluginInfo.abilities) { LyricsAPI::class,
put( result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
LyricsAPI::class, )
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME) }
) if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
} put(
if (PluginAbility.SCROBBLE in pluginInfo.abilities) { ScrobbleAPI::class,
put( result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
ScrobbleAPI::class, )
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME) }
) }
}
} serviceRegistry.putAll(apiMap)
trace("start(): API ready")
serviceRegistry.putAll(apiMap) }
trace("start(): API ready")
} private fun runLogInFlowObservers() = scope.launch {
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
private fun runLogInFlowObservers() = scope.launch { coreAPI.loggedInFlow.collect { isLoggedIn ->
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI loggedInStateFlow.value = isLoggedIn
coreAPI.loggedInFlow.collect { isLoggedIn -> }
loggedInStateFlow.value = isLoggedIn }
}
} override suspend fun start() {
lifecycleMutex.withLock {
override suspend fun start() { trace("start(): entered")
lifecycleMutex.withLock { if (serviceRegistry.isNotEmpty()) {
trace("start(): entered") trace("start(): already started, skipping")
if (serviceRegistry.isNotEmpty()) { return
trace("start(): already started, skipping") }
return
} logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
withContext(ziplineDispatcher.dispatcher) {
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" } trace("start(): inside zipline dispatcher before loadOnce")
withContext(ziplineDispatcher.dispatcher) { val result = ziplineLoader.loadOnce(
trace("start(): inside zipline dispatcher before loadOnce") applicationName = applicationName,
val result = ziplineLoader.loadOnce( manifestUrl = manifestUrl,
applicationName = applicationName, freshnessChecker = DefaultFreshnessCheckerNotFresh)
manifestUrl = manifestUrl, when (result) {
freshnessChecker = DefaultFreshnessCheckerNotFresh, is LoadResult.Success -> {
) logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
when (result) { ziplineInstance = result.zipline
is LoadResult.Success -> { // Now we consume the initializer
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
ziplineInstance = result.zipline // Bind host services before initialization, so plugins can use them in their initializer
// Now we consume the initializer bindHostServices(result.zipline)
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME) trace("start(): calling initializer.initialize()")
// Bind host services before initialization, so plugins can use them in their initializer runCatching { initializer.initialize() }
bindHostServices(result.zipline) .onSuccess {
trace("start(): calling initializer.initialize()") consumePluginServices(result)
runCatching { initializer.initialize() } runLogInFlowObservers()
.onSuccess { }
consumePluginServices(result) .onFailure { e ->
runLogInFlowObservers() logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
} throw e
.onFailure { e -> }
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" } }
throw e
} is LoadResult.Failure -> {
} trace("start(): loadOnce failure: ${result.exception}")
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
is LoadResult.Failure -> { throw result.exception
trace("start(): loadOnce failure: ${result.exception}") }
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" } }
throw result.exception }
}
} }
} }
} override suspend fun stop() {
} lifecycleMutex.withLock {
trace("stop(): entered")
override suspend fun stop() { withContext(ziplineDispatcher.dispatcher) {
lifecycleMutex.withLock { ziplineInstance?.close()
trace("stop(): entered") ziplineInstance = null
withContext(ziplineDispatcher.dispatcher) { for (service in serviceRegistry.values) {
ziplineInstance?.close() try {
ziplineInstance = null trace("stop(): closing service ${service::class.simpleName}")
for (service in serviceRegistry.values) { service.close()
try { } catch (_: Exception) {
trace("stop(): closing service ${service::class.simpleName}") trace("stop(): error closing service ${service::class.simpleName}")
service.close() }
} catch (_: Exception) { }
trace("stop(): error closing service ${service::class.simpleName}") }
} serviceRegistry.clear()
} scope.cancel()
} loggedInStateFlow.value = false
serviceRegistry.clear() ziplineDispatcher.close()
scope.cancel() trace("stop(): completed")
loggedInStateFlow.value = false }
ziplineDispatcher.close() }
trace("stop(): completed")
} override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
} return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
// Create the scope with the current registry
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T { val scope = PluginServiceScope(serviceRegistry)
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) { // Execute the block with 'scope' as 'this'
// Create the scope with the current registry scope.block()
val scope = PluginServiceScope(serviceRegistry) }
// Execute the block with 'scope' as 'this' }
scope.block()
}
}
} }

View File

@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format {
} }
} }
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI { class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
private val provider = CryptographyProvider.Default private val provider = CryptographyProvider.Default
@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
return CryptographyRandom.nextBytes(size) return CryptographyRandom.nextBytes(size)
} }
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray { override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
val hasher = provider.get(algorithm.algorithm()).hasher() val hasher = provider.get(algorithm.algorithm()).hasher()
return hasher.hash(data) return hasher.hash(data)
@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
val keyPair = provider.get(RSA.PSS).keyPairGenerator( val keyPair = provider.get(RSA.PSS).keyPairGenerator(
algorithm.keySizeBits.bits, algorithm.keySizeBits.bits,
algorithm.hashAlgorithm.algorithm(), algorithm.hashAlgorithm.algorithm(),
algorithm.publicExponent.toBigInt(), algorithm.publicExponent.toBigInt()).generateKey()
).generateKey()
Pair( Pair(
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()), keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.ECDSA -> is SignAlgorithms.ECDSA ->
provider.get(ECDSA).privateKeyDecoder( provider.get(ECDSA).privateKeyDecoder(
algorithm.curve.curve(), algorithm.curve.curve()).decodeFromByteArray(
).decodeFromByteArray(
algorithm.format.privateKeyFormat(), algorithm.format.privateKeyFormat(),
privateKey privateKey
).signatureGenerator( ).signatureGenerator(
@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.EdDSA -> is SignAlgorithms.EdDSA ->
provider.get(EdDSA).privateKeyDecoder( provider.get(EdDSA).privateKeyDecoder(
algorithm.curve.curve(), algorithm.curve.curve()).decodeFromByteArray(
).decodeFromByteArray(
algorithm.format.privateKeyFormat(), algorithm.format.privateKeyFormat(),
privateKey privateKey
).signatureGenerator().generateSignature(data) ).signatureGenerator().generateSignature(data)
@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.ECDSA -> is SignAlgorithms.ECDSA ->
provider.get(ECDSA).publicKeyDecoder( provider.get(ECDSA).publicKeyDecoder(
algorithm.curve.curve(), algorithm.curve.curve()).decodeFromByteArray(
).decodeFromByteArray(
algorithm.format.publicKeyFormat(), algorithm.format.publicKeyFormat(),
publicKey publicKey
).signatureVerifier( ).signatureVerifier(
@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
is SignAlgorithms.EdDSA -> is SignAlgorithms.EdDSA ->
provider.get(EdDSA).publicKeyDecoder( provider.get(EdDSA).publicKeyDecoder(
algorithm.curve.curve(), algorithm.curve.curve()).decodeFromByteArray(
).decodeFromByteArray(
algorithm.format.publicKeyFormat(), algorithm.format.publicKeyFormat(),
publicKey publicKey
).signatureVerifier().verifySignature( ).signatureVerifier().verifySignature(

View File

@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText
import io.ktor.http.takeFrom import io.ktor.http.takeFrom
import org.koin.core.component.KoinComponent import org.koin.core.component.KoinComponent
class RealHttpClientAPI : HttpClientAPI, KoinComponent { class RealHttpClientAPI : HttpClientAPI, KoinComponent {
val logger by injectLogger<RealHttpClientAPI>() val logger by injectLogger<RealHttpClientAPI>()
val httpClient = HttpClient() val httpClient = HttpClient()

View File

@ -1,74 +1,73 @@
/* /*
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors * Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
* *
* This program is free software: you can redistribute it and/or modify * This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU Affero General Public License as published by * it under the terms of the GNU Affero General Public License as published by
* the Free Software Foundation, either version 3 of the License, or * the Free Software Foundation, either version 3 of the License, or
* (at your option) any later version. * (at your option) any later version.
* *
* This program is distributed in the hope that it will be useful, * This program is distributed in the hope that it will be useful,
* but WITHOUT ANY WARRANTY; without even the implied warranty of * but WITHOUT ANY WARRANTY; without even the implied warranty of
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
* GNU Affero General Public License for more details. * GNU Affero General Public License for more details.
* *
* You should have received a copy of the GNU Affero General Public License * You should have received a copy of the GNU Affero General Public License
* along with this program. If not, see <https://www.gnu.org/licenses/>. * along with this program. If not, see <https://www.gnu.org/licenses/>.
*/ */
package dev.krtirtho.spotube.core.zipline.host_apis package dev.krtirtho.spotube.core.zipline.host_apis
import dev.krtirtho.plugin_interfaces.host_apis.Cookie import dev.krtirtho.plugin_interfaces.host_apis.Cookie
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
import dev.krtirtho.spotube.core.webview.WebViewController import dev.krtirtho.spotube.core.webview.WebViewController
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
class RealWebViewAPI( class RealWebViewAPI(
private val scope: CoroutineScope, private val scope: CoroutineScope,
private val webViewController: WebViewController, private val webViewController: WebViewController,
private val pluginId: String, private val pluginId: String) : WebViewAPI {
) : WebViewAPI { override fun navigateTo(url: String) {
override fun navigateTo(url: String) { scope.launch(Dispatchers.Main) {
scope.launch(Dispatchers.Main) { webViewController.navigateTo(url, pluginId)
webViewController.navigateTo(url, pluginId) }
} }
}
override fun navigateToHTML(html: String) {
override fun navigateToHTML(html: String) { scope.launch {
scope.launch { webViewController.navigateToHTML(html, pluginId)
webViewController.navigateToHTML(html, pluginId) }
} }
}
override suspend fun getCookies(url: String): List<Cookie> {
override suspend fun getCookies(url: String): List<Cookie> { return withContext(Dispatchers.Main) {
return withContext(Dispatchers.Main) { webViewController.getCookies(url)
webViewController.getCookies(url) }
} }
}
override suspend fun evaluateJavaScript(script: String): String? {
override suspend fun evaluateJavaScript(script: String): String? { return withContext(Dispatchers.Main) {
return withContext(Dispatchers.Main) { webViewController.evaluateJavascript(script)
webViewController.evaluateJavascript(script) }
} }
}
override fun urlChangeFlow(): Flow<String> {
override fun urlChangeFlow(): Flow<String> { return webViewController.urlChangedFlow
return webViewController.urlChangedFlow }
} override fun webviewCreatedFlow(): Flow<Unit> {
override fun webviewCreatedFlow(): Flow<Unit> { return webViewController.webviewCreatedFlow
return webViewController.webviewCreatedFlow }
} override fun postMessagesFlow(): Flow<String> {
override fun postMessagesFlow(): Flow<String> { return webViewController.postMessagesFlow
return webViewController.postMessagesFlow }
}
override fun exitWebView() {
override fun exitWebView() { scope.launch(Dispatchers.Main) {
scope.launch(Dispatchers.Main) { webViewController.closeWebview()
webViewController.closeWebview() }
} }
}
} }

View File

@ -45,8 +45,7 @@ data class LRCLibResponse(
val artistName: String, val artistName: String,
val albumName: String, val albumName: String,
val plainLyrics: String?, val plainLyrics: String?,
val syncedLyrics: String?, val syncedLyrics: String?)
)
class RealLRCLibLyricsAPI : LyricsAPI { class RealLRCLibLyricsAPI : LyricsAPI {
private val httpClient = HttpClient { private val httpClient = HttpClient {
@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI {
val text = line.substring(match.range.last + 1).trim() val text = line.substring(match.range.last + 1).trim()
LyricsLine(time = timeMs, text = text) LyricsLine(time = timeMs, text = text)
}, },
plainLyrics = response.plainLyrics, plainLyrics = response.plainLyrics)
)
} }
} }

View File

@ -124,16 +124,14 @@ data class MusicGatewayResponse(
val externalIds: ExternalIds, val externalIds: ExternalIds,
val id: String, val id: String,
val name: String, val name: String,
val popularity: Int, val popularity: Int) {
) {
@Serializable @Serializable
data class Album( data class Album(
@SerialName("album_type") @SerialName("album_type")
val albumType: String, val albumType: String,
val artists: List<Artist>, val artists: List<Artist>,
val id: String, val id: String,
val name: String, val name: String)
)
@Serializable @Serializable
data class Artist( data class Artist(

View File

@ -57,7 +57,6 @@ class ISRCProviders {
} }
} }
private var ifpiSessionToken: String? = null private var ifpiSessionToken: String? = null
private val ifpiDefaultHeaders = mapOf( private val ifpiDefaultHeaders = mapOf(
"Content-Type" to "application/json", "Content-Type" to "application/json",
@ -76,7 +75,6 @@ class ISRCProviders {
"User-Agent" to UserAgents.random() "User-Agent" to UserAgents.random()
) )
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) { private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
if (ifpiSessionToken != null) return@withContext ifpiSessionToken if (ifpiSessionToken != null) return@withContext ifpiSessionToken

View File

@ -65,12 +65,10 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
Thumbnail( Thumbnail(
url = video.thumbnailUrl, url = video.thumbnailUrl,
width = 300, width = 300,
height = 300, height = 300)
)
), ),
externalUri = "https://www.youtube.com/watch?v=${video.id}", externalUri = "https://www.youtube.com/watch?v=${video.id}",
confidence = confidence, confidence = confidence)
)
} }
override val supportedQualities: List<AudioFormat> = listOf( override val supportedQualities: List<AudioFormat> = listOf(
@ -79,15 +77,13 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000), AudioQuality.Lossy(bitrate = 256_000))
)
), AudioFormat( ), AudioFormat(
codec = "aac", container = "mp4", qualities = listOf( codec = "aac", container = "mp4", qualities = listOf(
AudioQuality.Lossy(bitrate = 44_000), AudioQuality.Lossy(bitrate = 44_000),
AudioQuality.Lossy(bitrate = 96_000), AudioQuality.Lossy(bitrate = 96_000),
AudioQuality.Lossy(bitrate = 128_000), AudioQuality.Lossy(bitrate = 128_000),
AudioQuality.Lossy(bitrate = 256_000), AudioQuality.Lossy(bitrate = 256_000))
)
) )
) )
@ -98,7 +94,6 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
)) ))
val isYouTubeID = track.id.matches(youtubeIDRegex) val isYouTubeID = track.id.matches(youtubeIDRegex)
logger.i { logger.i {
"Searching streams for track(${track.id}): ${track.title} by ${ "Searching streams for track(${track.id}): ${track.title} by ${
track.artists.joinToString( track.artists.joinToString(
@ -119,13 +114,11 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
Thumbnail( Thumbnail(
url = videoInfo.thumbnailUrl, url = videoInfo.thumbnailUrl,
width = 300, width = 300,
height = 300, height = 300)
)
), ),
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
confidence = 1.0f, confidence = 1.0f,
streams = videoInfo.audioStreams, streams = videoInfo.audioStreams)
)
) )
} }
} }
@ -165,8 +158,7 @@ class RealNewPipeAudioAPI : AudioAPI, KoinComponent {
Thumbnail( Thumbnail(
url = videoInfo.thumbnailUrl, url = videoInfo.thumbnailUrl,
width = 300, width = 300,
height = 300, height = 300)
)
), ),
externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}", externalUri = "https://www.youtube.com/watch?v=${videoInfo.id}",
confidence = source.confidence, confidence = source.confidence,

View File

@ -36,8 +36,7 @@ import kotlinx.coroutines.launch
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class AlbumRepository( class AlbumRepository(
val pluginManager: PluginManager, val pluginManager: PluginManager,
private val libraryRepository: LibraryRepository, private val libraryRepository: LibraryRepository) {
) {
val scope = CoroutineScope(Dispatchers.Default + SupervisorJob()) val scope = CoroutineScope(Dispatchers.Default + SupervisorJob())
val plugin val plugin

View File

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

View File

@ -65,15 +65,13 @@ sealed interface AlbumScreenState {
override val isSaved: Boolean = false, override val isSaved: Boolean = false,
override val tracks: List<MetadataTrack> = emptyList(), override val tracks: List<MetadataTrack> = emptyList(),
override val nextPagination: PaginationStrategy? = null, override val nextPagination: PaginationStrategy? = null,
override val isSaving: Boolean = false, override val isSaving: Boolean = false) : Data {
) : Data {
fun toLoadingMore(): LoadingMore = LoadingMore( fun toLoadingMore(): LoadingMore = LoadingMore(
album = album, album = album,
isSaved = isSaved, isSaved = isSaved,
tracks = tracks, tracks = tracks,
nextPagination = nextPagination, nextPagination = nextPagination,
isSaving = isSaving, isSaving = isSaving)
)
} }
data class LoadingMore( data class LoadingMore(
@ -81,8 +79,7 @@ sealed interface AlbumScreenState {
override val isSaved: Boolean = false, override val isSaved: Boolean = false,
override val tracks: List<MetadataTrack> = emptyList(), override val tracks: List<MetadataTrack> = emptyList(),
override val nextPagination: PaginationStrategy? = null, override val nextPagination: PaginationStrategy? = null,
override val isSaving: Boolean = false, override val isSaving: Boolean = false) : Data
) : Data
} }
data class Error(val message: String) : AlbumScreenState data class Error(val message: String) : AlbumScreenState
@ -99,8 +96,7 @@ class AlbumViewModel(
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService, private val shareService: ShareService,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController, private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent {
) : ViewModel(), KoinComponent {
private val logger by injectLogger<AlbumViewModel>() private val logger by injectLogger<AlbumViewModel>()
private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading) private val _state = MutableStateFlow<AlbumScreenState>(AlbumScreenState.Loading)
@ -126,8 +122,7 @@ class AlbumViewModel(
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow, savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds, _blacklistedTrackIds,
_blacklistedArtistIds, _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext( TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry -> queueTrackIds = queue.mapNotNull { entry ->
@ -135,8 +130,7 @@ class AlbumViewModel(
}.toSet(), }.toSet(),
savedTrackIds = savedIds, savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks, blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists, blacklistedArtistIds = blacklistedArtists)
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init { init {
@ -175,8 +169,7 @@ class AlbumViewModel(
album = albumInfo?.first, album = albumInfo?.first,
isSaved = albumInfo?.second ?: false, isSaved = albumInfo?.second ?: false,
tracks = tracksResult?.items ?: emptyList(), tracks = tracksResult?.items ?: emptyList(),
nextPagination = tracksResult?.nextPagination, nextPagination = tracksResult?.nextPagination)
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load album" } logger.e(e) { "Failed to load album" }
_state.value = AlbumScreenState.Error(e.message ?: "Unknown error") _state.value = AlbumScreenState.Error(e.message ?: "Unknown error")
@ -193,8 +186,7 @@ class AlbumViewModel(
isSaved = currentState.isSaved, isSaved = currentState.isSaved,
tracks = currentState.tracks + (result?.items ?: emptyList()), tracks = currentState.tracks + (result?.items ?: emptyList()),
nextPagination = result?.nextPagination, nextPagination = result?.nextPagination,
isSaving = currentState.isSaving, isSaving = currentState.isSaving)
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more tracks" } logger.e(e) { "Failed to load more tracks" }
@ -245,8 +237,7 @@ class AlbumViewModel(
type = RemoteCollectionType.Album, type = RemoteCollectionType.Album,
id = albumId, id = albumId,
title = title, title = title,
startTrack = track, startTrack = track)
)
} }
fun refresh() { fun refresh() {
@ -319,8 +310,7 @@ class AlbumViewModel(
runCatching { runCatching {
libraryRepository.addTracksToPlaylist( libraryRepository.addTracksToPlaylist(
playlistId, playlistId,
_tracksToAddToPlaylist.value.map { it.id }, _tracksToAddToPlaylist.value.map { it.id })
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" } logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess { }.onSuccess {

View File

@ -17,6 +17,15 @@
package dev.krtirtho.spotube.modules.artist package dev.krtirtho.spotube.modules.artist
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumButton
import org.spotube.millennium.composables.MillenniumButtonStyle
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import org.spotube.millennium.composables.MillenniumCard
import org.spotube.millennium.composables.MillenniumTooltip
import org.spotube.millennium.theme.MillenniumTheme
import androidx.compose.foundation.background import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@ -35,19 +44,9 @@ import androidx.compose.foundation.lazy.LazyRow
import androidx.compose.foundation.lazy.items import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Card import dev.krtirtho.spotube.core.ui.theming.AppTheme
import androidx.compose.material3.CardDefaults import com.composeunstyled.Text
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.PlainTooltip
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Text
import androidx.compose.material3.TooltipAnchorPosition
import androidx.compose.material3.TooltipBox
import androidx.compose.material3.TooltipDefaults
import androidx.compose.material3.rememberTooltipState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -74,10 +73,6 @@ import dev.krtirtho.spotube.core.jam.JamRoomService
import dev.krtirtho.spotube.core.navigation.NavigationCommands import dev.krtirtho.spotube.core.navigation.NavigationCommands
import dev.krtirtho.spotube.core.navigation.Routes import dev.krtirtho.spotube.core.navigation.Routes
import org.koin.compose.koinInject import org.koin.compose.koinInject
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
import dev.krtirtho.spotube.core.ui.base.SecondaryButton
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
import dev.krtirtho.spotube.core.ui.component.AlbumCard import dev.krtirtho.spotube.core.ui.component.AlbumCard
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
import dev.krtirtho.spotube.core.ui.component.ArtistCard import dev.krtirtho.spotube.core.ui.component.ArtistCard
@ -118,17 +113,19 @@ fun ArtistScreen(
val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle() val trackOptionsContext by viewModel.trackOptionsContext.collectAsStateWithLifecycle()
val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle() val showAddToPlaylistPicker by viewModel.showAddToPlaylistPicker.collectAsStateWithLifecycle()
BaseScaffold( Column(
topBar = { ApplicationMainBar() } modifier = Modifier
) { innerPadding -> .fillMaxSize()
.background(MillenniumTheme.colors.background)) {
ApplicationMainBar()
Box(modifier = Modifier.fillMaxWidth().weight(1f)) {
when (val currentState = state) { when (val currentState = state) {
is ArtistScreenState.Loading -> { is ArtistScreenState.Loading -> {
ArtistLoadingContent(innerPadding) ArtistLoadingContent()
} }
is ArtistScreenState.Error -> { is ArtistScreenState.Error -> {
ArtistErrorContent( ArtistErrorContent(
innerPadding = innerPadding,
message = currentState.message, message = currentState.message,
onRetry = { viewModel.refresh() } onRetry = { viewModel.refresh() }
) )
@ -141,25 +138,21 @@ fun ArtistScreen(
state = listState, state = listState,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.padding(innerPadding)
.padding(bottom = LocalAppShellBottomInset.current + 24.dp), .padding(bottom = LocalAppShellBottomInset.current + 24.dp),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
) {
item { item {
ArtistHeaderCard( ArtistHeaderCard(
artist = currentState.artist, artist = currentState.artist,
isSaved = savedArtistIds.contains(currentState.artist.id), isSaved = savedArtistIds.contains(currentState.artist.id),
onFollowClick = viewModel::toggleSavedArtist, onFollowClick = viewModel::toggleSavedArtist,
isBlacklisted = currentState.artist.id in blacklistedArtistIds, isBlacklisted = currentState.artist.id in blacklistedArtistIds,
onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) }, onBlacklistClick = { viewModel.toggleArtistBlacklist(currentState.artist) })
)
} }
item { item {
TopTracksHeader( TopTracksHeader(
onPlay = viewModel::playTopTracks, onPlay = viewModel::playTopTracks,
onAddToQueue = viewModel::addTopTracksToQueue, onAddToQueue = viewModel::addTopTracksToQueue)
)
} }
item { item {
@ -197,8 +190,7 @@ fun ArtistScreen(
onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker, onBulkAddToPlaylist = viewModel::showAddToPlaylistPicker,
onBulkAddToJam = viewModel::addTracksToJam, onBulkAddToJam = viewModel::addTracksToJam,
isInJam = jamActive, isInJam = jamActive,
isJamGuest = isJamGuest, isJamGuest = isJamGuest)
)
} }
if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) { if (currentState.albums.isNotEmpty() || currentState.albumsNextPagination != null) {
@ -212,8 +204,7 @@ fun ArtistScreen(
) )
) )
}, },
onLoadMore = { viewModel.loadMoreAlbums() }, onLoadMore = { viewModel.loadMoreAlbums() })
)
} }
} }
@ -228,8 +219,7 @@ fun ArtistScreen(
) )
) )
}, },
onLoadMore = { viewModel.loadMoreRelatedArtists() }, onLoadMore = { viewModel.loadMoreRelatedArtists() })
)
} }
} }
@ -244,73 +234,62 @@ fun ArtistScreen(
) )
) )
}, },
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }, onLoadMore = { viewModel.loadMoreFeaturedPlaylists() })
)
} }
} }
} }
} }
} }
}
AddToPlaylistPicker( AddToPlaylistPicker(
visible = showAddToPlaylistPicker, visible = showAddToPlaylistPicker,
currentUserId = currentUserId, currentUserId = currentUserId,
onDismiss = viewModel::dismissAddToPlaylistPicker, onDismiss = viewModel::dismissAddToPlaylistPicker,
onPlaylistSelected = viewModel::addTracksToPlaylist, onPlaylistSelected = viewModel::addTracksToPlaylist)
)
} }
} }
@Composable @Composable
private fun ArtistLoadingContent(innerPadding: PaddingValues) { private fun ArtistLoadingContent() {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize(),
.padding(innerPadding), contentAlignment = Alignment.Center) {
contentAlignment = Alignment.Center,
) {
SkeletonTree(true) { SkeletonTree(true) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(24.dp), verticalArrangement = Arrangement.spacedBy(24.dp)) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp)) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(200.dp) .size(200.dp)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest), .background(AppTheme.colors.surfaceContainerHighest))
)
} }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
verticalArrangement = Arrangement.spacedBy(8.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp)) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(vertical = 8.dp), .padding(vertical = 8.dp))
)
} }
LazyRow( LazyRow(
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp)) {
) {
items(4) { items(4) {
PlayableCard( PlayableCard(
title = "Item Title", title = "Item Title",
subtitle = "Subtitle", subtitle = "Subtitle",
imageURL = "https://placehold.co/600x400", imageURL = "https://placehold.co/600x400")
)
} }
} }
} }
@ -321,26 +300,20 @@ private fun ArtistLoadingContent(innerPadding: PaddingValues) {
@Composable @Composable
private fun ArtistErrorContent( private fun ArtistErrorContent(
innerPadding: PaddingValues,
message: String, message: String,
onRetry: () -> Unit, onRetry: () -> Unit) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize(),
.padding(innerPadding), contentAlignment = Alignment.Center) {
contentAlignment = Alignment.Center,
) {
Column( Column(
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(16.dp), verticalArrangement = Arrangement.spacedBy(16.dp)) {
) {
Text( Text(
text = message, text = message,
color = MaterialTheme.colorScheme.error, color = AppTheme.colors.error,
style = MaterialTheme.typography.bodyLarge, style = AppTheme.typography.bodyLarge)
) MillenniumButton(onClick = onRetry) {
PrimaryButton(onClick = onRetry) {
Text("Retry") Text("Retry")
} }
} }
@ -353,44 +326,34 @@ private fun ArtistHeaderCard(
isSaved: Boolean, isSaved: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean, isBlacklisted: Boolean,
onBlacklistClick: () -> Unit, onBlacklistClick: () -> Unit) {
) {
BoxWithConstraints(modifier = Modifier.fillMaxWidth()) { BoxWithConstraints(modifier = Modifier.fillMaxWidth()) {
val isCompact = maxWidth < 600.dp val isCompact = maxWidth < 600.dp
Card( MillenniumCard(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 12.dp, vertical = 8.dp), .padding(horizontal = 12.dp, vertical = 8.dp)) {
colors = CardDefaults.cardColors(
containerColor = MaterialTheme.colorScheme.surfaceContainer,
),
shape = RoundedCornerShape(20.dp),
) {
if (isCompact) { if (isCompact) {
Column( Column(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(16.dp), .padding(16.dp),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally) {
) {
ArtistAvatar( ArtistAvatar(
artist = artist, artist = artist,
size = 180.dp, size = 180.dp)
)
ArtistMeta( ArtistMeta(
artist = artist, artist = artist,
isCompact = true, isCompact = true)
)
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick, onBlacklistClick = onBlacklistClick)
)
} }
} else { } else {
Row( Row(
@ -398,28 +361,23 @@ private fun ArtistHeaderCard(
.fillMaxWidth() .fillMaxWidth()
.padding(20.dp), .padding(20.dp),
horizontalArrangement = Arrangement.spacedBy(20.dp), horizontalArrangement = Arrangement.spacedBy(20.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top) {
) {
ArtistAvatar( ArtistAvatar(
artist = artist, artist = artist,
size = 220.dp, size = 220.dp)
)
Column( Column(
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
verticalArrangement = Arrangement.spacedBy(14.dp), verticalArrangement = Arrangement.spacedBy(14.dp)) {
) {
ArtistMeta( ArtistMeta(
artist = artist, artist = artist,
isCompact = false, isCompact = false)
)
ArtistHeaderActions( ArtistHeaderActions(
isSaved = isSaved, isSaved = isSaved,
onFollowClick = onFollowClick, onFollowClick = onFollowClick,
isBlacklisted = isBlacklisted, isBlacklisted = isBlacklisted,
onBlacklistClick = onBlacklistClick, onBlacklistClick = onBlacklistClick)
)
} }
} }
} }
@ -430,30 +388,26 @@ private fun ArtistHeaderCard(
@Composable @Composable
private fun ArtistAvatar( private fun ArtistAvatar(
artist: MetadataArtist.Detailed, artist: MetadataArtist.Detailed,
size: androidx.compose.ui.unit.Dp, size: androidx.compose.ui.unit.Dp) {
) {
Box( Box(
modifier = Modifier modifier = Modifier
.size(size) .size(size)
.clip(CircleShape) .clip(CircleShape)
.background(MaterialTheme.colorScheme.surfaceContainerHighest), .background(AppTheme.colors.surfaceContainerHighest),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty() val imageUrl = artist.thumbnails.firstOrNull()?.url.orEmpty()
if (imageUrl.isNotBlank()) { if (imageUrl.isNotBlank()) {
AsyncImage( AsyncImage(
model = imageUrl, model = imageUrl,
contentDescription = artist.name, contentDescription = artist.name,
modifier = Modifier.fillMaxSize(), modifier = Modifier.fillMaxSize(),
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop)
)
} else { } else {
Icon( MillenniumIcon(
imageVector = Iconsax.User, imageVector = Iconsax.User,
contentDescription = artist.name, contentDescription = artist.name,
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = AppTheme.colors.onSurfaceVariant,
modifier = Modifier.size(size * 0.4f), modifier = Modifier.size(size * 0.4f))
)
} }
} }
} }
@ -461,21 +415,18 @@ private fun ArtistAvatar(
@Composable @Composable
private fun ArtistMeta( private fun ArtistMeta(
artist: MetadataArtist.Detailed, artist: MetadataArtist.Detailed,
isCompact: Boolean, isCompact: Boolean) {
) {
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start, horizontalAlignment = if (isCompact) Alignment.CenterHorizontally else Alignment.Start,
verticalArrangement = Arrangement.spacedBy(10.dp), verticalArrangement = Arrangement.spacedBy(10.dp)) {
) {
Text( Text(
text = artist.name, text = artist.name,
style = if (isCompact) MaterialTheme.typography.headlineSmall else MaterialTheme.typography.headlineLarge, style = if (isCompact) AppTheme.typography.headlineSmall else AppTheme.typography.headlineLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start,
maxLines = 2, maxLines = 2,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
Text( Text(
text = buildString { text = buildString {
@ -485,74 +436,66 @@ private fun ArtistMeta(
append(artist.genres.joinToString(", ")) append(artist.genres.joinToString(", "))
} }
}, },
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start)
)
artist.biography?.takeIf { it.isNotBlank() }?.let { artist.biography?.takeIf { it.isNotBlank() }?.let {
Text( Text(
text = it, text = it,
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
maxLines = if (isCompact) 4 else 6, maxLines = if (isCompact) 4 else 6,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
textAlign = if (isCompact) TextAlign.Center else TextAlign.Start, textAlign = if (isCompact) TextAlign.Center else TextAlign.Start)
)
} }
} }
} }
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
private fun ArtistHeaderActions( private fun ArtistHeaderActions(
isSaved: Boolean, isSaved: Boolean,
onFollowClick: () -> Unit, onFollowClick: () -> Unit,
isBlacklisted: Boolean, isBlacklisted: Boolean,
onBlacklistClick: () -> Unit, onBlacklistClick: () -> Unit) {
) {
Row( Row(
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically) {
) {
if (isSaved) { if (isSaved) {
SecondaryButton(onClick = onFollowClick) { MillenniumButton(
onClick = onFollowClick,
style = MillenniumButtonStyle.Secondary) {
Text( Text(
"Following", "Following",
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis)
)
} }
} else { } else {
PrimaryButton(onClick = onFollowClick) { MillenniumButton(onClick = onFollowClick) {
Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center) Text("Follow", modifier = Modifier.width(65.dp), textAlign = TextAlign.Center)
} }
} }
TooltipBox( MillenniumTooltip(
positionProvider = TooltipDefaults.rememberTooltipPositionProvider(TooltipAnchorPosition.Above), text = if (isBlacklisted) "Remove from blacklist" else "Add to blacklist"
tooltip = {
PlainTooltip {
Text(if (isBlacklisted) "Remove from blacklist" else "Add to blacklist")
}
},
state = rememberTooltipState()
) { ) {
if (isBlacklisted) { if (isBlacklisted) {
SecondaryIconButton(onClick = onBlacklistClick) { MillenniumIconButton(
Icon( onClick = onBlacklistClick,
style = MillenniumIconButtonStyle.Secondary) {
MillenniumIcon(
imageVector = Iconsax.IconsaxUserRemove, imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Remove from blacklist", contentDescription = "Remove from blacklist",
tint = MaterialTheme.colorScheme.error, tint = AppTheme.colors.error)
)
} }
} else { } else {
SecondaryIconButton(onClick = onBlacklistClick) { MillenniumIconButton(
Icon( onClick = onBlacklistClick,
style = MillenniumIconButtonStyle.Secondary) {
MillenniumIcon(
imageVector = Iconsax.IconsaxUserRemove, imageVector = Iconsax.IconsaxUserRemove,
contentDescription = "Add to blacklist", contentDescription = "Add to blacklist")
)
} }
} }
} }
@ -562,33 +505,32 @@ private fun ArtistHeaderActions(
@Composable @Composable
private fun TopTracksHeader( private fun TopTracksHeader(
onPlay: () -> Unit, onPlay: () -> Unit,
onAddToQueue: () -> Unit, onAddToQueue: () -> Unit) {
) {
Row( Row(
modifier = Modifier modifier = Modifier
.fillMaxWidth() .fillMaxWidth()
.padding(horizontal = 16.dp), .padding(horizontal = 16.dp),
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.SpaceBetween, horizontalArrangement = Arrangement.SpaceBetween) {
) {
Text( Text(
text = "Top Tracks", text = "Top Tracks",
style = MaterialTheme.typography.titleLarge, style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold)
)
Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) { Row(horizontalArrangement = Arrangement.spacedBy(10.dp)) {
PrimaryIconButton(onClick = onPlay) { MillenniumIconButton(
Icon( onClick = onPlay,
style = MillenniumIconButtonStyle.Primary) {
MillenniumIcon(
imageVector = Iconsax.IconsaxPlay, imageVector = Iconsax.IconsaxPlay,
contentDescription = "Play top tracks", contentDescription = "Play top tracks")
)
} }
SecondaryIconButton(onClick = onAddToQueue) { MillenniumIconButton(
Icon( onClick = onAddToQueue,
style = MillenniumIconButtonStyle.Secondary) {
MillenniumIcon(
imageVector = Iconsax.IconsaxAddSquare, imageVector = Iconsax.IconsaxAddSquare,
contentDescription = "Add top tracks to queue", contentDescription = "Add top tracks to queue")
)
} }
} }
} }
@ -598,8 +540,7 @@ private fun TopTracksHeader(
private fun AlbumsSection( private fun AlbumsSection(
albums: List<MetadataAlbum.Detailed>, albums: List<MetadataAlbum.Detailed>,
onAlbumClick: (MetadataAlbum.Detailed) -> Unit, onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit) {
) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
LaunchedEffect(rowState) { LaunchedEffect(rowState) {
@ -624,8 +565,7 @@ private fun AlbumsSection(
modifier = Modifier.dragScrollable(rowState), modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top) {
) {
items(albums, key = { it.id }) { album -> items(albums, key = { it.id }) { album ->
AlbumCard(album = album) AlbumCard(album = album)
} }
@ -636,8 +576,7 @@ private fun AlbumsSection(
private fun RelatedArtistsSection( private fun RelatedArtistsSection(
artists: List<MetadataArtist.Basic>, artists: List<MetadataArtist.Basic>,
onArtistClick: (MetadataArtist.Basic) -> Unit, onArtistClick: (MetadataArtist.Basic) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit) {
) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
LaunchedEffect(rowState) { LaunchedEffect(rowState) {
@ -662,8 +601,7 @@ private fun RelatedArtistsSection(
modifier = Modifier.dragScrollable(rowState), modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top) {
) {
items(artists, key = { it.id }) { artist -> items(artists, key = { it.id }) { artist ->
ArtistCard(artist = artist) ArtistCard(artist = artist)
} }
@ -674,8 +612,7 @@ private fun RelatedArtistsSection(
private fun FeaturedPlaylistsSection( private fun FeaturedPlaylistsSection(
playlists: List<MetadataPlaylist>, playlists: List<MetadataPlaylist>,
onPlaylistClick: (MetadataPlaylist) -> Unit, onPlaylistClick: (MetadataPlaylist) -> Unit,
onLoadMore: () -> Unit, onLoadMore: () -> Unit) {
) {
val rowState = rememberLazyListState() val rowState = rememberLazyListState()
LaunchedEffect(rowState) { LaunchedEffect(rowState) {
@ -700,8 +637,7 @@ private fun FeaturedPlaylistsSection(
modifier = Modifier.dragScrollable(rowState), modifier = Modifier.dragScrollable(rowState),
contentPadding = PaddingValues(horizontal = 16.dp), contentPadding = PaddingValues(horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp), horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.Top, verticalAlignment = Alignment.Top) {
) {
items(playlists, key = { it.id }) { playlist -> items(playlists, key = { it.id }) { playlist ->
PlaylistCard(playlist = playlist) PlaylistCard(playlist = playlist)
} }
@ -712,10 +648,9 @@ private fun FeaturedPlaylistsSection(
private fun SectionHeader(title: String) { private fun SectionHeader(title: String) {
Text( Text(
text = title, text = title,
style = MaterialTheme.typography.titleLarge, style = AppTheme.typography.titleLarge,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.padding(horizontal = 16.dp), modifier = Modifier.padding(horizontal = 16.dp))
)
} }
private fun formatFollowers(count: Int?): String { private fun formatFollowers(count: Int?): String {

View File

@ -59,8 +59,7 @@ sealed interface ArtistScreenState {
val relatedArtists: List<MetadataArtist.Basic>, val relatedArtists: List<MetadataArtist.Basic>,
val relatedArtistsNextPagination: PaginationStrategy?, val relatedArtistsNextPagination: PaginationStrategy?,
val featuredPlaylists: List<MetadataPlaylist>, val featuredPlaylists: List<MetadataPlaylist>,
val featuredPlaylistsNextPagination: PaginationStrategy?, val featuredPlaylistsNextPagination: PaginationStrategy?) : ArtistScreenState
) : ArtistScreenState
data class Error(val message: String) : ArtistScreenState data class Error(val message: String) : ArtistScreenState
} }
@ -75,8 +74,7 @@ class ArtistViewModel(
private val blacklistRepository: BlacklistRepository, private val blacklistRepository: BlacklistRepository,
private val shareService: ShareService, private val shareService: ShareService,
private val downloadManager: DownloadManager, private val downloadManager: DownloadManager,
private val remotePlaybackController: RemotePlaybackController, private val remotePlaybackController: RemotePlaybackController) : ViewModel(), KoinComponent {
) : ViewModel(), KoinComponent {
private val logger by injectLogger<ArtistViewModel>() private val logger by injectLogger<ArtistViewModel>()
private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading) private val _state = MutableStateFlow<ArtistScreenState>(ArtistScreenState.Loading)
@ -106,8 +104,7 @@ class ArtistViewModel(
audioPlayerQueue.currentQueueEntryFlow, audioPlayerQueue.currentQueueEntryFlow,
savedTracksRepository.savedTracksIdsFlow, savedTracksRepository.savedTracksIdsFlow,
_blacklistedTrackIds, _blacklistedTrackIds,
_blacklistedArtistIds, _blacklistedArtistIds) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
) { queue, currentEntry, savedIds, blacklistedTracks, blacklistedArtists ->
TrackOptionsContext( TrackOptionsContext(
currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id, currentTrackId = (currentEntry as? QueueEntry.StreamingTrack)?.track?.id,
queueTrackIds = queue.mapNotNull { entry -> queueTrackIds = queue.mapNotNull { entry ->
@ -115,8 +112,7 @@ class ArtistViewModel(
}.toSet(), }.toSet(),
savedTrackIds = savedIds, savedTrackIds = savedIds,
blacklistedTrackIds = blacklistedTracks, blacklistedTrackIds = blacklistedTracks,
blacklistedArtistIds = blacklistedArtists, blacklistedArtistIds = blacklistedArtists)
)
}.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext()) }.stateIn(viewModelScope, SharingStarted.WhileSubscribed(5000), TrackOptionsContext())
init { init {
@ -198,8 +194,7 @@ class ArtistViewModel(
if (result != null) { if (result != null) {
_state.value = currentState.copy( _state.value = currentState.copy(
albums = currentState.albums + result.items, albums = currentState.albums + result.items,
albumsNextPagination = result.nextPagination, albumsNextPagination = result.nextPagination)
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more albums" } logger.e(e) { "Failed to load more albums" }
@ -219,8 +214,7 @@ class ArtistViewModel(
if (result != null) { if (result != null) {
_state.value = currentState.copy( _state.value = currentState.copy(
relatedArtists = currentState.relatedArtists + result.items, relatedArtists = currentState.relatedArtists + result.items,
relatedArtistsNextPagination = result.nextPagination, relatedArtistsNextPagination = result.nextPagination)
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more related artists" } logger.e(e) { "Failed to load more related artists" }
@ -240,8 +234,7 @@ class ArtistViewModel(
if (result != null) { if (result != null) {
_state.value = currentState.copy( _state.value = currentState.copy(
featuredPlaylists = currentState.featuredPlaylists + result.items, featuredPlaylists = currentState.featuredPlaylists + result.items,
featuredPlaylistsNextPagination = result.nextPagination, featuredPlaylistsNextPagination = result.nextPagination)
)
} }
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to load more featured playlists" } logger.e(e) { "Failed to load more featured playlists" }
@ -254,8 +247,7 @@ class ArtistViewModel(
remotePlaybackController.requestCollectionAddToQueue( remotePlaybackController.requestCollectionAddToQueue(
RemoteCollectionType.ArtistTopTracks, RemoteCollectionType.ArtistTopTracks,
artistId, artistId,
artistName, artistName)
)
} }
fun playTopTracks() { fun playTopTracks() {
@ -263,8 +255,7 @@ class ArtistViewModel(
remotePlaybackController.requestCollectionPlay( remotePlaybackController.requestCollectionPlay(
RemoteCollectionType.ArtistTopTracks, RemoteCollectionType.ArtistTopTracks,
artistId, artistId,
artistName, artistName)
)
} }
fun playTopTracksFromTrack(track: MetadataTrack) { fun playTopTracksFromTrack(track: MetadataTrack) {
@ -273,8 +264,7 @@ class ArtistViewModel(
type = RemoteCollectionType.ArtistTopTracks, type = RemoteCollectionType.ArtistTopTracks,
id = artistId, id = artistId,
title = artistName, title = artistName,
startTrack = track, startTrack = track)
)
} }
fun addTracksToJam(tracks: List<MetadataTrack>) { fun addTracksToJam(tracks: List<MetadataTrack>) {
remotePlaybackController.addTracksToJam(tracks) remotePlaybackController.addTracksToJam(tracks)
@ -349,8 +339,7 @@ class ArtistViewModel(
runCatching { runCatching {
libraryRepository.addTracksToPlaylist( libraryRepository.addTracksToPlaylist(
playlistId, playlistId,
_tracksToAddToPlaylist.value.map { it.id }, _tracksToAddToPlaylist.value.map { it.id })
)
}.onFailure { e -> }.onFailure { e ->
logger.e(e) { "Failed to add tracks to playlist" } logger.e(e) { "Failed to add tracks to playlist" }
}.onSuccess { }.onSuccess {
@ -389,8 +378,7 @@ class ArtistViewModel(
relatedArtists = overview.relatedArtists.items, relatedArtists = overview.relatedArtists.items,
relatedArtistsNextPagination = overview.relatedArtists.nextPagination, relatedArtistsNextPagination = overview.relatedArtists.nextPagination,
featuredPlaylists = overview.featuredPlaylists.items, featuredPlaylists = overview.featuredPlaylists.items,
featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination, featuredPlaylistsNextPagination = overview.featuredPlaylists.nextPagination)
)
} else { } else {
_state.value = ArtistScreenState.Error("Failed to load artist overview") _state.value = ArtistScreenState.Error("Failed to load artist overview")
} }

View File

@ -29,8 +29,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.encodeToString import kotlinx.serialization.encodeToString
open class BlacklistRepository( open class BlacklistRepository(
private val database: Database, private val database: Database) {
) {
companion object { companion object {
private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks") private val BLACKLISTED_TRACKS_KEY = stringPreferencesKey("blacklisted_tracks")
private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists") private val BLACKLISTED_ARTISTS_KEY = stringPreferencesKey("blacklisted_artists")

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