Compare commits
15 Commits
911d6babc5
...
cf1aba3676
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
cf1aba3676 | ||
|
|
d720467338 | ||
|
|
8efcbd4af4 | ||
|
|
76518a01a1 | ||
|
|
caad76ba95 | ||
|
|
bb9f225fd7 | ||
|
|
cfd472016e | ||
|
|
963b360a81 | ||
|
|
9f96b07ebd | ||
|
|
4da5b25a5c | ||
|
|
fa9ad9981c | ||
|
|
5c0652b395 | ||
|
|
71d7649db6 | ||
|
|
0def3e2e49 | ||
|
|
d48b0a2f65 |
2
.github/workflows/spotube-release-binary.yml
vendored
@ -425,7 +425,7 @@ jobs:
|
|||||||
done
|
done
|
||||||
|
|
||||||
- name: Create Release
|
- name: Create Release
|
||||||
uses: softprops/action-gh-release@v2
|
uses: softprops/action-gh-release@v3
|
||||||
with:
|
with:
|
||||||
tag_name: ${{ steps.vars.outputs.tag }}
|
tag_name: ${{ steps.vars.outputs.tag }}
|
||||||
name: ${{ steps.vars.outputs.release_name }}
|
name: ${{ steps.vars.outputs.release_name }}
|
||||||
|
|||||||
|
Before Width: | Height: | Size: 94 KiB |
|
Before Width: | Height: | Size: 285 KiB |
|
Before Width: | Height: | Size: 3.6 KiB |
|
Before Width: | Height: | Size: 2.4 KiB |
|
Before Width: | Height: | Size: 14 KiB |
|
Before Width: | Height: | Size: 8.7 KiB |
|
Before Width: | Height: | Size: 47 KiB |
@ -119,7 +119,6 @@ kotlin {
|
|||||||
// Jetpack Compose dependencies
|
// Jetpack Compose dependencies
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
implementation(libs.compose.material3)
|
|
||||||
implementation(libs.compose.ui)
|
implementation(libs.compose.ui)
|
||||||
implementation(libs.coil.compose)
|
implementation(libs.coil.compose)
|
||||||
implementation(libs.coil.network.ktor3)
|
implementation(libs.coil.network.ktor3)
|
||||||
@ -132,6 +131,9 @@ kotlin {
|
|||||||
implementation(libs.kotlinx.datetime)
|
implementation(libs.kotlinx.datetime)
|
||||||
implementation(libs.dns.sd.kt)
|
implementation(libs.dns.sd.kt)
|
||||||
|
|
||||||
|
// Millennium UI
|
||||||
|
implementation(libs.millennium)
|
||||||
|
|
||||||
// Navigation
|
// Navigation
|
||||||
implementation(libs.jetbrains.navigation3.ui)
|
implementation(libs.jetbrains.navigation3.ui)
|
||||||
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
implementation(libs.jetbrains.lifecycle.viewmodelNavigation3)
|
||||||
@ -164,12 +166,6 @@ kotlin {
|
|||||||
// icons
|
// icons
|
||||||
implementation(libs.feather.icons)
|
implementation(libs.feather.icons)
|
||||||
|
|
||||||
// Material3 adaptive
|
|
||||||
implementation(libs.jetbrains.material3.adaptive)
|
|
||||||
implementation(libs.jetbrains.material3.adaptive.layout)
|
|
||||||
implementation(libs.jetbrains.material3.adaptive.suite)
|
|
||||||
implementation(libs.jetbrains.material3.adaptiveNavigation3)
|
|
||||||
implementation(libs.jetbrains.material3.window.size)
|
|
||||||
// androidx-datastore
|
// androidx-datastore
|
||||||
implementation(libs.androidx.datastore)
|
implementation(libs.androidx.datastore)
|
||||||
implementation(libs.androidx.datastore.preferences)
|
implementation(libs.androidx.datastore.preferences)
|
||||||
@ -184,8 +180,6 @@ kotlin {
|
|||||||
|
|
||||||
implementation(libs.semver)
|
implementation(libs.semver)
|
||||||
|
|
||||||
implementation(libs.material.kolor)
|
|
||||||
|
|
||||||
// crypto
|
// crypto
|
||||||
implementation(libs.cryptography.core)
|
implementation(libs.cryptography.core)
|
||||||
implementation(libs.cryptography.provider.optimal)
|
implementation(libs.cryptography.provider.optimal)
|
||||||
@ -206,7 +200,6 @@ kotlin {
|
|||||||
|
|
||||||
// Shimmer effect
|
// Shimmer effect
|
||||||
implementation(libs.compose.shimmer)
|
implementation(libs.compose.shimmer)
|
||||||
implementation(libs.compose.placeholder.material3)
|
|
||||||
|
|
||||||
// DLNA
|
// DLNA
|
||||||
implementation(libs.dns.sd.kt)
|
implementation(libs.dns.sd.kt)
|
||||||
|
|||||||
@ -26,7 +26,6 @@ import org.schabi.newpipe.extractor.ServiceList.YouTube
|
|||||||
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
import org.schabi.newpipe.extractor.services.youtube.linkHandler.YoutubeSearchQueryHandlerFactory
|
||||||
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
import org.schabi.newpipe.extractor.stream.StreamInfoItem
|
||||||
|
|
||||||
|
|
||||||
actual class NewPipeService {
|
actual class NewPipeService {
|
||||||
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
actual suspend fun searchVideos(query: String): List<VideoSearchResult> {
|
||||||
return withContext(Dispatchers.IO) {
|
return withContext(Dispatchers.IO) {
|
||||||
@ -47,8 +46,7 @@ actual class NewPipeService {
|
|||||||
url = item.url,
|
url = item.url,
|
||||||
uploader = item.uploaderName,
|
uploader = item.uploaderName,
|
||||||
durationMs = item.duration,
|
durationMs = item.duration,
|
||||||
thumbnailUrl = item.thumbnails.first().url,
|
thumbnailUrl = item.thumbnails.first().url)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
@ -67,8 +65,7 @@ actual class NewPipeService {
|
|||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = stream.codec,
|
||||||
container = stream.format!!.suffix,
|
container = stream.format!!.suffix,
|
||||||
bitrate = stream.bitrate,
|
bitrate = stream.bitrate)
|
||||||
)
|
|
||||||
} + buildList {
|
} + buildList {
|
||||||
if (extractor.dashMpdUrl.isNotBlank()) add(
|
if (extractor.dashMpdUrl.isNotBlank()) add(
|
||||||
AudioStream.Lossy(
|
AudioStream.Lossy(
|
||||||
@ -90,7 +87,6 @@ actual class NewPipeService {
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val videoStreams = extractor.videoStreams
|
val videoStreams = extractor.videoStreams
|
||||||
.filter { stream -> stream.isUrl && stream.format != null }
|
.filter { stream -> stream.isUrl && stream.format != null }
|
||||||
.map { stream ->
|
.map { stream ->
|
||||||
@ -98,8 +94,7 @@ actual class NewPipeService {
|
|||||||
url = stream.content,
|
url = stream.content,
|
||||||
codec = stream.codec,
|
codec = stream.codec,
|
||||||
bitrate = stream.bitrate,
|
bitrate = stream.bitrate,
|
||||||
container = stream.format!!.suffix,
|
container = stream.format!!.suffix)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
VideoInfo(
|
VideoInfo(
|
||||||
id = id,
|
id = id,
|
||||||
|
|||||||
@ -45,7 +45,6 @@ actual val platformModules = module {
|
|||||||
collectionPlaybackHelper = get(),
|
collectionPlaybackHelper = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,14 +35,12 @@ import androidx.core.content.ContextCompat
|
|||||||
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
actual fun rememberLocalNetworkPermissionRequester(): () -> Unit {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
ActivityResultContracts.RequestPermission(),
|
ActivityResultContracts.RequestPermission()) { /* result is picked up by discovery/advertising retry loops */ }
|
||||||
) { /* result is picked up by discovery/advertising retry loops */ }
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
val granted = ContextCompat.checkSelfPermission(
|
val granted = ContextCompat.checkSelfPermission(
|
||||||
context,
|
context,
|
||||||
Manifest.permission.NEARBY_WIFI_DEVICES,
|
Manifest.permission.NEARBY_WIFI_DEVICES) == PackageManager.PERMISSION_GRANTED
|
||||||
) == PackageManager.PERMISSION_GRANTED
|
|
||||||
if (!granted) {
|
if (!granted) {
|
||||||
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
launcher.launch(Manifest.permission.NEARBY_WIFI_DEVICES)
|
||||||
}
|
}
|
||||||
|
|||||||
@ -53,8 +53,7 @@ class MediaBrowseHelper(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) {
|
||||||
) {
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
const val MEDIA_ID_ROOT = "root"
|
const val MEDIA_ID_ROOT = "root"
|
||||||
@ -253,8 +252,7 @@ class MediaBrowseHelper(
|
|||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildBrowseItems(): List<MediaItem> {
|
private suspend fun buildBrowseItems(): List<MediaItem> {
|
||||||
@ -390,8 +388,7 @@ class MediaBrowseHelper(
|
|||||||
.setIsBrowsable(true)
|
.setIsBrowsable(true)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
private suspend fun buildSavedPlaylistsItems(): List<MediaItem> {
|
||||||
@ -475,8 +472,7 @@ class MediaBrowseHelper(
|
|||||||
.setIsPlayable(false)
|
.setIsPlayable(false)
|
||||||
.build()
|
.build()
|
||||||
)
|
)
|
||||||
.build(),
|
.build())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
private suspend fun buildArtistTracksItems(artistId: String): List<MediaItem> {
|
||||||
|
|||||||
@ -32,8 +32,7 @@ import java.io.File
|
|||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
class AndroidLocalMediaDiscoveryService(
|
class AndroidLocalMediaDiscoveryService(
|
||||||
private val context: Context,
|
private val context: Context) : LocalMediaDiscoveryService, KoinComponent {
|
||||||
) : LocalMediaDiscoveryService, KoinComponent {
|
|
||||||
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
val logger by injectLogger<AndroidLocalMediaDiscoveryService>()
|
||||||
|
|
||||||
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
override suspend fun discoverFolders(roots: List<String>): List<LocalMediaFolder> {
|
||||||
@ -59,8 +58,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
MediaStore.Audio.Media.ARTIST,
|
MediaStore.Audio.Media.ARTIST,
|
||||||
MediaStore.Audio.Media.ALBUM,
|
MediaStore.Audio.Media.ALBUM,
|
||||||
MediaStore.Audio.Media.DURATION,
|
MediaStore.Audio.Media.DURATION,
|
||||||
MediaStore.Audio.Media.MIME_TYPE,
|
MediaStore.Audio.Media.MIME_TYPE) + if (Build.VERSION.SDK_INT >= 29) {
|
||||||
) + if (Build.VERSION.SDK_INT >= 29) {
|
|
||||||
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
arrayOf(MediaStore.Audio.Media.RELATIVE_PATH)
|
||||||
} else {
|
} else {
|
||||||
emptyArray()
|
emptyArray()
|
||||||
@ -131,8 +129,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
artists = listOfNotNull(artist),
|
artists = listOfNotNull(artist),
|
||||||
durationMs = durationMs,
|
durationMs = durationMs,
|
||||||
album = album,
|
album = album,
|
||||||
coverBytes = null,
|
coverBytes = null)
|
||||||
)
|
|
||||||
|
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||||
acceptedRows += 1
|
acceptedRows += 1
|
||||||
@ -150,8 +147,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = folderPath,
|
path = folderPath,
|
||||||
name = normalizeFolderName(folderPath),
|
name = normalizeFolderName(folderPath),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
|
|
||||||
@ -172,8 +168,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
override fun observeChanges(
|
override fun observeChanges(
|
||||||
roots: List<String>,
|
roots: List<String>,
|
||||||
onChanged: LocalMediaChangeCallback,
|
onChanged: LocalMediaChangeCallback): LocalMediaObservation? {
|
||||||
): LocalMediaObservation? {
|
|
||||||
logger.i { "observeChanges: start roots=${roots.size}" }
|
logger.i { "observeChanges: start roots=${roots.size}" }
|
||||||
if (!hasReadPermission()) {
|
if (!hasReadPermission()) {
|
||||||
logger.w { "observeChanges: missing media permission, observer not registered" }
|
logger.w { "observeChanges: missing media permission, observer not registered" }
|
||||||
@ -190,8 +185,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
context.contentResolver.registerContentObserver(
|
context.contentResolver.registerContentObserver(
|
||||||
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
MediaStore.Audio.Media.EXTERNAL_CONTENT_URI,
|
||||||
true,
|
true,
|
||||||
observer,
|
observer)
|
||||||
)
|
|
||||||
|
|
||||||
logger.i { "observeChanges: observer registered" }
|
logger.i { "observeChanges: observer registered" }
|
||||||
|
|
||||||
@ -257,8 +251,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
artists = emptyList(),
|
artists = emptyList(),
|
||||||
durationMs = 0L,
|
durationMs = 0L,
|
||||||
album = null,
|
album = null,
|
||||||
coverBytes = null,
|
coverBytes = null)
|
||||||
)
|
|
||||||
|
|
||||||
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
tracksByFolder.getOrPut(folderPath) { mutableListOf() }.add(track)
|
||||||
}
|
}
|
||||||
@ -269,8 +262,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = folderPath,
|
path = folderPath,
|
||||||
name = normalizeFolderName(folderPath),
|
name = normalizeFolderName(folderPath),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
|
|
||||||
@ -292,8 +284,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC),
|
||||||
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS),
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
context.getExternalFilesDir(Environment.DIRECTORY_MUSIC),
|
||||||
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
|
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS))
|
||||||
)
|
|
||||||
|
|
||||||
return (configured + defaults)
|
return (configured + defaults)
|
||||||
.map { it.absoluteFile }
|
.map { it.absoluteFile }
|
||||||
@ -320,8 +311,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
|
|
||||||
private fun mergeFolders(
|
private fun mergeFolders(
|
||||||
mediaStoreFolders: List<LocalMediaFolder>,
|
mediaStoreFolders: List<LocalMediaFolder>,
|
||||||
fallbackFolders: List<LocalMediaFolder>,
|
fallbackFolders: List<LocalMediaFolder>): List<LocalMediaFolder> {
|
||||||
): List<LocalMediaFolder> {
|
|
||||||
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
if (fallbackFolders.isEmpty()) return mediaStoreFolders
|
||||||
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
if (mediaStoreFolders.isEmpty()) return fallbackFolders
|
||||||
|
|
||||||
@ -342,8 +332,7 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
LocalMediaFolder(
|
LocalMediaFolder(
|
||||||
path = path,
|
path = path,
|
||||||
name = normalizeFolderName(path),
|
name = normalizeFolderName(path),
|
||||||
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) },
|
tracks = tracks.sortedBy { it.name.lowercase(Locale.getDefault()) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
.sortedBy { it.name.lowercase(Locale.getDefault()) }
|
||||||
}
|
}
|
||||||
@ -357,7 +346,6 @@ class AndroidLocalMediaDiscoveryService(
|
|||||||
"wav",
|
"wav",
|
||||||
"ogg",
|
"ogg",
|
||||||
"opus",
|
"opus",
|
||||||
"wma",
|
"wma")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -42,19 +42,16 @@ actual fun rememberLocalMediaPermissionState(): LocalMediaPermissionState {
|
|||||||
|
|
||||||
var isGranted by remember {
|
var isGranted by remember {
|
||||||
mutableStateOf(
|
mutableStateOf(
|
||||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED,
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val launcher = rememberLauncherForActivityResult(
|
val launcher = rememberLauncherForActivityResult(
|
||||||
contract = ActivityResultContracts.RequestPermission(),
|
contract = ActivityResultContracts.RequestPermission()) { granted ->
|
||||||
) { granted ->
|
|
||||||
isGranted = granted ||
|
isGranted = granted ||
|
||||||
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
context.checkSelfPermission(permission) == PackageManager.PERMISSION_GRANTED
|
||||||
}
|
}
|
||||||
|
|
||||||
return LocalMediaPermissionState(
|
return LocalMediaPermissionState(
|
||||||
isGranted = isGranted,
|
isGranted = isGranted,
|
||||||
requestPermission = { launcher.launch(permission) },
|
requestPermission = { launcher.launch(permission) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
|
After Width: | Height: | Size: 5.7 KiB |
@ -0,0 +1,17 @@
|
|||||||
|
<?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>
|
||||||
|
After Width: | Height: | Size: 787 B |
|
After Width: | Height: | Size: 144 KiB |
@ -16,16 +16,23 @@
|
|||||||
-->
|
-->
|
||||||
|
|
||||||
<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>
|
<string name="settings_section_downloads">Downloads</string>f
|
||||||
<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>
|
||||||
@ -43,6 +50,14 @@
|
|||||||
<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>
|
||||||
@ -59,6 +74,24 @@
|
|||||||
<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>
|
||||||
@ -198,12 +231,123 @@
|
|||||||
<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_ignore">Ignore</string>
|
||||||
<string name="update_action_update">Update</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_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="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_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_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="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>
|
||||||
|
|||||||
@ -24,30 +24,36 @@ import androidx.compose.foundation.layout.Column
|
|||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
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.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 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.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.support.SupportDialog
|
||||||
|
import dev.krtirtho.spotube.modules.support.SupportReminderIntervalMillis
|
||||||
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
|
import dev.krtirtho.spotube.modules.update.AppUpdateDialog
|
||||||
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
import dev.krtirtho.spotube.modules.update.AppUpdateService
|
||||||
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
import dev.krtirtho.spotube.modules.update.PluginUpdateDialog
|
||||||
import dev.krtirtho.spotube.modules.update.PluginUpdateService
|
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
|
||||||
@ -60,6 +66,7 @@ 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.core.annotation.KoinExperimentalAPI
|
import org.koin.core.annotation.KoinExperimentalAPI
|
||||||
@ -77,7 +84,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(
|
||||||
@ -91,7 +98,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(
|
||||||
@ -108,27 +115,23 @@ 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(
|
@OptIn(KoinExperimentalAPI::class)
|
||||||
KoinExperimentalAPI::class,
|
|
||||||
ExperimentalCoroutinesApi::class,
|
|
||||||
ExperimentalSharedTransitionApi::class
|
|
||||||
)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun App(
|
fun App(
|
||||||
content: @Composable () -> Unit = {}
|
content: @Composable () -> Unit = {}
|
||||||
@ -140,24 +143,68 @@ fun App(
|
|||||||
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
val availableAppUpdate by appUpdateService.availableUpdate.collectAsStateWithLifecycle()
|
||||||
val availablePluginUpdate by pluginUpdateService.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 = Routes.Home, topLevelRoutes = TOP_LEVEL_ROUTES
|
startRoute = startRoute, topLevelRoutes = TOP_LEVEL_ROUTES
|
||||||
)
|
)
|
||||||
val navigator = remember {
|
val navigator = remember(navigationState) {
|
||||||
Navigator(navigationState)
|
Navigator(navigationState)
|
||||||
}
|
}
|
||||||
|
|
||||||
SpotubeTheme(settings = userSettings) {
|
SpotubeTheme(settings = userSettings) {
|
||||||
val baseUITheme = rememberBaseUITheme()
|
CompositionLocalProvider(LocalSupportPrompt provides {
|
||||||
CompositionLocalProvider(LocalBaseUITheme provides baseUITheme) {
|
supportDialogIsReminder = false
|
||||||
|
showSupportDialog = true
|
||||||
|
}) {
|
||||||
Box(modifier = Modifier.fillMaxSize()) {
|
Box(modifier = Modifier.fillMaxSize()) {
|
||||||
val currentRoute = navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
val currentRoute =
|
||||||
|
navigationState.backStacks[navigationState.topLevelRoute]?.last()
|
||||||
|
|
||||||
Box(Modifier.fillMaxSize()) {
|
Box(Modifier.fillMaxSize()) {
|
||||||
AppShell(navigator, navigationState) {
|
AppShell(navigator, navigationState) {
|
||||||
SharedTransitionLayout {
|
SharedTransitionLayout {
|
||||||
CompositionLocalProvider(
|
CompositionLocalProvider(
|
||||||
LocalSharedTransitionScope provides this@SharedTransitionLayout,
|
LocalSharedTransitionScope provides this@SharedTransitionLayout
|
||||||
) {
|
) {
|
||||||
Column {
|
Column {
|
||||||
NavDisplay(
|
NavDisplay(
|
||||||
@ -184,8 +231,7 @@ fun App(
|
|||||||
onUpdate = {
|
onUpdate = {
|
||||||
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
runCatching { openUrlInBrowser("https://spotube.cc/downloads/") }
|
||||||
appUpdateService.dismissUpdate()
|
appUpdateService.dismissUpdate()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
availablePluginUpdate?.let { update ->
|
availablePluginUpdate?.let { update ->
|
||||||
PluginUpdateDialog(
|
PluginUpdateDialog(
|
||||||
@ -195,9 +241,12 @@ fun App(
|
|||||||
onUpdate = {
|
onUpdate = {
|
||||||
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
runCatching { openUrlInBrowser(update.info.directDownloadUrl) }
|
||||||
pluginUpdateService.dismissUpdate()
|
pluginUpdateService.dismissUpdate()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
SupportDialog(
|
||||||
|
visible = showSupportDialog,
|
||||||
|
onDismissRequest = { showSupportDialog = false },
|
||||||
|
isReminder = supportDialogIsReminder)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -0,0 +1,42 @@
|
|||||||
|
/*
|
||||||
|
* 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,7 +21,6 @@ import kotlinx.coroutines.flow.Flow
|
|||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
||||||
|
|
||||||
interface AudioPlayerInterface {
|
interface AudioPlayerInterface {
|
||||||
// // Playback
|
// // Playback
|
||||||
suspend fun play()
|
suspend fun play()
|
||||||
|
|||||||
@ -30,8 +30,7 @@ data class MediaItem(
|
|||||||
val duration: Duration,
|
val duration: Duration,
|
||||||
val coverURL: String,
|
val coverURL: String,
|
||||||
val url: String,
|
val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE)
|
||||||
)
|
|
||||||
|
|
||||||
enum class LoopState {
|
enum class LoopState {
|
||||||
NONE, ONE, ALL;
|
NONE, ONE, ALL;
|
||||||
@ -60,8 +59,7 @@ sealed interface QueueEntry {
|
|||||||
val track: MetadataTrack,
|
val track: MetadataTrack,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
val protocol: StreamProtocol = StreamProtocol.PROGRESSIVE,
|
||||||
override val addedBy: String = "",
|
override val addedBy: String = "") : QueueEntry
|
||||||
) : QueueEntry
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("local")
|
@SerialName("local")
|
||||||
@ -72,8 +70,7 @@ sealed interface QueueEntry {
|
|||||||
val album: String?,
|
val album: String?,
|
||||||
val coverBytes: ByteArray?,
|
val coverBytes: ByteArray?,
|
||||||
override val url: String,
|
override val url: String,
|
||||||
override val addedBy: String = "",
|
override val addedBy: String = "") : QueueEntry {
|
||||||
) : QueueEntry {
|
|
||||||
override fun equals(other: Any?): Boolean {
|
override fun equals(other: Any?): Boolean {
|
||||||
if (this === other) return true
|
if (this === other) return true
|
||||||
if (other == null || this::class != other::class) return false
|
if (other == null || this::class != other::class) return false
|
||||||
@ -111,14 +108,12 @@ sealed interface QueueCollectionEntry {
|
|||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playlist")
|
@SerialName("playlist")
|
||||||
data class Playlist(
|
data class Playlist(
|
||||||
override val id: String,
|
override val id: String) : QueueCollectionEntry
|
||||||
) : QueueCollectionEntry
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("album")
|
@SerialName("album")
|
||||||
data class Album(
|
data class Album(
|
||||||
override val id: String,
|
override val id: String) : QueueCollectionEntry
|
||||||
) : QueueCollectionEntry
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("saved_tracks")
|
@SerialName("saved_tracks")
|
||||||
@ -132,5 +127,4 @@ data class PersistedQueueState(
|
|||||||
val entries: List<QueueEntry>,
|
val entries: List<QueueEntry>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int,
|
||||||
val currentCollectionEntry: QueueCollectionEntry? = null,
|
val currentCollectionEntry: QueueCollectionEntry? = null,
|
||||||
val collectionHistory: List<QueueCollectionEntry> = emptyList(),
|
val collectionHistory: List<QueueCollectionEntry> = emptyList())
|
||||||
)
|
|
||||||
|
|||||||
@ -29,8 +29,7 @@ interface AudioPlayerQueue {
|
|||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
autoPlay: Boolean = true,
|
autoPlay: Boolean = true,
|
||||||
startPosition: Int = 0,
|
startPosition: Int = 0,
|
||||||
collectionEntry: QueueCollectionEntry? = null,
|
collectionEntry: QueueCollectionEntry? = null)
|
||||||
)
|
|
||||||
suspend fun addToQueue(entry: QueueEntry)
|
suspend fun addToQueue(entry: QueueEntry)
|
||||||
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
suspend fun addAllToQueue(entries: List<QueueEntry>, collectionEntry: QueueCollectionEntry? = null)
|
||||||
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
suspend fun addAllAfterCurrent(entries: List<QueueEntry>)
|
||||||
|
|||||||
@ -45,8 +45,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider,
|
||||||
private val repository: QueueStateRepository,
|
private val repository: QueueStateRepository,
|
||||||
private val pluginProvider: PluginProvider,
|
private val pluginProvider: PluginProvider,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) : AudioPlayerQueue, KoinComponent {
|
||||||
) : AudioPlayerQueue, KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
private val logger by injectLogger<DeviceAudioPlayerQueue>()
|
||||||
|
|
||||||
@ -60,8 +59,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
override val queueFlow: StateFlow<List<QueueEntry>> = combine(
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
entryByMediaUrl,
|
entryByMediaUrl) { playlist, knownEntries ->
|
||||||
) { playlist, knownEntries ->
|
|
||||||
playlist.map { mediaItem ->
|
playlist.map { mediaItem ->
|
||||||
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
knownEntries[mediaItem.url] ?: mediaItem.toFallbackQueueEntry()
|
||||||
}
|
}
|
||||||
@ -69,8 +67,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
|
|
||||||
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
override val currentQueueEntryFlow: StateFlow<QueueEntry?> = combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
entryByMediaUrl,
|
entryByMediaUrl) { mediaItem, knownEntries ->
|
||||||
) { mediaItem, knownEntries ->
|
|
||||||
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
mediaItem?.let { knownEntries[it.url] ?: it.toFallbackQueueEntry() }
|
||||||
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
}.stateIn(scope, SharingStarted.WhileSubscribed(5_000), null)
|
||||||
|
|
||||||
@ -107,8 +104,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
combine(
|
combine(
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
audioPlayer.playlistFlow,
|
audioPlayer.playlistFlow,
|
||||||
audioPlayer.loopStateFlow,
|
audioPlayer.loopStateFlow) { currentMedia, playlist, loopState ->
|
||||||
) { currentMedia, playlist, loopState ->
|
|
||||||
Triple(currentMedia, playlist, loopState)
|
Triple(currentMedia, playlist, loopState)
|
||||||
}.collect { (currentMedia, playlist, loopState) ->
|
}.collect { (currentMedia, playlist, loopState) ->
|
||||||
if (currentMedia == null || playlist.isEmpty()) return@collect
|
if (currentMedia == null || playlist.isEmpty()) return@collect
|
||||||
@ -129,8 +125,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
entries: List<QueueEntry>,
|
entries: List<QueueEntry>,
|
||||||
autoPlay: Boolean,
|
autoPlay: Boolean,
|
||||||
startPosition: Int,
|
startPosition: Int,
|
||||||
collectionEntry: QueueCollectionEntry?,
|
collectionEntry: QueueCollectionEntry?) {
|
||||||
) {
|
|
||||||
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
logger.i { "Loading queue with ${entries.size} entries, autoPlay=$autoPlay, startPosition=$startPosition" }
|
||||||
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
val mediaItems = entries.mapNotNull { it.toMediaItem() }
|
||||||
if (mediaItems.isEmpty()) {
|
if (mediaItems.isEmpty()) {
|
||||||
@ -436,8 +431,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
entries = queueEntries,
|
entries = queueEntries,
|
||||||
currentIndex = normalizedCurrentIndex,
|
currentIndex = normalizedCurrentIndex,
|
||||||
currentCollectionEntry = currentCollectionEntryState.value,
|
currentCollectionEntry = currentCollectionEntryState.value,
|
||||||
collectionHistory = collectionHistoryState.value,
|
collectionHistory = collectionHistoryState.value)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -471,8 +465,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = track.durationMs.milliseconds,
|
duration = track.durationMs.milliseconds,
|
||||||
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
coverURL = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url.orEmpty(),
|
||||||
url = resolvedUrl,
|
url = resolvedUrl,
|
||||||
protocol = protocol,
|
protocol = protocol)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> {
|
is QueueEntry.LocalTrack -> {
|
||||||
@ -483,8 +476,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
album = album.orEmpty(),
|
album = album.orEmpty(),
|
||||||
duration = duration.milliseconds,
|
duration = duration.milliseconds,
|
||||||
coverURL = "",
|
coverURL = "",
|
||||||
url = url,
|
url = url)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -496,8 +488,7 @@ class DeviceAudioPlayerQueue(
|
|||||||
duration = duration.inWholeMilliseconds,
|
duration = duration.inWholeMilliseconds,
|
||||||
album = album.ifBlank { null },
|
album = album.ifBlank { null },
|
||||||
coverBytes = null,
|
coverBytes = null,
|
||||||
url = url,
|
url = url)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
private suspend fun buildStreamingUrl(trackId: String, protocol: StreamProtocol): String {
|
||||||
|
|||||||
@ -23,10 +23,25 @@ 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() }
|
||||||
@ -61,6 +76,18 @@ 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 {
|
||||||
|
|||||||
@ -86,6 +86,8 @@ 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
|
||||||
@ -141,6 +143,10 @@ val sharedModules = module {
|
|||||||
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
viewModelOf(::SettingsViewModel) { bind<SettingsProvider>() }
|
||||||
viewModelOf(::JamSettingsViewModel)
|
viewModelOf(::JamSettingsViewModel)
|
||||||
|
|
||||||
|
// Welcome / Onboarding
|
||||||
|
singleOf(::WelcomeRepository)
|
||||||
|
viewModelOf(::WelcomeViewModel)
|
||||||
|
|
||||||
// Updates
|
// Updates
|
||||||
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
single { AppUpdateService(get()) } withOptions { createdAtStart() }
|
||||||
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
single { PluginUpdateService(get(), get()) } withOptions { createdAtStart() }
|
||||||
@ -162,8 +168,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Saved Tracks
|
// Saved Tracks
|
||||||
@ -177,8 +182,7 @@ val sharedModules = module {
|
|||||||
libraryRepository = get(),
|
libraryRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Artist
|
// Artist
|
||||||
@ -193,8 +197,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Blacklist
|
// Blacklist
|
||||||
@ -206,8 +209,7 @@ val sharedModules = module {
|
|||||||
JamViewModel(
|
JamViewModel(
|
||||||
jamRoomService = get(),
|
jamRoomService = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
settingsProvider = get(),
|
settingsProvider = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Album
|
// Album
|
||||||
@ -223,8 +225,7 @@ val sharedModules = module {
|
|||||||
blacklistRepository = get(),
|
blacklistRepository = get(),
|
||||||
shareService = get(),
|
shareService = get(),
|
||||||
downloadManager = get(),
|
downloadManager = get(),
|
||||||
remotePlaybackController = get(),
|
remotePlaybackController = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Lyrics
|
// Lyrics
|
||||||
@ -238,8 +239,7 @@ val sharedModules = module {
|
|||||||
savedTracksRepository = get(),
|
savedTracksRepository = get(),
|
||||||
artistRepository = get(),
|
artistRepository = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
blacklistRepository = get(),
|
blacklistRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
singleOf(::MatchedTracksRepository) { bind<TrackSourceRepository>() }
|
||||||
single {
|
single {
|
||||||
@ -247,8 +247,7 @@ val sharedModules = module {
|
|||||||
pluginManager = get(),
|
pluginManager = get(),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
matchedTracksRepository = get(),
|
matchedTracksRepository = get(),
|
||||||
settingsRepository = get(),
|
settingsRepository = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
singleOf(::CacheManager)
|
singleOf(::CacheManager)
|
||||||
singleOf(::AlternativeTracksRepository)
|
singleOf(::AlternativeTracksRepository)
|
||||||
|
|||||||
@ -34,8 +34,7 @@ import kotlin.time.Clock
|
|||||||
|
|
||||||
class DiscordRpcService(
|
class DiscordRpcService(
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val settingsProvider: SettingsProvider,
|
private val settingsProvider: SettingsProvider) : KoinComponent, AutoCloseable {
|
||||||
) : KoinComponent, AutoCloseable {
|
|
||||||
|
|
||||||
private val logger by injectLogger<DiscordRpcService>()
|
private val logger by injectLogger<DiscordRpcService>()
|
||||||
|
|
||||||
@ -61,8 +60,7 @@ class DiscordRpcService(
|
|||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow,
|
||||||
audioPlayer.currentMediaItemFlow,
|
audioPlayer.currentMediaItemFlow,
|
||||||
audioPlayer.positionFlow,
|
audioPlayer.positionFlow,
|
||||||
audioPlayer.durationFlow,
|
audioPlayer.durationFlow) { settings, playerState, mediaItem, position, duration ->
|
||||||
) { settings, playerState, mediaItem, position, duration ->
|
|
||||||
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
DiscordRpcState(settings?.discordRichPresence == true, playerState, mediaItem, position, duration)
|
||||||
}.collect { state ->
|
}.collect { state ->
|
||||||
handleStateChange(state)
|
handleStateChange(state)
|
||||||
@ -86,8 +84,7 @@ class DiscordRpcService(
|
|||||||
album = mediaItem.album,
|
album = mediaItem.album,
|
||||||
coverUrl = mediaItem.coverURL,
|
coverUrl = mediaItem.coverURL,
|
||||||
positionMs = state.position.inWholeMilliseconds,
|
positionMs = state.position.inWholeMilliseconds,
|
||||||
durationMs = state.duration.inWholeMilliseconds,
|
durationMs = state.duration.inWholeMilliseconds)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PlayerState.PAUSED -> {
|
PlayerState.PAUSED -> {
|
||||||
val mediaItem = state.mediaItem ?: return
|
val mediaItem = state.mediaItem ?: return
|
||||||
@ -98,8 +95,7 @@ class DiscordRpcService(
|
|||||||
album = mediaItem.album,
|
album = mediaItem.album,
|
||||||
coverUrl = mediaItem.coverURL,
|
coverUrl = mediaItem.coverURL,
|
||||||
positionMs = state.position.inWholeMilliseconds,
|
positionMs = state.position.inWholeMilliseconds,
|
||||||
durationMs = state.duration.inWholeMilliseconds,
|
durationMs = state.duration.inWholeMilliseconds)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
PlayerState.IDLE, PlayerState.COMPLETED -> {
|
||||||
clearPresence()
|
clearPresence()
|
||||||
@ -158,8 +154,7 @@ class DiscordRpcService(
|
|||||||
album: String,
|
album: String,
|
||||||
coverUrl: String,
|
coverUrl: String,
|
||||||
positionMs: Long,
|
positionMs: Long,
|
||||||
durationMs: Long,
|
durationMs: Long) {
|
||||||
) {
|
|
||||||
val client = rpcClient ?: return
|
val client = rpcClient ?: return
|
||||||
try {
|
try {
|
||||||
client.updatePresence(
|
client.updatePresence(
|
||||||
@ -168,8 +163,7 @@ class DiscordRpcService(
|
|||||||
album = album,
|
album = album,
|
||||||
coverUrl = coverUrl,
|
coverUrl = coverUrl,
|
||||||
positionMs = positionMs,
|
positionMs = positionMs,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
rpcClient = null
|
rpcClient = null
|
||||||
connectionFailed = true
|
connectionFailed = true
|
||||||
@ -210,6 +204,5 @@ class DiscordRpcService(
|
|||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
val mediaItem: dev.krtirtho.spotube.core.audioplayer.MediaItem?,
|
||||||
val position: kotlin.time.Duration,
|
val position: kotlin.time.Duration,
|
||||||
val duration: kotlin.time.Duration,
|
val duration: kotlin.time.Duration)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -30,8 +30,7 @@ data class DiscoveredDevice(
|
|||||||
val type: String,
|
val type: String,
|
||||||
val host: String,
|
val host: String,
|
||||||
val port: Int,
|
val port: Int,
|
||||||
val deviceId: String,
|
val deviceId: String) {
|
||||||
) {
|
|
||||||
val key: String get() = "$name$type".replace(".", "")
|
val key: String get() = "$name$type".replace(".", "")
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -55,8 +54,7 @@ class DeviceDiscoveryService {
|
|||||||
type = event.service.type,
|
type = event.service.type,
|
||||||
host = event.service.host,
|
host = event.service.host,
|
||||||
port = event.service.port,
|
port = event.service.port,
|
||||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
|
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||||
)
|
|
||||||
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
DiscoveryState.Discovered(device = device, resolve = event.resolve)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -66,8 +64,7 @@ class DeviceDiscoveryService {
|
|||||||
type = event.service.type,
|
type = event.service.type,
|
||||||
host = event.service.host,
|
host = event.service.host,
|
||||||
port = event.service.port,
|
port = event.service.port,
|
||||||
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty(),
|
deviceId = event.service.txt[TXT_DEVICE_ID]?.decodeToString().orEmpty())
|
||||||
)
|
|
||||||
DiscoveryState.Resolved(device = device)
|
DiscoveryState.Resolved(device = device)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -77,8 +74,7 @@ class DeviceDiscoveryService {
|
|||||||
type = event.service.type,
|
type = event.service.type,
|
||||||
host = event.service.host,
|
host = event.service.host,
|
||||||
port = event.service.port,
|
port = event.service.port,
|
||||||
deviceId = "",
|
deviceId = "")
|
||||||
)
|
|
||||||
DiscoveryState.Removed(device = device)
|
DiscoveryState.Removed(device = device)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -88,8 +84,7 @@ class DeviceDiscoveryService {
|
|||||||
name: String,
|
name: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
deviceId: String,
|
deviceId: String,
|
||||||
registerTimeoutMs: Long = 5_000,
|
registerTimeoutMs: Long = 5_000): NetService {
|
||||||
): NetService {
|
|
||||||
val service = createService(name, port, deviceId)
|
val service = createService(name, port, deviceId)
|
||||||
service.register(timeoutInMs = registerTimeoutMs)
|
service.register(timeoutInMs = registerTimeoutMs)
|
||||||
return service
|
return service
|
||||||
@ -98,11 +93,9 @@ class DeviceDiscoveryService {
|
|||||||
fun createService(
|
fun createService(
|
||||||
name: String,
|
name: String,
|
||||||
port: Int,
|
port: Int,
|
||||||
deviceId: String,
|
deviceId: String): NetService = createNetService(
|
||||||
): NetService = createNetService(
|
|
||||||
type = SERVICE_TYPE,
|
type = SERVICE_TYPE,
|
||||||
name = name,
|
name = name,
|
||||||
port = port,
|
port = port,
|
||||||
txt = mapOf(TXT_DEVICE_ID to deviceId),
|
txt = mapOf(TXT_DEVICE_ID to deviceId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
@ -46,8 +46,7 @@ sealed class JamMessage {
|
|||||||
*/
|
*/
|
||||||
val follow: Boolean = false,
|
val follow: Boolean = false,
|
||||||
/** Host's live play state — late-joining guests start with it. */
|
/** Host's live play state — late-joining guests start with it. */
|
||||||
val isPlaying: Boolean = false,
|
val isPlaying: Boolean = false) : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("playbackCommand")
|
@SerialName("playbackCommand")
|
||||||
@ -57,22 +56,19 @@ sealed class JamMessage {
|
|||||||
@SerialName("suggestTrack")
|
@SerialName("suggestTrack")
|
||||||
data class SuggestTrack(
|
data class SuggestTrack(
|
||||||
val mediaItem: JamMediaItem,
|
val mediaItem: JamMediaItem,
|
||||||
val addedBy: String = "",
|
val addedBy: String = "") : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("suggestPlaylist")
|
@SerialName("suggestPlaylist")
|
||||||
data class SuggestPlaylist(
|
data class SuggestPlaylist(
|
||||||
val tracks: List<JamMediaItem>,
|
val tracks: List<JamMediaItem>,
|
||||||
val addedBy: String = "",
|
val addedBy: String = "") : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("kick")
|
@SerialName("kick")
|
||||||
data class Kick(
|
data class Kick(
|
||||||
val participantId: String,
|
val participantId: String,
|
||||||
val reason: String = "kicked",
|
val reason: String = "kicked") : JamMessage()
|
||||||
) : JamMessage()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("leave")
|
@SerialName("leave")
|
||||||
@ -104,8 +100,7 @@ data class JamPresence(
|
|||||||
val clientId: String,
|
val clientId: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val isHost: Boolean,
|
val isHost: Boolean,
|
||||||
val left: Boolean = false,
|
val left: Boolean = false)
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class JamMediaItem(
|
data class JamMediaItem(
|
||||||
@ -117,8 +112,7 @@ data class JamMediaItem(
|
|||||||
val durationMs: Long,
|
val durationMs: Long,
|
||||||
val coverUrl: String,
|
val coverUrl: String,
|
||||||
val protocol: String,
|
val protocol: String,
|
||||||
val addedBy: String = "",
|
val addedBy: String = "") {
|
||||||
) {
|
|
||||||
companion object {
|
companion object {
|
||||||
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
fun fromQueueEntry(entry: QueueEntry): JamMediaItem = when (entry) {
|
||||||
is QueueEntry.StreamingTrack -> JamMediaItem(
|
is QueueEntry.StreamingTrack -> JamMediaItem(
|
||||||
@ -132,8 +126,7 @@ data class JamMediaItem(
|
|||||||
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
?: entry.track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
protocol = entry.protocol.name,
|
protocol = entry.protocol.name,
|
||||||
addedBy = entry.addedBy,
|
addedBy = entry.addedBy)
|
||||||
)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> JamMediaItem(
|
is QueueEntry.LocalTrack -> JamMediaItem(
|
||||||
url = entry.url,
|
url = entry.url,
|
||||||
@ -144,8 +137,7 @@ data class JamMediaItem(
|
|||||||
durationMs = entry.duration,
|
durationMs = entry.duration,
|
||||||
coverUrl = "",
|
coverUrl = "",
|
||||||
protocol = "PROGRESSIVE",
|
protocol = "PROGRESSIVE",
|
||||||
addedBy = entry.addedBy,
|
addedBy = entry.addedBy)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
fun fromTrack(track: MetadataTrack): JamMediaItem = JamMediaItem(
|
||||||
@ -158,8 +150,7 @@ data class JamMediaItem(
|
|||||||
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
coverUrl = track.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
?: track.album?.thumbnails?.maxByOrNull { it.width * it.height }?.url
|
||||||
.orEmpty(),
|
.orEmpty(),
|
||||||
protocol = "PROGRESSIVE",
|
protocol = "PROGRESSIVE")
|
||||||
)
|
|
||||||
|
|
||||||
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
fun fromMediaItem(item: MediaItem): JamMediaItem = JamMediaItem(
|
||||||
url = item.url,
|
url = item.url,
|
||||||
@ -168,8 +159,7 @@ data class JamMediaItem(
|
|||||||
album = item.album,
|
album = item.album,
|
||||||
durationMs = item.duration.inWholeMilliseconds,
|
durationMs = item.duration.inWholeMilliseconds,
|
||||||
coverUrl = item.coverURL,
|
coverUrl = item.coverURL,
|
||||||
protocol = item.protocol.name,
|
protocol = item.protocol.name)
|
||||||
)
|
|
||||||
|
|
||||||
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
fun toMediaItem(item: JamMediaItem): MediaItem = MediaItem(
|
||||||
title = item.title,
|
title = item.title,
|
||||||
@ -179,8 +169,7 @@ data class JamMediaItem(
|
|||||||
coverURL = item.coverUrl,
|
coverURL = item.coverUrl,
|
||||||
url = item.url,
|
url = item.url,
|
||||||
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
protocol = dev.krtirtho.plugin_interfaces.plugin_apis.audio.StreamProtocol
|
||||||
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }),
|
.valueOf(item.protocol.ifBlank { "PROGRESSIVE" }))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -188,8 +177,7 @@ data class JamMediaItem(
|
|||||||
data class JamParticipant(
|
data class JamParticipant(
|
||||||
val id: String,
|
val id: String,
|
||||||
val displayName: String,
|
val displayName: String,
|
||||||
val isHost: Boolean,
|
val isHost: Boolean)
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
enum class JamRole {
|
enum class JamRole {
|
||||||
|
|||||||
@ -30,10 +30,8 @@ import com.ditchoom.mqtt.controlpacket.TopicName
|
|||||||
import com.ditchoom.mqtt.controlpacket.WillConfig
|
import com.ditchoom.mqtt.controlpacket.WillConfig
|
||||||
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
import com.ditchoom.mqtt5.controlpacket.ConnectionRequest
|
||||||
import dev.krtirtho.spotube.modules.settings.JamBroker
|
import dev.krtirtho.spotube.modules.settings.JamBroker
|
||||||
import kotlin.time.Duration.Companion.seconds
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.Job
|
|
||||||
import kotlinx.coroutines.SupervisorJob
|
import kotlinx.coroutines.SupervisorJob
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
@ -47,6 +45,7 @@ import kotlinx.coroutines.launch
|
|||||||
import kotlinx.coroutines.withTimeout
|
import kotlinx.coroutines.withTimeout
|
||||||
import kotlinx.serialization.json.Json
|
import kotlinx.serialization.json.Json
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
* Thin facade over the Ditchoom MQTT 5 client for one jam room.
|
||||||
@ -114,8 +113,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
code: String,
|
code: String,
|
||||||
clientId: String,
|
clientId: String,
|
||||||
displayName: String,
|
displayName: String,
|
||||||
isHost: Boolean,
|
isHost: Boolean): Result<Unit> {
|
||||||
): Result<Unit> {
|
|
||||||
disconnect()
|
disconnect()
|
||||||
if (broker.host.isBlank()) {
|
if (broker.host.isBlank()) {
|
||||||
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
return Result.failure(IllegalArgumentException("No jam broker configured"))
|
||||||
@ -126,8 +124,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
isHost = isHost,
|
isHost = isHost,
|
||||||
left = false,
|
left = false)
|
||||||
)
|
|
||||||
|
|
||||||
val mqtt = startClient(broker, clientId)
|
val mqtt = startClient(broker, clientId)
|
||||||
client = mqtt
|
client = mqtt
|
||||||
@ -167,8 +164,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
publishJson(
|
publishJson(
|
||||||
presenceTopic(code, presence.clientId),
|
presenceTopic(code, presence.clientId),
|
||||||
json.encodeToString(JamPresence.serializer(), presence),
|
json.encodeToString(JamPresence.serializer(), presence),
|
||||||
retain = true,
|
retain = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Marks us as the host in presence (host takeover). */
|
/** Marks us as the host in presence (host takeover). */
|
||||||
@ -186,8 +182,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
publishJson(
|
publishJson(
|
||||||
presenceTopic(code, presence.clientId),
|
presenceTopic(code, presence.clientId),
|
||||||
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
json.encodeToString(JamPresence.serializer(), presence.copy(left = true)),
|
||||||
retain = true,
|
retain = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -208,26 +203,22 @@ class JamRoomClient : KoinComponent {
|
|||||||
host = broker.host,
|
host = broker.host,
|
||||||
port = broker.port,
|
port = broker.port,
|
||||||
tlsEnabled = broker.useTls,
|
tlsEnabled = broker.useTls,
|
||||||
connectionTimeout = broker.connectionTimeoutSeconds.seconds,
|
connectionTimeout = broker.connectionTimeoutSeconds.seconds)
|
||||||
)
|
|
||||||
val code = roomCode ?: "unset"
|
val code = roomCode ?: "unset"
|
||||||
val will = WillConfig.Enabled(
|
val will = WillConfig.Enabled(
|
||||||
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
topic = TopicName.fromOrThrow(presenceTopic(code, clientId)),
|
||||||
payload = json.encodeToString(
|
payload = json.encodeToString(
|
||||||
JamPresence.serializer(),
|
JamPresence.serializer(),
|
||||||
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true),
|
JamPresence(clientId, localPresence?.displayName ?: clientId, isHost = false, left = true)).toReadBuffer(Charset.UTF8),
|
||||||
).toReadBuffer(Charset.UTF8),
|
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
retain = true,
|
retain = true)
|
||||||
)
|
|
||||||
val request = ConnectionRequest(
|
val request = ConnectionRequest(
|
||||||
clientId = clientId,
|
clientId = clientId,
|
||||||
keepAliveSeconds = broker.keepAliveSeconds,
|
keepAliveSeconds = broker.keepAliveSeconds,
|
||||||
cleanStart = true,
|
cleanStart = true,
|
||||||
userName = broker.username,
|
userName = broker.username,
|
||||||
password = broker.password,
|
password = broker.password,
|
||||||
will = will,
|
will = will)
|
||||||
)
|
|
||||||
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
val persistence = request.controlPacketFactory.defaultPersistence(inMemory = true)
|
||||||
val brokerRef = persistence.addBroker(connection, request)
|
val brokerRef = persistence.addBroker(connection, request)
|
||||||
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
return MqttClient.start(scope = scope, broker = brokerRef, persistence = persistence)
|
||||||
@ -265,8 +256,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
val operation = mqtt.subscribe(
|
val operation = mqtt.subscribe(
|
||||||
roomFilter(code),
|
roomFilter(code),
|
||||||
OpaquePublishPayloadCodec,
|
OpaquePublishPayloadCodec,
|
||||||
QualityOfService.AT_LEAST_ONCE,
|
QualityOfService.AT_LEAST_ONCE) { publish, payload ->
|
||||||
) { publish, payload ->
|
|
||||||
route(publish.topic.toString(), payload)
|
route(publish.topic.toString(), payload)
|
||||||
}
|
}
|
||||||
runCatching { operation.subAck.await() }
|
runCatching { operation.subAck.await() }
|
||||||
@ -309,8 +299,7 @@ class JamRoomClient : KoinComponent {
|
|||||||
topicName = topic,
|
topicName = topic,
|
||||||
qos = QualityOfService.AT_LEAST_ONCE,
|
qos = QualityOfService.AT_LEAST_ONCE,
|
||||||
payload = payload.toReadBuffer(Charset.UTF8),
|
payload = payload.toReadBuffer(Charset.UTF8),
|
||||||
retain = retain,
|
retain = retain)
|
||||||
)
|
|
||||||
}.onFailure { e ->
|
}.onFailure { e ->
|
||||||
log.w(e) { "Failed to publish to $topic" }
|
log.w(e) { "Failed to publish to $topic" }
|
||||||
}
|
}
|
||||||
|
|||||||
@ -59,15 +59,13 @@ private data class PlaybackBroadcast(
|
|||||||
val queue: List<QueueEntry>,
|
val queue: List<QueueEntry>,
|
||||||
val current: QueueEntry?,
|
val current: QueueEntry?,
|
||||||
val shuffle: Boolean,
|
val shuffle: Boolean,
|
||||||
val playerState: PlayerState,
|
val playerState: PlayerState)
|
||||||
)
|
|
||||||
|
|
||||||
class JamRoomService(
|
class JamRoomService(
|
||||||
private val jamClient: JamRoomClient,
|
private val jamClient: JamRoomClient,
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository) {
|
||||||
) {
|
|
||||||
private val log = Logger.withTag("JamRoomService")
|
private val log = Logger.withTag("JamRoomService")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@ -171,8 +169,7 @@ class JamRoomService(
|
|||||||
code = code,
|
code = code,
|
||||||
clientId = localClientId,
|
clientId = localClientId,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
isHost = true,
|
isHost = true).map {
|
||||||
).map {
|
|
||||||
_role.value = JamRole.Host
|
_role.value = JamRole.Host
|
||||||
_roomCode.value = code
|
_roomCode.value = code
|
||||||
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
_participants.value = listOf(JamParticipant(localClientId, name, isHost = true))
|
||||||
@ -206,8 +203,7 @@ class JamRoomService(
|
|||||||
code = normalized,
|
code = normalized,
|
||||||
clientId = localClientId,
|
clientId = localClientId,
|
||||||
displayName = name,
|
displayName = name,
|
||||||
isHost = false,
|
isHost = false).map {
|
||||||
).map {
|
|
||||||
_role.value = JamRole.Guest
|
_role.value = JamRole.Guest
|
||||||
_roomCode.value = normalized
|
_roomCode.value = normalized
|
||||||
_participants.value = emptyList()
|
_participants.value = emptyList()
|
||||||
@ -261,8 +257,7 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestTrack(
|
JamMessage.SuggestTrack(
|
||||||
mediaItem = JamMediaItem.fromTrack(track),
|
mediaItem = JamMediaItem.fromTrack(track),
|
||||||
addedBy = localClientId,
|
addedBy = localClientId)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -271,8 +266,7 @@ class JamRoomService(
|
|||||||
jamClient.publishCommand(
|
jamClient.publishCommand(
|
||||||
JamMessage.SuggestPlaylist(
|
JamMessage.SuggestPlaylist(
|
||||||
tracks = tracks.map(JamMediaItem::fromTrack),
|
tracks = tracks.map(JamMediaItem::fromTrack),
|
||||||
addedBy = localClientId,
|
addedBy = localClientId)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -296,8 +290,7 @@ class JamRoomService(
|
|||||||
audioPlayerQueue.queueFlow,
|
audioPlayerQueue.queueFlow,
|
||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
audioPlayer.shuffleModeFlow,
|
audioPlayer.shuffleModeFlow,
|
||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow) { queue, current, shuffle, playerState ->
|
||||||
) { queue, current, shuffle, playerState ->
|
|
||||||
PlaybackBroadcast(queue, current, shuffle, playerState)
|
PlaybackBroadcast(queue, current, shuffle, playerState)
|
||||||
}
|
}
|
||||||
.onEach { broadcast ->
|
.onEach { broadcast ->
|
||||||
@ -314,8 +307,7 @@ class JamRoomService(
|
|||||||
currentIndex = index.coerceAtLeast(0),
|
currentIndex = index.coerceAtLeast(0),
|
||||||
shuffleEnabled = broadcast.shuffle,
|
shuffleEnabled = broadcast.shuffle,
|
||||||
follow = now() > autoAdvanceDeadlineMs,
|
follow = now() > autoAdvanceDeadlineMs,
|
||||||
isPlaying = broadcast.playerState == PlayerState.PLAYING,
|
isPlaying = broadcast.playerState == PlayerState.PLAYING)
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
.launchIn(this)
|
.launchIn(this)
|
||||||
@ -374,8 +366,7 @@ class JamRoomService(
|
|||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = items.map { it.toQueueEntry() },
|
entries = items.map { it.toQueueEntry() },
|
||||||
autoPlay = wasPlaying,
|
autoPlay = wasPlaying,
|
||||||
startPosition = startIndex,
|
startPosition = startIndex)
|
||||||
)
|
|
||||||
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
}.onFailure { log.w(it) { "Failed to apply jam queue" } }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
@ -524,13 +515,11 @@ class JamRoomService(
|
|||||||
explicit = null,
|
explicit = null,
|
||||||
popularity = null,
|
popularity = null,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
url = "",
|
url = "",
|
||||||
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
protocol = runCatching { StreamProtocol.valueOf(protocol.ifBlank { "PROGRESSIVE" }) }
|
||||||
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
.getOrDefault(StreamProtocol.PROGRESSIVE),
|
||||||
addedBy = addedBy,
|
addedBy = addedBy)
|
||||||
)
|
|
||||||
|
|
||||||
else -> QueueEntry.LocalTrack(
|
else -> QueueEntry.LocalTrack(
|
||||||
name = title,
|
name = title,
|
||||||
@ -539,8 +528,7 @@ class JamRoomService(
|
|||||||
album = album.ifBlank { null },
|
album = album.ifBlank { null },
|
||||||
coverBytes = null,
|
coverBytes = null,
|
||||||
url = url,
|
url = url,
|
||||||
addedBy = addedBy,
|
addedBy = addedBy)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
private fun JamMediaItem.matchesEntry(entry: QueueEntry?): Boolean {
|
||||||
|
|||||||
@ -34,6 +34,7 @@ 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
|
||||||
@ -91,6 +92,9 @@ 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)
|
||||||
@ -121,8 +125,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Artist> {
|
navigation<Routes.Artist> {
|
||||||
ArtistScreen(
|
ArtistScreen(
|
||||||
@ -132,8 +135,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Album> {
|
navigation<Routes.Album> {
|
||||||
AlbumScreen(
|
AlbumScreen(
|
||||||
@ -144,8 +146,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Lyrics> {
|
navigation<Routes.Lyrics> {
|
||||||
LyricsScreen(viewModel = koinViewModel())
|
LyricsScreen(viewModel = koinViewModel())
|
||||||
@ -158,8 +159,7 @@ val navigationModule = module {
|
|||||||
),
|
),
|
||||||
audioPlayerQueue = get(),
|
audioPlayerQueue = get(),
|
||||||
audioPlayer = get(),
|
audioPlayer = get(),
|
||||||
navigationCommands = get(),
|
navigationCommands = get())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
navigation<Routes.Blacklist> {
|
navigation<Routes.Blacklist> {
|
||||||
BlacklistScreen()
|
BlacklistScreen()
|
||||||
@ -178,4 +178,7 @@ val navigationModule = module {
|
|||||||
navigation<Routes.JamSettings> {
|
navigation<Routes.JamSettings> {
|
||||||
JamSettingsScreen(viewModel = koinViewModel())
|
JamSettingsScreen(viewModel = koinViewModel())
|
||||||
}
|
}
|
||||||
|
navigation<Routes.Welcome> {
|
||||||
|
WelcomeScreen(viewModel = koinViewModel())
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,7 +26,6 @@ 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
|
||||||
@ -45,8 +44,9 @@ 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,6 +63,7 @@ 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())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -121,9 +122,7 @@ fun NavigationState.toEntries(
|
|||||||
): SnapshotStateList<NavEntry<NavKey>> {
|
): SnapshotStateList<NavEntry<NavKey>> {
|
||||||
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
val decoratedEntries = backStacks.mapValues { (_, stack) ->
|
||||||
val decorators = listOf(
|
val decorators = listOf(
|
||||||
rememberSaveableStateHolderNavEntryDecorator<NavKey>(),
|
rememberSaveableStateHolderNavEntryDecorator<NavKey>())
|
||||||
rememberViewModelStoreNavEntryDecorator()
|
|
||||||
)
|
|
||||||
rememberDecoratedNavEntries(
|
rememberDecoratedNavEntries(
|
||||||
backStack = stack,
|
backStack = stack,
|
||||||
entryDecorators = decorators,
|
entryDecorators = decorators,
|
||||||
|
|||||||
@ -49,5 +49,4 @@ data class MediaStream(
|
|||||||
val container: String,
|
val container: String,
|
||||||
val bitrate: Int,
|
val bitrate: Int,
|
||||||
val isAudio: Boolean,
|
val isAudio: Boolean,
|
||||||
val isVideo: Boolean,
|
val isVideo: Boolean)
|
||||||
)
|
|
||||||
@ -34,8 +34,7 @@ class CollectionPlaybackHelper(
|
|||||||
private val savedTracksRepository: SavedTracksRepository,
|
private val savedTracksRepository: SavedTracksRepository,
|
||||||
private val artistRepository: ArtistRepository,
|
private val artistRepository: ArtistRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository) {
|
||||||
) {
|
|
||||||
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
suspend fun playAlbum(albumId: String, shuffle: Boolean = false) {
|
||||||
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
if (!shuffle && audioPlayerQueue.isAlbumPlaying(albumId)) return
|
||||||
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
val entries = fetchAllAlbumTracks(albumId).let { if (shuffle) it.shuffled() else it }
|
||||||
@ -44,8 +43,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -54,8 +52,7 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -87,8 +84,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = startPosition,
|
startPosition = startPosition,
|
||||||
collectionEntry = QueueCollectionEntry.Album(albumId),
|
collectionEntry = QueueCollectionEntry.Album(albumId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
suspend fun playPlaylist(playlistId: String, shuffle: Boolean = false) {
|
||||||
@ -99,8 +95,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -109,8 +104,7 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -142,8 +136,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = startPosition,
|
startPosition = startPosition,
|
||||||
collectionEntry = QueueCollectionEntry.Playlist(playlistId),
|
collectionEntry = QueueCollectionEntry.Playlist(playlistId))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
suspend fun playSavedTracks(shuffle: Boolean = false) {
|
||||||
@ -154,8 +147,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -166,8 +158,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -190,8 +181,7 @@ class CollectionPlaybackHelper(
|
|||||||
if (entries.isNotEmpty()) {
|
if (entries.isNotEmpty()) {
|
||||||
audioPlayerQueue.addAllToQueue(
|
audioPlayerQueue.addAllToQueue(
|
||||||
entries,
|
entries,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -216,8 +206,7 @@ class CollectionPlaybackHelper(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = startPosition,
|
startPosition = startPosition,
|
||||||
collectionEntry = QueueCollectionEntry.SavedTracks,
|
collectionEntry = QueueCollectionEntry.SavedTracks)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
private suspend fun fetchAllAlbumTracks(albumId: String): List<QueueEntry> {
|
||||||
@ -313,8 +302,7 @@ class CollectionPlaybackHelper(
|
|||||||
private fun isTrackBlacklisted(
|
private fun isTrackBlacklisted(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
blacklistedTrackIds: Set<String>,
|
blacklistedTrackIds: Set<String>,
|
||||||
blacklistedArtistIds: Set<String>,
|
blacklistedArtistIds: Set<String>): Boolean {
|
||||||
): Boolean {
|
|
||||||
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
return track.id in blacklistedTrackIds || track.artists.any { it.id in blacklistedArtistIds }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,17 +17,24 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.remote
|
package dev.krtirtho.spotube.core.remote
|
||||||
|
|
||||||
import androidx.compose.material3.Button
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.material3.Text
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.text.style.TextAlign
|
import androidx.compose.ui.text.style.TextAlign
|
||||||
import dev.krtirtho.spotube.core.ui.component.AdaptiveDialogBottomSheet
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumDialog
|
||||||
import org.koin.compose.koinInject
|
import org.koin.compose.koinInject
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -42,50 +49,45 @@ fun ConnectionRequestDialogHost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
pendingRequest?.let { request ->
|
pendingRequest?.let { request ->
|
||||||
AdaptiveDialogBottomSheet(
|
MillenniumDialog(
|
||||||
onDismiss = {
|
visible = true,
|
||||||
|
onDismissRequest = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
title = {
|
title = "Remote Control Request") {
|
||||||
Text(
|
Text(
|
||||||
text = "Remote Control Request",
|
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
||||||
style = MaterialTheme.typography.titleLarge,
|
style = AppTheme.typography.bodyMedium,
|
||||||
)
|
textAlign = TextAlign.Start)
|
||||||
},
|
Row(
|
||||||
content = {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
Text(
|
horizontalArrangement = Arrangement.spacedBy(8.dp, Alignment.End),
|
||||||
text = "\"${request.deviceName}\" wants to control playback on this device.",
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
MillenniumButton(
|
||||||
textAlign = TextAlign.Start,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
actions = {
|
|
||||||
Button(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Deny)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumButtonStyle.Secondary) {
|
||||||
Text("Deny")
|
Text("Deny")
|
||||||
}
|
}
|
||||||
Button(
|
MillenniumButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.Allow)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
}) {
|
||||||
) {
|
|
||||||
Text("Allow")
|
Text("Allow")
|
||||||
}
|
}
|
||||||
Button(
|
MillenniumButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
handler.resolveConnectionRequest(request.sessionId, ConnectionRequestResponse.AllowAlways)
|
||||||
pendingRequest = null
|
pendingRequest = null
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
Text("Allow Always")
|
Text("Allow Always")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -162,8 +162,7 @@ class RemoteControlClient {
|
|||||||
|
|
||||||
val envelope = CommandEnvelope(
|
val envelope = CommandEnvelope(
|
||||||
commandId = randomShortId(),
|
commandId = randomShortId(),
|
||||||
command = command,
|
command = command)
|
||||||
)
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
val text = json.encodeToString(CommandEnvelope.serializer(), envelope)
|
||||||
|
|||||||
@ -48,13 +48,11 @@ class RemoteControlHandler(
|
|||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
val logger by injectLogger<RemoteControlHandler>()
|
val logger by injectLogger<RemoteControlHandler>()
|
||||||
|
|
||||||
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
val incomingConnectionRequests = MutableSharedFlow<ConnectionRequest>(
|
||||||
extraBufferCapacity = 16,
|
extraBufferCapacity = 16)
|
||||||
)
|
|
||||||
|
|
||||||
private val json = Json {
|
private val json = Json {
|
||||||
ignoreUnknownKeys = true
|
ignoreUnknownKeys = true
|
||||||
@ -88,8 +86,7 @@ class RemoteControlHandler(
|
|||||||
deviceId = deviceId ?: "unknown",
|
deviceId = deviceId ?: "unknown",
|
||||||
deviceName = deviceName,
|
deviceName = deviceName,
|
||||||
sessionId = session.call.request.headers["X-Request-Id"]
|
sessionId = session.call.request.headers["X-Request-Id"]
|
||||||
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}",
|
?: "req-${kotlin.time.Clock.System.now().toEpochMilliseconds()}")
|
||||||
)
|
|
||||||
incomingConnectionRequests.emit(request)
|
incomingConnectionRequests.emit(request)
|
||||||
val response = waitForRequestResolution(request.sessionId)
|
val response = waitForRequestResolution(request.sessionId)
|
||||||
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
if (response != ConnectionRequestResponse.Allow && response != ConnectionRequestResponse.AllowAlways) {
|
||||||
@ -225,8 +222,7 @@ class RemoteControlHandler(
|
|||||||
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
entries = listOf(QueueEntry.StreamingTrack(track = command.track, url = "")),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
is RemoteControlCommand.AddTrackToQueue -> {
|
is RemoteControlCommand.AddTrackToQueue -> {
|
||||||
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
audioPlayerQueue.addToQueue(QueueEntry.StreamingTrack(track = command.track, url = ""))
|
||||||
@ -300,8 +296,7 @@ class RemoteControlHandler(
|
|||||||
private suspend fun handleCollectionSource(
|
private suspend fun handleCollectionSource(
|
||||||
source: String,
|
source: String,
|
||||||
action: RemoteCollectionAction,
|
action: RemoteCollectionAction,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false) {
|
||||||
) {
|
|
||||||
logger.d { "Remote collection $action for source: $source" }
|
logger.d { "Remote collection $action for source: $source" }
|
||||||
when {
|
when {
|
||||||
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
source.startsWith(COLLECTION_PLAYLIST_PREFIX) -> {
|
||||||
@ -359,8 +354,7 @@ class RemoteControlHandler(
|
|||||||
currentTrackTitle = current?.titleOrNull(),
|
currentTrackTitle = current?.titleOrNull(),
|
||||||
currentTrackArtists = current?.artistsOrNull(),
|
currentTrackArtists = current?.artistsOrNull(),
|
||||||
currentTrackAlbum = current?.albumOrNull(),
|
currentTrackAlbum = current?.albumOrNull(),
|
||||||
currentTrackCoverUrl = current?.coverUrlOrNull(),
|
currentTrackCoverUrl = current?.coverUrlOrNull())
|
||||||
)
|
|
||||||
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
val text = json.encodeToString(RemoteControlEvent.serializer(), state)
|
||||||
session.send(Frame.Text(text))
|
session.send(Frame.Text(text))
|
||||||
}
|
}
|
||||||
@ -375,8 +369,7 @@ class RemoteControlHandler(
|
|||||||
}
|
}
|
||||||
val event = RemoteControlEvent.QueueUpdated(
|
val event = RemoteControlEvent.QueueUpdated(
|
||||||
entries = queue.map { it.toRemoteQueueEntry() },
|
entries = queue.map { it.toRemoteQueueEntry() },
|
||||||
currentIndex = currentIndex,
|
currentIndex = currentIndex)
|
||||||
)
|
|
||||||
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
val text = json.encodeToString(RemoteControlEvent.serializer(), event)
|
||||||
session.send(Frame.Text(text))
|
session.send(Frame.Text(text))
|
||||||
}
|
}
|
||||||
@ -403,8 +396,7 @@ class RemoteControlHandler(
|
|||||||
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
artists = track.artists.joinToString(", ") { artist -> artist.name },
|
||||||
album = track.album?.title,
|
album = track.album?.title,
|
||||||
coverUrl = coverUrlOrNull(),
|
coverUrl = coverUrlOrNull(),
|
||||||
durationMs = track.durationMs,
|
durationMs = track.durationMs)
|
||||||
)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
is QueueEntry.LocalTrack -> RemoteQueueEntry(
|
||||||
mediaUrl = url,
|
mediaUrl = url,
|
||||||
@ -413,8 +405,7 @@ class RemoteControlHandler(
|
|||||||
artists = artists.joinToString(", "),
|
artists = artists.joinToString(", "),
|
||||||
album = album,
|
album = album,
|
||||||
coverUrl = null,
|
coverUrl = null,
|
||||||
durationMs = duration,
|
durationMs = duration)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun QueueEntry.mediaKey(): String = when (this) {
|
private fun QueueEntry.mediaKey(): String = when (this) {
|
||||||
@ -447,8 +438,7 @@ class RemoteControlHandler(
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class CommandEnvelope(
|
data class CommandEnvelope(
|
||||||
val commandId: String,
|
val commandId: String,
|
||||||
val command: RemoteControlCommand,
|
val command: RemoteControlCommand)
|
||||||
)
|
|
||||||
|
|
||||||
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
private const val COLLECTION_PLAYLIST_PREFIX = "spotube://playlist/"
|
||||||
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
private const val COLLECTION_ALBUM_PREFIX = "spotube://album/"
|
||||||
@ -464,8 +454,7 @@ enum class RemoteCollectionAction {
|
|||||||
data class ConnectionRequest(
|
data class ConnectionRequest(
|
||||||
val deviceId: String,
|
val deviceId: String,
|
||||||
val deviceName: String,
|
val deviceName: String,
|
||||||
val sessionId: String,
|
val sessionId: String)
|
||||||
)
|
|
||||||
|
|
||||||
enum class ConnectionRequestResponse {
|
enum class ConnectionRequestResponse {
|
||||||
Allow,
|
Allow,
|
||||||
|
|||||||
@ -119,15 +119,13 @@ sealed class RemoteControlEvent {
|
|||||||
val currentTrackTitle: String?,
|
val currentTrackTitle: String?,
|
||||||
val currentTrackArtists: String?,
|
val currentTrackArtists: String?,
|
||||||
val currentTrackAlbum: String?,
|
val currentTrackAlbum: String?,
|
||||||
val currentTrackCoverUrl: String?,
|
val currentTrackCoverUrl: String?) : RemoteControlEvent()
|
||||||
) : RemoteControlEvent()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("queueUpdated")
|
@SerialName("queueUpdated")
|
||||||
data class QueueUpdated(
|
data class QueueUpdated(
|
||||||
val entries: List<RemoteQueueEntry>,
|
val entries: List<RemoteQueueEntry>,
|
||||||
val currentIndex: Int,
|
val currentIndex: Int) : RemoteControlEvent()
|
||||||
) : RemoteControlEvent()
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@SerialName("ack")
|
@SerialName("ack")
|
||||||
@ -146,5 +144,4 @@ data class RemoteQueueEntry(
|
|||||||
val artists: String,
|
val artists: String,
|
||||||
val album: String?,
|
val album: String?,
|
||||||
val coverUrl: String?,
|
val coverUrl: String?,
|
||||||
val durationMs: Long,
|
val durationMs: Long)
|
||||||
)
|
|
||||||
|
|||||||
@ -53,8 +53,7 @@ import kotlin.random.Random
|
|||||||
class RemoteControlService(
|
class RemoteControlService(
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository,
|
||||||
private val discoveryService: DeviceDiscoveryService,
|
private val discoveryService: DeviceDiscoveryService,
|
||||||
private val localServer: LocalServer,
|
private val localServer: LocalServer) {
|
||||||
) {
|
|
||||||
private val log = Logger.withTag("RemoteControlService")
|
private val log = Logger.withTag("RemoteControlService")
|
||||||
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
private val scope = CoroutineScope(Dispatchers.IO + SupervisorJob())
|
||||||
|
|
||||||
@ -78,8 +77,7 @@ class RemoteControlService(
|
|||||||
scope.launch {
|
scope.launch {
|
||||||
combine(
|
combine(
|
||||||
settingsRepository.userSettings,
|
settingsRepository.userSettings,
|
||||||
localServer.port,
|
localServer.port) { settings, port -> settings to port }
|
||||||
) { settings, port -> settings to port }
|
|
||||||
.distinctUntilChanged()
|
.distinctUntilChanged()
|
||||||
.collect { (settings, port) ->
|
.collect { (settings, port) ->
|
||||||
if (settings.allowRemoteControl && port != null) {
|
if (settings.allowRemoteControl && port != null) {
|
||||||
@ -139,8 +137,7 @@ class RemoteControlService(
|
|||||||
val service = discoveryService.createService(
|
val service = discoveryService.createService(
|
||||||
name = serviceName,
|
name = serviceName,
|
||||||
port = port,
|
port = port,
|
||||||
deviceId = deviceId,
|
deviceId = deviceId)
|
||||||
)
|
|
||||||
pendingService = service
|
pendingService = service
|
||||||
try {
|
try {
|
||||||
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
service.register(timeoutInMs = REGISTER_TIMEOUT_MS)
|
||||||
|
|||||||
@ -64,20 +64,17 @@ sealed interface PlaybackDestinationRequest {
|
|||||||
val type: RemoteCollectionType,
|
val type: RemoteCollectionType,
|
||||||
val id: String,
|
val id: String,
|
||||||
val startTrack: MetadataTrack? = null,
|
val startTrack: MetadataTrack? = null,
|
||||||
val shuffle: Boolean = false,
|
val shuffle: Boolean = false) : PlaybackDestinationRequest
|
||||||
) : PlaybackDestinationRequest
|
|
||||||
|
|
||||||
data class Track(
|
data class Track(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
override val action: PlaybackDestinationAction,
|
override val action: PlaybackDestinationAction,
|
||||||
val track: MetadataTrack,
|
val track: MetadataTrack) : PlaybackDestinationRequest
|
||||||
) : PlaybackDestinationRequest
|
|
||||||
|
|
||||||
data class Tracks(
|
data class Tracks(
|
||||||
override val title: String,
|
override val title: String,
|
||||||
override val action: PlaybackDestinationAction,
|
override val action: PlaybackDestinationAction,
|
||||||
val tracks: List<MetadataTrack>,
|
val tracks: List<MetadataTrack>) : PlaybackDestinationRequest
|
||||||
) : PlaybackDestinationRequest
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@ -90,8 +87,7 @@ class RemotePlaybackController(
|
|||||||
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
private val collectionPlaybackHelper: CollectionPlaybackHelper,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val blacklistRepository: BlacklistRepository,
|
private val blacklistRepository: BlacklistRepository,
|
||||||
private val jamRoomService: JamRoomService,
|
private val jamRoomService: JamRoomService) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
private val logger = Logger.withTag("RemotePlaybackController")
|
private val logger = Logger.withTag("RemotePlaybackController")
|
||||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.Default)
|
||||||
|
|
||||||
@ -113,8 +109,7 @@ class RemotePlaybackController(
|
|||||||
id: String,
|
id: String,
|
||||||
title: String,
|
title: String,
|
||||||
startTrack: MetadataTrack? = null,
|
startTrack: MetadataTrack? = null,
|
||||||
shuffle: Boolean = false,
|
shuffle: Boolean = false) {
|
||||||
) {
|
|
||||||
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
request(PlaybackDestinationRequest.Collection(title, PlaybackDestinationAction.Play, type, id, startTrack, shuffle))
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -205,8 +200,7 @@ class RemotePlaybackController(
|
|||||||
QueueEntry.StreamingTrack(
|
QueueEntry.StreamingTrack(
|
||||||
track = track,
|
track = track,
|
||||||
url = "",
|
url = "",
|
||||||
addedBy = jamRoomService.participantClientId,
|
addedBy = jamRoomService.participantClientId)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
@ -291,8 +285,7 @@ class RemotePlaybackController(
|
|||||||
entries = listOf(entry),
|
entries = listOf(entry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDestinationAction.AddToQueue -> {
|
PlaybackDestinationAction.AddToQueue -> {
|
||||||
@ -319,8 +312,7 @@ class RemotePlaybackController(
|
|||||||
entries = entries,
|
entries = entries,
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
PlaybackDestinationAction.AddToQueue -> {
|
PlaybackDestinationAction.AddToQueue -> {
|
||||||
|
|||||||
@ -52,8 +52,7 @@ class ScrobbleManager(
|
|||||||
private val audioPlayer: AudioPlayerInterface,
|
private val audioPlayer: AudioPlayerInterface,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val scrobblePluginSource: ScrobblePluginSource,
|
private val scrobblePluginSource: ScrobblePluginSource,
|
||||||
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO),
|
private val scope: CoroutineScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<ScrobbleManager>()
|
private val logger by injectLogger<ScrobbleManager>()
|
||||||
|
|
||||||
@ -69,14 +68,12 @@ class ScrobbleManager(
|
|||||||
audioPlayerQueue.currentQueueEntryFlow,
|
audioPlayerQueue.currentQueueEntryFlow,
|
||||||
audioPlayer.playerStateFlow,
|
audioPlayer.playerStateFlow,
|
||||||
audioPlayer.positionFlow,
|
audioPlayer.positionFlow,
|
||||||
audioPlayer.durationFlow,
|
audioPlayer.durationFlow) { entry, playerState, position, duration ->
|
||||||
) { entry, playerState, position, duration ->
|
|
||||||
PlaybackSnapshot(
|
PlaybackSnapshot(
|
||||||
entry = entry,
|
entry = entry,
|
||||||
playerState = playerState,
|
playerState = playerState,
|
||||||
positionMs = position.inWholeMilliseconds,
|
positionMs = position.inWholeMilliseconds,
|
||||||
playerDurationMs = duration.inWholeMilliseconds,
|
playerDurationMs = duration.inWholeMilliseconds)
|
||||||
)
|
|
||||||
}.collect(::onPlaybackSnapshot)
|
}.collect(::onPlaybackSnapshot)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -137,8 +134,7 @@ class ScrobbleManager(
|
|||||||
scrobbled = true
|
scrobbled = true
|
||||||
val track = entry.toScrobbleTrack(
|
val track = entry.toScrobbleTrack(
|
||||||
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
timestamp = playbackStartedAtEpochSeconds ?: nowEpochSeconds(),
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
scope.launch { submitScrobble(track) }
|
scope.launch { submitScrobble(track) }
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -197,8 +193,7 @@ class ScrobbleManager(
|
|||||||
artistName = track.artists.joinToString(", ") { it.name },
|
artistName = track.artists.joinToString(", ") { it.name },
|
||||||
albumName = track.album?.title,
|
albumName = track.album?.title,
|
||||||
streamingProvider = resolveStreamingProvider(track),
|
streamingProvider = resolveStreamingProvider(track),
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
|
|
||||||
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
is QueueEntry.LocalTrack -> ScrobbleTrack(
|
||||||
timestamp = timestamp,
|
timestamp = timestamp,
|
||||||
@ -209,8 +204,7 @@ class ScrobbleManager(
|
|||||||
artistName = artists.joinToString(", "),
|
artistName = artists.joinToString(", "),
|
||||||
albumName = album,
|
albumName = album,
|
||||||
streamingProvider = LOCAL_PROVIDER,
|
streamingProvider = LOCAL_PROVIDER,
|
||||||
durationMs = durationMs,
|
durationMs = durationMs)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -245,8 +239,7 @@ class ScrobbleManager(
|
|||||||
val entry: QueueEntry?,
|
val entry: QueueEntry?,
|
||||||
val playerState: PlayerState,
|
val playerState: PlayerState,
|
||||||
val positionMs: Long,
|
val positionMs: Long,
|
||||||
val playerDurationMs: Long,
|
val playerDurationMs: Long)
|
||||||
)
|
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
/** Tracks shorter than this are never scrobbled (Last.fm rule). */
|
||||||
|
|||||||
@ -29,8 +29,7 @@ class AlternativeTracksRepository(
|
|||||||
private val matchedTracksRepository: TrackSourceRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<AlternativeTracksRepository>()
|
private val logger by injectLogger<AlternativeTracksRepository>()
|
||||||
|
|
||||||
|
|||||||
@ -51,13 +51,11 @@ data class CacheEntry(
|
|||||||
val filename: String,
|
val filename: String,
|
||||||
val sizeBytes: Long,
|
val sizeBytes: Long,
|
||||||
val createdAtMs: Long,
|
val createdAtMs: Long,
|
||||||
val contentType: String = "application/octet-stream",
|
val contentType: String = "application/octet-stream")
|
||||||
)
|
|
||||||
|
|
||||||
class CacheManager(
|
class CacheManager(
|
||||||
private val paths: Paths,
|
private val paths: Paths,
|
||||||
private val settingsRepository: SettingsRepository,
|
private val settingsRepository: SettingsRepository) {
|
||||||
) {
|
|
||||||
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
private val fileSystem: FileSystem = FileSystem.SYSTEM
|
||||||
private val cacheMutex: Mutex = Mutex()
|
private val cacheMutex: Mutex = Mutex()
|
||||||
private val json: Json = Json { ignoreUnknownKeys = true }
|
private val json: Json = Json { ignoreUnknownKeys = true }
|
||||||
@ -160,15 +158,13 @@ class CacheManager(
|
|||||||
trackId: String,
|
trackId: String,
|
||||||
filename: String,
|
filename: String,
|
||||||
allBytes: ByteArray,
|
allBytes: ByteArray,
|
||||||
contentType: String,
|
contentType: String) {
|
||||||
) {
|
|
||||||
val entry = CacheEntry(
|
val entry = CacheEntry(
|
||||||
trackId = trackId,
|
trackId = trackId,
|
||||||
filename = filename,
|
filename = filename,
|
||||||
sizeBytes = allBytes.size.toLong(),
|
sizeBytes = allBytes.size.toLong(),
|
||||||
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
createdAtMs = kotlin.time.Clock.System.now().toEpochMilliseconds(),
|
||||||
contentType = contentType,
|
contentType = contentType)
|
||||||
)
|
|
||||||
cacheMutex.withLock {
|
cacheMutex.withLock {
|
||||||
val index = readCacheIndex()
|
val index = readCacheIndex()
|
||||||
writeCacheIndex(CacheIndex(
|
writeCacheIndex(CacheIndex(
|
||||||
|
|||||||
@ -60,8 +60,7 @@ class LocalServer(
|
|||||||
private val streamingUrlRepository: StreamingUrlRepository,
|
private val streamingUrlRepository: StreamingUrlRepository,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val cacheManager: CacheManager,
|
private val cacheManager: CacheManager,
|
||||||
private val remoteControlHandler: RemoteControlHandler,
|
private val remoteControlHandler: RemoteControlHandler) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
val logger by injectLogger<LocalServer>()
|
val logger by injectLogger<LocalServer>()
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
@ -99,8 +98,7 @@ class LocalServer(
|
|||||||
audioPlayerQueue = audioPlayerQueue,
|
audioPlayerQueue = audioPlayerQueue,
|
||||||
isCachingEnabled = { cachedCacheEnabled.value },
|
isCachingEnabled = { cachedCacheEnabled.value },
|
||||||
activePort = { activePort.value },
|
activePort = { activePort.value },
|
||||||
scope = scope,
|
scope = scope)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
|
|||||||
@ -37,8 +37,7 @@ import kotlinx.serialization.json.Json
|
|||||||
@Serializable
|
@Serializable
|
||||||
data class PluginTaggedAudioSource(
|
data class PluginTaggedAudioSource(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val source: AudioSource.Basic,
|
val source: AudioSource.Basic)
|
||||||
)
|
|
||||||
|
|
||||||
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
/** Persists (and looks up) the audio source matched to a [MetadataTrack] by a given plugin. */
|
||||||
interface TrackSourceRepository {
|
interface TrackSourceRepository {
|
||||||
|
|||||||
@ -62,8 +62,7 @@ internal class StreamProxy(
|
|||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val isCachingEnabled: () -> Boolean,
|
private val isCachingEnabled: () -> Boolean,
|
||||||
private val activePort: () -> Int?,
|
private val activePort: () -> Int?,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope): KoinComponent {
|
||||||
): KoinComponent {
|
|
||||||
val logger by injectLogger<StreamProxy>()
|
val logger by injectLogger<StreamProxy>()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
@ -414,8 +413,7 @@ internal class StreamProxy(
|
|||||||
trackId = trackIdForCache,
|
trackId = trackIdForCache,
|
||||||
filename = cacheFilename,
|
filename = cacheFilename,
|
||||||
allBytes = allBytes,
|
allBytes = allBytes,
|
||||||
contentType = cacheContentType,
|
contentType = cacheContentType)
|
||||||
)
|
|
||||||
scope.launch {
|
scope.launch {
|
||||||
cacheManager.evictIfNeeded()
|
cacheManager.evictIfNeeded()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,28 +48,24 @@ data class CachedStreamUrl(
|
|||||||
// The id of the audio plugin that produced this entry. Guards against a stale entry
|
// The id of the audio plugin that produced this entry. Guards against a stale entry
|
||||||
// (e.g. a YouTube video URL) being served after the user switches to a different
|
// (e.g. a YouTube video URL) being served after the user switches to a different
|
||||||
// audio plugin, which would otherwise play back completely unrelated content.
|
// audio plugin, which would otherwise play back completely unrelated content.
|
||||||
val pluginId: String,
|
val pluginId: String)
|
||||||
)
|
|
||||||
|
|
||||||
private data class CachedAlternatives(
|
private data class CachedAlternatives(
|
||||||
val pluginId: String,
|
val pluginId: String,
|
||||||
val sources: List<AudioSource>,
|
val sources: List<AudioSource>)
|
||||||
)
|
|
||||||
|
|
||||||
data class StreamInfo(
|
data class StreamInfo(
|
||||||
val url: String,
|
val url: String,
|
||||||
val protocol: StreamProtocol,
|
val protocol: StreamProtocol,
|
||||||
val codec: String,
|
val codec: String,
|
||||||
val container: String,
|
val container: String)
|
||||||
)
|
|
||||||
|
|
||||||
class StreamingUrlRepository(
|
class StreamingUrlRepository(
|
||||||
private val pluginManager: AudioPluginSource,
|
private val pluginManager: AudioPluginSource,
|
||||||
private val audioPlayerQueue: AudioPlayerQueue,
|
private val audioPlayerQueue: AudioPlayerQueue,
|
||||||
private val matchedTracksRepository: TrackSourceRepository,
|
private val matchedTracksRepository: TrackSourceRepository,
|
||||||
private val settingsRepository: UserSettingsSource,
|
private val settingsRepository: UserSettingsSource,
|
||||||
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds,
|
private val streamUrlCacheTtlMs: Long = 30.seconds.inWholeMilliseconds) : KoinComponent {
|
||||||
) : KoinComponent {
|
|
||||||
|
|
||||||
private val logger by injectLogger<StreamingUrlRepository>()
|
private val logger by injectLogger<StreamingUrlRepository>()
|
||||||
private val streamUrlCacheMutex = Mutex()
|
private val streamUrlCacheMutex = Mutex()
|
||||||
@ -133,8 +129,7 @@ class StreamingUrlRepository(
|
|||||||
val resolvedStream = selectPreferredAudioStream(
|
val resolvedStream = selectPreferredAudioStream(
|
||||||
stream.streams,
|
stream.streams,
|
||||||
preferredFormat,
|
preferredFormat,
|
||||||
preferredQuality,
|
preferredQuality)
|
||||||
)
|
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
@ -193,8 +188,7 @@ class StreamingUrlRepository(
|
|||||||
val resolvedStream = selectPreferredAudioStream(
|
val resolvedStream = selectPreferredAudioStream(
|
||||||
stream.streams,
|
stream.streams,
|
||||||
preferredFormat,
|
preferredFormat,
|
||||||
preferredQuality,
|
preferredQuality)
|
||||||
)
|
|
||||||
if (resolvedStream != null) {
|
if (resolvedStream != null) {
|
||||||
val info = resolvedStream.toStreamInfo()
|
val info = resolvedStream.toStreamInfo()
|
||||||
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
cacheStreamInfo(trackId, info, preferredFormat, preferredQuality, stream, resolvedStream, audioPlugin.pluginId)
|
||||||
@ -207,15 +201,13 @@ class StreamingUrlRepository(
|
|||||||
url = normalizeManifestUrl(url, protocol),
|
url = normalizeManifestUrl(url, protocol),
|
||||||
protocol = protocol,
|
protocol = protocol,
|
||||||
codec = codec,
|
codec = codec,
|
||||||
container = container,
|
container = container)
|
||||||
)
|
|
||||||
|
|
||||||
private suspend fun getCachedStreamUrl(
|
private suspend fun getCachedStreamUrl(
|
||||||
trackId: String,
|
trackId: String,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
pluginId: String,
|
pluginId: String): StreamInfo? {
|
||||||
): StreamInfo? {
|
|
||||||
val now = Clock.System.now().toEpochMilliseconds()
|
val now = Clock.System.now().toEpochMilliseconds()
|
||||||
return streamUrlCacheMutex.withLock {
|
return streamUrlCacheMutex.withLock {
|
||||||
val cached = streamUrlCache[trackId] ?: return@withLock null
|
val cached = streamUrlCache[trackId] ?: return@withLock null
|
||||||
@ -241,8 +233,7 @@ class StreamingUrlRepository(
|
|||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality,
|
||||||
source: AudioSource.Streamed,
|
source: AudioSource.Streamed,
|
||||||
selectedStream: AudioStream,
|
selectedStream: AudioStream,
|
||||||
pluginId: String,
|
pluginId: String) {
|
||||||
) {
|
|
||||||
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
val expiresAtMs = Clock.System.now().toEpochMilliseconds() + streamUrlCacheTtlMs
|
||||||
streamUrlCacheMutex.withLock {
|
streamUrlCacheMutex.withLock {
|
||||||
streamUrlCache[trackId] = CachedStreamUrl(
|
streamUrlCache[trackId] = CachedStreamUrl(
|
||||||
@ -255,8 +246,7 @@ class StreamingUrlRepository(
|
|||||||
selectedStream = selectedStream,
|
selectedStream = selectedStream,
|
||||||
preferredFormat = preferredFormat,
|
preferredFormat = preferredFormat,
|
||||||
preferredQuality = preferredQuality,
|
preferredQuality = preferredQuality,
|
||||||
pluginId = pluginId,
|
pluginId = pluginId)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -326,8 +316,7 @@ internal fun normalizeManifestUrl(url: String, protocol: StreamProtocol): String
|
|||||||
internal fun selectPreferredAudioStream(
|
internal fun selectPreferredAudioStream(
|
||||||
streams: List<AudioStream>,
|
streams: List<AudioStream>,
|
||||||
preferredFormat: AudioFormat,
|
preferredFormat: AudioFormat,
|
||||||
preferredQuality: AudioQuality,
|
preferredQuality: AudioQuality): AudioStream? = streams.minWithOrNull(
|
||||||
): AudioStream? = streams.minWithOrNull(
|
|
||||||
compareBy<AudioStream> { stream ->
|
compareBy<AudioStream> { stream ->
|
||||||
formatMismatchCount(stream, preferredFormat)
|
formatMismatchCount(stream, preferredFormat)
|
||||||
}.thenBy { stream ->
|
}.thenBy { stream ->
|
||||||
|
|||||||
@ -1,483 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.defaultMinSize
|
|
||||||
import androidx.compose.foundation.layout.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,844 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,81 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.layout.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))
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,253 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,171 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,186 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.animation.scaleIn
|
|
||||||
import androidx.compose.animation.scaleOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.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,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,172 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.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)),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,132 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.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()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,177 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,313 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.Canvas
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.gestures.detectHorizontalDragGestures
|
|
||||||
import androidx.compose.foundation.gestures.detectTapGestures
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.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,
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,243 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
|
||||||
import androidx.compose.animation.fadeIn
|
|
||||||
import androidx.compose.animation.fadeOut
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsFocusedAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.Row
|
|
||||||
import androidx.compose.foundation.layout.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",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,264 +0,0 @@
|
|||||||
/*
|
|
||||||
* Copyright (C) 2026 Kingkor Roy Tirtho and Spotube Contributors
|
|
||||||
*
|
|
||||||
* This program is free software: you can redistribute it and/or modify
|
|
||||||
* it under the terms of the GNU Affero General Public License as published by
|
|
||||||
* the Free Software Foundation, either version 3 of the License, or
|
|
||||||
* (at your option) any later version.
|
|
||||||
*
|
|
||||||
* This program is distributed in the hope that it will be useful,
|
|
||||||
* but WITHOUT ANY WARRANTY; without even the implied warranty of
|
|
||||||
* MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
|
|
||||||
* GNU Affero General Public License for more details.
|
|
||||||
*
|
|
||||||
* You should have received a copy of the GNU Affero General Public License
|
|
||||||
* along with this program. If not, see <https://www.gnu.org/licenses/>.
|
|
||||||
*/
|
|
||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.base
|
|
||||||
|
|
||||||
import androidx.compose.animation.core.animateDpAsState
|
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
|
||||||
import androidx.compose.animation.core.tween
|
|
||||||
import androidx.compose.foundation.BorderStroke
|
|
||||||
import androidx.compose.foundation.background
|
|
||||||
import androidx.compose.foundation.border
|
|
||||||
import androidx.compose.foundation.clickable
|
|
||||||
import androidx.compose.foundation.hoverable
|
|
||||||
import androidx.compose.foundation.interaction.MutableInteractionSource
|
|
||||||
import androidx.compose.foundation.interaction.collectIsHoveredAsState
|
|
||||||
import androidx.compose.foundation.interaction.collectIsPressedAsState
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
|
||||||
import androidx.compose.foundation.layout.Box
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.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)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,23 +0,0 @@
|
|||||||
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,6 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumDialog
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -25,75 +32,54 @@ 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 androidx.compose.foundation.layout.size
|
import com.composeunstyled.SheetDetent
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import com.composeunstyled.rememberModalBottomSheetState
|
||||||
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) {
|
||||||
ThemedDialog(
|
MillenniumDialog(
|
||||||
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = if (title != null) {
|
title = "",
|
||||||
{
|
modifier = modifier) {
|
||||||
Row(
|
title?.let { it() }
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
|
||||||
verticalAlignment = Alignment.CenterVertically
|
|
||||||
) {
|
|
||||||
Box(Modifier.weight(1f)) { title() }
|
|
||||||
IconButton(onClick = onDismiss) {
|
|
||||||
Icon(
|
|
||||||
Iconsax.FluentDismiss,
|
|
||||||
contentDescription = "Close dialog",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
null
|
|
||||||
},
|
|
||||||
actions = actions,
|
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
|
actions?.let {
|
||||||
|
MillenniumSeparator()
|
||||||
|
it()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
ModalBottomSheet(
|
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||||
onDismissRequest = onDismiss,
|
LaunchedEffect(sheetState.currentDetent) {
|
||||||
dragHandle = null,
|
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||||
modifier = modifier,
|
onDismiss()
|
||||||
) {
|
}
|
||||||
|
}
|
||||||
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp)
|
.padding(horizontal = 16.dp)
|
||||||
.padding(top = 16.dp),
|
.padding(top = 16.dp),
|
||||||
horizontalAlignment = Alignment.Start,
|
horizontalAlignment = Alignment.Start) {
|
||||||
) {
|
|
||||||
title?.let {
|
title?.let {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
@ -101,11 +87,12 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
verticalAlignment = Alignment.CenterVertically
|
verticalAlignment = Alignment.CenterVertically
|
||||||
) {
|
) {
|
||||||
Box(Modifier.weight(1f)) { it() }
|
Box(Modifier.weight(1f)) { it() }
|
||||||
IconButton(onClick = onDismiss) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onDismiss,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
Iconsax.FluentDismiss,
|
Iconsax.FluentDismiss,
|
||||||
contentDescription = "Close dialog",
|
contentDescription = "Close dialog")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Spacer(Modifier.height(12.dp))
|
Spacer(Modifier.height(12.dp))
|
||||||
@ -113,7 +100,7 @@ fun AdaptiveDialogBottomSheet(
|
|||||||
content()
|
content()
|
||||||
actions?.let {
|
actions?.let {
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
HorizontalDivider(color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f))
|
MillenniumSeparator()
|
||||||
Spacer(Modifier.height(8.dp))
|
Spacer(Modifier.height(8.dp))
|
||||||
it()
|
it()
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,13 +17,12 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -34,20 +33,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.foundation.layout.widthIn
|
|
||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
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
|
||||||
@ -55,25 +46,37 @@ 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.base.TextField
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
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,
|
||||||
@ -82,7 +85,8 @@ 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,
|
||||||
@ -95,71 +99,129 @@ 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 adaptiveInfo = currentWindowAdaptiveInfo()
|
val isLargeScreen = currentWindowWidthBreakpoint() isAtLeast WindowBreakpoints.Medium
|
||||||
val isLargeScreen = adaptiveInfo.windowSizeClass.minWidthDp >= breakpointDp
|
|
||||||
|
|
||||||
Box(modifier = modifier) {
|
Box(modifier = modifier) {
|
||||||
trigger { expanded = true }
|
|
||||||
|
|
||||||
if (isLargeScreen) {
|
if (isLargeScreen) {
|
||||||
DropdownMenu(
|
UnstyledDropdownMenu(
|
||||||
expanded = expanded,
|
expanded = expanded,
|
||||||
onDismissRequest = { expanded = false },
|
onExpandedChange = { expanded = it },
|
||||||
) {
|
panel = {
|
||||||
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
DropdownMenuPanel(
|
||||||
header()
|
Modifier
|
||||||
}
|
.width(menuMinWidth)
|
||||||
|
.millenniumSurface(
|
||||||
var query by remember { mutableStateOf("") }
|
background = millenniumPanelBrush(MillenniumTheme.colors),
|
||||||
if (filter != null) {
|
shape = AppTheme.shapes.medium,
|
||||||
TextField(
|
borderColor = AppTheme.colors.outlineVariant,
|
||||||
value = query,
|
borderWidth = 1.dp)
|
||||||
onValueChange = {
|
.padding(4.dp)) {
|
||||||
query = it
|
if (header != null && (headerDisplayMode == HeaderDisplayMode.Always || headerDisplayMode == HeaderDisplayMode.OnlyInDropdown)) {
|
||||||
},
|
header()
|
||||||
placeholder = { Text("Search...") },
|
|
||||||
singleLine = true,
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Search",
|
|
||||||
modifier = Modifier.size(16.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxWidth()
|
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
items.forEach { item ->
|
|
||||||
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
|
||||||
return@forEach
|
|
||||||
}
|
}
|
||||||
if (item.dividerBefore) {
|
|
||||||
DropdownMenuDivider()
|
val dropdownQueryState = rememberTextFieldState()
|
||||||
|
val query = dropdownQueryState.text.toString()
|
||||||
|
if (filter != null) {
|
||||||
|
MillenniumTextField(
|
||||||
|
state = dropdownQueryState,
|
||||||
|
label = "",
|
||||||
|
placeholder = "Search...",
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
items.forEach { item ->
|
||||||
|
if (query.isNotBlank() && filter != null && !filter(item, query)) {
|
||||||
|
return@forEach
|
||||||
|
}
|
||||||
|
if (item.dividerBefore) {
|
||||||
|
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
|
}
|
||||||
|
val itemInteraction = remember { MutableInteractionSource() }
|
||||||
|
val itemHovered by itemInteraction.collectIsHoveredAsState()
|
||||||
|
val itemPressed by itemInteraction.collectIsPressedAsState()
|
||||||
|
val itemEnabled = item.enabled
|
||||||
|
val itemHighlighted = itemEnabled && (itemHovered || itemPressed || item.selected)
|
||||||
|
val itemForeground = when {
|
||||||
|
!itemEnabled -> AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
|
itemHighlighted -> MillenniumTheme.colors.onAccent
|
||||||
|
else -> AppTheme.colors.onSurface
|
||||||
|
}
|
||||||
|
UnstyledDropdownMenuItem(
|
||||||
|
onClick = { item.onClick() },
|
||||||
|
enabled = itemEnabled,
|
||||||
|
indication = null,
|
||||||
|
interactionSource = itemInteraction) {
|
||||||
|
Row(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.then(
|
||||||
|
if (itemHighlighted) {
|
||||||
|
Modifier.millenniumSurface(
|
||||||
|
background = gelBrush(
|
||||||
|
MillenniumTheme.colors.accentTop,
|
||||||
|
MillenniumTheme.colors.accentBottom),
|
||||||
|
shape = RoundedCornerShape(8.dp))
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
})
|
||||||
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
|
val itemIcon = item.icon
|
||||||
|
when {
|
||||||
|
item.leadingContent != null -> item.leadingContent.invoke()
|
||||||
|
itemIcon != null -> MillenniumIcon(
|
||||||
|
imageVector = itemIcon,
|
||||||
|
contentDescription = null,
|
||||||
|
tint = itemForeground,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
|
||||||
|
item.selected -> MillenniumIcon(
|
||||||
|
imageVector = FeatherIcons.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = itemForeground,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.label,
|
||||||
|
style = AppTheme.typography.bodyMedium,
|
||||||
|
color = itemForeground)
|
||||||
|
item.subtitle?.let { subtitle ->
|
||||||
|
Text(
|
||||||
|
text = subtitle,
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
color = if (itemHighlighted) {
|
||||||
|
itemForeground.copy(alpha = 0.85f)
|
||||||
|
} else {
|
||||||
|
AppTheme.colors.onSurfaceVariant
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (item.selected && (item.icon != null || item.leadingContent != null)) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = FeatherIcons.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
tint = itemForeground,
|
||||||
|
modifier = Modifier.size(20.dp))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
DropdownMenuItem(
|
|
||||||
text = item.label,
|
|
||||||
onClick = {
|
|
||||||
item.onClick()
|
|
||||||
expanded = false
|
|
||||||
},
|
|
||||||
enabled = item.enabled,
|
|
||||||
selected = item.selected,
|
|
||||||
leadingIcon = item.icon,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
|
anchor = { trigger { expanded = true } },
|
||||||
|
)
|
||||||
} else {
|
} else {
|
||||||
|
trigger { expanded = true }
|
||||||
if (expanded) {
|
if (expanded) {
|
||||||
AdaptiveBottomSheetContent(
|
AdaptiveBottomSheetContent(
|
||||||
onDismiss = { expanded = false },
|
onDismiss = { expanded = false },
|
||||||
@ -169,143 +231,124 @@ fun AdaptiveDropdownBottomSheet(
|
|||||||
} else {
|
} else {
|
||||||
null
|
null
|
||||||
},
|
},
|
||||||
filter = filter,
|
filter = filter)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun AdaptiveBottomSheetContent(
|
private fun AdaptiveBottomSheetContent(
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit,
|
||||||
items: List<AdaptiveMenuItem>,
|
items: List<AdaptiveMenuItem>,
|
||||||
header: @Composable (() -> Unit)?,
|
header: @Composable (() -> Unit)?,
|
||||||
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null,
|
filter: ((AdaptiveMenuItem, String) -> Boolean)? = null) {
|
||||||
) {
|
|
||||||
val hasSelection = items.any { it.selected }
|
val hasSelection = items.any { it.selected }
|
||||||
var query by remember { mutableStateOf("") }
|
val queryState = rememberTextFieldState()
|
||||||
|
val query = queryState.text.toString()
|
||||||
val filteredItems = if (filter != null && query.isNotBlank()) {
|
val filteredItems = if (filter != null && query.isNotBlank()) {
|
||||||
items.filter { filter(it, query) }
|
items.filter { filter(it, query) }
|
||||||
} else {
|
} else {
|
||||||
items
|
items
|
||||||
}
|
}
|
||||||
ModalBottomSheet(
|
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||||
onDismissRequest = onDismiss,
|
LaunchedEffect(sheetState.currentDetent) {
|
||||||
dragHandle = null,
|
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||||
) {
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
) {
|
|
||||||
header?.invoke()
|
header?.invoke()
|
||||||
|
|
||||||
if (filter != null) {
|
if (filter != null) {
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
TextField(
|
MillenniumTextField(
|
||||||
value = query,
|
state = queryState,
|
||||||
onValueChange = {
|
label = "",
|
||||||
query = it
|
placeholder = "Search...",
|
||||||
},
|
|
||||||
placeholder = { Text("Search...") },
|
|
||||||
singleLine = true,
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Search",
|
|
||||||
modifier = Modifier.size(18.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
},
|
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp)
|
.padding(horizontal = 12.dp, vertical = 8.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
LazyColumn(
|
LazyColumn(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
items(
|
items(
|
||||||
count = filteredItems.size,
|
count = filteredItems.size) { index ->
|
||||||
) { index ->
|
|
||||||
val item = filteredItems[index]
|
val item = filteredItems[index]
|
||||||
|
|
||||||
if (item.dividerBefore) {
|
if (item.dividerBefore) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(modifier = Modifier.padding(vertical = 4.dp))
|
||||||
modifier = Modifier.padding(vertical = 4.dp),
|
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(MaterialTheme.shapes.medium)
|
.clip(AppTheme.shapes.medium)
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = item.enabled,
|
enabled = item.enabled,
|
||||||
onClick = {
|
onClick = {
|
||||||
item.onClick()
|
item.onClick()
|
||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
})
|
||||||
)
|
|
||||||
.background(
|
.background(
|
||||||
color = if (item.selected) {
|
color = if (item.selected) {
|
||||||
MaterialTheme.colorScheme.secondary.copy(alpha = 0.12f)
|
AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
},
|
},
|
||||||
shape = MaterialTheme.shapes.medium,
|
shape = AppTheme.shapes.medium)
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
when {
|
||||||
Row(
|
item.leadingContent != null -> item.leadingContent.invoke()
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
item.icon != null -> MillenniumIcon(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
imageVector = item.icon,
|
||||||
) {
|
contentDescription = null,
|
||||||
if (item.icon != null) {
|
modifier = Modifier.size(22.dp),
|
||||||
Icon(
|
tint = if (item.enabled) {
|
||||||
imageVector = item.icon,
|
AppTheme.colors.onSurface
|
||||||
contentDescription = null,
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = if (item.enabled) {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
} else {
|
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
} else if (item.selected) {
|
|
||||||
Icon(
|
|
||||||
imageVector = FeatherIcons.Check,
|
|
||||||
contentDescription = "Selected",
|
|
||||||
modifier = Modifier.size(22.dp),
|
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
} else if (hasSelection) {
|
|
||||||
Spacer(modifier = Modifier.size(22.dp))
|
|
||||||
}
|
|
||||||
Text(
|
|
||||||
text = item.label,
|
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
|
||||||
color = if (item.enabled) {
|
|
||||||
MaterialTheme.colorScheme.onSurface
|
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
if (item.selected && item.icon != null) {
|
item.selected -> MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = FeatherIcons.Check,
|
imageVector = FeatherIcons.Check,
|
||||||
contentDescription = "Selected",
|
contentDescription = "Selected",
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
|
hasSelection -> Spacer(modifier = Modifier.size(22.dp))
|
||||||
|
}
|
||||||
|
|
||||||
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
|
Text(
|
||||||
|
text = item.label,
|
||||||
|
style = AppTheme.typography.bodyLarge,
|
||||||
|
color = if (item.enabled) {
|
||||||
|
AppTheme.colors.onSurface
|
||||||
|
} else {
|
||||||
|
AppTheme.colors.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)) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = FeatherIcons.Check,
|
||||||
|
contentDescription = "Selected",
|
||||||
|
modifier = Modifier.size(22.dp),
|
||||||
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -317,44 +360,37 @@ private fun AdaptiveBottomSheetContent(
|
|||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun AdaptiveDropdownBottomSheetPreview() {
|
private fun AdaptiveDropdownBottomSheetPreview() {
|
||||||
Scaffold {
|
Box(modifier = Modifier.background(AppTheme.colors.background)) {
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = listOf(
|
items = listOf(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxHeart,
|
icon = Iconsax.IconsaxHeart,
|
||||||
label = "Favorite",
|
label = "Favorite",
|
||||||
onClick = {},
|
onClick = {}),
|
||||||
),
|
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxShare,
|
icon = Iconsax.IconsaxShare,
|
||||||
label = "Share",
|
label = "Share",
|
||||||
onClick = {},
|
onClick = {}),
|
||||||
),
|
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxTrash,
|
icon = Iconsax.IconsaxTrash,
|
||||||
label = "Delete",
|
label = "Delete",
|
||||||
onClick = {},
|
onClick = {},
|
||||||
enabled = false,
|
enabled = false)),
|
||||||
),
|
|
||||||
),
|
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(32.dp)
|
.size(32.dp)
|
||||||
.background(
|
.background(
|
||||||
color = MaterialTheme.colorScheme.primary.copy(alpha = 0.1f),
|
color = AppTheme.colors.primary.copy(alpha = 0.1f),
|
||||||
shape = CircleShape,
|
shape = CircleShape)
|
||||||
)
|
|
||||||
.clip(CircleShape),
|
.clip(CircleShape),
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = MaterialTheme.colorScheme.primary,
|
tint = AppTheme.colors.primary)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,8 +40,7 @@ fun AlbumCard(
|
|||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject(),
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||||
) {
|
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
@ -57,17 +56,14 @@ fun AlbumCard(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.Album,
|
RemoteCollectionType.Album,
|
||||||
album.id,
|
album.id,
|
||||||
album.title,
|
album.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.Album,
|
RemoteCollectionType.Album,
|
||||||
album.id,
|
album.id,
|
||||||
album.title,
|
album.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = modifier.width(160.dp),
|
modifier = modifier.width(160.dp),
|
||||||
sharedElementKey = "album_art_${album.id}",
|
sharedElementKey = "album_art_${album.id}")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,9 +17,12 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.layout.RowScope
|
import androidx.compose.foundation.layout.RowScope
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
import dev.krtirtho.spotube.core.navigation.NavigationCommands
|
||||||
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
import dev.krtirtho.spotube.resources.iconsax.ArrowLeft3
|
||||||
@ -30,12 +33,12 @@ import org.koin.compose.koinInject
|
|||||||
fun ApplicationBackButton() {
|
fun ApplicationBackButton() {
|
||||||
val navigationCommands: NavigationCommands = koinInject()
|
val navigationCommands: NavigationCommands = koinInject()
|
||||||
|
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigationCommands.pop()
|
navigationCommands.pop()
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.ArrowLeft3,
|
imageVector = Iconsax.ArrowLeft3,
|
||||||
contentDescription = "Back"
|
contentDescription = "Back"
|
||||||
)
|
)
|
||||||
@ -47,5 +50,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 androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -52,8 +52,7 @@ import org.koin.compose.koinInject
|
|||||||
fun ArtistCard(
|
fun ArtistCard(
|
||||||
artist: MetadataArtist.Basic,
|
artist: MetadataArtist.Basic,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isBlacklisted: Boolean = false,
|
isBlacklisted: Boolean = false) {
|
||||||
) {
|
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
|
|
||||||
AvatarCard(
|
AvatarCard(
|
||||||
@ -64,7 +63,6 @@ fun ArtistCard(
|
|||||||
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
navigationCommands.navigateTo(Routes.Artist(artist.id))
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
isBlacklisted = isBlacklisted,
|
isBlacklisted = isBlacklisted)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -17,13 +17,24 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.BoxWithConstraints
|
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||||
|
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
|
||||||
@ -31,9 +42,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -42,12 +53,6 @@ import androidx.compose.ui.layout.ContentScale
|
|||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.OutlineButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
@ -62,7 +67,6 @@ import dev.krtirtho.spotube.resources.iconsax.User
|
|||||||
import org.jetbrains.compose.resources.DrawableResource
|
import org.jetbrains.compose.resources.DrawableResource
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSharedTransitionApi::class)
|
@OptIn(ExperimentalSharedTransitionApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun CollectionDetails(
|
fun CollectionDetails(
|
||||||
@ -85,10 +89,9 @@ 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()) {
|
||||||
BoxWithConstraints(modifier = modifier.fillMaxWidth()) {
|
val isCompact = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||||
val isCompact = maxWidth < 600.dp
|
|
||||||
|
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
@ -98,95 +101,86 @@ fun CollectionDetails(
|
|||||||
if (onAddToJam != null) {
|
if (onAddToJam != null) {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
) {
|
MillenniumButton(
|
||||||
PrimaryButton(
|
|
||||||
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
modifier = if (isCompact) Modifier.weight(1f) else Modifier,
|
||||||
onClick = onAddToJam!!,
|
onClick = onAddToJam!!) {
|
||||||
) {
|
MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to Jam",
|
contentDescription = "Add to Jam")
|
||||||
)
|
TextWithShimmer(text = "Add to Jam")
|
||||||
TextWithShimmer(
|
|
||||||
text = "Add to Jam",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
) {
|
|
||||||
val modifier = if (isCompact) {
|
val modifier = if (isCompact) {
|
||||||
Modifier.weight(1f)
|
Modifier.weight(1f)
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
Modifier
|
||||||
}
|
}
|
||||||
|
|
||||||
PrimaryButton(
|
MillenniumButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onPlay,
|
onClick = onPlay) {
|
||||||
) {
|
MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||||
)
|
TextWithShimmer(text = if (isPlaying) "Pause" else "Play")
|
||||||
TextWithShimmer(
|
|
||||||
text = if (isPlaying) "Pause" else "Play",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
OutlineButton(
|
MillenniumButton(
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onClick = onShufflePlay
|
onClick = onShufflePlay,
|
||||||
|
style = MillenniumButtonStyle.Secondary
|
||||||
) {
|
) {
|
||||||
Icon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
MillenniumIcon(imageVector = Iconsax.IconsaxShuffle, contentDescription = "Shuffle play")
|
||||||
TextWithShimmer(
|
TextWithShimmer(text = "Shuffle")
|
||||||
text = "Shuffle",
|
|
||||||
modifier = Modifier.padding(start = 6.dp),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val actions = @Composable {
|
val actions = @Composable {
|
||||||
ButtonGroup {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
GroupIconButton(onClick = onAddToQueue) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onAddToQueue,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue"
|
contentDescription = "Add to queue"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
ButtonGroupDivider()
|
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||||
if (showFollowButton) {
|
if (showFollowButton) {
|
||||||
if (isFollowing) {
|
if (isFollowing) {
|
||||||
GroupIconButton(onClick = onFollowClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onFollowClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxHeart2,
|
imageVector = Iconsax.IconsaxHeart2,
|
||||||
contentDescription = "Unfollow",
|
contentDescription = "Unfollow")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
GroupIconButton(onClick = onFollowClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onFollowClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxHeart,
|
imageVector = Iconsax.IconsaxHeart,
|
||||||
contentDescription = "Follow",
|
contentDescription = "Follow",
|
||||||
tint = MaterialTheme.colorScheme.onSurfaceVariant,
|
tint = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if (onEdit != null) {
|
if (onEdit != null) {
|
||||||
ButtonGroupDivider()
|
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||||
GroupIconButton(onClick = onEdit) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onEdit,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxEdit,
|
imageVector = Iconsax.IconsaxEdit,
|
||||||
contentDescription = "Edit playlist",
|
contentDescription = "Edit playlist")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,8 +189,7 @@ fun CollectionDetails(
|
|||||||
Row(
|
Row(
|
||||||
modifier = Modifier.clickable(onClick = onOwnerClick),
|
modifier = Modifier.clickable(onClick = onOwnerClick),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
) {
|
|
||||||
if (ownerImageURL != null) {
|
if (ownerImageURL != null) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = ownerImageURL,
|
model = ownerImageURL,
|
||||||
@ -205,27 +198,24 @@ fun CollectionDetails(
|
|||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.User,
|
imageVector = Iconsax.User,
|
||||||
contentDescription = ownerName,
|
contentDescription = ownerName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(16.dp)
|
.size(16.dp)
|
||||||
.background(
|
.background(
|
||||||
MaterialTheme.colorScheme.primaryContainer,
|
AppTheme.colors.primaryContainer,
|
||||||
CircleShape
|
CircleShape
|
||||||
)
|
)
|
||||||
.padding(6.dp),
|
.padding(6.dp),
|
||||||
tint = MaterialTheme.colorScheme.onPrimaryContainer,
|
tint = AppTheme.colors.onPrimaryContainer)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "By $ownerName",
|
text = "By $ownerName",
|
||||||
style = MaterialTheme.typography.labelSmall,
|
style = AppTheme.typography.labelSmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -237,79 +227,68 @@ fun CollectionDetails(
|
|||||||
.sharedElementOrNone(
|
.sharedElementOrNone(
|
||||||
key = sharedElementKey,
|
key = sharedElementKey,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope)
|
||||||
)
|
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
if (imageURL.isNotBlank()) {
|
if (imageURL.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else if (imageResource != null) {
|
} else if (imageResource != null) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
painter = painterResource(imageResource),
|
painter = painterResource(imageResource),
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||||
style = MaterialTheme.typography.displaySmall,
|
style = AppTheme.typography.displaySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
val metadata = @Composable {
|
val metadata = @Composable {
|
||||||
Column {
|
Column {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = if (isCompact) MaterialTheme.typography.bodyLarge else MaterialTheme.typography.headlineLarge,
|
style = if (isCompact) AppTheme.typography.bodyLarge else AppTheme.typography.headlineLarge,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
|
|
||||||
if (description.isNotBlank()) {
|
if (description.isNotBlank()) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = description,
|
text = description,
|
||||||
style = if (isCompact) MaterialTheme.typography.labelSmall else MaterialTheme.typography.bodyMedium,
|
style = if (isCompact) AppTheme.typography.labelSmall else AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
if (isCompact) {
|
if (isCompact) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(14.dp),
|
.padding(14.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
verticalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
artwork()
|
artwork()
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(6.dp),
|
verticalArrangement = Arrangement.spacedBy(6.dp)) {
|
||||||
) {
|
|
||||||
metadata()
|
metadata()
|
||||||
ownerInfo()
|
ownerInfo()
|
||||||
actions()
|
actions()
|
||||||
@ -323,14 +302,12 @@ fun CollectionDetails(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
horizontalArrangement = Arrangement.spacedBy(16.dp),
|
||||||
verticalAlignment = Alignment.Top,
|
verticalAlignment = Alignment.Top) {
|
||||||
) {
|
|
||||||
artwork()
|
artwork()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.weight(1f),
|
modifier = Modifier.weight(1f),
|
||||||
verticalArrangement = Arrangement.spacedBy(8.dp),
|
verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||||
) {
|
|
||||||
metadata()
|
metadata()
|
||||||
ownerInfo()
|
ownerInfo()
|
||||||
actions()
|
actions()
|
||||||
@ -354,6 +331,5 @@ fun CollectionDetailsPreview() {
|
|||||||
onOwnerClick = {},
|
onOwnerClick = {},
|
||||||
onPlay = {},
|
onPlay = {},
|
||||||
onShufflePlay = {},
|
onShufflePlay = {},
|
||||||
onAddToQueue = {},
|
onAddToQueue = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,18 +17,22 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import 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
|
||||||
@ -93,8 +97,7 @@ fun CollectionView(
|
|||||||
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
onAddToJam: (List<MetadataTrack>) -> Unit = {},
|
||||||
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
trackOptionsState: (MetadataTrack) -> TrackOptionsState = { TrackOptionsState() },
|
||||||
footerContent: (@Composable () -> Unit)? = null,
|
footerContent: (@Composable () -> Unit)? = null,
|
||||||
trailingContent: @Composable () -> Unit = {},
|
trailingContent: @Composable () -> Unit = {}) {
|
||||||
) {
|
|
||||||
val listState = rememberLazyListState()
|
val listState = rememberLazyListState()
|
||||||
val isCollapsed by remember {
|
val isCollapsed by remember {
|
||||||
derivedStateOf {
|
derivedStateOf {
|
||||||
@ -103,45 +106,42 @@ fun CollectionView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
topBar = {
|
ApplicationMainBar(
|
||||||
ApplicationMainBar(
|
backButton = true,
|
||||||
backButton = true,
|
title = if (isCollapsed) {
|
||||||
title = if (isCollapsed) {
|
{
|
||||||
{
|
CollapsedCollectionTitle(
|
||||||
CollapsedCollectionTitle(
|
title = title,
|
||||||
title = title,
|
imageURL = imageURL,
|
||||||
imageURL = imageURL,
|
imageResource = imageResource)
|
||||||
imageResource = imageResource,
|
}
|
||||||
)
|
} else {
|
||||||
|
{}
|
||||||
|
},
|
||||||
|
actions = if (isCollapsed && !isJamGuest) {
|
||||||
|
{
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onPlay,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
||||||
|
contentDescription = if (isPlaying) "Pause" else "Play")
|
||||||
}
|
}
|
||||||
} else {
|
}
|
||||||
{}
|
} else {
|
||||||
},
|
{}
|
||||||
actions = if (isCollapsed && !isJamGuest) {
|
})
|
||||||
{
|
|
||||||
IconButton(onClick = onPlay) {
|
Box(modifier = Modifier.weight(1f).fillMaxSize()) {
|
||||||
Icon(
|
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPauseCircle else Iconsax.IconsaxPlayCircle2,
|
|
||||||
contentDescription = if (isPlaying) "Pause" else "Play",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
{}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
) { innerPadding ->
|
|
||||||
if (error != null && tracks.isEmpty() && !isLoading) {
|
if (error != null && tracks.isEmpty() && !isLoading) {
|
||||||
ErrorDisplay(
|
ErrorDisplay(
|
||||||
errorMessage = error,
|
errorMessage = error,
|
||||||
onRetry = onRetry ?: {},
|
onRetry = onRetry ?: {},
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.fillMaxSize())
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
TrackList(
|
TrackList(
|
||||||
modifier = Modifier.padding(innerPadding),
|
modifier = Modifier.fillMaxSize(),
|
||||||
state = listState,
|
state = listState,
|
||||||
headerContent = {
|
headerContent = {
|
||||||
if (isLoading && tracks.isEmpty()) {
|
if (isLoading && tracks.isEmpty()) {
|
||||||
@ -164,8 +164,7 @@ fun CollectionView(
|
|||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
onAddToJam = { onAddToJam(tracks) },
|
onAddToJam = { onAddToJam(tracks) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
CollectionDetails(
|
CollectionDetails(
|
||||||
@ -186,8 +185,7 @@ fun CollectionView(
|
|||||||
onEdit = onEdit,
|
onEdit = onEdit,
|
||||||
sharedElementKey = sharedElementKey,
|
sharedElementKey = sharedElementKey,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
onAddToJam = { onAddToJam(tracks) },
|
onAddToJam = { onAddToJam(tracks) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
footerContent = footerContent,
|
footerContent = footerContent,
|
||||||
@ -211,11 +209,11 @@ fun CollectionView(
|
|||||||
onBulkAddToJam = onBulkAddToJam,
|
onBulkAddToJam = onBulkAddToJam,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest,
|
||||||
trackOptionsState = trackOptionsState,
|
trackOptionsState = trackOptionsState)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
trailingContent()
|
trailingContent()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -223,46 +221,39 @@ fun CollectionView(
|
|||||||
private fun CollapsedCollectionTitle(
|
private fun CollapsedCollectionTitle(
|
||||||
title: String,
|
title: String,
|
||||||
imageURL: String,
|
imageURL: String,
|
||||||
imageResource: DrawableResource? = null,
|
imageResource: DrawableResource? = null) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(36.dp)
|
.size(36.dp)
|
||||||
.clip(RoundedCornerShape(6.dp))
|
.clip(RoundedCornerShape(6.dp))
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
if (imageURL.isNotBlank()) {
|
if (imageURL.isNotBlank()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else if (imageResource != null) {
|
} else if (imageResource != null) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
painter = painterResource(imageResource),
|
painter = painterResource(imageResource),
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.size(36.dp),
|
modifier = Modifier.size(36.dp),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
Text(
|
Text(
|
||||||
text = title.take(1).ifBlank { "?" }.uppercase(),
|
text = title.take(1).ifBlank { "?" }.uppercase(),
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = AppTheme.typography.labelMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(start = 10.dp),
|
modifier = Modifier.padding(start = 10.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,30 +17,33 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.PaddingValues
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import org.spotube.millennium.composables.MillenniumButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
|
import org.spotube.millennium.composables.MillenniumDialog
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.text.selection.SelectionContainer
|
import androidx.compose.foundation.text.selection.SelectionContainer
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material3.AlertDialog
|
|
||||||
import androidx.compose.material3.Button
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.CardDefaults
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import 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
|
||||||
@ -67,8 +70,7 @@ import compose.icons.feathericons.RefreshCw
|
|||||||
fun ErrorDisplay(
|
fun ErrorDisplay(
|
||||||
errorMessage: String,
|
errorMessage: String,
|
||||||
onRetry: () -> Unit,
|
onRetry: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
var showFullLog by remember { mutableStateOf(false) }
|
var showFullLog by remember { mutableStateOf(false) }
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
@ -77,117 +79,99 @@ fun ErrorDisplay(
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
verticalArrangement = Arrangement.spacedBy(20.dp),
|
verticalArrangement = Arrangement.spacedBy(20.dp)) {
|
||||||
) {
|
MillenniumIcon(
|
||||||
Icon(
|
|
||||||
imageVector = FeatherIcons.AlertCircle,
|
imageVector = FeatherIcons.AlertCircle,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(64.dp),
|
modifier = Modifier.size(64.dp),
|
||||||
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
|
tint = AppTheme.colors.error.copy(alpha = 0.7f))
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "We're Truly Sorry",
|
text = "We're Truly Sorry",
|
||||||
style = MaterialTheme.typography.headlineSmall,
|
style = AppTheme.typography.headlineSmall,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = AppTheme.colors.onSurface,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center)
|
||||||
)
|
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
|
text = "Something isn't working right now. Your experience matters to us, and we're doing our best to fix this. Please try again or check the error details below.",
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
lineHeight = MaterialTheme.typography.bodyMedium.lineHeight * 1.2,
|
lineHeight = AppTheme.typography.bodyMedium.lineHeight * 1.2)
|
||||||
)
|
|
||||||
|
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
colors = CardDefaults.cardColors(
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
containerColor = MaterialTheme.colorScheme.errorContainer.copy(alpha = 0.15f),
|
|
||||||
),
|
|
||||||
shape = MaterialTheme.shapes.medium,
|
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 12.dp, vertical = 8.dp),
|
.padding(horizontal = 12.dp, vertical = 8.dp),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
Text(
|
Text(
|
||||||
text = "Error Details",
|
text = "Error Details",
|
||||||
style = MaterialTheme.typography.labelMedium,
|
style = AppTheme.typography.labelMedium,
|
||||||
fontWeight = FontWeight.SemiBold,
|
fontWeight = FontWeight.SemiBold,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(4.dp),
|
horizontalArrangement = Arrangement.spacedBy(4.dp)) {
|
||||||
) {
|
MillenniumIconButton(
|
||||||
IconButton(
|
|
||||||
onClick = {
|
onClick = {
|
||||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||||
},
|
},
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Copy,
|
imageVector = FeatherIcons.Copy,
|
||||||
contentDescription = "Copy to clipboard",
|
contentDescription = "Copy to clipboard",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = { showFullLog = true },
|
onClick = { showFullLog = true },
|
||||||
modifier = Modifier.size(32.dp),
|
modifier = Modifier.size(32.dp),
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Maximize2,
|
imageVector = FeatherIcons.Maximize2,
|
||||||
contentDescription = "View full log",
|
contentDescription = "View full log",
|
||||||
modifier = Modifier.size(16.dp),
|
modifier = Modifier.size(16.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Surface(
|
MillenniumCard(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f),
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
) {
|
|
||||||
SelectionContainer {
|
SelectionContainer {
|
||||||
Text(
|
Text(
|
||||||
text = errorMessage,
|
text = errorMessage,
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
style = AppTheme.typography.bodySmall.copy(
|
||||||
fontFamily = FontFamily.Monospace,
|
fontFamily = FontFamily.Monospace),
|
||||||
),
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
maxLines = 5,
|
maxLines = 5,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.padding(12.dp),
|
modifier = Modifier.padding(12.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
MillenniumButton(
|
||||||
Button(
|
|
||||||
onClick = onRetry,
|
onClick = onRetry,
|
||||||
modifier = Modifier.height(40.dp),
|
modifier = Modifier.height(40.dp)) {
|
||||||
) {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.RefreshCw,
|
imageVector = FeatherIcons.RefreshCw,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.padding(end = 8.dp).size(18.dp),
|
modifier = Modifier.padding(end = 8.dp).size(18.dp))
|
||||||
)
|
Text("Retry")
|
||||||
Text("Retry")
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -195,93 +179,85 @@ fun ErrorDisplay(
|
|||||||
if (showFullLog) {
|
if (showFullLog) {
|
||||||
ErrorLogDialog(
|
ErrorLogDialog(
|
||||||
errorMessage = errorMessage,
|
errorMessage = errorMessage,
|
||||||
onDismiss = { showFullLog = false },
|
onDismiss = { showFullLog = false })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorLogDialog(
|
private fun ErrorLogDialog(
|
||||||
errorMessage: String,
|
errorMessage: String,
|
||||||
onDismiss: () -> Unit,
|
onDismiss: () -> Unit) {
|
||||||
) {
|
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboardManager = LocalClipboardManager.current
|
||||||
|
|
||||||
AlertDialog(
|
MillenniumDialog(
|
||||||
|
visible = true,
|
||||||
onDismissRequest = onDismiss,
|
onDismissRequest = onDismiss,
|
||||||
title = {
|
title = "") {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
Text(
|
||||||
Text(
|
text = "Full Error Log",
|
||||||
text = "Full Error Log",
|
style = AppTheme.typography.titleLarge,
|
||||||
style = MaterialTheme.typography.titleLarge,
|
fontWeight = FontWeight.Bold)
|
||||||
fontWeight = FontWeight.Bold,
|
|
||||||
)
|
|
||||||
|
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
clipboardManager.setText(AnnotatedString(errorMessage))
|
clipboardManager.setText(AnnotatedString(errorMessage))
|
||||||
},
|
},
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = FeatherIcons.Copy,
|
imageVector = FeatherIcons.Copy,
|
||||||
contentDescription = "Copy to clipboard",
|
contentDescription = "Copy to clipboard")
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
text = {
|
Box(
|
||||||
Box(
|
modifier = Modifier
|
||||||
modifier = Modifier
|
.fillMaxWidth()
|
||||||
.fillMaxWidth()
|
.height(400.dp)
|
||||||
.height(400.dp)
|
.clip(AppTheme.shapes.small)
|
||||||
.clip(MaterialTheme.shapes.small)
|
.background(AppTheme.colors.surfaceContainerHighest.copy(alpha = 0.3f))) {
|
||||||
.background(MaterialTheme.colorScheme.surfaceContainerHighest.copy(alpha = 0.3f)),
|
SelectionContainer {
|
||||||
) {
|
Text(
|
||||||
SelectionContainer {
|
text = errorMessage,
|
||||||
Text(
|
style = AppTheme.typography.bodySmall.copy(
|
||||||
text = errorMessage,
|
fontFamily = FontFamily.Monospace),
|
||||||
style = MaterialTheme.typography.bodySmall.copy(
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
fontFamily = FontFamily.Monospace,
|
modifier = Modifier
|
||||||
),
|
.fillMaxSize()
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.verticalScroll(rememberScrollState())
|
||||||
modifier = Modifier
|
.padding(12.dp))
|
||||||
.fillMaxSize()
|
|
||||||
.verticalScroll(rememberScrollState())
|
|
||||||
.padding(12.dp),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
confirmButton = {
|
Row(
|
||||||
TextButton(onClick = onDismiss) {
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
horizontalArrangement = Arrangement.End) {
|
||||||
|
MillenniumButton(
|
||||||
|
onClick = onDismiss,
|
||||||
|
style = MillenniumButtonStyle.Quiet) {
|
||||||
Text("Close")
|
Text("Close")
|
||||||
}
|
}
|
||||||
},
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorDisplayPreview() {
|
private fun ErrorDisplayPreview() {
|
||||||
Surface {
|
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||||
ErrorDisplay(
|
ErrorDisplay(
|
||||||
errorMessage = "This is an error message",
|
errorMessage = "This is an error message",
|
||||||
onRetry = {},
|
onRetry = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Preview
|
@Preview
|
||||||
@Composable
|
@Composable
|
||||||
private fun ErrorLogDialogPreview() {
|
private fun ErrorLogDialogPreview() {
|
||||||
Scaffold {
|
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||||
ErrorLogDialog(
|
ErrorLogDialog(
|
||||||
errorMessage = "This is an error message",
|
errorMessage = "This is an error message",
|
||||||
onDismiss = {},
|
onDismiss = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.animation.core.animateFloatAsState
|
import androidx.compose.animation.core.animateFloatAsState
|
||||||
import androidx.compose.animation.core.tween
|
import androidx.compose.animation.core.tween
|
||||||
import androidx.compose.foundation.background
|
import androidx.compose.foundation.background
|
||||||
@ -34,11 +38,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 androidx.compose.material3.IconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.currentWindowWidthBreakpoint
|
||||||
import androidx.compose.material3.adaptive.currentWindowAdaptiveInfo
|
import org.spotube.millennium.adaptive.WindowBreakpoints
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
import androidx.compose.runtime.mutableIntStateOf
|
||||||
@ -83,8 +87,7 @@ import org.koin.compose.koinInject
|
|||||||
@Composable
|
@Composable
|
||||||
fun FeaturedCarousel(
|
fun FeaturedCarousel(
|
||||||
items: List<MetadataBrowseItem>,
|
items: List<MetadataBrowseItem>,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val filteredItems = remember(items) {
|
val filteredItems = remember(items) {
|
||||||
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
items.filter { it is MetadataBrowseItem.Album || it is MetadataBrowseItem.Playlist || it is MetadataBrowseItem.Track }
|
||||||
}
|
}
|
||||||
@ -92,20 +95,17 @@ 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 adaptiveInfo = currentWindowAdaptiveInfo()
|
val isSmallScreen = currentWindowWidthBreakpoint() isBelow WindowBreakpoints.Medium
|
||||||
val isSmallScreen = adaptiveInfo.windowSizeClass.minWidthDp <= 600
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
@ -113,8 +113,7 @@ private fun FeaturedCarouselContent(
|
|||||||
.height(
|
.height(
|
||||||
if (isSmallScreen) 200.dp else 300.dp
|
if (isSmallScreen) 200.dp else 300.dp
|
||||||
),
|
),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
Coverflow(
|
Coverflow(
|
||||||
state = state,
|
state = state,
|
||||||
params = CoverflowParams(
|
params = CoverflowParams(
|
||||||
@ -123,21 +122,17 @@ private fun FeaturedCarouselContent(
|
|||||||
angle = 12f,
|
angle = 12f,
|
||||||
shift = 0.255f,
|
shift = 0.255f,
|
||||||
zoom = 0.7864f,
|
zoom = 0.7864f,
|
||||||
mirror = false,
|
mirror = false),
|
||||||
),
|
modifier = modifier) {
|
||||||
modifier = modifier,
|
|
||||||
) {
|
|
||||||
items(
|
items(
|
||||||
items = items,
|
items = items,
|
||||||
key = { index: Int -> items[index].hashCode() },
|
key = { index: Int -> items[index].hashCode() }) { item: MetadataBrowseItem ->
|
||||||
) { item: MetadataBrowseItem ->
|
|
||||||
FeaturedCarouselCard(
|
FeaturedCarouselCard(
|
||||||
item = item,
|
item = item,
|
||||||
isCentered = selectedIndex == items.indexOf(item),
|
isCentered = selectedIndex == items.indexOf(item),
|
||||||
onScrollToCenter = {
|
onScrollToCenter = {
|
||||||
state.scrollToItem(items.indexOf(item))
|
state.scrollToItem(items.indexOf(item))
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -148,8 +143,7 @@ private fun FeaturedCarouselCard(
|
|||||||
item: MetadataBrowseItem,
|
item: MetadataBrowseItem,
|
||||||
isCentered: Boolean,
|
isCentered: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
onScrollToCenter: () -> Unit = { },
|
onScrollToCenter: () -> Unit = { }) {
|
||||||
) {
|
|
||||||
val navigationCommands = koinInject<NavigationCommands>()
|
val navigationCommands = koinInject<NavigationCommands>()
|
||||||
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
val audioPlayerQueue = koinInject<AudioPlayerQueue>()
|
||||||
|
|
||||||
@ -204,25 +198,21 @@ private fun FeaturedCarouselCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.hazeSource(hazeState),
|
.hazeSource(hazeState)) {
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = thumbnailUrl,
|
model = thumbnailUrl,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize()
|
.fillMaxSize()
|
||||||
.background(Color.Black.copy(alpha = dimAlpha)),
|
.background(Color.Black.copy(alpha = dimAlpha)))
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxSize(),
|
modifier = Modifier.fillMaxSize(),
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -231,43 +221,35 @@ private fun FeaturedCarouselCard(
|
|||||||
brush = Brush.verticalGradient(
|
brush = Brush.verticalGradient(
|
||||||
colors = listOf(
|
colors = listOf(
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Black.copy(alpha = 0.9f),
|
Color.Black.copy(alpha = 0.9f)))))
|
||||||
),
|
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxSize(),
|
.fillMaxSize(),
|
||||||
contentAlignment = Alignment.BottomCenter,
|
contentAlignment = Alignment.BottomCenter) {
|
||||||
) {
|
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.SpaceBetween,
|
horizontalArrangement = Arrangement.SpaceBetween,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(16.dp),
|
.padding(16.dp)) {
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = AppTheme.typography.titleMedium,
|
||||||
fontWeight = FontWeight.Bold,
|
fontWeight = FontWeight.Bold,
|
||||||
color = Color.White,
|
color = Color.White,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = Color.White.copy(alpha = 0.85f),
|
color = Color.White.copy(alpha = 0.85f),
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
val animatedAlpha by animateFloatAsState(
|
val animatedAlpha by animateFloatAsState(
|
||||||
@ -306,8 +288,7 @@ private fun FeaturedPlayButton(
|
|||||||
navigationCommands: NavigationCommands,
|
navigationCommands: NavigationCommands,
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
enabled: Boolean,
|
enabled: Boolean,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -320,7 +301,8 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
navigationCommands.navigateTo(Routes.Album(item.data.id))
|
||||||
@ -330,7 +312,7 @@ private fun FeaturedPlayButton(
|
|||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
@ -347,7 +329,8 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
navigationCommands.navigateTo(Routes.Playlist(item.data.id))
|
||||||
@ -357,7 +340,7 @@ private fun FeaturedPlayButton(
|
|||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
@ -377,20 +360,20 @@ private fun FeaturedPlayButton(
|
|||||||
modifier = modifier.size(44.dp),
|
modifier = modifier.size(44.dp),
|
||||||
contentAlignment = Alignment.BottomEnd
|
contentAlignment = Alignment.BottomEnd
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
|
style = MillenniumIconButtonStyle.Quiet,
|
||||||
onClick = {
|
onClick = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
audioPlayerQueue.load(
|
audioPlayerQueue.load(
|
||||||
entries = listOf(queueEntry),
|
entries = listOf(queueEntry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = enabled
|
enabled = enabled
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
imageVector = if (isPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
tint = Color.White
|
tint = Color.White
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@ -35,11 +39,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.FilledIconButton
|
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Icon
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
@ -76,8 +78,7 @@ fun LikedTracksCard(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject(),
|
navigationCommands: NavigationCommands = koinInject()) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
@ -92,8 +93,7 @@ fun LikedTracksCard(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
.clickable(onClick = { navigationCommands.navigateTo(Routes.SavedTracks) })
|
||||||
.width(160.dp)
|
.width(160.dp)
|
||||||
.hoverable(interactionSource = interactionSource),
|
.hoverable(interactionSource = interactionSource)) {
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(modifier = Modifier.fillMaxWidth()) {
|
Box(modifier = Modifier.fillMaxWidth()) {
|
||||||
androidx.compose.foundation.Image(
|
androidx.compose.foundation.Image(
|
||||||
@ -106,10 +106,8 @@ fun LikedTracksCard(
|
|||||||
.sharedElementOrNone(
|
.sharedElementOrNone(
|
||||||
key = "saved_tracks_art",
|
key = "saved_tracks_art",
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope),
|
||||||
),
|
contentScale = ContentScale.Crop)
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
modifier = Modifier.fillMaxWidth().aspectRatio(1f).padding(8.dp),
|
||||||
@ -120,22 +118,24 @@ fun LikedTracksCard(
|
|||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp)
|
||||||
) {
|
) {
|
||||||
FilledTonalIconButton(
|
MillenniumIconButton(
|
||||||
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
onClick = { scope.launch { playbackHelper.addSavedTracksToQueue() } },
|
||||||
modifier = Modifier.size(30.dp)
|
modifier = Modifier.size(30.dp),
|
||||||
|
style = MillenniumIconButtonStyle.Secondary
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue",
|
contentDescription = "Add to queue",
|
||||||
modifier = Modifier.size(16.dp)
|
modifier = Modifier.size(16.dp)
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
FilledIconButton(
|
MillenniumIconButton(
|
||||||
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
onClick = { scope.launch { playbackHelper.playSavedTracks() } },
|
||||||
modifier = Modifier.size(30.dp)
|
modifier = Modifier.size(30.dp),
|
||||||
|
style = MillenniumIconButtonStyle.Primary
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) {
|
imageVector = if (isPlaying) {
|
||||||
Iconsax.IconsaxPauseCircle
|
Iconsax.IconsaxPauseCircle
|
||||||
} else Iconsax.Play,
|
} else Iconsax.Play,
|
||||||
@ -152,18 +152,16 @@ fun LikedTracksCard(
|
|||||||
) {
|
) {
|
||||||
Text(
|
Text(
|
||||||
text = "Liked Tracks",
|
text = "Liked Tracks",
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
Text(
|
Text(
|
||||||
text = "Your saved songs",
|
text = "Your saved songs",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -40,8 +40,7 @@ fun PlaylistCard(
|
|||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
||||||
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
playbackHelper: CollectionPlaybackHelper = koinInject(),
|
||||||
navigationCommands: NavigationCommands = koinInject(),
|
navigationCommands: NavigationCommands = koinInject(),
|
||||||
remotePlaybackController: RemotePlaybackController = koinInject(),
|
remotePlaybackController: RemotePlaybackController = koinInject()) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
val currentCollectionEntry by audioPlayerQueue.currentCollectionEntryFlow.collectAsStateWithLifecycle()
|
||||||
|
|
||||||
@ -58,17 +57,14 @@ fun PlaylistCard(
|
|||||||
remotePlaybackController.requestCollectionPlay(
|
remotePlaybackController.requestCollectionPlay(
|
||||||
RemoteCollectionType.Playlist,
|
RemoteCollectionType.Playlist,
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title,
|
playlist.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
remotePlaybackController.requestCollectionAddToQueue(
|
remotePlaybackController.requestCollectionAddToQueue(
|
||||||
RemoteCollectionType.Playlist,
|
RemoteCollectionType.Playlist,
|
||||||
playlist.id,
|
playlist.id,
|
||||||
playlist.title,
|
playlist.title)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
sharedElementKey = "playlist_art_${playlist.id}",
|
sharedElementKey = "playlist_art_${playlist.id}")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -35,13 +35,11 @@ val LocalAnimatedVisibilityScope = compositionLocalOf<AnimatedVisibilityScope?>
|
|||||||
fun Modifier.sharedElementOrNone(
|
fun Modifier.sharedElementOrNone(
|
||||||
key: String?,
|
key: String?,
|
||||||
sharedTransitionScope: SharedTransitionScope?,
|
sharedTransitionScope: SharedTransitionScope?,
|
||||||
animatedVisibilityScope: AnimatedVisibilityScope?,
|
animatedVisibilityScope: AnimatedVisibilityScope?): Modifier {
|
||||||
): Modifier {
|
|
||||||
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
if (sharedTransitionScope == null || animatedVisibilityScope == null || key == null) return this
|
||||||
with(sharedTransitionScope) {
|
with(sharedTransitionScope) {
|
||||||
return this@sharedElementOrNone.sharedElement(
|
return this@sharedElementOrNone.sharedElement(
|
||||||
sharedContentState = rememberSharedContentState(key = key),
|
sharedContentState = rememberSharedContentState(key = key),
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,12 +26,9 @@ import androidx.compose.foundation.layout.height
|
|||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.FilledIconButton
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.FilledTonalIconButton
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
@ -54,8 +51,7 @@ import org.koin.compose.koinInject
|
|||||||
fun TrackCard(
|
fun TrackCard(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
audioPlayerQueue: AudioPlayerQueue = koinInject(),
|
audioPlayerQueue: AudioPlayerQueue = koinInject()) {
|
||||||
) {
|
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
val queueEntry = QueueEntry.StreamingTrack(track = track, url = "")
|
||||||
|
|
||||||
@ -69,8 +65,7 @@ fun TrackCard(
|
|||||||
entries = listOf(queueEntry),
|
entries = listOf(queueEntry),
|
||||||
autoPlay = true,
|
autoPlay = true,
|
||||||
startPosition = 0,
|
startPosition = 0,
|
||||||
collectionEntry = null,
|
collectionEntry = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onAddToQueue = {
|
onAddToQueue = {
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
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
|
||||||
@ -36,6 +40,7 @@ import androidx.compose.foundation.layout.PaddingValues
|
|||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.heightIn
|
import androidx.compose.foundation.layout.heightIn
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
@ -44,12 +49,20 @@ import androidx.compose.foundation.layout.widthIn
|
|||||||
import androidx.compose.foundation.lazy.LazyColumn
|
import androidx.compose.foundation.lazy.LazyColumn
|
||||||
import androidx.compose.foundation.lazy.LazyListState
|
import androidx.compose.foundation.lazy.LazyListState
|
||||||
import androidx.compose.foundation.lazy.rememberLazyListState
|
import androidx.compose.foundation.lazy.rememberLazyListState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.foundation.text.input.rememberTextFieldState
|
||||||
import androidx.compose.material3.HorizontalDivider
|
import androidx.compose.ui.state.ToggleableState
|
||||||
import androidx.compose.material3.Icon
|
import org.spotube.millennium.composables.MillenniumCard
|
||||||
import androidx.compose.material3.MaterialTheme
|
import org.spotube.millennium.composables.MillenniumCheckbox
|
||||||
import androidx.compose.material3.Scaffold
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
import androidx.compose.material3.Text
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumListTile
|
||||||
|
import org.spotube.millennium.composables.MillenniumSeparator
|
||||||
|
import org.spotube.millennium.composables.MillenniumTextField
|
||||||
|
import org.spotube.millennium.composables.MillenniumTriStateCheckbox
|
||||||
|
|
||||||
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.derivedStateOf
|
import androidx.compose.runtime.derivedStateOf
|
||||||
@ -68,7 +81,6 @@ 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
|
||||||
@ -83,16 +95,6 @@ import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.album.MetadataAlbumTy
|
|||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.artist.MetadataArtist
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.common.Thumbnail
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroup
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.ButtonGroupDivider
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.Card
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.CheckBox
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.CheckBoxState
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.GroupIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.TextField
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.highlight
|
|
||||||
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
import dev.krtirtho.spotube.core.ui.misc.SkeletonTree
|
||||||
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
import dev.krtirtho.spotube.core.ui.misc.TextWithShimmer
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
@ -122,7 +124,7 @@ enum class TrackSortOption(val label: String) {
|
|||||||
Duration("Duration"),
|
Duration("Duration"),
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalFoundationApi::class, ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
fun TrackList(
|
fun TrackList(
|
||||||
tracks: List<MetadataTrack> = emptyList(),
|
tracks: List<MetadataTrack> = emptyList(),
|
||||||
@ -153,9 +155,9 @@ fun TrackList(
|
|||||||
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
contentPadding: PaddingValues = PaddingValues(bottom = LocalAppShellBottomInset.current),
|
||||||
simplified: Boolean = false,
|
simplified: Boolean = false,
|
||||||
scrollable: Boolean = true,
|
scrollable: Boolean = true,
|
||||||
state: LazyListState? = null,
|
state: LazyListState? = null) {
|
||||||
) {
|
val filterState = rememberTextFieldState()
|
||||||
var filterQuery by rememberSaveable { mutableStateOf("") }
|
val filterQuery = filterState.text.toString()
|
||||||
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
var sortBy by rememberSaveable { mutableStateOf(TrackSortOption.None) }
|
||||||
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
var selectedTrackForOptions by remember { mutableStateOf<MetadataTrack?>(null) }
|
||||||
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
var isSelectionMode by rememberSaveable { mutableStateOf(false) }
|
||||||
@ -207,20 +209,19 @@ fun TrackList(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val windowInfo = LocalWindowInfo.current
|
val widthBreakpoint = currentWindowWidthBreakpoint()
|
||||||
val maxWidth = with(density) { windowInfo.containerSize.width.toDp() }
|
val isCompact = widthBreakpoint isBelow WindowBreakpoints.Medium
|
||||||
val isCompact = maxWidth < CompactTrackListBreakpoint
|
|
||||||
val isDesktop = remember { getPlatform().isDesktop() }
|
val isDesktop = remember { getPlatform().isDesktop() }
|
||||||
val showIndex = maxWidth >= LargeTrackListBreakpoint
|
val showIndex = widthBreakpoint isAtLeast WindowBreakpoints.Expanded
|
||||||
val showAlbum = !isCompact
|
val showAlbum = !isCompact
|
||||||
val useDropdownForOptions = isDesktop && !isCompact
|
val useDropdownForOptions = isDesktop && !isCompact
|
||||||
|
|
||||||
val trackCardContent: @Composable () -> Unit = {
|
val trackCardContent: @Composable () -> Unit = {
|
||||||
Card(
|
MillenniumCard(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(vertical = 8.dp),
|
.padding(vertical = 8.dp),
|
||||||
) {
|
contentPadding = PaddingValues(0.dp)) {
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
@ -228,9 +229,7 @@ fun TrackList(
|
|||||||
) {
|
) {
|
||||||
visibleTracks.forEachIndexed { displayedIndex, track ->
|
visibleTracks.forEachIndexed { displayedIndex, track ->
|
||||||
if (displayedIndex > 0) {
|
if (displayedIndex > 0) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator()
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TrackListRow(
|
TrackListRow(
|
||||||
index = displayedIndex + 1,
|
index = displayedIndex + 1,
|
||||||
@ -284,38 +283,31 @@ fun TrackList(
|
|||||||
onShowOptionsClick = { selectedTrackForOptions = track },
|
onShowOptionsClick = { selectedTrackForOptions = track },
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
onArtistsOverflowClick = { onArtistsOverflowClick(track) },
|
onArtistsOverflowClick = { onArtistsOverflowClick(track) })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (isLoading && tracks.isEmpty()) {
|
if (isLoading && tracks.isEmpty()) {
|
||||||
repeat(ShimmerRowCount) { shimmerIndex ->
|
repeat(ShimmerRowCount) { shimmerIndex ->
|
||||||
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
if (visibleTracks.isNotEmpty() || shimmerIndex > 0) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShimmerTrackListRow(
|
ShimmerTrackListRow(
|
||||||
index = shimmerIndex + 1,
|
index = shimmerIndex + 1,
|
||||||
showIndex = showIndex,
|
showIndex = showIndex,
|
||||||
showAlbum = showAlbum,
|
showAlbum = showAlbum,
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
useDropdownForOptions = useDropdownForOptions)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if (error != null) {
|
if (error != null) {
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
TrackListFeedbackRow(
|
TrackListFeedbackRow(
|
||||||
message = error,
|
message = error,
|
||||||
isError = true,
|
isError = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
if (showEmptyMessage && !isLoading && visibleTracks.isEmpty() && error == null) {
|
||||||
@ -324,17 +316,14 @@ fun TrackList(
|
|||||||
|
|
||||||
if (isLoadingNextPage) {
|
if (isLoadingNextPage) {
|
||||||
if (visibleTracks.isNotEmpty()) {
|
if (visibleTracks.isNotEmpty()) {
|
||||||
HorizontalDivider(
|
MillenniumSeparator(
|
||||||
modifier = Modifier.padding(horizontal = 8.dp),
|
modifier = Modifier.padding(horizontal = 8.dp))
|
||||||
color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.5f),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ShimmerTrackListRow(
|
ShimmerTrackListRow(
|
||||||
index = visibleTracks.size + 1,
|
index = visibleTracks.size + 1,
|
||||||
showIndex = showIndex,
|
showIndex = showIndex,
|
||||||
showAlbum = showAlbum,
|
showAlbum = showAlbum,
|
||||||
useDropdownForOptions = useDropdownForOptions,
|
useDropdownForOptions = useDropdownForOptions)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -345,19 +334,18 @@ fun TrackList(
|
|||||||
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)
|
||||||
.heightIn(max = 60.dp),
|
.heightIn(max = 60.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End),
|
horizontalArrangement = Arrangement.spacedBy(6.dp, Alignment.End)) {
|
||||||
) {
|
|
||||||
if (showIndex || isSelectionMode) {
|
if (showIndex || isSelectionMode) {
|
||||||
val allSelected =
|
val allSelected =
|
||||||
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
visibleTracks.isNotEmpty() && selectedTrackIds.size == visibleTracks.size
|
||||||
val anySelected = selectedTrackIds.isNotEmpty()
|
val anySelected = selectedTrackIds.isNotEmpty()
|
||||||
val headerState = when {
|
val headerState = when {
|
||||||
allSelected -> CheckBoxState.SELECTED
|
allSelected -> ToggleableState.On
|
||||||
anySelected -> CheckBoxState.INDETERMINATE
|
anySelected -> ToggleableState.Indeterminate
|
||||||
else -> CheckBoxState.UNSELECTED
|
else -> ToggleableState.Off
|
||||||
}
|
}
|
||||||
CheckBox(
|
MillenniumTriStateCheckbox(
|
||||||
state = headerState,
|
value = headerState,
|
||||||
onClick = {
|
onClick = {
|
||||||
if (allSelected) {
|
if (allSelected) {
|
||||||
isSelectionMode = false
|
isSelectionMode = false
|
||||||
@ -367,58 +355,45 @@ fun TrackList(
|
|||||||
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
selectedTrackIds = visibleTracks.map { it.id }.toSet()
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
label = "")
|
||||||
}
|
}
|
||||||
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
) {
|
) {
|
||||||
TextField(
|
MillenniumTextField(
|
||||||
value = filterQuery,
|
state = filterState,
|
||||||
onValueChange = { filterQuery = it },
|
label = "",
|
||||||
|
placeholder = "Filter",
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.widthIn(max = 400.dp)
|
.widthIn(max = 400.dp)
|
||||||
.align(Alignment.CenterEnd),
|
.align(Alignment.CenterEnd))
|
||||||
placeholder = { TextWithShimmer("Filter") },
|
|
||||||
leadingIcon = {
|
|
||||||
Icon(
|
|
||||||
imageVector = Iconsax.IconsaxFilterSearch,
|
|
||||||
contentDescription = "Filter",
|
|
||||||
)
|
|
||||||
},
|
|
||||||
singleLine = true,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
ButtonGroup {
|
Row(verticalAlignment = Alignment.CenterVertically) {
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = TrackSortOption.entries.map { option ->
|
items = TrackSortOption.entries.map { option ->
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
label = option.label,
|
label = option.label,
|
||||||
onClick = { sortBy = option },
|
onClick = { sortBy = option },
|
||||||
selected = sortBy == option,
|
selected = sortBy == option)
|
||||||
)
|
|
||||||
},
|
},
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GroupIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxSort,
|
imageVector = Iconsax.IconsaxSort,
|
||||||
contentDescription = "Sort",
|
contentDescription = "Sort")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
ButtonGroupDivider()
|
MillenniumSeparator(modifier = Modifier.height(24.dp), vertical = true)
|
||||||
Box(
|
Box(
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
val targetTracks = if (selectedTrackIds.isNotEmpty()) {
|
||||||
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
visibleTracks.filter { selectedTrackIds.contains(it.id) }
|
||||||
} else {
|
} else {
|
||||||
@ -433,54 +408,42 @@ fun TrackList(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
label = if (isAll) "Download All" else "Download $trackCount",
|
label = if (isAll) "Download All" else "Download $trackCount",
|
||||||
onClick = { onBulkDownload(targetTracks) },
|
onClick = { onBulkDownload(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
if (!isJamGuest) {
|
if (!isJamGuest) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
label = if (isAll) "Add All to Queue" else "Add $trackCount to Queue",
|
||||||
onClick = { onBulkAddToQueue(targetTracks) },
|
onClick = { onBulkAddToQueue(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
label = if (isAll) "Play All Next" else "Play $trackCount Next",
|
||||||
onClick = { onBulkPlayNext(targetTracks) },
|
onClick = { onBulkPlayNext(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicPlaylist,
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
label = if (isAll) "Add All to Playlist" else "Add $trackCount to Playlist",
|
||||||
onClick = { onBulkAddToPlaylist(targetTracks) },
|
onClick = { onBulkAddToPlaylist(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} + if (isInJam) {
|
} + if (isInJam) {
|
||||||
listOf(
|
listOf(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
label = if (isAll) "Add All to Jam" else "Add $trackCount to Jam",
|
||||||
onClick = { onBulkAddToJam(targetTracks) },
|
onClick = { onBulkAddToJam(targetTracks) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
emptyList()
|
emptyList()
|
||||||
},
|
},
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GroupIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onClick,
|
onClick = onClick,
|
||||||
) {
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = "Bulk actions",
|
contentDescription = "Bulk actions")
|
||||||
)
|
|
||||||
}
|
}
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -495,8 +458,7 @@ fun TrackList(
|
|||||||
.align(Alignment.TopCenter)
|
.align(Alignment.TopCenter)
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
contentPadding = contentPadding,
|
contentPadding = contentPadding,
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
if (headerContent != null) {
|
if (headerContent != null) {
|
||||||
item {
|
item {
|
||||||
headerContent()
|
headerContent()
|
||||||
@ -525,8 +487,7 @@ fun TrackList(
|
|||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
VerticalScrollbar(listState, modifier = Modifier.align(Alignment.CenterEnd))
|
||||||
@ -536,8 +497,7 @@ fun TrackList(
|
|||||||
modifier = modifier
|
modifier = modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
.padding(horizontal = if (isCompact) 6.dp else 16.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
if (headerContent != null) {
|
if (headerContent != null) {
|
||||||
headerContent()
|
headerContent()
|
||||||
}
|
}
|
||||||
@ -560,8 +520,7 @@ fun TrackList(
|
|||||||
onTrackOptionsAction(track, action)
|
onTrackOptionsAction(track, action)
|
||||||
selectedTrackForOptions = null
|
selectedTrackForOptions = null
|
||||||
},
|
},
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -590,235 +549,198 @@ private fun TrackListRow(
|
|||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
onAlbumClick: (MetadataAlbum.Detailed) -> Unit,
|
||||||
onArtistsOverflowClick: () -> Unit,
|
onArtistsOverflowClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
val rowTheme = LocalBaseUITheme.current.listRowTile
|
|
||||||
val isLight = MaterialTheme.colorScheme.surface.luminance() > 0.5f
|
|
||||||
val artworkInteractionSource = remember { MutableInteractionSource() }
|
val artworkInteractionSource = remember { MutableInteractionSource() }
|
||||||
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
val isArtworkHovered by artworkInteractionSource.collectIsHoveredAsState()
|
||||||
val rowInteractionSource = remember { MutableInteractionSource() }
|
val rowInteractionSource = remember { MutableInteractionSource() }
|
||||||
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
val isRowHovered by rowInteractionSource.collectIsHoveredAsState()
|
||||||
val rowBackgroundColor = when {
|
val rowBackgroundColor = when {
|
||||||
isSelected -> rowTheme.background.selected
|
isSelected -> AppTheme.colors.secondary.copy(alpha = 0.12f)
|
||||||
isCurrentTrack && isCurrentTrackPlaying -> rowTheme.background.hovered
|
isCurrentTrack && isCurrentTrackPlaying -> AppTheme.colors.primaryContainer
|
||||||
isCurrentTrack -> rowTheme.background.focused
|
isCurrentTrack -> AppTheme.colors.surfaceVariant
|
||||||
trackOptionsState.isBlacklisted -> androidx.compose.ui.graphics.SolidColor(MaterialTheme.colorScheme.error.copy(alpha = 0.1f))
|
trackOptionsState.isBlacklisted -> AppTheme.colors.error.copy(alpha = 0.1f)
|
||||||
else -> rowTheme.background.normal
|
else -> Color.Transparent
|
||||||
}
|
}
|
||||||
val highlightColor = Color.White.copy(
|
|
||||||
alpha = if (isLight) 0.9f else 0.06f
|
|
||||||
)
|
|
||||||
|
|
||||||
Row(
|
MillenniumListTile(
|
||||||
modifier = modifier
|
modifier = modifier,
|
||||||
.fillMaxWidth()
|
selected = isSelected || isCurrentTrack,
|
||||||
.hoverable(rowInteractionSource)
|
onClick = onTrackClick,
|
||||||
.combinedClickable(
|
title = {
|
||||||
onClick = onTrackClick,
|
|
||||||
onLongClick = onLongClick,
|
|
||||||
)
|
|
||||||
.background(rowBackgroundColor)
|
|
||||||
.then(
|
|
||||||
if (isRowHovered || isSelected || (isCurrentTrack && isCurrentTrackPlaying)) {
|
|
||||||
Modifier.highlight(highlightColor)
|
|
||||||
} else {
|
|
||||||
Modifier
|
|
||||||
}
|
|
||||||
)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.padding(horizontal = 8.dp, vertical = 6.dp)
|
|
||||||
.padding(end = 6.dp),
|
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
|
||||||
horizontalArrangement = Arrangement.spacedBy(10.dp),
|
|
||||||
) {
|
|
||||||
if (isSelectionMode) {
|
|
||||||
CheckBox(
|
|
||||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
onClick = { onSelectionToggle(!isSelected) },
|
|
||||||
modifier = Modifier.shimmerApply()
|
|
||||||
)
|
|
||||||
} else if (showIndex) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier.width(30.dp),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
AnimatedContent(
|
|
||||||
targetState = isRowHovered,
|
|
||||||
transitionSpec = {
|
|
||||||
fadeIn() togetherWith fadeOut()
|
|
||||||
},
|
|
||||||
label = "index-checkbox",
|
|
||||||
) { hovered ->
|
|
||||||
if (hovered) {
|
|
||||||
CheckBox(
|
|
||||||
state = if (isSelected) CheckBoxState.SELECTED else CheckBoxState.UNSELECTED,
|
|
||||||
onClick = { onSelectionToggle(!isSelected) },
|
|
||||||
modifier = Modifier.shimmerApply()
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
TextWithShimmer(
|
|
||||||
text = index.toString(),
|
|
||||||
style = MaterialTheme.typography.labelMedium,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.size(44.dp)
|
|
||||||
.clip(MaterialTheme.shapes.small)
|
|
||||||
.hoverable(interactionSource = artworkInteractionSource),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
|
|
||||||
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
|
||||||
val platformContext = LocalPlatformContext.current
|
|
||||||
val imageRequest = remember(imageUrl) {
|
|
||||||
ImageRequest.Builder(platformContext)
|
|
||||||
.data(imageUrl)
|
|
||||||
.size(128) // 44.dp * ~3x density = ~132px. 128 is a perfect power-of-2 size.
|
|
||||||
.crossfade(false) // Crucial for scroll performance
|
|
||||||
.build()
|
|
||||||
}
|
|
||||||
|
|
||||||
AsyncImage(
|
|
||||||
model = imageRequest,
|
|
||||||
contentDescription = track.title,
|
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
modifier = Modifier.fillMaxSize().shimmerApply(),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (isArtworkHovered || isCurrentTrack) {
|
|
||||||
Box(
|
|
||||||
modifier = Modifier
|
|
||||||
.fillMaxSize()
|
|
||||||
.background(Color.Black.copy(alpha = 0.10f)),
|
|
||||||
contentAlignment = Alignment.Center,
|
|
||||||
) {
|
|
||||||
Icon(
|
|
||||||
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
|
||||||
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
|
||||||
tint = Color.White,
|
|
||||||
modifier = Modifier.size(18.dp).shimmerApply(),
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1.2f)) {
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
},
|
||||||
|
subtitle = {
|
||||||
ArtistChips(
|
ArtistChips(
|
||||||
artists = track.artists,
|
artists = track.artists,
|
||||||
onArtistClick = onArtistClick,
|
onArtistClick = onArtistClick,
|
||||||
onArtistsOverflowClick = onArtistsOverflowClick,
|
onArtistsOverflowClick = onArtistsOverflowClick)
|
||||||
)
|
},
|
||||||
}
|
leading = {
|
||||||
|
|
||||||
if (showAlbum && track.album != null) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.weight(1f)
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
) {
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
TextWithShimmer(
|
if (isSelectionMode) {
|
||||||
text = track.album!!.title,
|
MillenniumCheckbox(
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
checked = isSelected,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
onCheckedChange = { onSelectionToggle(it) },
|
||||||
maxLines = 1,
|
label = "",
|
||||||
overflow = TextOverflow.Ellipsis,
|
modifier = Modifier.shimmerApply())
|
||||||
modifier = Modifier
|
} else if (showIndex) {
|
||||||
.hoverable(interactionSource)
|
Box(
|
||||||
.clickable(
|
modifier = Modifier.width(30.dp),
|
||||||
indication = null,
|
contentAlignment = Alignment.Center) {
|
||||||
interactionSource = interactionSource
|
AnimatedContent(
|
||||||
) { onAlbumClick(track.album!!) }
|
targetState = isRowHovered,
|
||||||
// Hide ripple and show underline on hover/click
|
transitionSpec = { fadeIn() togetherWith fadeOut() },
|
||||||
.then(
|
label = "index-checkbox") { hovered ->
|
||||||
if (isHovered || isPressed) {
|
if (hovered) {
|
||||||
Modifier.drawWithCache {
|
MillenniumCheckbox(
|
||||||
val underlineHeight = 1.dp.toPx()
|
checked = isSelected,
|
||||||
onDrawWithContent {
|
onCheckedChange = { onSelectionToggle(it) },
|
||||||
drawContent()
|
label = "",
|
||||||
drawLine(
|
modifier = Modifier.shimmerApply())
|
||||||
color = colorScheme.primary,
|
|
||||||
start = Offset(0f, size.height - underlineHeight),
|
|
||||||
end = Offset(size.width, size.height - underlineHeight),
|
|
||||||
strokeWidth = underlineHeight,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
} else {
|
} else {
|
||||||
Modifier
|
TextWithShimmer(
|
||||||
|
text = index.toString(),
|
||||||
|
style = AppTheme.typography.labelMedium,
|
||||||
|
textAlign = TextAlign.Center,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant)
|
||||||
}
|
}
|
||||||
)
|
}
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
TextWithShimmer(
|
Box(
|
||||||
text = track.durationMs.toDurationString(),
|
modifier = Modifier
|
||||||
style = MaterialTheme.typography.bodySmall,
|
.size(44.dp)
|
||||||
textAlign = TextAlign.End,
|
.clip(AppTheme.shapes.small)
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
.hoverable(interactionSource = artworkInteractionSource),
|
||||||
modifier = Modifier.width(56.dp),
|
contentAlignment = Alignment.Center) {
|
||||||
)
|
val imageUrl = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url
|
||||||
|
val platformContext = LocalPlatformContext.current
|
||||||
|
val imageRequest = remember(imageUrl) {
|
||||||
|
ImageRequest.Builder(platformContext)
|
||||||
|
.data(imageUrl)
|
||||||
|
.size(128)
|
||||||
|
.crossfade(false)
|
||||||
|
.build()
|
||||||
|
}
|
||||||
|
|
||||||
if (useDropdownForOptions) {
|
AsyncImage(
|
||||||
TrackOptions(
|
model = imageRequest,
|
||||||
track = track,
|
contentDescription = track.title,
|
||||||
state = trackOptionsState,
|
contentScale = ContentScale.Crop,
|
||||||
onAction = onTrackOptionsAction,
|
modifier = Modifier.fillMaxSize().shimmerApply())
|
||||||
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
|
||||||
isInJam = isInJam,
|
if (isArtworkHovered || isCurrentTrack) {
|
||||||
isJamGuest = isJamGuest,
|
Box(
|
||||||
)
|
modifier = Modifier
|
||||||
} else {
|
.fillMaxSize()
|
||||||
GhostIconButton(onClick = onShowOptionsClick) {
|
.background(Color.Black.copy(alpha = 0.10f)),
|
||||||
Icon(
|
contentAlignment = Alignment.Center) {
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
MillenniumIcon(
|
||||||
contentDescription = "Track options",
|
imageVector = if (isCurrentTrack && isCurrentTrackPlaying) Iconsax.IconsaxPause else Iconsax.IconsaxPlay,
|
||||||
)
|
contentDescription = if (isCurrentTrack && isCurrentTrackPlaying) "Pause" else "Play",
|
||||||
|
tint = Color.White,
|
||||||
|
modifier = Modifier.size(18.dp).shimmerApply())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
},
|
||||||
}
|
trailing = {
|
||||||
|
Row(
|
||||||
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
|
horizontalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||||
|
if (showAlbum && track.album != null) {
|
||||||
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
val colorScheme = AppTheme.colors
|
||||||
|
|
||||||
|
TextWithShimmer(
|
||||||
|
text = track.album!!.title,
|
||||||
|
style = AppTheme.typography.bodyMedium,
|
||||||
|
color = AppTheme.colors.primary,
|
||||||
|
maxLines = 1,
|
||||||
|
overflow = TextOverflow.Ellipsis,
|
||||||
|
modifier = Modifier
|
||||||
|
.widthIn(max = 220.dp)
|
||||||
|
.hoverable(interactionSource)
|
||||||
|
.clickable(
|
||||||
|
indication = null,
|
||||||
|
interactionSource = interactionSource) { onAlbumClick(track.album!!) }
|
||||||
|
.then(
|
||||||
|
if (isHovered || isPressed) {
|
||||||
|
Modifier.drawWithCache {
|
||||||
|
val underlineHeight = 1.dp.toPx()
|
||||||
|
onDrawWithContent {
|
||||||
|
drawContent()
|
||||||
|
drawLine(
|
||||||
|
color = colorScheme.primary,
|
||||||
|
start = Offset(0f, size.height - underlineHeight),
|
||||||
|
end = Offset(size.width, size.height - underlineHeight),
|
||||||
|
strokeWidth = underlineHeight)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
Modifier
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
|
||||||
|
TextWithShimmer(
|
||||||
|
text = track.durationMs.toDurationString(),
|
||||||
|
style = AppTheme.typography.bodySmall,
|
||||||
|
textAlign = TextAlign.End,
|
||||||
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
|
modifier = Modifier.width(56.dp))
|
||||||
|
|
||||||
|
if (useDropdownForOptions) {
|
||||||
|
TrackOptions(
|
||||||
|
track = track,
|
||||||
|
state = trackOptionsState,
|
||||||
|
onAction = onTrackOptionsAction,
|
||||||
|
onAlbumClick = { track.album?.let { onAlbumClick(it) } },
|
||||||
|
isInJam = isInJam,
|
||||||
|
isJamGuest = isJamGuest)
|
||||||
|
} else {
|
||||||
|
MillenniumIconButton(
|
||||||
|
onClick = onShowOptionsClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
|
contentDescription = "Track options")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ArtistChips(
|
private fun ArtistChips(
|
||||||
artists: List<MetadataArtist.Basic>,
|
artists: List<MetadataArtist.Basic>,
|
||||||
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
onArtistClick: (MetadataArtist.Basic) -> Unit,
|
||||||
onArtistsOverflowClick: () -> Unit,
|
onArtistsOverflowClick: () -> Unit) {
|
||||||
) {
|
|
||||||
val visibleArtists = artists.take(2)
|
val visibleArtists = artists.take(2)
|
||||||
Row(
|
Row(
|
||||||
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
horizontalArrangement = Arrangement.spacedBy(6.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth()) {
|
||||||
) {
|
|
||||||
visibleArtists.forEachIndexed { index, artist ->
|
visibleArtists.forEachIndexed { index, artist ->
|
||||||
Row(
|
Row(
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically) {
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
val isPressed by interactionSource.collectIsPressedAsState()
|
val isPressed by interactionSource.collectIsPressedAsState()
|
||||||
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = artist.name,
|
text = artist.name,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = AppTheme.colors.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
textDecoration = if (isHovered || isPressed) TextDecoration.Underline else null,
|
||||||
@ -827,13 +749,12 @@ private fun ArtistChips(
|
|||||||
indication = null,
|
indication = null,
|
||||||
interactionSource = interactionSource
|
interactionSource = interactionSource
|
||||||
) { onArtistClick(artist) }
|
) { onArtistClick(artist) }
|
||||||
,
|
)
|
||||||
)
|
|
||||||
if (index < visibleArtists.size - 1)
|
if (index < visibleArtists.size - 1)
|
||||||
Text(
|
Text(
|
||||||
",",
|
",",
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodySmall
|
style = AppTheme.typography.bodySmall
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -842,12 +763,11 @@ private fun ArtistChips(
|
|||||||
if (remaining > 0) {
|
if (remaining > 0) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = "+$remaining more",
|
text = "+$remaining more",
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.primary,
|
color = AppTheme.colors.primary,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.clickable(onClick = onArtistsOverflowClick),
|
modifier = Modifier.clickable(onClick = onArtistsOverflowClick))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -857,8 +777,7 @@ private fun ShimmerTrackListRow(
|
|||||||
index: Int,
|
index: Int,
|
||||||
showIndex: Boolean,
|
showIndex: Boolean,
|
||||||
showAlbum: Boolean,
|
showAlbum: Boolean,
|
||||||
useDropdownForOptions: Boolean,
|
useDropdownForOptions: Boolean) {
|
||||||
) {
|
|
||||||
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
val dummyTrack = remember(index) { dummyTrackForShimmer(index) }
|
||||||
SkeletonTree(true) {
|
SkeletonTree(true) {
|
||||||
TrackListRow(
|
TrackListRow(
|
||||||
@ -881,8 +800,7 @@ private fun ShimmerTrackListRow(
|
|||||||
onShowOptionsClick = {},
|
onShowOptionsClick = {},
|
||||||
onArtistClick = {},
|
onArtistClick = {},
|
||||||
onAlbumClick = {},
|
onAlbumClick = {},
|
||||||
onArtistsOverflowClick = {},
|
onArtistsOverflowClick = {})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -891,8 +809,7 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
|||||||
id = "shimmer_artist_$index",
|
id = "shimmer_artist_$index",
|
||||||
name = "Artist Name",
|
name = "Artist Name",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null)
|
||||||
)
|
|
||||||
return MetadataTrack(
|
return MetadataTrack(
|
||||||
id = "shimmer_track_$index",
|
id = "shimmer_track_$index",
|
||||||
title = "Track Title Here",
|
title = "Track Title Here",
|
||||||
@ -913,29 +830,25 @@ private fun dummyTrackForShimmer(index: Int): MetadataTrack {
|
|||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
albumType = MetadataAlbumType.Album,
|
albumType = MetadataAlbumType.Album,
|
||||||
artists = listOf(artist),
|
artists = listOf(artist),
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
thumbnails = null,
|
thumbnails = null,
|
||||||
explicit = false,
|
explicit = false,
|
||||||
popularity = 0,
|
popularity = 0,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun TrackListFeedbackRow(
|
private fun TrackListFeedbackRow(
|
||||||
message: String,
|
message: String,
|
||||||
isError: Boolean = false,
|
isError: Boolean = false) {
|
||||||
) {
|
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = message,
|
text = message,
|
||||||
color = if (isError) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.onSurfaceVariant,
|
color = if (isError) AppTheme.colors.error else AppTheme.colors.onSurfaceVariant,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 12.dp),
|
.padding(horizontal = 8.dp, vertical = 12.dp))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun Long.toDurationString(): String {
|
private fun Long.toDurationString(): String {
|
||||||
@ -951,21 +864,17 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
id = "artist_1",
|
id = "artist_1",
|
||||||
name = "Aria Vale",
|
name = "Aria Vale",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
MetadataArtist.Basic(
|
MetadataArtist.Basic(
|
||||||
id = "artist_2",
|
id = "artist_2",
|
||||||
name = "Neon Harbor",
|
name = "Neon Harbor",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
MetadataArtist.Basic(
|
MetadataArtist.Basic(
|
||||||
id = "artist_3",
|
id = "artist_3",
|
||||||
name = "Echo Drift",
|
name = "Echo Drift",
|
||||||
thumbnails = emptyList(),
|
thumbnails = emptyList(),
|
||||||
externalUri = null,
|
externalUri = null))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
val album = MetadataAlbum.Detailed(
|
val album = MetadataAlbum.Detailed(
|
||||||
releaseDate = null,
|
releaseDate = null,
|
||||||
@ -978,13 +887,11 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
Thumbnail(
|
Thumbnail(
|
||||||
url = "https://picsum.photos/120",
|
url = "https://picsum.photos/120",
|
||||||
width = 120,
|
width = 120,
|
||||||
height = 120,
|
height = 120)
|
||||||
)
|
|
||||||
),
|
),
|
||||||
albumType = MetadataAlbumType.Album,
|
albumType = MetadataAlbumType.Album,
|
||||||
artists = artists.take(2),
|
artists = artists.take(2),
|
||||||
externalUri = null,
|
externalUri = null)
|
||||||
)
|
|
||||||
|
|
||||||
return listOf(
|
return listOf(
|
||||||
MetadataTrack(
|
MetadataTrack(
|
||||||
@ -999,8 +906,7 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
explicit = false,
|
explicit = false,
|
||||||
popularity = 92,
|
popularity = 92,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null),
|
||||||
),
|
|
||||||
MetadataTrack(
|
MetadataTrack(
|
||||||
id = "track_2",
|
id = "track_2",
|
||||||
title = "Static Horizon",
|
title = "Static Horizon",
|
||||||
@ -1013,8 +919,7 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
popularity = 80,
|
popularity = 80,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null,
|
||||||
thumbnails = null,
|
thumbnails = null),
|
||||||
),
|
|
||||||
MetadataTrack(
|
MetadataTrack(
|
||||||
id = "track_3",
|
id = "track_3",
|
||||||
title = "Last Train Echo",
|
title = "Last Train Echo",
|
||||||
@ -1027,19 +932,16 @@ fun previewTracks(): List<MetadataTrack> {
|
|||||||
popularity = 77,
|
popularity = 77,
|
||||||
isrcCode = null,
|
isrcCode = null,
|
||||||
externalUri = null,
|
externalUri = null,
|
||||||
thumbnails = null,
|
thumbnails = null))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@Preview
|
@Preview
|
||||||
private fun TrackListPreview() {
|
private fun TrackListPreview() {
|
||||||
Scaffold {
|
Box(modifier = Modifier.fillMaxSize().background(AppTheme.colors.background)) {
|
||||||
TrackList(
|
TrackList(
|
||||||
tracks = previewTracks(),
|
tracks = previewTracks(),
|
||||||
hasMore = true,
|
hasMore = true,
|
||||||
isLoadingNextPage = true,
|
isLoadingNextPage = true)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,13 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component
|
package dev.krtirtho.spotube.core.ui.component
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
|
import org.spotube.millennium.composables.MillenniumModalBottomSheet
|
||||||
|
import com.composeunstyled.SheetDetent
|
||||||
|
import com.composeunstyled.rememberModalBottomSheetState
|
||||||
import androidx.compose.foundation.clickable
|
import androidx.compose.foundation.clickable
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
@ -24,12 +31,11 @@ import androidx.compose.foundation.layout.Row
|
|||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.material3.Icon
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.ModalBottomSheet
|
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -38,8 +44,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
import dev.krtirtho.plugin_interfaces.plugin_apis.metadata.track.MetadataTrack
|
||||||
import dev.krtirtho.spotube.core.ui.base.GhostIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.IconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
import dev.krtirtho.spotube.core.ui.misc.shimmerApply
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax
|
||||||
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
import dev.krtirtho.spotube.resources.iconsax.Iconsax3DotsMore
|
||||||
@ -72,8 +76,7 @@ data class TrackOptionsState(
|
|||||||
val isInQueue: Boolean = false,
|
val isInQueue: Boolean = false,
|
||||||
val isCurrentlyPlaying: Boolean = false,
|
val isCurrentlyPlaying: Boolean = false,
|
||||||
val isFavorite: Boolean = false,
|
val isFavorite: Boolean = false,
|
||||||
val isBlacklisted: Boolean = false,
|
val isBlacklisted: Boolean = false)
|
||||||
)
|
|
||||||
|
|
||||||
data class TrackOptionsContext(
|
data class TrackOptionsContext(
|
||||||
val currentTrackId: String? = null,
|
val currentTrackId: String? = null,
|
||||||
@ -81,15 +84,13 @@ data class TrackOptionsContext(
|
|||||||
val savedTrackIds: Set<String> = emptySet(),
|
val savedTrackIds: Set<String> = emptySet(),
|
||||||
val blacklistedTrackIds: Set<String> = emptySet(),
|
val blacklistedTrackIds: Set<String> = emptySet(),
|
||||||
val blacklistedArtistIds: Set<String> = emptySet(),
|
val blacklistedArtistIds: Set<String> = emptySet(),
|
||||||
val forceFavorite: Boolean = false,
|
val forceFavorite: Boolean = false) {
|
||||||
) {
|
|
||||||
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
fun stateFor(track: MetadataTrack): TrackOptionsState = TrackOptionsState(
|
||||||
isInQueue = track.id in queueTrackIds,
|
isInQueue = track.id in queueTrackIds,
|
||||||
isCurrentlyPlaying = track.id == currentTrackId,
|
isCurrentlyPlaying = track.id == currentTrackId,
|
||||||
isFavorite = forceFavorite || track.id in savedTrackIds,
|
isFavorite = forceFavorite || track.id in savedTrackIds,
|
||||||
isBlacklisted = track.id in blacklistedTrackIds ||
|
isBlacklisted = track.id in blacklistedTrackIds ||
|
||||||
track.artists.any { it.id in blacklistedArtistIds },
|
track.artists.any { it.id in blacklistedArtistIds })
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -100,8 +101,7 @@ fun TrackOptions(
|
|||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false,
|
isJamGuest: Boolean = false) {
|
||||||
) {
|
|
||||||
AdaptiveDropdownBottomSheet(
|
AdaptiveDropdownBottomSheet(
|
||||||
items = buildTrackMenuItems(
|
items = buildTrackMenuItems(
|
||||||
track = track,
|
track = track,
|
||||||
@ -109,11 +109,12 @@ fun TrackOptions(
|
|||||||
onAction = onAction,
|
onAction = onAction,
|
||||||
onAlbumClick = onAlbumClick,
|
onAlbumClick = onAlbumClick,
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest),
|
||||||
),
|
|
||||||
trigger = { onClick ->
|
trigger = { onClick ->
|
||||||
GhostIconButton(onClick = onClick) {
|
MillenniumIconButton(
|
||||||
Icon(
|
onClick = onClick,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet) {
|
||||||
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.Iconsax3DotsMore,
|
imageVector = Iconsax.Iconsax3DotsMore,
|
||||||
contentDescription = "Track options",
|
contentDescription = "Track options",
|
||||||
modifier = Modifier.shimmerApply()
|
modifier = Modifier.shimmerApply()
|
||||||
@ -122,11 +123,9 @@ fun TrackOptions(
|
|||||||
},
|
},
|
||||||
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
headerDisplayMode = HeaderDisplayMode.OnlyInBottomSheet,
|
||||||
header = { TrackOptionsSheetHeader(track = track) },
|
header = { TrackOptionsSheetHeader(track = track) },
|
||||||
modifier = modifier,
|
modifier = modifier)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun TrackOptionsBottomSheet(
|
fun TrackOptionsBottomSheet(
|
||||||
track: MetadataTrack,
|
track: MetadataTrack,
|
||||||
@ -135,9 +134,14 @@ fun TrackOptionsBottomSheet(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false,
|
isJamGuest: Boolean = false) {
|
||||||
) {
|
val sheetState = rememberModalBottomSheetState(initialDetent = SheetDetent.FullyExpanded)
|
||||||
ModalBottomSheet(onDismissRequest = onDismiss) {
|
LaunchedEffect(sheetState.currentDetent) {
|
||||||
|
if (sheetState.currentDetent == SheetDetent.Hidden) {
|
||||||
|
onDismiss()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
MillenniumModalBottomSheet(state = sheetState) {
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
TrackOptionsSheetHeader(track = track)
|
TrackOptionsSheetHeader(track = track)
|
||||||
|
|
||||||
@ -145,8 +149,7 @@ fun TrackOptionsBottomSheet(
|
|||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 8.dp, vertical = 8.dp),
|
.padding(horizontal = 8.dp, vertical = 8.dp),
|
||||||
verticalArrangement = Arrangement.spacedBy(2.dp),
|
verticalArrangement = Arrangement.spacedBy(2.dp)) {
|
||||||
) {
|
|
||||||
buildTrackMenuItems(
|
buildTrackMenuItems(
|
||||||
track = track,
|
track = track,
|
||||||
state = state,
|
state = state,
|
||||||
@ -159,40 +162,35 @@ fun TrackOptionsBottomSheet(
|
|||||||
onDismiss()
|
onDismiss()
|
||||||
},
|
},
|
||||||
isInJam = isInJam,
|
isInJam = isInJam,
|
||||||
isJamGuest = isJamGuest,
|
isJamGuest = isJamGuest).forEach { item ->
|
||||||
).forEach { item ->
|
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clickable(
|
.clickable(
|
||||||
enabled = item.enabled,
|
enabled = item.enabled,
|
||||||
onClick = item.onClick,
|
onClick = item.onClick)
|
||||||
)
|
|
||||||
.padding(horizontal = 12.dp, vertical = 14.dp),
|
.padding(horizontal = 12.dp, vertical = 14.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
item.icon?.let { icon ->
|
item.icon?.let { icon ->
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = icon,
|
imageVector = icon,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
modifier = Modifier.size(22.dp),
|
modifier = Modifier.size(22.dp),
|
||||||
tint = if (item.enabled) {
|
tint = if (item.enabled) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
AppTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Text(
|
Text(
|
||||||
text = item.label,
|
text = item.label,
|
||||||
style = MaterialTheme.typography.bodyLarge,
|
style = AppTheme.typography.bodyLarge,
|
||||||
color = if (item.enabled) {
|
color = if (item.enabled) {
|
||||||
MaterialTheme.colorScheme.onSurface
|
AppTheme.colors.onSurface
|
||||||
} else {
|
} else {
|
||||||
MaterialTheme.colorScheme.onSurface.copy(alpha = 0.38f)
|
AppTheme.colors.onSurface.copy(alpha = 0.38f)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -207,39 +205,34 @@ fun TrackOptionsSheetHeader(track: MetadataTrack) {
|
|||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.padding(horizontal = 16.dp, vertical = 12.dp),
|
.padding(horizontal = 16.dp, vertical = 12.dp),
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
horizontalArrangement = Arrangement.spacedBy(12.dp),
|
horizontalArrangement = Arrangement.spacedBy(12.dp)) {
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
model = (track.album?.thumbnails ?: track.thumbnails)?.firstOrNull()?.url,
|
||||||
contentDescription = null,
|
contentDescription = null,
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.size(56.dp)
|
.size(56.dp)
|
||||||
.clip(MaterialTheme.shapes.small),
|
.clip(AppTheme.shapes.small))
|
||||||
)
|
|
||||||
|
|
||||||
Column(modifier = Modifier.weight(1f)) {
|
Column(modifier = Modifier.weight(1f)) {
|
||||||
Text(
|
Text(
|
||||||
text = track.title,
|
text = track.title,
|
||||||
style = MaterialTheme.typography.titleMedium,
|
style = AppTheme.typography.titleMedium,
|
||||||
maxLines = 2,
|
maxLines = 2,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Text(
|
Text(
|
||||||
text = track.artists.joinToString(", ") { it.name },
|
text = track.artists.joinToString(", ") { it.name },
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
track.album?.let { album ->
|
track.album?.let { album ->
|
||||||
Text(
|
Text(
|
||||||
text = album.title,
|
text = album.title,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -251,25 +244,20 @@ private fun buildTrackMenuItems(
|
|||||||
onAction: (TrackOptionsAction) -> Unit,
|
onAction: (TrackOptionsAction) -> Unit,
|
||||||
onAlbumClick: () -> Unit,
|
onAlbumClick: () -> Unit,
|
||||||
isInJam: Boolean = false,
|
isInJam: Boolean = false,
|
||||||
isJamGuest: Boolean = false,
|
isJamGuest: Boolean = false): List<AdaptiveMenuItem> = buildList {
|
||||||
): List<AdaptiveMenuItem> = buildList {
|
|
||||||
if (isInJam) {
|
if (isInJam) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = "Add to Jam",
|
label = "Add to Jam",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToJam) },
|
onClick = { onAction(TrackOptionsAction.AddToJam) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicCircle,
|
icon = Iconsax.IconsaxMusicCircle,
|
||||||
label = "Start radio",
|
label = "Start radio",
|
||||||
onClick = { onAction(TrackOptionsAction.StartRadio) },
|
onClick = { onAction(TrackOptionsAction.StartRadio) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
// A guest's queue is the shared jam queue — mutating it locally is not
|
// A guest's queue is the shared jam queue — mutating it locally is not
|
||||||
// allowed, so queue actions are replaced by "Add to Jam".
|
// allowed, so queue actions are replaced by "Add to Jam".
|
||||||
@ -279,17 +267,13 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = "Play next",
|
label = "Play next",
|
||||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
} else if (state.isInQueue && !state.isCurrentlyPlaying) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxNext,
|
icon = Iconsax.IconsaxNext,
|
||||||
label = "Move to next",
|
label = "Move to next",
|
||||||
onClick = { onAction(TrackOptionsAction.PlayNext) },
|
onClick = { onAction(TrackOptionsAction.PlayNext) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!state.isInQueue) {
|
if (!state.isInQueue) {
|
||||||
@ -297,17 +281,13 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxAddSquare,
|
icon = Iconsax.IconsaxAddSquare,
|
||||||
label = "Add to queue",
|
label = "Add to queue",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToQueue) },
|
onClick = { onAction(TrackOptionsAction.AddToQueue) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
} else {
|
} else {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicSquareRemove,
|
icon = Iconsax.IconsaxMusicSquareRemove,
|
||||||
label = "Remove from queue",
|
label = "Remove from queue",
|
||||||
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) },
|
onClick = { onAction(TrackOptionsAction.RemoveFromQueue) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -315,42 +295,32 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
icon = if (state.isFavorite) Iconsax.IconsaxHeart2 else Iconsax.IconsaxHeart,
|
||||||
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
label = if (state.isFavorite) "Remove from favorites" else "Save as favorite",
|
||||||
onClick = { onAction(TrackOptionsAction.ToggleFavorite) },
|
onClick = { onAction(TrackOptionsAction.ToggleFavorite) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicPlaylist,
|
icon = Iconsax.IconsaxMusicPlaylist,
|
||||||
label = "Add to playlist",
|
label = "Add to playlist",
|
||||||
onClick = { onAction(TrackOptionsAction.AddToPlaylist) },
|
onClick = { onAction(TrackOptionsAction.AddToPlaylist) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxDirectboxReceive,
|
icon = Iconsax.IconsaxDirectboxReceive,
|
||||||
label = "Download",
|
label = "Download",
|
||||||
onClick = { onAction(TrackOptionsAction.Download) },
|
onClick = { onAction(TrackOptionsAction.Download) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
icon = Iconsax.IconsaxMusicSquareRemoveFilled,
|
||||||
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
label = if (state.isBlacklisted) "Remove from blacklist" else "Add to blacklist",
|
||||||
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) },
|
onClick = { onAction(TrackOptionsAction.ToggleBlacklist) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
|
|
||||||
if (!track.externalUri.isNullOrBlank()) {
|
if (!track.externalUri.isNullOrBlank()) {
|
||||||
add(
|
add(
|
||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxShare,
|
icon = Iconsax.IconsaxShare,
|
||||||
label = "Share",
|
label = "Share",
|
||||||
onClick = { onAction(TrackOptionsAction.Share) },
|
onClick = { onAction(TrackOptionsAction.Share) }))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
if (track.album != null) {
|
if (track.album != null) {
|
||||||
@ -358,8 +328,6 @@ private fun buildTrackMenuItems(
|
|||||||
AdaptiveMenuItem(
|
AdaptiveMenuItem(
|
||||||
icon = Iconsax.IconsaxCd,
|
icon = Iconsax.IconsaxCd,
|
||||||
label = "Go to album",
|
label = "Go to album",
|
||||||
onClick = onAlbumClick,
|
onClick = onAlbumClick))
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,9 +26,9 @@ import androidx.compose.foundation.layout.padding
|
|||||||
import androidx.compose.foundation.layout.size
|
import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
@ -44,8 +44,7 @@ import dev.krtirtho.spotube.core.ui.component.cards.AvatarCard
|
|||||||
@Composable
|
@Composable
|
||||||
fun UserCard(
|
fun UserCard(
|
||||||
user: MetadataUser,
|
user: MetadataUser,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier) {
|
||||||
) {
|
|
||||||
AvatarCard(
|
AvatarCard(
|
||||||
title = user.displayName ?: user.username,
|
title = user.displayName ?: user.username,
|
||||||
subtitle = "User",
|
subtitle = "User",
|
||||||
@ -53,7 +52,6 @@ fun UserCard(
|
|||||||
onClick = {
|
onClick = {
|
||||||
// TODO: Route to user details screen.
|
// TODO: Route to user details screen.
|
||||||
},
|
},
|
||||||
modifier = modifier,
|
modifier = modifier)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -28,8 +28,7 @@ import dev.krtirtho.spotube.isDesktop
|
|||||||
@Composable
|
@Composable
|
||||||
expect fun VerticalScrollbar(
|
expect fun VerticalScrollbar(
|
||||||
listState: LazyListState,
|
listState: LazyListState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier)
|
||||||
)
|
|
||||||
|
|
||||||
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
fun Modifier.dragScrollable(rowState: LazyListState): Modifier =
|
||||||
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
if (getPlatform().isDesktop()) pointerInput(rowState) {
|
||||||
|
|||||||
@ -31,9 +31,9 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Card
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
@ -58,10 +58,9 @@ fun AvatarCard(
|
|||||||
imageURL: String? = null,
|
imageURL: String? = null,
|
||||||
onClick: () -> Unit = {},
|
onClick: () -> Unit = {},
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
isBlacklisted: Boolean = false,
|
isBlacklisted: Boolean = false) {
|
||||||
) {
|
|
||||||
val backgroundColor = if (isBlacklisted) {
|
val backgroundColor = if (isBlacklisted) {
|
||||||
MaterialTheme.colorScheme.error.copy(alpha = 0.1f)
|
AppTheme.colors.error.copy(alpha = 0.1f)
|
||||||
} else {
|
} else {
|
||||||
Color.Transparent
|
Color.Transparent
|
||||||
}
|
}
|
||||||
@ -71,20 +70,17 @@ fun AvatarCard(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.background(backgroundColor)
|
.background(backgroundColor)
|
||||||
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
.clickable(onClick = { onClick.invoke() }, enabled = true)
|
||||||
.width(160.dp),
|
.width(160.dp)) {
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth(),
|
.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.padding(12.dp),
|
.padding(12.dp),
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
model = imageURL,
|
model = imageURL,
|
||||||
contentDescription = title,
|
contentDescription = title,
|
||||||
@ -93,27 +89,24 @@ fun AvatarCard(
|
|||||||
.aspectRatio(1f)
|
.aspectRatio(1f)
|
||||||
.clip(CircleShape)
|
.clip(CircleShape)
|
||||||
.shimmerApply(),
|
.shimmerApply(),
|
||||||
contentScale = ContentScale.Crop,
|
contentScale = ContentScale.Crop)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
modifier = Modifier.fillMaxWidth().padding(12.dp)
|
||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth())
|
||||||
)
|
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = AppTheme.typography.bodyMedium,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth())
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,6 +17,10 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.component.cards
|
package dev.krtirtho.spotube.core.ui.component.cards
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.animation.AnimatedVisibility
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
import androidx.compose.animation.ExperimentalSharedTransitionApi
|
||||||
import androidx.compose.animation.fadeIn
|
import androidx.compose.animation.fadeIn
|
||||||
@ -36,9 +40,8 @@ import androidx.compose.foundation.layout.size
|
|||||||
import androidx.compose.foundation.layout.width
|
import androidx.compose.foundation.layout.width
|
||||||
import androidx.compose.foundation.shape.CircleShape
|
import androidx.compose.foundation.shape.CircleShape
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Icon
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import 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
|
||||||
@ -50,10 +53,6 @@ import androidx.compose.ui.text.style.TextOverflow
|
|||||||
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
import androidx.compose.ui.tooling.preview.PreviewLightDark
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
import dev.krtirtho.spotube.core.ui.base.BaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.LocalBaseUITheme
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.PrimaryIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.base.SecondaryIconButton
|
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
import dev.krtirtho.spotube.core.ui.component.LocalAnimatedVisibilityScope
|
||||||
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
import dev.krtirtho.spotube.core.ui.component.LocalSharedTransitionScope
|
||||||
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
import dev.krtirtho.spotube.core.ui.component.sharedElementOrNone
|
||||||
@ -63,6 +62,11 @@ 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
|
||||||
@ -75,8 +79,7 @@ fun PlayableCard(
|
|||||||
onPlay: (() -> Unit)? = null,
|
onPlay: (() -> Unit)? = null,
|
||||||
onAddToQueue: (() -> Unit)? = null,
|
onAddToQueue: (() -> Unit)? = null,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
sharedElementKey: String? = null,
|
sharedElementKey: String? = null) {
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
val sharedTransitionScope = LocalSharedTransitionScope.current
|
val sharedTransitionScope = LocalSharedTransitionScope.current
|
||||||
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
val animatedVisibilityScope = LocalAnimatedVisibilityScope.current
|
||||||
@ -86,8 +89,7 @@ fun PlayableCard(
|
|||||||
.clip(RoundedCornerShape(8.dp))
|
.clip(RoundedCornerShape(8.dp))
|
||||||
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
.clickable(onClick = { onClick?.invoke() }, enabled = true)
|
||||||
.width(160.dp)
|
.width(160.dp)
|
||||||
.hoverable(interactionSource = interactionSource),
|
.hoverable(interactionSource = interactionSource)) {
|
||||||
) {
|
|
||||||
Column(modifier = Modifier.fillMaxWidth()) {
|
Column(modifier = Modifier.fillMaxWidth()) {
|
||||||
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
Box(modifier = Modifier.fillMaxWidth().shimmerApply()) {
|
||||||
AsyncImage(
|
AsyncImage(
|
||||||
@ -100,10 +102,8 @@ fun PlayableCard(
|
|||||||
.sharedElementOrNone(
|
.sharedElementOrNone(
|
||||||
key = sharedElementKey,
|
key = sharedElementKey,
|
||||||
sharedTransitionScope = sharedTransitionScope,
|
sharedTransitionScope = sharedTransitionScope,
|
||||||
animatedVisibilityScope = animatedVisibilityScope,
|
animatedVisibilityScope = animatedVisibilityScope),
|
||||||
),
|
contentScale = ContentScale.Crop)
|
||||||
contentScale = ContentScale.Crop,
|
|
||||||
)
|
|
||||||
|
|
||||||
if (onPlay != null || onAddToQueue != null)
|
if (onPlay != null || onAddToQueue != null)
|
||||||
Box(
|
Box(
|
||||||
@ -112,48 +112,45 @@ fun PlayableCard(
|
|||||||
) {
|
) {
|
||||||
val isHovered by interactionSource.collectIsHoveredAsState()
|
val isHovered by interactionSource.collectIsHoveredAsState()
|
||||||
this@Column.AnimatedVisibility(
|
this@Column.AnimatedVisibility(
|
||||||
visible = isHovered,
|
visible = isHovered || (isPlaying && onPlay != null),
|
||||||
enter = fadeIn(),
|
enter = fadeIn(),
|
||||||
exit = fadeOut(),
|
exit = fadeOut()) {
|
||||||
) {
|
|
||||||
Column(
|
Column(
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
modifier = Modifier.padding(4.dp)
|
modifier = Modifier.padding(4.dp)
|
||||||
) {
|
) {
|
||||||
onAddToQueue?.let {
|
// While this collection is playing the play/pause toggle stays
|
||||||
SecondaryIconButton(
|
// visible and the add-to-queue action is hidden entirely.
|
||||||
|
onAddToQueue?.takeIf { !isPlaying }?.let {
|
||||||
|
MillenniumIconButton(
|
||||||
onClick = onAddToQueue,
|
onClick = onAddToQueue,
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(34.dp),
|
||||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
style = MillenniumIconButtonStyle.Secondary
|
||||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = Iconsax.IconsaxAddSquare,
|
imageVector = Iconsax.IconsaxAddSquare,
|
||||||
contentDescription = "Add to queue",
|
contentDescription = stringResource(
|
||||||
)
|
Res.string.player_add_to_queue_content_description))
|
||||||
}
|
}
|
||||||
|
Spacer(modifier = Modifier.height(8.dp))
|
||||||
}
|
}
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
|
||||||
onPlay?.let {
|
onPlay?.let {
|
||||||
PrimaryIconButton(
|
MillenniumIconButton(
|
||||||
onClick = onPlay,
|
onClick = onPlay,
|
||||||
modifier = Modifier.size(30.dp),
|
modifier = Modifier.size(34.dp),
|
||||||
theme = LocalBaseUITheme.current.iconButtons.secondary.copy(
|
style = MillenniumIconButtonStyle.Primary
|
||||||
shape = BaseUITheme.InteractionState.fromSingleValue(
|
|
||||||
CircleShape
|
|
||||||
)
|
|
||||||
)
|
|
||||||
|
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
imageVector = if (isPlaying) {
|
imageVector = if (isPlaying) {
|
||||||
Iconsax.IconsaxPause
|
Iconsax.IconsaxPause
|
||||||
} else Iconsax.IconsaxPlay,
|
} else Iconsax.IconsaxPlay,
|
||||||
contentDescription = "Play",
|
contentDescription = stringResource(
|
||||||
)
|
if (isPlaying) {
|
||||||
|
Res.string.player_pause_content_description
|
||||||
|
} else {
|
||||||
|
Res.string.player_play_content_description
|
||||||
|
}))
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -165,19 +162,17 @@ fun PlayableCard(
|
|||||||
) {
|
) {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = title,
|
text = title,
|
||||||
style = MaterialTheme.typography.titleSmall,
|
style = AppTheme.typography.titleSmall,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(4.dp))
|
Spacer(modifier = Modifier.height(4.dp))
|
||||||
subtitle?.let {
|
subtitle?.let {
|
||||||
TextWithShimmer(
|
TextWithShimmer(
|
||||||
text = subtitle,
|
text = subtitle,
|
||||||
style = MaterialTheme.typography.bodySmall,
|
style = AppTheme.typography.bodySmall,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = AppTheme.colors.onSurfaceVariant,
|
||||||
maxLines = 1,
|
maxLines = 1,
|
||||||
overflow = TextOverflow.Ellipsis,
|
overflow = TextOverflow.Ellipsis)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -185,11 +180,10 @@ fun PlayableCard(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
@PreviewLightDark
|
@PreviewLightDark
|
||||||
@Composable
|
@Composable
|
||||||
private fun PlayableCardPreview() {
|
private fun PlayableCardPreview() {
|
||||||
Scaffold {
|
Box {
|
||||||
PlayableCard(
|
PlayableCard(
|
||||||
title = "Sample Title",
|
title = "Sample Title",
|
||||||
subtitle = "Sample Subtitle",
|
subtitle = "Sample Subtitle",
|
||||||
|
|||||||
@ -45,8 +45,7 @@ fun Coverflow(
|
|||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
state: CoverflowState = rememberCoverflowState(),
|
state: CoverflowState = rememberCoverflowState(),
|
||||||
params: CoverflowParams = CoverflowParams(),
|
params: CoverflowParams = CoverflowParams(),
|
||||||
content: CoverflowScope.() -> Unit,
|
content: CoverflowScope.() -> Unit) {
|
||||||
) {
|
|
||||||
var size by remember { mutableStateOf(IntSize.Zero) }
|
var size by remember { mutableStateOf(IntSize.Zero) }
|
||||||
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
var geometry by remember { mutableStateOf<Geometry?>(null) }
|
||||||
|
|
||||||
@ -55,8 +54,7 @@ fun Coverflow(
|
|||||||
val g = geometry ?: return@LaunchedEffect
|
val g = geometry ?: return@LaunchedEffect
|
||||||
state.lazyListState.scrollToItem(
|
state.lazyListState.scrollToItem(
|
||||||
state.selectedIndex + 1,
|
state.selectedIndex + 1,
|
||||||
-g.spacerWidth,
|
-g.spacerWidth)
|
||||||
)
|
|
||||||
state.initialScrollDone = true
|
state.initialScrollDone = true
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -69,8 +67,7 @@ fun Coverflow(
|
|||||||
},
|
},
|
||||||
verticalAlignment = Alignment.CenterVertically,
|
verticalAlignment = Alignment.CenterVertically,
|
||||||
state = state.lazyListState,
|
state = state.lazyListState,
|
||||||
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState),
|
flingBehavior = rememberSnapFlingBehavior(lazyListState = state.lazyListState)) {
|
||||||
) {
|
|
||||||
if (size != IntSize.Zero) {
|
if (size != IntSize.Zero) {
|
||||||
val g = Geometry(params, size)
|
val g = Geometry(params, size)
|
||||||
geometry = g
|
geometry = g
|
||||||
@ -79,8 +76,7 @@ fun Coverflow(
|
|||||||
val coverflowScope = CoverflowScopeImpl(
|
val coverflowScope = CoverflowScopeImpl(
|
||||||
geometry = g,
|
geometry = g,
|
||||||
lazyListScope = this,
|
lazyListScope = this,
|
||||||
coverflowState = state,
|
coverflowState = state)
|
||||||
)
|
|
||||||
|
|
||||||
item {
|
item {
|
||||||
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
Spacer(modifier = Modifier.width(with(LocalDensity.current) { g.spacerWidth.toDp() }))
|
||||||
@ -102,15 +98,13 @@ fun Coverflow(
|
|||||||
if (state.selectedIndex > 0) {
|
if (state.selectedIndex > 0) {
|
||||||
state.scrollToItem(state.selectedIndex - 1)
|
state.scrollToItem(state.selectedIndex - 1)
|
||||||
}
|
}
|
||||||
},
|
}) {}
|
||||||
) {}
|
|
||||||
Box(
|
Box(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.fillMaxHeight()
|
.fillMaxHeight()
|
||||||
.align(Alignment.CenterEnd)
|
.align(Alignment.CenterEnd)
|
||||||
.clickable {
|
.clickable {
|
||||||
state.scrollToItem(state.selectedIndex + 1)
|
state.scrollToItem(state.selectedIndex + 1)
|
||||||
},
|
}) {}
|
||||||
) {}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,8 +25,7 @@ import kotlin.math.min
|
|||||||
@Stable
|
@Stable
|
||||||
internal class Geometry(
|
internal class Geometry(
|
||||||
internal val params: CoverflowParams = CoverflowParams(),
|
internal val params: CoverflowParams = CoverflowParams(),
|
||||||
private val size: IntSize = IntSize.Zero,
|
private val size: IntSize = IntSize.Zero) {
|
||||||
) {
|
|
||||||
@Stable
|
@Stable
|
||||||
private val shortEdge
|
private val shortEdge
|
||||||
get() = min(size.width, size.height)
|
get() = min(size.width, size.height)
|
||||||
|
|||||||
@ -41,8 +41,7 @@ internal fun CoverflowItem(
|
|||||||
geometry: Geometry,
|
geometry: Geometry,
|
||||||
onClickHandler: () -> Unit = {},
|
onClickHandler: () -> Unit = {},
|
||||||
onSelectedHandler: (Boolean) -> Unit,
|
onSelectedHandler: (Boolean) -> Unit,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit) {
|
||||||
) {
|
|
||||||
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
var horizontalPosition by remember { mutableStateOf<Float?>(null) }
|
||||||
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
val distanceToCenter = geometry.distanceToCenter(horizontalPosition ?: 0f)
|
||||||
|
|
||||||
@ -53,8 +52,7 @@ internal fun CoverflowItem(
|
|||||||
.onGloballyPositioned { coordinates ->
|
.onGloballyPositioned { coordinates ->
|
||||||
horizontalPosition = coordinates.positionInParent().x
|
horizontalPosition = coordinates.positionInParent().x
|
||||||
},
|
},
|
||||||
contentAlignment = Alignment.Center,
|
contentAlignment = Alignment.Center) {
|
||||||
) {
|
|
||||||
if (horizontalPosition != null) {
|
if (horizontalPosition != null) {
|
||||||
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
onSelectedHandler(geometry.isSelected(horizontalPosition ?: 0f))
|
||||||
|
|
||||||
@ -65,20 +63,16 @@ internal fun CoverflowItem(
|
|||||||
rotationY = geometry.rotation(distanceToCenter),
|
rotationY = geometry.rotation(distanceToCenter),
|
||||||
translationX = geometry.translationX(distanceToCenter),
|
translationX = geometry.translationX(distanceToCenter),
|
||||||
scaleY = geometry.scale(distanceToCenter),
|
scaleY = geometry.scale(distanceToCenter),
|
||||||
scaleX = geometry.scale(distanceToCenter),
|
scaleX = geometry.scale(distanceToCenter)),
|
||||||
),
|
propagateMinConstraints = true) {
|
||||||
propagateMinConstraints = true,
|
|
||||||
) {
|
|
||||||
val interactionSource = remember { MutableInteractionSource() }
|
val interactionSource = remember { MutableInteractionSource() }
|
||||||
if (geometry.params.mirror) {
|
if (geometry.params.mirror) {
|
||||||
Mirror(
|
Mirror(
|
||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = null,
|
indication = null,
|
||||||
onClick = onClickHandler,
|
onClick = onClickHandler),
|
||||||
),
|
coverSize = geometry.coverSize) {
|
||||||
coverSize = geometry.coverSize,
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
@ -86,9 +80,7 @@ internal fun CoverflowItem(
|
|||||||
modifier = Modifier.clickable(
|
modifier = Modifier.clickable(
|
||||||
interactionSource = interactionSource,
|
interactionSource = interactionSource,
|
||||||
indication = null,
|
indication = null,
|
||||||
onClick = onClickHandler,
|
onClick = onClickHandler)) {
|
||||||
),
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -26,5 +26,4 @@ data class CoverflowParams(
|
|||||||
val angle: Float = 45f,
|
val angle: Float = 45f,
|
||||||
val shift: Float = 0.4f,
|
val shift: Float = 0.4f,
|
||||||
val zoom: Float = 0.8f,
|
val zoom: Float = 0.8f,
|
||||||
val mirror: Boolean = false,
|
val mirror: Boolean = false)
|
||||||
)
|
|
||||||
|
|||||||
@ -26,37 +26,32 @@ interface CoverflowScope {
|
|||||||
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
|
onSelectHandler: (item: T, index: Int) -> Unit = { _: T, _: Int -> },
|
||||||
key: ((index: Int) -> Any)? = null,
|
key: ((index: Int) -> Any)? = null,
|
||||||
contentType: (index: Int) -> Any? = { null },
|
contentType: (index: Int) -> Any? = { null },
|
||||||
itemContent: @Composable ((item: T) -> Unit),
|
itemContent: @Composable ((item: T) -> Unit))
|
||||||
)
|
|
||||||
|
|
||||||
fun items(
|
fun items(
|
||||||
count: Int,
|
count: Int,
|
||||||
onSelectHandler: (index: Int) -> Unit = {},
|
onSelectHandler: (index: Int) -> Unit = {},
|
||||||
key: ((index: Int) -> Any)? = null,
|
key: ((index: Int) -> Any)? = null,
|
||||||
contentType: (index: Int) -> Any? = { null },
|
contentType: (index: Int) -> Any? = { null },
|
||||||
itemContent: @Composable ((index: Int) -> Unit),
|
itemContent: @Composable ((index: Int) -> Unit))
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
internal class CoverflowScopeImpl(
|
internal class CoverflowScopeImpl(
|
||||||
private val geometry: Geometry,
|
private val geometry: Geometry,
|
||||||
private val lazyListScope: LazyListScope,
|
private val lazyListScope: LazyListScope,
|
||||||
private val coverflowState: CoverflowState,
|
private val coverflowState: CoverflowState) : CoverflowScope {
|
||||||
) : CoverflowScope {
|
|
||||||
override fun <T> items(
|
override fun <T> items(
|
||||||
items: List<T>,
|
items: List<T>,
|
||||||
onSelectHandler: (item: T, index: Int) -> Unit,
|
onSelectHandler: (item: T, index: Int) -> Unit,
|
||||||
key: ((index: Int) -> Any)?,
|
key: ((index: Int) -> Any)?,
|
||||||
contentType: (index: Int) -> Any?,
|
contentType: (index: Int) -> Any?,
|
||||||
itemContent: @Composable (item: T) -> Unit,
|
itemContent: @Composable (item: T) -> Unit) = items(
|
||||||
) = items(
|
|
||||||
count = items.size,
|
count = items.size,
|
||||||
onSelectHandler = {
|
onSelectHandler = {
|
||||||
onSelectHandler(items[it], it)
|
onSelectHandler(items[it], it)
|
||||||
},
|
},
|
||||||
key = key,
|
key = key,
|
||||||
contentType = contentType,
|
contentType = contentType) {
|
||||||
) {
|
|
||||||
itemContent(items[it])
|
itemContent(items[it])
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -65,12 +60,10 @@ internal class CoverflowScopeImpl(
|
|||||||
onSelectHandler: (index: Int) -> Unit,
|
onSelectHandler: (index: Int) -> Unit,
|
||||||
key: ((index: Int) -> Any)?,
|
key: ((index: Int) -> Any)?,
|
||||||
contentType: (index: Int) -> Any?,
|
contentType: (index: Int) -> Any?,
|
||||||
itemContent: @Composable (index: Int) -> Unit,
|
itemContent: @Composable (index: Int) -> Unit) = lazyListScope.items(
|
||||||
) = lazyListScope.items(
|
|
||||||
count,
|
count,
|
||||||
key,
|
key,
|
||||||
contentType,
|
contentType) {
|
||||||
) {
|
|
||||||
CoverflowItem(
|
CoverflowItem(
|
||||||
onClickHandler = { coverflowState.scrollToItem(it) },
|
onClickHandler = { coverflowState.scrollToItem(it) },
|
||||||
onSelectedHandler = { isSelected: Boolean ->
|
onSelectedHandler = { isSelected: Boolean ->
|
||||||
@ -78,16 +71,14 @@ internal class CoverflowScopeImpl(
|
|||||||
onSelectHandler(it)
|
onSelectHandler(it)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
geometry = geometry,
|
geometry = geometry) {
|
||||||
) {
|
|
||||||
itemContent(it)
|
itemContent(it)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun selectHandler(
|
private fun selectHandler(
|
||||||
index: Int,
|
index: Int,
|
||||||
isSelected: Boolean,
|
isSelected: Boolean): Boolean {
|
||||||
): Boolean {
|
|
||||||
if (!isSelected && index == coverflowState.selectedIndex) {
|
if (!isSelected && index == coverflowState.selectedIndex) {
|
||||||
coverflowState.selectedIndex = -1
|
coverflowState.selectedIndex = -1
|
||||||
} else if (isSelected && index != coverflowState.selectedIndex) {
|
} else if (isSelected && index != coverflowState.selectedIndex) {
|
||||||
|
|||||||
@ -28,19 +28,16 @@ import kotlinx.coroutines.launch
|
|||||||
@Composable
|
@Composable
|
||||||
fun rememberCoverflowState(
|
fun rememberCoverflowState(
|
||||||
initialIndex: Int = 0,
|
initialIndex: Int = 0,
|
||||||
onSelectHandler: (Int) -> Unit = {},
|
onSelectHandler: (Int) -> Unit = {}): CoverflowState {
|
||||||
): CoverflowState {
|
|
||||||
val lazyListState: LazyListState = rememberLazyListState(
|
val lazyListState: LazyListState = rememberLazyListState(
|
||||||
initialFirstVisibleItemIndex = initialIndex + 1,
|
initialFirstVisibleItemIndex = initialIndex + 1)
|
||||||
)
|
|
||||||
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
val coroutineScope: CoroutineScope = rememberCoroutineScope()
|
||||||
return remember {
|
return remember {
|
||||||
CoverflowState(
|
CoverflowState(
|
||||||
lazyListState,
|
lazyListState,
|
||||||
coroutineScope,
|
coroutineScope,
|
||||||
onSelectHandler,
|
onSelectHandler,
|
||||||
initialIndex,
|
initialIndex)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -48,8 +45,7 @@ class CoverflowState internal constructor(
|
|||||||
internal val lazyListState: LazyListState,
|
internal val lazyListState: LazyListState,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
private val onSelectHandler: (Int) -> Unit = {},
|
private val onSelectHandler: (Int) -> Unit = {},
|
||||||
initialIndex: Int = 0,
|
initialIndex: Int = 0) {
|
||||||
) {
|
|
||||||
internal var geometry: Geometry? = null
|
internal var geometry: Geometry? = null
|
||||||
internal var initialScrollDone: Boolean = false
|
internal var initialScrollDone: Boolean = false
|
||||||
|
|
||||||
@ -65,8 +61,7 @@ class CoverflowState internal constructor(
|
|||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
lazyListState.animateScrollToItem(
|
lazyListState.animateScrollToItem(
|
||||||
index + 1,
|
index + 1,
|
||||||
-geometryCopy.spacerWidth,
|
-geometryCopy.spacerWidth)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -48,23 +48,20 @@ import androidx.compose.ui.unit.times
|
|||||||
internal fun Mirror(
|
internal fun Mirror(
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
coverSize: Float,
|
coverSize: Float,
|
||||||
content: @Composable () -> Unit,
|
content: @Composable () -> Unit) {
|
||||||
) {
|
|
||||||
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
val coverSizeDp = with(LocalDensity.current) { coverSize.toDp() }
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.padding(top = coverSizeDp)
|
.padding(top = coverSizeDp)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.requiredHeight(2 * coverSizeDp),
|
.requiredHeight(2 * coverSizeDp)) {
|
||||||
) {
|
|
||||||
Box(
|
Box(
|
||||||
propagateMinConstraints = true,
|
propagateMinConstraints = true,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.weight(1f)
|
.weight(1f)
|
||||||
.fillMaxWidth()
|
.fillMaxWidth()
|
||||||
.height(coverSizeDp),
|
.height(coverSizeDp)) {
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
Box(
|
Box(
|
||||||
@ -75,34 +72,27 @@ internal fun Mirror(
|
|||||||
.height(coverSizeDp)
|
.height(coverSizeDp)
|
||||||
.graphicsLayer(
|
.graphicsLayer(
|
||||||
compositingStrategy = CompositingStrategy.Offscreen,
|
compositingStrategy = CompositingStrategy.Offscreen,
|
||||||
rotationX = 180f,
|
rotationX = 180f)
|
||||||
)
|
|
||||||
.clip(HalfSizeShape)
|
.clip(HalfSizeShape)
|
||||||
.drawWithContent {
|
.drawWithContent {
|
||||||
val colors = listOf(
|
val colors = listOf(
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Transparent,
|
Color.Transparent,
|
||||||
Color.Black,
|
Color.Black)
|
||||||
)
|
|
||||||
drawContent()
|
drawContent()
|
||||||
drawRect(
|
drawRect(
|
||||||
topLeft = Offset(
|
topLeft = Offset(
|
||||||
0f,
|
0f,
|
||||||
this.size.height / 2,
|
this.size.height / 2),
|
||||||
),
|
|
||||||
size = Size(
|
size = Size(
|
||||||
this.size.width,
|
this.size.width,
|
||||||
this.size.height / 2,
|
this.size.height / 2),
|
||||||
),
|
|
||||||
brush = Brush.verticalGradient(colors),
|
brush = Brush.verticalGradient(colors),
|
||||||
blendMode = BlendMode.DstIn,
|
blendMode = BlendMode.DstIn)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
.blur(
|
.blur(
|
||||||
radiusX = 1.dp,
|
radiusX = 1.dp,
|
||||||
radiusY = 3.dp,
|
radiusY = 3.dp)) {
|
||||||
),
|
|
||||||
) {
|
|
||||||
content()
|
content()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -112,17 +102,12 @@ private object HalfSizeShape : Shape {
|
|||||||
override fun createOutline(
|
override fun createOutline(
|
||||||
size: Size,
|
size: Size,
|
||||||
layoutDirection: LayoutDirection,
|
layoutDirection: LayoutDirection,
|
||||||
density: Density,
|
density: Density): Outline = Outline.Rectangle(
|
||||||
): Outline = Outline.Rectangle(
|
|
||||||
Rect(
|
Rect(
|
||||||
Offset(
|
Offset(
|
||||||
0f,
|
0f,
|
||||||
size.height / 2,
|
size.height / 2),
|
||||||
),
|
|
||||||
Size(
|
Size(
|
||||||
size.width,
|
size.width,
|
||||||
size.height,
|
size.height)))
|
||||||
),
|
|
||||||
),
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -17,10 +17,11 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.ui.misc
|
package dev.krtirtho.spotube.core.ui.misc
|
||||||
|
|
||||||
|
import androidx.compose.foundation.background
|
||||||
import androidx.compose.foundation.layout.Box
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.text.TextAutoSize
|
import androidx.compose.foundation.text.TextAutoSize
|
||||||
import androidx.compose.material3.LocalTextStyle
|
import com.composeunstyled.LocalTextStyle
|
||||||
import androidx.compose.material3.Text
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.CompositionLocalProvider
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.compose.runtime.compositionLocalOf
|
import androidx.compose.runtime.compositionLocalOf
|
||||||
@ -38,7 +39,6 @@ import androidx.compose.ui.text.style.TextAlign
|
|||||||
import androidx.compose.ui.text.style.TextDecoration
|
import androidx.compose.ui.text.style.TextDecoration
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.TextUnit
|
import androidx.compose.ui.unit.TextUnit
|
||||||
import com.eygraber.compose.placeholder.material3.placeholder
|
|
||||||
import com.valentinilk.shimmer.ShimmerBounds
|
import com.valentinilk.shimmer.ShimmerBounds
|
||||||
import com.valentinilk.shimmer.rememberShimmer
|
import com.valentinilk.shimmer.rememberShimmer
|
||||||
import com.valentinilk.shimmer.shimmer
|
import com.valentinilk.shimmer.shimmer
|
||||||
@ -84,11 +84,7 @@ fun Modifier.shimmerApply(
|
|||||||
): Modifier = composed {
|
): Modifier = composed {
|
||||||
val isTreeLoading = LocalSkeletonLoading.current
|
val isTreeLoading = LocalSkeletonLoading.current
|
||||||
|
|
||||||
this.placeholder(
|
if (isTreeLoading) this.background(color) else this
|
||||||
visible = isTreeLoading,
|
|
||||||
color = color,
|
|
||||||
shape = null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@ -110,23 +106,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 = color,
|
color = if (isLoadingSkeleton) Color.Transparent else color,
|
||||||
modifier = Modifier.shimmerApply().then(modifier),
|
modifier = Modifier.shimmerApply().then(modifier),
|
||||||
autoSize = autoSize,
|
autoSize = autoSize,
|
||||||
fontSize = fontSize,
|
fontSize = fontSize,
|
||||||
fontStyle = fontStyle,
|
|
||||||
fontWeight = fontWeight,
|
fontWeight = fontWeight,
|
||||||
fontFamily = fontFamily,
|
fontFamily = fontFamily,
|
||||||
letterSpacing = letterSpacing,
|
letterSpacing = letterSpacing,
|
||||||
textDecoration = textDecoration,
|
textDecoration = textDecoration,
|
||||||
textAlign = textAlign,
|
textAlign = textAlign ?: TextAlign.Start,
|
||||||
lineHeight = lineHeight,
|
lineHeight = lineHeight,
|
||||||
overflow = overflow,
|
overflow = overflow,
|
||||||
softWrap = softWrap,
|
|
||||||
maxLines = maxLines,
|
maxLines = maxLines,
|
||||||
minLines = minLines,
|
minLines = minLines,
|
||||||
onTextLayout = onTextLayout,
|
onTextLayout = onTextLayout,
|
||||||
|
|||||||
@ -18,39 +18,202 @@
|
|||||||
package dev.krtirtho.spotube.core.ui.theming
|
package dev.krtirtho.spotube.core.ui.theming
|
||||||
|
|
||||||
import androidx.compose.foundation.isSystemInDarkTheme
|
import androidx.compose.foundation.isSystemInDarkTheme
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.Surface
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import com.materialkolor.rememberDynamicColorScheme
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
|
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
|
||||||
|
|
||||||
val accent = if (isDarkTheme) {
|
MillenniumTheme(dark = isDarkTheme, config = MillenniumThemeConfig(palette = palette)) {
|
||||||
settings.accentColor.toDarkColor()
|
val colors = appColorsFrom(MillenniumTheme.colors)
|
||||||
} else {
|
MillenniumTooltipHost {
|
||||||
settings.accentColor.toLightColor()
|
CompositionLocalProvider(
|
||||||
}
|
LocalAppColors provides colors,
|
||||||
|
LocalAppTypography provides AppDefaultTypography,
|
||||||
val colorScheme = rememberDynamicColorScheme(
|
LocalAppShapes provides AppDefaultShapes,
|
||||||
seedColor = accent,
|
content = content
|
||||||
isDark = isDarkTheme,
|
)
|
||||||
)
|
}
|
||||||
|
|
||||||
MaterialTheme(
|
|
||||||
colorScheme = colorScheme,
|
|
||||||
) {
|
|
||||||
Surface(content = content)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Whether the active Millennium appearance is light. */
|
||||||
|
@Composable
|
||||||
|
fun isLightAppearance(): Boolean = MillenniumTheme.colors.background.luminance() > 0.5f
|
||||||
|
|||||||
@ -17,26 +17,30 @@
|
|||||||
|
|
||||||
package dev.krtirtho.spotube.core.webview
|
package dev.krtirtho.spotube.core.webview
|
||||||
|
|
||||||
|
|
||||||
|
import org.spotube.millennium.composables.MillenniumIcon
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButton
|
||||||
|
import org.spotube.millennium.composables.MillenniumIconButtonStyle
|
||||||
import androidx.compose.foundation.BorderStroke
|
import androidx.compose.foundation.BorderStroke
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.border
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
import androidx.compose.foundation.layout.Row
|
import androidx.compose.foundation.layout.Row
|
||||||
import androidx.compose.foundation.layout.WindowInsets
|
|
||||||
import androidx.compose.foundation.layout.fillMaxSize
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.height
|
import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.foundation.layout.statusBars
|
|
||||||
import androidx.compose.foundation.layout.statusBarsPadding
|
import androidx.compose.foundation.layout.statusBarsPadding
|
||||||
import androidx.compose.foundation.layout.wrapContentHeight
|
import androidx.compose.foundation.layout.wrapContentHeight
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.foundation.text.BasicTextField
|
import androidx.compose.foundation.text.BasicTextField
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.ui.draw.clip
|
||||||
import androidx.compose.material3.IconButton
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
import dev.krtirtho.spotube.core.ui.theming.AppTheme
|
||||||
import androidx.compose.material3.Scaffold
|
|
||||||
import androidx.compose.material3.Surface
|
import com.composeunstyled.Text
|
||||||
import androidx.compose.material3.Text
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@ -148,10 +152,8 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Column(modifier = Modifier.fillMaxSize()) {
|
||||||
contentWindowInsets = WindowInsets.statusBars,
|
ApplicationMainBar(
|
||||||
topBar = {
|
|
||||||
ApplicationMainBar(
|
|
||||||
title = {
|
title = {
|
||||||
Row(
|
Row(
|
||||||
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
modifier = Modifier.fillMaxWidth().statusBarsPadding().height(56.dp),
|
||||||
@ -163,40 +165,50 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
horizontalArrangement = Arrangement.Start,
|
horizontalArrangement = Arrangement.Start,
|
||||||
modifier = Modifier.height(56.dp)
|
modifier = Modifier.height(56.dp)
|
||||||
) {
|
) {
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.navigateBack()
|
navigator.navigateBack()
|
||||||
}, enabled = navigator.canGoBack
|
},
|
||||||
|
enabled = navigator.canGoBack,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
FeatherIcons.ChevronLeft,
|
FeatherIcons.ChevronLeft,
|
||||||
contentDescription = "Go back to browser history"
|
contentDescription = "Go back to browser history"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
IconButton(
|
MillenniumIconButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
navigator.navigateForward()
|
navigator.navigateForward()
|
||||||
}, enabled = navigator.canGoForward
|
},
|
||||||
|
enabled = navigator.canGoForward,
|
||||||
|
style = MillenniumIconButtonStyle.Quiet
|
||||||
) {
|
) {
|
||||||
Icon(
|
MillenniumIcon(
|
||||||
FeatherIcons.ChevronRight,
|
FeatherIcons.ChevronRight,
|
||||||
contentDescription = "Go forward to browser history"
|
contentDescription = "Go forward to browser history"
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Surface(
|
Box(
|
||||||
modifier = Modifier.weight(1f).height(36.dp).padding(horizontal = 4.dp),
|
modifier = Modifier
|
||||||
shape = RoundedCornerShape(18.dp),
|
.weight(1f)
|
||||||
color = MaterialTheme.colorScheme.surfaceVariant.copy(alpha = 0.5f),
|
.height(36.dp)
|
||||||
border = BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f))
|
.padding(horizontal = 4.dp)
|
||||||
|
.clip(RoundedCornerShape(18.dp))
|
||||||
|
.background(AppTheme.colors.surfaceVariant.copy(alpha = 0.5f))
|
||||||
|
.border(
|
||||||
|
BorderStroke(1.dp, Color.Gray.copy(alpha = 0.5f)),
|
||||||
|
RoundedCornerShape(18.dp)),
|
||||||
|
contentAlignment = Alignment.CenterStart
|
||||||
) {
|
) {
|
||||||
BasicTextField(
|
BasicTextField(
|
||||||
value = state.lastLoadedUrl ?: "",
|
value = state.lastLoadedUrl ?: "",
|
||||||
onValueChange = {}, // Read-only
|
onValueChange = {}, // Read-only
|
||||||
readOnly = true,
|
readOnly = true,
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
textStyle = MaterialTheme.typography.bodyMedium.copy(
|
textStyle = AppTheme.typography.bodyMedium.copy(
|
||||||
color = MaterialTheme.colorScheme.onSurface,
|
color = AppTheme.colors.onSurface,
|
||||||
textAlign = TextAlign.Start
|
textAlign = TextAlign.Start
|
||||||
),
|
),
|
||||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp)
|
||||||
@ -205,17 +217,14 @@ fun PlatformWebViewScreen(webViewController: WebViewController) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
})
|
|
||||||
{ innerPadding ->
|
|
||||||
WebView(
|
WebView(
|
||||||
state = state,
|
state = state,
|
||||||
modifier = Modifier.padding(innerPadding).fillMaxSize(),
|
modifier = Modifier.weight(1f).fillMaxWidth(),
|
||||||
navigator = navigator,
|
navigator = navigator,
|
||||||
webViewJsBridge = webViewBridge,
|
webViewJsBridge = webViewBridge,
|
||||||
onCreated = { webView ->
|
onCreated = { webView ->
|
||||||
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
navigator.evaluateJavaScript(bridgeBootstrapScript)
|
||||||
},
|
})
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -38,8 +38,7 @@ import org.koin.core.component.KoinComponent
|
|||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
class BuiltInPluginService(
|
class BuiltInPluginService(
|
||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||||
) : PluginService, KoinComponent {
|
|
||||||
|
|
||||||
override val pluginId: String = pluginInfo.id
|
override val pluginId: String = pluginInfo.id
|
||||||
private val loggedInStateFlow = MutableStateFlow(false)
|
private val loggedInStateFlow = MutableStateFlow(false)
|
||||||
@ -58,7 +57,6 @@ class BuiltInPluginService(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun start() {
|
override suspend fun start() {
|
||||||
when (pluginInfo) {
|
when (pluginInfo) {
|
||||||
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
NEWPIPE_YOUTUBE_BUILT_IN_PLUGIN -> {
|
||||||
|
|||||||
@ -86,7 +86,6 @@ import org.koin.core.component.KoinComponent
|
|||||||
import org.koin.core.component.inject
|
import org.koin.core.component.inject
|
||||||
import kotlin.reflect.KClass
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Manages the lifecycle of a Zipline plugin.
|
* Manages the lifecycle of a Zipline plugin.
|
||||||
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
* It runs everything on its own dispatcher (different thread) as per Zipline's requirements.
|
||||||
@ -102,8 +101,7 @@ import kotlin.reflect.KClass
|
|||||||
open class ZiplinePluginService(
|
open class ZiplinePluginService(
|
||||||
val applicationName: String,
|
val applicationName: String,
|
||||||
private val manifestUrl: String,
|
private val manifestUrl: String,
|
||||||
private val pluginInfo: PluginEntry,
|
private val pluginInfo: PluginEntry) : PluginService, KoinComponent {
|
||||||
) : PluginService, KoinComponent {
|
|
||||||
|
|
||||||
override val pluginId: String = pluginInfo.id
|
override val pluginId: String = pluginInfo.id
|
||||||
|
|
||||||
@ -280,8 +278,7 @@ open class ZiplinePluginService(
|
|||||||
val result = ziplineLoader.loadOnce(
|
val result = ziplineLoader.loadOnce(
|
||||||
applicationName = applicationName,
|
applicationName = applicationName,
|
||||||
manifestUrl = manifestUrl,
|
manifestUrl = manifestUrl,
|
||||||
freshnessChecker = DefaultFreshnessCheckerNotFresh,
|
freshnessChecker = DefaultFreshnessCheckerNotFresh)
|
||||||
)
|
|
||||||
when (result) {
|
when (result) {
|
||||||
is LoadResult.Success -> {
|
is LoadResult.Success -> {
|
||||||
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
logger.d { "[$applicationName] start(): loadOnce succeeded, consuming services" }
|
||||||
|
|||||||
@ -185,7 +185,6 @@ fun AESEncodingFormats.format(): AES.Key.Format {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
||||||
private val provider = CryptographyProvider.Default
|
private val provider = CryptographyProvider.Default
|
||||||
|
|
||||||
@ -193,7 +192,6 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
return CryptographyRandom.nextBytes(size)
|
return CryptographyRandom.nextBytes(size)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
override suspend fun hash(algorithm: HashAlgorithms, data: ByteArray): ByteArray {
|
||||||
val hasher = provider.get(algorithm.algorithm()).hasher()
|
val hasher = provider.get(algorithm.algorithm()).hasher()
|
||||||
return hasher.hash(data)
|
return hasher.hash(data)
|
||||||
@ -301,8 +299,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
val keyPair = provider.get(RSA.PSS).keyPairGenerator(
|
||||||
algorithm.keySizeBits.bits,
|
algorithm.keySizeBits.bits,
|
||||||
algorithm.hashAlgorithm.algorithm(),
|
algorithm.hashAlgorithm.algorithm(),
|
||||||
algorithm.publicExponent.toBigInt(),
|
algorithm.publicExponent.toBigInt()).generateKey()
|
||||||
).generateKey()
|
|
||||||
|
|
||||||
Pair(
|
Pair(
|
||||||
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
keyPair.publicKey.encodeToByteArray(algorithm.format.publicKeyFormat()),
|
||||||
@ -366,8 +363,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.ECDSA ->
|
is SignAlgorithms.ECDSA ->
|
||||||
provider.get(ECDSA).privateKeyDecoder(
|
provider.get(ECDSA).privateKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.privateKeyFormat(),
|
algorithm.format.privateKeyFormat(),
|
||||||
privateKey
|
privateKey
|
||||||
).signatureGenerator(
|
).signatureGenerator(
|
||||||
@ -377,8 +373,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.EdDSA ->
|
is SignAlgorithms.EdDSA ->
|
||||||
provider.get(EdDSA).privateKeyDecoder(
|
provider.get(EdDSA).privateKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.privateKeyFormat(),
|
algorithm.format.privateKeyFormat(),
|
||||||
privateKey
|
privateKey
|
||||||
).signatureGenerator().generateSignature(data)
|
).signatureGenerator().generateSignature(data)
|
||||||
@ -422,8 +417,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.ECDSA ->
|
is SignAlgorithms.ECDSA ->
|
||||||
provider.get(ECDSA).publicKeyDecoder(
|
provider.get(ECDSA).publicKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.publicKeyFormat(),
|
algorithm.format.publicKeyFormat(),
|
||||||
publicKey
|
publicKey
|
||||||
).signatureVerifier(
|
).signatureVerifier(
|
||||||
@ -436,8 +430,7 @@ class RealCryptoAPI(private val scope: CoroutineContext) : CryptoAPI {
|
|||||||
|
|
||||||
is SignAlgorithms.EdDSA ->
|
is SignAlgorithms.EdDSA ->
|
||||||
provider.get(EdDSA).publicKeyDecoder(
|
provider.get(EdDSA).publicKeyDecoder(
|
||||||
algorithm.curve.curve(),
|
algorithm.curve.curve()).decodeFromByteArray(
|
||||||
).decodeFromByteArray(
|
|
||||||
algorithm.format.publicKeyFormat(),
|
algorithm.format.publicKeyFormat(),
|
||||||
publicKey
|
publicKey
|
||||||
).signatureVerifier().verifySignature(
|
).signatureVerifier().verifySignature(
|
||||||
|
|||||||
@ -28,7 +28,6 @@ import io.ktor.client.statement.bodyAsText
|
|||||||
import io.ktor.http.takeFrom
|
import io.ktor.http.takeFrom
|
||||||
import org.koin.core.component.KoinComponent
|
import org.koin.core.component.KoinComponent
|
||||||
|
|
||||||
|
|
||||||
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
class RealHttpClientAPI : HttpClientAPI, KoinComponent {
|
||||||
val logger by injectLogger<RealHttpClientAPI>()
|
val logger by injectLogger<RealHttpClientAPI>()
|
||||||
val httpClient = HttpClient()
|
val httpClient = HttpClient()
|
||||||
|
|||||||
@ -29,8 +29,7 @@ import kotlinx.coroutines.withContext
|
|||||||
class RealWebViewAPI(
|
class RealWebViewAPI(
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private val webViewController: WebViewController,
|
private val webViewController: WebViewController,
|
||||||
private val pluginId: String,
|
private val pluginId: String) : WebViewAPI {
|
||||||
) : WebViewAPI {
|
|
||||||
override fun navigateTo(url: String) {
|
override fun navigateTo(url: String) {
|
||||||
scope.launch(Dispatchers.Main) {
|
scope.launch(Dispatchers.Main) {
|
||||||
webViewController.navigateTo(url, pluginId)
|
webViewController.navigateTo(url, pluginId)
|
||||||
|
|||||||
@ -45,8 +45,7 @@ data class LRCLibResponse(
|
|||||||
val artistName: String,
|
val artistName: String,
|
||||||
val albumName: String,
|
val albumName: String,
|
||||||
val plainLyrics: String?,
|
val plainLyrics: String?,
|
||||||
val syncedLyrics: String?,
|
val syncedLyrics: String?)
|
||||||
)
|
|
||||||
|
|
||||||
class RealLRCLibLyricsAPI : LyricsAPI {
|
class RealLRCLibLyricsAPI : LyricsAPI {
|
||||||
private val httpClient = HttpClient {
|
private val httpClient = HttpClient {
|
||||||
@ -95,7 +94,6 @@ class RealLRCLibLyricsAPI : LyricsAPI {
|
|||||||
val text = line.substring(match.range.last + 1).trim()
|
val text = line.substring(match.range.last + 1).trim()
|
||||||
LyricsLine(time = timeMs, text = text)
|
LyricsLine(time = timeMs, text = text)
|
||||||
},
|
},
|
||||||
plainLyrics = response.plainLyrics,
|
plainLyrics = response.plainLyrics)
|
||||||
)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -124,16 +124,14 @@ data class MusicGatewayResponse(
|
|||||||
val externalIds: ExternalIds,
|
val externalIds: ExternalIds,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String,
|
||||||
val popularity: Int,
|
val popularity: Int) {
|
||||||
) {
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Album(
|
data class Album(
|
||||||
@SerialName("album_type")
|
@SerialName("album_type")
|
||||||
val albumType: String,
|
val albumType: String,
|
||||||
val artists: List<Artist>,
|
val artists: List<Artist>,
|
||||||
val id: String,
|
val id: String,
|
||||||
val name: String,
|
val name: String)
|
||||||
)
|
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class Artist(
|
data class Artist(
|
||||||
|
|||||||
@ -57,7 +57,6 @@ class ISRCProviders {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
private var ifpiSessionToken: String? = null
|
private var ifpiSessionToken: String? = null
|
||||||
private val ifpiDefaultHeaders = mapOf(
|
private val ifpiDefaultHeaders = mapOf(
|
||||||
"Content-Type" to "application/json",
|
"Content-Type" to "application/json",
|
||||||
@ -76,7 +75,6 @@ class ISRCProviders {
|
|||||||
"User-Agent" to UserAgents.random()
|
"User-Agent" to UserAgents.random()
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
|
private suspend fun ifpiSessionToken(): String? = withContext(Dispatchers.IO) {
|
||||||
if (ifpiSessionToken != null) return@withContext ifpiSessionToken
|
if (ifpiSessionToken != null) return@withContext ifpiSessionToken
|
||||||
|
|
||||||
|
|||||||