Compare commits
1 Commits
937edf8dfc
...
1421e8d452
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1421e8d452 |
@ -31,7 +31,6 @@
|
|||||||
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
- `plugin_interfaces` also has a JS target (`browser()`), used by the plugin system.
|
||||||
|
|
||||||
## UI component patterns
|
## UI component patterns
|
||||||
- **ALL user-facing strings MUST come from `composeApp/src/commonMain/composeResources/values/strings.xml` via `stringResource(Res.string.xxx)`.** Never hardcode a literal string in a `Text(...)`, dialog title, button label, content description, placeholder, or error message inside `@Composable` code. If the string you need doesn't exist yet, add a new `<string name="...">` entry to `strings.xml` (grouped near related keys, following the existing `feature_context_purpose` naming convention) and reference it — do not inline the text "just this once". This applies to every module, including ones you're about to add (dialogs, screens, snackbars, etc.), not just `modules/settings`. `strings.xml` is the single source of truth for copy, not a showcase file for one screen.
|
|
||||||
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
- **ViewModels own ALL state and logic.** Composables are dumb renderers: they collect a single `StateFlow<UiState>` from the ViewModel and forward user events (clicks, text input, drag callbacks) back to ViewModel functions. No business logic, filtering, derivation, reordering buffers, or `LaunchedEffect`-based state syncing belongs in a composable — it goes in the ViewModel. The `combine`/`stateIn` flow chain in the ViewModel must produce fully-computed, ready-to-render UI state so the composable never needs intermediate `remember` derivations or `mutableStateListOf` mirrors.
|
||||||
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
- **AdaptiveDropdownBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDropdownBottomSheet.kt`): switches between `DropdownMenu` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Do NOT use expect/actual — all adaptive components that rely ONLY on Compose/Material3 APIs belong in commonMain.
|
||||||
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
- **AdaptiveDialogBottomSheet** (`commonMain/.../core/ui/component/AdaptiveDialogBottomSheet.kt`): switches between `ThemedDialog` (large screen) and `ModalBottomSheet` (small screen) via `currentWindowAdaptiveInfo()`. Same rule — keep in commonMain unless platform-specific APIs are required.
|
||||||
|
|||||||
BIN
assets/images/album-placeholder.png
Normal file
|
After Width: | Height: | Size: 94 KiB |
BIN
assets/images/bengali-patterns-bg.jpg
Normal file
|
After Width: | Height: | Size: 285 KiB |
BIN
assets/images/logos/dab-music.png
Normal file
|
After Width: | Height: | Size: 3.6 KiB |
BIN
assets/images/logos/invidious.jpg
Normal file
|
After Width: | Height: | Size: 2.4 KiB |
BIN
assets/images/logos/jiosaavn.png
Normal file
|
After Width: | Height: | Size: 14 KiB |
BIN
assets/images/placeholder.png
Normal file
|
After Width: | Height: | Size: 8.7 KiB |
BIN
assets/images/user-placeholder.png
Normal file
|
After Width: | Height: | Size: 47 KiB |
@ -119,6 +119,7 @@ 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)
|
||||||
@ -131,9 +132,6 @@ 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)
|
||||||
@ -166,6 +164,12 @@ 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)
|
||||||
@ -180,6 +184,8 @@ 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)
|
||||||
@ -200,6 +206,7 @@ kotlin {
|
|||||||
|
|
||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
implementation(libs.compose.shimmer)
|
implementation(libs.compose.shimmer)
|
||||||
|
implementation(libs.compose.placeholder.material3)
|
||||||
|
|
||||||
// DLNA
|
// DLNA
|
||||||
implementation(libs.dns.sd.kt)
|
implementation(libs.dns.sd.kt)
|
||||||
|
|||||||
@ -26,6 +26,7 @@ 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) {
|
||||||
@ -46,7 +47,8 @@ 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
|
||||||
}
|
}
|
||||||
@ -65,7 +67,8 @@ 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(
|
||||||
@ -87,6 +90,7 @@ 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 ->
|
||||||
@ -94,7 +98,8 @@ actual class NewPipeService {
|
|||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = stream.codec,
|
||||||
bitrate = stream.bitrate,
|
bitrate = stream.bitrate,
|
||||||
container = stream.format!!.suffix)
|
container = stream.format!!.suffix,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
VideoInfo(
|
VideoInfo(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
@ -45,6 +45,7 @@ actual val platformModules = module {
|
|||||||
collectionPlaybackHelper = get(),
|
collectionPlaybackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get(),
|
||||||
blacklistRepository = get())
|
blacklistRepository = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,12 +35,14 @@ 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()) { /* result is picked up by discovery/advertising retry loops */ }
|
ActivityResultContracts.RequestPermission(),
|
||||||
|
) { /* 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) == PackageManager.PERMISSION_GRANTED
|
Manifest.permission.NEARBY_WIFI_DEVICES,
|
||||||
|
) == PackageManager.PERMISSION_GRANTED
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,7 +53,8 @@ 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"
|
||||||
@ -252,7 +253,8 @@ class MediaBrowseHelper(
|
|||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build())
|
.build(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||||
@ -388,7 +390,8 @@ class MediaBrowseHelper(
|
|||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build())
|
.build(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||||
@ -472,7 +475,8 @@ class MediaBrowseHelper(
|
|||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build())
|
.build(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||||
|
|||||||
@ -32,7 +32,8 @@ import java.io.File
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AndroidLocalMediaDiscoveryService(
|
class AndroidLocalMediaDiscoveryService(
|
||||||
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
private val context: Context,
|
||||||
|
) : 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> {
|
||||||
@ -58,7 +59,8 @@ 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) + if (Build.VERSION.SDK_INT >= 29) {
|
MediaStore.Audio.Media.MIME_TYPE,
|
||||||
|
) + if (Build.VERSION.SDK_INT >= 29) {
|
||||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||||
} else {
|
} else {
|
||||||
emptyArray()
|
emptyArray()
|
||||||
@ -129,7 +131,8 @@ 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
|
||||||
@ -147,7 +150,8 @@ 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()) }
|
||||||
|
|
||||||
@ -168,7 +172,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
override fun observeChanges(
|
override fun observeChanges(
|
||||||
roots: List<String>,
|
roots: List<String>,
|
||||||
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
onChanged: LocalMediaChangeCallback,
|
||||||
|
): 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" }
|
||||||
@ -185,7 +190,8 @@ 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" }
|
||||||
|
|
||||||
@ -251,7 +257,8 @@ 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)
|
||||||
}
|
}
|
||||||
@ -262,7 +269,8 @@ 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()) }
|
||||||
|
|
||||||
@ -284,7 +292,8 @@ 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 }
|
||||||
@ -311,7 +320,8 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
private fun mergeFolders(
|
private fun mergeFolders(
|
||||||
mediaStoreFolders: List<LocalMediaFolder>,
|
mediaStoreFolders: List<LocalMediaFolder>,
|
||||||
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
fallbackFolders: List<LocalMediaFolder>,
|
||||||
|
): List<LocalMediaFolder> {
|
||||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
||||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
||||||
|
|
||||||
@ -332,7 +342,8 @@ 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()) }
|
||||||
}
|
}
|
||||||
@ -346,6 +357,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
"wav",
|
"wav",
|
||||||
"ogg",
|
"ogg",
|
||||||
"opus",
|
"opus",
|
||||||
"wma")
|
"wma",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,16 +42,19 @@ 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()) { granted ->
|
contract = ActivityResultContracts.RequestPermission(),
|
||||||
|
) { 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) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 5.7 KiB |
@ -1,17 +0,0 @@
|
|||||||
<?xml version="1.0" encoding="utf-8"?>
|
|
||||||
<!-- Generator: Adobe Illustrator 19.0.0, SVG Export Plug-In . SVG Version: 6.00 Build 0) -->
|
|
||||||
<svg version="1.1" xmlns="http://www.w3.org/2000/svg" x="0px" y="0px"
|
|
||||||
viewBox="0 0 100 100" style="enable-background:new 0 0 100 100;" xml:space="preserve">
|
|
||||||
<g id="Alapkör">
|
|
||||||
<circle id="XMLID_23_" fill="#CD201F" cx="50" cy="50" r="50"/>
|
|
||||||
</g>
|
|
||||||
<g id="Elemek">
|
|
||||||
<path id="XMLID_19_" fill="#FFFFFF" d="M47,28.2c-9-5.3-15.3-9-15.3-9v61.7c0,0,30.4-18,52.3-30.9C72.1,43,57.7,34.5,47,28.2z"/>
|
|
||||||
</g>
|
|
||||||
<g id="Fedő">
|
|
||||||
<path id="XMLID_5_" fill="#CD201F" d="M48.4,40.1c-4.1-2.4-7-4.1-7-4.1V64c0,0,13.9-8.2,23.8-14C59.8,46.8,53.3,42.9,48.4,40.1z"/>
|
|
||||||
<rect id="XMLID_4_" x="41.4" y="55.6" fill="#CD201F" width="6.2" height="21"/>
|
|
||||||
</g>
|
|
||||||
<g id="Vonalak">
|
|
||||||
</g>
|
|
||||||
</svg>
|
|
||||||
|
Before Width: | Height: | Size: 787 B |
|
Before Width: | Height: | Size: 144 KiB |
@ -16,22 +16,16 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<resources>
|
<resources>
|
||||||
<string name="queue_title">Queue</string>
|
|
||||||
<string name="queue_clear_queue">Clear Queue</string>
|
|
||||||
<string name="queue_filter_queue">Filter queue...</string>
|
|
||||||
<string name="queue_no_queue_entries">No queue entries</string>
|
|
||||||
|
|
||||||
<string name="settings_screen_title">Settings</string>
|
<string name="settings_screen_title">Settings</string>
|
||||||
|
|
||||||
<string name="settings_section_plugins">Plugins</string>
|
<string name="settings_section_plugins">Plugins</string>
|
||||||
<string name="settings_section_language_region">Language and Region</string>
|
<string name="settings_section_language_region">Language and Region</string>
|
||||||
<string name="settings_section_appearance">Appearance</string>
|
<string name="settings_section_appearance">Appearance</string>
|
||||||
<string name="settings_section_playback">Playback</string>
|
<string name="settings_section_playback">Playback</string>
|
||||||
<string name="settings_section_downloads">Downloads</string>f
|
<string name="settings_section_downloads">Downloads</string>
|
||||||
<string name="settings_section_caching">Caching</string>
|
<string name="settings_section_caching">Caching</string>
|
||||||
<string name="settings_section_desktop">Desktop</string>
|
<string name="settings_section_desktop">Desktop</string>
|
||||||
<string name="settings_section_updates">Updates</string>
|
<string name="settings_section_updates">Updates</string>
|
||||||
<string name="settings_section_about">About</string>
|
|
||||||
|
|
||||||
<string name="settings_action_close">Close</string>
|
<string name="settings_action_close">Close</string>
|
||||||
<string name="settings_action_save">Save</string>
|
<string name="settings_action_save">Save</string>
|
||||||
@ -49,14 +43,6 @@
|
|||||||
<string name="settings_theme_dark">Dark</string>
|
<string name="settings_theme_dark">Dark</string>
|
||||||
<string name="settings_theme_system">System</string>
|
<string name="settings_theme_system">System</string>
|
||||||
|
|
||||||
<string name="settings_surface_theme_title">Surface</string>
|
|
||||||
<string name="settings_surface_theme_subtitle_current">Background material of the app. Current: %1$s</string>
|
|
||||||
<string name="settings_surface_watery">Watery</string>
|
|
||||||
<string name="settings_surface_icy">Icy</string>
|
|
||||||
<string name="settings_surface_chrome">Chrome</string>
|
|
||||||
<string name="settings_surface_acrylic">Acrylic</string>
|
|
||||||
<string name="settings_surface_glass">Glass</string>
|
|
||||||
|
|
||||||
<string name="settings_accent_title">Accent color</string>
|
<string name="settings_accent_title">Accent color</string>
|
||||||
<string name="settings_accent_subtitle_current">Current palette: %1$s</string>
|
<string name="settings_accent_subtitle_current">Current palette: %1$s</string>
|
||||||
<string name="settings_accent_dialog_title">Accent color</string>
|
<string name="settings_accent_dialog_title">Accent color</string>
|
||||||
@ -73,24 +59,6 @@
|
|||||||
<string name="settings_accent_midnight_blue">Midnight Blue</string>
|
<string name="settings_accent_midnight_blue">Midnight Blue</string>
|
||||||
<string name="settings_accent_metallic_slate">Metallic Slate</string>
|
<string name="settings_accent_metallic_slate">Metallic Slate</string>
|
||||||
|
|
||||||
<string name="settings_appearance_title">Look & feel</string>
|
|
||||||
<string name="settings_appearance_subtitle_current">Surface material and accent. Current: %1$s · %2$s</string>
|
|
||||||
<string name="settings_appearance_dialog_title">Look & feel</string>
|
|
||||||
<string name="settings_appearance_dialog_description">Pick a surface material, then an accent that pairs with it. Every option is a curated combination.</string>
|
|
||||||
<string name="settings_appearance_surface_label">Surface</string>
|
|
||||||
<string name="settings_appearance_accent_label">Accent</string>
|
|
||||||
<string name="settings_preview_secondary">Secondary</string>
|
|
||||||
|
|
||||||
<string name="settings_palette_title">Palette</string>
|
|
||||||
<string name="settings_palette_subtitle_current">Millennium accent palette. Current: %1$s</string>
|
|
||||||
<string name="settings_palette_dialog_title">Palette</string>
|
|
||||||
<string name="settings_palette_dialog_description">Choose a Millennium accent palette and preview it in light and dark.</string>
|
|
||||||
<string name="settings_palette_label">Palette</string>
|
|
||||||
<string name="settings_appearance_subtitle_palette">Millennium palette and accent. Current: %1$s</string>
|
|
||||||
<string name="settings_palette_blue">Blue</string>
|
|
||||||
<string name="settings_palette_orange">Orange</string>
|
|
||||||
<string name="settings_palette_lime">Lime</string>
|
|
||||||
|
|
||||||
<string name="settings_download_folder_title">Download folder override</string>
|
<string name="settings_download_folder_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>
|
||||||
@ -230,102 +198,4 @@
|
|||||||
<string name="player_go_to_album">Go to album</string>
|
<string name="player_go_to_album">Go to album</string>
|
||||||
<string name="player_sleep_timer">Sleep timer</string>
|
<string name="player_sleep_timer">Sleep timer</string>
|
||||||
<string name="player_track_details">Track details</string>
|
<string name="player_track_details">Track details</string>
|
||||||
|
|
||||||
<string name="update_action_ignore">Ignore</string>
|
|
||||||
<string name="update_action_update">Update</string>
|
|
||||||
<string name="app_update_dialog_title">Spotube %1$s is available</string>
|
|
||||||
<string name="app_update_dialog_no_notes">See what’s new in this release on the Spotube downloads page.</string>
|
|
||||||
<string name="plugin_update_dialog_title">%1$s %2$s is available</string>
|
|
||||||
<string name="plugin_update_dialog_current_version">Currently installed: %1$s</string>
|
|
||||||
<string name="plugin_update_dialog_no_notes">See what’s new in this update from the plugin’s page.</string>
|
|
||||||
|
|
||||||
<string name="welcome_skip">Skip</string>
|
|
||||||
<string name="welcome_next">Next</string>
|
|
||||||
<string name="welcome_back">Back</string>
|
|
||||||
<string name="welcome_get_started">Get Started</string>
|
|
||||||
<string name="welcome_app_name">Spotube</string>
|
|
||||||
<string name="welcome_brand_freedom">Music, without the strings.</string>
|
|
||||||
<string name="welcome_brand_quiet_description">An open-source player for a world of music.\nYours to explore. Yours to make your own.</string>
|
|
||||||
<string name="welcome_donate_quiet_title">Keep music free.</string>
|
|
||||||
<string name="welcome_donate_letter">Spotube is built by people, not a subscription business. If it becomes part of your everyday life, a little support helps us give it the care it deserves.\n\nThank you for making room for independent software.</string>
|
|
||||||
<string name="welcome_plugins_setup_guide">Start with a catalog plugin to find songs and playlists. Choose an audio plugin to play them. Lyrics and listening history are optional. Discover a plugin below, review its permissions, install it, then select it here.</string>
|
|
||||||
<string name="welcome_plugins_discover">Discover plugins</string>
|
|
||||||
<string name="welcome_plugins_sources">Your sources</string>
|
|
||||||
<string name="welcome_plugins_retry">Try again</string>
|
|
||||||
<string name="welcome_plugins_more">More plugins</string>
|
|
||||||
<string name="welcome_plugins_discovery_empty">No new plugins found. You can also install one from a link or a .smplug file above.</string>
|
|
||||||
<string name="welcome_plugins_discovery_error">Plugin discovery is unavailable right now. Try again, or install from a link or file.</string>
|
|
||||||
<string name="welcome_how_quiet_title">A familiar player. A different kind of freedom.</string>
|
|
||||||
<string name="welcome_how_quiet_description">Browse through your chosen catalog. Press play and your audio plugin finds the stream.\n\nYour device handles playback and caching. Add synced lyrics, save tracks for later, or listen together with Group Jam.</string>
|
|
||||||
<string name="welcome_demo_streaming">DEMONSTRATION</string>
|
|
||||||
<string name="welcome_demo_paused">PAUSED</string>
|
|
||||||
<string name="welcome_demo_pause_action">Pause demonstration</string>
|
|
||||||
<string name="welcome_demo_play_action">Play demonstration</string>
|
|
||||||
|
|
||||||
<!-- Page 1: branding -->
|
|
||||||
<string name="welcome_brand_tagline">Your music. Your sources. Your rules.</string>
|
|
||||||
<string name="welcome_brand_subtitle">A free, open-source music player built by a community that simply loves music.</string>
|
|
||||||
<string name="welcome_brand_chip_open_source">Open source</string>
|
|
||||||
<string name="welcome_brand_chip_no_ads">No ads</string>
|
|
||||||
<string name="welcome_brand_chip_no_tracking">No telemetry</string>
|
|
||||||
<string name="welcome_brand_chip_plugin_powered">Plugin powered</string>
|
|
||||||
<string name="welcome_lets_go">Let’s Go</string>
|
|
||||||
|
|
||||||
<!-- Page 2: donations -->
|
|
||||||
<string name="welcome_donate_eyebrow">Supporters keep us going</string>
|
|
||||||
<string name="welcome_donate_title">Built with passion. Kept alive by you.</string>
|
|
||||||
<string name="welcome_donate_message">Spotube is 100% free, libre, and open source — no ads, no subscriptions, no tracking. Every feature exists because people like you chose to help keep the code flowing.</string>
|
|
||||||
<string name="welcome_donate_patreon_title">Patreon</string>
|
|
||||||
<string name="welcome_donate_patreon_tag">Monthly Sponsor</string>
|
|
||||||
<string name="welcome_donate_patreon_subtitle">Support recurring development and infrastructure</string>
|
|
||||||
<string name="welcome_donate_bmac_title">Buy Me a Coffee</string>
|
|
||||||
<string name="welcome_donate_bmac_tag">One-Time Coffee</string>
|
|
||||||
<string name="welcome_donate_bmac_subtitle">Fuel a late-night coding session with a quick tip</string>
|
|
||||||
<string name="welcome_donate_opencollective_title">Open Collective</string>
|
|
||||||
<string name="welcome_donate_opencollective_tag">Community Fund</string>
|
|
||||||
<string name="welcome_donate_opencollective_subtitle">Transparent, community-governed financial backing</string>
|
|
||||||
<string name="welcome_donate_thanks">Thank you from the bottom of our hearts for believing in open music.</string>
|
|
||||||
<string name="welcome_donate_optional">Supporting is completely optional — Spotube stays free either way.</string>
|
|
||||||
<string name="welcome_donate_action">Sponsor</string>
|
|
||||||
<string name="welcome_donate_open_content_description">Open %1$s</string>
|
|
||||||
|
|
||||||
<!-- Page 3: plugins -->
|
|
||||||
<string name="welcome_plugins_eyebrow">Modular Architecture</string>
|
|
||||||
<string name="welcome_plugins_title">Choose where your music comes from.</string>
|
|
||||||
<string name="welcome_plugins_subtitle">Spotube separates catalog discovery, audio streaming, and synced lyrics. Configure your engines below or switch them anytime.</string>
|
|
||||||
<string name="welcome_plugins_metadata_title">Metadata Engine</string>
|
|
||||||
<string name="welcome_plugins_metadata_description">Powers music discovery, search, track listings, and playlists.</string>
|
|
||||||
<string name="welcome_plugins_audio_title">Audio Engine</string>
|
|
||||||
<string name="welcome_plugins_audio_description">Fetches and decodes the audio streams that play.</string>
|
|
||||||
<string name="welcome_plugins_lyrics_title">Lyrics Engine</string>
|
|
||||||
<string name="welcome_plugins_lyrics_description">Streams time-synced karaoke lyrics in real time.</string>
|
|
||||||
<string name="welcome_plugins_scrobble_title">Scrobble Engine</string>
|
|
||||||
<string name="welcome_plugins_scrobble_description">Records listening history to Last.fm or ListenBrainz.</string>
|
|
||||||
<string name="welcome_plugins_badge_active">Active</string>
|
|
||||||
<string name="welcome_plugins_badge_builtin">Built-in</string>
|
|
||||||
<string name="welcome_plugins_badge_ready">Ready</string>
|
|
||||||
<string name="welcome_plugins_badge_optional">Optional</string>
|
|
||||||
<string name="welcome_plugins_none_selected">Select an engine</string>
|
|
||||||
<string name="welcome_plugins_installed_section">Installed Engines</string>
|
|
||||||
<string name="welcome_plugins_set_default">Set as default</string>
|
|
||||||
<string name="welcome_plugins_builtin_note">NewPipe YouTube and LRCLib Lyrics are built-in and ready to stream out of the box.</string>
|
|
||||||
<string name="welcome_plugins_tip">Manage or add new community plugins anytime in Settings → Plugins.</string>
|
|
||||||
|
|
||||||
<!-- Page 4: how it works -->
|
|
||||||
<string name="welcome_how_eyebrow">Retro-Modern Architecture</string>
|
|
||||||
<string name="welcome_how_title">Pure Hi-Fi, zero middlemen</string>
|
|
||||||
<string name="welcome_how_subtitle">Here’s how Spotube orchestrates decentralized playback directly on your machine.</string>
|
|
||||||
<string name="welcome_how_status_badge">DIRECT PEER-TO-PEER • 256 KBPS OPUS</string>
|
|
||||||
<string name="welcome_how_mockup_title">Blue hour</string>
|
|
||||||
<string name="welcome_how_mockup_artist">Spotube · player demonstration</string>
|
|
||||||
<string name="welcome_how_mockup_lyric">A little music for the moments in between.</string>
|
|
||||||
<string name="welcome_how_step_search_title">1. Decentralized Discovery</string>
|
|
||||||
<string name="welcome_how_step_search_desc">Queries go straight to your chosen catalog plugin. Spotube never profiles your taste.</string>
|
|
||||||
<string name="welcome_how_step_stream_title">2. Direct Stream Resolution</string>
|
|
||||||
<string name="welcome_how_step_stream_desc">Audio streams directly from content servers to your machine. No proxy hops or remote data harvesting.</string>
|
|
||||||
<string name="welcome_how_step_playback_title">3. Local DSP & Playback</string>
|
|
||||||
<string name="welcome_how_step_playback_desc">Decoded and cached locally with equalizer controls and optional offline download.</string>
|
|
||||||
<string name="welcome_how_step_lyrics_title">4. Synced Lyrics & Jam</string>
|
|
||||||
<string name="welcome_how_step_lyrics_desc">Karaoke lyrics track the playback timestamp, with LAN remote control and Group Jam.</string>
|
|
||||||
<string name="welcome_how_footer">No middleman servers. Playback happens right on your device — private by design.</string>
|
|
||||||
</resources>
|
</resources>
|
||||||
|
|||||||
@ -22,31 +22,39 @@ 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.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.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.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.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
|
import androidx.compose.ui.text.font.FontWeight
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
import androidx.lifecycle.compose.collectAsStateWithLifecycle
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
|
import com.mikepenz.markdown.m3.Markdown
|
||||||
import dev.krtirtho.spotube.core.navigation.Navigator
|
import dev.krtirtho.spotube.core.navigation.Navigator
|
||||||
import dev.krtirtho.spotube.core.navigation.Routes
|
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.LocalBaseUITheme
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
||||||
|
import dev.krtirtho.spotube.core.ui.base.ThemedDialog
|
||||||
|
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
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
import dev.krtirtho.spotube.modules.shell.AppShell
|
import dev.krtirtho.spotube.modules.shell.AppShell
|
||||||
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
|
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||||
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
|
||||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
|
||||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
|
||||||
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
import dev.krtirtho.spotube.modules.webview.WebViewScreen
|
||||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxCd
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxDirectboxReceive
|
||||||
@ -61,6 +69,7 @@ import dev.krtirtho.spotube.resources.iconsax.User
|
|||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
import org.koin.compose.navigation3.koinEntryProvider
|
import org.koin.compose.navigation3.koinEntryProvider
|
||||||
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.annotation.KoinExperimentalAPI
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
|
|
||||||
interface NavigationItem {
|
interface NavigationItem {
|
||||||
@ -76,7 +85,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(
|
||||||
@ -90,7 +99,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(
|
||||||
@ -107,95 +116,109 @@ 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)
|
||||||
)
|
)
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(
|
||||||
|
KoinExperimentalAPI::class,
|
||||||
|
ExperimentalCoroutinesApi::class,
|
||||||
|
ExperimentalSharedTransitionApi::class
|
||||||
|
)
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
content: @Composable () -> Unit = {}
|
content: @Composable () -> Unit = {}
|
||||||
) {
|
) {
|
||||||
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
val settingsRepository: SettingsRepository = koinInject<SettingsRepository>()
|
||||||
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
val userSettings by settingsRepository.userSettings.collectAsStateWithLifecycle(initialValue = UserSettings())
|
||||||
val appUpdateService: AppUpdateService = koinInject()
|
val updateCheckerViewModel: UpdateCheckerViewModel = koinViewModel()
|
||||||
val pluginUpdateService: PluginUpdateService = koinInject()
|
val availableUpdate by updateCheckerViewModel.availableUpdate.collectAsStateWithLifecycle()
|
||||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
|
||||||
val availablePluginUpdate by pluginUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
val welcomeRepository: WelcomeRepository = koinInject()
|
|
||||||
val showOnboarding by welcomeRepository.shouldShowOnboarding.collectAsStateWithLifecycle()
|
|
||||||
|
|
||||||
// The app always renders the shell + NavDisplay; onboarding is just the start destination.
|
|
||||||
// When it is completed the destination flips to Home and the navigation state is rebuilt.
|
|
||||||
val startRoute = if (showOnboarding) Routes.Welcome else Routes.Home
|
|
||||||
|
|
||||||
val navigationState = rememberNavigationState(
|
val navigationState = rememberNavigationState(
|
||||||
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
|
startRoute = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
)
|
)
|
||||||
val navigator = remember(navigationState) {
|
val navigator = remember {
|
||||||
Navigator(navigationState)
|
Navigator(navigationState)
|
||||||
}
|
}
|
||||||
|
|
||||||
SpotubeTheme(settings = userSettings) {
|
SpotubeTheme(settings = userSettings) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
val baseUITheme = rememberBaseUITheme()
|
||||||
val currentRoute =
|
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||||
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
|
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AppShell(navigator, navigationState) {
|
AppShell(navigator, navigationState) {
|
||||||
SharedTransitionLayout {
|
SharedTransitionLayout {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
onBack = navigator::pop,
|
onBack = navigator::pop,
|
||||||
entries = navigationState.toEntries(koinEntryProvider())
|
entries = navigationState.toEntries(koinEntryProvider())
|
||||||
)
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
when (currentRoute) {
|
||||||
|
Routes.WebView -> WebViewScreen(koinInject())
|
||||||
|
else -> {}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
when (currentRoute) {
|
}
|
||||||
Routes.WebView -> WebViewScreen(koinInject())
|
content()
|
||||||
else -> {}
|
if (availableUpdate != null) {
|
||||||
|
val update = availableUpdate!!
|
||||||
|
ThemedDialog(
|
||||||
|
onDismissRequest = updateCheckerViewModel::dismissUpdate,
|
||||||
|
title = {
|
||||||
|
Text(
|
||||||
|
text = "Spotube ${update.tag} is available",
|
||||||
|
style = MaterialTheme.typography.titleLarge,
|
||||||
|
fontWeight = FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
OutlineButton(onClick = updateCheckerViewModel::ignoreUpdate) {
|
||||||
|
Text("Ignore")
|
||||||
|
}
|
||||||
|
PrimaryButton(
|
||||||
|
onClick = {
|
||||||
|
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||||
|
updateCheckerViewModel.dismissUpdate()
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Text("Update")
|
||||||
|
}
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
if (update.releaseNotesMarkdown.isNotBlank()) {
|
||||||
|
SelectionContainer {
|
||||||
|
Markdown(
|
||||||
|
content = update.releaseNotesMarkdown,
|
||||||
|
modifier = Modifier.padding(bottom = 8.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Text("See what's new in this release on the Spotube downloads page.")
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
|
||||||
availableAppUpdate?.let { update ->
|
|
||||||
AppUpdateDialog(
|
|
||||||
update = update,
|
|
||||||
onDismiss = appUpdateService::dismissUpdate,
|
|
||||||
onIgnore = appUpdateService::ignoreUpdate,
|
|
||||||
onUpdate = {
|
|
||||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
|
||||||
appUpdateService.dismissUpdate()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
availablePluginUpdate?.let { update ->
|
|
||||||
PluginUpdateDialog(
|
|
||||||
update = update,
|
|
||||||
onDismiss = pluginUpdateService::dismissUpdate,
|
|
||||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
|
||||||
onUpdate = {
|
|
||||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
|
||||||
pluginUpdateService.dismissUpdate()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,33 +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
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Development-only switches. These are intentionally kept out of [dev.krtirtho.spotube.modules.settings.UserSettings]
|
|
||||||
* so they can never leak into user-facing configuration.
|
|
||||||
*/
|
|
||||||
object DebugConfig {
|
|
||||||
/**
|
|
||||||
* Flip to `true` while developing to always open the Welcome/Onboarding screen, even after
|
|
||||||
* it has been completed (so you don't have to wipe app data to see it again). It still
|
|
||||||
* dismisses for the session when you finish or skip it.
|
|
||||||
*
|
|
||||||
* Keep it `false` when committing.
|
|
||||||
*/
|
|
||||||
const val FORCE_SHOW_ONBOARDING: Boolean = true
|
|
||||||
}
|
|
||||||
@ -21,6 +21,7 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
||||||
interface AudioPlayerInterface {
|
interface AudioPlayerInterface {
|
||||||
// // Playback
|
// // Playback
|
||||||
suspend fun play()
|
suspend fun play()
|
||||||
|
|||||||
@ -30,7 +30,8 @@ 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;
|
||||||
@ -59,7 +60,8 @@ 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 = "") : QueueEntry
|
override val addedBy: String = "",
|
||||||
|
) : QueueEntry
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("local")
|
@SerialName("local")
|
||||||
@ -70,7 +72,8 @@ 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 = "") : QueueEntry {
|
override val addedBy: String = "",
|
||||||
|
) : 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
|
||||||
@ -108,12 +111,14 @@ sealed interface QueueCollectionEntry {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playlist")
|
@SerialName("playlist")
|
||||||
data class Playlist(
|
data class Playlist(
|
||||||
override val id: String) : QueueCollectionEntry
|
override val id: String,
|
||||||
|
) : QueueCollectionEntry
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("album")
|
@SerialName("album")
|
||||||
data class Album(
|
data class Album(
|
||||||
override val id: String) : QueueCollectionEntry
|
override val id: String,
|
||||||
|
) : QueueCollectionEntry
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("saved_tracks")
|
@SerialName("saved_tracks")
|
||||||
@ -127,4 +132,5 @@ data class PersistedQueueState(
|
|||||||
val entries: List<QueueEntry>,
|
val entries: List<QueueEntry>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int,
|
||||||
val currentCollectionEntry: QueueCollectionEntry? = null,
|
val currentCollectionEntry: QueueCollectionEntry? = null,
|
||||||
val collectionHistory: List<QueueCollectionEntry> = emptyList())
|
val collectionHistory: List<QueueCollectionEntry> = emptyList(),
|
||||||
|
)
|
||||||
|
|||||||
@ -29,7 +29,8 @@ interface AudioPlayerQueue {
|
|||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
autoPlay: Boolean = true,
|
autoPlay: Boolean = true,
|
||||||
startPosition: Int = 0,
|
startPosition: Int = 0,
|
||||||
collectionEntry: QueueCollectionEntry? = null)
|
collectionEntry: QueueCollectionEntry? = null,
|
||||||
|
)
|
||||||
suspend fun addToQueue(entry: QueueEntry)
|
suspend fun addToQueue(entry: QueueEntry)
|
||||||
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
||||||
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
||||||
|
|||||||
@ -45,7 +45,8 @@ 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) : AudioPlayerQueue, KoinComponent {
|
private val blacklistRepository: BlacklistRepository,
|
||||||
|
) : AudioPlayerQueue, KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
|
|
||||||
@ -59,7 +60,8 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
entryByMediaUrl) { playlist, knownEntries ->
|
entryByMediaUrl,
|
||||||
|
) { playlist, knownEntries ->
|
||||||
playlist.map { mediaItem ->
|
playlist.map { mediaItem ->
|
||||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||||
}
|
}
|
||||||
@ -67,7 +69,8 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
entryByMediaUrl) { mediaItem, knownEntries ->
|
entryByMediaUrl,
|
||||||
|
) { 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)
|
||||||
|
|
||||||
@ -104,7 +107,8 @@ class DeviceAudioPlayerQueue(
|
|||||||
combine(
|
combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
|
audioPlayer.loopStateFlow,
|
||||||
|
) { 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
|
||||||
@ -125,7 +129,8 @@ 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()) {
|
||||||
@ -431,7 +436,8 @@ class DeviceAudioPlayerQueue(
|
|||||||
entries = queueEntries,
|
entries = queueEntries,
|
||||||
currentIndex = normalizedCurrentIndex,
|
currentIndex = normalizedCurrentIndex,
|
||||||
currentCollectionEntry = currentCollectionEntryState.value,
|
currentCollectionEntry = currentCollectionEntryState.value,
|
||||||
collectionHistory = collectionHistoryState.value)
|
collectionHistory = collectionHistoryState.value,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -465,7 +471,8 @@ 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 -> {
|
||||||
@ -476,7 +483,8 @@ class DeviceAudioPlayerQueue(
|
|||||||
album = album.orEmpty(),
|
album = album.orEmpty(),
|
||||||
duration = duration.milliseconds,
|
duration = duration.milliseconds,
|
||||||
coverURL = "",
|
coverURL = "",
|
||||||
url = url)
|
url = url,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -488,7 +496,8 @@ 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 {
|
||||||
|
|||||||
@ -23,25 +23,10 @@ import androidx.datastore.preferences.core.Preferences
|
|||||||
import androidx.datastore.preferences.core.longPreferencesKey
|
import androidx.datastore.preferences.core.longPreferencesKey
|
||||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||||
import dev.krtirtho.spotube.core.paths.Paths
|
import dev.krtirtho.spotube.core.paths.Paths
|
||||||
import okio.FileSystem
|
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import okio.SYSTEM
|
|
||||||
|
|
||||||
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
@Suppress("EXPECT_ACTUAL_CLASSIFIERS_ARE_IN_BETA_WARNING")
|
||||||
class Database(val paths: Paths) {
|
class Database(val paths: Paths) {
|
||||||
/**
|
|
||||||
* True when Spotube had already written its own data before this process started. Captured
|
|
||||||
* synchronously at construction time — the earliest point, before any eager service can
|
|
||||||
* create a fresh store — so it reliably tells a brand-new install apart from an upgrade of
|
|
||||||
* an existing one (used to skip the first-run onboarding on upgrades).
|
|
||||||
*/
|
|
||||||
val isExistingInstall: Boolean = runCatching {
|
|
||||||
val dataDirPath = paths.getApplicationDataDirPath()
|
|
||||||
EXISTING_INSTALL_PROBE_FILES.any { fileName ->
|
|
||||||
FileSystem.SYSTEM.exists("$dataDirPath/$fileName".toPath())
|
|
||||||
}
|
|
||||||
}.getOrDefault(false)
|
|
||||||
|
|
||||||
val settingsDataStore: DataStore<Preferences> by lazy {
|
val settingsDataStore: DataStore<Preferences> by lazy {
|
||||||
PreferenceDataStoreFactory.createWithPath(
|
PreferenceDataStoreFactory.createWithPath(
|
||||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
|
produceFile = { "${paths.getApplicationDataDirPath()}/spotube.preferences_pb".toPath() }
|
||||||
@ -76,18 +61,6 @@ class Database(val paths: Paths) {
|
|||||||
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
produceFile = { "${paths.getApplicationDataDirPath()}/spotube_blacklist.preferences_pb".toPath() }
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private companion object {
|
|
||||||
/** Preference files written by previous launches; their presence means an upgrade. */
|
|
||||||
val EXISTING_INSTALL_PROBE_FILES = listOf(
|
|
||||||
"spotube.preferences_pb",
|
|
||||||
"spotube_plugins.preferences_pb",
|
|
||||||
"spotube_matched_tracks.preferences_pb",
|
|
||||||
"spotube_audio_player_queue.preferences_pb",
|
|
||||||
"spotube_local_media.preferences_pb",
|
|
||||||
"spotube_blacklist.preferences_pb",
|
|
||||||
"spotube_cookies.preferences_pb")
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
object DatabaseKeys {
|
object DatabaseKeys {
|
||||||
|
|||||||
@ -32,7 +32,6 @@ import dev.krtirtho.spotube.core.remote.RemoteControlClient
|
|||||||
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
import dev.krtirtho.spotube.core.remote.RemoteControlHandler
|
||||||
import dev.krtirtho.spotube.core.remote.RemoteControlService
|
import dev.krtirtho.spotube.core.remote.RemoteControlService
|
||||||
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
import dev.krtirtho.spotube.core.remote.RemotePlaybackController
|
||||||
import dev.krtirtho.spotube.core.scrobble.ScrobbleManager
|
|
||||||
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
import dev.krtirtho.spotube.core.playback.CollectionPlaybackHelper
|
||||||
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
import dev.krtirtho.spotube.core.server.AlternativeTracksRepository
|
||||||
import dev.krtirtho.spotube.core.server.CacheManager
|
import dev.krtirtho.spotube.core.server.CacheManager
|
||||||
@ -70,13 +69,11 @@ import dev.krtirtho.spotube.modules.plugin.AudioPluginSource
|
|||||||
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
import dev.krtirtho.spotube.modules.plugin.PluginManager
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
import dev.krtirtho.spotube.modules.plugin.PluginProvider
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
|
import dev.krtirtho.spotube.modules.plugin.PluginViewModel
|
||||||
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
|
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksRepository
|
||||||
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksViewModel
|
||||||
import dev.krtirtho.spotube.modules.search.SearchRepository
|
import dev.krtirtho.spotube.modules.search.SearchRepository
|
||||||
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
import dev.krtirtho.spotube.modules.search.SearchScreenViewModel
|
||||||
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
import dev.krtirtho.spotube.modules.settings.SettingsProvider
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
import dev.krtirtho.spotube.modules.settings.SettingsRepository
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
import dev.krtirtho.spotube.modules.settings.UserSettingsSource
|
||||||
@ -86,8 +83,6 @@ import dev.krtirtho.spotube.modules.shell.AppShellViewModel
|
|||||||
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
import dev.krtirtho.spotube.modules.shell.PlayerOptionsViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
import dev.krtirtho.spotube.modules.shell.alternative_track.AlternativeTrackContentViewModel
|
||||||
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
import dev.krtirtho.spotube.modules.shell.player_queue.PlayerQueueContentViewModel
|
||||||
import dev.krtirtho.spotube.modules.welcome.WelcomeRepository
|
|
||||||
import dev.krtirtho.spotube.modules.welcome.WelcomeViewModel
|
|
||||||
import org.koin.core.module.Module
|
import org.koin.core.module.Module
|
||||||
import org.koin.core.module.dsl.bind
|
import org.koin.core.module.dsl.bind
|
||||||
import org.koin.core.module.dsl.createdAtStart
|
import org.koin.core.module.dsl.createdAtStart
|
||||||
@ -134,7 +129,6 @@ val sharedModules = module {
|
|||||||
singleOf(::PluginManager) {
|
singleOf(::PluginManager) {
|
||||||
bind<PluginProvider>()
|
bind<PluginProvider>()
|
||||||
bind<AudioPluginSource>()
|
bind<AudioPluginSource>()
|
||||||
bind<ScrobblePluginSource>()
|
|
||||||
}
|
}
|
||||||
viewModelOf(::PluginViewModel)
|
viewModelOf(::PluginViewModel)
|
||||||
|
|
||||||
@ -142,14 +136,7 @@ val sharedModules = module {
|
|||||||
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
singleOf(::SettingsRepository) { bind<UserSettingsSource>() }
|
||||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
viewModelOf(::JamSettingsViewModel)
|
viewModelOf(::JamSettingsViewModel)
|
||||||
|
viewModelOf(::UpdateCheckerViewModel)
|
||||||
// Welcome / Onboarding
|
|
||||||
singleOf(::WelcomeRepository)
|
|
||||||
viewModelOf(::WelcomeViewModel)
|
|
||||||
|
|
||||||
// Updates
|
|
||||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
|
||||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
|
||||||
|
|
||||||
// Downloads
|
// Downloads
|
||||||
singleOf(::DownloadManager)
|
singleOf(::DownloadManager)
|
||||||
@ -168,7 +155,8 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get())
|
remotePlaybackController = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved Tracks
|
// Saved Tracks
|
||||||
@ -182,7 +170,8 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get())
|
remotePlaybackController = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
@ -197,7 +186,8 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get())
|
remotePlaybackController = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blacklist
|
// Blacklist
|
||||||
@ -209,7 +199,8 @@ val sharedModules = module {
|
|||||||
JamViewModel(
|
JamViewModel(
|
||||||
jamRoomService = get(),
|
jamRoomService = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
settingsProvider = get())
|
settingsProvider = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
@ -225,7 +216,8 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get())
|
remotePlaybackController = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lyrics
|
// Lyrics
|
||||||
@ -239,7 +231,8 @@ 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 {
|
||||||
@ -247,7 +240,8 @@ 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)
|
||||||
@ -270,9 +264,6 @@ val sharedModules = module {
|
|||||||
|
|
||||||
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
single { DiscordRpcService(get(), get()) } withOptions { createdAtStart() }
|
||||||
|
|
||||||
// Scrobbling
|
|
||||||
single { ScrobbleManager(get(), get(), get()) } withOptions { createdAtStart() }
|
|
||||||
|
|
||||||
factory { (tag: String?) ->
|
factory { (tag: String?) ->
|
||||||
if (tag != null) {
|
if (tag != null) {
|
||||||
Logger.withTag(tag)
|
Logger.withTag(tag)
|
||||||
|
|||||||
@ -34,7 +34,8 @@ import kotlin.time.Clock
|
|||||||
|
|
||||||
class DiscordRpcService(
|
class DiscordRpcService(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
|
private val settingsProvider: SettingsProvider,
|
||||||
|
) : KoinComponent, AutoCloseable {
|
||||||
|
|
||||||
private val logger by injectLogger<DiscordRpcService>()
|
private val logger by injectLogger<DiscordRpcService>()
|
||||||
|
|
||||||
@ -60,7 +61,8 @@ class DiscordRpcService(
|
|||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow,
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
audioPlayer.positionFlow,
|
audioPlayer.positionFlow,
|
||||||
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
|
audioPlayer.durationFlow,
|
||||||
|
) { 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)
|
||||||
@ -84,7 +86,8 @@ 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
|
||||||
@ -95,7 +98,8 @@ 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()
|
||||||
@ -154,7 +158,8 @@ 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(
|
||||||
@ -163,7 +168,8 @@ 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
|
||||||
@ -204,5 +210,6 @@ class DiscordRpcService(
|
|||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||||
val position: kotlin.time.Duration,
|
val position: kotlin.time.Duration,
|
||||||
val duration: kotlin.time.Duration)
|
val duration: kotlin.time.Duration,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,7 +30,8 @@ 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(".", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,7 +55,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -64,7 +66,8 @@ 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)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -74,7 +77,8 @@ 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)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -84,7 +88,8 @@ class DeviceDiscoveryService {
|
|||||||
name: String,
|
name: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
registerTimeoutMs: Long = 5_000): NetService {
|
registerTimeoutMs: Long = 5_000,
|
||||||
|
): 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
|
||||||
@ -93,9 +98,11 @@ class DeviceDiscoveryService {
|
|||||||
fun createService(
|
fun createService(
|
||||||
name: String,
|
name: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
deviceId: String): NetService = createNetService(
|
deviceId: String,
|
||||||
|
): NetService = createNetService(
|
||||||
type = SERVICE_TYPE,
|
type = SERVICE_TYPE,
|
||||||
name = name,
|
name = name,
|
||||||
port = port,
|
port = port,
|
||||||
txt = mapOf(TXT_DEVICE_ID to deviceId))
|
txt = mapOf(TXT_DEVICE_ID to deviceId),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
@ -46,7 +46,8 @@ 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) : JamMessage()
|
val isPlaying: Boolean = false,
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playbackCommand")
|
@SerialName("playbackCommand")
|
||||||
@ -56,19 +57,22 @@ sealed class JamMessage {
|
|||||||
@SerialName("suggestTrack")
|
@SerialName("suggestTrack")
|
||||||
data class SuggestTrack(
|
data class SuggestTrack(
|
||||||
val mediaItem: JamMediaItem,
|
val mediaItem: JamMediaItem,
|
||||||
val addedBy: String = "") : JamMessage()
|
val addedBy: String = "",
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("suggestPlaylist")
|
@SerialName("suggestPlaylist")
|
||||||
data class SuggestPlaylist(
|
data class SuggestPlaylist(
|
||||||
val tracks: List<JamMediaItem>,
|
val tracks: List<JamMediaItem>,
|
||||||
val addedBy: String = "") : JamMessage()
|
val addedBy: String = "",
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("kick")
|
@SerialName("kick")
|
||||||
data class Kick(
|
data class Kick(
|
||||||
val participantId: String,
|
val participantId: String,
|
||||||
val reason: String = "kicked") : JamMessage()
|
val reason: String = "kicked",
|
||||||
|
) : JamMessage()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("leave")
|
@SerialName("leave")
|
||||||
@ -100,7 +104,8 @@ 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(
|
||||||
@ -112,7 +117,8 @@ 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(
|
||||||
@ -126,7 +132,8 @@ 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,
|
||||||
@ -137,7 +144,8 @@ 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(
|
||||||
@ -150,7 +158,8 @@ 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,
|
||||||
@ -159,7 +168,8 @@ 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,
|
||||||
@ -169,7 +179,8 @@ 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" }),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -177,7 +188,8 @@ data class JamMediaItem(
|
|||||||
data class JamParticipant(
|
data class JamParticipant(
|
||||||
val id: String,
|
val id: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val isHost: Boolean)
|
val isHost: Boolean,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class JamRole {
|
enum class JamRole {
|
||||||
|
|||||||
@ -30,8 +30,10 @@ 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
|
||||||
@ -45,7 +47,6 @@ 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.
|
||||||
@ -113,7 +114,8 @@ class JamRoomClient : KoinComponent {
|
|||||||
code: String,
|
code: String,
|
||||||
clientId: String,
|
clientId: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
isHost: Boolean): Result<Unit> {
|
isHost: Boolean,
|
||||||
|
): 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"))
|
||||||
@ -124,7 +126,8 @@ 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
|
||||||
@ -164,7 +167,8 @@ 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). */
|
||||||
@ -182,7 +186,8 @@ 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -203,22 +208,26 @@ 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)).toReadBuffer(Charset.UTF8),
|
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
||||||
|
).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)
|
||||||
@ -256,7 +265,8 @@ class JamRoomClient : KoinComponent {
|
|||||||
val operation = mqtt.subscribe(
|
val operation = mqtt.subscribe(
|
||||||
roomFilter(code),
|
roomFilter(code),
|
||||||
OpaquePublishPayloadCodec,
|
OpaquePublishPayloadCodec,
|
||||||
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
QualityOfService.AT_LEAST_ONCE,
|
||||||
|
) { publish, payload ->
|
||||||
route(publish.topic.toString(), payload)
|
route(publish.topic.toString(), payload)
|
||||||
}
|
}
|
||||||
runCatching { operation.subAck.await() }
|
runCatching { operation.subAck.await() }
|
||||||
@ -299,7 +309,8 @@ class JamRoomClient : KoinComponent {
|
|||||||
topicName = topic,
|
topicName = topic,
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
payload = payload.toReadBuffer(Charset.UTF8),
|
payload = payload.toReadBuffer(Charset.UTF8),
|
||||||
retain = retain)
|
retain = retain,
|
||||||
|
)
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.w(e) { "Failed to publish to $topic" }
|
log.w(e) { "Failed to publish to $topic" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,13 +59,15 @@ 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)
|
||||||
|
|
||||||
@ -169,7 +171,8 @@ class JamRoomService(
|
|||||||
code = code,
|
code = code,
|
||||||
clientId = localClientId,
|
clientId = localClientId,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
isHost = true).map {
|
isHost = true,
|
||||||
|
).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))
|
||||||
@ -203,7 +206,8 @@ class JamRoomService(
|
|||||||
code = normalized,
|
code = normalized,
|
||||||
clientId = localClientId,
|
clientId = localClientId,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
isHost = false).map {
|
isHost = false,
|
||||||
|
).map {
|
||||||
_role.value = JamRole.Guest
|
_role.value = JamRole.Guest
|
||||||
_roomCode.value = normalized
|
_roomCode.value = normalized
|
||||||
_participants.value = emptyList()
|
_participants.value = emptyList()
|
||||||
@ -257,7 +261,8 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestTrack(
|
JamMessage.SuggestTrack(
|
||||||
mediaItem = JamMediaItem.fromTrack(track),
|
mediaItem = JamMediaItem.fromTrack(track),
|
||||||
addedBy = localClientId)
|
addedBy = localClientId,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -266,7 +271,8 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestPlaylist(
|
JamMessage.SuggestPlaylist(
|
||||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||||
addedBy = localClientId)
|
addedBy = localClientId,
|
||||||
|
)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -290,7 +296,8 @@ class JamRoomService(
|
|||||||
audioPlayerQueue.queueFlow,
|
audioPlayerQueue.queueFlow,
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
audioPlayer.shuffleModeFlow,
|
audioPlayer.shuffleModeFlow,
|
||||||
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
audioPlayer.playerStateFlow,
|
||||||
|
) { queue, current, shuffle, playerState ->
|
||||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||||
}
|
}
|
||||||
.onEach { broadcast ->
|
.onEach { broadcast ->
|
||||||
@ -307,7 +314,8 @@ 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)
|
||||||
@ -366,7 +374,8 @@ 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
|
||||||
}
|
}
|
||||||
@ -515,11 +524,13 @@ 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,
|
||||||
@ -528,7 +539,8 @@ 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 {
|
||||||
|
|||||||
@ -34,7 +34,6 @@ import dev.krtirtho.spotube.modules.saved_tracks.SavedTracksScreen
|
|||||||
import dev.krtirtho.spotube.modules.search.SearchScreen
|
import dev.krtirtho.spotube.modules.search.SearchScreen
|
||||||
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
import dev.krtirtho.spotube.modules.settings.SettingsScreen
|
||||||
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
|
import dev.krtirtho.spotube.modules.settings.JamSettingsScreen
|
||||||
import dev.krtirtho.spotube.modules.welcome.WelcomeScreen
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
import org.koin.compose.viewmodel.koinViewModel
|
||||||
import org.koin.core.annotation.KoinExperimentalAPI
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
@ -92,9 +91,6 @@ sealed interface Routes : NavKey {
|
|||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data object JamSettings : Routes
|
data object JamSettings : Routes
|
||||||
|
|
||||||
@Serializable
|
|
||||||
data object Welcome : Routes
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(KoinExperimentalAPI::class)
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
@ -125,7 +121,8 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get())
|
navigationCommands = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
navigation<Routes.Artist> {
|
navigation<Routes.Artist> {
|
||||||
ArtistScreen(
|
ArtistScreen(
|
||||||
@ -135,7 +132,8 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get())
|
navigationCommands = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
navigation<Routes.Album> {
|
navigation<Routes.Album> {
|
||||||
AlbumScreen(
|
AlbumScreen(
|
||||||
@ -146,7 +144,8 @@ 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())
|
||||||
@ -159,7 +158,8 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get())
|
navigationCommands = get(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
navigation<Routes.Blacklist> {
|
navigation<Routes.Blacklist> {
|
||||||
BlacklistScreen()
|
BlacklistScreen()
|
||||||
@ -178,7 +178,4 @@ val navigationModule = module {
|
|||||||
navigation<Routes.JamSettings> {
|
navigation<Routes.JamSettings> {
|
||||||
JamSettingsScreen(viewModel = koinViewModel())
|
JamSettingsScreen(viewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
navigation<Routes.Welcome> {
|
|
||||||
WelcomeScreen(viewModel = koinViewModel())
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,6 +26,7 @@ import androidx.compose.runtime.saveable.rememberSerializable
|
|||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.runtime.snapshots.SnapshotStateList
|
import androidx.compose.runtime.snapshots.SnapshotStateList
|
||||||
import androidx.compose.runtime.toMutableStateList
|
import androidx.compose.runtime.toMutableStateList
|
||||||
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavBackStack
|
import androidx.navigation3.runtime.NavBackStack
|
||||||
import androidx.navigation3.runtime.NavEntry
|
import androidx.navigation3.runtime.NavEntry
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
@ -44,9 +45,8 @@ val TOP_LEVEL_ROUTES = setOf(
|
|||||||
Routes.Home,
|
Routes.Home,
|
||||||
Routes.Search,
|
Routes.Search,
|
||||||
Routes.Library,
|
Routes.Library,
|
||||||
Routes.Settings,
|
Routes.Settings
|
||||||
// Bootstrap destination: needs its own back stack when the app starts on it.
|
)
|
||||||
Routes.Welcome)
|
|
||||||
|
|
||||||
val serializersConfig = SavedStateConfiguration {
|
val serializersConfig = SavedStateConfiguration {
|
||||||
serializersModule = SerializersModule {
|
serializersModule = SerializersModule {
|
||||||
@ -63,7 +63,6 @@ val serializersConfig = SavedStateConfiguration {
|
|||||||
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
subclass(Routes.Blacklist::class, Routes.Blacklist.serializer())
|
||||||
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
subclass(Routes.Devices::class, Routes.Devices.serializer())
|
||||||
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
subclass(Routes.Jam::class, Routes.Jam.serializer())
|
||||||
subclass(Routes.Welcome::class, Routes.Welcome.serializer())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -122,7 +121,9 @@ 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>(),
|
||||||
|
rememberViewModelStoreNavEntryDecorator()
|
||||||
|
)
|
||||||
rememberDecoratedNavEntries(
|
rememberDecoratedNavEntries(
|
||||||
backStack = stack,
|
backStack = stack,
|
||||||
entryDecorators = decorators,
|
entryDecorators = decorators,
|
||||||
|
|||||||
@ -49,4 +49,5 @@ data class MediaStream(
|
|||||||
val container: String,
|
val container: String,
|
||||||
val bitrate: Int,
|
val bitrate: Int,
|
||||||
val isAudio: Boolean,
|
val isAudio: Boolean,
|
||||||
val isVideo: Boolean)
|
val isVideo: Boolean,
|
||||||
|
)
|
||||||
@ -34,7 +34,8 @@ 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 }
|
||||||
@ -43,7 +44,8 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -52,7 +54,8 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId))
|
collectionEntry = QueueCollectionEntry.Album(albumId),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -84,7 +87,8 @@ 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) {
|
||||||
@ -95,7 +99,8 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -104,7 +109,8 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -136,7 +142,8 @@ 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) {
|
||||||
@ -147,7 +154,8 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -158,7 +166,8 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null)
|
collectionEntry = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -181,7 +190,8 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks)
|
collectionEntry = QueueCollectionEntry.SavedTracks,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -206,7 +216,8 @@ 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> {
|
||||||
@ -302,7 +313,8 @@ class CollectionPlaybackHelper(
|
|||||||
private fun isTrackBlacklisted(
|
private fun isTrackBlacklisted(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
blacklistedTrackIds: Set<String>,
|
blacklistedTrackIds: Set<String>,
|
||||||
blacklistedArtistIds: Set<String>): Boolean {
|
blacklistedArtistIds: Set<String>,
|
||||||
|
): Boolean {
|
||||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,24 +17,17 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.remote
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.material3.Button
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.material3.Text
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
|
||||||
import com.composeunstyled.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.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 androidx.compose.ui.unit.dp
|
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
||||||
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
|
||||||
@ -49,45 +42,50 @@ fun ConnectionRequestDialogHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingRequest?.let { request ->
|
pendingRequest?.let { request ->
|
||||||
MillenniumDialog(
|
AdaptiveDialogBottomSheet(
|
||||||
visible = true,
|
onDismiss = {
|
||||||
onDismissRequest = {
|
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
title = "Remote Control Request") {
|
title = {
|
||||||
Text(
|
Text(
|
||||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
text = "Remote Control Request",
|
||||||
style = AppTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.titleLarge,
|
||||||
textAlign = TextAlign.Start)
|
)
|
||||||
Row(
|
},
|
||||||
modifier = Modifier.fillMaxWidth(),
|
content = {
|
||||||
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
Text(
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||||
MillenniumButton(
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
textAlign = TextAlign.Start,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
actions = {
|
||||||
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
style = MillenniumButtonStyle.Secondary) {
|
) {
|
||||||
Text("Deny")
|
Text("Deny")
|
||||||
}
|
}
|
||||||
MillenniumButton(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
}) {
|
},
|
||||||
|
) {
|
||||||
Text("Allow")
|
Text("Allow")
|
||||||
}
|
}
|
||||||
MillenniumButton(
|
Button(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
) {
|
||||||
Text("Allow Always")
|
Text("Allow Always")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,7 +162,8 @@ class RemoteControlClient {
|
|||||||
|
|
||||||
val envelope = CommandEnvelope(
|
val envelope = CommandEnvelope(
|
||||||
commandId = randomShortId(),
|
commandId = randomShortId(),
|
||||||
command = command)
|
command = command,
|
||||||
|
)
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
||||||
|
|||||||
@ -48,11 +48,13 @@ 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) : KoinComponent {
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
|
) : 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
|
||||||
@ -86,7 +88,8 @@ 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) {
|
||||||
@ -222,7 +225,8 @@ 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 = ""))
|
||||||
@ -296,7 +300,8 @@ 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) -> {
|
||||||
@ -354,7 +359,8 @@ 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))
|
||||||
}
|
}
|
||||||
@ -369,7 +375,8 @@ 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))
|
||||||
}
|
}
|
||||||
@ -396,7 +403,8 @@ 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,
|
||||||
@ -405,7 +413,8 @@ 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) {
|
||||||
@ -438,7 +447,8 @@ 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/"
|
||||||
@ -454,7 +464,8 @@ enum class RemoteCollectionAction {
|
|||||||
data class ConnectionRequest(
|
data class ConnectionRequest(
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val deviceName: String,
|
val deviceName: String,
|
||||||
val sessionId: String)
|
val sessionId: String,
|
||||||
|
)
|
||||||
|
|
||||||
enum class ConnectionRequestResponse {
|
enum class ConnectionRequestResponse {
|
||||||
Allow,
|
Allow,
|
||||||
|
|||||||
@ -119,13 +119,15 @@ sealed class RemoteControlEvent {
|
|||||||
val currentTrackTitle: String?,
|
val currentTrackTitle: String?,
|
||||||
val currentTrackArtists: String?,
|
val currentTrackArtists: String?,
|
||||||
val currentTrackAlbum: String?,
|
val currentTrackAlbum: String?,
|
||||||
val currentTrackCoverUrl: String?) : RemoteControlEvent()
|
val currentTrackCoverUrl: String?,
|
||||||
|
) : RemoteControlEvent()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("queueUpdated")
|
@SerialName("queueUpdated")
|
||||||
data class QueueUpdated(
|
data class QueueUpdated(
|
||||||
val entries: List<RemoteQueueEntry>,
|
val entries: List<RemoteQueueEntry>,
|
||||||
val currentIndex: Int) : RemoteControlEvent()
|
val currentIndex: Int,
|
||||||
|
) : RemoteControlEvent()
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("ack")
|
@SerialName("ack")
|
||||||
@ -144,4 +146,5 @@ data class RemoteQueueEntry(
|
|||||||
val artists: String,
|
val artists: String,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val durationMs: Long)
|
val durationMs: Long,
|
||||||
|
)
|
||||||
|
|||||||
@ -53,7 +53,8 @@ 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())
|
||||||
|
|
||||||
@ -77,7 +78,8 @@ class RemoteControlService(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
settingsRepository.userSettings,
|
settingsRepository.userSettings,
|
||||||
localServer.port) { settings, port -> settings to port }
|
localServer.port,
|
||||||
|
) { settings, port -> settings to port }
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { (settings, port) ->
|
.collect { (settings, port) ->
|
||||||
if (settings.allowRemoteControl && port != null) {
|
if (settings.allowRemoteControl && port != null) {
|
||||||
@ -137,7 +139,8 @@ class RemoteControlService(
|
|||||||
val service = discoveryService.createService(
|
val service = discoveryService.createService(
|
||||||
name = serviceName,
|
name = serviceName,
|
||||||
port = port,
|
port = port,
|
||||||
deviceId = deviceId)
|
deviceId = deviceId,
|
||||||
|
)
|
||||||
pendingService = service
|
pendingService = service
|
||||||
try {
|
try {
|
||||||
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
||||||
|
|||||||
@ -64,17 +64,20 @@ 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) : PlaybackDestinationRequest
|
val shuffle: Boolean = false,
|
||||||
|
) : 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) : PlaybackDestinationRequest
|
val track: MetadataTrack,
|
||||||
|
) : 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>) : PlaybackDestinationRequest
|
val tracks: List<MetadataTrack>,
|
||||||
|
) : PlaybackDestinationRequest
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -87,7 +90,8 @@ 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) : KoinComponent {
|
private val jamRoomService: JamRoomService,
|
||||||
|
) : 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)
|
||||||
|
|
||||||
@ -109,7 +113,8 @@ 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))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -200,7 +205,8 @@ class RemotePlaybackController(
|
|||||||
QueueEntry.StreamingTrack(
|
QueueEntry.StreamingTrack(
|
||||||
track = track,
|
track = track,
|
||||||
url = "",
|
url = "",
|
||||||
addedBy = jamRoomService.participantClientId)
|
addedBy = jamRoomService.participantClientId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -285,7 +291,8 @@ class RemotePlaybackController(
|
|||||||
entries = listOf(entry),
|
entries = listOf(entry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null)
|
collectionEntry = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDestinationAction.AddToQueue -> {
|
PlaybackDestinationAction.AddToQueue -> {
|
||||||
@ -312,7 +319,8 @@ class RemotePlaybackController(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null)
|
collectionEntry = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDestinationAction.AddToQueue -> {
|
PlaybackDestinationAction.AddToQueue -> {
|
||||||
|
|||||||
@ -1,265 +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.scrobble
|
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleTrack
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerInterface
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.AudioPlayerQueue
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.PlayerState
|
|
||||||
import dev.krtirtho.spotube.core.audioplayer.QueueEntry
|
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
|
||||||
import dev.krtirtho.spotube.modules.plugin.ScrobblePluginSource
|
|
||||||
import kotlinx.coroutines.CancellationException
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
|
||||||
import kotlinx.coroutines.Dispatchers
|
|
||||||
import kotlinx.coroutines.IO
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
|
||||||
import kotlinx.coroutines.flow.combine
|
|
||||||
import kotlinx.coroutines.launch
|
|
||||||
import org.koin.core.component.KoinComponent
|
|
||||||
import kotlin.time.Clock
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Watches local playback and submits scrobbles through the currently selected scrobble plugin.
|
|
||||||
*
|
|
||||||
* Scrobbling follows the standard Last.fm rules, which plugins are expected to honour as well:
|
|
||||||
* - Tracks shorter than [MIN_TRACK_DURATION_MS] are never scrobbled.
|
|
||||||
* - The track must be listened to for at least half of its duration, or [MAX_SCROBBLE_THRESHOLD_MS],
|
|
||||||
* whichever comes first.
|
|
||||||
*
|
|
||||||
* Only time during which the player is actively [PlayerState.PLAYING] is counted, so pauses are
|
|
||||||
* ignored, and forward seeks don't credit unheard audio. The timestamp sent with the scrobble is
|
|
||||||
* the moment playback of the track started. Each play is scrobbled at most once; replaying or
|
|
||||||
* repeating the track starts a new scrobble session.
|
|
||||||
*/
|
|
||||||
class ScrobbleManager(
|
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
|
||||||
private val scrobblePluginSource: ScrobblePluginSource,
|
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<ScrobbleManager>()
|
|
||||||
|
|
||||||
private var activeTrackKey: String? = null
|
|
||||||
private var playbackStartedAtEpochSeconds: Long? = null
|
|
||||||
private var listenedMs: Long = 0L
|
|
||||||
private var lastPositionMs: Long? = null
|
|
||||||
private var scrobbled = false
|
|
||||||
|
|
||||||
init {
|
|
||||||
scope.launch {
|
|
||||||
combine(
|
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
|
||||||
audioPlayer.playerStateFlow,
|
|
||||||
audioPlayer.positionFlow,
|
|
||||||
audioPlayer.durationFlow) { entry, playerState, position, duration ->
|
|
||||||
PlaybackSnapshot(
|
|
||||||
entry = entry,
|
|
||||||
playerState = playerState,
|
|
||||||
positionMs = position.inWholeMilliseconds,
|
|
||||||
playerDurationMs = duration.inWholeMilliseconds)
|
|
||||||
}.collect(::onPlaybackSnapshot)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun onPlaybackSnapshot(snapshot: PlaybackSnapshot) {
|
|
||||||
val entry = snapshot.entry
|
|
||||||
if (entry == null) {
|
|
||||||
clearSession()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val trackKey = entry.trackKey()
|
|
||||||
if (trackKey != activeTrackKey) {
|
|
||||||
beginSession(trackKey, snapshot.positionMs)
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
val previousPositionMs = lastPositionMs
|
|
||||||
lastPositionMs = snapshot.positionMs
|
|
||||||
|
|
||||||
if (snapshot.playerState != PlayerState.PLAYING) return
|
|
||||||
if (previousPositionMs == null) {
|
|
||||||
playbackStartedAtEpochSeconds = playbackStartedAtEpochSeconds ?: nowEpochSeconds()
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
if (playbackStartedAtEpochSeconds == null) {
|
|
||||||
playbackStartedAtEpochSeconds = nowEpochSeconds()
|
|
||||||
}
|
|
||||||
|
|
||||||
val deltaMs = snapshot.positionMs - previousPositionMs
|
|
||||||
if (deltaMs < 0) {
|
|
||||||
// A backward seek to the very beginning means the play started over
|
|
||||||
// (e.g. repeat one), so allow it to be scrobbled again.
|
|
||||||
if (snapshot.positionMs <= RESTART_POSITION_THRESHOLD_MS) {
|
|
||||||
restartSession(snapshot.positionMs)
|
|
||||||
}
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// A jump larger than a couple of position ticks is a forward seek or a
|
|
||||||
// buffering/app-suspension stall. Never credit unplayed audio for it.
|
|
||||||
if (deltaMs > MAX_COUNTED_POSITION_DELTA_MS) return
|
|
||||||
|
|
||||||
listenedMs += deltaMs
|
|
||||||
if (scrobbled) return
|
|
||||||
|
|
||||||
val durationMs = entry.durationMs().takeIf { it > 0 } ?: snapshot.playerDurationMs
|
|
||||||
val thresholdMs = scrobbleThresholdMs(durationMs) ?: return
|
|
||||||
if (listenedMs < thresholdMs) return
|
|
||||||
|
|
||||||
if (scrobblePluginSource.selectedScrobblePlugin.value == null) {
|
|
||||||
// No scrobble plugin selected yet: keep the session hot so it can be
|
|
||||||
// submitted if the user selects one while the track is still playing.
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
scrobbled = true
|
|
||||||
val track = entry.toScrobbleTrack(
|
|
||||||
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
|
||||||
durationMs = durationMs)
|
|
||||||
scope.launch { submitScrobble(track) }
|
|
||||||
}
|
|
||||||
|
|
||||||
private suspend fun submitScrobble(track: ScrobbleTrack) {
|
|
||||||
val plugin = scrobblePluginSource.selectedScrobblePlugin.value ?: return
|
|
||||||
try {
|
|
||||||
plugin.use { scrobbleAPI.scrobble(track) }
|
|
||||||
logger.d { "Scrobbled \"${track.trackName}\" by ${track.artistName} via ${plugin.pluginId}" }
|
|
||||||
} catch (e: CancellationException) {
|
|
||||||
throw e
|
|
||||||
} catch (e: Exception) {
|
|
||||||
logger.e(e) { "Failed to scrobble \"${track.trackName}\" by ${track.artistName}" }
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun beginSession(trackKey: String, positionMs: Long) {
|
|
||||||
activeTrackKey = trackKey
|
|
||||||
restartSession(positionMs)
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun restartSession(positionMs: Long) {
|
|
||||||
listenedMs = 0L
|
|
||||||
playbackStartedAtEpochSeconds = null
|
|
||||||
lastPositionMs = positionMs
|
|
||||||
scrobbled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun clearSession() {
|
|
||||||
activeTrackKey = null
|
|
||||||
listenedMs = 0L
|
|
||||||
playbackStartedAtEpochSeconds = null
|
|
||||||
lastPositionMs = null
|
|
||||||
scrobbled = false
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun nowEpochSeconds(): Long = Clock.System.now().epochSeconds
|
|
||||||
|
|
||||||
private fun QueueEntry.trackKey(): String = when (this) {
|
|
||||||
is QueueEntry.StreamingTrack -> track.id
|
|
||||||
is QueueEntry.LocalTrack -> url
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun QueueEntry.durationMs(): Long = when (this) {
|
|
||||||
is QueueEntry.StreamingTrack -> track.durationMs
|
|
||||||
is QueueEntry.LocalTrack -> duration
|
|
||||||
}
|
|
||||||
|
|
||||||
private fun QueueEntry.toScrobbleTrack(timestamp: Long, durationMs: Long): ScrobbleTrack {
|
|
||||||
return when (this) {
|
|
||||||
is QueueEntry.StreamingTrack -> ScrobbleTrack(
|
|
||||||
timestamp = timestamp,
|
|
||||||
trackId = track.id,
|
|
||||||
artistId = track.artists.firstOrNull()?.id.orEmpty(),
|
|
||||||
albumId = track.album?.id,
|
|
||||||
trackName = track.title,
|
|
||||||
artistName = track.artists.joinToString(", ") { it.name },
|
|
||||||
albumName = track.album?.title,
|
|
||||||
streamingProvider = resolveStreamingProvider(track),
|
|
||||||
durationMs = durationMs)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
|
||||||
timestamp = timestamp,
|
|
||||||
trackId = url,
|
|
||||||
artistId = "",
|
|
||||||
albumId = null,
|
|
||||||
trackName = name,
|
|
||||||
artistName = artists.joinToString(", "),
|
|
||||||
albumName = album,
|
|
||||||
streamingProvider = LOCAL_PROVIDER,
|
|
||||||
durationMs = durationMs)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/** Maps a track's external URI to the streaming provider key scrobble plugins expect. */
|
|
||||||
private fun resolveStreamingProvider(track: MetadataTrack): String {
|
|
||||||
val host = track.externalUri
|
|
||||||
?.substringAfter("://", missingDelimiterValue = "")
|
|
||||||
?.substringBefore('/')
|
|
||||||
?.substringAfter('@')
|
|
||||||
?.substringBefore(':')
|
|
||||||
?.lowercase()
|
|
||||||
?.removePrefix("www.")
|
|
||||||
.orEmpty()
|
|
||||||
|
|
||||||
if (host.isBlank()) return UNKNOWN_PROVIDER
|
|
||||||
|
|
||||||
return when {
|
|
||||||
"spotify" in host -> "spotify"
|
|
||||||
"apple" in host -> "apple_music"
|
|
||||||
"music.youtube" in host -> "youtube_music"
|
|
||||||
"youtube" in host || "youtu.be" in host -> "youtube"
|
|
||||||
"deezer" in host -> "deezer"
|
|
||||||
"tidal" in host -> "tidal"
|
|
||||||
"soundcloud" in host -> "soundcloud"
|
|
||||||
"amazon" in host -> "amazon_music"
|
|
||||||
"pandora" in host -> "pandora"
|
|
||||||
else -> host.substringBefore('.')
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private data class PlaybackSnapshot(
|
|
||||||
val entry: QueueEntry?,
|
|
||||||
val playerState: PlayerState,
|
|
||||||
val positionMs: Long,
|
|
||||||
val playerDurationMs: Long)
|
|
||||||
|
|
||||||
companion object {
|
|
||||||
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
|
||||||
private const val MIN_TRACK_DURATION_MS = 30_000L
|
|
||||||
|
|
||||||
/** Listening threshold is capped at four minutes (Last.fm rule). */
|
|
||||||
private const val MAX_SCROBBLE_THRESHOLD_MS = 4 * 60 * 1000L
|
|
||||||
|
|
||||||
/** Largest position delta credited as listening, covers position polling jitter. */
|
|
||||||
private const val MAX_COUNTED_POSITION_DELTA_MS = 2_000L
|
|
||||||
|
|
||||||
/** Positions at or below this are treated as the start of a fresh play. */
|
|
||||||
private const val RESTART_POSITION_THRESHOLD_MS = 5_000L
|
|
||||||
|
|
||||||
private const val UNKNOWN_PROVIDER = "unknown"
|
|
||||||
private const val LOCAL_PROVIDER = "local"
|
|
||||||
|
|
||||||
private fun scrobbleThresholdMs(durationMs: Long): Long? {
|
|
||||||
if (durationMs < MIN_TRACK_DURATION_MS) return null
|
|
||||||
return minOf(durationMs / 2, MAX_SCROBBLE_THRESHOLD_MS)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -29,7 +29,8 @@ 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) : KoinComponent {
|
private val cacheManager: CacheManager,
|
||||||
|
) : KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||||
|
|
||||||
|
|||||||
@ -51,11 +51,13 @@ 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 }
|
||||||
@ -158,13 +160,15 @@ class CacheManager(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
allBytes: ByteArray,
|
allBytes: ByteArray,
|
||||||
contentType: String) {
|
contentType: String,
|
||||||
|
) {
|
||||||
val entry = CacheEntry(
|
val entry = CacheEntry(
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
sizeBytes = allBytes.size.toLong(),
|
sizeBytes = allBytes.size.toLong(),
|
||||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||||
contentType = contentType)
|
contentType = contentType,
|
||||||
|
)
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
val index = readCacheIndex()
|
val index = readCacheIndex()
|
||||||
writeCacheIndex(CacheIndex(
|
writeCacheIndex(CacheIndex(
|
||||||
|
|||||||
@ -60,7 +60,8 @@ 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) : KoinComponent {
|
private val remoteControlHandler: RemoteControlHandler,
|
||||||
|
) : KoinComponent {
|
||||||
|
|
||||||
val logger by injectLogger<LocalServer>()
|
val logger by injectLogger<LocalServer>()
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
@ -98,7 +99,8 @@ class LocalServer(
|
|||||||
audioPlayerQueue = audioPlayerQueue,
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
isCachingEnabled = { cachedCacheEnabled.value },
|
isCachingEnabled = { cachedCacheEnabled.value },
|
||||||
activePort = { activePort.value },
|
activePort = { activePort.value },
|
||||||
scope = scope)
|
scope = scope,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -37,7 +37,8 @@ import kotlinx.serialization.json.Json
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class PluginTaggedAudioSource(
|
data class PluginTaggedAudioSource(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val source: AudioSource.Basic)
|
val source: AudioSource.Basic,
|
||||||
|
)
|
||||||
|
|
||||||
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
||||||
interface TrackSourceRepository {
|
interface TrackSourceRepository {
|
||||||
|
|||||||
@ -62,7 +62,8 @@ 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): KoinComponent {
|
private val scope: CoroutineScope,
|
||||||
|
): KoinComponent {
|
||||||
val logger by injectLogger<StreamProxy>()
|
val logger by injectLogger<StreamProxy>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -413,7 +414,8 @@ internal class StreamProxy(
|
|||||||
trackId = trackIdForCache,
|
trackId = trackIdForCache,
|
||||||
filename = cacheFilename,
|
filename = cacheFilename,
|
||||||
allBytes = allBytes,
|
allBytes = allBytes,
|
||||||
contentType = cacheContentType)
|
contentType = cacheContentType,
|
||||||
|
)
|
||||||
scope.launch {
|
scope.launch {
|
||||||
cacheManager.evictIfNeeded()
|
cacheManager.evictIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,24 +48,28 @@ 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) : KoinComponent {
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
||||||
|
) : KoinComponent {
|
||||||
|
|
||||||
private val logger by injectLogger<StreamingUrlRepository>()
|
private val logger by injectLogger<StreamingUrlRepository>()
|
||||||
private val streamUrlCacheMutex = Mutex()
|
private val streamUrlCacheMutex = Mutex()
|
||||||
@ -129,7 +133,8 @@ 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)
|
||||||
@ -188,7 +193,8 @@ 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)
|
||||||
@ -201,13 +207,15 @@ 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): StreamInfo? {
|
pluginId: String,
|
||||||
|
): 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
|
||||||
@ -233,7 +241,8 @@ 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(
|
||||||
@ -246,7 +255,8 @@ class StreamingUrlRepository(
|
|||||||
selectedStream = selectedStream,
|
selectedStream = selectedStream,
|
||||||
preferredFormat = preferredFormat,
|
preferredFormat = preferredFormat,
|
||||||
preferredQuality = preferredQuality,
|
preferredQuality = preferredQuality,
|
||||||
pluginId = pluginId)
|
pluginId = pluginId,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -316,7 +326,8 @@ 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): AudioStream? = streams.minWithOrNull(
|
preferredQuality: AudioQuality,
|
||||||
|
): AudioStream? = streams.minWithOrNull(
|
||||||
compareBy<AudioStream> { stream ->
|
compareBy<AudioStream> { stream ->
|
||||||
formatMismatchCount(stream, preferredFormat)
|
formatMismatchCount(stream, preferredFormat)
|
||||||
}.thenBy { stream ->
|
}.thenBy { stream ->
|
||||||
|
|||||||
@ -0,0 +1,483 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.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.tooling.preview.Preview
|
||||||
|
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
|
||||||
|
|
||||||
|
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
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.width(textFieldWidthDp)
|
||||||
|
.heightIn(max = menuMaxHeight)
|
||||||
|
.shadow(resolvedMenuTheme.shadowElevation, resolvedMenuTheme.shape)
|
||||||
|
.background(resolvedMenuTheme.background, resolvedMenuTheme.shape)
|
||||||
|
.clip(resolvedMenuTheme.shape),
|
||||||
|
) {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun AutocompleteTextFieldPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
val artists = remember {
|
||||||
|
listOf(
|
||||||
|
"Twenty One Pilots",
|
||||||
|
"The Beatles",
|
||||||
|
"Adele",
|
||||||
|
"Drake",
|
||||||
|
"Taylor Swift",
|
||||||
|
"Arctic Monkeys",
|
||||||
|
"Billie Eilish",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
var value by remember { mutableStateOf("") }
|
||||||
|
|
||||||
|
AutocompleteTextField(
|
||||||
|
value = value,
|
||||||
|
onValueChange = {
|
||||||
|
value = it
|
||||||
|
},
|
||||||
|
items = artists.filter {
|
||||||
|
it.contains(value, ignoreCase = true) && value.isNotEmpty()
|
||||||
|
},
|
||||||
|
onItemSelected = { selected ->
|
||||||
|
value = selected
|
||||||
|
},
|
||||||
|
placeholder = { Text("Search artists...") },
|
||||||
|
leadingIcon = {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Iconsax.IconsaxSearchBroken,
|
||||||
|
contentDescription = "Search",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
itemContent = { item, isSelected ->
|
||||||
|
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),
|
||||||
|
) {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Iconsax.IconsaxSearchBroken,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
text = item,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,844 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.remember
|
||||||
|
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.tooling.preview.Preview
|
||||||
|
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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@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
|
||||||
|
)
|
||||||
|
.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.highlight(state.colors.highlight))
|
||||||
|
.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)
|
||||||
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = ripple(),
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.highlight(state.colors.highlight)
|
||||||
|
.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)
|
||||||
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = ripple(),
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.highlight(state.colors.highlight)
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ButtonsRow1Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlineButton(onClick = {}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxShare,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Text(
|
||||||
|
"Copy link",
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlineButton(onClick = {}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.User,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Text("Login", maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ButtonsRow2Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ButtonsRow3Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlineButton(onClick = {}) {
|
||||||
|
Text("Cancel", maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
PrimaryButton(onClick = {}) {
|
||||||
|
Text(
|
||||||
|
"Done",
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
IconButton(onClick = {}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxMagic,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlineButton(onClick = {}) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxHeart,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
Text("Like", maxLines = 1, softWrap = false)
|
||||||
|
ButtonBadge(count = 2)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ButtonsRow4Preview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
OutlineButton(onClick = {}) {
|
||||||
|
Text("Forward", maxLines = 1, softWrap = false)
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxArrowSquareUp,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun ButtonStylesPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
OutlineButton(onClick = {}) {
|
||||||
|
Text("Outline", maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
SecondaryButton(onClick = {}) {
|
||||||
|
Text("Secondary", maxLines = 1, softWrap = false)
|
||||||
|
}
|
||||||
|
PrimaryButton(onClick = {}) {
|
||||||
|
Text(
|
||||||
|
"Primary",
|
||||||
|
maxLines = 1,
|
||||||
|
softWrap = false,
|
||||||
|
fontWeight = androidx.compose.ui.text.font.FontWeight.SemiBold,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,81 @@
|
|||||||
|
/*
|
||||||
|
* 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.Box
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
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.unit.dp
|
||||||
|
|
||||||
|
@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)
|
||||||
|
.border(BorderStroke(cardTheme.border.width, cardTheme.border.color), cardTheme.shape)
|
||||||
|
.padding(cardTheme.padding),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
@androidx.compose.ui.tooling.preview.Preview
|
||||||
|
private fun CardPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Card {
|
||||||
|
Box(modifier = Modifier.padding(16.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,253 @@
|
|||||||
|
/*
|
||||||
|
* 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.padding
|
||||||
|
import androidx.compose.foundation.layout.size
|
||||||
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
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.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.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.tooling.preview.Preview
|
||||||
|
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)
|
||||||
|
.border(BorderStroke(0.5.dp, borderColor), resolved.shape)
|
||||||
|
.highlight(resolved.colors.highlight)
|
||||||
|
.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),
|
||||||
|
)
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun CheckBoxPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CheckBox(state = CheckBoxState.SELECTED, onClick = {})
|
||||||
|
Text("Selected", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CheckBox(state = CheckBoxState.UNSELECTED, onClick = {})
|
||||||
|
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CheckBox(state = CheckBoxState.INDETERMINATE, onClick = {})
|
||||||
|
Text("Indeterminate", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
CheckBox(state = CheckBoxState.SELECTED, onClick = {}, enabled = false)
|
||||||
|
Text("Disabled", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,171 @@
|
|||||||
|
/*
|
||||||
|
* 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.Row
|
||||||
|
import androidx.compose.foundation.layout.RowScope
|
||||||
|
import androidx.compose.foundation.layout.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
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.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.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
|
||||||
|
|
||||||
|
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)
|
||||||
|
.border(BorderStroke(state.border.width, state.border.color), state.shape)
|
||||||
|
.clickable(
|
||||||
|
enabled = enabled,
|
||||||
|
interactionSource = interactionSource,
|
||||||
|
indication = ripple(),
|
||||||
|
onClick = onClick,
|
||||||
|
)
|
||||||
|
.highlight(state.colors.highlight)
|
||||||
|
.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,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,186 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.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.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.shadow
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
val dialogTheme = theme ?: LocalBaseUITheme.current.dialog
|
||||||
|
|
||||||
|
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,
|
||||||
|
) {
|
||||||
|
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))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun ThemedDialogPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
var show by remember { mutableStateOf(true) }
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
) {
|
||||||
|
PrimaryButton(onClick = { show = true }) {
|
||||||
|
Text("Open Dialog")
|
||||||
|
}
|
||||||
|
if (show) {
|
||||||
|
ThemedDialog(onDismissRequest = { show = false }) {
|
||||||
|
Text(
|
||||||
|
"This is a themed dialog with glass-like gradient, shadow, and scale animation.",
|
||||||
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,172 @@
|
|||||||
|
/*
|
||||||
|
* 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.ColumnScope
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
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.remember
|
||||||
|
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
|
||||||
|
|
||||||
|
@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
|
||||||
|
.widthIn(min = 180.dp)
|
||||||
|
.shadow(
|
||||||
|
elevation = menuTheme.shadow.elevation,
|
||||||
|
shape = menuTheme.shape,
|
||||||
|
ambientColor = menuTheme.shadow.ambientColor,
|
||||||
|
spotColor = menuTheme.shadow.spotColor,
|
||||||
|
)
|
||||||
|
.clip(menuTheme.shape)
|
||||||
|
.background(menuTheme.background, menuTheme.shape)
|
||||||
|
.border(
|
||||||
|
BorderStroke(menuTheme.border.width, menuTheme.border.color),
|
||||||
|
menuTheme.shape,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
content()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun DropdownMenuItem(
|
||||||
|
text: String,
|
||||||
|
onClick: () -> Unit,
|
||||||
|
modifier: Modifier = Modifier,
|
||||||
|
enabled: Boolean = true,
|
||||||
|
leadingIcon: ImageVector? = 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),
|
||||||
|
) {
|
||||||
|
if (leadingIcon != null) {
|
||||||
|
Icon(
|
||||||
|
imageVector = leadingIcon,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(18.dp),
|
||||||
|
tint = if (enabled) {
|
||||||
|
menuTheme.itemForeground
|
||||||
|
} else {
|
||||||
|
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
Text(
|
||||||
|
text = text,
|
||||||
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
|
color = if (enabled) {
|
||||||
|
menuTheme.itemForeground
|
||||||
|
} else {
|
||||||
|
menuTheme.itemForeground.copy(alpha = 0.38f)
|
||||||
|
},
|
||||||
|
modifier = Modifier.weight(1f),
|
||||||
|
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)),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,132 @@
|
|||||||
|
/*
|
||||||
|
* 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.Column
|
||||||
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.Spacer
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.width
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.shadow
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
|
||||||
|
@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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,177 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.shadow
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Shape
|
||||||
|
import androidx.compose.ui.graphics.graphicsLayer
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
|
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),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
private fun RadioPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Column(verticalArrangement = Arrangement.spacedBy(16.dp)) {
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Radio(selected = true, onClick = {})
|
||||||
|
Text("Selected", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
Row(
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
|
Radio(selected = false, onClick = {})
|
||||||
|
Text("Unselected", style = MaterialTheme.typography.bodyMedium)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,313 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.Surface
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
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.tooling.preview.Preview
|
||||||
|
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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,243 @@
|
|||||||
|
/*
|
||||||
|
* 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.defaultMinSize
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
|
import androidx.compose.foundation.text.KeyboardActions
|
||||||
|
import androidx.compose.foundation.text.KeyboardOptions
|
||||||
|
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.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.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.tooling.preview.Preview
|
||||||
|
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
|
||||||
|
|
||||||
|
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()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun TextFieldPreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
androidx.compose.material3.Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
TextField(
|
||||||
|
modifier = Modifier.padding(top = 4.dp),
|
||||||
|
value = "Twenty One Pilots",
|
||||||
|
onValueChange = {},
|
||||||
|
placeholder = { Text("Placeholder") },
|
||||||
|
label = {
|
||||||
|
Text(
|
||||||
|
"Search Field",
|
||||||
|
modifier = Modifier.padding(bottom = 6.dp),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
color = MaterialTheme.colorScheme.onSurface.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
leadingIcon = {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Iconsax.IconsaxSearchBroken,
|
||||||
|
contentDescription = "Search Icon",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
trailingIcon = {
|
||||||
|
androidx.compose.material3.Icon(
|
||||||
|
imageVector = Iconsax.IconsaxTrash,
|
||||||
|
contentDescription = "Clear Icon",
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,264 @@
|
|||||||
|
/*
|
||||||
|
* 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.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.Surface
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
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.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.tooling.preview.Preview
|
||||||
|
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)
|
||||||
|
.border(BorderStroke(resolved.border.width, borderColor), resolved.shape)
|
||||||
|
.highlight(resolved.colors.highlight)
|
||||||
|
.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,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@Preview(showBackground = true)
|
||||||
|
@Composable
|
||||||
|
private fun TogglePreview() {
|
||||||
|
MaterialTheme {
|
||||||
|
val theme = rememberBaseUITheme()
|
||||||
|
CompositionLocalProvider(LocalBaseUITheme provides theme) {
|
||||||
|
Surface(
|
||||||
|
color = MaterialTheme.colorScheme.background,
|
||||||
|
modifier = Modifier.padding(24.dp),
|
||||||
|
) {
|
||||||
|
Column(
|
||||||
|
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
|
) {
|
||||||
|
Toggle(checked = true, onCheckedChange = {})
|
||||||
|
Toggle(checked = false, onCheckedChange = {})
|
||||||
|
Toggle(checked = true, onCheckedChange = null)
|
||||||
|
Toggle(checked = false, onCheckedChange = null, enabled = false)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -0,0 +1,23 @@
|
|||||||
|
package dev.krtirtho.spotube.core.ui.base
|
||||||
|
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.draw.drawWithCache
|
||||||
|
import androidx.compose.ui.graphics.Brush
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
fun Modifier.highlight(color: Color): Modifier =
|
||||||
|
drawWithCache {
|
||||||
|
val highlightBrush = Brush.verticalGradient(
|
||||||
|
colors = listOf(color, Color.Transparent),
|
||||||
|
startY = 0f,
|
||||||
|
endY = size.height * 0.5f,
|
||||||
|
)
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawRect(
|
||||||
|
brush = highlightBrush,
|
||||||
|
topLeft = androidx.compose.ui.geometry.Offset.Zero,
|
||||||
|
size = size,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
@ -17,13 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -32,54 +25,75 @@ import androidx.compose.foundation.layout.Spacer
|
|||||||
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 com.composeunstyled.SheetDetent
|
import androidx.compose.foundation.layout.size
|
||||||
import com.composeunstyled.rememberModalBottomSheetState
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
import 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 com.composeunstyled.currentWindowWidthBreakpoint
|
|
||||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
|
||||||
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,
|
||||||
title: @Composable (() -> Unit)? = null,
|
title: @Composable (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
breakpointDp: Float = 600f,
|
||||||
actions: @Composable (() -> Unit)? = null,
|
actions: @Composable (() -> Unit)? = null,
|
||||||
content: @Composable () -> Unit) {
|
content: @Composable () -> Unit,
|
||||||
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
) {
|
||||||
|
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||||
|
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||||
|
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
MillenniumDialog(
|
ThemedDialog(
|
||||||
visible = true,
|
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = "",
|
title = if (title != null) {
|
||||||
modifier = modifier) {
|
{
|
||||||
title?.let { it() }
|
Row(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
|
verticalAlignment = Alignment.CenterVertically
|
||||||
|
) {
|
||||||
|
Box(Modifier.weight(1f)) { title() }
|
||||||
|
IconButton(onClick = onDismiss) {
|
||||||
|
Icon(
|
||||||
|
Iconsax.FluentDismiss,
|
||||||
|
contentDescription = "Close dialog",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
|
actions = actions,
|
||||||
|
modifier = modifier,
|
||||||
|
) {
|
||||||
content()
|
content()
|
||||||
actions?.let {
|
|
||||||
MillenniumSeparator()
|
|
||||||
it()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
ModalBottomSheet(
|
||||||
LaunchedEffect(sheetState.currentDetent) {
|
onDismissRequest = onDismiss,
|
||||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
dragHandle = null,
|
||||||
onDismiss()
|
modifier = modifier,
|
||||||
}
|
) {
|
||||||
}
|
|
||||||
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(),
|
||||||
@ -87,12 +101,11 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(Modifier.weight(1f)) { it() }
|
Box(Modifier.weight(1f)) { it() }
|
||||||
MillenniumIconButton(
|
IconButton(onClick = onDismiss) {
|
||||||
onClick = onDismiss,
|
Icon(
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
Iconsax.FluentDismiss,
|
Iconsax.FluentDismiss,
|
||||||
contentDescription = "Close dialog")
|
contentDescription = "Close dialog",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -100,7 +113,7 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
content()
|
content()
|
||||||
actions?.let {
|
actions?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
MillenniumSeparator()
|
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
it()
|
it()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
|
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
|
||||||
@ -33,12 +34,20 @@ 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.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.foundation.text.input.rememberTextFieldState
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
|
import androidx.compose.material3.HorizontalDivider
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.ModalBottomSheet
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
||||||
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
|
||||||
@ -46,37 +55,25 @@ import androidx.compose.runtime.setValue
|
|||||||
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
|
||||||
|
import androidx.compose.ui.draw.shadow
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.ui.graphics.vector.ImageVector
|
import androidx.compose.ui.graphics.vector.ImageVector
|
||||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
|
||||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
|
||||||
import androidx.compose.ui.tooling.preview.Preview
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
|
import androidx.compose.ui.unit.DpOffset
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.composeunstyled.DropdownMenuPanel
|
|
||||||
import com.composeunstyled.SheetDetent
|
|
||||||
import com.composeunstyled.Text
|
|
||||||
import com.composeunstyled.UnstyledDropdownMenu
|
|
||||||
import com.composeunstyled.UnstyledDropdownMenuItem
|
|
||||||
import com.composeunstyled.rememberModalBottomSheetState
|
|
||||||
import compose.icons.FeatherIcons
|
import compose.icons.FeatherIcons
|
||||||
import compose.icons.feathericons.Check
|
import compose.icons.feathericons.Check
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
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.IconsaxHeart
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxHeart
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxShare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxTrash
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
|
||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
|
||||||
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
|
||||||
import org.spotube.millennium.composables.MillenniumSeparator
|
|
||||||
import org.spotube.millennium.composables.MillenniumTextField
|
|
||||||
import org.spotube.millennium.theme.MillenniumTheme
|
|
||||||
import org.spotube.millennium.theme.gelBrush
|
|
||||||
import org.spotube.millennium.theme.millenniumPanelBrush
|
|
||||||
import org.spotube.millennium.theme.millenniumSurface
|
|
||||||
|
|
||||||
data class AdaptiveMenuItem(
|
data class AdaptiveMenuItem(
|
||||||
val icon: ImageVector? = null,
|
val icon: ImageVector? = null,
|
||||||
@ -85,8 +82,7 @@ data class AdaptiveMenuItem(
|
|||||||
val enabled: Boolean = true,
|
val enabled: Boolean = true,
|
||||||
val selected: Boolean = false,
|
val selected: Boolean = false,
|
||||||
val dividerBefore: Boolean = false,
|
val dividerBefore: Boolean = false,
|
||||||
val subtitle: String? = null,
|
)
|
||||||
val leadingContent: (@Composable () -> Unit)? = null)
|
|
||||||
|
|
||||||
enum class HeaderDisplayMode {
|
enum class HeaderDisplayMode {
|
||||||
Always,
|
Always,
|
||||||
@ -99,129 +95,71 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
items: List<AdaptiveMenuItem>,
|
items: List<AdaptiveMenuItem>,
|
||||||
trigger: @Composable (onClick: () -> Unit) -> Unit,
|
trigger: @Composable (onClick: () -> Unit) -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
|
breakpointDp: Float = 600f,
|
||||||
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 isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||||
|
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
|
trigger { expanded = true }
|
||||||
|
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
UnstyledDropdownMenu(
|
DropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onExpandedChange = { expanded = it },
|
onDismissRequest = { expanded = false },
|
||||||
panel = {
|
) {
|
||||||
DropdownMenuPanel(
|
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||||
Modifier
|
header()
|
||||||
.width(menuMinWidth)
|
|
||||||
.millenniumSurface(
|
|
||||||
background = millenniumPanelBrush(MillenniumTheme.colors),
|
|
||||||
shape = AppTheme.shapes.medium,
|
|
||||||
borderColor = AppTheme.colors.outlineVariant,
|
|
||||||
borderWidth = 1.dp)
|
|
||||||
.padding(4.dp)) {
|
|
||||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
|
||||||
header()
|
|
||||||
}
|
|
||||||
|
|
||||||
val dropdownQueryState = rememberTextFieldState()
|
|
||||||
val query = dropdownQueryState.text.toString()
|
|
||||||
if (filter != null) {
|
|
||||||
MillenniumTextField(
|
|
||||||
state = dropdownQueryState,
|
|
||||||
label = "",
|
|
||||||
placeholder = "Search...",
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp))
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach { item ->
|
|
||||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
|
||||||
return@forEach
|
|
||||||
}
|
|
||||||
if (item.dividerBefore) {
|
|
||||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
|
||||||
}
|
|
||||||
val itemInteraction = remember { MutableInteractionSource() }
|
|
||||||
val itemHovered by itemInteraction.collectIsHoveredAsState()
|
|
||||||
val itemPressed by itemInteraction.collectIsPressedAsState()
|
|
||||||
val itemEnabled = item.enabled
|
|
||||||
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
|
|
||||||
val itemForeground = when {
|
|
||||||
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
|
||||||
itemHighlighted -> MillenniumTheme.colors.onAccent
|
|
||||||
else -> AppTheme.colors.onSurface
|
|
||||||
}
|
|
||||||
UnstyledDropdownMenuItem(
|
|
||||||
onClick = { item.onClick() },
|
|
||||||
enabled = itemEnabled,
|
|
||||||
indication = null,
|
|
||||||
interactionSource = itemInteraction) {
|
|
||||||
Row(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.then(
|
|
||||||
if (itemHighlighted) {
|
|
||||||
Modifier.millenniumSurface(
|
|
||||||
background = gelBrush(
|
|
||||||
MillenniumTheme.colors.accentTop,
|
|
||||||
MillenniumTheme.colors.accentBottom),
|
|
||||||
shape = RoundedCornerShape(8.dp))
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
})
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
|
||||||
val itemIcon = item.icon
|
|
||||||
when {
|
|
||||||
item.leadingContent != null -> item.leadingContent.invoke()
|
|
||||||
itemIcon != null -> MillenniumIcon(
|
|
||||||
imageVector = itemIcon,
|
|
||||||
contentDescription = null,
|
|
||||||
tint = itemForeground,
|
|
||||||
modifier = Modifier.size(20.dp))
|
|
||||||
|
|
||||||
item.selected -> MillenniumIcon(
|
|
||||||
imageVector = FeatherIcons.Check,
|
|
||||||
contentDescription = "Selected",
|
|
||||||
tint = itemForeground,
|
|
||||||
modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
|
||||||
Text(
|
|
||||||
text = item.label,
|
|
||||||
style = AppTheme.typography.bodyMedium,
|
|
||||||
color = itemForeground)
|
|
||||||
item.subtitle?.let { subtitle ->
|
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = AppTheme.typography.bodySmall,
|
|
||||||
color = if (itemHighlighted) {
|
|
||||||
itemForeground.copy(alpha = 0.85f)
|
|
||||||
} else {
|
|
||||||
AppTheme.colors.onSurfaceVariant
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = FeatherIcons.Check,
|
|
||||||
contentDescription = "Selected",
|
|
||||||
tint = itemForeground,
|
|
||||||
modifier = Modifier.size(20.dp))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
|
||||||
anchor = { trigger { expanded = true } },
|
var query by remember { mutableStateOf("") }
|
||||||
)
|
if (filter != null) {
|
||||||
|
TextField(
|
||||||
|
value = query,
|
||||||
|
onValueChange = {
|
||||||
|
query = it
|
||||||
|
},
|
||||||
|
placeholder = { Text("Search...") },
|
||||||
|
singleLine = true,
|
||||||
|
leadingIcon = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.IconsaxFilterSearch,
|
||||||
|
contentDescription = "Search",
|
||||||
|
modifier = Modifier.size(16.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
},
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach { item ->
|
||||||
|
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (item.dividerBefore) {
|
||||||
|
DropdownMenuDivider()
|
||||||
|
}
|
||||||
|
DropdownMenuItem(
|
||||||
|
text = item.label,
|
||||||
|
onClick = {
|
||||||
|
item.onClick()
|
||||||
|
expanded = false
|
||||||
|
},
|
||||||
|
enabled = item.enabled,
|
||||||
|
selected = item.selected,
|
||||||
|
leadingIcon = item.icon,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
} else {
|
} else {
|
||||||
trigger { expanded = true }
|
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
AdaptiveBottomSheetContent(
|
AdaptiveBottomSheetContent(
|
||||||
onDismiss = { expanded = false },
|
onDismiss = { expanded = false },
|
||||||
@ -231,124 +169,143 @@ 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 }
|
||||||
val queryState = rememberTextFieldState()
|
var query by remember { mutableStateOf("") }
|
||||||
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
|
||||||
}
|
}
|
||||||
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
ModalBottomSheet(
|
||||||
LaunchedEffect(sheetState.currentDetent) {
|
onDismissRequest = onDismiss,
|
||||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
dragHandle = null,
|
||||||
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))
|
||||||
MillenniumTextField(
|
TextField(
|
||||||
state = queryState,
|
value = query,
|
||||||
label = "",
|
onValueChange = {
|
||||||
placeholder = "Search...",
|
query = it
|
||||||
|
},
|
||||||
|
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) { index ->
|
count = filteredItems.size,
|
||||||
|
) { index ->
|
||||||
val item = filteredItems[index]
|
val item = filteredItems[index]
|
||||||
|
|
||||||
if (item.dividerBefore) {
|
if (item.dividerBefore) {
|
||||||
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
HorizontalDivider(
|
||||||
|
modifier = Modifier.padding(vertical = 4.dp),
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(AppTheme.shapes.medium)
|
.clip(MaterialTheme.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) {
|
||||||
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
},
|
},
|
||||||
shape = AppTheme.shapes.medium)
|
shape = MaterialTheme.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.SpaceBetween,
|
||||||
when {
|
) {
|
||||||
item.leadingContent != null -> item.leadingContent.invoke()
|
Row(
|
||||||
item.icon != null -> MillenniumIcon(
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
imageVector = item.icon,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
contentDescription = null,
|
) {
|
||||||
modifier = Modifier.size(22.dp),
|
if (item.icon != null) {
|
||||||
tint = if (item.enabled) {
|
Icon(
|
||||||
AppTheme.colors.onSurface
|
imageVector = item.icon,
|
||||||
} else {
|
contentDescription = null,
|
||||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
modifier = Modifier.size(22.dp),
|
||||||
})
|
tint = if (item.enabled) {
|
||||||
|
MaterialTheme.colorScheme.onSurface
|
||||||
item.selected -> MillenniumIcon(
|
} else {
|
||||||
imageVector = FeatherIcons.Check,
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
contentDescription = "Selected",
|
},
|
||||||
modifier = Modifier.size(22.dp),
|
)
|
||||||
tint = AppTheme.colors.onSurfaceVariant)
|
} else if (item.selected) {
|
||||||
|
Icon(
|
||||||
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
imageVector = FeatherIcons.Check,
|
||||||
}
|
contentDescription = "Selected",
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
} else if (hasSelection) {
|
||||||
|
Spacer(modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = AppTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (item.enabled) {
|
color = if (item.enabled) {
|
||||||
AppTheme.colors.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
})
|
},
|
||||||
item.subtitle?.let { subtitle ->
|
)
|
||||||
Text(
|
|
||||||
text = subtitle,
|
|
||||||
style = AppTheme.typography.bodySmall,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
if (item.selected && item.icon != null) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = FeatherIcons.Check,
|
imageVector = FeatherIcons.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = "Selected",
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = AppTheme.colors.onSurfaceVariant)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -360,37 +317,44 @@ private fun AdaptiveBottomSheetContent(
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||||
Box(modifier = Modifier.background(AppTheme.colors.background)) {
|
Scaffold {
|
||||||
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 ->
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.background(
|
.background(
|
||||||
color = AppTheme.colors.primary.copy(alpha = 0.1f),
|
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
||||||
shape = CircleShape)
|
shape = CircleShape,
|
||||||
|
)
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = AppTheme.colors.primary)
|
tint = MaterialTheme.colorScheme.primary,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,8 @@ 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(
|
||||||
@ -56,14 +57,17 @@ fun AlbumCard(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.Album,
|
RemoteCollectionType.Album,
|
||||||
album.id,
|
album.id,
|
||||||
album.title)
|
album.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.Album,
|
RemoteCollectionType.Album,
|
||||||
album.id,
|
album.id,
|
||||||
album.title)
|
album.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier.width(160.dp),
|
modifier = modifier.width(160.dp),
|
||||||
sharedElementKey = "album_art_${album.id}")
|
sharedElementKey = "album_art_${album.id}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,12 +17,9 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -33,12 +30,12 @@ import org.koin.compose.koinInject
|
|||||||
fun ApplicationBackButton() {
|
fun ApplicationBackButton() {
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
|
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigationCommands.pop()
|
navigationCommands.pop()
|
||||||
},
|
},
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = Iconsax.ArrowLeft3,
|
imageVector = Iconsax.ArrowLeft3,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
@ -50,5 +47,5 @@ expect fun ApplicationMainBar(
|
|||||||
title: @Composable () -> Unit = {},
|
title: @Composable () -> Unit = {},
|
||||||
subtitle: @Composable () -> Unit = {},
|
subtitle: @Composable () -> Unit = {},
|
||||||
actions: @Composable (RowScope.() -> Unit) = {},
|
actions: @Composable (RowScope.() -> Unit) = {},
|
||||||
backButton: Boolean = true,
|
backButton: Boolean = true
|
||||||
transparent: Boolean = false)
|
)
|
||||||
@ -29,9 +29,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.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,7 +52,8 @@ 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(
|
||||||
@ -63,6 +64,7 @@ fun ArtistCard(
|
|||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
isBlacklisted = isBlacklisted)
|
isBlacklisted = isBlacklisted,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,24 +17,13 @@
|
|||||||
|
|
||||||
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 com.composeunstyled.currentWindowWidthBreakpoint
|
import androidx.compose.foundation.layout.BoxWithConstraints
|
||||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
|
||||||
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
|
||||||
@ -42,9 +31,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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.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
|
||||||
@ -53,6 +42,12 @@ 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
|
||||||
@ -67,6 +62,7 @@ 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(
|
||||||
@ -89,9 +85,10 @@ fun CollectionDetails(
|
|||||||
onFollowClick: () -> Unit = { },
|
onFollowClick: () -> Unit = { },
|
||||||
showFollowButton: Boolean = true,
|
showFollowButton: Boolean = true,
|
||||||
onEdit: (() -> Unit)? = null,
|
onEdit: (() -> Unit)? = null,
|
||||||
sharedElementKey: String? = null) {
|
sharedElementKey: String? = null,
|
||||||
Box(modifier = modifier.fillMaxWidth()) {
|
) {
|
||||||
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
||||||
|
val isCompact = maxWidth < 600.dp
|
||||||
|
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
@ -101,86 +98,95 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumButton(
|
PrimaryButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onPlay) {
|
onClick = onPlay,
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
|
||||||
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
|
|
||||||
}
|
|
||||||
MillenniumButton(
|
|
||||||
modifier = modifier,
|
|
||||||
onClick = onShufflePlay,
|
|
||||||
style = MillenniumButtonStyle.Secondary
|
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
Icon(
|
||||||
TextWithShimmer(text = "Shuffle")
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
|
)
|
||||||
|
TextWithShimmer(
|
||||||
|
text = if (isPlaying) "Pause" else "Play",
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
OutlineButton(
|
||||||
|
modifier = modifier,
|
||||||
|
onClick = onShufflePlay
|
||||||
|
) {
|
||||||
|
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||||
|
TextWithShimmer(
|
||||||
|
text = "Shuffle",
|
||||||
|
modifier = Modifier.padding(start = 6.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val actions = @Composable {
|
val actions = @Composable {
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
ButtonGroup {
|
||||||
MillenniumIconButton(
|
GroupIconButton(onClick = onAddToQueue) {
|
||||||
onClick = onAddToQueue,
|
Icon(
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue"
|
contentDescription = "Add to queue"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
ButtonGroupDivider()
|
||||||
if (showFollowButton) {
|
if (showFollowButton) {
|
||||||
if (isFollowing) {
|
if (isFollowing) {
|
||||||
MillenniumIconButton(
|
GroupIconButton(onClick = onFollowClick) {
|
||||||
onClick = onFollowClick,
|
Icon(
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.IconsaxHeart2,
|
imageVector = Iconsax.IconsaxHeart2,
|
||||||
contentDescription = "Unfollow")
|
contentDescription = "Unfollow",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
MillenniumIconButton(
|
GroupIconButton(onClick = onFollowClick) {
|
||||||
onClick = onFollowClick,
|
Icon(
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.IconsaxHeart,
|
imageVector = Iconsax.IconsaxHeart,
|
||||||
contentDescription = "Follow",
|
contentDescription = "Follow",
|
||||||
tint = AppTheme.colors.onSurfaceVariant)
|
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (onEdit != null) {
|
if (onEdit != null) {
|
||||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
ButtonGroupDivider()
|
||||||
MillenniumIconButton(
|
GroupIconButton(onClick = onEdit) {
|
||||||
onClick = onEdit,
|
Icon(
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.IconsaxEdit,
|
imageVector = Iconsax.IconsaxEdit,
|
||||||
contentDescription = "Edit playlist")
|
contentDescription = "Edit playlist",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -189,7 +195,8 @@ 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,
|
||||||
@ -198,24 +205,27 @@ fun CollectionDetails(
|
|||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentScale = ContentScale.Crop)
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = Iconsax.User,
|
imageVector = Iconsax.User,
|
||||||
contentDescription = ownerName,
|
contentDescription = ownerName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.background(
|
.background(
|
||||||
AppTheme.colors.primaryContainer,
|
MaterialTheme.colorScheme.primaryContainer,
|
||||||
CircleShape
|
CircleShape
|
||||||
)
|
)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
tint = AppTheme.colors.onPrimaryContainer)
|
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "By $ownerName",
|
text = "By $ownerName",
|
||||||
style = AppTheme.typography.labelSmall,
|
style = MaterialTheme.typography.labelSmall,
|
||||||
color = AppTheme.colors.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -227,68 +237,79 @@ 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 = AppTheme.typography.displaySmall,
|
style = MaterialTheme.typography.displaySmall,
|
||||||
color = AppTheme.colors.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val metadata = @Composable {
|
val metadata = @Composable {
|
||||||
Column {
|
Column {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
|
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.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) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
|
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis)
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumCard(
|
Card(
|
||||||
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()
|
||||||
@ -302,12 +323,14 @@ 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()
|
||||||
@ -331,5 +354,6 @@ fun CollectionDetailsPreview() {
|
|||||||
onOwnerClick = {},
|
onOwnerClick = {},
|
||||||
onPlay = {},
|
onPlay = {},
|
||||||
onShufflePlay = {},
|
onShufflePlay = {},
|
||||||
onAddToQueue = {})
|
onAddToQueue = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,22 +17,18 @@
|
|||||||
|
|
||||||
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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.IconButton
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
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
|
||||||
@ -97,7 +93,8 @@ 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 {
|
||||||
@ -106,42 +103,45 @@ fun CollectionView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Scaffold(
|
||||||
ApplicationMainBar(
|
topBar = {
|
||||||
backButton = true,
|
ApplicationMainBar(
|
||||||
title = if (isCollapsed) {
|
backButton = true,
|
||||||
{
|
title = if (isCollapsed) {
|
||||||
CollapsedCollectionTitle(
|
{
|
||||||
title = title,
|
CollapsedCollectionTitle(
|
||||||
imageURL = imageURL,
|
title = title,
|
||||||
imageResource = imageResource)
|
imageURL = imageURL,
|
||||||
}
|
imageResource = imageResource,
|
||||||
} else {
|
)
|
||||||
{}
|
|
||||||
},
|
|
||||||
actions = if (isCollapsed && !isJamGuest) {
|
|
||||||
{
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = onPlay,
|
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play")
|
|
||||||
}
|
}
|
||||||
}
|
} else {
|
||||||
} else {
|
{}
|
||||||
{}
|
},
|
||||||
})
|
actions = if (isCollapsed && !isJamGuest) {
|
||||||
|
{
|
||||||
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
IconButton(onClick = onPlay) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
) { innerPadding ->
|
||||||
if (error != null && tracks.isEmpty() && !isLoading) {
|
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||||
ErrorDisplay(
|
ErrorDisplay(
|
||||||
errorMessage = error,
|
errorMessage = error,
|
||||||
onRetry = onRetry ?: {},
|
onRetry = onRetry ?: {},
|
||||||
modifier = Modifier.fillMaxSize())
|
modifier = Modifier.padding(innerPadding),
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
TrackList(
|
TrackList(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.padding(innerPadding),
|
||||||
state = listState,
|
state = listState,
|
||||||
headerContent = {
|
headerContent = {
|
||||||
if (isLoading && tracks.isEmpty()) {
|
if (isLoading && tracks.isEmpty()) {
|
||||||
@ -164,7 +164,8 @@ fun CollectionView(
|
|||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
onAddToJam = { onAddToJam(tracks) })
|
onAddToJam = { onAddToJam(tracks) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CollectionDetails(
|
CollectionDetails(
|
||||||
@ -185,7 +186,8 @@ fun CollectionView(
|
|||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
onAddToJam = { onAddToJam(tracks) })
|
onAddToJam = { onAddToJam(tracks) },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
footerContent = footerContent,
|
footerContent = footerContent,
|
||||||
@ -209,11 +211,11 @@ fun CollectionView(
|
|||||||
onBulkAddToJam = onBulkAddToJam,
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
trackOptionsState = trackOptionsState)
|
trackOptionsState = trackOptionsState,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
trailingContent()
|
trailingContent()
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -221,39 +223,46 @@ fun CollectionView(
|
|||||||
private fun CollapsedCollectionTitle(
|
private fun CollapsedCollectionTitle(
|
||||||
title: String,
|
title: String,
|
||||||
imageURL: String,
|
imageURL: String,
|
||||||
imageResource: DrawableResource? = null) {
|
imageResource: DrawableResource? = null,
|
||||||
|
) {
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentAlignment = Alignment.Center) {
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
if (imageURL.isNotBlank()) {
|
if (imageURL.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
contentScale = ContentScale.Crop)
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
} else if (imageResource != null) {
|
} else if (imageResource != null) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
painter = painterResource(imageResource),
|
painter = painterResource(imageResource),
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
contentScale = ContentScale.Crop)
|
contentScale = ContentScale.Crop,
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||||
style = AppTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
color = AppTheme.colors.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = AppTheme.typography.titleSmall,
|
style = MaterialTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(start = 10.dp))
|
modifier = Modifier.padding(start = 10.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,33 +17,30 @@
|
|||||||
|
|
||||||
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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.Button
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.CardDefaults
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
|
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
|
||||||
@ -70,7 +67,8 @@ 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
|
||||||
|
|
||||||
@ -79,99 +77,117 @@ 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 = AppTheme.colors.error.copy(alpha = 0.7f))
|
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
||||||
|
)
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "We're Truly Sorry",
|
text = "We're Truly Sorry",
|
||||||
style = AppTheme.typography.headlineSmall,
|
style = MaterialTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = AppTheme.colors.onSurface,
|
color = MaterialTheme.colorScheme.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 = AppTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
|
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
|
||||||
|
)
|
||||||
|
|
||||||
MillenniumCard(
|
Card(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(0.dp)) {
|
colors = CardDefaults.cardColors(
|
||||||
|
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 = AppTheme.typography.labelMedium,
|
style = MaterialTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = AppTheme.colors.onSurfaceVariant)
|
color = MaterialTheme.colorScheme.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) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = FeatherIcons.Copy,
|
imageVector = FeatherIcons.Copy,
|
||||||
contentDescription = "Copy to clipboard",
|
contentDescription = "Copy to clipboard",
|
||||||
modifier = Modifier.size(16.dp))
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
onClick = { showFullLog = true },
|
onClick = { showFullLog = true },
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = FeatherIcons.Maximize2,
|
imageVector = FeatherIcons.Maximize2,
|
||||||
contentDescription = "View full log",
|
contentDescription = "View full log",
|
||||||
modifier = Modifier.size(16.dp))
|
modifier = Modifier.size(16.dp),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
MillenniumCard(
|
Surface(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
contentPadding = PaddingValues(0.dp)) {
|
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
|
||||||
|
) {
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = errorMessage,
|
text = errorMessage,
|
||||||
style = AppTheme.typography.bodySmall.copy(
|
style = MaterialTheme.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) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
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")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -179,85 +195,93 @@ fun ErrorDisplay(
|
|||||||
if (showFullLog) {
|
if (showFullLog) {
|
||||||
ErrorLogDialog(
|
ErrorLogDialog(
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
onDismiss = { showFullLog = false })
|
onDismiss = { showFullLog = false },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorLogDialog(
|
private fun ErrorLogDialog(
|
||||||
errorMessage: String,
|
errorMessage: String,
|
||||||
onDismiss: () -> Unit) {
|
onDismiss: () -> Unit,
|
||||||
|
) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
MillenniumDialog(
|
AlertDialog(
|
||||||
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 = "Full Error Log",
|
|
||||||
style = AppTheme.typography.titleLarge,
|
|
||||||
fontWeight = FontWeight.Bold)
|
|
||||||
|
|
||||||
MillenniumIconButton(
|
|
||||||
onClick = {
|
|
||||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
|
||||||
},
|
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = FeatherIcons.Copy,
|
|
||||||
contentDescription = "Copy to clipboard")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.height(400.dp)
|
|
||||||
.clip(AppTheme.shapes.small)
|
|
||||||
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
|
||||||
SelectionContainer {
|
|
||||||
Text(
|
Text(
|
||||||
text = errorMessage,
|
text = "Full Error Log",
|
||||||
style = AppTheme.typography.bodySmall.copy(
|
style = MaterialTheme.typography.titleLarge,
|
||||||
fontFamily = FontFamily.Monospace),
|
fontWeight = FontWeight.Bold,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
)
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
IconButton(
|
||||||
.verticalScroll(rememberScrollState())
|
onClick = {
|
||||||
.padding(12.dp))
|
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||||
|
},
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = FeatherIcons.Copy,
|
||||||
|
contentDescription = "Copy to clipboard",
|
||||||
|
)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
Row(
|
text = {
|
||||||
modifier = Modifier.fillMaxWidth(),
|
Box(
|
||||||
horizontalArrangement = Arrangement.End) {
|
modifier = Modifier
|
||||||
MillenniumButton(
|
.fillMaxWidth()
|
||||||
onClick = onDismiss,
|
.height(400.dp)
|
||||||
style = MillenniumButtonStyle.Quiet) {
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
|
||||||
|
) {
|
||||||
|
SelectionContainer {
|
||||||
|
Text(
|
||||||
|
text = errorMessage,
|
||||||
|
style = MaterialTheme.typography.bodySmall.copy(
|
||||||
|
fontFamily = FontFamily.Monospace,
|
||||||
|
),
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.verticalScroll(rememberScrollState())
|
||||||
|
.padding(12.dp),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
confirmButton = {
|
||||||
|
TextButton(onClick = onDismiss) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorDisplayPreview() {
|
private fun ErrorDisplayPreview() {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
Surface {
|
||||||
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() {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
Scaffold {
|
||||||
ErrorLogDialog(
|
ErrorLogDialog(
|
||||||
errorMessage = "This is an error message",
|
errorMessage = "This is an error message",
|
||||||
onDismiss = {})
|
onDismiss = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,10 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -38,11 +34,11 @@ 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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.IconButton
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.MaterialTheme
|
||||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
import androidx.compose.material3.Text
|
||||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
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
|
||||||
@ -87,7 +83,8 @@ 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 }
|
||||||
}
|
}
|
||||||
@ -95,17 +92,20 @@ 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 }
|
||||||
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
val adaptiveInfo = currentWindowAdaptiveInfo()
|
||||||
|
val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@ -113,7 +113,8 @@ 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(
|
||||||
@ -122,17 +123,21 @@ 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() }) { item: MetadataBrowseItem ->
|
key = { index: Int -> items[index].hashCode() },
|
||||||
|
) { 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))
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -143,7 +148,8 @@ 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>()
|
||||||
|
|
||||||
@ -198,21 +204,25 @@ 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()
|
||||||
@ -221,35 +231,43 @@ 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 = AppTheme.typography.titleMedium,
|
style = MaterialTheme.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 = AppTheme.typography.bodySmall,
|
style = MaterialTheme.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(
|
||||||
@ -288,7 +306,8 @@ 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()
|
||||||
|
|
||||||
@ -301,8 +320,7 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
style = MillenniumIconButtonStyle.Quiet,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
||||||
@ -312,7 +330,7 @@ private fun FeaturedPlayButton(
|
|||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
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
|
||||||
@ -329,8 +347,7 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
style = MillenniumIconButtonStyle.Quiet,
|
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
||||||
@ -340,7 +357,7 @@ private fun FeaturedPlayButton(
|
|||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
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
|
||||||
@ -360,20 +377,20 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
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
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
|
|||||||
@ -17,10 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -39,9 +35,11 @@ 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 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.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -78,7 +76,8 @@ 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()
|
||||||
@ -93,7 +92,8 @@ 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,8 +106,10 @@ 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),
|
||||||
@ -118,24 +120,22 @@ fun LikedTracksCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp)
|
||||||
) {
|
) {
|
||||||
MillenniumIconButton(
|
FilledTonalIconButton(
|
||||||
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(30.dp)
|
||||||
style = MillenniumIconButtonStyle.Secondary
|
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
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))
|
||||||
MillenniumIconButton(
|
FilledIconButton(
|
||||||
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(30.dp)
|
||||||
style = MillenniumIconButtonStyle.Primary
|
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = if (isPlaying) {
|
imageVector = if (isPlaying) {
|
||||||
Iconsax.IconsaxPauseCircle
|
Iconsax.IconsaxPauseCircle
|
||||||
} else Iconsax.Play,
|
} else Iconsax.Play,
|
||||||
@ -152,16 +152,18 @@ fun LikedTracksCard(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Liked Tracks",
|
text = "Liked Tracks",
|
||||||
style = AppTheme.typography.titleSmall,
|
style = MaterialTheme.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 = AppTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis)
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,7 +40,8 @@ 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()
|
||||||
|
|
||||||
@ -57,14 +58,17 @@ fun PlaylistCard(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.Playlist,
|
RemoteCollectionType.Playlist,
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title)
|
playlist.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.Playlist,
|
RemoteCollectionType.Playlist,
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title)
|
playlist.title,
|
||||||
|
)
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
sharedElementKey = "playlist_art_${playlist.id}")
|
sharedElementKey = "playlist_art_${playlist.id}",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,11 +35,13 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
|
|||||||
fun Modifier.sharedElementOrNone(
|
fun Modifier.sharedElementOrNone(
|
||||||
key: String?,
|
key: String?,
|
||||||
sharedTransitionScope: SharedTransitionScope?,
|
sharedTransitionScope: SharedTransitionScope?,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
animatedVisibilityScope: AnimatedVisibilityScope?,
|
||||||
|
): 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,12 @@ 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 dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.FilledIconButton
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.FilledTonalIconButton
|
||||||
|
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
|
||||||
@ -51,7 +54,8 @@ 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 = "")
|
||||||
|
|
||||||
@ -65,7 +69,8 @@ fun TrackCard(
|
|||||||
entries = listOf(queueEntry),
|
entries = listOf(queueEntry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null)
|
collectionEntry = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
|
|||||||
@ -17,10 +17,6 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
|
||||||
import com.composeunstyled.currentWindowWidthBreakpoint
|
|
||||||
import org.spotube.millennium.adaptive.WindowBreakpoints
|
|
||||||
import androidx.compose.animation.AnimatedContent
|
import androidx.compose.animation.AnimatedContent
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
import androidx.compose.animation.fadeOut
|
import androidx.compose.animation.fadeOut
|
||||||
@ -40,7 +36,6 @@ 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
|
||||||
@ -49,20 +44,12 @@ 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.foundation.text.input.rememberTextFieldState
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.ui.state.ToggleableState
|
import androidx.compose.material3.HorizontalDivider
|
||||||
import org.spotube.millennium.composables.MillenniumCard
|
import androidx.compose.material3.Icon
|
||||||
import org.spotube.millennium.composables.MillenniumCheckbox
|
import androidx.compose.material3.MaterialTheme
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import androidx.compose.material3.Scaffold
|
||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
import androidx.compose.material3.Text
|
||||||
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
|
||||||
@ -81,6 +68,7 @@ import androidx.compose.ui.graphics.Color
|
|||||||
import androidx.compose.ui.graphics.luminance
|
import androidx.compose.ui.graphics.luminance
|
||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
|
import androidx.compose.ui.platform.LocalWindowInfo
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.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
|
||||||
@ -95,6 +83,16 @@ 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
|
||||||
@ -124,7 +122,7 @@ enum class TrackSortOption(val label: String) {
|
|||||||
Duration("Duration"),
|
Duration("Duration"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TrackList(
|
fun TrackList(
|
||||||
tracks: List<MetadataTrack> = emptyList(),
|
tracks: List<MetadataTrack> = emptyList(),
|
||||||
@ -155,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()
|
) {
|
||||||
val filterQuery = filterState.text.toString()
|
var filterQuery by rememberSaveable { mutableStateOf("") }
|
||||||
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) }
|
||||||
@ -209,19 +207,20 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val widthBreakpoint = currentWindowWidthBreakpoint()
|
val windowInfo = LocalWindowInfo.current
|
||||||
val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium
|
val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
|
||||||
|
val isCompact = maxWidth < CompactTrackListBreakpoint
|
||||||
val isDesktop = remember { getPlatform().isDesktop() }
|
val isDesktop = remember { getPlatform().isDesktop() }
|
||||||
val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded
|
val showIndex = maxWidth >= LargeTrackListBreakpoint
|
||||||
val showAlbum = !isCompact
|
val showAlbum = !isCompact
|
||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
val trackCardContent: @Composable () -> Unit = {
|
val trackCardContent: @Composable () -> Unit = {
|
||||||
MillenniumCard(
|
Card(
|
||||||
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()
|
||||||
@ -229,7 +228,9 @@ fun TrackList(
|
|||||||
) {
|
) {
|
||||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||||
if (displayedIndex > 0) {
|
if (displayedIndex > 0) {
|
||||||
MillenniumSeparator()
|
HorizontalDivider(
|
||||||
|
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
TrackListRow(
|
TrackListRow(
|
||||||
index = displayedIndex + 1,
|
index = displayedIndex + 1,
|
||||||
@ -283,31 +284,38 @@ 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) {
|
||||||
MillenniumSeparator(
|
HorizontalDivider(
|
||||||
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()) {
|
||||||
MillenniumSeparator(
|
HorizontalDivider(
|
||||||
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) {
|
||||||
@ -316,14 +324,17 @@ fun TrackList(
|
|||||||
|
|
||||||
if (isLoadingNextPage) {
|
if (isLoadingNextPage) {
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
MillenniumSeparator(
|
HorizontalDivider(
|
||||||
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -334,18 +345,19 @@ 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 -> ToggleableState.On
|
allSelected -> CheckBoxState.SELECTED
|
||||||
anySelected -> ToggleableState.Indeterminate
|
anySelected -> CheckBoxState.INDETERMINATE
|
||||||
else -> ToggleableState.Off
|
else -> CheckBoxState.UNSELECTED
|
||||||
}
|
}
|
||||||
MillenniumTriStateCheckbox(
|
CheckBox(
|
||||||
value = headerState,
|
state = headerState,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
@ -355,45 +367,58 @@ 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)
|
||||||
) {
|
) {
|
||||||
MillenniumTextField(
|
TextField(
|
||||||
state = filterState,
|
value = filterQuery,
|
||||||
label = "",
|
onValueChange = { filterQuery = it },
|
||||||
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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(verticalAlignment = Alignment.CenterVertically) {
|
ButtonGroup {
|
||||||
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 ->
|
||||||
MillenniumIconButton(
|
GroupIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = Iconsax.IconsaxSort,
|
imageVector = Iconsax.IconsaxSort,
|
||||||
contentDescription = "Sort")
|
contentDescription = "Sort",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
ButtonGroupDivider()
|
||||||
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 {
|
||||||
@ -408,42 +433,54 @@ 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 ->
|
||||||
MillenniumIconButton(
|
GroupIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = "Bulk actions")
|
contentDescription = "Bulk actions",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -458,7 +495,8 @@ 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()
|
||||||
@ -487,7 +525,8 @@ 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))
|
||||||
@ -497,7 +536,8 @@ 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()
|
||||||
}
|
}
|
||||||
@ -520,7 +560,8 @@ fun TrackList(
|
|||||||
onTrackOptionsAction(track, action)
|
onTrackOptionsAction(track, action)
|
||||||
selectedTrackForOptions = null
|
selectedTrackForOptions = null
|
||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } })
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -549,198 +590,235 @@ 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 -> AppTheme.colors.secondary.copy(alpha = 0.12f)
|
isSelected -> rowTheme.background.selected
|
||||||
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
|
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
||||||
isCurrentTrack -> AppTheme.colors.surfaceVariant
|
isCurrentTrack -> rowTheme.background.focused
|
||||||
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
|
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
||||||
else -> Color.Transparent
|
else -> rowTheme.background.normal
|
||||||
}
|
}
|
||||||
|
val highlightColor = Color.White.copy(
|
||||||
|
alpha = if (isLight) 0.9f else 0.06f
|
||||||
|
)
|
||||||
|
|
||||||
MillenniumListTile(
|
Row(
|
||||||
modifier = modifier,
|
modifier = modifier
|
||||||
selected = isSelected || isCurrentTrack,
|
.fillMaxWidth()
|
||||||
onClick = onTrackClick,
|
.hoverable(rowInteractionSource)
|
||||||
title = {
|
.combinedClickable(
|
||||||
|
onClick = onTrackClick,
|
||||||
|
onLongClick = onLongClick,
|
||||||
|
)
|
||||||
|
.background(rowBackgroundColor)
|
||||||
|
.then(
|
||||||
|
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
|
||||||
|
Modifier.highlight(highlightColor)
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.padding(horizontal = 8.dp, vertical = 6.dp)
|
||||||
|
.padding(end = 6.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
||||||
|
) {
|
||||||
|
if (isSelectionMode) {
|
||||||
|
CheckBox(
|
||||||
|
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||||
|
onClick = { onSelectionToggle(!isSelected) },
|
||||||
|
modifier = Modifier.shimmerApply()
|
||||||
|
)
|
||||||
|
} else if (showIndex) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier.width(30.dp),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
AnimatedContent(
|
||||||
|
targetState = isRowHovered,
|
||||||
|
transitionSpec = {
|
||||||
|
fadeIn() togetherWith fadeOut()
|
||||||
|
},
|
||||||
|
label = "index-checkbox",
|
||||||
|
) { hovered ->
|
||||||
|
if (hovered) {
|
||||||
|
CheckBox(
|
||||||
|
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
||||||
|
onClick = { onSelectionToggle(!isSelected) },
|
||||||
|
modifier = Modifier.shimmerApply()
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
TextWithShimmer(
|
||||||
|
text = index.toString(),
|
||||||
|
style = MaterialTheme.typography.labelMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.size(44.dp)
|
||||||
|
.clip(MaterialTheme.shapes.small)
|
||||||
|
.hoverable(interactionSource = artworkInteractionSource),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
|
||||||
|
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||||
|
val platformContext = LocalPlatformContext.current
|
||||||
|
val imageRequest = remember(imageUrl) {
|
||||||
|
ImageRequest.Builder(platformContext)
|
||||||
|
.data(imageUrl)
|
||||||
|
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
|
||||||
|
.crossfade(false) // Crucial for scroll performance
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
|
AsyncImage(
|
||||||
|
model = imageRequest,
|
||||||
|
contentDescription = track.title,
|
||||||
|
contentScale = ContentScale.Crop,
|
||||||
|
modifier = Modifier.fillMaxSize().shimmerApply(),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (isArtworkHovered || isCurrentTrack) {
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(Color.Black.copy(alpha = 0.10f)),
|
||||||
|
contentAlignment = Alignment.Center,
|
||||||
|
) {
|
||||||
|
Icon(
|
||||||
|
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
|
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp).shimmerApply(),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1.2f)) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = AppTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis)
|
overflow = TextOverflow.Ellipsis,
|
||||||
},
|
)
|
||||||
subtitle = {
|
|
||||||
ArtistChips(
|
ArtistChips(
|
||||||
artists = track.artists,
|
artists = track.artists,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick)
|
onArtistsOverflowClick = onArtistsOverflowClick,
|
||||||
},
|
)
|
||||||
leading = {
|
}
|
||||||
|
|
||||||
|
if (showAlbum && track.album != null) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.weight(1f)
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
) {
|
||||||
if (isSelectionMode) {
|
|
||||||
MillenniumCheckbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onSelectionToggle(it) },
|
|
||||||
label = "",
|
|
||||||
modifier = Modifier.shimmerApply())
|
|
||||||
} else if (showIndex) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.width(30.dp),
|
|
||||||
contentAlignment = Alignment.Center) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = isRowHovered,
|
|
||||||
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
|
||||||
label = "index-checkbox") { hovered ->
|
|
||||||
if (hovered) {
|
|
||||||
MillenniumCheckbox(
|
|
||||||
checked = isSelected,
|
|
||||||
onCheckedChange = { onSelectionToggle(it) },
|
|
||||||
label = "",
|
|
||||||
modifier = Modifier.shimmerApply())
|
|
||||||
} else {
|
|
||||||
TextWithShimmer(
|
|
||||||
text = index.toString(),
|
|
||||||
style = AppTheme.typography.labelMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = AppTheme.colors.onSurfaceVariant)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(AppTheme.shapes.small)
|
|
||||||
.hoverable(interactionSource = artworkInteractionSource),
|
|
||||||
contentAlignment = Alignment.Center) {
|
|
||||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
|
||||||
val platformContext = LocalPlatformContext.current
|
|
||||||
val imageRequest = remember(imageUrl) {
|
|
||||||
ImageRequest.Builder(platformContext)
|
|
||||||
.data(imageUrl)
|
|
||||||
.size(128)
|
|
||||||
.crossfade(false)
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncImage(
|
|
||||||
model = imageRequest,
|
|
||||||
contentDescription = track.title,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize().shimmerApply())
|
|
||||||
|
|
||||||
if (isArtworkHovered || isCurrentTrack) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.10f)),
|
|
||||||
contentAlignment = Alignment.Center) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
|
||||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(18.dp).shimmerApply())
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
trailing = {
|
|
||||||
Row(
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
|
||||||
if (showAlbum && track.album != null) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val colorScheme = AppTheme.colors
|
|
||||||
|
|
||||||
TextWithShimmer(
|
|
||||||
text = track.album!!.title,
|
|
||||||
style = AppTheme.typography.bodyMedium,
|
|
||||||
color = AppTheme.colors.primary,
|
|
||||||
maxLines = 1,
|
|
||||||
overflow = TextOverflow.Ellipsis,
|
|
||||||
modifier = Modifier
|
|
||||||
.widthIn(max = 220.dp)
|
|
||||||
.hoverable(interactionSource)
|
|
||||||
.clickable(
|
|
||||||
indication = null,
|
|
||||||
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
|
|
||||||
.then(
|
|
||||||
if (isHovered || isPressed) {
|
|
||||||
Modifier.drawWithCache {
|
|
||||||
val underlineHeight = 1.dp.toPx()
|
|
||||||
onDrawWithContent {
|
|
||||||
drawContent()
|
|
||||||
drawLine(
|
|
||||||
color = colorScheme.primary,
|
|
||||||
start = Offset(0f, size.height - underlineHeight),
|
|
||||||
end = Offset(size.width, size.height - underlineHeight),
|
|
||||||
strokeWidth = underlineHeight)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}))
|
|
||||||
}
|
|
||||||
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = track.durationMs.toDurationString(),
|
text = track.album!!.title,
|
||||||
style = AppTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
textAlign = TextAlign.End,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
maxLines = 1,
|
||||||
modifier = Modifier.width(56.dp))
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
if (useDropdownForOptions) {
|
.hoverable(interactionSource)
|
||||||
TrackOptions(
|
.clickable(
|
||||||
track = track,
|
indication = null,
|
||||||
state = trackOptionsState,
|
interactionSource = interactionSource
|
||||||
onAction = onTrackOptionsAction,
|
) { onAlbumClick(track.album!!) }
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
// Hide ripple and show underline on hover/click
|
||||||
isInJam = isInJam,
|
.then(
|
||||||
isJamGuest = isJamGuest)
|
if (isHovered || isPressed) {
|
||||||
} else {
|
Modifier.drawWithCache {
|
||||||
MillenniumIconButton(
|
val underlineHeight = 1.dp.toPx()
|
||||||
onClick = onShowOptionsClick,
|
onDrawWithContent {
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
drawContent()
|
||||||
MillenniumIcon(
|
drawLine(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
color = colorScheme.primary,
|
||||||
contentDescription = "Track options")
|
start = Offset(0f, size.height - underlineHeight),
|
||||||
}
|
end = Offset(size.width, size.height - underlineHeight),
|
||||||
}
|
strokeWidth = underlineHeight,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
})
|
}
|
||||||
|
|
||||||
|
TextWithShimmer(
|
||||||
|
text = track.durationMs.toDurationString(),
|
||||||
|
style = MaterialTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(56.dp),
|
||||||
|
)
|
||||||
|
|
||||||
|
if (useDropdownForOptions) {
|
||||||
|
TrackOptions(
|
||||||
|
track = track,
|
||||||
|
state = trackOptionsState,
|
||||||
|
onAction = onTrackOptionsAction,
|
||||||
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest,
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
GhostIconButton(onClick = onShowOptionsClick) {
|
||||||
|
Icon(
|
||||||
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
|
contentDescription = "Track options",
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistChips(
|
private fun ArtistChips(
|
||||||
artists: List<MetadataArtist.Basic>,
|
artists: List<MetadataArtist.Basic>,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onArtistsOverflowClick: () -> Unit) {
|
onArtistsOverflowClick: () -> Unit,
|
||||||
|
) {
|
||||||
val visibleArtists = artists.take(2)
|
val visibleArtists = artists.take(2)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth()) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
visibleArtists.forEachIndexed { index, artist ->
|
visibleArtists.forEachIndexed { index, artist ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
) {
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = artist.name,
|
text = artist.name,
|
||||||
style = AppTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppTheme.colors.primary,
|
color = MaterialTheme.colorScheme.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,
|
||||||
@ -749,12 +827,13 @@ 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 = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = AppTheme.typography.bodySmall
|
style = MaterialTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -763,11 +842,12 @@ private fun ArtistChips(
|
|||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "+$remaining more",
|
text = "+$remaining more",
|
||||||
style = AppTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppTheme.colors.primary,
|
color = MaterialTheme.colorScheme.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
|
modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -777,7 +857,8 @@ 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(
|
||||||
@ -800,7 +881,8 @@ private fun ShimmerTrackListRow(
|
|||||||
onShowOptionsClick = {},
|
onShowOptionsClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistsOverflowClick = {})
|
onArtistsOverflowClick = {},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -809,7 +891,8 @@ 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",
|
||||||
@ -830,25 +913,29 @@ 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) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
style = AppTheme.typography.bodyMedium,
|
style = MaterialTheme.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 {
|
||||||
@ -864,17 +951,21 @@ 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,
|
||||||
@ -887,11 +978,13 @@ 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(
|
||||||
@ -906,7 +999,8 @@ 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",
|
||||||
@ -919,7 +1013,8 @@ 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",
|
||||||
@ -932,16 +1027,19 @@ 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() {
|
||||||
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
Scaffold {
|
||||||
TrackList(
|
TrackList(
|
||||||
tracks = previewTracks(),
|
tracks = previewTracks(),
|
||||||
hasMore = true,
|
hasMore = true,
|
||||||
isLoadingNextPage = true)
|
isLoadingNextPage = true,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,13 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -31,11 +24,12 @@ 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 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.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
|
||||||
@ -44,6 +38,8 @@ 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
|
||||||
@ -76,7 +72,8 @@ 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,
|
||||||
@ -84,13 +81,15 @@ 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
|
||||||
@ -101,7 +100,8 @@ 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,12 +109,11 @@ fun TrackOptions(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest),
|
isJamGuest = isJamGuest,
|
||||||
|
),
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
MillenniumIconButton(
|
GhostIconButton(onClick = onClick) {
|
||||||
onClick = onClick,
|
Icon(
|
||||||
style = MillenniumIconButtonStyle.Quiet) {
|
|
||||||
MillenniumIcon(
|
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = "Track options",
|
contentDescription = "Track options",
|
||||||
modifier = Modifier.shimmerApply()
|
modifier = Modifier.shimmerApply()
|
||||||
@ -123,9 +122,11 @@ 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,
|
||||||
@ -134,14 +135,9 @@ 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)
|
) {
|
||||||
LaunchedEffect(sheetState.currentDetent) {
|
ModalBottomSheet(onDismissRequest = onDismiss) {
|
||||||
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
|
||||||
onDismiss()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
MillenniumModalBottomSheet(state = sheetState) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
TrackOptionsSheetHeader(track = track)
|
TrackOptionsSheetHeader(track = track)
|
||||||
|
|
||||||
@ -149,7 +145,8 @@ 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,
|
||||||
@ -162,35 +159,40 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest).forEach { item ->
|
isJamGuest = isJamGuest,
|
||||||
|
).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 ->
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
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) {
|
||||||
AppTheme.colors.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = AppTheme.typography.bodyLarge,
|
style = MaterialTheme.typography.bodyLarge,
|
||||||
color = if (item.enabled) {
|
color = if (item.enabled) {
|
||||||
AppTheme.colors.onSurface
|
MaterialTheme.colorScheme.onSurface
|
||||||
} else {
|
} else {
|
||||||
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
||||||
})
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -205,34 +207,39 @@ 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(AppTheme.shapes.small))
|
.clip(MaterialTheme.shapes.small),
|
||||||
|
)
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = AppTheme.typography.titleMedium,
|
style = MaterialTheme.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 = AppTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.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 = AppTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis)
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -244,20 +251,25 @@ private fun buildTrackMenuItems(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
|
isJamGuest: Boolean = false,
|
||||||
|
): 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".
|
||||||
@ -267,13 +279,17 @@ 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) {
|
||||||
@ -281,13 +297,17 @@ 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) },
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -295,32 +315,42 @@ 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) {
|
||||||
@ -328,6 +358,8 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxCd,
|
icon = Iconsax.IconsaxCd,
|
||||||
label = "Go to album",
|
label = "Go to album",
|
||||||
onClick = onAlbumClick))
|
onClick = onAlbumClick,
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.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,7 +44,8 @@ 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",
|
||||||
@ -52,6 +53,7 @@ fun UserCard(
|
|||||||
onClick = {
|
onClick = {
|
||||||
// TODO: Route to user details screen.
|
// TODO: Route to user details screen.
|
||||||
},
|
},
|
||||||
modifier = modifier)
|
modifier = modifier,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,7 +28,8 @@ import dev.krtirtho.spotube.isDesktop
|
|||||||
@Composable
|
@Composable
|
||||||
expect fun VerticalScrollbar(
|
expect fun VerticalScrollbar(
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier)
|
modifier: Modifier = Modifier,
|
||||||
|
)
|
||||||
|
|
||||||
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
||||||
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
||||||
|
|||||||
@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
|
import androidx.compose.material3.Card
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.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,9 +58,10 @@ 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) {
|
||||||
AppTheme.colors.error.copy(alpha = 0.1f)
|
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
}
|
}
|
||||||
@ -70,17 +71,20 @@ 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,
|
||||||
@ -89,24 +93,27 @@ 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 = AppTheme.typography.titleSmall,
|
style = MaterialTheme.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 = AppTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth())
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,10 +17,6 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -40,8 +36,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.Icon
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
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
|
||||||
@ -53,6 +50,10 @@ 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
|
||||||
@ -74,7 +75,8 @@ 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
|
||||||
@ -84,7 +86,8 @@ 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(
|
||||||
@ -97,8 +100,10 @@ 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(
|
||||||
@ -109,35 +114,46 @@ 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 {
|
||||||
MillenniumIconButton(
|
SecondaryIconButton(
|
||||||
onClick = onAddToQueue,
|
onClick = onAddToQueue,
|
||||||
modifier = Modifier.size(34.dp),
|
modifier = Modifier.size(30.dp),
|
||||||
style = MillenniumIconButtonStyle.Secondary
|
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||||
|
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
)
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
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 {
|
||||||
MillenniumIconButton(
|
PrimaryIconButton(
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier.size(34.dp),
|
modifier = Modifier.size(30.dp),
|
||||||
style = MillenniumIconButtonStyle.Primary
|
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
||||||
|
shape = BaseUITheme.InteractionState.fromSingleValue(
|
||||||
|
CircleShape
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
imageVector = if (isPlaying) {
|
imageVector = if (isPlaying) {
|
||||||
Iconsax.IconsaxPause
|
Iconsax.IconsaxPause
|
||||||
} else Iconsax.IconsaxPlay,
|
} else Iconsax.IconsaxPlay,
|
||||||
contentDescription = "Play")
|
contentDescription = "Play",
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -149,17 +165,19 @@ fun PlayableCard(
|
|||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = AppTheme.typography.titleSmall,
|
style = MaterialTheme.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 = AppTheme.typography.bodySmall,
|
style = MaterialTheme.typography.bodySmall,
|
||||||
color = AppTheme.colors.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis)
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -167,10 +185,11 @@ fun PlayableCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PreviewLightDark
|
@PreviewLightDark
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayableCardPreview() {
|
private fun PlayableCardPreview() {
|
||||||
Box {
|
Scaffold {
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
title = "Sample Title",
|
title = "Sample Title",
|
||||||
subtitle = "Sample Subtitle",
|
subtitle = "Sample Subtitle",
|
||||||
|
|||||||
@ -45,7 +45,8 @@ 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) }
|
||||||
|
|
||||||
@ -54,7 +55,8 @@ 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
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -67,7 +69,8 @@ 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
|
||||||
@ -76,7 +79,8 @@ 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() }))
|
||||||
@ -98,13 +102,15 @@ fun Coverflow(
|
|||||||
if (state.selectedIndex > 0) {
|
if (state.selectedIndex > 0) {
|
||||||
state.scrollToItem(state.selectedIndex - 1)
|
state.scrollToItem(state.selectedIndex - 1)
|
||||||
}
|
}
|
||||||
}) {}
|
},
|
||||||
|
) {}
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.clickable {
|
.clickable {
|
||||||
state.scrollToItem(state.selectedIndex + 1)
|
state.scrollToItem(state.selectedIndex + 1)
|
||||||
}) {}
|
},
|
||||||
|
) {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,7 +25,8 @@ import kotlin.math.min
|
|||||||
@Stable
|
@Stable
|
||||||
internal class Geometry(
|
internal class Geometry(
|
||||||
internal val params: CoverflowParams = CoverflowParams(),
|
internal val params: CoverflowParams = CoverflowParams(),
|
||||||
private val size: IntSize = IntSize.Zero) {
|
private val size: IntSize = IntSize.Zero,
|
||||||
|
) {
|
||||||
@Stable
|
@Stable
|
||||||
private val shortEdge
|
private val shortEdge
|
||||||
get() = min(size.width, size.height)
|
get() = min(size.width, size.height)
|
||||||
|
|||||||
@ -41,7 +41,8 @@ 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)
|
||||||
|
|
||||||
@ -52,7 +53,8 @@ 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))
|
||||||
|
|
||||||
@ -63,16 +65,20 @@ 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 {
|
||||||
@ -80,7 +86,9 @@ internal fun CoverflowItem(
|
|||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = null,
|
indication = null,
|
||||||
onClick = onClickHandler)) {
|
onClick = onClickHandler,
|
||||||
|
),
|
||||||
|
) {
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,4 +26,5 @@ data class CoverflowParams(
|
|||||||
val angle: Float = 45f,
|
val angle: Float = 45f,
|
||||||
val shift: Float = 0.4f,
|
val shift: Float = 0.4f,
|
||||||
val zoom: Float = 0.8f,
|
val zoom: Float = 0.8f,
|
||||||
val mirror: Boolean = false)
|
val mirror: Boolean = false,
|
||||||
|
)
|
||||||
|
|||||||
@ -26,32 +26,37 @@ 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) : CoverflowScope {
|
private val coverflowState: CoverflowState,
|
||||||
|
) : 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) = items(
|
itemContent: @Composable (item: T) -> Unit,
|
||||||
|
) = 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])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -60,10 +65,12 @@ 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) = lazyListScope.items(
|
itemContent: @Composable (index: Int) -> Unit,
|
||||||
|
) = lazyListScope.items(
|
||||||
count,
|
count,
|
||||||
key,
|
key,
|
||||||
contentType) {
|
contentType,
|
||||||
|
) {
|
||||||
CoverflowItem(
|
CoverflowItem(
|
||||||
onClickHandler = { coverflowState.scrollToItem(it) },
|
onClickHandler = { coverflowState.scrollToItem(it) },
|
||||||
onSelectedHandler = { isSelected: Boolean ->
|
onSelectedHandler = { isSelected: Boolean ->
|
||||||
@ -71,14 +78,16 @@ 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): Boolean {
|
isSelected: Boolean,
|
||||||
|
): Boolean {
|
||||||
if (!isSelected && index == coverflowState.selectedIndex) {
|
if (!isSelected && index == coverflowState.selectedIndex) {
|
||||||
coverflowState.selectedIndex = -1
|
coverflowState.selectedIndex = -1
|
||||||
} else if (isSelected && index != coverflowState.selectedIndex) {
|
} else if (isSelected && index != coverflowState.selectedIndex) {
|
||||||
|
|||||||
@ -28,16 +28,19 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberCoverflowState(
|
fun rememberCoverflowState(
|
||||||
initialIndex: Int = 0,
|
initialIndex: Int = 0,
|
||||||
onSelectHandler: (Int) -> Unit = {}): CoverflowState {
|
onSelectHandler: (Int) -> Unit = {},
|
||||||
|
): 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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -45,7 +48,8 @@ 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
|
||||||
|
|
||||||
@ -61,7 +65,8 @@ class CoverflowState internal constructor(
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
lazyListState.animateScrollToItem(
|
lazyListState.animateScrollToItem(
|
||||||
index + 1,
|
index + 1,
|
||||||
-geometryCopy.spacerWidth)
|
-geometryCopy.spacerWidth,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,20 +48,23 @@ 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(
|
||||||
@ -72,27 +75,34 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -102,12 +112,17 @@ private object HalfSizeShape : Shape {
|
|||||||
override fun createOutline(
|
override fun createOutline(
|
||||||
size: Size,
|
size: Size,
|
||||||
layoutDirection: LayoutDirection,
|
layoutDirection: LayoutDirection,
|
||||||
density: Density): Outline = Outline.Rectangle(
|
density: Density,
|
||||||
|
): Outline = Outline.Rectangle(
|
||||||
Rect(
|
Rect(
|
||||||
Offset(
|
Offset(
|
||||||
0f,
|
0f,
|
||||||
size.height / 2),
|
size.height / 2,
|
||||||
|
),
|
||||||
Size(
|
Size(
|
||||||
size.width,
|
size.width,
|
||||||
size.height)))
|
size.height,
|
||||||
|
),
|
||||||
|
),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,11 +17,10 @@
|
|||||||
|
|
||||||
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 com.composeunstyled.LocalTextStyle
|
import androidx.compose.material3.LocalTextStyle
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.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
|
||||||
@ -39,6 +38,7 @@ 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,7 +84,11 @@ fun Modifier.shimmerApply(
|
|||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
val isTreeLoading = LocalSkeletonLoading.current
|
val isTreeLoading = LocalSkeletonLoading.current
|
||||||
|
|
||||||
if (isTreeLoading) this.background(color) else this
|
this.placeholder(
|
||||||
|
visible = isTreeLoading,
|
||||||
|
color = color,
|
||||||
|
shape = null,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -106,20 +110,23 @@ 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.Start,
|
textAlign = textAlign,
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
|
softWrap = softWrap,
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
onTextLayout = onTextLayout,
|
onTextLayout = onTextLayout,
|
||||||
|
|||||||
@ -18,202 +18,39 @@
|
|||||||
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.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 com.materialkolor.rememberDynamicColorScheme
|
||||||
import androidx.compose.runtime.Immutable
|
|
||||||
import androidx.compose.runtime.remember
|
|
||||||
import androidx.compose.runtime.staticCompositionLocalOf
|
|
||||||
import androidx.compose.ui.graphics.Color
|
|
||||||
import androidx.compose.ui.graphics.Shape
|
|
||||||
import androidx.compose.ui.graphics.luminance
|
|
||||||
import androidx.compose.ui.text.TextStyle
|
|
||||||
import androidx.compose.ui.text.font.FontFamily
|
|
||||||
import androidx.compose.ui.text.font.FontWeight
|
|
||||||
import androidx.compose.ui.unit.dp
|
|
||||||
import androidx.compose.ui.unit.sp
|
|
||||||
|
|
||||||
import dev.krtirtho.spotube.modules.settings.Theme
|
import dev.krtirtho.spotube.modules.settings.Theme
|
||||||
import dev.krtirtho.spotube.modules.settings.UserSettings
|
import dev.krtirtho.spotube.modules.settings.UserSettings
|
||||||
import org.spotube.millennium.composables.MillenniumTooltipHost
|
|
||||||
import org.spotube.millennium.theme.MillenniumColors
|
|
||||||
import org.spotube.millennium.theme.MillenniumPalette
|
|
||||||
import org.spotube.millennium.theme.MillenniumTheme
|
|
||||||
import org.spotube.millennium.theme.MillenniumThemeConfig
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Semantic color roles used across the app, resolved from the active Millennium palette.
|
|
||||||
*
|
|
||||||
* This is not a second palette: every role is derived from [MillenniumColors], so the
|
|
||||||
* whole app follows the selected Millennium accent family and light/dark appearance.
|
|
||||||
*/
|
|
||||||
@Immutable
|
|
||||||
data class AppColors(
|
|
||||||
val primary: Color,
|
|
||||||
val onPrimary: Color,
|
|
||||||
val primaryContainer: Color,
|
|
||||||
val onPrimaryContainer: Color,
|
|
||||||
val secondary: Color,
|
|
||||||
val onSecondary: Color,
|
|
||||||
val secondaryContainer: Color,
|
|
||||||
val onSecondaryContainer: Color,
|
|
||||||
val tertiaryContainer: Color,
|
|
||||||
val onTertiaryContainer: Color,
|
|
||||||
val background: Color,
|
|
||||||
val onBackground: Color,
|
|
||||||
val surface: Color,
|
|
||||||
val onSurface: Color,
|
|
||||||
val surfaceVariant: Color,
|
|
||||||
val onSurfaceVariant: Color,
|
|
||||||
val surfaceContainer: Color,
|
|
||||||
val surfaceContainerHigh: Color,
|
|
||||||
val surfaceContainerHighest: Color,
|
|
||||||
val inverseSurface: Color,
|
|
||||||
val inverseOnSurface: Color,
|
|
||||||
val outline: Color,
|
|
||||||
val outlineVariant: Color,
|
|
||||||
val error: Color,
|
|
||||||
val onError: Color,
|
|
||||||
val errorContainer: Color,
|
|
||||||
val onErrorContainer: Color,
|
|
||||||
val scrim: Color
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Maps Millennium's semantic palette onto the app's role names. */
|
|
||||||
fun appColorsFrom(m: MillenniumColors): AppColors = AppColors(
|
|
||||||
primary = m.accent,
|
|
||||||
onPrimary = m.onAccent,
|
|
||||||
primaryContainer = m.raised,
|
|
||||||
onPrimaryContainer = m.text,
|
|
||||||
secondary = m.accent,
|
|
||||||
onSecondary = m.onAccent,
|
|
||||||
secondaryContainer = m.raised,
|
|
||||||
onSecondaryContainer = m.text,
|
|
||||||
tertiaryContainer = m.raised,
|
|
||||||
onTertiaryContainer = m.text,
|
|
||||||
background = m.background,
|
|
||||||
onBackground = m.text,
|
|
||||||
surface = m.surface,
|
|
||||||
onSurface = m.text,
|
|
||||||
surfaceVariant = m.inset,
|
|
||||||
onSurfaceVariant = m.muted,
|
|
||||||
surfaceContainer = m.surface,
|
|
||||||
surfaceContainerHigh = m.raised,
|
|
||||||
surfaceContainerHighest = m.raised,
|
|
||||||
inverseSurface = m.text,
|
|
||||||
inverseOnSurface = m.background,
|
|
||||||
outline = m.border,
|
|
||||||
outlineVariant = m.border,
|
|
||||||
error = m.danger,
|
|
||||||
onError = m.background,
|
|
||||||
errorContainer = m.danger.copy(alpha = 0.16f),
|
|
||||||
onErrorContainer = m.danger,
|
|
||||||
scrim = m.scrim
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Named text styles used by the app, independent of Material typography. */
|
|
||||||
@Immutable
|
|
||||||
data class AppTypography(
|
|
||||||
val displaySmall: TextStyle,
|
|
||||||
val headlineLarge: TextStyle,
|
|
||||||
val headlineMedium: TextStyle,
|
|
||||||
val headlineSmall: TextStyle,
|
|
||||||
val titleLarge: TextStyle,
|
|
||||||
val titleMedium: TextStyle,
|
|
||||||
val titleSmall: TextStyle,
|
|
||||||
val bodyLarge: TextStyle,
|
|
||||||
val bodyMedium: TextStyle,
|
|
||||||
val bodySmall: TextStyle,
|
|
||||||
val labelLarge: TextStyle,
|
|
||||||
val labelMedium: TextStyle,
|
|
||||||
val labelSmall: TextStyle
|
|
||||||
)
|
|
||||||
|
|
||||||
private fun style(
|
|
||||||
size: Int,
|
|
||||||
lineHeight: Int,
|
|
||||||
weight: FontWeight = FontWeight.Normal
|
|
||||||
): TextStyle = TextStyle(
|
|
||||||
fontFamily = FontFamily.SansSerif,
|
|
||||||
fontSize = size.sp,
|
|
||||||
lineHeight = lineHeight.sp,
|
|
||||||
fontWeight = weight
|
|
||||||
)
|
|
||||||
|
|
||||||
val AppDefaultTypography = AppTypography(
|
|
||||||
displaySmall = style(36, 44),
|
|
||||||
headlineLarge = style(32, 40),
|
|
||||||
headlineMedium = style(28, 36),
|
|
||||||
headlineSmall = style(24, 32),
|
|
||||||
titleLarge = style(22, 28, FontWeight.Medium),
|
|
||||||
titleMedium = style(16, 24, FontWeight.Medium),
|
|
||||||
titleSmall = style(14, 20, FontWeight.Medium),
|
|
||||||
bodyLarge = style(16, 24),
|
|
||||||
bodyMedium = style(14, 20),
|
|
||||||
bodySmall = style(12, 16),
|
|
||||||
labelLarge = style(14, 20, FontWeight.Medium),
|
|
||||||
labelMedium = style(12, 16, FontWeight.Medium),
|
|
||||||
labelSmall = style(11, 16, FontWeight.Medium)
|
|
||||||
)
|
|
||||||
|
|
||||||
/** Corner shapes used by the app. */
|
|
||||||
@Immutable
|
|
||||||
data class AppShapes(
|
|
||||||
val small: Shape,
|
|
||||||
val medium: Shape,
|
|
||||||
val large: Shape
|
|
||||||
)
|
|
||||||
|
|
||||||
val AppDefaultShapes = AppShapes(
|
|
||||||
small = RoundedCornerShape(8.dp),
|
|
||||||
medium = RoundedCornerShape(12.dp),
|
|
||||||
large = RoundedCornerShape(16.dp)
|
|
||||||
)
|
|
||||||
|
|
||||||
val LocalAppColors =
|
|
||||||
staticCompositionLocalOf { appColorsFrom(org.spotube.millennium.theme.MillenniumLightColors) }
|
|
||||||
val LocalAppTypography = staticCompositionLocalOf { AppDefaultTypography }
|
|
||||||
val LocalAppShapes = staticCompositionLocalOf { AppDefaultShapes }
|
|
||||||
|
|
||||||
/** Reads the active app theme tokens, all backed by Millennium. */
|
|
||||||
object AppTheme {
|
|
||||||
val colors: AppColors @Composable get() = LocalAppColors.current
|
|
||||||
val typography: AppTypography @Composable get() = LocalAppTypography.current
|
|
||||||
val shapes: AppShapes @Composable get() = LocalAppShapes.current
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* The app's single source of truth for colour.
|
|
||||||
*
|
|
||||||
* Installs [MillenniumTheme] with the user's selected [UserSettings.palette] and light/dark
|
|
||||||
* [UserSettings.theme], then derives the app's semantic [AppColors], [AppTypography] and
|
|
||||||
* [AppShapes] tokens from the active Millennium theme.
|
|
||||||
*/
|
|
||||||
@Composable
|
@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 palette: MillenniumPalette = settings.palette
|
|
||||||
|
|
||||||
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
val accent = if (isDarkTheme) {
|
||||||
val colors = appColorsFrom(MillenniumTheme.colors)
|
settings.accentColor.toDarkColor()
|
||||||
MillenniumTooltipHost {
|
} else {
|
||||||
CompositionLocalProvider(
|
settings.accentColor.toLightColor()
|
||||||
LocalAppColors provides colors,
|
}
|
||||||
LocalAppTypography provides AppDefaultTypography,
|
|
||||||
LocalAppShapes provides AppDefaultShapes,
|
val colorScheme = rememberDynamicColorScheme(
|
||||||
content = content
|
seedColor = accent,
|
||||||
)
|
isDark = isDarkTheme,
|
||||||
}
|
)
|
||||||
|
|
||||||
|
MaterialTheme(
|
||||||
|
colorScheme = colorScheme,
|
||||||
|
) {
|
||||||
|
Surface(content = content)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Whether the active Millennium appearance is light. */
|
|
||||||
@Composable
|
|
||||||
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
|
|
||||||
|
|||||||
@ -17,30 +17,26 @@
|
|||||||
|
|
||||||
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.ui.draw.clip
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButton
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.Scaffold
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.material3.Surface
|
||||||
|
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
|
||||||
@ -152,8 +148,10 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
Scaffold(
|
||||||
ApplicationMainBar(
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
|
topBar = {
|
||||||
|
ApplicationMainBar(
|
||||||
title = {
|
title = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||||
@ -165,50 +163,40 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.Start,
|
||||||
modifier = Modifier.height(56.dp)
|
modifier = Modifier.height(56.dp)
|
||||||
) {
|
) {
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.navigateBack()
|
navigator.navigateBack()
|
||||||
},
|
}, enabled = navigator.canGoBack
|
||||||
enabled = navigator.canGoBack,
|
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
FeatherIcons.ChevronLeft,
|
FeatherIcons.ChevronLeft,
|
||||||
contentDescription = "Go back to browser history"
|
contentDescription = "Go back to browser history"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
MillenniumIconButton(
|
IconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.navigateForward()
|
navigator.navigateForward()
|
||||||
},
|
}, enabled = navigator.canGoForward
|
||||||
enabled = navigator.canGoForward,
|
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
|
||||||
) {
|
) {
|
||||||
MillenniumIcon(
|
Icon(
|
||||||
FeatherIcons.ChevronRight,
|
FeatherIcons.ChevronRight,
|
||||||
contentDescription = "Go forward to browser history"
|
contentDescription = "Go forward to browser history"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Box(
|
Surface(
|
||||||
modifier = Modifier
|
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||||
.weight(1f)
|
shape = RoundedCornerShape(18.dp),
|
||||||
.height(36.dp)
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
.padding(horizontal = 4.dp)
|
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||||
.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 = AppTheme.typography.bodyMedium.copy(
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
color = AppTheme.colors.onSurface,
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
textAlign = TextAlign.Start
|
textAlign = TextAlign.Start
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||||
@ -217,14 +205,18 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
})
|
||||||
|
{ innerPadding ->
|
||||||
WebView(
|
WebView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
webViewJsBridge = webViewBridge,
|
webViewJsBridge = webViewBridge,
|
||||||
onCreated = { webView ->
|
onCreated = { webView ->
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
})
|
|
||||||
|
},
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -38,7 +38,8 @@ import org.koin.core.component.KoinComponent
|
|||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class BuiltInPluginService(
|
class BuiltInPluginService(
|
||||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
private val pluginInfo: PluginEntry,
|
||||||
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
override val pluginId: String = pluginInfo.id
|
override val pluginId: String = pluginInfo.id
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
@ -57,6 +58,7 @@ class BuiltInPluginService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun start() {
|
override suspend fun start() {
|
||||||
when (pluginInfo) {
|
when (pluginInfo) {
|
||||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
||||||
|
|||||||
@ -86,6 +86,7 @@ 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.
|
||||||
@ -101,7 +102,8 @@ 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) : PluginService, KoinComponent {
|
private val pluginInfo: PluginEntry,
|
||||||
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
override val pluginId: String = pluginInfo.id
|
override val pluginId: String = pluginInfo.id
|
||||||
|
|
||||||
@ -278,7 +280,8 @@ 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" }
|
||||||
|
|||||||
@ -185,6 +185,7 @@ 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
|
||||||
|
|
||||||
@ -192,6 +193,7 @@ 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)
|
||||||
@ -299,7 +301,8 @@ 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()).generateKey()
|
algorithm.publicExponent.toBigInt(),
|
||||||
|
).generateKey()
|
||||||
|
|
||||||
Pair(
|
Pair(
|
||||||
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
||||||
@ -363,7 +366,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.ECDSA ->
|
is SignAlgorithms.ECDSA ->
|
||||||
provider.get(ECDSA).privateKeyDecoder(
|
provider.get(ECDSA).privateKeyDecoder(
|
||||||
algorithm.curve.curve()).decodeFromByteArray(
|
algorithm.curve.curve(),
|
||||||
|
).decodeFromByteArray(
|
||||||
algorithm.format.privateKeyFormat(),
|
algorithm.format.privateKeyFormat(),
|
||||||
privateKey
|
privateKey
|
||||||
).signatureGenerator(
|
).signatureGenerator(
|
||||||
@ -373,7 +377,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.EdDSA ->
|
is SignAlgorithms.EdDSA ->
|
||||||
provider.get(EdDSA).privateKeyDecoder(
|
provider.get(EdDSA).privateKeyDecoder(
|
||||||
algorithm.curve.curve()).decodeFromByteArray(
|
algorithm.curve.curve(),
|
||||||
|
).decodeFromByteArray(
|
||||||
algorithm.format.privateKeyFormat(),
|
algorithm.format.privateKeyFormat(),
|
||||||
privateKey
|
privateKey
|
||||||
).signatureGenerator().generateSignature(data)
|
).signatureGenerator().generateSignature(data)
|
||||||
@ -417,7 +422,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.ECDSA ->
|
is SignAlgorithms.ECDSA ->
|
||||||
provider.get(ECDSA).publicKeyDecoder(
|
provider.get(ECDSA).publicKeyDecoder(
|
||||||
algorithm.curve.curve()).decodeFromByteArray(
|
algorithm.curve.curve(),
|
||||||
|
).decodeFromByteArray(
|
||||||
algorithm.format.publicKeyFormat(),
|
algorithm.format.publicKeyFormat(),
|
||||||
publicKey
|
publicKey
|
||||||
).signatureVerifier(
|
).signatureVerifier(
|
||||||
@ -430,7 +436,8 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.EdDSA ->
|
is SignAlgorithms.EdDSA ->
|
||||||
provider.get(EdDSA).publicKeyDecoder(
|
provider.get(EdDSA).publicKeyDecoder(
|
||||||
algorithm.curve.curve()).decodeFromByteArray(
|
algorithm.curve.curve(),
|
||||||
|
).decodeFromByteArray(
|
||||||
algorithm.format.publicKeyFormat(),
|
algorithm.format.publicKeyFormat(),
|
||||||
publicKey
|
publicKey
|
||||||
).signatureVerifier().verifySignature(
|
).signatureVerifier().verifySignature(
|
||||||
|
|||||||
@ -28,6 +28,7 @@ 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()
|
||||||
|
|||||||
@ -29,7 +29,8 @@ 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) : WebViewAPI {
|
private val pluginId: String,
|
||||||
|
) : 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)
|
||||||
|
|||||||
@ -45,7 +45,8 @@ 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 {
|
||||||
@ -94,6 +95,7 @@ class RealLRCLibLyricsAPI : LyricsAPI {
|
|||||||
val text = line.substring(match.range.last + 1).trim()
|
val text = line.substring(match.range.last + 1).trim()
|
||||||
LyricsLine(time = timeMs, text = text)
|
LyricsLine(time = timeMs, text = text)
|
||||||
},
|
},
|
||||||
plainLyrics = response.plainLyrics)
|
plainLyrics = response.plainLyrics,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,14 +124,16 @@ data class MusicGatewayResponse(
|
|||||||
val externalIds: ExternalIds,
|
val externalIds: ExternalIds,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val popularity: Int) {
|
val popularity: Int,
|
||||||
|
) {
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Album(
|
data class Album(
|
||||||
@SerialName("album_type")
|
@SerialName("album_type")
|
||||||
val albumType: String,
|
val albumType: String,
|
||||||
val artists: List<Artist>,
|
val artists: List<Artist>,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String)
|
val name: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Artist(
|
data class Artist(
|
||||||
|
|||||||
@ -57,6 +57,7 @@ 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",
|
||||||
@ -75,6 +76,7 @@ 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
|
||||||
|
|
||||||
|
|||||||