Compare commits
1 Commits
a78eeed0a9
...
0261e398fd
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0261e398fd |
@ -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,23 +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_support">Support</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>
|
||||||
@ -50,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>
|
||||||
@ -74,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>
|
||||||
@ -231,123 +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="player_play_content_description">Play</string>
|
|
||||||
<string name="player_pause_content_description">Pause</string>
|
|
||||||
<string name="player_add_to_queue_content_description">Add to queue</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 this project alive.</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_step_welcome">Welcome</string>
|
|
||||||
<string name="welcome_step_support">Support</string>
|
|
||||||
<string name="welcome_step_plugins">Plugins</string>
|
|
||||||
<string name="welcome_step_guide">Guide</string>
|
|
||||||
|
|
||||||
<string name="welcome_guide_title">How to use Spotube</string>
|
|
||||||
<string name="welcome_guide_subtitle">Four quick steps and you’re listening.</string>
|
|
||||||
<string name="welcome_guide_signin_title">Sign in</string>
|
|
||||||
<string name="welcome_guide_signin_desc">Connect your Spotify account, or pick your own catalog plugin.</string>
|
|
||||||
<string name="welcome_guide_search_title">Search and play</string>
|
|
||||||
<string name="welcome_guide_search_desc">Find any song, album, or artist, then press play.</string>
|
|
||||||
<string name="welcome_guide_offline_title">Listen offline</string>
|
|
||||||
<string name="welcome_guide_offline_desc">Download tracks to keep playing without a connection.</string>
|
|
||||||
<string name="welcome_guide_lyrics_title">Follow the lyrics</string>
|
|
||||||
<string name="welcome_guide_lyrics_desc">Open synced lyrics from the player while a track plays.</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>
|
|
||||||
|
|
||||||
<!-- Support / donations -->
|
|
||||||
<string name="support_button_label">Support Spotube</string>
|
|
||||||
<string name="support_dialog_title">Support Spotube</string>
|
|
||||||
<string name="support_dialog_message">Spotube is free, libre and open source — no ads, no subscriptions, no tracking. If it brings you joy, consider supporting the project.</string>
|
|
||||||
<string name="support_dialog_reminder_hint">You can turn these reminders off in Settings → Support.</string>
|
|
||||||
<string name="support_dialog_dismiss">Maybe later</string>
|
|
||||||
<string name="settings_support_reminders_title">Support reminders</string>
|
|
||||||
<string name="settings_support_reminders_subtitle">Show a gentle reminder to support the project every 7 days.</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,38 +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.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
|
||||||
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 dev.krtirtho.spotube.core.DebugConfig
|
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.support.LocalSupportPrompt
|
import dev.krtirtho.spotube.modules.update.UpdateCheckerViewModel
|
||||||
import dev.krtirtho.spotube.modules.support.SupportDialog
|
|
||||||
import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis
|
|
||||||
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
|
|
||||||
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
|
||||||
@ -66,9 +67,9 @@ import dev.krtirtho.spotube.resources.iconsax.IconsaxSetting2
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxSoundTwotone
|
||||||
import dev.krtirtho.spotube.resources.iconsax.User
|
import dev.krtirtho.spotube.resources.iconsax.User
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlin.time.Clock
|
|
||||||
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 {
|
||||||
@ -84,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(
|
||||||
@ -98,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(
|
||||||
@ -115,96 +116,54 @@ 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()
|
|
||||||
|
|
||||||
var showSupportDialog by remember { mutableStateOf(false) }
|
|
||||||
var supportDialogIsReminder by remember { mutableStateOf(false) }
|
|
||||||
|
|
||||||
// Nudge the user to support the project at most once every seven days, unless they opted
|
|
||||||
// out in Settings. The first eligible launch after onboarding only records the baseline, so
|
|
||||||
// the prompt never appears right after the onboarding donation page.
|
|
||||||
LaunchedEffect(showOnboarding) {
|
|
||||||
if (showOnboarding) return@LaunchedEffect
|
|
||||||
// Read the persisted value rather than the collected state, whose initial value is the
|
|
||||||
// default (0) until DataStore has emitted once.
|
|
||||||
val current = settingsRepository.getCurrentSettings()
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
|
||||||
// Development override: show the reminder on every launch, regardless of timing or the
|
|
||||||
// "Support reminders" toggle.
|
|
||||||
if (DebugConfig.FORCE_SUPPORT_REMINDER) {
|
|
||||||
supportDialogIsReminder = true
|
|
||||||
showSupportDialog = true
|
|
||||||
settingsRepository.updateSettings(
|
|
||||||
current.copy(lastSupportReminderEpochMs = now))
|
|
||||||
return@LaunchedEffect
|
|
||||||
}
|
|
||||||
if (!current.supportRemindersEnabled) return@LaunchedEffect
|
|
||||||
val last = current.lastSupportReminderEpochMs
|
|
||||||
if (last == 0L) {
|
|
||||||
settingsRepository.updateSettings(
|
|
||||||
current.copy(lastSupportReminderEpochMs = now))
|
|
||||||
} else if (now - last >= SupportReminderIntervalMillis) {
|
|
||||||
supportDialogIsReminder = true
|
|
||||||
showSupportDialog = true
|
|
||||||
settingsRepository.updateSettings(
|
|
||||||
current.copy(lastSupportReminderEpochMs = now))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// 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) {
|
||||||
CompositionLocalProvider(LocalSupportPrompt provides {
|
val baseUITheme = rememberBaseUITheme()
|
||||||
supportDialogIsReminder = false
|
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
||||||
showSupportDialog = true
|
|
||||||
}) {
|
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val currentRoute =
|
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||||
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(
|
||||||
@ -223,30 +182,43 @@ fun App(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
content()
|
content()
|
||||||
availableAppUpdate?.let { update ->
|
if (availableUpdate != null) {
|
||||||
AppUpdateDialog(
|
val update = availableUpdate!!
|
||||||
update = update,
|
ThemedDialog(
|
||||||
onDismiss = appUpdateService::dismissUpdate,
|
onDismissRequest = updateCheckerViewModel::dismissUpdate,
|
||||||
onIgnore = appUpdateService::ignoreUpdate,
|
title = {
|
||||||
onUpdate = {
|
Text(
|
||||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
text = "Spotube ${update.tag} is available",
|
||||||
appUpdateService.dismissUpdate()
|
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.")
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
availablePluginUpdate?.let { update ->
|
|
||||||
PluginUpdateDialog(
|
|
||||||
update = update,
|
|
||||||
onDismiss = pluginUpdateService::dismissUpdate,
|
|
||||||
onIgnore = pluginUpdateService::ignoreUpdate,
|
|
||||||
onUpdate = {
|
|
||||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
|
||||||
pluginUpdateService.dismissUpdate()
|
|
||||||
})
|
|
||||||
}
|
|
||||||
SupportDialog(
|
|
||||||
visible = showSupportDialog,
|
|
||||||
onDismissRequest = { showSupportDialog = false },
|
|
||||||
isReminder = supportDialogIsReminder)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,42 +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 = false
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Flip to `true` while developing to show the support reminder dialog on the next eligible
|
|
||||||
* app launch, ignoring both the 7-day interval and the "Support reminders" opt-out toggle.
|
|
||||||
* It still waits until onboarding is dismissed, and only fires once per launch.
|
|
||||||
*
|
|
||||||
* Keep it `false` when committing.
|
|
||||||
*/
|
|
||||||
const val FORCE_SUPPORT_REMINDER: Boolean = false
|
|
||||||
}
|
|
||||||
@ -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
|
||||||
@ -62,11 +63,6 @@ import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
|||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxAddSquare
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPause
|
||||||
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
import dev.krtirtho.spotube.resources.iconsax.IconsaxPlay
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import spotube.composeapp.generated.resources.Res
|
|
||||||
import spotube.composeapp.generated.resources.player_add_to_queue_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_pause_content_description
|
|
||||||
import spotube.composeapp.generated.resources.player_play_content_description
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
@ -79,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
|
||||||
@ -89,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(
|
||||||
@ -102,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(
|
||||||
@ -112,45 +112,48 @@ fun PlayableCard(
|
|||||||
) {
|
) {
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
this@Column.AnimatedVisibility(
|
this@Column.AnimatedVisibility(
|
||||||
visible = isHovered || (isPlaying && onPlay != null),
|
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)
|
||||||
) {
|
) {
|
||||||
// While this collection is playing the play/pause toggle stays
|
onAddToQueue?.let {
|
||||||
// visible and the add-to-queue action is hidden entirely.
|
SecondaryIconButton(
|
||||||
onAddToQueue?.takeIf { !isPlaying }?.let {
|
|
||||||
MillenniumIconButton(
|
|
||||||
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 = stringResource(
|
contentDescription = "Add to queue",
|
||||||
Res.string.player_add_to_queue_content_description))
|
)
|
||||||
}
|
}
|
||||||
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 = stringResource(
|
contentDescription = "Play",
|
||||||
if (isPlaying) {
|
)
|
||||||
Res.string.player_pause_content_description
|
|
||||||
} else {
|
|
||||||
Res.string.player_play_content_description
|
|
||||||
}))
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,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,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -180,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,23 +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,
|
||||||
// While a skeleton tree is loading, the placeholder glyphs must not be readable:
|
) {
|
||||||
// only the shimmer bars (from `shimmerApply`) should show through.
|
|
||||||
val isLoadingSkeleton = LocalSkeletonLoading.current
|
|
||||||
Text(
|
Text(
|
||||||
text = text,
|
text = text,
|
||||||
color = if (isLoadingSkeleton) Color.Transparent else 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
|
|
||||||
|
|||||||
@ -1,230 +1,222 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
|
import androidx.compose.foundation.BorderStroke
|
||||||
import org.spotube.millennium.composables.MillenniumIcon
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import org.spotube.millennium.composables.MillenniumIconButton
|
import androidx.compose.foundation.layout.Column
|
||||||
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.layout.WindowInsets
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.border
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.statusBars
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.material3.IconButton
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.material3.Text
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import com.composeunstyled.Text
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.snapshotFlow
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.ui.graphics.Color
|
||||||
import androidx.compose.runtime.snapshotFlow
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.compose.ui.Modifier
|
import compose.icons.FeatherIcons
|
||||||
import androidx.compose.ui.graphics.Color
|
import compose.icons.feathericons.ChevronLeft
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import compose.icons.feathericons.ChevronRight
|
||||||
import androidx.compose.ui.unit.dp
|
import compose.icons.feathericons.X
|
||||||
import compose.icons.FeatherIcons
|
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
||||||
import compose.icons.feathericons.ChevronLeft
|
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
||||||
import compose.icons.feathericons.ChevronRight
|
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
||||||
import compose.icons.feathericons.X
|
import dev.nucleusframework.webview.jsbridge.JsMessage
|
||||||
import dev.krtirtho.spotube.core.tools.user_agents.UserAgents
|
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
||||||
import dev.krtirtho.spotube.core.ui.component.ApplicationMainBar
|
import dev.nucleusframework.webview.web.WebView
|
||||||
import dev.nucleusframework.webview.jsbridge.IJsMessageHandler
|
import dev.nucleusframework.webview.web.WebViewNavigator
|
||||||
import dev.nucleusframework.webview.jsbridge.JsMessage
|
import dev.nucleusframework.webview.web.WebViewState
|
||||||
import dev.nucleusframework.webview.jsbridge.rememberWebViewJsBridge
|
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
||||||
import dev.nucleusframework.webview.web.WebView
|
|
||||||
import dev.nucleusframework.webview.web.WebViewNavigator
|
class PostMessageHandler(
|
||||||
import dev.nucleusframework.webview.web.WebViewState
|
private val onMessageReceived: (String) -> Unit = {}
|
||||||
import dev.nucleusframework.webview.web.rememberWebViewNavigator
|
) : IJsMessageHandler {
|
||||||
|
override fun methodName(): String {
|
||||||
class PostMessageHandler(
|
return "sendMessage"
|
||||||
private val onMessageReceived: (String) -> Unit = {}
|
}
|
||||||
) : IJsMessageHandler {
|
|
||||||
override fun methodName(): String {
|
override fun handle(
|
||||||
return "sendMessage"
|
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
||||||
}
|
) {
|
||||||
|
onMessageReceived(message.params)
|
||||||
override fun handle(
|
callback(message.params)
|
||||||
message: JsMessage, navigator: WebViewNavigator?, callback: (String) -> Unit
|
}
|
||||||
) {
|
}
|
||||||
onMessageReceived(message.params)
|
|
||||||
callback(message.params)
|
@Composable
|
||||||
}
|
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
||||||
}
|
if (webViewController.getContent() == null) {
|
||||||
|
// This should never happen, but just in case
|
||||||
@Composable
|
Text("No URL to load")
|
||||||
fun PlatformWebViewScreen(webViewController: WebViewController) {
|
return
|
||||||
if (webViewController.getContent() == null) {
|
}
|
||||||
// This should never happen, but just in case
|
|
||||||
Text("No URL to load")
|
val state = remember {
|
||||||
return
|
WebViewState(
|
||||||
}
|
webViewController.getWebContent(
|
||||||
|
additionalHttpHeaders = mapOf(
|
||||||
val state = remember {
|
"User-Agent" to UserAgents.random()
|
||||||
WebViewState(
|
)
|
||||||
webViewController.getWebContent(
|
)
|
||||||
additionalHttpHeaders = mapOf(
|
)
|
||||||
"User-Agent" to UserAgents.random()
|
}.apply {
|
||||||
)
|
this.content = webViewController.getWebContent()
|
||||||
)
|
platformWebviewConfig(this, webViewController.currentPluginId)
|
||||||
)
|
}
|
||||||
}.apply {
|
|
||||||
this.content = webViewController.getWebContent()
|
val navigator = rememberWebViewNavigator()
|
||||||
platformWebviewConfig(this, webViewController.currentPluginId)
|
val webViewBridge = rememberWebViewJsBridge(navigator)
|
||||||
}
|
|
||||||
|
val bridgeBootstrapScript = remember {
|
||||||
val navigator = rememberWebViewNavigator()
|
"""
|
||||||
val webViewBridge = rememberWebViewJsBridge(navigator)
|
(function() {
|
||||||
|
if (typeof window.sendMessage !== "function") {
|
||||||
val bridgeBootstrapScript = remember {
|
window.sendMessage = function(message) {
|
||||||
"""
|
if (typeof message !== "string") {
|
||||||
(function() {
|
throw new TypeError("[window.sendMessage] Message must be a string");
|
||||||
if (typeof window.sendMessage !== "function") {
|
}
|
||||||
window.sendMessage = function(message) {
|
window.kmpJsBridge.callNative("sendMessage", message);
|
||||||
if (typeof message !== "string") {
|
};
|
||||||
throw new TypeError("[window.sendMessage] Message must be a string");
|
}
|
||||||
}
|
|
||||||
window.kmpJsBridge.callNative("sendMessage", message);
|
if (!window.bridgeReady) {
|
||||||
};
|
const event = new CustomEvent("onBridgeReady");
|
||||||
}
|
window.dispatchEvent(event);
|
||||||
|
window.bridgeReady = true;
|
||||||
if (!window.bridgeReady) {
|
}
|
||||||
const event = new CustomEvent("onBridgeReady");
|
})();
|
||||||
window.dispatchEvent(event);
|
""".trimIndent()
|
||||||
window.bridgeReady = true;
|
}
|
||||||
}
|
|
||||||
})();
|
LaunchedEffect(state) {
|
||||||
""".trimIndent()
|
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
||||||
}
|
if (url != null) {
|
||||||
|
webViewController.emitUrlChange(url)
|
||||||
LaunchedEffect(state) {
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
snapshotFlow { state.lastLoadedUrl }.collect { url ->
|
webViewController.emitWebViewCreated()
|
||||||
if (url != null) {
|
}
|
||||||
webViewController.emitUrlChange(url)
|
}
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
}
|
||||||
webViewController.emitWebViewCreated()
|
|
||||||
}
|
LaunchedEffect(state.cookieManager, navigator) {
|
||||||
}
|
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
||||||
}
|
webViewController.webViewNavigator = navigator
|
||||||
|
}
|
||||||
LaunchedEffect(state.cookieManager, navigator) {
|
|
||||||
webViewController.setCookieManager(cookieManager = state.cookieManager)
|
LaunchedEffect(webViewBridge) {
|
||||||
webViewController.webViewNavigator = navigator
|
webViewBridge.register(PostMessageHandler { message ->
|
||||||
}
|
webViewController.emitPostMessage(message)
|
||||||
|
})
|
||||||
LaunchedEffect(webViewBridge) {
|
}
|
||||||
webViewBridge.register(PostMessageHandler { message ->
|
|
||||||
webViewController.emitPostMessage(message)
|
DisposableEffect(Unit) {
|
||||||
})
|
onDispose {
|
||||||
}
|
webViewController.dispose()
|
||||||
|
}
|
||||||
DisposableEffect(Unit) {
|
}
|
||||||
onDispose {
|
|
||||||
webViewController.dispose()
|
Scaffold(
|
||||||
}
|
contentWindowInsets = WindowInsets.statusBars,
|
||||||
}
|
topBar = {
|
||||||
|
ApplicationMainBar(
|
||||||
Column(modifier = Modifier.fillMaxSize()) {
|
title = {
|
||||||
ApplicationMainBar(
|
Row(
|
||||||
title = {
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||||
Row(
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
verticalAlignment = Alignment.CenterVertically
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
) {
|
||||||
verticalAlignment = Alignment.CenterVertically
|
Row(
|
||||||
) {
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
Row(
|
horizontalArrangement = Arrangement.Start,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
modifier = Modifier.height(56.dp)
|
||||||
horizontalArrangement = Arrangement.Start,
|
) {
|
||||||
modifier = Modifier.height(56.dp)
|
IconButton(
|
||||||
) {
|
onClick = {
|
||||||
MillenniumIconButton(
|
navigator.navigateBack()
|
||||||
onClick = {
|
}, enabled = navigator.canGoBack
|
||||||
navigator.navigateBack()
|
) {
|
||||||
},
|
Icon(
|
||||||
enabled = navigator.canGoBack,
|
FeatherIcons.ChevronLeft,
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
contentDescription = "Go back to browser history"
|
||||||
) {
|
)
|
||||||
MillenniumIcon(
|
}
|
||||||
FeatherIcons.ChevronLeft,
|
IconButton(
|
||||||
contentDescription = "Go back to browser history"
|
onClick = {
|
||||||
)
|
navigator.navigateForward()
|
||||||
}
|
}, enabled = navigator.canGoForward
|
||||||
MillenniumIconButton(
|
) {
|
||||||
onClick = {
|
Icon(
|
||||||
navigator.navigateForward()
|
FeatherIcons.ChevronRight,
|
||||||
},
|
contentDescription = "Go forward to browser history"
|
||||||
enabled = navigator.canGoForward,
|
)
|
||||||
style = MillenniumIconButtonStyle.Quiet
|
}
|
||||||
) {
|
}
|
||||||
MillenniumIcon(
|
Surface(
|
||||||
FeatherIcons.ChevronRight,
|
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
||||||
contentDescription = "Go forward to browser history"
|
shape = RoundedCornerShape(18.dp),
|
||||||
)
|
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
||||||
}
|
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
||||||
}
|
) {
|
||||||
Box(
|
BasicTextField(
|
||||||
modifier = Modifier
|
value = state.lastLoadedUrl ?: "",
|
||||||
.weight(1f)
|
onValueChange = {}, // Read-only
|
||||||
.height(36.dp)
|
readOnly = true,
|
||||||
.padding(horizontal = 4.dp)
|
singleLine = true,
|
||||||
.clip(RoundedCornerShape(18.dp))
|
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
||||||
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
|
color = MaterialTheme.colorScheme.onSurface,
|
||||||
.border(
|
textAlign = TextAlign.Start
|
||||||
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
|
),
|
||||||
RoundedCornerShape(18.dp)),
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||||
contentAlignment = Alignment.CenterStart
|
.wrapContentHeight(Alignment.CenterVertically)
|
||||||
) {
|
)
|
||||||
BasicTextField(
|
}
|
||||||
value = state.lastLoadedUrl ?: "",
|
}
|
||||||
onValueChange = {}, // Read-only
|
}
|
||||||
readOnly = true,
|
)
|
||||||
singleLine = true,
|
})
|
||||||
textStyle = AppTheme.typography.bodyMedium.copy(
|
{ innerPadding ->
|
||||||
color = AppTheme.colors.onSurface,
|
WebView(
|
||||||
textAlign = TextAlign.Start
|
state = state,
|
||||||
),
|
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
navigator = navigator,
|
||||||
.wrapContentHeight(Alignment.CenterVertically)
|
webViewJsBridge = webViewBridge,
|
||||||
)
|
onCreated = { webView ->
|
||||||
}
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
}
|
|
||||||
}
|
},
|
||||||
)
|
)
|
||||||
WebView(
|
}
|
||||||
state = state,
|
|
||||||
modifier = Modifier.weight(1f).fillMaxWidth(),
|
|
||||||
navigator = navigator,
|
|
||||||
webViewJsBridge = webViewBridge,
|
|
||||||
onCreated = { webView ->
|
|
||||||
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 -> {
|
||||||
|
|||||||
@ -1,344 +1,347 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline
|
package dev.krtirtho.spotube.core.zipline
|
||||||
|
|
||||||
import app.cash.zipline.Zipline
|
import app.cash.zipline.Zipline
|
||||||
import app.cash.zipline.ZiplineService
|
import app.cash.zipline.ZiplineService
|
||||||
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
import app.cash.zipline.loader.DefaultFreshnessCheckerNotFresh
|
||||||
import app.cash.zipline.loader.LoadResult
|
import app.cash.zipline.loader.LoadResult
|
||||||
import app.cash.zipline.loader.ManifestVerifier
|
import app.cash.zipline.loader.ManifestVerifier
|
||||||
import app.cash.zipline.loader.ZiplineLoader
|
import app.cash.zipline.loader.ZiplineLoader
|
||||||
import dev.krtirtho.plugin_interfaces.core.Initializer
|
import dev.krtirtho.plugin_interfaces.core.Initializer
|
||||||
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.core.Initializer_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.CryptoAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.HttpClientAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.PersistedStorageAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.SystemInformationAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.audio.AudioAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.core.CoreAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.lyrics.LyricsAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtistAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.browse.MetadataBrowseAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.playlist.MetadataPlaylistAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.search.MetadataSearchAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrackAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.user.MetadataUserAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
import dev.krtirtho.plugin_interfaces.plugin_apis.scrobble.ScrobbleAPI_SERVICE_NAME
|
||||||
import dev.krtirtho.spotube.core.di.injectLogger
|
import dev.krtirtho.spotube.core.di.injectLogger
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealCryptoAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealHttpClientAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealPersistedStorageAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealSystemInformationAPI
|
||||||
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
import dev.krtirtho.spotube.core.zipline.host_apis.RealWebViewAPI
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
import dev.krtirtho.spotube.modules.plugin.PluginAbility
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
import dev.krtirtho.spotube.modules.plugin.PluginCapability
|
||||||
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
import dev.krtirtho.spotube.modules.plugin.PluginEntry
|
||||||
import io.ktor.http.URLBuilder
|
import io.ktor.http.URLBuilder
|
||||||
import io.ktor.http.decodeURLQueryComponent
|
import io.ktor.http.decodeURLQueryComponent
|
||||||
import kotlinx.coroutines.CoroutineExceptionHandler
|
import kotlinx.coroutines.CoroutineExceptionHandler
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.cancel
|
import kotlinx.coroutines.cancel
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlinx.coroutines.flow.asStateFlow
|
import kotlinx.coroutines.flow.asStateFlow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.sync.Mutex
|
import kotlinx.coroutines.sync.Mutex
|
||||||
import kotlinx.coroutines.sync.withLock
|
import kotlinx.coroutines.sync.withLock
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
import okio.Path.Companion.toPath
|
import okio.Path.Companion.toPath
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
/**
|
|
||||||
* Manages the lifecycle of a Zipline plugin.
|
/**
|
||||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
* Manages the lifecycle of a Zipline plugin.
|
||||||
* Anything it provides, must be called within that dispatcher context.
|
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||||
* The [use] function must be used.
|
* Anything it provides, must be called within that dispatcher context.
|
||||||
*
|
* The [use] function must be used.
|
||||||
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
*
|
||||||
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
* The host bindings are called by plugins in the supplied [ZiplineDispatcher] as well,
|
||||||
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
* so if they are calling something on [Dispatchers.Main], those calls should be wrapped in
|
||||||
*
|
* `withContext(Dispatchers.Main)` to avoid blocking the zipline thread. It can cause stack-overflows.
|
||||||
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
*
|
||||||
*/
|
* The plugin is loaded lazily when [start] is called, and all services are closed when [stop] is called.
|
||||||
open class ZiplinePluginService(
|
*/
|
||||||
val applicationName: String,
|
open class ZiplinePluginService(
|
||||||
private val manifestUrl: String,
|
val applicationName: String,
|
||||||
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
private val manifestUrl: String,
|
||||||
|
private val pluginInfo: PluginEntry,
|
||||||
override val pluginId: String = pluginInfo.id
|
) : PluginService, KoinComponent {
|
||||||
|
|
||||||
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
override val pluginId: String = pluginInfo.id
|
||||||
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
|
||||||
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
// QuickJS compile() uses deep C-level recursion on the native thread stack.
|
||||||
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
// Zipline.create() sets maxStackSize to only 6 MiB, but compiling large JS modules
|
||||||
// instance is created, before any modules are loaded.
|
// (e.g. kotlin-stdlib at ~491 KB) can exceed that during AST parsing.
|
||||||
private val ziplineDispatcher = createZiplineDispatcher()
|
// We use a custom EventListener to increase maxStackSize right after the Zipline
|
||||||
|
// instance is created, before any modules are loaded.
|
||||||
private fun trace(event: String) {
|
private val ziplineDispatcher = createZiplineDispatcher()
|
||||||
logger.d { "[$applicationName] $event" }
|
|
||||||
}
|
private fun trace(event: String) {
|
||||||
|
logger.d { "[$applicationName] $event" }
|
||||||
private val logger by injectLogger<ZiplinePluginService>()
|
}
|
||||||
private val webViewController: WebViewController by inject()
|
|
||||||
|
private val logger by injectLogger<ZiplinePluginService>()
|
||||||
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
private val webViewController: WebViewController by inject()
|
||||||
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
|
||||||
logger.e(throwable) { "Zipline Engine Error" }
|
private val scope = CoroutineScope(SupervisorJob() + ziplineDispatcher.dispatcher)
|
||||||
}
|
private val ziplineExceptionHandler = CoroutineExceptionHandler { _, throwable ->
|
||||||
private val lifecycleMutex = Mutex()
|
logger.e(throwable) { "Zipline Engine Error" }
|
||||||
private var ziplineLoader: ZiplineLoader
|
}
|
||||||
private var ziplineInstance: Zipline? = null
|
private val lifecycleMutex = Mutex()
|
||||||
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
private var ziplineLoader: ZiplineLoader
|
||||||
|
private var ziplineInstance: Zipline? = null
|
||||||
init {
|
private val serviceRegistry = mutableMapOf<KClass<*>, ZiplineService>()
|
||||||
val manifestPath = URLBuilder(manifestUrl)
|
|
||||||
val baseDir =
|
init {
|
||||||
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
val manifestPath = URLBuilder(manifestUrl)
|
||||||
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
val baseDir =
|
||||||
ziplineLoader = ZiplineLoader(
|
manifestPath.encodedParameters["path"]?.decodeURLQueryComponent()?.toPath()?.parent
|
||||||
dispatcher = ziplineDispatcher.dispatcher,
|
?: throw IllegalArgumentException("Invalid manifest URL: $manifestUrl. Expected a 'path' query parameter pointing to the manifest file.")
|
||||||
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
ziplineLoader = ZiplineLoader(
|
||||||
httpClient = FileSystemHTTPClient(baseDir)
|
dispatcher = ziplineDispatcher.dispatcher,
|
||||||
)
|
manifestVerifier = ManifestVerifier.NO_SIGNATURE_CHECKS,
|
||||||
}
|
httpClient = FileSystemHTTPClient(baseDir)
|
||||||
|
)
|
||||||
private val realHttpClientAPI = RealHttpClientAPI()
|
}
|
||||||
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
|
||||||
|
private val realHttpClientAPI = RealHttpClientAPI()
|
||||||
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
private val realWebViewAPI = RealWebViewAPI(scope, webViewController, pluginInfo.id)
|
||||||
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
|
||||||
private val systemInformationAPI = RealSystemInformationAPI()
|
private val persistedStorageAPI = RealPersistedStorageAPI(pluginInfo)
|
||||||
|
private val cryptoAPI = RealCryptoAPI(scope.coroutineContext)
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val systemInformationAPI = RealSystemInformationAPI()
|
||||||
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
|
||||||
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
private fun bindHostServices(zipline: Zipline) {
|
override val loggedInFlow: StateFlow<Boolean> = loggedInStateFlow.asStateFlow()
|
||||||
trace("initializer(): binding host APIs")
|
|
||||||
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
private fun bindHostServices(zipline: Zipline) {
|
||||||
try {
|
trace("initializer(): binding host APIs")
|
||||||
// Basic APIs
|
logger.d { "[$applicationName] Binding host APIs in initializer" }
|
||||||
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
try {
|
||||||
zipline.bind<SystemInformationAPI>(
|
// Basic APIs
|
||||||
SystemInformationAPI_SERVICE_NAME,
|
zipline.bind<CryptoAPI>(CryptoAPI_SERVICE_NAME, cryptoAPI)
|
||||||
systemInformationAPI
|
zipline.bind<SystemInformationAPI>(
|
||||||
)
|
SystemInformationAPI_SERVICE_NAME,
|
||||||
|
systemInformationAPI
|
||||||
// Conditional APIs based on plugin capabilities
|
)
|
||||||
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
|
||||||
zipline.bind<HttpClientAPI>(
|
// Conditional APIs based on plugin capabilities
|
||||||
HttpClientAPI_SERVICE_NAME,
|
if (PluginCapability.NETWORK_REQUESTS in pluginInfo.capabilities) {
|
||||||
realHttpClientAPI
|
zipline.bind<HttpClientAPI>(
|
||||||
)
|
HttpClientAPI_SERVICE_NAME,
|
||||||
}
|
realHttpClientAPI
|
||||||
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
)
|
||||||
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
}
|
||||||
}
|
if (PluginCapability.WEBVIEW in pluginInfo.capabilities) {
|
||||||
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
zipline.bind<WebViewAPI>(WebViewAPI_SERVICE_NAME, realWebViewAPI)
|
||||||
zipline.bind<PersistedStorageAPI>(
|
}
|
||||||
PersistedStorageAPI_SERVICE_NAME,
|
if (PluginCapability.PERSISTENT_STORAGE in pluginInfo.capabilities) {
|
||||||
persistedStorageAPI
|
zipline.bind<PersistedStorageAPI>(
|
||||||
)
|
PersistedStorageAPI_SERVICE_NAME,
|
||||||
}
|
persistedStorageAPI
|
||||||
} catch (e: Exception) {
|
)
|
||||||
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
}
|
||||||
throw e
|
} catch (e: Exception) {
|
||||||
}
|
logger.e(e) { "[$applicationName] Failed to bind host APIs: ${e.message}" }
|
||||||
}
|
throw e
|
||||||
|
}
|
||||||
private fun consumePluginServices(result: LoadResult.Success) {
|
}
|
||||||
trace("start(): loadOnce success")
|
|
||||||
val apiMap =
|
private fun consumePluginServices(result: LoadResult.Success) {
|
||||||
buildMap<KClass<*>, ZiplineService> {
|
trace("start(): loadOnce success")
|
||||||
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
val apiMap =
|
||||||
|
buildMap<KClass<*>, ZiplineService> {
|
||||||
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
put(CoreAPI::class, result.zipline.take<CoreAPI>(CoreAPI_SERVICE_NAME))
|
||||||
put(
|
|
||||||
MetadataUserAPI::class,
|
if (PluginAbility.METADATA in pluginInfo.abilities) {
|
||||||
result.zipline.take<MetadataUserAPI>(
|
put(
|
||||||
MetadataUserAPI_SERVICE_NAME
|
MetadataUserAPI::class,
|
||||||
)
|
result.zipline.take<MetadataUserAPI>(
|
||||||
)
|
MetadataUserAPI_SERVICE_NAME
|
||||||
put(
|
)
|
||||||
MetadataTrackAPI::class,
|
)
|
||||||
result.zipline.take<MetadataTrackAPI>(
|
put(
|
||||||
MetadataTrackAPI_SERVICE_NAME
|
MetadataTrackAPI::class,
|
||||||
)
|
result.zipline.take<MetadataTrackAPI>(
|
||||||
)
|
MetadataTrackAPI_SERVICE_NAME
|
||||||
put(
|
)
|
||||||
MetadataAlbumAPI::class,
|
)
|
||||||
result.zipline.take<MetadataAlbumAPI>(
|
put(
|
||||||
MetadataAlbumAPI_SERVICE_NAME
|
MetadataAlbumAPI::class,
|
||||||
)
|
result.zipline.take<MetadataAlbumAPI>(
|
||||||
)
|
MetadataAlbumAPI_SERVICE_NAME
|
||||||
put(
|
)
|
||||||
MetadataArtistAPI::class,
|
)
|
||||||
result.zipline.take<MetadataArtistAPI>(
|
put(
|
||||||
MetadataArtistAPI_SERVICE_NAME
|
MetadataArtistAPI::class,
|
||||||
)
|
result.zipline.take<MetadataArtistAPI>(
|
||||||
)
|
MetadataArtistAPI_SERVICE_NAME
|
||||||
put(
|
)
|
||||||
MetadataPlaylistAPI::class,
|
)
|
||||||
result.zipline.take<MetadataPlaylistAPI>(
|
put(
|
||||||
MetadataPlaylistAPI_SERVICE_NAME
|
MetadataPlaylistAPI::class,
|
||||||
)
|
result.zipline.take<MetadataPlaylistAPI>(
|
||||||
)
|
MetadataPlaylistAPI_SERVICE_NAME
|
||||||
put(
|
)
|
||||||
MetadataBrowseAPI::class,
|
)
|
||||||
result.zipline.take<MetadataBrowseAPI>(
|
put(
|
||||||
MetadataBrowseAPI_SERVICE_NAME
|
MetadataBrowseAPI::class,
|
||||||
)
|
result.zipline.take<MetadataBrowseAPI>(
|
||||||
)
|
MetadataBrowseAPI_SERVICE_NAME
|
||||||
put(
|
)
|
||||||
MetadataSearchAPI::class,
|
)
|
||||||
result.zipline.take<MetadataSearchAPI>(
|
put(
|
||||||
MetadataSearchAPI_SERVICE_NAME
|
MetadataSearchAPI::class,
|
||||||
)
|
result.zipline.take<MetadataSearchAPI>(
|
||||||
)
|
MetadataSearchAPI_SERVICE_NAME
|
||||||
}
|
)
|
||||||
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
)
|
||||||
put(
|
}
|
||||||
AudioAPI::class,
|
if (PluginAbility.AUDIO in pluginInfo.abilities) {
|
||||||
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
put(
|
||||||
)
|
AudioAPI::class,
|
||||||
}
|
result.zipline.take<AudioAPI>(AudioAPI_SERVICE_NAME)
|
||||||
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
)
|
||||||
put(
|
}
|
||||||
LyricsAPI::class,
|
if (PluginAbility.LYRICS in pluginInfo.abilities) {
|
||||||
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
put(
|
||||||
)
|
LyricsAPI::class,
|
||||||
}
|
result.zipline.take<LyricsAPI>(LyricsAPI_SERVICE_NAME)
|
||||||
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
)
|
||||||
put(
|
}
|
||||||
ScrobbleAPI::class,
|
if (PluginAbility.SCROBBLE in pluginInfo.abilities) {
|
||||||
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
put(
|
||||||
)
|
ScrobbleAPI::class,
|
||||||
}
|
result.zipline.take<ScrobbleAPI>(ScrobbleAPI_SERVICE_NAME)
|
||||||
}
|
)
|
||||||
|
}
|
||||||
serviceRegistry.putAll(apiMap)
|
}
|
||||||
trace("start(): API ready")
|
|
||||||
}
|
serviceRegistry.putAll(apiMap)
|
||||||
|
trace("start(): API ready")
|
||||||
private fun runLogInFlowObservers() = scope.launch {
|
}
|
||||||
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
|
||||||
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
private fun runLogInFlowObservers() = scope.launch {
|
||||||
loggedInStateFlow.value = isLoggedIn
|
val coreAPI = serviceRegistry[CoreAPI::class] as CoreAPI
|
||||||
}
|
coreAPI.loggedInFlow.collect { isLoggedIn ->
|
||||||
}
|
loggedInStateFlow.value = isLoggedIn
|
||||||
|
}
|
||||||
override suspend fun start() {
|
}
|
||||||
lifecycleMutex.withLock {
|
|
||||||
trace("start(): entered")
|
override suspend fun start() {
|
||||||
if (serviceRegistry.isNotEmpty()) {
|
lifecycleMutex.withLock {
|
||||||
trace("start(): already started, skipping")
|
trace("start(): entered")
|
||||||
return
|
if (serviceRegistry.isNotEmpty()) {
|
||||||
}
|
trace("start(): already started, skipping")
|
||||||
|
return
|
||||||
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
}
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
|
||||||
trace("start(): inside zipline dispatcher before loadOnce")
|
logger.d { "[$applicationName] start(): loading plugin from $manifestUrl" }
|
||||||
val result = ziplineLoader.loadOnce(
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
applicationName = applicationName,
|
trace("start(): inside zipline dispatcher before loadOnce")
|
||||||
manifestUrl = manifestUrl,
|
val result = ziplineLoader.loadOnce(
|
||||||
freshnessChecker = DefaultFreshnessCheckerNotFresh)
|
applicationName = applicationName,
|
||||||
when (result) {
|
manifestUrl = manifestUrl,
|
||||||
is LoadResult.Success -> {
|
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
||||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
)
|
||||||
ziplineInstance = result.zipline
|
when (result) {
|
||||||
// Now we consume the initializer
|
is LoadResult.Success -> {
|
||||||
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||||
// Bind host services before initialization, so plugins can use them in their initializer
|
ziplineInstance = result.zipline
|
||||||
bindHostServices(result.zipline)
|
// Now we consume the initializer
|
||||||
trace("start(): calling initializer.initialize()")
|
val initializer = result.zipline.take<Initializer>(Initializer_SERVICE_NAME)
|
||||||
runCatching { initializer.initialize() }
|
// Bind host services before initialization, so plugins can use them in their initializer
|
||||||
.onSuccess {
|
bindHostServices(result.zipline)
|
||||||
consumePluginServices(result)
|
trace("start(): calling initializer.initialize()")
|
||||||
runLogInFlowObservers()
|
runCatching { initializer.initialize() }
|
||||||
}
|
.onSuccess {
|
||||||
.onFailure { e ->
|
consumePluginServices(result)
|
||||||
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
runLogInFlowObservers()
|
||||||
throw e
|
}
|
||||||
}
|
.onFailure { e ->
|
||||||
}
|
logger.e(e) { "[$applicationName] Initializer failed: ${e.message}" }
|
||||||
|
throw e
|
||||||
is LoadResult.Failure -> {
|
}
|
||||||
trace("start(): loadOnce failure: ${result.exception}")
|
}
|
||||||
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
|
||||||
throw result.exception
|
is LoadResult.Failure -> {
|
||||||
}
|
trace("start(): loadOnce failure: ${result.exception}")
|
||||||
}
|
logger.e(result.exception) { "[$applicationName] Failed to load plugin: ${result.exception.message}" }
|
||||||
}
|
throw result.exception
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun stop() {
|
}
|
||||||
lifecycleMutex.withLock {
|
}
|
||||||
trace("stop(): entered")
|
|
||||||
withContext(ziplineDispatcher.dispatcher) {
|
override suspend fun stop() {
|
||||||
ziplineInstance?.close()
|
lifecycleMutex.withLock {
|
||||||
ziplineInstance = null
|
trace("stop(): entered")
|
||||||
for (service in serviceRegistry.values) {
|
withContext(ziplineDispatcher.dispatcher) {
|
||||||
try {
|
ziplineInstance?.close()
|
||||||
trace("stop(): closing service ${service::class.simpleName}")
|
ziplineInstance = null
|
||||||
service.close()
|
for (service in serviceRegistry.values) {
|
||||||
} catch (_: Exception) {
|
try {
|
||||||
trace("stop(): error closing service ${service::class.simpleName}")
|
trace("stop(): closing service ${service::class.simpleName}")
|
||||||
}
|
service.close()
|
||||||
}
|
} catch (_: Exception) {
|
||||||
}
|
trace("stop(): error closing service ${service::class.simpleName}")
|
||||||
serviceRegistry.clear()
|
}
|
||||||
scope.cancel()
|
}
|
||||||
loggedInStateFlow.value = false
|
}
|
||||||
ziplineDispatcher.close()
|
serviceRegistry.clear()
|
||||||
trace("stop(): completed")
|
scope.cancel()
|
||||||
}
|
loggedInStateFlow.value = false
|
||||||
}
|
ziplineDispatcher.close()
|
||||||
|
trace("stop(): completed")
|
||||||
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
}
|
||||||
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
}
|
||||||
// Create the scope with the current registry
|
|
||||||
val scope = PluginServiceScope(serviceRegistry)
|
override suspend fun <T> use(block: suspend PluginServiceScope.() -> T): T {
|
||||||
// Execute the block with 'scope' as 'this'
|
return withContext(ziplineDispatcher.dispatcher + ziplineExceptionHandler) {
|
||||||
scope.block()
|
// Create the scope with the current registry
|
||||||
}
|
val scope = PluginServiceScope(serviceRegistry)
|
||||||
}
|
// Execute the block with 'scope' as 'this'
|
||||||
|
scope.block()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
@ -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()
|
||||||
|
|||||||
@ -1,73 +1,74 @@
|
|||||||
/*
|
/*
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
||||||
*
|
*
|
||||||
* This program is free software: you can redistribute it and/or modify
|
* This program is free software: you can redistribute it and/or modify
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
* it under the terms of the GNU Affero General Public License as published by
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
* the Free Software Foundation, either version 3 of the License, or
|
||||||
* (at your option) any later version.
|
* (at your option) any later version.
|
||||||
*
|
*
|
||||||
* This program is distributed in the hope that it will be useful,
|
* This program is distributed in the hope that it will be useful,
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
||||||
* GNU Affero General Public License for more details.
|
* GNU Affero General Public License for more details.
|
||||||
*
|
*
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
* You should have received a copy of the GNU Affero General Public License
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
||||||
*/
|
*/
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.zipline.host_apis
|
package dev.krtirtho.spotube.core.zipline.host_apis
|
||||||
|
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
import dev.krtirtho.plugin_interfaces.host_apis.Cookie
|
||||||
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
import dev.krtirtho.plugin_interfaces.host_apis.WebViewAPI
|
||||||
import dev.krtirtho.spotube.core.webview.WebViewController
|
import dev.krtirtho.spotube.core.webview.WebViewController
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
class RealWebViewAPI(
|
class RealWebViewAPI(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val webViewController: WebViewController,
|
private val webViewController: WebViewController,
|
||||||
private val pluginId: String) : WebViewAPI {
|
private val pluginId: String,
|
||||||
override fun navigateTo(url: String) {
|
) : WebViewAPI {
|
||||||
scope.launch(Dispatchers.Main) {
|
override fun navigateTo(url: String) {
|
||||||
webViewController.navigateTo(url, pluginId)
|
scope.launch(Dispatchers.Main) {
|
||||||
}
|
webViewController.navigateTo(url, pluginId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun navigateToHTML(html: String) {
|
|
||||||
scope.launch {
|
override fun navigateToHTML(html: String) {
|
||||||
webViewController.navigateToHTML(html, pluginId)
|
scope.launch {
|
||||||
}
|
webViewController.navigateToHTML(html, pluginId)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override suspend fun getCookies(url: String): List<Cookie> {
|
|
||||||
return withContext(Dispatchers.Main) {
|
override suspend fun getCookies(url: String): List<Cookie> {
|
||||||
webViewController.getCookies(url)
|
return withContext(Dispatchers.Main) {
|
||||||
}
|
webViewController.getCookies(url)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override suspend fun evaluateJavaScript(script: String): String? {
|
|
||||||
return withContext(Dispatchers.Main) {
|
override suspend fun evaluateJavaScript(script: String): String? {
|
||||||
webViewController.evaluateJavascript(script)
|
return withContext(Dispatchers.Main) {
|
||||||
}
|
webViewController.evaluateJavascript(script)
|
||||||
}
|
}
|
||||||
|
}
|
||||||
override fun urlChangeFlow(): Flow<String> {
|
|
||||||
return webViewController.urlChangedFlow
|
override fun urlChangeFlow(): Flow<String> {
|
||||||
}
|
return webViewController.urlChangedFlow
|
||||||
override fun webviewCreatedFlow(): Flow<Unit> {
|
}
|
||||||
return webViewController.webviewCreatedFlow
|
override fun webviewCreatedFlow(): Flow<Unit> {
|
||||||
}
|
return webViewController.webviewCreatedFlow
|
||||||
override fun postMessagesFlow(): Flow<String> {
|
}
|
||||||
return webViewController.postMessagesFlow
|
override fun postMessagesFlow(): Flow<String> {
|
||||||
}
|
return webViewController.postMessagesFlow
|
||||||
|
}
|
||||||
override fun exitWebView() {
|
|
||||||
scope.launch(Dispatchers.Main) {
|
override fun exitWebView() {
|
||||||
webViewController.closeWebview()
|
scope.launch(Dispatchers.Main) {
|
||||||
}
|
webViewController.closeWebview()
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
@ -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
|
||||||
|
|
||||||
|
|||||||