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,20 +148,6 @@ fun App(
} }
SpotubeTheme(settings = userSettings) { SpotubeTheme(settings = userSettings) {
val isDark = MaterialTheme.colorScheme.surface.luminance() < 0.5f
val accent = if (isDark) {
userSettings.accentColor.toDarkColor()
} else {
userSettings.accentColor.toLightColor()
}
val baseUITheme = rememberBaseUITheme(
surfaceTheme = userSettings.surfaceTheme,
accentColor = accent,
)
CompositionLocalProvider(
LocalBaseUITheme provides baseUITheme,
LocalBaseUIColors provides baseUITheme.colors,
) {
Box(modifier = Modifier.fillMaxSize()) { Box(modifier = Modifier.fillMaxSize()) {
val currentRoute = val currentRoute =
navigationState.backStacks[navigationState.topLevelRoute]?.last() navigationState.backStacks[navigationState.topLevelRoute]?.last()
@ -181,8 +156,7 @@ fun App(
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(),
@ -208,8 +182,7 @@ fun App(
onUpdate = { onUpdate = {
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") } runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
appUpdateService.dismissUpdate() appUpdateService.dismissUpdate()
}, })
)
} }
availablePluginUpdate?.let { update -> availablePluginUpdate?.let { update ->
PluginUpdateDialog( PluginUpdateDialog(
@ -219,9 +192,7 @@ fun App(
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 = "Remote Control Request",
style = MaterialTheme.typography.titleLarge,
)
},
content = {
Text( Text(
text = "\"${request.deviceName}\" wants to control playback on this device.", text = "\"${request.deviceName}\" wants to control playback on this device.",
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
textAlign = TextAlign.Start, textAlign = TextAlign.Start)
) Row(
}, modifier = Modifier.fillMaxWidth(),
actions = { horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
Button( verticalAlignment = Alignment.CenterVertically) {
MillenniumButton(
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,45 +110,40 @@ 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 = {
DropdownMenuPanel(
Modifier
.width(menuMinWidth)
.millenniumSurface(
background = millenniumPanelBrush(MillenniumTheme.colors),
shape = AppTheme.shapes.medium,
borderColor = AppTheme.colors.outlineVariant,
borderWidth = 1.dp)
.padding(4.dp)) {
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) { if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
header() header()
} }
var query by remember { mutableStateOf("") } val dropdownQueryState = rememberTextFieldState()
val query = dropdownQueryState.text.toString()
if (filter != null) { if (filter != null) {
TextField( MillenniumTextField(
value = query, state = dropdownQueryState,
onValueChange = { label = "",
query = it placeholder = "Search...",
},
placeholder = { Text("Search...") },
singleLine = true,
leadingIcon = {
Icon(
imageVector = Iconsax.IconsaxFilterSearch,
contentDescription = "Search",
modifier = Modifier.size(16.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))
)
} }
items.forEach { item -> items.forEach { item ->
@ -147,23 +151,86 @@ fun AdaptiveDropdownBottomSheet(
return@forEach return@forEach
} }
if (item.dividerBefore) { if (item.dividerBefore) {
DropdownMenuDivider() MillenniumSeparator(modifier = Modifier.padding(vertical = 4.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,
)
} }
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 { } 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))
}
}
}
}
}
},
anchor = { trigger { expanded = true } },
)
} 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,8 +106,7 @@ fun CollectionView(
} }
} }
BaseScaffold( Column(modifier = Modifier.fillMaxSize()) {
topBar = {
ApplicationMainBar( ApplicationMainBar(
backButton = true, backButton = true,
title = if (isCollapsed) { title = if (isCollapsed) {
@ -112,36 +114,34 @@ fun CollectionView(
CollapsedCollectionTitle( CollapsedCollectionTitle(
title = title, title = title,
imageURL = imageURL, imageURL = imageURL,
imageResource = imageResource, imageResource = imageResource)
)
} }
} else { } else {
{} {}
}, },
actions = if (isCollapsed && !isJamGuest) { actions = if (isCollapsed && !isJamGuest) {
{ {
IconButton(onClick = onPlay) { MillenniumIconButton(
Icon( onClick = onPlay,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
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")
)
} }
} }
} else { } else {
{} {}
}, })
)
} Box(modifier = Modifier.weight(1f).fillMaxSize()) {
) { 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,58 +209,51 @@ fun CollectionView(
onBulkAddToJam = onBulkAddToJam, onBulkAddToJam = onBulkAddToJam,
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest,
trackOptionsState = trackOptionsState, trackOptionsState = trackOptionsState)
)
} }
trailingContent() trailingContent()
} }
} }
}
@Composable @Composable
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,211 +79,185 @@ 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")
} }
} }
} }
}
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 = MaterialTheme.typography.titleLarge, style = AppTheme.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(MaterialTheme.shapes.small) .clip(AppTheme.shapes.small)
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)), .background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
) {
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,
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.verticalScroll(rememberScrollState()) .verticalScroll(rememberScrollState())
.padding(12.dp), .padding(12.dp))
)
} }
} }
}, Row(
confirmButton = { modifier = Modifier.fillMaxWidth(),
TextButton(onClick = onDismiss) { 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(
modifier = Modifier.fillMaxWidth(),
verticalAlignment = Alignment.CenterVertically) {
Box(modifier = Modifier.size(16.dp)) { Box(modifier = Modifier.size(16.dp)) {
Icon( MillenniumIcon(
imageVector = icon, imageVector = icon,
contentDescription = label, contentDescription = label,
tint = if (selected) { tint = if (selected) {
MaterialTheme.colorScheme.primary AppTheme.colors.primary
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant AppTheme.colors.onSurfaceVariant
}, })
)
badge?.invoke(this) badge?.invoke(this)
} }
AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) { AnimatedVisibility(visible = expanded, enter = fadeIn(), exit = fadeOut()) {
Text( Text(
text = label, text = label,
maxLines = 1, singleLine = true,
softWrap = false,
style = LocalTextStyle.current.copy( style = LocalTextStyle.current.copy(
fontSize = MaterialTheme.typography.labelMedium.fontSize, fontSize = AppTheme.typography.labelMedium.fontSize,
fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium, fontWeight = if (selected) FontWeight.SemiBold else FontWeight.Medium,
color = if (selected) { color = if (selected) {
MaterialTheme.colorScheme.onSecondaryContainer AppTheme.colors.onSecondaryContainer
} else { } else {
MaterialTheme.colorScheme.onSurfaceVariant 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,78 +548,66 @@ 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)
.combinedClickable(
onClick = onTrackClick, onClick = onTrackClick,
onLongClick = onLongClick, title = {
) TextWithShimmer(
.background(rowBackgroundColor) text = track.title,
.then( style = AppTheme.typography.bodyLarge,
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) { maxLines = 1,
Modifier.highlight(highlightColor) overflow = TextOverflow.Ellipsis)
} else { },
Modifier subtitle = {
} ArtistChips(
) artists = track.artists,
.clip(MaterialTheme.shapes.small) onArtistClick = onArtistClick,
.padding(horizontal = 8.dp, vertical = 6.dp) onArtistsOverflowClick = onArtistsOverflowClick)
.padding(end = 6.dp), },
leading = {
Row(
verticalAlignment = Alignment.CenterVertically, verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp), horizontalArrangement = Arrangement.spacedBy(10.dp)) {
) {
if (isSelectionMode) { if (isSelectionMode) {
CheckBox( MillenniumCheckbox(
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, checked = isSelected,
onClick = { onSelectionToggle(!isSelected) }, onCheckedChange = { onSelectionToggle(it) },
modifier = Modifier.shimmerApply() label = "",
) modifier = Modifier.shimmerApply())
} else if (showIndex) { } else if (showIndex) {
Box( Box(
modifier = Modifier.width(30.dp), modifier = Modifier.width(30.dp),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
AnimatedContent( AnimatedContent(
targetState = isRowHovered, targetState = isRowHovered,
transitionSpec = { transitionSpec = { fadeIn() togetherWith fadeOut() },
fadeIn() togetherWith fadeOut() label = "index-checkbox") { hovered ->
},
label = "index-checkbox",
) { hovered ->
if (hovered) { if (hovered) {
CheckBox( MillenniumCheckbox(
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED, checked = isSelected,
onClick = { onSelectionToggle(!isSelected) }, onCheckedChange = { onSelectionToggle(it) },
modifier = Modifier.shimmerApply() label = "",
) modifier = Modifier.shimmerApply())
} else { } else {
TextWithShimmer( TextWithShimmer(
text = index.toString(), text = index.toString(),
style = MaterialTheme.typography.labelMedium, style = AppTheme.typography.labelMedium,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant)
)
} }
} }
} }
@ -670,18 +616,16 @@ private fun TrackListRow(
Box( Box(
modifier = Modifier modifier = Modifier
.size(44.dp) .size(44.dp)
.clip(MaterialTheme.shapes.small) .clip(AppTheme.shapes.small)
.hoverable(interactionSource = artworkInteractionSource), .hoverable(interactionSource = artworkInteractionSource),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) {
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
val platformContext = LocalPlatformContext.current val platformContext = LocalPlatformContext.current
val imageRequest = remember(imageUrl) { val imageRequest = remember(imageUrl) {
ImageRequest.Builder(platformContext) ImageRequest.Builder(platformContext)
.data(imageUrl) .data(imageUrl)
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size. .size(128)
.crossfade(false) // Crucial for scroll performance .crossfade(false)
.build() .build()
} }
@ -689,62 +633,46 @@ private fun TrackListRow(
model = imageRequest, model = imageRequest,
contentDescription = track.title, contentDescription = track.title,
contentScale = ContentScale.Crop, contentScale = ContentScale.Crop,
modifier = Modifier.fillMaxSize().shimmerApply(), modifier = Modifier.fillMaxSize().shimmerApply())
)
if (isArtworkHovered || isCurrentTrack) { if (isArtworkHovered || isCurrentTrack) {
Box( Box(
modifier = Modifier modifier = Modifier
.fillMaxSize() .fillMaxSize()
.background(Color.Black.copy(alpha = 0.10f)), .background(Color.Black.copy(alpha = 0.10f)),
contentAlignment = Alignment.Center, contentAlignment = Alignment.Center) {
) { MillenniumIcon(
Icon(
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay, imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play", contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
tint = Color.White, tint = Color.White,
modifier = Modifier.size(18.dp).shimmerApply(), modifier = Modifier.size(18.dp).shimmerApply())
)
} }
} }
} }
Column(modifier = Modifier.weight(1.2f)) {
TextWithShimmer(
text = track.title,
style = MaterialTheme.typography.bodyLarge,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
)
ArtistChips(
artists = track.artists,
onArtistClick = onArtistClick,
onArtistsOverflowClick = onArtistsOverflowClick,
)
} }
},
trailing = {
Row(
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
if (showAlbum && track.album != null) { if (showAlbum && track.album != null) {
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()
val colorScheme = MaterialTheme.colorScheme val colorScheme = AppTheme.colors
Row(
modifier = Modifier.weight(1f)
) {
TextWithShimmer( TextWithShimmer(
text = track.album!!.title, text = track.album!!.title,
style = MaterialTheme.typography.bodyMedium, style = AppTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.primary, color = AppTheme.colors.primary,
maxLines = 1, maxLines = 1,
overflow = TextOverflow.Ellipsis, overflow = TextOverflow.Ellipsis,
modifier = Modifier modifier = Modifier
.widthIn(max = 220.dp)
.hoverable(interactionSource) .hoverable(interactionSource)
.clickable( .clickable(
indication = null, indication = null,
interactionSource = interactionSource interactionSource = interactionSource) { onAlbumClick(track.album!!) }
) { onAlbumClick(track.album!!) }
// Hide ripple and show underline on hover/click
.then( .then(
if (isHovered || isPressed) { if (isHovered || isPressed) {
Modifier.drawWithCache { Modifier.drawWithCache {
@ -755,25 +683,20 @@ private fun TrackListRow(
color = colorScheme.primary, color = colorScheme.primary,
start = Offset(0f, size.height - underlineHeight), start = Offset(0f, size.height - underlineHeight),
end = Offset(size.width, size.height - underlineHeight), end = Offset(size.width, size.height - underlineHeight),
strokeWidth = underlineHeight, strokeWidth = underlineHeight)
)
} }
} }
} else { } else {
Modifier Modifier
} }))
)
)
}
} }
TextWithShimmer( TextWithShimmer(
text = track.durationMs.toDurationString(), text = track.durationMs.toDurationString(),
style = MaterialTheme.typography.bodySmall, style = AppTheme.typography.bodySmall,
textAlign = TextAlign.End, textAlign = TextAlign.End,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = AppTheme.colors.onSurfaceVariant,
modifier = Modifier.width(56.dp), modifier = Modifier.width(56.dp))
)
if (useDropdownForOptions) { if (useDropdownForOptions) {
TrackOptions( TrackOptions(
@ -782,43 +705,41 @@ private fun TrackListRow(
onAction = onTrackOptionsAction, onAction = onTrackOptionsAction,
onAlbumClick = { track.album?.let { onAlbumClick(it) } }, onAlbumClick = { track.album?.let { onAlbumClick(it) } },
isInJam = isInJam, isInJam = isInJam,
isJamGuest = isJamGuest, isJamGuest = isJamGuest)
)
} else { } else {
GhostIconButton(onClick = onShowOptionsClick) { MillenniumIconButton(
Icon( onClick = onShowOptionsClick,
style = MillenniumIconButtonStyle.Quiet) {
MillenniumIcon(
imageVector = Iconsax.Iconsax3DotsMore, imageVector = Iconsax.Iconsax3DotsMore,
contentDescription = "Track options", 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

@ -17,26 +17,30 @@
package dev.krtirtho.spotube.core.webview package dev.krtirtho.spotube.core.webview
import org.spotube.millennium.composables.MillenniumIcon
import org.spotube.millennium.composables.MillenniumIconButton
import org.spotube.millennium.composables.MillenniumIconButtonStyle
import androidx.compose.foundation.BorderStroke import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.WindowInsets
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 androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.statusBarsPadding import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.layout.wrapContentHeight import androidx.compose.foundation.layout.wrapContentHeight
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.BasicTextField
import androidx.compose.material3.Icon import androidx.compose.ui.draw.clip
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme import dev.krtirtho.spotube.core.ui.theming.AppTheme
import dev.krtirtho.spotube.core.ui.base.BaseScaffold
import androidx.compose.material3.Surface import com.composeunstyled.Text
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
@ -148,9 +152,7 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
} }
} }
BaseScaffold( Column(modifier = Modifier.fillMaxSize()) {
contentWindowInsets = WindowInsets.statusBars,
topBar = {
ApplicationMainBar( ApplicationMainBar(
title = { title = {
Row( Row(
@ -163,40 +165,50 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
horizontalArrangement = Arrangement.Start, horizontalArrangement = Arrangement.Start,
modifier = Modifier.height(56.dp) modifier = Modifier.height(56.dp)
) { ) {
IconButton( MillenniumIconButton(
onClick = { onClick = {
navigator.navigateBack() navigator.navigateBack()
}, enabled = navigator.canGoBack },
enabled = navigator.canGoBack,
style = MillenniumIconButtonStyle.Quiet
) { ) {
Icon( MillenniumIcon(
FeatherIcons.ChevronLeft, FeatherIcons.ChevronLeft,
contentDescription = "Go back to browser history" contentDescription = "Go back to browser history"
) )
} }
IconButton( MillenniumIconButton(
onClick = { onClick = {
navigator.navigateForward() navigator.navigateForward()
}, enabled = navigator.canGoForward },
enabled = navigator.canGoForward,
style = MillenniumIconButtonStyle.Quiet
) { ) {
Icon( MillenniumIcon(
FeatherIcons.ChevronRight, FeatherIcons.ChevronRight,
contentDescription = "Go forward to browser history" contentDescription = "Go forward to browser history"
) )
} }
} }
Surface( Box(
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp), modifier = Modifier
shape = RoundedCornerShape(18.dp), .weight(1f)
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f), .height(36.dp)
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)) .padding(horizontal = 4.dp)
.clip(RoundedCornerShape(18.dp))
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
.border(
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
RoundedCornerShape(18.dp)),
contentAlignment = Alignment.CenterStart
) { ) {
BasicTextField( BasicTextField(
value = state.lastLoadedUrl ?: "", value = state.lastLoadedUrl ?: "",
onValueChange = {}, // Read-only onValueChange = {}, // Read-only
readOnly = true, readOnly = true,
singleLine = true, singleLine = true,
textStyle = MaterialTheme.typography.bodyMedium.copy( textStyle = AppTheme.typography.bodyMedium.copy(
color = MaterialTheme.colorScheme.onSurface, color = AppTheme.colors.onSurface,
textAlign = TextAlign.Start textAlign = TextAlign.Start
), ),
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp) modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
@ -206,16 +218,13 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
} }
} }
) )
})
{ innerPadding ->
WebView( WebView(
state = state, state = state,
modifier = Modifier.padding(innerPadding).fillMaxSize(), modifier = Modifier.weight(1f).fillMaxWidth(),
navigator = navigator, navigator = navigator,
webViewJsBridge = webViewBridge, webViewJsBridge = webViewBridge,
onCreated = { webView -> onCreated = { webView ->
navigator.evaluateJavaScript(bridgeBootstrapScript) 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

@ -86,7 +86,6 @@ 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.
@ -102,8 +101,7 @@ import kotlin.reflect.KClass
open class ZiplinePluginService( open class ZiplinePluginService(
val applicationName: String, val applicationName: String,
private val manifestUrl: String, private val manifestUrl: String,
private val pluginInfo: PluginEntry, private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
) : PluginService, KoinComponent {
override val pluginId: String = pluginInfo.id override val pluginId: String = pluginInfo.id
@ -280,8 +278,7 @@ open class ZiplinePluginService(
val result = ziplineLoader.loadOnce( val result = ziplineLoader.loadOnce(
applicationName = applicationName, applicationName = applicationName,
manifestUrl = manifestUrl, manifestUrl = manifestUrl,
freshnessChecker = DefaultFreshnessCheckerNotFresh, freshnessChecker = DefaultFreshnessCheckerNotFresh)
)
when (result) { when (result) {
is LoadResult.Success -> { is LoadResult.Success -> {
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" } logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }

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

@ -29,8 +29,7 @@ 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)

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,8 +234,8 @@ fun ArtistScreen(
) )
) )
}, },
onLoadMore = { viewModel.loadMoreFeaturedPlaylists() }, onLoadMore = { viewModel.loadMoreFeaturedPlaylists() })
) }
} }
} }
} }
@ -256,61 +246,50 @@ fun ArtistScreen(
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